pico-ioc 2.0.5__py3-none-any.whl → 2.1.1__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,166 @@
1
+ import inspect
2
+ import os
3
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union, Set
4
+ from .constants import PICO_INFRA, PICO_NAME, PICO_KEY, PICO_META
5
+ from .factory import ProviderMetadata, DeferredProvider
6
+ from .decorators import get_return_type
7
+ from .config_registrar import ConfigurationManager
8
+ from .analysis import analyze_callable_dependencies, DependencyRequest
9
+
10
+ KeyT = Union[str, type]
11
+ Provider = Callable[[], Any]
12
+
13
+ class ComponentScanner:
14
+ def __init__(self, profiles: Set[str], environ: Dict[str, str], config_manager: ConfigurationManager):
15
+ self._profiles = profiles
16
+ self._environ = environ
17
+ self._config_manager = config_manager
18
+ self._candidates: Dict[KeyT, List[Tuple[bool, Provider, ProviderMetadata]]] = {}
19
+ self._on_missing: List[Tuple[int, KeyT, type]] = []
20
+ self._deferred: List[DeferredProvider] = []
21
+ self._provides_functions: Dict[KeyT, Callable[..., Any]] = {}
22
+
23
+ def get_scan_results(self) -> Tuple[Dict[KeyT, List[Tuple[bool, Provider, ProviderMetadata]]], List[Tuple[int, KeyT, type]], List[DeferredProvider], Dict[KeyT, Callable[..., Any]]]:
24
+ return self._candidates, self._on_missing, self._deferred, self._provides_functions
25
+
26
+ def _queue(self, key: KeyT, provider: Provider, md: ProviderMetadata) -> None:
27
+ lst = self._candidates.setdefault(key, [])
28
+ lst.append((md.primary, provider, md))
29
+ if isinstance(provider, DeferredProvider):
30
+ self._deferred.append(provider)
31
+
32
+ def _enabled_by_condition(self, obj: Any) -> bool:
33
+ meta = getattr(obj, PICO_META, {})
34
+ c = meta.get("conditional", None)
35
+ if not c:
36
+ return True
37
+
38
+ p = set(c.get("profiles") or ())
39
+ if p and not (p & self._profiles):
40
+ return False
41
+
42
+ req = c.get("require_env") or ()
43
+ for k in req:
44
+ if k not in self._environ or not self._environ.get(k):
45
+ return False
46
+
47
+ pred = c.get("predicate")
48
+ if pred is None:
49
+ return True
50
+
51
+ try:
52
+ ok = bool(pred())
53
+ except Exception:
54
+ return False
55
+ if not ok:
56
+ return False
57
+
58
+ return True
59
+
60
+ def _register_component_class(self, cls: type) -> None:
61
+ if not self._enabled_by_condition(cls):
62
+ return
63
+ key = getattr(cls, PICO_KEY, cls)
64
+ qset = set(str(q) for q in getattr(cls, PICO_META, {}).get("qualifier", ()))
65
+ sc = getattr(cls, PICO_META, {}).get("scope", "singleton")
66
+ deps = analyze_callable_dependencies(cls.__init__)
67
+ provider = DeferredProvider(lambda pico, loc, c=cls, d=deps: pico.build_class(c, loc, d))
68
+ md = ProviderMetadata(key=key, provided_type=cls, concrete_class=cls, factory_class=None, factory_method=None, qualifiers=qset, primary=bool(getattr(cls, PICO_META, {}).get("primary")), lazy=bool(getattr(cls, PICO_META, {}).get("lazy", False)), infra=getattr(cls, PICO_INFRA, None), pico_name=getattr(cls, PICO_NAME, None), scope=sc, dependencies=deps)
69
+ self._queue(key, provider, md)
70
+
71
+ def _register_factory_class(self, cls: type) -> None:
72
+ if not self._enabled_by_condition(cls):
73
+ return
74
+
75
+ factory_deps: Optional[Tuple[DependencyRequest, ...]] = None
76
+ has_instance_provides = False
77
+ for name in dir(cls):
78
+ try:
79
+ raw = inspect.getattr_static(cls, name)
80
+ if inspect.isfunction(raw) and getattr(raw, PICO_INFRA, None) == "provides":
81
+ has_instance_provides = True
82
+ break
83
+ except Exception:
84
+ continue
85
+
86
+ if has_instance_provides:
87
+ factory_deps = analyze_callable_dependencies(cls.__init__)
88
+
89
+
90
+ for name in dir(cls):
91
+ try:
92
+ raw = inspect.getattr_static(cls, name)
93
+ except Exception:
94
+ continue
95
+
96
+ fn = None
97
+ kind = None
98
+ if isinstance(raw, staticmethod):
99
+ fn = raw.__func__
100
+ kind = "static"
101
+ elif isinstance(raw, classmethod):
102
+ fn = raw.__func__
103
+ kind = "class"
104
+ elif inspect.isfunction(raw):
105
+ fn = raw
106
+ kind = "instance"
107
+ else:
108
+ continue
109
+
110
+ if getattr(fn, PICO_INFRA, None) != "provides":
111
+ continue
112
+ if not self._enabled_by_condition(fn):
113
+ continue
114
+
115
+ k = getattr(fn, PICO_KEY)
116
+ deps = analyze_callable_dependencies(fn)
117
+
118
+ if kind == "instance":
119
+ if factory_deps is None: factory_deps = analyze_callable_dependencies(cls.__init__)
120
+ provider = DeferredProvider(lambda pico, loc, fc=cls, mn=name, df=factory_deps, dm=deps: pico.build_method(getattr(pico.build_class(fc, loc, df), mn), loc, dm))
121
+ else:
122
+ provider = DeferredProvider(lambda pico, loc, f=fn, d=deps: pico.build_method(f, loc, d))
123
+
124
+ rt = get_return_type(fn)
125
+ qset = set(str(q) for q in getattr(fn, PICO_META, {}).get("qualifier", ()))
126
+ sc = getattr(fn, PICO_META, {}).get("scope", getattr(cls, PICO_META, {}).get("scope", "singleton"))
127
+ md = ProviderMetadata(key=k, provided_type=rt if isinstance(rt, type) else (k if isinstance(k, type) else None), concrete_class=None, factory_class=cls, factory_method=name, qualifiers=qset, primary=bool(getattr(fn, PICO_META, {}).get("primary")), lazy=bool(getattr(fn, PICO_META, {}).get("lazy", False)), infra=getattr(cls, PICO_INFRA, None), pico_name=getattr(fn, PICO_NAME, None), scope=sc, dependencies=deps)
128
+ self._queue(k, provider, md)
129
+
130
+ def _register_provides_function(self, fn: Callable[..., Any]) -> None:
131
+ if not self._enabled_by_condition(fn):
132
+ return
133
+ k = getattr(fn, PICO_KEY)
134
+ deps = analyze_callable_dependencies(fn)
135
+ provider = DeferredProvider(lambda pico, loc, f=fn, d=deps: pico.build_method(f, loc, d))
136
+ rt = get_return_type(fn)
137
+ qset = set(str(q) for q in getattr(fn, PICO_META, {}).get("qualifier", ()))
138
+ sc = getattr(fn, PICO_META, {}).get("scope", "singleton")
139
+ md = ProviderMetadata(key=k, provided_type=rt if isinstance(rt, type) else (k if isinstance(k, type) else None), concrete_class=None, factory_class=None, factory_method=getattr(fn, "__name__", None), qualifiers=qset, primary=bool(getattr(fn, PICO_META, {}).get("primary")), lazy=bool(getattr(fn, PICO_META, {}).get("lazy", False)), infra="provides", pico_name=getattr(fn, PICO_NAME, None), scope=sc, dependencies=deps)
140
+ self._queue(k, provider, md)
141
+ self._provides_functions[k] = fn
142
+
143
+ def scan_module(self, module: Any) -> None:
144
+ for _, obj in inspect.getmembers(module):
145
+ if inspect.isclass(obj):
146
+ meta = getattr(obj, PICO_META, {})
147
+ if "on_missing" in meta:
148
+ sel = meta["on_missing"]["selector"]
149
+ pr = int(meta["on_missing"].get("priority", 0))
150
+ self._on_missing.append((pr, sel, obj))
151
+ continue
152
+
153
+ infra = getattr(obj, PICO_INFRA, None)
154
+ if infra == "component":
155
+ self._register_component_class(obj)
156
+ elif infra == "factory":
157
+ self._register_factory_class(obj)
158
+ elif infra == "configured":
159
+ enabled = self._enabled_by_condition(obj)
160
+ reg_data = self._config_manager.register_configured_class(obj, enabled)
161
+ if reg_data:
162
+ self._queue(reg_data[0], reg_data[1], reg_data[2])
163
+
164
+ for _, fn in inspect.getmembers(module, predicate=inspect.isfunction):
165
+ if getattr(fn, PICO_INFRA, None) == "provides":
166
+ self._register_provides_function(fn)
@@ -0,0 +1,87 @@
1
+ import os
2
+ import json
3
+ from dataclasses import dataclass
4
+ from typing import Any, Optional, Protocol, Mapping, List, Tuple, Dict, Union
5
+
6
+ from .config_runtime import TreeSource, DictSource, JsonTreeSource, YamlTreeSource, Value
7
+ from .exceptions import ConfigurationError
8
+
9
+ class ConfigSource(Protocol):
10
+ pass
11
+
12
+ class EnvSource(ConfigSource):
13
+ def __init__(self, prefix: str = "") -> None:
14
+ self.prefix = prefix
15
+ def get(self, key: str) -> Optional[str]:
16
+ return os.environ.get(self.prefix + key)
17
+
18
+ class FileSource(ConfigSource):
19
+ def __init__(self, path: str, prefix: str = "") -> None:
20
+ self.prefix = prefix
21
+ try:
22
+ with open(path, "r", encoding="utf-8") as f:
23
+ self._data = json.load(f)
24
+ except Exception:
25
+ self._data = {}
26
+ def get(self, key: str) -> Optional[str]:
27
+ k = self.prefix + key
28
+ v = self._data
29
+ for part in k.split("__"):
30
+ if isinstance(v, dict) and part in v:
31
+ v = v[part]
32
+ else:
33
+ return None
34
+ if isinstance(v, (str, int, float, bool)):
35
+ return str(v)
36
+ return None
37
+
38
+ class FlatDictSource(ConfigSource):
39
+ def __init__(self, data: Mapping[str, Any], prefix: str = "", case_sensitive: bool = True):
40
+ base = dict(data)
41
+ if case_sensitive:
42
+ self._data = {str(k): v for k, v in base.items()}
43
+ self._prefix = prefix
44
+ else:
45
+ self._data = {str(k).upper(): v for k, v in base.items()}
46
+ self._prefix = prefix.upper()
47
+ self._case_sensitive = case_sensitive
48
+ def get(self, key: str) -> Optional[str]:
49
+ if not key:
50
+ return None
51
+ k = f"{self._prefix}{key}" if self._prefix else key
52
+ if not self._case_sensitive:
53
+ k = k.upper()
54
+ v = self._data.get(k)
55
+ if v is None:
56
+ return None
57
+ if isinstance(v, (str, int, float, bool)):
58
+ return str(v)
59
+ return None
60
+
61
+ @dataclass(frozen=True)
62
+ class ContextConfig:
63
+ flat_sources: Tuple[Union[EnvSource, FileSource, FlatDictSource], ...]
64
+ tree_sources: Tuple[TreeSource, ...]
65
+ overrides: Dict[str, Any]
66
+
67
+ def configuration(
68
+ *sources: Any,
69
+ overrides: Optional[Dict[str, Any]] = None
70
+ ) -> ContextConfig:
71
+
72
+ flat: List[Union[EnvSource, FileSource, FlatDictSource]] = []
73
+ tree: List[TreeSource] = []
74
+
75
+ for src in sources:
76
+ if isinstance(src, (EnvSource, FileSource, FlatDictSource)):
77
+ flat.append(src)
78
+ elif isinstance(src, TreeSource):
79
+ tree.append(src)
80
+ else:
81
+ raise ConfigurationError(f"Unknown configuration source type: {type(src)}")
82
+
83
+ return ContextConfig(
84
+ flat_sources=tuple(flat),
85
+ tree_sources=tuple(tree),
86
+ overrides=dict(overrides or {})
87
+ )
@@ -0,0 +1,236 @@
1
+ import inspect
2
+ from dataclasses import is_dataclass, fields, MISSING
3
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union, get_args, get_origin, Annotated
4
+ from .constants import PICO_INFRA, PICO_NAME, PICO_META
5
+ from .exceptions import ConfigurationError
6
+ from .factory import ProviderMetadata, DeferredProvider
7
+ from .config_builder import ContextConfig, ConfigSource, FlatDictSource, Value
8
+ from .config_runtime import ConfigResolver, TypeAdapterRegistry, ObjectGraphBuilder, TreeSource
9
+ from .analysis import analyze_callable_dependencies, DependencyRequest
10
+
11
+ KeyT = Union[str, type]
12
+ Provider = Callable[[], Any]
13
+
14
+ def _truthy(s: str) -> bool:
15
+ return s.strip().lower() in {"1", "true", "yes", "on", "y", "t"}
16
+
17
+ def _coerce(val: Optional[str], t: type) -> Any:
18
+ if val is None:
19
+ return None
20
+ if t is str:
21
+ return val
22
+ if t is int:
23
+ return int(val)
24
+ if t is float:
25
+ return float(val)
26
+ if t is bool:
27
+ return _truthy(val)
28
+ org = get_origin(t)
29
+ if org is Union:
30
+ args = [a for a in get_args(t) if a is not type(None)]
31
+ if not args:
32
+ return None
33
+ return _coerce(val, args[0])
34
+ return val
35
+
36
+ def _upper_key(name: str) -> str:
37
+ return name.upper()
38
+
39
+ def _lookup(sources: Tuple[ConfigSource, ...], key: str) -> Optional[str]:
40
+ for src in sources:
41
+ v = src.get(key)
42
+ if v is not None:
43
+ return v
44
+ return None
45
+
46
+ class ConfigurationManager:
47
+ def __init__(self, config: Optional[ContextConfig]) -> None:
48
+ cfg = config or ContextConfig(flat_sources=(), tree_sources=(), overrides={})
49
+
50
+ self._flat_config = cfg.flat_sources
51
+ self._overrides = cfg.overrides
52
+
53
+ self._resolver = ConfigResolver(cfg.tree_sources)
54
+ self._adapters = TypeAdapterRegistry()
55
+ self._graph = ObjectGraphBuilder(self._resolver, self._adapters)
56
+
57
+ def _lookup_flat(self, key: str) -> Optional[str]:
58
+ if key in self._overrides:
59
+ return str(self._overrides[key])
60
+ for src in self._flat_config:
61
+ v = src.get(key)
62
+ if v is not None:
63
+ return v
64
+ return None
65
+
66
+ def _build_flat_instance(self, cls: type, prefix: Optional[str]) -> Any:
67
+ if not is_dataclass(cls):
68
+ raise ConfigurationError(f"Configuration class {getattr(cls, '__name__', str(cls))} must be a dataclass")
69
+ values: Dict[str, Any] = {}
70
+ for f in fields(cls):
71
+ field_type = f.type
72
+ value_override = None
73
+
74
+ if get_origin(field_type) is Annotated:
75
+ args = get_args(field_type)
76
+ field_type = args[0] if args else Any
77
+ metas = args[1:] if len(args) > 1 else ()
78
+ for m in metas:
79
+ if isinstance(m, Value):
80
+ value_override = m.value
81
+ break
82
+
83
+ if value_override is not None:
84
+ values[f.name] = value_override
85
+ continue
86
+
87
+ base_key = _upper_key(f.name)
88
+ keys_to_try = []
89
+ if prefix:
90
+ keys_to_try.append(prefix + base_key)
91
+ keys_to_try.append(base_key)
92
+
93
+ raw = None
94
+ for k in keys_to_try:
95
+ raw = self._lookup_flat(k)
96
+ if raw is not None:
97
+ break
98
+
99
+ if raw is None:
100
+ if f.default is not MISSING or f.default_factory is not MISSING:
101
+ continue
102
+ raise ConfigurationError(f"Missing configuration key: {(prefix or '') + base_key}")
103
+ values[f.name] = _coerce(raw, field_type if isinstance(field_type, type) or get_origin(field_type) else str)
104
+ return cls(**values)
105
+
106
+ def _auto_detect_mapping(self, target_type: type) -> str:
107
+ if not is_dataclass(target_type):
108
+ return "tree"
109
+
110
+ primitives = (str, int, float, bool)
111
+ for f in fields(target_type):
112
+ t = f.type
113
+
114
+ if get_origin(t) is Annotated:
115
+ args = get_args(t)
116
+ t = args[0] if args else Any
117
+
118
+ origin = get_origin(t)
119
+
120
+ if origin in (list, List, dict, Dict, Union):
121
+ return "tree"
122
+ if isinstance(t, type) and is_dataclass(t):
123
+ return "tree"
124
+
125
+ base_type = t
126
+ is_optional = origin is Union and type(None) in get_args(t)
127
+ if is_optional:
128
+ real_args = [a for a in get_args(t) if a is not type(None)]
129
+ if real_args:
130
+ base_type = real_args[0]
131
+ else:
132
+ continue
133
+
134
+ if isinstance(base_type, type) and base_type not in primitives:
135
+ return "tree"
136
+
137
+ return "flat"
138
+
139
+ def register_configured_class(self, cls: type, enabled: bool) -> Optional[Tuple[KeyT, Provider, ProviderMetadata]]:
140
+ if not enabled:
141
+ return None
142
+
143
+ meta = getattr(cls, PICO_META, {})
144
+ cfg = meta.get("configured", None)
145
+ if not cfg:
146
+ return None
147
+
148
+ target = cfg.get("target")
149
+ prefix = cfg.get("prefix")
150
+ mapping = cfg.get("mapping", "auto")
151
+
152
+ if target == "self":
153
+ target = cls
154
+
155
+ if not isinstance(target, type):
156
+ return None
157
+
158
+ if mapping == "auto":
159
+ mapping = self._auto_detect_mapping(target)
160
+
161
+ graph_builder = self._graph
162
+ qset = set(str(q) for q in meta.get("qualifier", ()))
163
+ sc = meta.get("scope", "singleton")
164
+
165
+ if mapping == "tree":
166
+ provider = DeferredProvider(lambda pico, loc, t=target, p=prefix, g=graph_builder: g.build_from_prefix(t, p))
167
+ deps = ()
168
+ if not is_dataclass(target) and hasattr(target, "__init__"):
169
+ deps = analyze_callable_dependencies(target.__init__)
170
+ md = ProviderMetadata(
171
+ key=target,
172
+ provided_type=target,
173
+ concrete_class=None,
174
+ factory_class=None,
175
+ factory_method=None,
176
+ qualifiers=qset,
177
+ primary=True,
178
+ lazy=bool(meta.get("lazy", False)),
179
+ infra="configured",
180
+ pico_name=prefix,
181
+ scope=sc,
182
+ dependencies=deps
183
+ )
184
+ return (target, provider, md)
185
+
186
+ elif mapping == "flat":
187
+ if not is_dataclass(target):
188
+ raise ConfigurationError(f"Target class {target.__name__} for flat mapping must be a dataclass")
189
+
190
+ provider = DeferredProvider(lambda pico, loc, c=target, p=prefix: self._build_flat_instance(c, p))
191
+ md = ProviderMetadata(
192
+ key=target,
193
+ provided_type=target,
194
+ concrete_class=target,
195
+ factory_class=None,
196
+ factory_method=None,
197
+ qualifiers=qset,
198
+ primary=True,
199
+ lazy=bool(meta.get("lazy", False)),
200
+ infra="configured",
201
+ pico_name=prefix,
202
+ scope=sc,
203
+ dependencies=()
204
+ )
205
+ return (target, provider, md)
206
+
207
+ return None
208
+
209
+ def prefix_exists(self, md: ProviderMetadata) -> bool:
210
+ if md.infra != "configured":
211
+ return False
212
+
213
+ target_type = md.provided_type or md.concrete_class
214
+ if not isinstance(target_type, type):
215
+ return False
216
+
217
+ meta = getattr(target_type, PICO_META, {})
218
+ cfg = meta.get("configured", {})
219
+ mapping = cfg.get("mapping", "auto")
220
+
221
+ if mapping == "auto":
222
+ mapping = self._auto_detect_mapping(target_type)
223
+
224
+ if mapping == "tree":
225
+ try:
226
+ _ = self._resolver.subtree(md.pico_name)
227
+ return True
228
+ except Exception:
229
+ return False
230
+ else:
231
+ if not is_dataclass(target_type):
232
+ return False
233
+ prefix = md.pico_name or ""
234
+ keys = [_upper_key(f.name) for f in fields(target_type)]
235
+ return any(self._lookup_flat(prefix + k) is not None for k in keys)
236
+
@@ -1,4 +1,3 @@
1
- # src/pico_ioc/config_runtime.py
2
1
  import json
