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 +36 -0
- smartdict/function.py +50 -0
- smartdict/path.py +20 -0
- smartdict/resolver.py +92 -0
- smartdict/roba.py +50 -0
- smartdict/smartdict.py +336 -0
- smartdict-0.4.0.dist-info/METADATA +433 -0
- smartdict-0.4.0.dist-info/RECORD +10 -0
- smartdict-0.4.0.dist-info/WHEEL +5 -0
- smartdict-0.4.0.dist-info/top_level.txt +1 -0
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 @@
|
|
|
1
|
+
smartdict
|