Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-peewee for openSUSE:Factory checked in at 2026-05-26 16:34:09 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-peewee (Old) and /work/SRC/openSUSE:Factory/.python-peewee.new.2084 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-peewee" Tue May 26 16:34:09 2026 rev:41 rq:1355100 version:4.0.6 Changes: -------- --- /work/SRC/openSUSE:Factory/python-peewee/python-peewee.changes 2026-04-25 23:28:11.929531308 +0200 +++ /work/SRC/openSUSE:Factory/.python-peewee.new.2084/python-peewee.changes 2026-05-26 16:34:16.396595952 +0200 @@ -1,0 +2,20 @@ +Mon May 25 20:00:33 UTC 2026 - Dirk Müller <[email protected]> + +- update to 4.0.6: + * Add new methods to the postgres `BinaryJSONField`: helpers + for in-place modifications (`set`, `replace`, `insert`, `append`, + `update`). + * Also add json-path helpers to the postgres `BinaryJSONField` + (`path_exists`, `path_match`, `path_query`, `path_query_array`, + `path_query_first`). + * Quote path elements in SQLite's JSON field. + * Better and faster parsing of formatted date/times. Use the + stdlib `fromisoformat` as a first attempt since it's faster + and more robust. + * Ensure `db.connection_context()` can be nested cleanly, + #3046. + * Fix potential deadlock in `pool.close_all` and + `pool.manual_close`, #3047. + * Restore whitespace stripping in `FixedCharField`, #3048. + +------------------------------------------------------------------- Old: ---- peewee-4.0.5.tar.gz New: ---- peewee-4.0.6.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-peewee.spec ++++++ --- /var/tmp/diff_new_pack.tNyjy3/_old 2026-05-26 16:34:17.100625079 +0200 +++ /var/tmp/diff_new_pack.tNyjy3/_new 2026-05-26 16:34:17.104625244 +0200 @@ -23,7 +23,7 @@ %endif %{?sle15_python_module_pythons} Name: python-peewee -Version: 4.0.5 +Version: 4.0.6 Release: 0 Summary: An expressive ORM that supports multiple SQL backends License: MIT ++++++ peewee-4.0.5.tar.gz -> peewee-4.0.6.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.5/CHANGELOG.md new/peewee-4.0.6/CHANGELOG.md --- old/peewee-4.0.5/CHANGELOG.md 2026-04-23 23:15:53.000000000 +0200 +++ new/peewee-4.0.6/CHANGELOG.md 2026-05-20 15:13:03.000000000 +0200 @@ -7,7 +7,22 @@ ## master -[View commits](https://github.com/coleifer/peewee/compare/4.0.5...master) +[View commits](https://github.com/coleifer/peewee/compare/4.0.6...master) + +## 4.0.6 + +* Add new methods to the postgres `BinaryJSONField`: helpers for in-place + modifications (`set`, `replace`, `insert`, `append`, `update`). +* Also add json-path helpers to the postgres `BinaryJSONField` (`path_exists`, + `path_match`, `path_query`, `path_query_array`, `path_query_first`). +* Quote path elements in SQLite's JSON field. +* Better and faster parsing of formatted date/times. Use the stdlib + `fromisoformat` as a first attempt since it's faster and more robust. +* Ensure `db.connection_context()` can be nested cleanly, #3046. +* Fix potential deadlock in `pool.close_all` and `pool.manual_close`, #3047. +* Restore whitespace stripping in `FixedCharField`, #3048. + +[View commits](https://github.com/coleifer/peewee/compare/4.0.5...4.0.6) ## 4.0.5 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.5/docs/peewee/api.rst new/peewee-4.0.6/docs/peewee/api.rst --- old/peewee-4.0.5/docs/peewee/api.rst 2026-04-23 23:15:53.000000000 +0200 +++ new/peewee-4.0.6/docs/peewee/api.rst 2026-05-20 15:13:03.000000000 +0200 @@ -3192,8 +3192,16 @@ '%Y-%m-%d %H:%M:%S.%f' # year-month-day hour-minute-second.microsecond '%Y-%m-%d %H:%M:%S' # year-month-day hour-minute-second + '%Y-%m-%d %H:%M:%S.%f%z' # ...with timezone offset + '%Y-%m-%d %H:%M:%S%z' # ...with timezone offset '%Y-%m-%d' # year-month-day + In addition, any string accepted by + :py:meth:`datetime.datetime.fromisoformat` is parsed automatically, + including the ``T`` separator and a trailing ``Z`` (UTC). Custom + ``formats`` are still consulted as a fallback for non-ISO inputs (e.g. + ``'01/02/2003 01:37 PM'``). + SQLite does not have a native datetime data-type, so datetimes are stored as strings. This is handled transparently by Peewee, but if you have pre-existing data you should ensure it is stored as @@ -3270,6 +3278,9 @@ '%Y-%m-%d %H:%M:%S' # year-month-day hour-minute-second '%Y-%m-%d %H:%M:%S.%f' # year-month-day hour-minute-second.microsecond + In addition, any string accepted by + :py:meth:`datetime.datetime.fromisoformat` is parsed automatically. + .. note:: If the incoming value does not match a format, it is returned as-is. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.5/docs/peewee/postgres.rst new/peewee-4.0.6/docs/peewee/postgres.rst --- old/peewee-4.0.5/docs/peewee/postgres.rst 2026-04-23 23:15:53.000000000 +0200 +++ new/peewee-4.0.6/docs/peewee/postgres.rst 2026-05-20 15:13:03.000000000 +0200 @@ -329,6 +329,141 @@ # Equivalent to above. Event.select().where(Event.data['result'].extract('success') == True) + .. method:: set(value) + + Set the value at the path described by this lookup. Wraps Postgres' + ``jsonb_set(..., create_missing=true)``: if the path does not exist it + is created. ``value`` may be a scalar, dict, or list. + + .. code-block:: python + + # Replace an existing key (or create it if missing). + Event.update(data=Event.data['result'].set({'success': True})) \\ + .execute() + + # Set a deeply nested key. + Event.update(data=Event.data['metadata']['author'].set('alice')) \\ + .execute() + + .. method:: replace(value) + + Like :meth:`~BinaryJSONField.set`, but ``jsonb_set`` is called with + ``create_missing=false``: if the path does not exist, the document is + returned unchanged. + + .. code-block:: python + + # Updates only rows where data['result'] already exists. + Event.update(data=Event.data['result'].replace({'ok': True})) \\ + .execute() + + .. method:: insert(value) + + Set the value at this path **only if the path does not already exist**. + Internally rendered as a ``CASE`` expression around ``jsonb_set`` since + Postgres has no single-call for this. + + .. code-block:: python + + # Add a "created_at" timestamp only on rows that don't already have one. + Event.update(data=Event.data['created_at'].insert(timestamp)) \\ + .execute() + + .. method:: append(value) + + Append ``value`` to the array at this path. Wraps ``jsonb_insert`` with + ``insert_after=true`` at index ``-1``. The value may be any + JSON-serializable object. + + .. code-block:: python + + # Append a tag to an existing array. + Event.update(data=Event.data['tags'].append('new-tag')).execute() + + # Field-level form: append to a root array. + Event.update(data=Event.data.append('new-tag')).execute() + + .. method:: update(value) + + Shallow-merge ``value`` into the object at this path. Equivalent to + ``jsonb_set(field, path, current || value, true)``. Top-level keys in + ``value`` overwrite existing keys, Postgres does **not** provide RFC-7396 + deep merge. + + .. code-block:: python + + # Merge new keys into a nested object, existing 'success' is + # overwritten if present in `value`. + Event.update(data=Event.data['result'].update({'ok': True})) \\ + .execute() + + # Field-level form: merge into the root object. + Event.update(data=Event.data.update({'env': 'prod'})).execute() + + .. method:: path_exists(jsonpath) + + Return whether the SQL/JSON path expression matches anything in the + JSON document. Renders as the Postgres ``@?`` operator. The argument + is cast to ``jsonpath`` so it can be passed as a normal text parameter. + Requires PostgreSQL 12 or newer. + + Available on the field directly and on path lookups, so you can target + a sub-document instead of the whole field: + + .. code-block:: python + + # Rows where data has any "tags" entry. + Event.select().where(Event.data.path_exists('$.tags')) + + # Rows where any tag starts with "urgent". + Event.select().where( + Event.data.path_exists('$.tags[*] ? (@ starts with "urgent")')) + + # Apply the path expression to a sub-document. + Event.select().where( + Event.data['users'].path_exists('$[*] ? (@.role == "admin")')) + + .. method:: path_match(jsonpath) + + Like :meth:`~BinaryJSONField.path_exists`, but uses the ``@@`` + operator: the path expression is treated as a predicate and the + operator returns true when it evaluates to ``true``. Useful for + filtering on values inside the document. + + .. code-block:: python + + # Rows where data.score > 100. + Event.select().where(Event.data.path_match('$.score > 100')) + + .. method:: path_query(jsonpath) + + Wrap ``jsonb_path_query``, a set-returning function that yields each + match of ``jsonpath`` as a separate row. When used inside a + ``SELECT``, Postgres expands the SRF inline. + + .. code-block:: python + + # One row per tag in the data. + q = Event.select(Event.data.path_query('$.tags[*]').alias('tag')) + for row in q.tuples(): + print(row[0]) + + .. method:: path_query_array(jsonpath) + + Wrap ``jsonb_path_query_array``, which collects all matches into a + single JSON array. + + .. code-block:: python + + q = Event.select( + Event.data.path_query_array('$.items[*] ? (@.qty > 0).name') + .alias('names')) + + .. method:: path_query_first(jsonpath) + + Wrap ``jsonb_path_query_first``, which returns the first match of + ``jsonpath`` (or ``NULL`` if there are no matches). + .. class:: JSONField(dumps=None, *args, **kwargs) @@ -363,6 +498,27 @@ See :meth:`BinaryJSONField.extract` for example usage. + .. method:: append(value) + + Append ``value`` to the array at the document root. The lookup form + ``field['arr'].append(value)`` works as well for nested arrays. + + See :meth:`BinaryJSONField.append` for example usage. + + .. method:: update(value) + + Shallow-merge ``value`` into the root object. + + See :meth:`BinaryJSONField.update` for example usage and a note on + shallow-vs-deep merge semantics. + + The lookup-level mutation builders (``set``, ``replace``, ``insert``, + ``append``, ``update``) are also available via ``__getitem__``, e.g. + ``MyModel.data['key'].set(value)``. They emit ``jsonb_set`` / + ``jsonb_insert`` SQL; for ``json`` columns Postgres casts implicitly on + ``UPDATE``. See the corresponding methods on :class:`BinaryJSONField` for + details. + .. _postgres-hstore: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.5/peewee.py new/peewee-4.0.6/peewee.py --- old/peewee-4.0.5/peewee.py 2026-04-23 23:15:53.000000000 +0200 +++ new/peewee-4.0.6/peewee.py 2026-05-20 15:13:03.000000000 +0200 @@ -69,7 +69,7 @@ mysql = None -__version__ = '4.0.5' +__version__ = '4.0.6' __all__ = [ 'AnyField', 'AsIs', @@ -3174,11 +3174,16 @@ class ConnectionContext(object): __slots__ = ('db',) - def __init__(self, db): self.db = db + def __init__(self, db): + self.db = db def __enter__(self): if self.db.is_closed(): self.db.connect() - def __exit__(self, exc_type, exc_val, exc_tb): self.db.close() + self.db._state.ctx.append(self) + def __exit__(self, exc_type, exc_val, exc_tb): + self.db._state.ctx.pop() + if not self.db._state.ctx: + self.db.close() def __call__(self, fn): @wraps(fn) def inner(*args, **kwargs): @@ -5208,7 +5213,7 @@ def adapt(self, value): value = super(FixedCharField, self).adapt(value) if value: - value = value[:self.max_length] + value = value.strip()[:self.max_length].strip() return value @@ -5472,8 +5477,23 @@ return self.model._meta.database.extract_date(date_part, self) return dec +# fromisoformat() is C-implemented and ~10x faster than strptime. Available +# since 3.7; pre-3.11 is strict about the separator and rejects 'Z'. +_fromisoformat = getattr(datetime.datetime, 'fromisoformat', None) + + def format_date_time(value, formats, post_process=None): post_process = post_process or (lambda x: x) + if _fromisoformat is not None and value: + s = value + if len(s) > 10 and s[10] == ' ': + s = s[:10] + 'T' + s[11:] + if s[-1:] == 'Z': + s = s[:-1] + '+00:00' + try: + return post_process(_fromisoformat(s)) + except (TypeError, ValueError): + pass for fmt in formats: try: return post_process(datetime.datetime.strptime(value, fmt)) @@ -8001,6 +8021,97 @@ return validate +def _resolve_model_columns(cursor, model, select): + """Resolve cursor columns against a model's selected nodes. + + Returns ``(columns, fields, converters, no_convert, convert)``: + ``columns`` and ``fields`` are aligned per-column lists, ``converters`` + is a per-column ``python_value`` callable or ``None``, and + ``no_convert``/``convert`` are the index partitions of ``converters``. + """ + combined = model._meta.combined + table = model._meta.table + description = cursor.description + + ncols = len(description) + columns = [] + converters = [None] * ncols + fields = [None] * ncols + + for idx, description_item in enumerate(description): + column = orig_column = description_item[0] + + # Try to clean-up messy column descriptions when people do not + # provide an alias. The idea is that we take something like: + # SUM("t1"."price") -> "price") -> price + dot_index = column.rfind('.') + if dot_index != -1: + column = column[dot_index + 1:] + column = column.strip('()"`') + columns.append(column) + + # Now we'll see what they selected and see if we can improve the + # column-name being returned - e.g. by mapping it to the selected + # field's name. + try: + raw_node = select[idx] + except IndexError: + if column in combined: + raw_node = node = combined[column] + else: + continue + else: + node = raw_node.unwrap() + + # If this column was given an alias, then we will use whatever + # alias was returned by the cursor. + is_alias = raw_node.is_alias() + if is_alias: + columns[idx] = orig_column + + # Heuristics used to attempt to get the field associated with a + # given SELECT column, so that we can accurately convert the value + # returned by the database-cursor into a Python object. + if isinstance(node, Field): + if raw_node._coerce: + converters[idx] = node.python_value + fields[idx] = node + if not is_alias: + columns[idx] = node.name + elif isinstance(node, ColumnBase) and raw_node._converter: + converters[idx] = raw_node._converter + elif isinstance(node, Function) and node._coerce: + if node._python_value is not None: + converters[idx] = node._python_value + elif node.arguments and isinstance(node.arguments[0], Node): + # If the first argument is a field or references a column + # on a Model, try using that field's conversion function. + # This usually works, but we use "safe_python_value()" so + # that if a TypeError or ValueError occurs during + # conversion we can just fall-back to the raw cursor value. + first = node.arguments[0].unwrap() + if isinstance(first, Entity): + path = first._path[-1] # Try to look-up by name. + first = combined.get(path) + if isinstance(first, Field): + converters[idx] = safe_python_value(first.python_value) + elif column in combined: + if node._coerce: + converters[idx] = combined[column].python_value + if isinstance(node, Column) and node.source == table: + fields[idx] = combined[column] + + no_convert = [] + convert = [] + for i in range(ncols): + if converters[i] is not None: + convert.append(i) + else: + no_convert.append(i) + + return columns, fields, converters, no_convert, convert + + class BaseModelCursorWrapper(DictCursorWrapper): def __init__(self, cursor, model, columns): super(BaseModelCursorWrapper, self).__init__(cursor) @@ -8008,85 +8119,10 @@ self.select = columns or [] def initialize(self): - combined = self.model._meta.combined - table = self.model._meta.table - description = self.cursor.description - - self.ncols = len(self.cursor.description) - self.columns = [] - self.converters = converters = [None] * self.ncols - self.fields = fields = [None] * self.ncols - - for idx, description_item in enumerate(description): - column = orig_column = description_item[0] - - # Try to clean-up messy column descriptions when people do not - # provide an alias. The idea is that we take something like: - # SUM("t1"."price") -> "price") -> price - dot_index = column.rfind('.') - if dot_index != -1: - column = column[dot_index + 1:] - column = column.strip('()"`') - self.columns.append(column) - - # Now we'll see what they selected and see if we can improve the - # column-name being returned - e.g. by mapping it to the selected - # field's name. - try: - raw_node = self.select[idx] - except IndexError: - if column in combined: - raw_node = node = combined[column] - else: - continue - else: - node = raw_node.unwrap() - - # If this column was given an alias, then we will use whatever - # alias was returned by the cursor. - is_alias = raw_node.is_alias() - if is_alias: - self.columns[idx] = orig_column - - # Heuristics used to attempt to get the field associated with a - # given SELECT column, so that we can accurately convert the value - # returned by the database-cursor into a Python object. - if isinstance(node, Field): - if raw_node._coerce: - converters[idx] = node.python_value - fields[idx] = node - if not is_alias: - self.columns[idx] = node.name - elif isinstance(node, ColumnBase) and raw_node._converter: - converters[idx] = raw_node._converter - elif isinstance(node, Function) and node._coerce: - if node._python_value is not None: - converters[idx] = node._python_value - elif node.arguments and isinstance(node.arguments[0], Node): - # If the first argument is a field or references a column - # on a Model, try using that field's conversion function. - # This usually works, but we use "safe_python_value()" so - # that if a TypeError or ValueError occurs during - # conversion we can just fall-back to the raw cursor value. - first = node.arguments[0].unwrap() - if isinstance(first, Entity): - path = first._path[-1] # Try to look-up by name. - first = combined.get(path) - if isinstance(first, Field): - converters[idx] = safe_python_value(first.python_value) - elif column in combined: - if node._coerce: - converters[idx] = combined[column].python_value - if isinstance(node, Column) and node.source == table: - fields[idx] = combined[column] - - self.no_convert = [] - self.convert = [] - for i in range(self.ncols): - if converters[i] is not None: - self.convert.append(i) - else: - self.no_convert.append(i) + (self.columns, self.fields, self.converters, + self.no_convert, self.convert) = _resolve_model_columns( + self.cursor, self.model, self.select) + self.ncols = len(self.columns) def process_row(self, row): raise NotImplementedError diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.5/playhouse/pool.py new/peewee-4.0.6/playhouse/pool.py --- old/peewee-4.0.5/playhouse/pool.py 2026-04-23 23:15:53.000000000 +0200 +++ new/peewee-4.0.6/playhouse/pool.py 2026-05-20 15:13:03.000000000 +0200 @@ -185,7 +185,6 @@ # Wake up thread that may be waiting on connection. self._pool_available.notify() - @locked def manual_close(self): """ Close the underlying connection without returning it to the pool. @@ -198,8 +197,11 @@ key = self.conn_key(conn) # Remove from _in_use so that subsequent self.close() won't try to - # restore it to the pool. - self._in_use.pop(key, None) + # restore it to the pool. pool lock must be released before calling + # self.close(), since close acquires the database lock. + with self._pool_lock: + self._in_use.pop(key, None) + self.close() self._close_raw(conn) @@ -227,17 +229,21 @@ self._pool_available.notify_all() return n - @locked def close_all(self): # Close all connections -- available and in-use. Warning: may break any # active connections used by other threads. + + # self.close() acquires the database lock, calling it while holding + # the pool lock would invert the lock order used by Database.connect() + # (db lock, then pool lock via _connect) and deadlock. self.close() - self.close_idle() - in_use, self._in_use = self._in_use, {} - for pool_conn in in_use.values(): - self._close_raw(pool_conn.connection) + with self._pool_lock: + self.close_idle() + in_use, self._in_use = self._in_use, {} + for pool_conn in in_use.values(): + self._close_raw(pool_conn.connection) - self._pool_available.notify_all() + self._pool_available.notify_all() class _PooledMySQLDatabase(PooledDatabase): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.5/playhouse/postgres_ext.py new/peewee-4.0.6/playhouse/postgres_ext.py --- old/peewee-4.0.5/playhouse/postgres_ext.py 2026-04-23 23:15:53.000000000 +0200 +++ new/peewee-4.0.6/playhouse/postgres_ext.py 2026-05-20 15:13:03.000000000 +0200 @@ -53,6 +53,8 @@ JSONB_REMOVE = '-' JSONB_PATH_REMOVE = '#-' JSONB_PATH = '#>' +JSONB_PATH_EXISTS = '@?' +JSONB_PATH_MATCH = '@@' class Json(Node): @@ -249,6 +251,12 @@ return Expression(self, HCONTAINS_ANY_KEY, AsIs(list(keys))) +def _path_array(parts): + # Build a Postgres `text[]` literal from a sequence of path components. + # Used by the jsonb_set/jsonb_insert wrappers on JSON lookups and fields. + return Cast(AsIs([str(p) for p in parts], False), 'text[]') + + class _JsonLookupBase(_LookupNode): def __init__(self, node, parts, as_json=False): super(_JsonLookupBase, self).__init__(node, parts) @@ -309,6 +317,82 @@ def path(self, *keys): return JsonPath(self.as_json(True), keys, as_json=True) + def _resolve_root(self): + # Walk up through chained lookups (e.g. lookup.path(...) wraps the + # lookup as the inner node) to find the underlying field and the full + # list of path parts relative to it. + node = self + parts = [] + while isinstance(node, _JsonLookupBase): + parts = list(node.parts) + parts + node = node.node + return node, parts + + def set(self, value): + # jsonb_set(field, '{path}', value, create_missing=true) + field, parts = self._resolve_root() + return fn.jsonb_set(field, _path_array(parts), + field._wrap_value(value), True) + + def replace(self, value): + # jsonb_set(field, '{path}', value, create_missing=false): no-op when + # the path doesn't exist. + field, parts = self._resolve_root() + return fn.jsonb_set(field, _path_array(parts), + field._wrap_value(value), False) + + def insert(self, value): + # SQLite-style "insert if missing": no-op when the path exists. + # Postgres has no single-call equivalent, so we wrap jsonb_set in a + # CASE that checks whether the path resolves. + field, parts = self._resolve_root() + current = JsonPath(field, parts, as_json=True) + return Case(None, [ + (current.is_null(), + fn.jsonb_set(field, _path_array(parts), + field._wrap_value(value), True)) + ], field) + + def append(self, value): + # Append to the array at this path. '-1' is the index of the last + # element; with insert_after=true, jsonb_insert places `value` after + # it. + field, parts = self._resolve_root() + path = _path_array(list(parts) + ['-1']) + return fn.jsonb_insert(field, path, field._wrap_value(value), True) + + def update(self, value): + # Postgres lacks a real merge-patch (e.g. what SQLite offers, following + # RFC-7396), so this is just a shallow copy using '||'. + field, parts = self._resolve_root() + current = JsonPath(field, parts, as_json=True) + merged = Expression(current, OP.CONCAT, field._wrap_value(value)) + return fn.jsonb_set(field, _path_array(parts), merged, True) + + # SQL/JSON path expression operators applied to the value at this lookup. + # These produce jsonb-typed expressions; usable on lookups off jsonb + # columns. Requires PostgreSQL 12+. + + def path_exists(self, expr): + return Expression(self.as_json(True), JSONB_PATH_EXISTS, + _jsonpath(expr)) + + def path_match(self, expr): + return Expression(self.as_json(True), JSONB_PATH_MATCH, + _jsonpath(expr)) + + def path_query(self, expr): + return fn.jsonb_path_query(self.as_json(True), + _jsonpath(expr)) + + def path_query_array(self, expr): + return fn.jsonb_path_query_array(self.as_json(True), + _jsonpath(expr)) + + def path_query_first(self, expr): + return fn.jsonb_path_query_first(self.as_json(True), + _jsonpath(expr)) + class JsonLookup(_JsonLookupBase): def __getitem__(self, value): @@ -387,6 +471,26 @@ path = [str(p) if isinstance(p, int) else p for p in path] return fn.json_extract_path(self, *path) + def _wrap_value(self, value): + if isinstance(value, Node): + return value + return self.json_type(value) + + def append(self, value): + # Append to the document when it's an array. + return fn.jsonb_insert(self, _path_array(['-1']), + self._wrap_value(value), True) + + def update(self, value): + # Shallow merge `value` into the root object via `||`. + return Expression(self, OP.CONCAT, self._wrap_value(value)) + + +def _jsonpath(expr): + if isinstance(expr, Node): + return expr + return Cast(Value(expr, False), 'jsonpath') + class BinaryJSONField(IndexedFieldMixin, JSONField): field_type = 'JSONB' @@ -444,6 +548,24 @@ path = [str(p) if isinstance(p, int) else p for p in path] return fn.jsonb_extract_path(self, *path) + def path_exists(self, expr): + # field @? jsonpath - true if the path matches anything in the value. + return Expression(self, JSONB_PATH_EXISTS, _jsonpath(expr)) + + def path_match(self, expr): + # field @@ jsonpath - true if the path's predicate evaluates to true. + return Expression(self, JSONB_PATH_MATCH, _jsonpath(expr)) + + def path_query(self, expr): + # jsonb_path_query is set-returning, use in .from_() with an alias. + return fn.jsonb_path_query(self, _jsonpath(expr)) + + def path_query_array(self, expr): + return fn.jsonb_path_query_array(self, _jsonpath(expr)) + + def path_query_first(self, expr): + return fn.jsonb_path_query_first(self, _jsonpath(expr)) + class TSVectorField(IndexedFieldMixin, TextField): field_type = 'TSVECTOR' diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.5/playhouse/pwasyncio.py new/peewee-4.0.6/playhouse/pwasyncio.py --- old/peewee-4.0.5/playhouse/pwasyncio.py 2026-04-23 23:15:53.000000000 +0200 +++ new/peewee-4.0.6/playhouse/pwasyncio.py 2026-05-20 15:13:03.000000000 +0200 @@ -74,7 +74,7 @@ class _State(object): - __slots__ = ('conn', 'closed', 'transactions', '_task_id') + __slots__ = ('conn', 'closed', 'transactions', 'ctx', '_task_id') def __init__(self): self._task_id = None @@ -84,6 +84,7 @@ self.conn = None self.closed = True self.transactions = [] + self.ctx = [] class _ConnectionState(object): @@ -144,6 +145,10 @@ def transactions(self): return self._current().transactions + @property + def ctx(self): + return self._current().ctx + def reset(self): try: state = self._current() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.5/playhouse/sqlite_ext.py new/peewee-4.0.6/playhouse/sqlite_ext.py --- old/peewee-4.0.5/playhouse/sqlite_ext.py 2026-04-23 23:15:53.000000000 +0200 +++ new/peewee-4.0.6/playhouse/sqlite_ext.py 2026-05-20 15:13:03.000000000 +0200 @@ -88,7 +88,7 @@ if isinstance(idx, int) or idx == '#': item = '[%s]' % idx else: - item = '.%s' % idx + item = '."%s"' % idx.replace('"', '""') return type(self)(self._field, self._path + (item,)) def append(self, value, as_json=None): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.5/tests/db_tests.py new/peewee-4.0.6/tests/db_tests.py --- old/peewee-4.0.5/tests/db_tests.py 2026-04-23 23:15:53.000000000 +0200 +++ new/peewee-4.0.6/tests/db_tests.py 2026-05-20 15:13:03.000000000 +0200 @@ -110,6 +110,25 @@ # Closed after exit. self.assertTrue(self.database.is_closed()) + def test_context_managers(self): + @self.database.connection_context() + def with_ctx(): + self.assertFalse(self.database.is_closed()) + + with self.database.connection_context(): + with_ctx() + self.assertFalse(self.database.is_closed()) + with self.database.atomic(): + with self.database.atomic(): + with_ctx() + with self.database: + pass + self.assertTrue(self.database.in_transaction()) + self.assertTrue(self.database.in_transaction()) + + self.assertFalse(self.database.is_closed()) + self.assertTrue(self.database.is_closed()) + def test_connection_initialization(self): state = {'count': 0} class TestDatabase(SqliteDatabase): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.5/tests/fields.py new/peewee-4.0.6/tests/fields.py --- old/peewee-4.0.5/tests/fields.py 2026-04-23 23:15:53.000000000 +0200 +++ new/peewee-4.0.6/tests/fields.py 2026-05-20 15:13:03.000000000 +0200 @@ -28,6 +28,7 @@ from peewee import NodeList from peewee import VirtualField +from peewee import format_date_time from peewee import * from playhouse.hybrid import * @@ -243,7 +244,6 @@ class FC(TestModel): code = FixedCharField(max_length=5) - name = CharField() class TestFixedCharFieldIntegration(ModelTestCase): @@ -251,10 +251,34 @@ requires = [FC] def test_fixed_char_truncates(self): - FC.create(code='ABCDEF', name='short') - - fc = FC.get(FC.code == 'ABCDE') - self.assertEqual(fc.code, 'ABCDE') + cases = ( + 'abcde', + 'abcdef', + 'abcde fgh', + 'abcdef gh', + ' abcdef g', + ) + for case in cases: + fc = FC.create(code=case) + fc_db1 = FC.get(FC.code == case, FC.id == fc.id) + fc_db2 = FC.get(FC.id == fc.id) + self.assertEqual(fc_db1.code, 'abcde', case) + self.assertEqual(fc_db1.code, fc_db2.code) + + others = ( + ('abc', 'abc'), + ('abc ', 'abc'), + (' abc ', 'abc'), + ('abcd efg', 'abcd'), + ('a bcdef g', 'a bcd'), + (' a b cdef g', 'a b c'), + ) + for inp, out in others: + fc = FC.create(code=inp) + fc_db1 = FC.get(FC.code == inp, FC.id == fc.id) + fc_db2 = FC.get(FC.code == out, FC.id == fc.id) + fc_db3 = FC.get(FC.id == fc.id) + self.assertTrue(fc_db1.code == fc_db2.code == fc_db3.code == out) class LK(TestModel): @@ -404,6 +428,39 @@ datetime.datetime(2002, 3, 1, 0, 0, 0), datetime.datetime(2002, 3, 4, 0, 0, 0)]) + def test_date_time_iso_fast_path(self): + if not IS_MYSQL: + dm = DateModel.create(date_time='2019-01-02 03:04:05.123456') + dm_db = DateModel[dm.id] + self.assertEqual(dm_db.date_time, + datetime.datetime(2019, 1, 2, 3, 4, 5, 123456)) + + dm = DateModel.create(date_time='2019-01-02T03:04:05') + dm_db = DateModel[dm.id] + self.assertEqual(dm_db.date_time, + datetime.datetime(2019, 1, 2, 3, 4, 5)) + + val = format_date_time('2019-01-02T03:04:05Z', + DateTimeField.formats) + self.assertEqual(val, + datetime.datetime(2019, 1, 2, 3, 4, 5, + tzinfo=datetime.timezone.utc)) + + val = format_date_time('2019-01-02', DateField.formats, + lambda x: x.date()) + self.assertEqual(val, datetime.date(2019, 1, 2)) + + def test_date_time_format_fallback(self): + val = format_date_time('01/02/2003 01:37 PM', + ['%m/%d/%Y %I:%M %p']) + self.assertEqual(val, datetime.datetime(2003, 1, 2, 13, 37)) + + val = format_date_time('11:12:13', TimeField.formats, + lambda x: x.time()) + self.assertEqual(val, datetime.time(11, 12, 13)) + + self.assertEqual(format_date_time('not a date', []), 'not a date') + def test_to_timestamp(self): dt = datetime.datetime(2019, 1, 2, 3, 4, 5) ts = calendar.timegm(dt.utctimetuple()) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.5/tests/pool.py new/peewee-4.0.6/tests/pool.py --- old/peewee-4.0.5/tests/pool.py 2026-04-23 23:15:53.000000000 +0200 +++ new/peewee-4.0.6/tests/pool.py 2026-05-20 15:13:03.000000000 +0200 @@ -595,6 +595,63 @@ self.db.close() self.assertFalse(self.db.manual_close()) # Already closed. + def _assert_no_pool_lock_during_close(self, action_name, action): + # Helper: hold db._lock in the main thread (simulating another + # thread mid-connect) while a worker calls `action` (which must + # internally invoke self.close()). Verify the pool lock remains + # acquirable -- if the worker is holding the pool lock while + # blocked on db._lock, that is the lock-inversion deadlock. + db = FakePooledDatabase('testing') + worker_ready = threading.Event() + proceed = threading.Event() + worker_result = [] + + def worker(): + try: + db._state.closed = True + db.connect() # Establish thread-local connection. + worker_ready.set() + proceed.wait(timeout=2) + action(db) + worker_result.append('ok') + except Exception as exc: + worker_result.append(exc) + + t = threading.Thread(target=worker) + t.start() + self.assertTrue(worker_ready.wait(timeout=2)) + + with db._lock: + proceed.set() + time.sleep(0.1) + got_pool_lock = db._pool_lock.acquire(timeout=0.5) + if got_pool_lock: + db._pool_lock.release() + + t.join(timeout=2) + self.assertFalse(t.is_alive(), + '%s worker thread still running' % action_name) + self.assertEqual(worker_result, ['ok']) + self.assertTrue(got_pool_lock, + '%s held pool lock while blocked on db lock' % + action_name) + + def test_manual_close_does_not_hold_pool_lock(self): + # Regression: manual_close used to be @locked, meaning it held the + # pool lock across the call to self.close(). self.close() acquires + # the database lock; meanwhile Database.connect() in another thread + # acquires those locks in the opposite order (database lock first, + # then pool lock via the @locked _connect), so the two would + # deadlock. + self._assert_no_pool_lock_during_close( + 'manual_close', lambda db: db.manual_close()) + + def test_close_all_does_not_hold_pool_lock(self): + # Regression: close_all used to be @locked, holding the pool lock + # across self.close() -- same lock-inversion as manual_close. + self._assert_no_pool_lock_during_close( + 'close_all', lambda db: db.close_all()) + def test_close_idle_driver_closes_all(self): # Every idle connection should be driver-closed. db = FakePooledDatabase('testing', counter=5) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.5/tests/postgres.py new/peewee-4.0.6/tests/postgres.py --- old/peewee-4.0.5/tests/postgres.py 2026-04-23 23:15:53.000000000 +0200 +++ new/peewee-4.0.6/tests/postgres.py 2026-05-20 15:13:03.000000000 +0200 @@ -109,7 +109,7 @@ @skip_if(os.environ.get('CI'), 'running in ci mode, skipping') def test_tz_field(self): - self.database.set_time_zone('us/eastern') + self.database.set_time_zone('America/New_York') # Our naive datetime is treated as if it were in US/Eastern. dt = datetime.datetime(2019, 1, 1, 12) @@ -146,7 +146,7 @@ self.assertEqual(tzq2.id, tz2.id) # Change the connection timezone? - self.database.set_time_zone('us/central') + self.database.set_time_zone('America/Chicago') tz_db = TZModel[tz.id] self.assertEqual(tz_db.dt.timetuple()[:4], (2019, 1, 1, 11)) self.assertEqual(tz_db.dt.utctimetuple()[:4], (2019, 1, 1, 17)) @@ -650,6 +650,178 @@ assertData(['kx'], data) assertData(['k1', 'zz'], data) + def test_lookup_set(self): + BJson.delete().execute() + BJson.create(data={'a': 1, 'b': {'x': 1}}) + + def select(expr): + return BJson.select(expr).tuples()[:][0][0] + + # Replace existing nested key. + self.assertEqual(select(BJson.data['a'].set(99)), + {'a': 99, 'b': {'x': 1}}) + # Create missing key (jsonb_set with create_missing=true). + self.assertEqual(select(BJson.data['c'].set('x')), + {'a': 1, 'b': {'x': 1}, 'c': 'x'}) + # Set with a nested dict value. + self.assertEqual(select(BJson.data['b']['y'].set({'z': 2})), + {'a': 1, 'b': {'x': 1, 'y': {'z': 2}}}) + + def test_lookup_replace(self): + BJson.delete().execute() + BJson.create(data={'a': 1, 'b': 2}) + + def select(expr): + return BJson.select(expr).tuples()[:][0][0] + + # Existing key gets replaced. + self.assertEqual(select(BJson.data['a'].replace(99)), + {'a': 99, 'b': 2}) + # Missing key, replace is a no-op (create_missing=false). + self.assertEqual(select(BJson.data['c'].replace(7)), {'a': 1, 'b': 2}) + + def test_lookup_insert(self): + BJson.delete().execute() + BJson.create(data={'a': 1, 'b': 2}) + + def select(expr): + return BJson.select(expr).tuples()[:][0][0] + + # Missing key - value gets inserted. + self.assertEqual(select(BJson.data['c'].insert(7)), + {'a': 1, 'b': 2, 'c': 7}) + # Existing key - insert is a no-op. + self.assertEqual(select(BJson.data['a'].insert(99)), {'a': 1, 'b': 2}) + + def test_lookup_append(self): + BJson.delete().execute() + BJson.create(data={'arr': [1, 2, 3]}) + + def select(expr): + return BJson.select(expr).tuples()[:][0][0] + + self.assertEqual(select(BJson.data['arr'].append(4)), + {'arr': [1, 2, 3, 4]}) + # Append a non-scalar. + self.assertEqual(select(BJson.data['arr'].append({'x': 1})), + {'arr': [1, 2, 3, {'x': 1}]}) + + def test_lookup_update(self): + BJson.delete().execute() + BJson.create(data={'a': 1, 'b': {'x': 1}}) + + def select(expr): + return BJson.select(expr).tuples()[:][0][0] + + # Shallow merge new keys into a nested object. + self.assertEqual(select(BJson.data['b'].update({'y': 2})), + {'a': 1, 'b': {'x': 1, 'y': 2}}) + # Existing keys get overwritten by the merged value (shallow). + self.assertEqual(select(BJson.data['b'].update({'x': 9, 'z': 3})), + {'a': 1, 'b': {'x': 9, 'z': 3}}) + + def test_field_append_update(self): + # Root-level append (when the document is an array). + BJson.delete().execute() + BJson.create(data=[1, 2, 3]) + result = BJson.select(BJson.data.append(4)).tuples()[:][0][0] + self.assertEqual(result, [1, 2, 3, 4]) + + # Root-level update (shallow merge into the root object). + BJson.delete().execute() + BJson.create(data={'a': 1}) + result = BJson.select(BJson.data.update({'b': 2})).tuples()[:][0][0] + self.assertEqual(result, {'a': 1, 'b': 2}) + + def test_mutation_persists_through_update(self): + # Verify the builders work in an UPDATE statement, not just SELECT. + BJson.delete().execute() + bj = BJson.create(data={'a': 1}) + BJson.update({BJson.data: BJson.data['b'].set('x')}).execute() + BJson.update({BJson.data: BJson.data['arr'].set([0, 1])}).execute() + BJson.update({BJson.data: BJson.data['arr'].append(2)}).execute() + bj = BJson.get(BJson.id == bj.id) + self.assertEqual(bj.data, {'a': 1, 'b': 'x', 'arr': [0, 1, 2]}) + + @skip_unless(pg12, 'requires Postgres 12+ for jsonpath support') + def test_path_exists(self): + BJson.delete().execute() + a = BJson.create(data={'foo': [1, 5, 10]}) + b = BJson.create(data={'foo': [1, 2]}) + c = BJson.create(data={'bar': 'x'}) + + # Path matches at least one element with @ > 4. + q = BJson.select().where(BJson.data.path_exists('$.foo[*] ? (@ > 4)')) + self.assertEqual([m.id for m in q], [a.id]) + + # Plain key existence. + q = BJson.select().where(BJson.data.path_exists('$.foo')) + self.assertEqual(sorted(m.id for m in q), sorted([a.id, b.id])) + + @skip_unless(pg12, 'requires Postgres 12+ for jsonpath support') + def test_path_match(self): + BJson.delete().execute() + a = BJson.create(data={'n': 10}) + b = BJson.create(data={'n': 1}) + + q = BJson.select().where(BJson.data.path_match('$.n > 5')) + self.assertEqual([m.id for m in q], [a.id]) + + @skip_unless(pg12, 'requires Postgres 12+ for jsonpath support') + def test_path_query_array(self): + BJson.delete().execute() + BJson.create(data={'items': [ + {'name': 'a', 'qty': 1}, + {'name': 'b', 'qty': 5}, + {'name': 'c', 'qty': 3}]}) + + # All names with qty > 2. + q = BJson.select(BJson.data.path_query_array( + '$.items[*] ? (@.qty > 2).name')) + self.assertEqual(q.tuples()[:][0][0], ['b', 'c']) + + @skip_unless(pg12, 'requires Postgres 12+ for jsonpath support') + def test_path_query_first(self): + BJson.delete().execute() + BJson.create(data={'items': [{'qty': 1}, {'qty': 5}, {'qty': 3}]}) + + q = BJson.select(BJson.data.path_query_first( + '$.items[*] ? (@.qty > 2).qty')) + self.assertEqual(q.tuples()[:][0][0], 5) + + @skip_unless(pg12, 'requires Postgres 12+ for jsonpath support') + def test_path_query_srf(self): + # path_query returns a set-returning function; Postgres expands it + # into multiple rows when it appears in SELECT. + BJson.delete().execute() + BJson.create(data={'tags': ['a', 'b', 'c']}) + + q = BJson.select(BJson.data.path_query('$.tags[*]').alias('tag')) + self.assertEqual(sorted(r[0] for r in q.tuples()), ['a', 'b', 'c']) + + @skip_unless(pg12, 'requires Postgres 12+ for jsonpath support') + def test_path_ops_on_lookup(self): + # path_* operators are also exposed on _JsonLookupBase so they can + # target a sub-document rather than the whole field. + BJson.delete().execute() + a = BJson.create(data={'users': [ + {'name': 'alice', 'role': 'admin'}, + {'name': 'bob'}]}) + BJson.create(data={'users': [{'name': 'carol'}]}) + + q = BJson.select().where( + BJson.data['users'].path_exists('$[*] ? (@.role == "admin")')) + self.assertEqual([m.id for m in q], [a.id]) + + q = BJson.select().where( + BJson.data['users'].path_match('$[*].role == "admin"')) + self.assertEqual([m.id for m in q], [a.id]) + + q = (BJson + .select(BJson.data['users'].path_query_array('$[*].name')) + .where(BJson.id == a.id)) + self.assertEqual(q.tuples()[:][0][0], ['alice', 'bob']) + def test_json_length(self): BJson.delete().execute() # Clear out db. data = {'k1': {'x1': [1, 2, 3], 'x2': [1, 2], 'x3': []}} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/peewee-4.0.5/tests/sqlite.py new/peewee-4.0.6/tests/sqlite.py --- old/peewee-4.0.5/tests/sqlite.py 2026-04-23 23:15:53.000000000 +0200 +++ new/peewee-4.0.6/tests/sqlite.py 2026-05-20 15:13:03.000000000 +0200 @@ -199,6 +199,16 @@ kd_db = KeyData.get(KeyData.key == 'kx') self.assertEqual(kd_db.data, value) + def test_key_with_special_chars(self): + kd = KeyData.create(key='k1', data={'k 1': {'k.. 2': {'{k3}': 'v4'}}}) + def assertMatch(expr): + obj = KeyData.select().where(expr.is_null(False)).get() + self.assertEqual(obj.key, 'k1') + + assertMatch(KeyData.data['k 1']) + assertMatch(KeyData.data['k 1']['k.. 2']) + assertMatch(KeyData.data['k 1']['k.. 2']['{k3}']) + def test_json_unicode(self): with self.database.atomic(): KeyData.delete().execute()