3
2
  import os
4
3
  import re
@@ -9,6 +8,10 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, Union, g
9
8
  from .exceptions import ConfigurationError
10
9
  from .constants import PICO_META
11
10
 
11
+ class Value:
12
+ def __init__(self, value: Any):
13
+ self.value = value
14
+
12
15
  class Discriminator:
13
16
  def __init__(self, name: str):
14
17
  self.name = name
@@ -153,18 +156,23 @@ class ObjectGraphBuilder:
153
156
  def _build(self, node: Any, t: Any, path: Tuple[str, ...]) -> Any:
154
157
  if t is Any or t is object:
155
158
  return node
159
+
156
160
  adapter = self._registry.get(t) if isinstance(t, type) else None
157
161
  if adapter:
158
162
  return adapter(node)
163
+
159
164
  org = get_origin(t)
165
+
160
166
  if org is Annotated:
161
- base, meta = self._split_annotated(t)
162
- return self._build_discriminated(node, base, meta, path)
167
+ base, metas = self._split_annotated(t)
168
+ return self._build_discriminated(node, base, metas, path)
169
+
163
170
  if org in (list, List):
164
171
  elem_t = get_args(t)[0] if get_args(t) else Any
165
172
  if not isinstance(node, list):
166
173
  raise ConfigurationError(f"Expected list at {'.'.join(path)}")
