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()

Reply via email to