dotted-notation 0.49.2__tar.gz → 0.49.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/CHANGELOG.md +48 -0
  2. {dotted_notation-0.49.2/dotted_notation.egg-info → dotted_notation-0.49.4}/PKG-INFO +27 -1
  3. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/README.md +25 -0
  4. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/access.py +68 -2
  5. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/containers.py +159 -11
  6. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/filters.py +34 -8
  7. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/grammar.py +10 -4
  8. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/recursive.py +40 -8
  9. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/results.py +9 -2
  10. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/wrappers.py +6 -4
  11. {dotted_notation-0.49.2 → dotted_notation-0.49.4/dotted_notation.egg-info}/PKG-INFO +27 -1
  12. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/pyproject.toml +3 -2
  13. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/setup.py +4 -1
  14. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/LICENSE +0 -0
  15. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/MANIFEST.in +0 -0
  16. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/__init__.py +0 -0
  17. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/__main__.py +0 -0
  18. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/access.pxd +0 -0
  19. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/api.py +0 -0
  20. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/base.pxd +0 -0
  21. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/base.py +0 -0
  22. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/cli/__init__.py +0 -0
  23. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/cli/_compat.py +0 -0
  24. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/cli/formats.py +0 -0
  25. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/cli/main.py +0 -0
  26. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/engine.pxd +0 -0
  27. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/engine.py +0 -0
  28. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/filters.pxd +0 -0
  29. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/groups.pxd +0 -0
  30. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/groups.py +0 -0
  31. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/matchers.pxd +0 -0
  32. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/matchers.py +0 -0
  33. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/native.py +0 -0
  34. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/predicates.py +0 -0
  35. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/recursive.pxd +0 -0
  36. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/sql/__init__.py +0 -0
  37. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/sql/core.py +0 -0
  38. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/sql/pg.py +0 -0
  39. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/transforms.py +0 -0
  40. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/utils.py +0 -0
  41. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/utypes.py +0 -0
  42. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted/wrappers.pxd +0 -0
  43. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted_notation.egg-info/SOURCES.txt +0 -0
  44. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted_notation.egg-info/dependency_links.txt +0 -0
  45. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted_notation.egg-info/entry_points.txt +0 -0
  46. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted_notation.egg-info/requires.txt +0 -0
  47. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/dotted_notation.egg-info/top_level.txt +0 -0
  48. {dotted_notation-0.49.2 → dotted_notation-0.49.4}/setup.cfg +0 -0
@@ -3,6 +3,54 @@
3
3
  All notable changes to `dotted` are recorded here. Versions prior to
4
4
  the ones listed are omitted — browse git history for earlier entries.
5
5
 