167
174
  return [self._build(x, elem_t, path + (str(i),)) for i, x in enumerate(node)]
175
+
168
176
  if org in (dict, Dict, Mapping):
169
177
  args = get_args(t)
170
178
  kt = args[0] if args else str
@@ -174,6 +182,7 @@ class ObjectGraphBuilder:
174
182
  if not isinstance(node, dict):
175
183
  raise ConfigurationError(f"Expected dict at {'.'.join(path)}")
176
184
  return {k: self._build(v, vt, path + (k,)) for k, v in node.items()}
185
+
177
186
  if org is Union:
178
187
  args = [a for a in get_args(t)]
179
188
  if not isinstance(node, dict):
@@ -183,6 +192,7 @@ class ObjectGraphBuilder:
183
192
  except Exception:
184
193
  continue
185
194
  raise ConfigurationError(f"No union match at {'.'.join(path)}")
195
+
186
196
  if "$type" in node:
187
197
  tn = str(node["$type"])
188
198
  for cand in args:
@@ -190,12 +200,14 @@ class ObjectGraphBuilder:
190
200
  cleaned = {k: v for k, v in node.items() if k != "$type"}
191
201
  return self._build(cleaned, cand, path)
192
202
  raise ConfigurationError(f"Discriminator $type did not match at {'.'.join(path)}")
