dotted-notation 0.41.0__tar.gz → 0.41.2__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 (72) hide show
  1. {dotted_notation-0.41.0/dotted_notation.egg-info → dotted_notation-0.41.2}/PKG-INFO +39 -5
  2. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/README.md +38 -4
  3. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/base.py +8 -1
  4. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/engine.py +1 -1
  5. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/grammar.py +16 -4
  6. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/matchers.py +36 -34
  7. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/wrappers.py +16 -0
  8. {dotted_notation-0.41.0 → dotted_notation-0.41.2/dotted_notation.egg-info}/PKG-INFO +39 -5
  9. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted_notation.egg-info/SOURCES.txt +1 -0
  10. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/setup.py +1 -1
  11. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_named_subst.py +4 -4
  12. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_replace.py +10 -10
  13. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_subst_escape.py +2 -2
  14. dotted_notation-0.41.2/tests/test_subst_transforms.py +173 -0
  15. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/LICENSE +0 -0
  16. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/__init__.py +0 -0
  17. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/__main__.py +0 -0
  18. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/access.py +0 -0
  19. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/api.py +0 -0
  20. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/cli/__init__.py +0 -0
  21. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/cli/_compat.py +0 -0
  22. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/cli/formats.py +0 -0
  23. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/cli/main.py +0 -0
  24. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/containers.py +0 -0
  25. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/filters.py +0 -0
  26. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/groups.py +0 -0
  27. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/predicates.py +0 -0
  28. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/recursive.py +0 -0
  29. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/results.py +0 -0
  30. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/transforms.py +0 -0
  31. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/utils.py +0 -0
  32. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted/utypes.py +0 -0
  33. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted_notation.egg-info/dependency_links.txt +0 -0
  34. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted_notation.egg-info/entry_points.txt +0 -0
  35. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted_notation.egg-info/requires.txt +0 -0
  36. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/dotted_notation.egg-info/top_level.txt +0 -0
  37. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/setup.cfg +0 -0
  38. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/__init__.py +0 -0
  39. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_api.py +0 -0
  40. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_appender.py +0 -0
  41. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_assemble.py +0 -0
  42. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_attrs.py +0 -0
  43. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_cli.py +0 -0
  44. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_container_filter.py +0 -0
  45. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_cut.py +0 -0
  46. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_empty.py +0 -0
  47. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_filter_keyvalue.py +0 -0
  48. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_get.py +0 -0
  49. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_guard_transforms.py +0 -0
  50. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_invert.py +0 -0
  51. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_keys_values.py +0 -0
  52. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_match.py +0 -0
  53. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_negation.py +0 -0
  54. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_nop.py +0 -0
  55. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_numeric.py +0 -0
  56. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_opgroup.py +0 -0
  57. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_pluck.py +0 -0
  58. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_predicates.py +0 -0
  59. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_quote_idempotent.py +0 -0
  60. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_recursive.py +0 -0
  61. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_reference.py +0 -0
  62. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_slice.py +0 -0
  63. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_softcut.py +0 -0
  64. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_strict.py +0 -0
  65. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_string_glob.py +0 -0
  66. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_transforms.py +0 -0
  67. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_translate.py +0 -0
  68. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_type_restriction.py +0 -0
  69. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_unpack.py +0 -0
  70. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_update.py +0 -0
  71. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_update_if.py +0 -0
  72. {dotted_notation-0.41.0 → dotted_notation-0.41.2}/tests/test_value_guard.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotted_notation
3
- Version: 0.41.0
3
+ Version: 0.41.2
4
4
  Summary: Dotted notation for safe nested data traversal with optional chaining, pattern matching, and transforms
5
5
  Home-page: https://github.com/freywaid/dotted
6
6
  Author: Frey Waid
