pico-ioc 1.4.0__py3-none-any.whl → 2.0.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.
@@ -0,0 +1,289 @@
1
+ # src/pico_ioc/config_runtime.py
2
+ import json
3
+ import os
4
+ import re
5
+ import hashlib
6
+ from dataclasses import is_dataclass, fields
7
+ from enum import Enum
8
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, Union, get_args, get_origin, Annotated
9
+ from .exceptions import ConfigurationError
10
+ from .constants import PICO_META
11
+
12
+ class Discriminator:
13
+ def __init__(self, name: str):
14
+ self.name = name
15
+
16
+ class TreeSource:
17
+ def get_tree(self) -> Mapping[str, Any]:
18
+ raise NotImplementedError
19
+
20
+ class DictSource(TreeSource):
21
+ def __init__(self, data: Mapping[str, Any]):
22
+ self._data = data
23
+ def get_tree(self) -> Mapping[str, Any]:
24
+ return self._data
25
+
26
+ class JsonTreeSource(TreeSource):
27
+ def __init__(self, path: str):
28
+ self._path = path
29
+ def get_tree(self) -> Mapping[str, Any]:
30
+ try:
31
+ with open(self._path, "r", encoding="utf-8") as f:
32
+ return json.load(f)
33
+ except Exception as e:
34
+ raise ConfigurationError(f"Failed to load JSON config: {e}")
35
+
36
+ class YamlTreeSource(TreeSource):
37
+ def __init__(self, path: str):
38
+ self._path = path
39
+ def get_tree(self) -> Mapping[str, Any]:
40
+ try:
41
+ import yaml
42
+ except Exception:
43
+ raise ConfigurationError("PyYAML not installed")
44
+ try:
45
+ with open(self._path, "r", encoding="utf-8") as f:
46
+ data = yaml.safe_load(f) or {}
47
+ return data
48
+ except Exception as e:
49
+ raise ConfigurationError(f"Failed to load YAML config: {e}")
50
+
51
+ def _deep_merge(a: Any, b: Any) -> Any:
52
+ if isinstance(a, dict) and isinstance(b, dict):
53
+ out = dict(a)
54
+ for k, v in b.items():
55
+ if k in out:
56
+ out = dict(out)
57
+ out[k] = _deep_merge(out[k], v)
58
+ else:
59
+ out[k] = v
60
+ return out
61
+ return b
62
+
63
+ def _walk_path(root: Any, path: str) -> Any:
64
+ cur = root
65
+ for part in path.split("."):
66
+ if isinstance(cur, dict) and part in cur:
67
+ cur = cur[part]
68
+ else:
69
+ raise ConfigurationError(f"Invalid ref path: {path}")
70
+ return cur
71
+
72
+ _env_pat = re.compile(r"\$\{ENV:([A-Za-z_][A-Za-z0-9_]*)\}")
73
+ _ref_pat = re.compile(r"\$\{ref:([A-Za-z0-9_.]+)\}")
74
+
75
+ def _interpolate_string(s: str, root: Any) -> str:
76
+ def repl_env(m):
77
+ v = os.environ.get(m.group(1))
78
+ if v is None:
79
+ raise ConfigurationError(f"Missing ENV var {m.group(1)}")
80
+ return v
81
+ def repl_ref(m):
82
+ v = _walk_path(root, m.group(1))
83
+ if isinstance(v, (dict, list)):
84
+ raise ConfigurationError("Cannot interpolate non-scalar ref")
85
+ return str(v)
86
+ s = _env_pat.sub(repl_env, s)
87
+ s = _ref_pat.sub(repl_ref, s)
88
+ return s
89
+
90
+ def _resolve_refs(node: Any, root: Any) -> Any:
91
+ if isinstance(node, dict):
92
+ if "$ref" in node and len(node) == 1:
93
+ return _resolve_refs(_walk_path(root, node["$ref"]), root)
94
+ return {k: _resolve_refs(v, root) for k, v in node.items()}
95
+ if isinstance(node, list):
96
+ return [_resolve_refs(x, root) for x in node]
97
+ if isinstance(node, str):
98
+ return _interpolate_string(node, root)
99
+ return node
100
+
101
+ def canonicalize(node: Any) -> bytes:
102
+ return json.dumps(node, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
103
+
104
+ class ConfigResolver:
105
+ def __init__(self, sources: Tuple[TreeSource, ...]):
106
+ self._sources = tuple(sources)
107
+ self._tree: Optional[Mapping[str, Any]] = None
108
+ def tree(self) -> Mapping[str, Any]:
109
+ if self._tree is None:
110
+ acc: Mapping[str, Any] = {}
111
+ for s in self._sources:
112
+ acc = _deep_merge(acc, s.get_tree())
113
+ acc = _resolve_refs(acc, acc)
114
+ self._tree = acc
115
+ return self._tree
116
+ def subtree(self, prefix: Optional[str]) -> Any:
117
+ t = self.tree()
118
+ if not prefix:
119
+ return t
120
+ cur = t
121
+ for part in prefix.split("."):
122
+ if isinstance(cur, dict) and part in cur:
123
+ cur = cur[part]
124
+ else:
125
+ raise ConfigurationError(f"Missing config prefix: {prefix}")
126
+ return cur
127
+
128
+ class TypeAdapterRegistry:
129
+ def __init__(self):
130
+ self._adapters: Dict[type, Any] = {}
131
+ def register(self, t: type, fn):
132
+ self._adapters[t] = fn
133
+ def get(self, t: type):
134
+ return self._adapters.get(t)
135
+
136
+ class ObjectGraphBuilder:
137
+ def __init__(self, resolver: ConfigResolver, registry: TypeAdapterRegistry):
138
+ self._resolver = resolver
139
+ self._registry = registry
140
+ def build_from_prefix(self, target_type: type, prefix: Optional[str]) -> Any:
141
+ node = self._resolver.subtree(prefix)
142
+ inst = self._build(node, target_type, ("$root" if not prefix else prefix,))
143
+ try:
144
+ h = hashlib.sha256(canonicalize(node)).hexdigest()
145
+ m = getattr(inst, PICO_META, None)
146
+ if m is None:
147
+ setattr(inst, PICO_META, {"config_hash": h, "config_prefix": prefix, "config_source_order": tuple(type(s).__name__ for s in self._resolver._sources)})
148
+ else:
149
+ m.update({"config_hash": h, "config_prefix": prefix, "config_source_order": tuple(type(s).__name__ for s in self._resolver._sources)})
150
+ except Exception:
151
+ pass
152
+ return inst
153
+ def _build(self, node: Any, t: Any, path: Tuple[str, ...]) -> Any:
154
+ if t is Any or t is object:
155
+ return node
156
+ adapter = self._registry.get(t) if isinstance(t, type) else None
157
+ if adapter:
158
+ return adapter(node)
159
+ org = get_origin(t)
160
+ if org is Annotated:
161
+ base, meta = self._split_annotated(t)
162
+ return self._build_discriminated(node, base, meta, path)
163
+ if org in (list, List):
164
+ elem_t = get_args(t)[0] if get_args(t) else Any
165
+ if not isinstance(node, list):
166
+ raise ConfigurationError(f"Expected list at {'.'.join(path)}")
167
+ return [self._build(x, elem_t, path + (str(i),)) for i, x in enumerate(node)]
168
+ if org in (dict, Dict, Mapping):
169
+ args = get_args(t)
170
+ kt = args[0] if args else str
171
+ vt = args[1] if len(args) > 1 else Any
172
+ if kt not in (str, Any, object):
173
+ raise ConfigurationError(f"Only dicts with string keys supported at {'.'.join(path)}")
174
+ if not isinstance(node, dict):
175
+ raise ConfigurationError(f"Expected dict at {'.'.join(path)}")
176
+ return {k: self._build(v, vt, path + (k,)) for k, v in node.items()}
177
+ if org is Union:
178
+ args = [a for a in get_args(t)]
179
+ if not isinstance(node, dict):
180
+ for cand in args:
181
+ try:
182
+ return self._build(node, cand, path)
183
+ except Exception:
184
+ continue
185
+ raise ConfigurationError(f"No union match at {'.'.join(path)}")
186
+ if "$type" in node:
187
+ tn = str(node["$type"])
188
+ for cand in args:
189
+ if isinstance(cand, type) and getattr(cand, "__name__", "") == tn:
190
+ cleaned = {k: v for k, v in node.items() if k != "$type"}
191
+ return self._build(cleaned, cand, path)
192
+ raise ConfigurationError(f"Discriminator $type did not match at {'.'.join(path)}")
193
+ for cand in args:
194
+ try:
195
+ return self._build(node, cand, path)
196
+ except Exception:
197
+ continue
198
+ raise ConfigurationError(f"No union match at {'.'.join(path)}")
199
+ if isinstance(t, type) and issubclass(t, Enum):
200
+ if isinstance(node, str):
201
+ try:
202
+ return t[node]
203
+ except Exception:
204
+ for e in t:
205
+ if str(e.value) == node:
206
+ return e
207
+ raise ConfigurationError(f"Invalid enum at {'.'.join(path)}")
208
+ if isinstance(t, type) and is_dataclass(t):
209
+ if not isinstance(node, dict):
210
+ raise ConfigurationError(f"Expected object at {'.'.join(path)}")
211
+ known = {f.name for f in fields(t)}
212
+ extra = [k for k in node.keys() if k not in known]
213
+ if extra:
214
+ raise ConfigurationError(f"Unknown keys {extra} at {'.'.join(path)}")
215
+ vals: Dict[str, Any] = {}
216
+ for f in fields(t):
217
+ if f.name in node:
218
+ vals[f.name] = self._build(node[f.name], f.type, path + (f.name,))
219
+ else:
220
+ continue
221
+ return t(**vals)
222
+ if isinstance(t, type):
223
+ if t in (str, int, float, bool):
224
+ return self._coerce_prim(node, t, path)
225
+ if hasattr(t, "__init__"):
226
+ if not isinstance(node, dict):
227
+ raise ConfigurationError(f"Expected object for ctor at {'.'.join(path)}")
228
+ kwargs: Dict[str, Any] = {}
229
+ import inspect
230
+ sig = inspect.signature(t.__init__)
231
+ for name, p in sig.parameters.items():
232
+ if name in ("self", "cls"):
233
+ continue
234
+ if name in node:
235
+ kwargs[name] = self._build(node[name], p.annotation if p.annotation is not inspect._empty else Any, path + (name,))
236
+ return t(**kwargs)
237
+ return node
238
+ def _split_annotated(self, t: Any) -> Tuple[Any, Tuple[Any, ...]]:
239
+ args = get_args(t)
240
+ base = args[0] if args else Any
241
+ metas = tuple(args[1:]) if len(args) > 1 else ()
242
+ return base, metas
243
+ def _build_discriminated(self, node: Any, base: Any, metas: Tuple[Any, ...], path: Tuple[str, ...]) -> Any:
244
+ disc_name = None
245
+ for m in metas:
246
+ if isinstance(m, Discriminator):
247
+ disc_name = m.name
248
+ break
249
+ if disc_name and isinstance(node, dict) and disc_name in node:
250
+ if get_origin(base) is Union:
251
+ tn = str(node[disc_name])
252
+ for cand in get_args(base):
253
+ if isinstance(cand, type) and getattr(cand, "__name__", "") == tn:
254
+ cleaned = {k: v for k, v in node.items() if k != disc_name}
255
+ return self._build(cleaned, cand, path)
256
+ raise ConfigurationError(f"Discriminator {disc_name} did not match at {'.'.join(path)}")
257
+ return self._build(node, base, path)
258
+ def _coerce_prim(self, node: Any, t: type, path: Tuple[str, ...]) -> Any:
259
+ if t is str:
260
+ if isinstance(node, str):
261
+ return node
262
+ return str(node)
263
+ if t is int:
264
+ if isinstance(node, int):
265
+ return node
266
+ if isinstance(node, str) and node.strip().isdigit() or (isinstance(node, str) and node.strip().startswith("-") and node.strip()[1:].isdigit()):
267
+ return int(node)
268
+ raise ConfigurationError(f"Expected int at {'.'.join(path)}")
269
+ if t is float:
270
+ if isinstance(node, (int, float)):
271
+ return float(node)
272
+ if isinstance(node, str):
273
+ try:
274
+ return float(node)
275
+ except Exception:
276
+ pass
277
+ raise ConfigurationError(f"Expected float at {'.'.join(path)}")
278
+ if t is bool:
279
+ if isinstance(node, bool):
280
+ return node
281
+ if isinstance(node, str):
282
+ s = node.strip().lower()
283
+ if s in ("1", "true", "yes", "on", "y", "t"):
284
+ return True
285
+ if s in ("0", "false", "no", "off", "n", "f"):
286
+ return False
287
+ raise ConfigurationError(f"Expected bool at {'.'.join(path)}")
288
+ return node
289
+
pico_ioc/constants.py ADDED
@@ -0,0 +1,10 @@
1
+ import logging
2
+
3
+ LOGGER_NAME = "pico_ioc"
4
+ LOGGER = logging.getLogger(LOGGER_NAME)
5
+
6
+ PICO_INFRA = "_pico_infra"
7
+ PICO_NAME = "_pico_name"
8
+ PICO_KEY = "_pico_key"
9
+ PICO_META = "_pico_meta"
10
+