203
+
193
204
  for cand in args:
194
205
  try:
195
206
  return self._build(node, cand, path)
196
207
  except Exception:
197
208
  continue
198
209
  raise ConfigurationError(f"No union match at {'.'.join(path)}")
210
+
199
211
  if isinstance(t, type) and issubclass(t, Enum):
200
212
  if isinstance(node, str):
201
213
  try:
@@ -205,6 +217,7 @@ class ObjectGraphBuilder:
205
217
  if str(e.value) == node:
206
218
  return e
207
219
  raise ConfigurationError(f"Invalid enum at {'.'.join(path)}")
220
+
208
221
  if isinstance(t, type) and is_dataclass(t):
209
222
  if not isinstance(node, dict):
210
223
  raise ConfigurationError(f"Expected object at {'.'.join(path)}")
@@ -219,6 +232,7 @@ class ObjectGraphBuilder:
219
232
  else:
220
233
  continue
221
234
  return t(**vals)
235
+
222
236
  if isinstance(t, type):
223
237
  if t in (str, int, float, bool):
224
238
  return self._coerce_prim(node, t, path)
@@ -234,27 +248,55 @@ class ObjectGraphBuilder:
234
248
  if name in node:
235
249
  kwargs[name] = self._build(node[name], p.annotation if p.annotation is not inspect._empty else Any, path + (name,))