@@ -111,6 +111,7 @@ Or pick only what you need:
111
111
  - [Slicing vs Patterns](#slicing-vs-patterns)
112
112
  - [Substitutions and References](#substitutions-and-references)
113
113
  - [Substitution](#substitution)
114
+ - [Substitution transforms](#substitution-transforms)
114
115
  - [References](#references)
115
116
  - [Relative References](#relative-references)
116
117
  - [Escaping](#escaping)
@@ -462,7 +463,7 @@ excluding literals:
462
463
  Substitute placeholders in a template path with bound values. Template paths
463
464
  are validated at parse time — structural errors are caught immediately, not at runtime.
464
465
 
465
- Positional (`$N`) placeholders resolve against a tuple:
466
+ Positional (`$N`) placeholders resolve against a list or tuple:
466
467
 
467
468
  >>> import dotted
468
469
  >>> dotted.replace('people.$1.$2', ('users', 'alice', 'age'))
@@ -473,6 +474,11 @@ Named (`$(name)`) placeholders resolve against a dict:
473
474
  >>> dotted.replace('$(table).$(key)', {'table': 'users', 'key': 'alice'})
474
475
  'users.alice'
475
476
 
477
+ Placeholders support transforms inside the parenthesized form:
478
+
479
+ >>> dotted.replace('$(0|uppercase)', ['hello'])
480
+ 'HELLO'
481
+
476
482
  Combine with `match` to remap paths — capture groups from one pattern and substitute
477
483
  into another:
478
484
 
@@ -1167,8 +1173,10 @@ at replace time) and **references** (resolved during traversal).
1167
1173
 
1168
1174
  | Syntax | Type | Resolved against |
1169
1175
  |---|---|---|
1170
- | `$0`, `$1` | Positional substitution | `replace()` bindings |
1171
- | `$(name)` | Named substitution | `replace()` bindings |
1176
+ | `$0`, `$1` | Positional substitution | `replace()` bindings (list/tuple) |
1177
+ | `$(name)` | Named substitution | `replace()` bindings (dict) |
1178
+ | `$(0)`, `$(name)` | Substitution with parens | `replace()` bindings via `__getitem__` |
1179
+ | `$(name\|int)` | Substitution with transform | `replace()` bindings, then transform |
1172
1180
  | `$$(path)` | Reference | Root object during traversal |
1173
1181
  | `$$(^path)` | Relative reference | Current node during traversal |
1174
1182
  | `$$(^^path)` | Relative reference | Parent node during traversal |
@@ -1178,7 +1186,7 @@ at replace time) and **references** (resolved during traversal).
1178
1186
 
1179
1187
  Substitution references turn a path into a **template**. There are two forms:
1180
1188
 
1181
- - **Positional** (`$0`, `$1`, …) — resolved against a tuple of values
1189
+ - **Positional** (`$0`, `$1`, …) — resolved against a list or tuple
1182
1190
  - **Named** (`$(name)`, `$(key)`, …) — resolved against a dict
1183
1191
 
1184
1192
  The `replace` function resolves them:
@@ -1188,6 +1196,13 @@ The `replace` function resolves them:
1188
1196
  >>> dotted.replace('$(table).$(field)', {'table': 'users', 'field': 'email'})
1189
1197
  'users.email'
1190
1198
 
1199
+ The parenthesized form `$(N)` adapts to the binding type — it uses `__getitem__`,
1200
+ so `$(0)` works as a positional index against a list or as a numeric key against
1201
+ a dict:
1202
+
1203
+ >>> dotted.replace('$(0)', {0: 'zero'})
1204
+ 'zero'
1205
+
1191
1206
  Use `is_template` to test whether a path contains substitution references:
1192
1207
 
1193
1208
  >>> dotted.is_template('a.$0')
@@ -1197,6 +1212,25 @@ Use `is_template` to test whether a path contains substitution references:
1197
1212
  >>> dotted.is_template('a.b')
1198
1213
  False
1199
1214
 
1215
+ #### Substitution transforms
1216
+
1217
+ Substitutions support per-substitution transforms using the `|` separator inside
1218
+ the parenthesized form. The transform is applied to the resolved value before it
1219
+ is spliced into the path:
1220
+
1221
+ >>> dotted.replace('$(name|uppercase)', {'name': 'hello'})
1222
+ 'HELLO'
1223
+ >>> dotted.replace('$(0|str)', [42])
1224
+ '42'
1225
+
1226
+ Multiple transforms chain left to right:
1227
+
1228
+ >>> dotted.replace('$(name|strip|lowercase)', {'name': ' HELLO '})
1229
+ 'hello'
1230
+
1231
+ All [built-in transforms](#built-in-transforms) are available. The bare `$N` form
1232
+ does not support transforms — use `$(N|transform)` instead.
1233
+
1200
1234
  See [Replace](#replace) and [Translate](#translate) for full API details.
1201
1235
 
1202
1236
  <a id="references"></a>
@@ -74,6 +74,7 @@ Or pick only what you need:
74
74
  - [Slicing vs Patterns](#slicing-vs-patterns)
75
75
  - [Substitutions and References](#substitutions-and-references)
76
76
  - [Substitution](#substitution)
77
+ - [Substitution transforms](#substitution-transforms)
77
78
  - [References](#references)
78
79
  - [Relative References](#relative-references)
79
80
  - [Escaping](#escaping)
@@ -425,7 +426,7 @@ excluding literals:
425
426
  Substitute placeholders in a template path with bound values. Template paths
426
427
  are validated at parse time — structural errors are caught immediately, not at runtime.
427
428
 
428
- Positional (`$N`) placeholders resolve against a tuple:
429
+ Positional (`$N`) placeholders resolve against a list or tuple:
429
430
 
430
431
  >>> import dotted
431
432
  >>> dotted.replace('people.$1.$2', ('users', 'alice', 'age'))
@@ -436,6 +437,11 @@ Named (`$(name)`) placeholders resolve against a dict:
436
437
  >>> dotted.replace('$(table).$(key)', {'table': 'users', 'key': 'alice'})
437
438
  'users.alice'
438
439
 
440
+ Placeholders support transforms inside the parenthesized form:
441
+
442
+ >>> dotted.replace('$(0|uppercase)', ['hello'])
443
+ 'HELLO'
444
+
439
445
  Combine with `match` to remap paths — capture groups from one pattern and substitute
440
446
  into another:
441
447
 
@@ -1130,8 +1136,10 @@ at replace time) and **references** (resolved during traversal).
1130
1136
 
1131
1137
  | Syntax | Type | Resolved against |
1132
1138
  |---|---|---|
1133
- | `$0`, `$1` | Positional substitution | `replace()` bindings |
1134
- | `$(name)` | Named substitution | `replace()` bindings |
1139
+ | `$0`, `$1` | Positional substitution | `replace()` bindings (list/tuple) |
1140
+ | `$(name)` | Named substitution | `replace()` bindings (dict) |
1141
+ | `$(0)`, `$(name)` | Substitution with parens | `replace()` bindings via `__getitem__` |
1142
+ | `$(name\|int)` | Substitution with transform | `replace()` bindings, then transform |
1135
1143
  | `$$(path)` | Reference | Root object during traversal |
1136
1144
  | `$$(^path)` | Relative reference | Current node during traversal |
1137
1145
  | `$$(^^path)` | Relative reference | Parent node during traversal |
@@ -1141,7 +1149,7 @@ at replace time) and **references** (resolved during traversal).
1141
1149
 
1142
1150
  Substitution references turn a path into a **template**. There are two forms:
1143
1151
 
1144
- - **Positional** (`$0`, `$1`, …) — resolved against a tuple of values
1152
+ - **Positional** (`$0`, `$1`, …) — resolved against a list or tuple
1145
1153
  - **Named** (`$(name)`, `$(key)`, …) — resolved against a dict
1146
1154
 
1147
1155
  The `replace` function resolves them:
@@ -1151,6 +1159,13 @@ The `replace` function resolves them:
1151
1159
  >>> dotted.replace('$(table).$(field)', {'table': 'users', 'field': 'email'})
1152
1160
  'users.email'
1153
1161
 
1162
+ The parenthesized form `$(N)` adapts to the binding type — it uses `__getitem__`,
1163
+ so `$(0)` works as a positional index against a list or as a numeric key against
1164
+ a dict:
1165
+
1166
+ >>> dotted.replace('$(0)', {0: 'zero'})
1167
+ 'zero'
1168
+
1154
1169
  Use `is_template` to test whether a path contains substitution references:
1155
1170
 
1156
1171
  >>> dotted.is_template('a.$0')
@@ -1160,6 +1175,25 @@ Use `is_template` to test whether a path contains substitution references:
1160
1175
  >>> dotted.is_template('a.b')
1161
1176
  False
1162
1177
 
1178
+ #### Substitution transforms
1179
+
1180
+ Substitutions support per-substitution transforms using the `|` separator inside
1181
+ the parenthesized form. The transform is applied to the resolved value before it
1182
+ is spliced into the path:
1183
+
1184
+ >>> dotted.replace('$(name|uppercase)', {'name': 'hello'})
1185
+ 'HELLO'
1186
+ >>> dotted.replace('$(0|str)', [42])
1187
+ '42'
1188
+
1189
+ Multiple transforms chain left to right:
1190
+
1191
+ >>> dotted.replace('$(name|strip|lowercase)', {'name': ' HELLO '})
1192
+ 'hello'
1193
+
1194
+ All [built-in transforms](#built-in-transforms) are available. The bare `$N` form
1195
+ does not support transforms — use `$(N|transform)` instead.
1196
+
1163
1197
  See [Replace](#replace) and [Translate](#translate) for full API details.
1164
1198
 
1165
1199
  <a id="references"></a>
@@ -65,7 +65,7 @@ class Op:
65
65
  return self.__class__ == op.__class__ and self.args == op.args
66
66
  def resolve(self, bindings, partial=False):
67
67
  """
68
- Return a new op with all PositionalSubst resolved.
68
+ Return a new op with all substitutions resolved.
69
69
  Default: return self (no substitutions).
70
70
  """
71
71
  return self
@@ -149,6 +149,13 @@ class TraversalOp(Op):
149
149
  Base class for ops that participate in stack-based traversal.
150
150
  Subclasses must implement push_children(stack, frame, paths).
151
151
  """
152
+ @property
153
+ def most_inner(self):
154
+ """
155
+ Return self — no wrapping to unwrap.
156
+ """
157
+ return self
158
+
152
159
  def to_branches(self):
153
160
  return [tuple([self])]
154
161
 
@@ -18,7 +18,7 @@ def _needs_parents(ops):
18
18
  (parent or higher), requiring _parents tracking during traversal.
19
19
  """
20
20
  for op in ops:
21
- inner = op.inner if isinstance(op, wrappers.Wrap) else op
21
+ inner = op.most_inner
22
22
  if (hasattr(inner, 'is_reference') and inner.is_reference()
23
23
  and inner.op.depth >= 2):
24
24
  return True
@@ -84,11 +84,23 @@ _escaped_dollar = pp.Regex(r'\\\$\${0,2}(\([^)]*\)|[0-9]*)').set_parse_action(
84
84
  lambda t: matchers.Word(t[0][1:]))
85
85
  _reference = pp.Regex(r'\$\$\([^)]+\)').set_parse_action(
86
86
  lambda t: matchers.Reference(t[0][3:-1]))
87
- _named_subst = pp.Regex(r'\$\([a-zA-Z_]\w*\)').set_parse_action(
88
- lambda t: matchers.NamedSubst(t[0][2:-1]))
87
+ def _paren_subst_action(t):
88
+ """
89
+ Parse $(content) where content is name_or_index[|transform1|transform2...].
90
+ """
91
+ content = t[0][2:-1] # strip $( and )
92
+ parts = content.split('|')
93
+ name = parts[0]
94
+ xforms = tuple(base.Transform(p) for p in parts[1:]) if len(parts) > 1 else ()
95
+ try:
96
+ return matchers.Subst(int(name), transforms=xforms)
97
+ except ValueError:
98
+ return matchers.Subst(name, transforms=xforms)
99
+
100
+ _paren_subst = pp.Regex(r'\$\([^)]+\)').set_parse_action(_paren_subst_action)
89
101
  _raw_subst = pp.Regex(r'\$[0-9]+').set_parse_action(
90
- lambda t: matchers.PositionalSubst(int(t[0][1:])))
91
- subst = _escaped_dollar | _reference | _named_subst | _raw_subst
102
+ lambda t: matchers.Subst(int(t[0][1:])))
103
+ subst = _escaped_dollar | _reference | _paren_subst | _raw_subst
92
104
  slice = pp.Optional(integer | plus) + ':' + pp.Optional(integer | plus) \
93
105
  + pp.Optional(':') + pp.Optional(integer | plus)
94
106
 
@@ -147,8 +147,15 @@ class Pattern(MatchOp):
147
147
 
148
148
  class Subst(Pattern):
149
149
  """
150
- Base class for substitution ops ($0, $(name), etc.).
150
+ Substitution op: $0, $(name), $(0|transform), $(name|int), etc.
151
+ Resolves against bindings via __getitem__: list for positional,
152
+ dict for named or numeric keys. Optional transforms applied
153
+ after lookup.
151
154
  """
155
+ def __init__(self, *args, transforms=(), **kwargs):
156
+ super().__init__(*args, **kwargs)
157
+ self.transforms = tuple(transforms)
158
+
152
159
  @property
153
160
  def value(self):
154
161
  return self.args[0]
@@ -162,47 +169,42 @@ class Subst(Pattern):
162
169
  def matchable(self, op, specials=False):
163
170
  return False
164
171
 
165
-
166
- class PositionalSubst(Subst):
167
- """
168
- Substitution op for captured match groups: $0, $1, $2, etc.
169
- """
170
- def resolve(self, bindings, partial=False):
172
+ def _apply_transforms(self, val):
171
173
  """
172
- Resolve this substitution against bindings.
173
- Returns ResolvedValue on success, self if partial and out of range,
174
- or raises IndexError if not partial and out of range.
174
+ Apply this op's transforms to a resolved value.
175
175
  """
176
- idx = self.value
177
- if idx < len(bindings):
178
- return ResolvedValue(str(bindings[idx]))
179
- if partial:
180
- return self
181
- raise IndexError(
182
- f'${idx} out of range ({len(bindings)} bindings)')
183
- def __repr__(self):
184
- return f'${self.args[0]}'
176
+ if not self.transforms:
177
+ return val
178
+ from .results import apply_transforms
179
+ return apply_transforms(val, self.transforms)
185
180
 
181
+ def _transform_suffix(self):
182
+ """
183
+ Render |transform1|transform2 suffix for quote/repr.
184
+ """
185
+ if not self.transforms:
186
+ return ''
187
+ return ''.join(f'|{t.operator()}' for t in self.transforms)
186
188
 
187
- class NamedSubst(Subst):
188
- """
189
- Substitution op for named bindings: $(name), $(key), etc.
190
- """
191
189
  def resolve(self, bindings, partial=False):
192
190
  """
193
- Resolve this substitution against a dict of bindings.
194
- Returns ResolvedValue on success, self if partial and missing,
195
- or raises KeyError if not partial and missing.
191
+ Resolve this substitution against bindings.
196
192
  """
197
- name = self.value
198
- if name in bindings:
199
- return ResolvedValue(str(bindings[name]))
200
- if partial:
201
- return self
202
- raise KeyError(
203
- f'$({name}) not found in bindings')
193
+ try:
194
+ val = self._apply_transforms(bindings[self.value])
195
+ return ResolvedValue(str(val))
196
+ except (KeyError, IndexError, TypeError):
197
+ if partial:
198
+ return self
199
+ raise
200
+
204
201
  def __repr__(self):
205
- return f'$({self.args[0]})'
202
+ suffix = self._transform_suffix()
203
+ v = self.args[0]
204
+ if isinstance(v, int) and not suffix:
205
+ return f'${v}'
206
+ return f'$({v}{suffix})'
207
+
206
208
 
207
209
 
208
210
  class Reference(MatchOp):
@@ -21,6 +21,16 @@ class Wrap(base.TraversalOp):
21
21
 
22
22
  inner = None # subclasses set in __init__
23
23
 
24
+ @property
25
+ def most_inner(self):
26
+ """
27
+ Unwrap through nested Wraps to the innermost op.
28
+ """
29
+ op = self.inner
30
+ while isinstance(op, Wrap):
31
+ op = op.inner
32
+ return op
33
+
24
34
  def is_pattern(self):
25
35
  return self.inner.is_pattern() if hasattr(self.inner, 'is_pattern') else False
26
36
 
@@ -30,6 +40,12 @@ class Wrap(base.TraversalOp):
30
40
  """
31
41
  return self.inner.is_template() if hasattr(self.inner, 'is_template') else False
32
42
 
43
+ def is_reference(self):
44
+ """
45
+ Delegate to inner op.
46
+ """
47
+ return self.inner.is_reference() if hasattr(self.inner, 'is_reference') else False
48
+
33
49
  def default(self):
34
50
  return self.inner.default() if hasattr(self.inner, 'default') else {}
35
51
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotted_notation
3
- Version: 0.41.0
3
+ Version: 0.41.2
4
4
  Summary: Dotted notation for safe nested data traversal with optional chaining, pattern matching, and transforms
5
5
  Home-page: https://github.com/freywaid/dotted
6
6
  Author: Frey Waid
@@ -111,6 +111,7 @@ Or pick only what you need:
111
111
  - [Slicing vs Patterns](#slicing-vs-patterns)
112
112
  - [Substitutions and References](#substitutions-and-references)
113
113
  - [Substitution](#substitution)
114
+ - [Substitution transforms](#substitution-transforms)
114
115
  - [References](#references)
115
116
  - [Relative References](#relative-references)
116
117
  - [Escaping](#escaping)
@@ -462,7 +463,7 @@ excluding literals:
462
463
  Substitute placeholders in a template path with bound values. Template paths
463
464
  are validated at parse time — structural errors are caught immediately, not at runtime.
464
465
 
465
- Positional (`$N`) placeholders resolve against a tuple:
466
+ Positional (`$N`) placeholders resolve against a list or tuple:
466
467
 
467
468
  >>> import dotted
468
469
  >>> dotted.replace('people.$1.$2', ('users', 'alice', 'age'))
@@ -473,6 +474,11 @@ Named (`$(name)`) placeholders resolve against a dict:
473
474
  >>> dotted.replace('$(table).$(key)', {'table': 'users', 'key': 'alice'})
474
475
  'users.alice'
475
476
 
477
+ Placeholders support transforms inside the parenthesized form:
478
+
479
+ >>> dotted.replace('$(0|uppercase)', ['hello'])
480
+ 'HELLO'
481
+
476
482
  Combine with `match` to remap paths — capture groups from one pattern and substitute
477
483
  into another:
478
484
 
@@ -1167,8 +1173,10 @@ at replace time) and **references** (resolved during traversal).
1167
1173
 
1168
1174
  | Syntax | Type | Resolved against |
1169
1175
  |---|---|---|
1170
- | `$0`, `$1` | Positional substitution | `replace()` bindings |
1171
- | `$(name)` | Named substitution | `replace()` bindings |
1176
+ | `$0`, `$1` | Positional substitution | `replace()` bindings (list/tuple) |
1177
+ | `$(name)` | Named substitution | `replace()` bindings (dict) |
1178
+ | `$(0)`, `$(name)` | Substitution with parens | `replace()` bindings via `__getitem__` |
1179
+ | `$(name\|int)` | Substitution with transform | `replace()` bindings, then transform |
1172
1180
  | `$$(path)` | Reference | Root object during traversal |
1173
1181
  | `$$(^path)` | Relative reference | Current node during traversal |
1174
1182
  | `$$(^^path)` | Relative reference | Parent node during traversal |
@@ -1178,7 +1186,7 @@ at replace time) and **references** (resolved during traversal).
1178
1186
 
1179
1187
  Substitution references turn a path into a **template**. There are two forms:
1180
1188
 
1181
- - **Positional** (`$0`, `$1`, …) — resolved against a tuple of values
1189
+ - **Positional** (`$0`, `$1`, …) — resolved against a list or tuple
1182
1190
  - **Named** (`$(name)`, `$(key)`, …) — resolved against a dict
1183
1191
 
1184
1192
  The `replace` function resolves them:
@@ -1188,6 +1196,13 @@ The `replace` function resolves them:
1188
1196
  >>> dotted.replace('$(table).$(field)', {'table': 'users', 'field': 'email'})
1189
1197
  'users.email'
1190
1198
 
1199
+ The parenthesized form `$(N)` adapts to the binding type — it uses `__getitem__`,
1200
+ so `$(0)` works as a positional index against a list or as a numeric key against
1201
+ a dict:
1202
+
1203
+ >>> dotted.replace('$(0)', {0: 'zero'})
1204
+ 'zero'
1205
+
1191
1206
  Use `is_template` to test whether a path contains substitution references:
1192
1207
 
1193
1208
  >>> dotted.is_template('a.$0')
@@ -1197,6 +1212,25 @@ Use `is_template` to test whether a path contains substitution references:
1197
1212
  >>> dotted.is_template('a.b')
1198
1213
  False
1199
1214
 
1215
+ #### Substitution transforms
1216
+
1217
+ Substitutions support per-substitution transforms using the `|` separator inside
1218
+ the parenthesized form. The transform is applied to the resolved value before it
1219
+ is spliced into the path:
1220
+
1221
+ >>> dotted.replace('$(name|uppercase)', {'name': 'hello'})
1222
+ 'HELLO'
1223
+ >>> dotted.replace('$(0|str)', [42])
1224
+ '42'
1225
+
1226
+ Multiple transforms chain left to right:
1227
+
1228
+ >>> dotted.replace('$(name|strip|lowercase)', {'name': ' HELLO '})
1229
+ 'hello'
1230
+
1231
+ All [built-in transforms](#built-in-transforms) are available. The bare `$N` form
1232
+ does not support transforms — use `$(N|transform)` instead.
1233
+
1200
1234
  See [Replace](#replace) and [Translate](#translate) for full API details.
1201
1235
 
1202
1236
  <a id="references"></a>
@@ -60,6 +60,7 @@ tests/test_softcut.py
60
60
  tests/test_strict.py
61
61
  tests/test_string_glob.py
62
62
  tests/test_subst_escape.py
63
+ tests/test_subst_transforms.py
63
64
  tests/test_transforms.py
64
65
  tests/test_translate.py
65
66
  tests/test_type_restriction.py
@@ -5,7 +5,7 @@ with open("README.md", "rt") as f:
5
5
 
6
6
  setuptools.setup(
7
7
  name="dotted_notation",
8
- version="0.41.0",
8
+ version="0.41.2",
9
9
  author="Frey Waid",
10
10
  author_email="logophage1@gmail.com",
11
11
  description="Dotted notation for safe nested data traversal with optional chaining, pattern matching, and transforms",
@@ -4,27 +4,27 @@ Tests for $(name) named substitutions.
4
4
  import pytest
5
5
  import dotted
6
6
  from dotted.api import parse, assemble, replace, quote
7
- from dotted.matchers import NamedSubst, Word
7
+ from dotted.matchers import Subst, Word
8
8
 
9
9
 
10
10
  # ---- parsing ----
11
11
 
12
12
  def test_parse_named_subst():
13
13
  ops = parse('$(name)')
14
- assert isinstance(ops[0].op, NamedSubst)
14
+ assert isinstance(ops[0].op, Subst)
15
15
  assert ops[0].op.value == 'name'
16
16
 
17
17
 
18
18
  def test_parse_named_subst_underscore():
19
19
  ops = parse('$(my_key)')
20
- assert isinstance(ops[0].op, NamedSubst)
20
+ assert isinstance(ops[0].op, Subst)
21
21
  assert ops[0].op.value == 'my_key'
22
22
 
23
23
 
24
24
  def test_parse_named_subst_nested():
25
25
  ops = parse('a.$(key).b')
26
26
  assert len(ops) == 3
27
- assert isinstance(ops[1].op, NamedSubst)
27
+ assert isinstance(ops[1].op, Subst)
28
28
  assert ops[1].op.value == 'key'
29
29
 
30
30
 
@@ -5,7 +5,7 @@ import pytest
5
5
  import dotted
6
6
  from dotted.api import parse
7
7
  from dotted.access import Key, Attr, Slot
8
- from dotted.matchers import PositionalSubst
8
+ from dotted.matchers import Subst
9
9
  from dotted.results import assemble
10
10
 
11
11
 
@@ -17,7 +17,7 @@ def test_parse_subst_key_position():
17
17
  ops = parse('a.$0')
18
18
  assert len(ops) == 2
19
19
  assert isinstance(ops[1], Key)
20
- assert isinstance(ops[1].op, PositionalSubst)
20
+ assert isinstance(ops[1].op, Subst)
21
21
  assert ops[1].op.value == 0
22
22
 
23
23
 
@@ -25,7 +25,7 @@ def test_parse_subst_slot_position():
25
25
  ops = parse('a[$0]')
26
26
  assert len(ops) == 2
27
27
  assert isinstance(ops[1], Slot)
28
- assert isinstance(ops[1].op, PositionalSubst)
28
+ assert isinstance(ops[1].op, Subst)
29
29
  assert ops[1].op.value == 0
30
30
 
31
31
 
@@ -33,15 +33,15 @@ def test_parse_subst_attr_position():
33
33
  ops = parse('$0@$1')
34
34
  assert len(ops) == 2
35
35
  assert isinstance(ops[0], Key)
36
- assert isinstance(ops[0].op, PositionalSubst)
36
+ assert isinstance(ops[0].op, Subst)
37
37
  assert isinstance(ops[1], Attr)
38
- assert isinstance(ops[1].op, PositionalSubst)
38
+ assert isinstance(ops[1].op, Subst)
39
39
  assert ops[1].op.value == 1
40
40
 
41
41
 
42
42
  def test_parse_subst_multi_digit():
43
43
  ops = parse('$10')
44
- assert isinstance(ops[0].op, PositionalSubst)
44
+ assert isinstance(ops[0].op, Subst)
45
45
  assert ops[0].op.value == 10
46
46
 
47
47
 
@@ -99,22 +99,22 @@ def test_parse_subst_container_value():
99
99
 
100
100
 
101
101
  # ---------------------------------------------------------------------------
102
- # PositionalSubst.resolve
102
+ # Subst.resolve
103
103
  # ---------------------------------------------------------------------------
104
104
 
105
105
  def test_resolve_in_range():
106
- s = PositionalSubst(2)
106
+ s = Subst(2)
107
107
  r = s.resolve(('a', 'b', 'c'))
108
108
  assert r.value == 'c'
109
109
 
110
110
 
111
111
  def test_resolve_out_of_range():
112
- s = PositionalSubst(5)
112
+ s = Subst(5)
113
113
  assert s.resolve(('a', 'b'), partial=True) is s
114
114
 
115
115
 
116
116
  def test_resolve_out_of_range_strict():
117
- s = PositionalSubst(5)
117
+ s = Subst(5)
118
118
  with pytest.raises(IndexError):
119
119
  s.resolve(('a', 'b'))
120
120
 
@@ -3,7 +3,7 @@ Tests for $N substitution escaping and is_template API.
3
3
  """
4
4
  import dotted
5
5
  from dotted.api import parse, assemble, quote
6
- from dotted.matchers import Word, PositionalSubst
6
+ from dotted.matchers import Word, Subst
7
7
 
8
8
 
9
9
  # ---- escaping: \$ produces literal dollar-sign keys ----
@@ -28,7 +28,7 @@ def test_parse_escaped_dollar_multi_digit():
28
28
 
29
29
  def test_parse_raw_subst_still_works():
30
30
  ops = parse('$0')
31
- assert isinstance(ops[0].op, PositionalSubst)
31
+ assert isinstance(ops[0].op, Subst)
32
32
  assert ops[0].op.value == 0
33
33
 
34
34
 
@@ -0,0 +1,173 @@
1
+ """
2
+ Tests for substitution transforms: $(name|transform), $(0|transform).
3
+ """
4
+ from dotted.api import replace, is_template, parse
5
+ from dotted.matchers import Subst
6
+ from dotted.results import assemble
7
+
8
+
9
+ # ---- parsing ----
10
+
11
+ def test_parse_named_with_transform():
12
+ """
13
+ $(name|int) parses as Subst with int transform.
14
+ """
15
+ ops = parse('$(name|int)')
16
+ assert isinstance(ops[0].op, Subst)
17
+ assert ops[0].op.value == 'name'
18
+ assert len(ops[0].op.transforms) == 1
19
+ assert ops[0].op.transforms[0].name == 'int'
20
+
21
+
22
+ def test_parse_named_no_transform():
23
+ """
24
+ $(name) still parses as Subst with no transforms.
25
+ """
26
+ ops = parse('$(name)')
27
+ assert isinstance(ops[0].op, Subst)
28
+ assert ops[0].op.value == 'name'
29
+ assert ops[0].op.transforms == ()
30
+
31
+
32
+ def test_parse_numeric_paren_with_transform():
33
+ """
34
+ $(0|str) parses as Subst with str transform.
35
+ """
36
+ ops = parse('$(0|str)')
37
+ assert isinstance(ops[0].op, Subst)
38
+ assert ops[0].op.value == 0
39
+ assert len(ops[0].op.transforms) == 1
40
+ assert ops[0].op.transforms[0].name == 'str'
41
+
42
+
43
+ def test_parse_numeric_paren_no_transform():
44
+ """
45
+ $(0) parses as Subst with no transforms.
46
+ """
47
+ ops = parse('$(0)')
48
+ assert isinstance(ops[0].op, Subst)
49
+ assert ops[0].op.value == 0
50
+ assert ops[0].op.transforms == ()
51
+
52
+
53
+ def test_parse_raw_positional():
54
+ """
55
+ $0 still parses as Subst with no transforms.
56
+ """
57
+ ops = parse('$0')
58
+ assert isinstance(ops[0].op, Subst)
59
+ assert ops[0].op.value == 0
60
+ assert ops[0].op.transforms == ()
61
+
62
+
63
+ def test_parse_multiple_transforms():
64
+ """
65
+ $(name|strip|lowercase) parses with two transforms.
66
+ """
67
+ ops = parse('$(name|strip|lowercase)')
68
+ assert isinstance(ops[0].op, Subst)
69
+ assert len(ops[0].op.transforms) == 2
70
+ assert ops[0].op.transforms[0].name == 'strip'
71
+ assert ops[0].op.transforms[1].name == 'lowercase'
72
+
73
+
74
+ # ---- resolve with transforms ----
75
+
76
+ def test_resolve_named_with_int_transform():
77
+ """
78
+ $(name|int) resolves and applies int transform.
79
+ """
80
+ result = replace('$(name|int)', {'name': '42'})
81
+ assert result == '42'
82
+
83
+
84
+ def test_resolve_named_with_str_transform():
85
+ """
86
+ $(name|str) resolves and applies str transform.
87
+ """
88
+ result = replace('$(name|str)', {'name': 123})
89
+ assert result == '123'
90
+
91
+
92
+ def test_resolve_numeric_with_transform():
93
+ """
94
+ $(0|uppercase) resolves positional and applies transform.
95
+ """
96
+ result = replace('$(0|uppercase)', ['hello'])
97
+ assert result == 'HELLO'
98
+
99
+
100
+ def test_resolve_raw_positional_no_transform():
101
+ """
102
+ $0 resolves against list bindings as before.
103
+ """
104
+ result = replace('prefix.$0', ['world'])
105
+ assert result == 'prefix.world'
106
+
107
+
108
+ # ---- Subst against dict bindings ----
109
+
110
+ def test_numeric_subst_dict_bindings():
111
+ """
112
+ $(0) against dict bindings looks up numeric key 0.
113
+ """
114
+ result = replace('$(0)', {0: 'zero'})
115
+ assert result == 'zero'
116
+
117
+
118
+ # ---- round-trip ----
119
+
120
+ def test_repr_named_with_transform():
121
+ """
122
+ Subst with transforms repr includes the suffix.
123
+ """
124
+ ops = parse('$(name|int)')
125
+ assert repr(ops[0].op) == '$(name|int)'
126
+
127
+
128
+ def test_repr_numeric_with_transform():
129
+ """
130
+ Subst with transforms uses paren form.
131
+ """
132
+ ops = parse('$(0|str)')
133
+ assert repr(ops[0].op) == '$(0|str)'
134
+
135
+
136
+ def test_repr_numeric_no_transform():
137
+ """
138
+ Subst without transforms uses bare $N form.
139
+ """
140
+ ops = parse('$0')
141
+ assert repr(ops[0].op) == '$0'
142
+
143
+
144
+ def test_assemble_named_with_transform():
145
+ """
146
+ Assembling a path with $(name|int) round-trips.
147
+ """
148
+ ops = parse('a.$(name|int).b')
149
+ assert assemble(ops) == 'a.$(name|int).b'
150
+
151
+
152
+ def test_assemble_numeric_with_transform():
153
+ """
154
+ Assembling a path with $(0|str) round-trips.
155
+ """
156
+ ops = parse('a.$(0|str).b')
157
+ assert assemble(ops) == 'a.$(0|str).b'
158
+
159
+
160
+ # ---- is_template ----
161
+
162
+ def test_is_template_with_transform():
163
+ """
164
+ $(name|int) is still a template.
165
+ """
166
+ assert is_template('$(name|int)')
167
+
168
+
169
+ def test_is_template_numeric_with_transform():
170
+ """
171
+ $(0|str) is still a template.
172
+ """
173
+ assert is_template('$(0|str)')