smartdict 0.4.0__py3-none-any.whl

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.
smartdict/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ from smartdict.resolver import RefStringStatus, RefStringStatusWithValue, ComponentWithValue
2
+ from smartdict.smartdict import (
3
+ SmartDict,
4
+ CircularReferenceError,
5
+ ReferenceNotFoundError,
6
+ UnresolvedReference,
7
+ )
8
+ from smartdict.path import Path
9
+
10
+ __version__ = "0.4.0"
11
+
12
+ __all__ = [
13
+ "parse",
14
+ "partial_parse",
15
+ "iterative_parse",
16
+ "RefStringStatus",
17
+ "RefStringStatusWithValue",
18
+ "ComponentWithValue",
19
+ "SmartDict",
20
+ "CircularReferenceError",
21
+ "ReferenceNotFoundError",
22
+ "UnresolvedReference",
23
+ "Path"
24
+ ]
25
+
26
+
27
+ def parse(obj):
28
+ return SmartDict(data=obj).parse()
29
+
30
+
31
+ def partial_parse(obj):
32
+ return SmartDict(data=obj, partial=True).parse()
33
+
34
+
35
+ def iterative_parse(obj, iterations=1):
36
+ return SmartDict(data=obj, iterations=iterations, partial=True).parse()
smartdict/function.py ADDED
@@ -0,0 +1,50 @@
1
+ class Part:
2
+ def __init__(self, part: str, full: bool = False, partial: bool = False):
3
+ self.part = part
4
+ self.full = full
5
+ self.partial = partial
6
+
7
+ def __call__(self):
8
+ if self.full:
9
+ return '${' + self.part + '}$'
10
+ if self.partial:
11
+ return '${' + self.part + '}'
12
+ return self.part
13
+
14
+
15
+ def parse_ref_string(s: str) -> list[Part]:
16
+ parts = []
17
+ i = 0
18
+ n = len(s)
19
+
20
+ while i < n:
21
+ if s[i:i + 2] == '${': # 进入引用
22
+ depth = 1
23
+ j = i + 2
24
+ while j < n and depth > 0:
25
+ if s[j:j + 2] == '${':
26
+ depth += 1
27
+ j += 2
28
+ elif s[j] == '}':
29
+ depth -= 1
30
+ j += 1
31
+ else:
32
+ j += 1
33
+ if depth != 0:
34
+ raise ValueError(f"Unmatched braces in: {s}")
35
+ expr = s[i + 2:j - 1] # 提取 ${ ... }
36
+
37
+ # full 的严格条件:整个字符串是 ${...}$
38
+ if i == 0 and j == n - 1 and s.endswith('$'):
39
+ parts.append(Part(expr, full=True))
40
+ return parts # 整个字符串就是 full,直接返回
41
+ else:
42
+ parts.append(Part(expr, partial=True))
43
+ i = j
44
+ else:
45
+ # 普通文本
46
+ start = i
47
+ while i < n and s[i:i + 2] != '${':
48
+ i += 1
49
+ parts.append(Part(s[start:i]))
50
+ return parts
smartdict/path.py ADDED
@@ -0,0 +1,20 @@
1
+ class Path:
2
+ def __init__(self, path=None):
3
+ self._path = path or []
4
+
5
+ def __truediv__(self, other):
6
+ if isinstance(other, str):
7
+ return Path(self._path.copy() + [other])
8
+ if isinstance(other, Path):
9
+ return Path(self._path.copy() + other._path.copy())
10
+ raise TypeError(f'Path `/` operation get unexpected type {type(other)}')
11
+
12
+ def __call__(self):
13
+ return '→'.join(self._path)
14
+
15
+ def __len__(self):
16
+ return len(self._path)
17
+
18
+ def __str__(self):
19
+ path = list(map(lambda x: f'{x}', self._path))
20
+ return ' → '.join(path)
smartdict/resolver.py ADDED
@@ -0,0 +1,92 @@
1
+ from typing import Any, Dict, Union
2
+
3
+
4
+ class RefStringStatus:
5
+ RESOLVING = object()
6
+ UNRESOLVED = object()
7
+ RESOLVED = object()
8
+ NOTFOUND = object()
9
+
10
+ UNSET_VALUE = object()
11
+
12
+ def __init__(self, ref_string):
13
+ self.ref_string = ref_string
14
+
15
+ self.status = self.RESOLVING
16
+ self.value = self.UNSET_VALUE
17
+
18
+ @property
19
+ def is_resolved(self):
20
+ return self.status == self.RESOLVED
21
+
22
+ @property
23
+ def is_unresolved(self):
24
+ return self.status == self.UNRESOLVED
25
+
26
+ @property
27
+ def is_resolving(self):
28
+ return self.status == self.RESOLVING
29
+
30
+ def resolve(self, value):
31
+ self.status = self.RESOLVED
32
+ self.value = value
33
+ return self
34
+
35
+ def unresolve(self):
36
+ self.status = self.UNRESOLVED
37
+ return self
38
+
39
+ @property
40
+ def has_value(self):
41
+ return self.value != self.UNSET_VALUE
42
+
43
+ def __str__(self):
44
+ value = self.value if self.has_value else '<UNSET>'
45
+ return f'{self.ref_string} → {value}'
46
+
47
+ def __repr__(self):
48
+ return str(self)
49
+
50
+
51
+ class RefStringStatusWithValue:
52
+ def __init__(self, status: RefStringStatus, value: Any = RefStringStatus.UNSET_VALUE):
53
+ self.status = status
54
+ self.value = value
55
+
56
+ if status.is_resolved:
57
+ self.value = status.value
58
+
59
+ @property
60
+ def is_unset(self):
61
+ return self.value == RefStringStatus.UNSET_VALUE
62
+
63
+
64
+ class ComponentWithValue:
65
+ def __init__(self, path):
66
+ super().__init__()
67
+ self.path = str(path)
68
+ self.unresolved = {} # type: Dict[Any, Union[RefStringStatus, ComponentWithValue]]
69
+ self.final = None
70
+
71
+ def push(self, ref_value: RefStringStatusWithValue):
72
+ if ref_value.is_unset:
73
+ self.unresolved[ref_value.status.ref_string] = ref_value.status
74
+ return self
75
+
76
+ def list_push(self, index, component_value: 'ComponentWithValue'):
77
+ if component_value.has_unresolved:
78
+ self.unresolved[str(index)] = component_value
79
+ return self
80
+
81
+ def dict_push(self, key, component_value: 'ComponentWithValue'):
82
+ if component_value.has_unresolved:
83
+ self.unresolved[key] = component_value
84
+ return self
85
+
86
+ def finalize(self, obj):
87
+ self.final = obj
88
+ return self
89
+
90
+ @property
91
+ def has_unresolved(self):
92
+ return len(self.unresolved) > 0
smartdict/roba.py ADDED
@@ -0,0 +1,50 @@
1
+ import re
2
+
3
+ from oba.oba import raw, NotFound, iterable, Obj
4
+
5
+
6
+ pattern = re.compile(r"\$\{[^}]+}|[^.]+")
7
+
8
+ def re_split(s: str):
9
+ tokens = pattern.findall(s)
10
+ if len(tokens) > 1:
11
+ tokens = [tokens[0], '.'.join(tokens[1:])]
12
+ return tokens
13
+
14
+
15
+ class Roba(Obj):
16
+ def __init__(self, object_=None, path=None):
17
+ super().__init__(object_, path)
18
+
19
+ def __getitem__(self, key):
20
+ if isinstance(key, str):
21
+ key = re_split(key)
22
+
23
+ index = key[0] if isinstance(key, list) else key
24
+
25
+ # noinspection PyBroadException
26
+ try:
27
+ value = self.__obj__.__getitem__(index)
28
+ except Exception:
29
+ return NotFound(path=self.__path__ / index)
30
+ if iterable(value):
31
+ value = Roba(value, path=self.__path__ / str(key))
32
+
33
+ if isinstance(key, list) and len(key) > 1:
34
+ value = value[key[1]]
35
+ return value
36
+
37
+ def __setitem__(self, key: str, value):
38
+ key = re_split(key)
39
+ print(key,)
40
+
41
+ if len(key) == 1:
42
+ obj = raw(self)
43
+ obj[key[0]] = value
44
+ else:
45
+ print('here')
46
+ print(type(self[key[0]]))
47
+ self[key[0]][key[1]] = value
48
+
49
+ def __str__(self):
50
+ return 'Soba()'
smartdict/smartdict.py ADDED
@@ -0,0 +1,336 @@
1
+ import warnings
2
+ from dataclasses import dataclass
3
+ from typing import Any, Dict, Optional, Hashable
4
+
5
+ from smartdict import function
6
+ from smartdict.path import Path
7
+ from smartdict.resolver import RefStringStatus, ComponentWithValue, RefStringStatusWithValue
8
+ from smartdict.roba import Roba
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class UnresolvedReference:
13
+ path: str
14
+ reference: str
15
+
16
+
17
+ class CircularReferenceError(ReferenceError):
18
+ """Raised when a reference dependency chain loops back to itself."""
19
+
20
+ def __init__(self, ref_string: str):
21
+ self.ref_string = ref_string
22
+ super().__init__(f'Circular reference detected: {ref_string}')
23
+
24
+
25
+ class ReferenceNotFoundError(KeyError):
26
+ """Raised when strict parsing encounters unresolved references."""
27
+
28
+ def __init__(self, unresolved: list[UnresolvedReference]):
29
+ self.unresolved = tuple(unresolved)
30
+ details = ', '.join(
31
+ f'{item.path or "<root>"} -> {item.reference}' for item in self.unresolved
32
+ )
33
+ super().__init__(f'Unresolved references: {details}')
34
+
35
+
36
+ # _FULL_REF_PATTERN = re.compile(r"^\$\{([^}]+)}\$$")
37
+ # _PARTIAL_REF_PATTERN = re.compile(r"\$\{([^}]+)}")
38
+
39
+
40
+ class SmartDict:
41
+ def __init__(self, data: Any, partial: bool = False, iterations=1):
42
+ if iterations <= 0:
43
+ raise ValueError('`iterations` must be greater than 0')
44
+
45
+ self._src = data
46
+ self._cache = {} # type: Dict[str, RefStringStatus]
47
+ self._partial = partial
48
+ self._iterations = iterations
49
+
50
+ if self._iterations > 1 and not self._partial:
51
+ self._partial = True
52
+ warnings.warn('`partial` will be set to True when `iteration` > 1')
53
+
54
+ def combine_and_parse(self, cache: dict, return_cv=False):
55
+ roba = Roba(self._src)
56
+ for key, value in cache.items():
57
+ roba[key] = value
58
+ self._src = roba()
59
+
60
+ component_value = self.deep_resolve(self._src)
61
+ src = component_value.final
62
+
63
+ self._analyse(component_value)
64
+
65
+ if return_cv:
66
+ return component_value
67
+
68
+ return src
69
+
70
+ def parse(self, return_cv=False) -> Any:
71
+ iter_src = self._src
72
+ component_value = None # type: Optional[ComponentWithValue]
73
+ for _ in range(self._iterations):
74
+ self._cache.clear()
75
+ component_value = self.deep_resolve(iter_src)
76
+ iter_src = component_value.final
77
+
78
+ if component_value is None:
79
+ raise RuntimeError('source is not parsed yet')
80
+
81
+ self._analyse(component_value)
82
+
83
+ if return_cv:
84
+ return component_value
85
+
86
+ return iter_src
87
+
88
+ def _collect_unresolved(self, component_value: ComponentWithValue) -> list[UnresolvedReference]:
89
+ unresolved = []
90
+ for value in component_value.unresolved.values():
91
+ if isinstance(value, ComponentWithValue):
92
+ unresolved.extend(self._collect_unresolved(value))
93
+ elif isinstance(value, RefStringStatus):
94
+ unresolved.append(
95
+ UnresolvedReference(
96
+ path=component_value.path,
97
+ reference=value.ref_string,
98
+ )
99
+ )
100
+ else:
101
+ raise RuntimeError(f'unexpected type {type(value)}')
102
+ return unresolved
103
+
104
+ def _analyse(self, component_value: ComponentWithValue):
105
+ if self._partial or not component_value.has_unresolved:
106
+ return
107
+
108
+ raise ReferenceNotFoundError(self._collect_unresolved(component_value))
109
+
110
+ @staticmethod
111
+ def is_string(obj: Any) -> bool:
112
+ return isinstance(obj, str)
113
+
114
+ @staticmethod
115
+ def is_dict(obj: Any) -> bool:
116
+ return isinstance(obj, dict)
117
+
118
+ @staticmethod
119
+ def is_list(obj: Any) -> bool:
120
+ return isinstance(obj, list)
121
+
122
+ @classmethod
123
+ def is_tuple(cls: Any, obj: Any) -> bool:
124
+ return isinstance(obj, tuple)
125
+
126
+ def deep_resolve(self, obj: Any, path: Path = None) -> ComponentWithValue:
127
+ path = path or Path()
128
+
129
+ if self.is_string(obj):
130
+ return self._resolve_string(obj, path=path)
131
+
132
+ final_component_value = ComponentWithValue(path)
133
+
134
+ if self.is_list(obj) or self.is_tuple(obj):
135
+ new_list = []
136
+ for i, item in enumerate(obj):
137
+ component_value = self.deep_resolve(item, path=path / str(i))
138
+ new_list.append(component_value.final)
139
+ final_component_value.list_push(str(i), component_value)
140
+ if self.is_tuple(obj):
141
+ new_list = tuple(new_list)
142
+ return final_component_value.finalize(new_list)
143
+
144
+ if self.is_dict(obj):
145
+ new_dict = {}
146
+ for key, value in obj.items():
147
+ key_path = path / f'<k>' / key
148
+ key_component_value = self.deep_resolve(key, path=key_path)
149
+ final_component_value.dict_push(f'<k> / {key}', key_component_value)
150
+ if not isinstance(key_component_value.final, Hashable):
151
+ raise TypeError(f'Key object is not hashable: {key_component_value.final}')
152
+ new_key = key_component_value.final
153
+
154
+ if new_key in new_dict:
155
+ raise KeyError(f'Duplicate key: {new_key}')
156
+
157
+ value_component_value = self.deep_resolve(value, path=path / str(new_key))
158
+ final_component_value.dict_push(new_key, value_component_value)
159
+ new_dict[new_key] = value_component_value.final
160
+ return final_component_value.finalize(new_dict)
161
+
162
+ return final_component_value.finalize(obj)
163
+
164
+ @staticmethod
165
+ def _parse_default_value(value: Any) -> Any:
166
+ """
167
+ Automatically parses the type of default values:
168
+ - "true" / "false" / "null" (case-insensitive)
169
+ - Integers
170
+ - Floats
171
+ - Otherwise, keeps the string as-is
172
+ """
173
+ if isinstance(value, str):
174
+ lower_val = value.lower()
175
+ if lower_val == "true":
176
+ return True
177
+ elif lower_val == "false":
178
+ return False
179
+ elif lower_val == "null":
180
+ return None
181
+ else:
182
+ # Attempt to parse as int
183
+ try:
184
+ return int(value)
185
+ except ValueError:
186
+ pass
187
+ # Attempt to parse as float
188
+ try:
189
+ return float(value)
190
+ except ValueError:
191
+ pass
192
+ # Keep as string
193
+ return value
194
+ else:
195
+ # Non-string types remain unchanged
196
+ return value
197
+
198
+ @staticmethod
199
+ def _get_value(obj: Any, key):
200
+ try:
201
+ return obj[key]
202
+ except Exception:
203
+ pass
204
+
205
+ try:
206
+ return getattr(obj, key)
207
+ except Exception:
208
+ pass
209
+
210
+ try:
211
+ return obj[int(key)]
212
+ except Exception:
213
+ pass
214
+
215
+ return RefStringStatus.NOTFOUND
216
+
217
+ def _resolve_path_component(self, value: Any, path: Path, is_leaf: bool):
218
+ if is_leaf:
219
+ return self.deep_resolve(value, path).final
220
+
221
+ # Only resolve string aliases for intermediate nodes so we can keep
222
+ # walking the requested path without eagerly resolving sibling fields.
223
+ while self.is_string(value):
224
+ resolved = self.deep_resolve(value, path).final
225
+ if resolved == value:
226
+ break
227
+ value = resolved
228
+
229
+ return value
230
+
231
+ def _resolve_ref_string(self, ref_string: str, path: Path) -> RefStringStatusWithValue:
232
+ if ':' in ref_string:
233
+ ref_string, default_str = ref_string.split(':', 1)
234
+ default_value = self._parse_default_value(default_str)
235
+ else:
236
+ default_value = RefStringStatus.UNSET_VALUE
237
+
238
+ if ref_string in self._cache:
239
+ if self._cache[ref_string].is_resolving:
240
+ raise CircularReferenceError(ref_string)
241
+ return RefStringStatusWithValue(self._cache[ref_string], default_value)
242
+
243
+ self._cache[ref_string] = RefStringStatus(ref_string)
244
+ subkeys = ref_string.split(".") if ref_string else []
245
+ current_value = self._src
246
+ current_path = path
247
+ last_index = len(subkeys) - 1
248
+ for index, key in enumerate(subkeys):
249
+ current_path = current_path / key
250
+ current_value = self._get_value(current_value, key)
251
+ if current_value is RefStringStatus.NOTFOUND:
252
+ break
253
+ current_value = self._resolve_path_component(
254
+ current_value,
255
+ current_path,
256
+ is_leaf=index == last_index,
257
+ )
258
+
259
+ if current_value is RefStringStatus.NOTFOUND:
260
+ self._cache[ref_string].unresolve()
261
+ else:
262
+ self._cache[ref_string].resolve(current_value)
263
+ return RefStringStatusWithValue(self._cache[ref_string], default_value)
264
+
265
+ def _resolve_string(self, obj: str, path: Path) -> ComponentWithValue:
266
+ component_value = ComponentWithValue(path)
267
+
268
+ parts = function.parse_ref_string(obj)
269
+ if len(parts) == 1 and parts[0].full:
270
+ ref_string = self._resolve_string(parts[0].part, path).final
271
+ ref_value = self._resolve_ref_string(ref_string, path=path / '$')
272
+ return component_value.push(ref_value).finalize(obj if ref_value.is_unset else ref_value.value)
273
+
274
+ # m_full = _FULL_REF_PATTERN.match(obj)
275
+ #
276
+ # if m_full:
277
+ # ref_value = self._resolve_ref_string(m_full.group(1), path=path / '$')
278
+ # return component_value.push(ref_value).finalize(obj if ref_value.is_unset else ref_value.value)
279
+
280
+ result_parts = []
281
+
282
+ for p in parts:
283
+ if not p.partial:
284
+ result_parts.append(p.part)
285
+ continue
286
+ ref_string = self._resolve_string(p.part, path).final
287
+ ref_value = self._resolve_ref_string(ref_string, path=path / '$')
288
+ current = '${' + ref_string + '}' if ref_value.is_unset else ref_value.value
289
+ component_value.push(ref_value)
290
+
291
+ result_parts.append(str(current))
292
+
293
+ return component_value.finalize(''.join(result_parts))
294
+
295
+
296
+ #
297
+ # matches = list(_PARTIAL_REF_PATTERN.finditer(obj))
298
+ # if not matches:
299
+ # return component_value.finalize(obj)
300
+ #
301
+ # result_parts = []
302
+ # last_end = 0
303
+ # for m in matches:
304
+ # start_idx = m.start()
305
+ # end_idx = m.end()
306
+ # # Add the original text before this reference
307
+ # result_parts.append(obj[last_end:start_idx])
308
+ #
309
+ # ref_value = self._resolve_ref_string(m.group(1), path=path / '$')
310
+ # current = m.group(0) if ref_value.is_unset else ref_value.value
311
+ # component_value.push(ref_value)
312
+ #
313
+ # result_parts.append(str(current))
314
+ #
315
+ # last_end = end_idx
316
+ # # Add the remaining text after the last reference
317
+ # result_parts.append(obj[last_end:])
318
+ #
319
+ # return component_value.finalize("".join(result_parts))
320
+
321
+ @property
322
+ def cache(self):
323
+ return self._cache
324
+
325
+
326
+ if __name__ == '__main__':
327
+ data_ = {
328
+ 'a': {
329
+ 'z': 1,
330
+ 'b${a.y}': '${a.b}$',
331
+ 'c': '${a.b1}bad',
332
+ 'b': 33,
333
+ }
334
+ }
335
+ data_ = SmartDict(data_).parse()
336
+ print(data_)
@@ -0,0 +1,433 @@
1
+ Metadata-Version: 2.4
2
+ Name: smartdict
3
+ Version: 0.4.0
4
+ Summary: A reference resolver for nested dictionaries that supports default values, partial references, and circular dependency detection.
5
+ Author-email: Jyonn Liu <i@6-79.cn>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Jyonn/smartdict
8
+ Keywords: dict,reference
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: oba>=0.3.0
18
+
19
+ # SmartDict
20
+
21
+ `smartdict` is a small Python library for resolving references inside nested data structures.
22
+ It is especially useful for configuration dictionaries where one field needs to reuse another.
23
+
24
+ SmartDict walks through built-in `dict`, `list`, and `tuple` containers, finds reference
25
+ expressions inside strings, and replaces them with resolved values.
26
+
27
+ ## Features
28
+
29
+ - Inline string interpolation with `${path.to.value}`
30
+ - Full-value replacement with `${path.to.value}$`
31
+ - Nested reference strings such as `${${keys.${env}}}`
32
+ - Default values such as `${missing:42}` or `${missing:fallback}`
33
+ - Dictionary key generation from references
34
+ - List and tuple index lookup through dotted paths
35
+ - Circular reference detection
36
+ - Strict mode, partial mode, and iterative parsing mode
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pip install smartdict
42
+ ```
43
+
44
+ ## Quick Start
45
+
46
+ ```python
47
+ import smartdict
48
+
49
+ data = {
50
+ "dataset": "spotify",
51
+ "load": {
52
+ "base_path": "~/data/${dataset}",
53
+ "train_path": "${load.base_path}/train",
54
+ "dev_path": "${load.base_path}/dev",
55
+ "test_path": "${load.base_path}/test",
56
+ },
57
+ "network": {
58
+ "num_hidden_layers": 3,
59
+ "num_attention_heads": 8,
60
+ },
61
+ "store": "checkpoints/${dataset}/${network.num_hidden_layers}L${network.num_attention_heads}H/",
62
+ }
63
+
64
+ parsed = smartdict.parse(data)
65
+
66
+ print(parsed["load"]["base_path"])
67
+ # ~/data/spotify
68
+
69
+ print(parsed["load"]["dev_path"])
70
+ # ~/data/spotify/dev
71
+
72
+ print(parsed["store"])
73
+ # checkpoints/spotify/3L8H/
74
+ ```
75
+
76
+ ## Reference Syntax
77
+
78
+ ### 1. Inline references
79
+
80
+ Use `${...}` when the reference is part of a larger string.
81
+
82
+ ```python
83
+ import smartdict
84
+
85
+ parsed = smartdict.parse({
86
+ "name": "smartdict",
87
+ "message": "hello-${name}",
88
+ })
89
+
90
+ print(parsed["message"])
91
+ # hello-smartdict
92
+ ```
93
+
94
+ ### 2. Full-match references
95
+
96
+ Use `${...}$` when the whole value should become the referenced object instead of a string.
97
+
98
+ ```python
99
+ import smartdict
100
+
101
+ parsed = smartdict.parse({
102
+ "config": {
103
+ "debug": True,
104
+ "retries": 3,
105
+ },
106
+ "selected": "${config}$",
107
+ })
108
+
109
+ print(parsed["selected"])
110
+ # {'debug': True, 'retries': 3}
111
+ ```
112
+
113
+ This is useful when the target is a `dict`, `list`, `tuple`, number, boolean, or any other
114
+ non-string value.
115
+
116
+ ### 3. Nested reference strings
117
+
118
+ Reference expressions can themselves contain reference expressions.
119
+
120
+ ```python
121
+ import smartdict
122
+
123
+ parsed = smartdict.parse({
124
+ "env": "prod",
125
+ "keys": {"prod": "url"},
126
+ "url": "https://example.com",
127
+ "result": "${${keys.${env}}}",
128
+ })
129
+
130
+ print(parsed["result"])
131
+ # https://example.com
132
+ ```
133
+
134
+ ### 4. Default values
135
+
136
+ If a path cannot be found, you can provide a default value with `:`.
137
+
138
+ ```python
139
+ import smartdict
140
+
141
+ parsed = smartdict.parse({
142
+ "int_value": "${missing:42}$",
143
+ "bool_value": "${missing:true}$",
144
+ "null_value": "${missing:null}$",
145
+ "text_value": "${missing:fallback}$",
146
+ })
147
+
148
+ print(parsed)
149
+ # {
150
+ # 'int_value': 42,
151
+ # 'bool_value': True,
152
+ # 'null_value': None,
153
+ # 'text_value': 'fallback'
154
+ # }
155
+ ```
156
+
157
+ Default values are automatically interpreted as:
158
+
159
+ - `true` / `false` -> `bool`
160
+ - `null` -> `None`
161
+ - integers -> `int`
162
+ - floats -> `float`
163
+ - anything else -> `str`
164
+
165
+ ### 5. List and tuple indices
166
+
167
+ Dotted paths can also index built-in sequences.
168
+
169
+ ```python
170
+ import smartdict
171
+
172
+ parsed = smartdict.parse({
173
+ "items": ["a", "b"],
174
+ "pair": ("x", "y"),
175
+ "pick_list": "${items.1}",
176
+ "pick_tuple": "${pair.0}",
177
+ })
178
+
179
+ print(parsed["pick_list"])
180
+ # b
181
+
182
+ print(parsed["pick_tuple"])
183
+ # x
184
+ ```
185
+
186
+ ### 6. Dictionary keys can be generated
187
+
188
+ References are resolved in both keys and values.
189
+
190
+ ```python
191
+ import smartdict
192
+
193
+ parsed = smartdict.parse({
194
+ "name": "k",
195
+ "${name}": 1,
196
+ })
197
+
198
+ print(parsed)
199
+ # {'name': 'k', 'k': 1}
200
+ ```
201
+
202
+ ### 7. Referencing custom objects
203
+
204
+ SmartDict resolves path components in this order:
205
+
206
+ 1. `obj[key]`
207
+ 2. `getattr(obj, key)`
208
+ 3. `obj[int(key)]`
209
+
210
+ That means you can expose custom lookup behavior through objects used inside your data.
211
+
212
+ ```python
213
+ import random
214
+ import string
215
+
216
+ import smartdict
217
+
218
+
219
+ class Rand(dict):
220
+ chars = string.ascii_letters + string.digits
221
+
222
+ def __getitem__(self, item):
223
+ return "".join(random.choice(self.chars) for _ in range(int(item)))
224
+
225
+
226
+ parsed = smartdict.parse({
227
+ "utils": {
228
+ "rand": Rand(),
229
+ },
230
+ "filename": "${utils.rand.4}",
231
+ })
232
+
233
+ print(parsed["filename"])
234
+ # for example: aZ19
235
+ ```
236
+
237
+ ## Parse Modes
238
+
239
+ ### `smartdict.parse(obj)`
240
+
241
+ Strict mode.
242
+
243
+ - Resolves all references
244
+ - Raises an error if any reference cannot be resolved
245
+ - Detects circular references
246
+
247
+ ```python
248
+ import smartdict
249
+
250
+ parsed = smartdict.parse({
251
+ "a": "x",
252
+ "b": "${a}/y",
253
+ })
254
+
255
+ print(parsed)
256
+ # {'a': 'x', 'b': 'x/y'}
257
+ ```
258
+
259
+ ### `smartdict.partial_parse(obj)`
260
+
261
+ Best-effort mode.
262
+
263
+ - Resolves what it can
264
+ - Does not raise for missing references
265
+ - Leaves unresolved results in their current best-effort form
266
+
267
+ ```python
268
+ import smartdict
269
+
270
+ parsed = smartdict.partial_parse({
271
+ "a": "${missing}",
272
+ "b": "pre-${missing}-post",
273
+ "c": "${missing}$",
274
+ })
275
+
276
+ print(parsed)
277
+ # {'a': '${missing}', 'b': 'pre-${missing}-post', 'c': '${missing}$'}
278
+ ```
279
+
280
+ ### `smartdict.iterative_parse(obj, iterations=1)`
281
+
282
+ Repeated best-effort parsing.
283
+
284
+ This is useful when one pass unlocks another pass.
285
+
286
+ ```python
287
+ import smartdict
288
+
289
+ parsed = smartdict.iterative_parse({
290
+ "a": "${b}",
291
+ "b": "${c}",
292
+ "c": "ok",
293
+ }, iterations=2)
294
+
295
+ print(parsed)
296
+ # {'a': 'ok', 'b': 'ok', 'c': 'ok'}
297
+ ```
298
+
299
+ ## Errors
300
+
301
+ ### `ReferenceNotFoundError`
302
+
303
+ Raised by `smartdict.parse()` when a reference cannot be resolved.
304
+
305
+ ```python
306
+ import smartdict
307
+ from smartdict.smartdict import ReferenceNotFoundError
308
+
309
+ try:
310
+ smartdict.parse({
311
+ "a": "${missing}",
312
+ })
313
+ except ReferenceNotFoundError as exc:
314
+ print(type(exc).__name__, exc)
315
+ ```
316
+
317
+ Nested missing references are also detected:
318
+
319
+ ```python
320
+ import smartdict
321
+
322
+ smartdict.parse({
323
+ "app": {
324
+ "profile": "prod",
325
+ },
326
+ "services": {
327
+ "prod": {
328
+ "url": "${config.endpoints.api}",
329
+ },
330
+ },
331
+ "result": "${services.${app.profile}.url}",
332
+ })
333
+ ```
334
+
335
+ ### `CircularReferenceError`
336
+
337
+ Raised when references depend on each other in a cycle.
338
+
339
+ ```python
340
+ import smartdict
341
+ from smartdict.smartdict import CircularReferenceError
342
+
343
+ try:
344
+ smartdict.parse({
345
+ "a": "${b}$",
346
+ "b": "${a}$",
347
+ })
348
+ except CircularReferenceError as exc:
349
+ print(type(exc).__name__, exc)
350
+ ```
351
+
352
+ Cycles can also appear across nested dictionaries:
353
+
354
+ ```python
355
+ import smartdict
356
+
357
+ smartdict.parse({
358
+ "app": {
359
+ "profile": "${services.primary.profile}$",
360
+ },
361
+ "services": {
362
+ "primary": {
363
+ "profile": "${app.profile}$",
364
+ },
365
+ },
366
+ })
367
+ ```
368
+
369
+ ### `KeyError`
370
+
371
+ Raised when two dictionary keys resolve to the same final key.
372
+
373
+ ```python
374
+ import smartdict
375
+
376
+ smartdict.parse({
377
+ "aliases": {
378
+ "primary": "stable",
379
+ },
380
+ "${aliases.primary}": 1,
381
+ "stable": 2,
382
+ })
383
+ ```
384
+
385
+ ## Public API
386
+
387
+ The main public entry points are:
388
+
389
+ ```python
390
+ import smartdict
391
+
392
+ smartdict.parse(obj)
393
+ smartdict.partial_parse(obj)
394
+ smartdict.iterative_parse(obj, iterations=2)
395
+ ```
396
+
397
+ The package also exports:
398
+
399
+ - `SmartDict`
400
+ - `Path`
401
+ - `CircularReferenceError`
402
+ - `ReferenceNotFoundError`
403
+ - `UnresolvedReference`
404
+ - `RefStringStatus`
405
+ - `RefStringStatusWithValue`
406
+ - `ComponentWithValue`
407
+
408
+ ## Development
409
+
410
+ Run the test suite with:
411
+
412
+ ```bash
413
+ python -m unittest discover -s tests -v
414
+ ```
415
+
416
+ Build distributions with:
417
+
418
+ ```bash
419
+ python -m build
420
+ ```
421
+
422
+ ## Notes and Current Behavior
423
+
424
+ - SmartDict recursively parses built-in `dict`, `list`, `tuple`, and `str` values.
425
+ - Intermediate path components can be aliases, including full-match references such as `${config}$`.
426
+ - In strict mode, unresolved references raise `ReferenceNotFoundError`.
427
+ - `ReferenceNotFoundError.unresolved` contains structured unresolved entries with `path` and `reference`.
428
+ - `iterations` must be greater than `0`.
429
+ - If resolved dictionary keys collide, SmartDict raises `KeyError`.
430
+
431
+ ## License
432
+
433
+ MIT
@@ -0,0 +1,10 @@
1
+ smartdict/__init__.py,sha256=RkVhybvpYdHtzrfNCTdEKUg0Uh9v1-b7FQ7q4JLBTHw,802
2
+ smartdict/function.py,sha256=EBvKwwgc9idM-rQG1eW0Gg5KgMNLeopIGBasAj353II,1501
3
+ smartdict/path.py,sha256=sLyzDAFwVhd8Vqxb8fWPG4rvO2frVFtk8w35-6pvx0o,614
4
+ smartdict/resolver.py,sha256=pfBUqBQUwfx7WTrsurUYC3LCwPxDMnY2A2CuyCVzCNs,2370
5
+ smartdict/roba.py,sha256=byTR5snU7hxDnDDGu6m1SML8ISWKIUJSfTY34eZTNDI,1249
6
+ smartdict/smartdict.py,sha256=ru2k52BCLfTvsnOrLXdpqpT2kF-WcEupOdTRlmPMxL0,11513
7
+ smartdict-0.4.0.dist-info/METADATA,sha256=_9QYF9JeMNbMgCMZlamtr-4Mx8UTSi_b0JAMeKORL1g,8611
8
+ smartdict-0.4.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
9
+ smartdict-0.4.0.dist-info/top_level.txt,sha256=XeeTTHZjjhU3dIyVO2syS5Ge4zo8gadnrKu2AHy5gc0,10
10
+ smartdict-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ smartdict