236
250
  return t(**kwargs)
251
+
237
252
  return node
253
+
238
254
  def _split_annotated(self, t: Any) -> Tuple[Any, Tuple[Any, ...]]:
239
255
  args = get_args(t)
240
256
  base = args[0] if args else Any
241
257
  metas = tuple(args[1:]) if len(args) > 1 else ()
242
258
  return base, metas
259
+
243
260
  def _build_discriminated(self, node: Any, base: Any, metas: Tuple[Any, ...], path: Tuple[str, ...]) -> Any:
244
261
  disc_name = None
262
+ disc_value = None
263
+ has_value = False
264
+
245
265
  for m in metas:
246
266
  if isinstance(m, Discriminator):
247
267
  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)}")
268
+ if isinstance(m, Value):
269
+ disc_value = m.value
270
+ has_value = True
271
+
272
+ tn: Optional[str] = None
273
+
274
+ if disc_name and has_value:
275
+ tn = str(disc_value)
276
+ elif disc_name and isinstance(node, dict) and disc_name in node:
277
+ tn = str(node[disc_name])
278
+
279
+ if tn is not None and get_origin(base) is Union:
280
+ for cand in get_args(base):
281
+ if isinstance(cand, type) and getattr(cand, "__name__", "") == tn:
282
+
283
+ cleaned_node = {k: v for k, v in node.items() if k != disc_name}
284
+
285
+ if has_value:
286
+ cleaned_node[disc_name] = tn
287
+
288
+ return self._build(cleaned_node, cand, path)
289
+
290
+ raise ConfigurationError(
291
+ f"Discriminator value '{tn}' for field '{disc_name}' "
292
+ f"did not match any type in Union {base} at {'.'.join(path)}"
293
+ )
294
+
295
+ if has_value and not disc_name:
296
+ return disc_value
297
+
257
298
  return self._build(node, base, path)
299
+
258
300
  def _coerce_prim(self, node: Any, t: type, path: Tuple[str, ...]) -> Any:
259
301
  if t is str:
260
302
  if isinstance(node, str):
@@ -286,4 +328,3 @@ class ObjectGraphBuilder:
286
328
  return False
287
329
  raise ConfigurationError(f"Expected bool at {'.'.join(path)}")
288
330
  return node
289
-