6
+ ## [0.49.4]
7
+
8
+ ### Fixed
9
+ - `update` through `~` around a filtered or recursive op,
10
+ `[(~*&k=v#, +)]` or `~*a.x`, raised `TypeError` once something
11
+ matched (#65). It now keeps what the `~` matches as it is and, as
12
+ elsewhere, still updates the path after it: `[(~*&k=1#, +)].x`
13
+ updates `x` in the matching items.
14
+
15
+ ## [0.49.3]
16
+
17
+ ### Added
18
+ - A slice bound may be a reference or a substitution: `b[:$$(i)]`,
19
+ `b[$(n):]`, `b[$0:$1]`, `b[::$$(step)]`. It resolves as a slot's does,
20
+ so `b[:$$(i)]|sum` is a running total with `i` bound per element
21
+ (#64). A reference that does not resolve matches nothing.
22
+ - A reference may hold parentheses of its own: `$$()` runs to the `)`
23
+ that closes it, so a group or a pack works inside, `$$(a.(b,c))`,
24
+ `$$((a[*];))`. A `)` inside quotes does not count.
25
+ - A reference inside a container value, `=[1, $$(x)]`, `={"k": $$(x)}`,
26
+ `="ab"...$$(x)`, `=(1, $$(x))`, resolves as a whole guard or filter
27
+ value does. One whose path is a pattern branches the container, once
28
+ per value; pack it, `$$((x[*];))`, to compare with the tuple instead.
29
+ In a string or bytes glob it must resolve to `str` or `bytes`, else
30
+ `TypeError`; `$$(x|str)` coerces.
31
+ - A reference in a filter key part, `[*&$$(field)="bob"]`,
32
+ `[*&tags[$$(^^i)]="b"]`, `[*&xs[:$$(^^n)]=[1]]`, resolves against the
33
+ node that part is looked up in, as `^`; in the first part that is the
34
+ item. A pattern reference tries each key it resolves to.
35
+
36
+ ### Fixed
37
+ - An unresolved substitution inside a container value, `=[1, $(x)]`,
38
+ makes the path an unresolved template, so `get` and the rest raise
39
+ `TypeError` as for any other unresolved `$N`, rather than matching
40
+ nothing.
41
+ - Resolving a recursive op's substitutions, `**&x=$(v)` or
42
+ `*($(k), [*])&x=1`, now resolves those in its filters, keeps the
43
+ filters, and keeps `*?`/`**?` first-match. `is_template` and
44
+ `is_reference` see a recursive op's accessors and filters, so an
45
+ unresolved `$N` there raises and a `$$(^^…)` gets its parents.
46
+ - Resolving `$N` in a string or bytes glob no longer turns a `$$(path)`
47
+ part into the literal text of its path.
48
+ - A slice's repr shows its step, `[1:5:2]`, rather than repeating its
49
+ stop.
50
+ - `make native` on macOS, and on Linux from Python 3.14: `setup.py`
51
+ runs `setup()` under a `__main__` guard, since Cython's spawned
52
+ workers import it again.
53
+
6
54
  ## [0.49.2]
7
55
 
8
56
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotted_notation
3
- Version: 0.49.2
3
+ Version: 0.49.4
4
4
  Summary: Dotted notation for safe nested data traversal with optional chaining, pattern matching, and transforms
5
5
  Author-email: Frey Waid <logophage1@gmail.com>
6
6
  License-Expression: MIT
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.11
21
21
  Classifier: Programming Language :: Python :: 3.12
22
22
  Classifier: Programming Language :: Python :: 3.13
23
23
  Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Programming Language :: Python :: 3.15
24
25
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
26
  Classifier: Topic :: Utilities
26
27
  Requires-Python: >=3.6
@@ -1163,6 +1164,14 @@ Dotted slicing works like python slicing and all that entails.
1163
1164
  >>> dotted.get(d, '*.there[1:]')
1164
1165
  ([2, 3], [5, 6])
1165
1166
 
1167
+ A bound may be a reference or a substitution, resolved the way a slot's is:
1168
+
1169
+ >>> d = {'b': [3, 0, 5], 'i': 2}
1170
+ >>> dotted.get(d, 'b[:$$(i)]')
1171
+ [3, 0]
1172
+ >>> dotted.get(d, 'b[$(n):]', bindings={'n': 1})
1173
+ [0, 5]
1174
+
1166
1175
  <a id="dot-notation-for-sequence-indexing"></a>
1167
1176
  ### Dot notation for sequence indexing
1168
1177
 
@@ -1641,6 +1650,23 @@ as do the transforms before a guard:
1641
1650
  >>> dotted.get(data, '*|mul:$$(scale)=20')
1642
1651
  (2,)
1643
1652
 
1653
+ So does a reference inside a container value. One whose path is a pattern
1654
+ branches the container, once per value; pack it, `(…;)`, to compare with the
1655
+ tuple instead:
1656
+
1657
+ >>> data = {'want': [2, 3], 'items': [[1, 2], [1, 4], [1, (2, 3)]]}
1658
+ >>> dotted.get(data, 'items[*]=[1, $$(want[*])]')
1659
+ ([1, 2],)
1660
+ >>> dotted.get(data, 'items[*]=[1, $$((want[*];))]')
1661
+ ([1, (2, 3)],)
1662
+
1663
+ A filter key may hold references too. In each part of the key, `^` is the
1664
+ node that part is looked up in, so in the first part it is the item:
1665
+
1666
+ >>> data = {'field': 'name', 'users': [{'name': 'alice'}, {'name': 'bob'}]}
1667
+ >>> dotted.get(data, 'users[*&$$(field)="bob"]')
1668
+ ({'name': 'bob'},)
1669
+
1644
1670
  With `update`, the transforms apply to the value being set, so `^` is that
1645
1671
  value:
1646
1672
 
@@ -1116,6 +1116,14 @@ Dotted slicing works like python slicing and all that entails.
1116
1116
  >>> dotted.get(d, '*.there[1:]')
1117
1117
  ([2, 3], [5, 6])
1118
1118
 
1119
+ A bound may be a reference or a substitution, resolved the way a slot's is:
1120
+
1121
+ >>> d = {'b': [3, 0, 5], 'i': 2}
1122
+ >>> dotted.get(d, 'b[:$$(i)]')
1123
+ [3, 0]
1124
+ >>> dotted.get(d, 'b[$(n):]', bindings={'n': 1})
1125
+ [0, 5]
1126
+
1119
1127
  <a id="dot-notation-for-sequence-indexing"></a>
1120
1128
  ### Dot notation for sequence indexing
1121
1129
 
@@ -1594,6 +1602,23 @@ as do the transforms before a guard:
1594
1602
  >>> dotted.get(data, '*|mul:$$(scale)=20')
1595
1603
  (2,)
1596
1604
 
1605
+ So does a reference inside a container value. One whose path is a pattern
1606
+ branches the container, once per value; pack it, `(…;)`, to compare with the
1607
+ tuple instead:
1608
+
1609
+ >>> data = {'want': [2, 3], 'items': [[1, 2], [1, 4], [1, (2, 3)]]}
1610
+ >>> dotted.get(data, 'items[*]=[1, $$(want[*])]')
1611
+ ([1, 2],)
1612
+ >>> dotted.get(data, 'items[*]=[1, $$((want[*];))]')
1613
+ ([1, (2, 3)],)
1614
+
1615
+ A filter key may hold references too. In each part of the key, `^` is the
1616
+ node that part is looked up in, so in the first part it is the item:
1617
+
1618
+ >>> data = {'field': 'name', 'users': [{'name': 'alice'}, {'name': 'bob'}]}
1619
+ >>> dotted.get(data, 'users[*&$$(field)="bob"]')
1620
+ ({'name': 'bob'},)
1621
+
1597
1622
  With `update`, the transforms apply to the value being set, so `^` is that
1598
1623
  value:
1599
1624
 
@@ -994,12 +994,49 @@ class Slice(SimpleOp):
994
994
  start, stop, step = self.args
995
995
  m = [s(start), s(stop)]
996
996
  if step is not None:
997
- m += [s(stop)]
997
+ m += [s(step)]
998
998
  return '[' + ':'.join(m) + ']'
999
999
  def is_pattern(self):
1000
1000
  return False
1001
1001
  def is_slice(self):
1002
1002
  return True
1003
+ def is_template(self):
1004
+ """
1005
+ True if a bound is an unresolved substitution.
1006
+ """
1007
+ return any(isinstance(a, matchers.Subst) for a in self.args)
1008
+ def is_reference(self):
1009
+ """
1010
+ True if a bound is an internal reference.
1011
+ """
1012
+ return any(isinstance(a, matchers.Reference) for a in self.args)
1013
+ def resolve(self, bindings, partial=False):
1014
+ """
1015
+ Resolve $N in the bounds.
1016
+ """
1017
+ if not self.is_template():
1018
+ return self
1019
+ args = []
1020
+ for a in self.args:
1021
+ r = a.resolve(bindings, partial) if isinstance(a, matchers.Subst) else a
1022
+ # a resolved substitution is a ResolvedValue; the bound is its value
1023
+ args.append(r.value if isinstance(r, matchers.ResolvedValue) else r)
1024
+ return self.concrete(slice(*args))
1025
+ def _resolved(self, node=None, settings=base.SETTINGS):
1026
+ """
1027
+ This slice with its $$(path) bounds resolved, or None if one does
1028
+ not resolve.
1029
+ """
1030
+ args = []
1031
+ for a in self.args:
1032
+ if not isinstance(a, matchers.Reference):
1033
+ args.append(a)
1034
+ continue
1035
+ try:
1036
+ args.append(settings.resolve(a, node, settings.parents or ()))
1037
+ except KeyError:
1038
+ return None
1039
+ return self.concrete(slice(*args))
1003
1040
  def operator(self, top=False):
1004
1041
  return str(self)
1005
1042
  def slice(self, node=None):
@@ -1012,6 +1049,8 @@ class Slice(SimpleOp):
1012
1049
  Calculate cardinality of a slice; don't both dealing with countably infinite
1013
1050
  set arithmetic, instead just pick a suitably large integer
1014
1051
  """
1052
+ if self.is_template() or self.is_reference():
1053
+ return 0
1015
1054
  s = self.slice(node)
1016
1055
  start = s.start or 0
1017
1056
  stop = s.stop or '+'
@@ -1023,7 +1062,12 @@ class Slice(SimpleOp):
1023
1062
  return max(0, int((stop - start) / step))
1024
1063
 
1025
1064
  def keys(self, node, settings=base.SETTINGS):
1026
- return (self.slice(node),)
1065
+ if not self.is_reference():
1066
+ return (self.slice(node),)
1067
+ r = self._resolved(node=node, settings=settings)
1068
+ if r is None:
1069
+ return ()
1070
+ return r.keys(node, settings=settings)
1027
1071
  def items(self, node, settings=base.SETTINGS, filtered=True):
1028
1072
  return self._slice_items(node, settings)
1029
1073
  def _slice_items(self, node, settings):
@@ -1076,6 +1120,28 @@ class Slice(SimpleOp):
1076
1120
  def upsert(self, node, val):
1077
1121
  return self.update(node, self.slice(node), val)
1078
1122
 
1123
+ def do_update(self, ops, node, val, has_defaults, _path, nop, nop_from_unwrap=False, settings=base.SETTINGS):
1124
+ """
1125
+ Resolve references before updating.
1126
+ """
1127
+ if not self.is_reference():
1128
+ return super().do_update(ops, node, val, has_defaults, _path, nop, nop_from_unwrap=nop_from_unwrap, settings=settings)
1129
+ r = self._resolved(node=node, settings=settings)
1130
+ if r is None:
1131
+ return node
1132
+ return r.do_update(ops, node, val, has_defaults, _path, nop, nop_from_unwrap=nop_from_unwrap, settings=settings)
1133
+
1134
+ def do_remove(self, ops, node, val, nop, settings=base.SETTINGS):
1135
+ """
1136
+ Resolve references before removing.
1137
+ """
1138
+ if not self.is_reference():
1139
+ return super().do_remove(ops, node, val, nop, settings=settings)
1140
+ r = self._resolved(node=node, settings=settings)
1141
+ if r is None:
1142
+ return node
1143
+ return r.do_remove(ops, node, val, nop, settings=settings)
1144
+
1079
1145
  def pop(self, node, key):
1080
1146
  try:
1081
1147
  del node[key]
@@ -25,7 +25,7 @@ Type prefix semantics:
25
25
  fs{...} frozenset only
26
26
  """
27
27
  import re
28
- from itertools import combinations
28
+ from itertools import combinations, product
29
29
 
30
30
  from . import utils
31
31
  from . import base
@@ -46,11 +46,91 @@ def _element_matches(pattern, val):
46
46
  return any(True for _ in pattern.matches((val,)))
47
47
 
48
48
 
49
+ def _holds(items, method):
50
+ """
51
+ True if an item, or a part of a (key, value) dict entry, answers True
52
+ to method: 'is_template' or 'is_reference'.
53
+ """
54
+ for x in items:
55
+ if isinstance(x, tuple):
56
+ if _holds(x, method):
57
+ return True
58
+ continue
59
+ test = getattr(x, method, None)
60
+ if test is not None and test():
61
+ return True
62
+ return False
63
+
64
+
65
+ def _values(ref, settings, node, parents):
66
+ """
67
+ What reference ref resolves to, as a tuple: its value, or each value
68
+ of a pattern reference.
69
+ """
70
+ val = settings.resolve(ref, node, parents)
71
+ if ref.is_pattern():
72
+ return tuple(val)
73
+ return (val,)
74
+
75
+
76
+ def _alternatives(e, settings, node, parents):
77
+ """
78
+ The forms element e takes once its references resolve: a Const per
79
+ value of a reference, a nested container's branches, each pairing of
80
+ a dict entry's key and value forms; just e when it holds none.
81
+ """
82
+ if isinstance(e, tuple):
83
+ return tuple(product(*(_alternatives(p, settings, node, parents) for p in e)))
84
+ if not _holds((e,), 'is_reference'):
85
+ return (e,)
86
+ if isinstance(e, matchers.Reference):
87
+ return tuple(matchers.Const(v) for v in _values(e, settings, node, parents))
88
+ return e.branches(settings, node, parents)
89
+
90
+
91
+ def _fragments(part, typ, owner, settings, node, parents):
92
+ """
93
+ The fragments a string or bytes glob part stands for: a reference's
94
+ values, each of which must be a typ; any other part as it is.
95
+ """
96
+ if not isinstance(part, matchers.Reference):
97
+ return (part,)
98
+ vals = _values(part, settings, node, parents)
99
+ for v in vals:
100
+ if not isinstance(v, typ):
101
+ raise TypeError(
102
+ f'{owner} requires {typ.__name__}, got {type(v).__name__}'
103
+ f' — use $$(path|{typ.__name__}) to coerce')
104
+ return vals
105
+
106
+
107
+ class ContainerOp(base.Op):
108
+ """
109
+ Base of the container value patterns. Their args are their parts, so
110
+ whether one holds a substitution or a reference is read from those.
111
+
112
+ A reference inside resolves as the whole value's would (see
113
+ results.guard_matcher). One that is a pattern branches the container:
114
+ branches() gives the container once per combination of values.
115
+ """
116
+ def is_template(self):
117
+ """
118
+ True if a part holds an unresolved substitution.
119
+ """
120
+ return _holds(self.args, 'is_template')
121
+
122
+ def is_reference(self):
123
+ """
124
+ True if a part holds an internal reference.
125
+ """
126
+ return _holds(self.args, 'is_reference')
127
+
128
+
49
129
  # ---------------------------------------------------------------------------
50
130
  # Glob — the ... element
51
131
  # ---------------------------------------------------------------------------
52
132
 
53
- class Glob(base.Op):
133
+ class Glob(ContainerOp):
54
134
  """
55
135
  The ... element: zero or more values with optional pattern and count.
56
136
 
@@ -87,6 +167,14 @@ class Glob(base.Op):
87
167
  return self
88
168
  return Glob(new_pat, self.min_count, self.max_count)
89
169
 
170
+ def branches(self, settings, node=None, parents=()):
171
+ """
172
+ This glob once per form of its pattern (see ContainerOp).
173
+ """
174
+ return tuple(
175
+ Glob(p, self.min_count, self.max_count)
176
+ for p in _alternatives(self.pattern, settings, node, parents))
177
+
90
178
  def __repr__(self):
91
179
  parts = ['...']
92
180
  if self.pattern is not None:
@@ -105,7 +193,7 @@ class Glob(base.Op):
105
193
  # DictGlobEntry — a glob entry in a dict pattern
106
194
  # ---------------------------------------------------------------------------
107
195
 
108
- class DictGlobEntry(base.Op):
196
+ class DictGlobEntry(ContainerOp):
109
197
  """
110
198
  A glob entry in a dict: glob on key side with value pattern.
111
199
 
@@ -141,6 +229,15 @@ class DictGlobEntry(base.Op):
141
229
  return self
142
230
  return DictGlobEntry(new_kg, new_vp)
143
231
 
232
+ def branches(self, settings, node=None, parents=()):
233
+ """
234
+ This entry once per pairing of its key glob's and value pattern's
235
+ forms (see ContainerOp).
236
+ """
237
+ keys = _alternatives(self.key_glob, settings, node, parents)
238
+ vals = _alternatives(self.val_pattern, settings, node, parents)
239
+ return tuple(DictGlobEntry(k, v) for k, v in product(keys, vals))
240
+
144
241
  def __repr__(self):
145
242
  val_repr = repr(self.val_pattern) if self.val_pattern is not None else ''
146
243
  key_repr = repr(self.key_glob)
@@ -156,7 +253,7 @@ class DictGlobEntry(base.Op):
156
253
  _LIST_TYPES = (list, tuple)
157
254
 
158
255
 
159
- class ContainerList(base.Op):
256
+ class ContainerList(ContainerOp):
160
257
  """
161
258
  List/tuple pattern: [elem, elem, ...].
162
259
 
@@ -213,6 +310,14 @@ class ContainerList(base.Op):
213
310
  return self
214
311
  return ContainerList(*new_elems, type_prefix=self.type_prefix)
215
312
 
313
+ def branches(self, settings, node=None, parents=()):
314
+ """
315
+ This list once per combination of its elements' forms (see
316
+ ContainerOp).
317
+ """
318
+ forms = (_alternatives(e, settings, node, parents) for e in self.elements)
319
+ return tuple(ContainerList(*c, type_prefix=self.type_prefix) for c in product(*forms))
320
+
216
321
  def __repr__(self):
217
322
  prefix = self.type_prefix or ''
218
323
  return prefix + '[' + ', '.join(repr(e) for e in self.elements) + ']'
@@ -222,7 +327,7 @@ class ContainerList(base.Op):
222
327
  # ContainerDict — {k: v, ...} pattern
223
328
  # ---------------------------------------------------------------------------
224
329
 
225
- class ContainerDict(base.Op):
330
+ class ContainerDict(ContainerOp):
226
331
  """
227
332
  Dict pattern: {key: val, key: val, ...}.
228
333
 
@@ -280,6 +385,14 @@ class ContainerDict(base.Op):
280
385
  return self
281
386
  return ContainerDict(*new_entries, type_prefix=self.type_prefix)
282
387
 
388
+ def branches(self, settings, node=None, parents=()):
389
+ """
390
+ This dict once per combination of its entries' forms (see
391
+ ContainerOp).
392
+ """
393
+ forms = (_alternatives(e, settings, node, parents) for e in self.entries)
394
+ return tuple(ContainerDict(*c, type_prefix=self.type_prefix) for c in product(*forms))
395
+
283
396
  def matchable(self, op, specials=False):
284
397
  """
285
398
  ContainerDict can match against Const values.
@@ -302,7 +415,7 @@ class ContainerDict(base.Op):
302
415
  # ContainerSet — {v, v, ...} pattern
303
416
  # ---------------------------------------------------------------------------
304
417
 
305
- class ContainerSet(base.Op):
418
+ class ContainerSet(ContainerOp):
306
419
  """
307
420
  Set/frozenset pattern: {elem, elem, ...}.
308
421
 
@@ -353,6 +466,14 @@ class ContainerSet(base.Op):
353
466
  return self
354
467
  return ContainerSet(*new_elems, type_prefix=self.type_prefix)
355
468
 
469
+ def branches(self, settings, node=None, parents=()):
470
+ """
471
+ This set once per combination of its elements' forms (see
472
+ ContainerOp).
473
+ """
474
+ forms = (_alternatives(e, settings, node, parents) for e in self.elements)
475
+ return tuple(ContainerSet(*c, type_prefix=self.type_prefix) for c in product(*forms))
476
+
356
477
  def matchable(self, op, specials=False):
357
478
  """
358
479
  ContainerSet can match against Const values.
@@ -368,7 +489,7 @@ class ContainerSet(base.Op):
368
489
  # StringGlob — "prefix"..."suffix" string pattern
369
490
  # ---------------------------------------------------------------------------
370
491
 
371
- class StringGlob(base.Op):
492
+ class StringGlob(ContainerOp):
372
493
  """
373
494
  String glob pattern: quoted fragments with ... between them.
374
495
 
@@ -437,7 +558,8 @@ class StringGlob(base.Op):
437
558
  new_parts = []
438
559
  changed = False
439
560
  for p in self.parts:
440
- if not hasattr(p, 'resolve'):
561
+ # a glob holds no substitution; a reference resolves when matched
562
+ if isinstance(p, (Glob, matchers.Reference)) or not hasattr(p, 'resolve'):
441
563
  new_parts.append(p)
442
564
  continue
443
565
  np = p.resolve(bindings, partial)
@@ -462,6 +584,14 @@ class StringGlob(base.Op):
462
584
  return self
463
585
  return StringGlob(*new_parts)
464
586
 
587
+ def branches(self, settings, node=None, parents=()):
588
+ """
589
+ This glob once per combination of the strings its references
590
+ resolve to (see ContainerOp).
591
+ """
592
+ forms = (_fragments(p, str, 'StringGlob', settings, node, parents) for p in self.parts)
593
+ return tuple(StringGlob(*c) for c in product(*forms))
594
+
465
595
  def matches(self, vals):
466
596
  """
467
597
  Yield str vals matching the glob pattern.
@@ -493,7 +623,7 @@ class StringGlob(base.Op):
493
623
  # BytesGlob — b"prefix"...b"suffix" bytes pattern
494
624
  # ---------------------------------------------------------------------------
495
625
 
496
- class BytesGlob(base.Op):
626
+ class BytesGlob(ContainerOp):
497
627
  """
498
628
  Bytes glob pattern: byte-string fragments with ... between them.
499
629
 
@@ -562,7 +692,8 @@ class BytesGlob(base.Op):
562
692
  new_parts = []
563
693
  changed = False
564
694
  for p in self.parts:
565
- if not hasattr(p, 'resolve'):
695
+ # a glob holds no substitution; a reference resolves when matched
696
+ if isinstance(p, (Glob, matchers.Reference)) or not hasattr(p, 'resolve'):
566
697
  new_parts.append(p)
567
698
  continue
568
699
  np = p.resolve(bindings, partial)
@@ -587,6 +718,14 @@ class BytesGlob(base.Op):
587
718
  return self
588
719
  return BytesGlob(*new_parts)
589
720
 
721
+ def branches(self, settings, node=None, parents=()):
722
+ """
723
+ This glob once per combination of the bytes its references
724
+ resolve to (see ContainerOp).
725
+ """
726
+ forms = (_fragments(p, bytes, 'BytesGlob', settings, node, parents) for p in self.parts)
727
+ return tuple(BytesGlob(*c) for c in product(*forms))
728
+
590
729
  def matches(self, vals):
591
730
  """
592
731
  Yield bytes vals matching the glob pattern.
@@ -618,7 +757,7 @@ class BytesGlob(base.Op):
618
757
  # ValueGroup — (val1, val2) value disjunction
619
758
  # ---------------------------------------------------------------------------
620
759
 
621
- class ValueGroup(base.Op):
760
+ class ValueGroup(ContainerOp):
622
761
  """
623
762
  Value group: (val1, val2, ...) — matches any alternative.
624
763
 
@@ -652,6 +791,15 @@ class ValueGroup(base.Op):
652
791
  return self
653
792
  return ValueGroup(*new_alts)
654
793
 
794
+ def branches(self, settings, node=None, parents=()):
795
+ """
796
+ This group with its references resolved: one group, whose
797
+ alternatives are every form its alternatives take (see
798
+ ContainerOp).
799
+ """
800
+ alts = (f for a in self.alternatives for f in _alternatives(a, settings, node, parents))
801
+ return (ValueGroup(*alts),)
802
+
655
803
  def matchable(self, op, specials=False):
656
804
  """
657
805
  ValueGroup can match against Const values.
@@ -2,6 +2,7 @@
2
2
  Filter ops: key-value filters, boolean combinators, and negation.
3
3
  """
4
4
  from . import base
5
+ from . import matchers
5
6
  from . import predicates
6
7
  from .access import Slot, Slice
7
8
 
@@ -112,29 +113,37 @@ class FilterKey(base.MatchOp):
112
113
  if hasattr(part, 'gather'):
113
114
  yield from self._get_values(part.gather(node, settings), rest, settings)
114
115
  return
116
+ # a reference in a part resolves against the node the part is looked
117
+ # up in, as ^, and that node's ancestors above it
118
+ below = settings.below(node)
115
119
  if isinstance(part, Slot):
120
+ # only a reference goes through settings: a resolver taking
121
+ # literals in segments is not offered a filter key's
122
+ slots = part._resolved(node=node, settings=settings) if part.is_reference() else (part,)
116
123
  try:
117
- for v in part.values(node):
118
- yield from self._get_values(v, rest, settings)
124
+ for slot in slots:
125
+ for v in slot.values(node):
126
+ yield from self._get_values(v, rest, below)
119
127
  except (TypeError, AttributeError):
120
128
  yield None, False
121
129
  return
122
130
  if isinstance(part, Slice):
123
131
  try:
124
- s = part.slice(node)
132
+ s = next(iter(part.keys(node, settings=settings)))
125
133
  val = node[s]
126
- yield from self._get_values(val, rest, settings)
127
- except (TypeError, AttributeError, KeyError, IndexError):
134
+ yield from self._get_values(val, rest, below)
135
+ except (StopIteration, TypeError, AttributeError, KeyError, IndexError):
128
136
  yield None, False
129
137
  return
130
138
  # Key-like part (Word, Wildcard, etc.)
131
139
  if not hasattr(node, 'keys'):
132
140
  yield None, False
133
141
  return
142
+ keys = (k for m in self._key_matchers(part, node, settings) for k in m.matches(node.keys()))
134
143
  if len(parts) == 1 and not isinstance(part, Slot):
135
144
  # Simple key - yield all matching values (existing single-key behavior)
136
145
  found_any = False
137
- for km in part.matches(node.keys()):
146
+ for km in keys:
138
147
  yield node[km], True
139
148
  found_any = True
140
149
  if not found_any:
@@ -142,14 +151,31 @@ class FilterKey(base.MatchOp):
142
151
  return
143
152
  # Dotted path - traverse (first match only at each key-like level)
144
153
  found = False
145
- for km in part.matches(node.keys()):
154
+ for km in keys:
146
155
  child = node[km]
147
- yield from self._get_values(child, rest, settings)
156
+ yield from self._get_values(child, rest, below)
148
157
  found = True
149
158
  break
150
159
  if not found:
151
160
  yield None, False
152
161
 
162
+ def _key_matchers(self, part, node, settings):
163
+ """
164
+ The match ops a key-like part tests node's keys with: a reference
165
+ resolved against node and its ancestors, one per value when its
166
+ path is a pattern, none when it does not resolve; the part itself
167
+ otherwise.
168
+ """
169
+ if not part.is_reference():
170
+ return (part,)
171
+ try:
172
+ val = settings.resolve(part, node, settings.parents or ())
173
+ except KeyError:
174
+ return ()
175
+ if part.is_pattern():
176
+ return tuple(matchers.ResolvedValue(v) for v in val)
177
+ return (matchers.ResolvedValue(val),)
178
+
153
179
  def get_value(self, node):
154
180
  """
155
181
  Get first matching value from node (backwards compat)
@@ -260,8 +260,11 @@ regex = _regex.copy().set_parse_action(matchers.Regex)
260
260
  regex_first = (_regex + pp.Suppress(pp.Literal('?'))).set_parse_action(matchers.RegexFirst)
261
261
  _escaped_dollar = pp.Regex(r'\\\$\${0,2}(\([^)]*\)|[0-9]*)').set_parse_action(
262
262
  lambda t: matchers.Word(t[0][1:]))
263
- _reference = pp.Regex(r'\$\$\([^)]+\)').set_parse_action(
264
- lambda t: matchers.Reference(t[0][3:-1]))
263
+ # $$(path) runs to the ) that closes it, so the path may hold parentheses
264
+ # of its own, $$((a[*];)) or $$(a.(b,c)); a ) inside quotes does not count.
265
+ _reference = (S(pp.Regex(r'\$\$(?=\()'))
266
+ + pp.original_text_for(pp.nested_expr('(', ')')).add_condition(lambda t: t[0] != '()')
267
+ ).set_parse_action(lambda t: matchers.Reference(t[0][1:-1]))
265
268
  def _paren_subst_action(t):
266
269
  """
267
270
  Parse $(content) where content is name_or_index[|transform1|transform2...].
@@ -285,8 +288,11 @@ var = _escaped_dollar | _reference | _paren_subst | _raw_subst | _dollar_word
285
288
  # |min:$amount.value or =^lots.taken: one string, for a resolver to read
286
289
  # whole. In a segment position the . separates segments, so not there.
287
290
  _arg_sigil = pp.Regex(r'(?:\$(?![0-9($])|\^)[^' + re.escape(reserved.replace('.', '')) + r' \t\n\r]+').set_parse_action(matchers.Word)
288
- slice = pp.Optional(integer | plus) + ':' + pp.Optional(integer | plus) \
289
- + pp.Optional(':') + pp.Optional(integer | plus)
291
+ # A slice bound: an integer, + (the length), or a $$(path) reference or
292
+ # $(name)/$N substitution standing for one.
293
+ _slice_bound = integer | plus | _reference | _paren_subst | _raw_subst
294
+ slice = pp.Optional(_slice_bound) + ':' + pp.Optional(_slice_bound) \
295
+ + pp.Optional(':') + pp.Optional(_slice_bound)
290
296
 
291
297
  _common_pats = wildcard_first | wildcard | var | regex_first | regex
292
298
  _commons = bytes_literal | string | _common_pats | numeric_quoted
@@ -92,9 +92,33 @@ class Recursive(BaseOp):
92
92
  s = '.' + s
93
93
  return s
94
94
 
95
+ def _parts(self):
96
+ """
97
+ The inner match op, the accessor ops and the filters: every part
98
+ that may hold a substitution or reference.
99
+ """
100
+ yield self.inner
101
+ for item in self.accessors or ():
102
+ if item is base.BRANCH_CUT or item is base.BRANCH_SOFTCUT:
103
+ continue
104
+ yield from item
105
+ yield from self.filters
106
+
107
+ def is_template(self):
108
+ """
109
+ True if a part contains a substitution reference.
110
+ """
111
+ return any(hasattr(p, 'is_template') and p.is_template() for p in self._parts())
112
+
113
+ def is_reference(self):
114
+ """
115
+ True if a part contains an internal reference.
116
+ """
117
+ return any(hasattr(p, 'is_reference') and p.is_reference() for p in self._parts())
118
+
95
119
  def resolve(self, bindings, partial=False):
96
120
  """
97
- Resolve $N in inner match op and accessor branches.
121
+ Resolve $N in inner match op, accessor branches and filters.
98
122
  """
99
123
  new_inner = self.inner.resolve(bindings, partial) if hasattr(self.inner, 'resolve') else self.inner
100
124
  new_accessors = None
@@ -111,11 +135,16 @@ class Recursive(BaseOp):
111
135
  acc_changed = True
112
136
  new_accessors.append(new_branch)
113
137
  new_accessors = tuple(new_accessors)
114
- if new_inner is self.inner and not acc_changed:
138
+ new_filters = tuple(f.resolve(bindings, partial) for f in self.filters)
139
+ filt_changed = not all(nf is of for nf, of in zip(new_filters, self.filters))
140
+ if new_inner is self.inner and not acc_changed and not filt_changed:
115
141
  return self
116
- return Recursive(
142
+ # type(self): a RecursiveFirst stays first-match
143
+ r = type(self)(
117
144
  new_inner, accessors=new_accessors if new_accessors is not None else self.accessors,
118
145
  depth_start=self.depth_start, depth_stop=self.depth_stop, depth_step=self.depth_step)
146
+ r.filters = new_filters
147
+ return r
119
148
 
120
149
  def match(self, op, specials=False):
121
150
  return self.inner.matchable(op, specials=specials)
@@ -371,7 +400,7 @@ class Recursive(BaseOp):
371
400
  node[k] = v
372
401
  return node
373
402
 
374
- def _update_recursive(self, ops, node, val, has_defaults, _path, nop, depth=0, guard=None, seen=frozenset(), settings=base.SETTINGS):
403
+ def _update_recursive(self, ops, node, val, has_defaults, _path, nop, depth=0, guard=None, seen=frozenset(), settings=base.SETTINGS, nop_from_unwrap=False):
375
404
  node_id = id(node)
376
405
  if node_id in seen:
377
406
  return node
@@ -382,7 +411,8 @@ class Recursive(BaseOp):
382
411
 
383
412
  for acc, k, v in items:
384
413
  # Recurse first (bottom-up)
385
- v = self._update_recursive(ops, v, val, has_defaults, _path, nop, depth + 1, guard, seen, settings=settings)
414
+ v = self._update_recursive(ops, v, val, has_defaults, _path, nop, depth + 1, guard, seen, settings=settings,
415
+ nop_from_unwrap=nop_from_unwrap)
386
416
  node = self._assign(acc, node, k, v)
387
417
  if self.filters and not any(True for _ in self.filtered((v,), settings)):
388
418
  continue
@@ -392,13 +422,15 @@ class Recursive(BaseOp):
392
422
  if guard and not guard(v):
393
423
  continue
394
424
  if ops:
395
- node = self._assign(acc, node, k, engine.updates(ops, v, val, has_defaults, _path + [(self, k)], nop, settings=settings))
425
+ # a ~ on this op holds back only this op, not the ops after it
426
+ pass_nop = nop and not nop_from_unwrap
427
+ node = self._assign(acc, node, k, engine.updates(ops, v, val, has_defaults, _path + [(self, k)], pass_nop, settings=settings))
396
428
  elif not nop:
397
429
  node = self._assign(acc, node, k, val)
398
430
  return node
399
431
 
400
- def do_update(self, ops, node, val, has_defaults, _path, nop, settings=base.SETTINGS):
401
- return self._update_recursive(ops, node, val, has_defaults, _path, nop, settings=settings)
432
+ def do_update(self, ops, node, val, has_defaults, _path, nop, nop_from_unwrap=False, settings=base.SETTINGS):
433
+ return self._update_recursive(ops, node, val, has_defaults, _path, nop, settings=settings, nop_from_unwrap=nop_from_unwrap)
402
434
 
403
435
  def _remove_recursive(self, ops, node, val, nop, depth=0, guard=None, seen=frozenset(), settings=base.SETTINGS):
404
436
  node_id = id(node)
@@ -264,16 +264,23 @@ def guard_matcher(guard, settings=base.SETTINGS, node=None, parents=(), position
264
264
  $$(^x) looks x up in it). A plain value is resolved too when the
265
265
  resolver takes literals at position, 'guards' or 'filters'. A reference
266
266
  path that is a pattern becomes a value group of what it resolves to.
267
+ A container value resolves the references inside it the same way; one
268
+ that is a pattern branches the container, so it becomes a value group
269
+ of the container once per value (see containers.ContainerOp).
267
270
  Raises UnresolvedReference when the reference does not resolve.
268
271
  """
269
- # container values (ContainerList and kin) are ops without is_reference
270
272
  reference = hasattr(guard, 'is_reference') and guard.is_reference()
271
273
  if not (reference or (settings.literals(position) and isinstance(guard, Const))):
272
274
  return guard
275
+ from . import containers
276
+ if isinstance(guard, containers.ContainerOp):
277
+ branches = guard.branches(settings, node, parents)
278
+ if len(branches) == 1:
279
+ return branches[0]
280
+ return containers.ValueGroup(*branches)
273
281
  val = settings.resolve(guard, node, parents)
274
282
  if not guard.is_pattern():
275
283
  return Const(val)
276
- from . import containers
277
284
  return containers.ValueGroup(*(Const(v) for v in val))
278
285
 
279
286
 
@@ -314,7 +314,7 @@ class TransformWrap(Wrap):
314
314
  val = self.apply(val, settings)
315
315
  except base.UnresolvedReference:
316
316
  return node
317
- return self.inner.do_update(ops, node, val, has_defaults, _path, nop, settings=settings)
317
+ return self.inner.do_update(ops, node, val, has_defaults, _path, nop, nop_from_unwrap=nop_from_unwrap, settings=settings)
318
318
 
319
319
  def do_remove(self, ops, node, val, nop, settings=base.SETTINGS):
320
320
  return self.inner.do_remove(ops, node, val, nop, settings=settings)
@@ -543,7 +543,8 @@ class ValueGuard(Wrap):
543
543
  nop = self._nop(nop)
544
544
  if self.inner.is_recursive():
545
545
  return self.inner._update_recursive(
546
- ops, node, val, has_defaults, _path, nop, guard=self._guard(settings), settings=settings)
546
+ ops, node, val, has_defaults, _path, nop, guard=self._guard(settings), settings=settings,
547
+ nop_from_unwrap=nop_from_unwrap)
547
548
  if not ops and not nop:
548
549
  # the leaf: upsert() has no settings, and the guard needs them
549
550
  for k in list(self.keys(node, settings=settings)):
@@ -788,8 +789,9 @@ class FilterWrap(Wrap):
788
789
  return self
789
790
  return FilterWrap(new_inner, new_filters)
790
791
 
791
- def do_update(self, ops, node, val, has_defaults, _path, nop, settings=base.SETTINGS):
792
- return access.BaseOp.do_update(self, ops, node, val, has_defaults, _path, nop, settings=settings)
792
+ def do_update(self, ops, node, val, has_defaults, _path, nop, nop_from_unwrap=False, settings=base.SETTINGS):
793
+ return access.BaseOp.do_update(
794
+ self, ops, node, val, has_defaults, _path, nop, nop_from_unwrap=nop_from_unwrap, settings=settings)
793
795
 
794
796
  def do_remove(self, ops, node, val, nop, settings=base.SETTINGS):
795
797
  return access.BaseOp.do_remove(self, ops, node, val, nop, settings=settings)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotted_notation
3
- Version: 0.49.2
3
+ Version: 0.49.4
4
4
  Summary: Dotted notation for safe nested data traversal with optional chaining, pattern matching, and transforms
5
5
  Author-email: Frey Waid <logophage1@gmail.com>
6
6
  License-Expression: MIT
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.11
21
21
  Classifier: Programming Language :: Python :: 3.12
22
22
  Classifier: Programming Language :: Python :: 3.13
23
23
  Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Programming Language :: Python :: 3.15
24
25
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
26
  Classifier: Topic :: Utilities
26
27
  Requires-Python: >=3.6
@@ -1163,6 +1164,14 @@ Dotted slicing works like python slicing and all that entails.
1163
1164
  >>> dotted.get(d, '*.there[1:]')
1164
1165
  ([2, 3], [5, 6])
1165
1166
 
1167
+ A bound may be a reference or a substitution, resolved the way a slot's is:
1168
+
1169
+ >>> d = {'b': [3, 0, 5], 'i': 2}
1170
+ >>> dotted.get(d, 'b[:$$(i)]')
1171
+ [3, 0]
1172
+ >>> dotted.get(d, 'b[$(n):]', bindings={'n': 1})
1173
+ [0, 5]
1174
+
1166
1175
  <a id="dot-notation-for-sequence-indexing"></a>
1167
1176
  ### Dot notation for sequence indexing
1168
1177
 
@@ -1641,6 +1650,23 @@ as do the transforms before a guard:
1641
1650
  >>> dotted.get(data, '*|mul:$$(scale)=20')
1642
1651
  (2,)
1643
1652
 
1653
+ So does a reference inside a container value. One whose path is a pattern
1654
+ branches the container, once per value; pack it, `(…;)`, to compare with the
1655
+ tuple instead:
1656
+
1657
+ >>> data = {'want': [2, 3], 'items': [[1, 2], [1, 4], [1, (2, 3)]]}
1658
+ >>> dotted.get(data, 'items[*]=[1, $$(want[*])]')
1659
+ ([1, 2],)
1660
+ >>> dotted.get(data, 'items[*]=[1, $$((want[*];))]')
1661
+ ([1, (2, 3)],)
1662
+
1663
+ A filter key may hold references too. In each part of the key, `^` is the
1664
+ node that part is looked up in, so in the first part it is the item:
1665
+
1666
+ >>> data = {'field': 'name', 'users': [{'name': 'alice'}, {'name': 'bob'}]}
1667
+ >>> dotted.get(data, 'users[*&$$(field)="bob"]')
1668
+ ({'name': 'bob'},)
1669
+
1644
1670
  With `update`, the transforms apply to the value being set, so `^` is that
1645
1671
  value:
1646
1672
 
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "dotted_notation"
9
- version = "0.49.2"
9
+ version = "0.49.4"
10
10
  description = "Dotted notation for safe nested data traversal with optional chaining, pattern matching, and transforms"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.6"
@@ -38,6 +38,7 @@ classifiers = [
38
38
  "Programming Language :: Python :: 3.12",
39
39
  "Programming Language :: Python :: 3.13",
40
40
  "Programming Language :: Python :: 3.14",
41
+ "Programming Language :: Python :: 3.15",
41
42
  "Topic :: Software Development :: Libraries :: Python Modules",
42
43
  "Topic :: Utilities",
43
44
  ]
@@ -78,7 +79,7 @@ Changelog = "https://github.com/freywaid/dotted/blob/master/CHANGELOG.md"
78
79
  include = ["dotted*"]
79
80
 
80
81
  [tool.cibuildwheel]
81
- # Compiled wheels for CPython 3.11-3.14 (the versions are listed in the
82
+ # Compiled wheels for CPython 3.11-3.15 (the versions are listed in the
82
83
  # publish workflow, one job each); everything else installs the pure wheel.
83
84
  skip = "pp*"
84
85
  test-requires = "pytest"
@@ -64,4 +64,7 @@ class build_ext(_build_ext):
64
64
  self.parallel = os.cpu_count() or 1
65
65
 
66
66
 
67
- setup(ext_modules=extensions(), cmdclass={'build_ext': build_ext})
67
+ # Guarded: cythonize compiles in a process pool, and a spawned worker
68
+ # (macOS, and Linux from Python 3.14) imports this file again.
69
+ if __name__ == '__main__':
70
+ setup(ext_modules=extensions(), cmdclass={'build_ext': build_ext})