pico-ioc 2.0.0__py3-none-any.whl → 2.0.2__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.
pico_ioc/api.py CHANGED
@@ -1,4 +1,3 @@
1
- # src/pico_ioc/api.py
2
1
  import os
3
2
  import json
4
3
  import inspect
@@ -7,12 +6,11 @@ import importlib
7
6
  import pkgutil
8
7
  import logging
9
8
  from dataclasses import is_dataclass, fields, dataclass, MISSING
10
- from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, get_args, get_origin, Annotated, Protocol
9
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, get_args, get_origin, Annotated, Protocol, Mapping
11
10
  from .constants import LOGGER, PICO_INFRA, PICO_NAME, PICO_KEY, PICO_META
12
11
  from .exceptions import (
13
12
  ProviderNotFoundError,
14
13
  CircularDependencyError,
15
- ComponentCreationError,
16
14
  ScopeError,
17
15
  ConfigurationError,
18
16
  SerializationError,
@@ -56,6 +54,31 @@ class FileSource:
56
54
  return str(v)
57
55
  return None
58
56
 
57
+ class FlatDictSource(ConfigSource):
58
+ def __init__(self, data: Mapping[str, Any], prefix: str = "", case_sensitive: bool = True):
59
+ base = dict(data)
60
+ if case_sensitive:
61
+ self._data = {str(k): v for k, v in base.items()}
62
+ self._prefix = prefix
63
+ else:
64
+ self._data = {str(k).upper(): v for k, v in base.items()}
65
+ self._prefix = prefix.upper()
66
+ self._case_sensitive = case_sensitive
67
+
68
+ def get(self, key: str) -> Optional[str]:
69
+ if not key:
70
+ return None
71
+ k = f"{self._prefix}{key}" if self._prefix else key
72
+ if not self._case_sensitive:
73
+ k = k.upper()
74
+ v = self._data.get(k)
75
+ if v is None:
76
+ return None
77
+ if isinstance(v, (str, int, float, bool)):
78
+ return str(v)
79
+ return None
80
+
81
+
59
82
  def _meta_get(obj: Any) -> Dict[str, Any]:
60
83
  m = getattr(obj, PICO_META, None)
61
84
  if m is None:
@@ -63,73 +86,119 @@ def _meta_get(obj: Any) -> Dict[str, Any]:
63
86
  setattr(obj, PICO_META, m)
64
87
  return m
65
88
 
66
- def component(cls=None, *, name: Any = None):
89
+ def component(
90
+ cls=None,
91
+ *,
92
+ name: Any = None,
93
+ qualifiers: Iterable[str] = (),
94
+ scope: str = "singleton",
95
+ primary: bool = False,
96
+ lazy: bool = False,
97
+ conditional_profiles: Iterable[str] = (),
98
+ conditional_require_env: Iterable[str] = (),
99
+ conditional_predicate: Optional[Callable[[], bool]] = None,
100
+ on_missing_selector: Optional[object] = None,
101
+ on_missing_priority: int = 0,
102
+ ):
67
103
  def dec(c):
68
104
  setattr(c, PICO_INFRA, "component")
69
105
  setattr(c, PICO_NAME, name if name is not None else getattr(c, "__name__", str(c)))
70
106
  setattr(c, PICO_KEY, name if name is not None else c)
71
- _meta_get(c)
107
+ m = _meta_get(c)
108
+ m["qualifier"] = tuple(str(q) for q in qualifiers or ())
109
+ m["scope"] = scope
110
+ if primary:
111
+ m["primary"] = True
112
+ if lazy:
113
+ m["lazy"] = True
114
+ if conditional_profiles or conditional_require_env or conditional_predicate is not None:
115
+ m["conditional"] = {
116
+ "profiles": tuple(p for p in conditional_profiles or ()),
117
+ "require_env": tuple(e for e in conditional_require_env or ()),
118
+ "predicate": conditional_predicate,
119
+ }
120
+ if on_missing_selector is not None:
121
+ m["on_missing"] = {"selector": on_missing_selector, "priority": int(on_missing_priority)}
72
122
  return c
73
123
  return dec(cls) if cls else dec
74
124
 
75
- def factory(cls):
76
- setattr(cls, PICO_INFRA, "factory")
77
- setattr(cls, PICO_NAME, getattr(cls, "__name__", str(cls)))
78
- _meta_get(cls)
79
- return cls
80
-
81
- def provides(key: Any):
82
- def dec(fn):
83
- @functools.wraps(fn)
84
- def w(*a, **k):
85
- return fn(*a, **k)
86
- setattr(w, PICO_INFRA, "provides")
87
- setattr(w, PICO_NAME, key)
88
- setattr(w, PICO_KEY, key)
89
- _meta_get(w)
90
- return w
91
- return dec
92
-
93
- class Qualifier(str):
94
- __slots__ = ()
95
-
96
- def qualifier(*qs: Qualifier):
97
- def dec(cls):
98
- m = _meta_get(cls)
99
- cur = tuple(m.get("qualifier", ()))
100
- seen = set(cur)
101
- merged = list(cur)
102
- for q in qs:
103
- if q not in seen:
104
- merged.append(q)
105
- seen.add(q)
106
- m["qualifier"] = tuple(merged)
107
- return cls
108
- return dec
109
-
110
- def on_missing(selector: object, *, priority: int = 0):
111
- def dec(obj):
112
- m = _meta_get(obj)
113
- m["on_missing"] = {"selector": selector, "priority": int(priority)}
114
- return obj
115
- return dec
125
+ def factory(
126
+ cls=None,
127
+ *,
128
+ name: Any = None,
129
+ qualifiers: Iterable[str] = (),
130
+ scope: str = "singleton",
131
+ primary: bool = False,
132
+ lazy: bool = False,
133
+ conditional_profiles: Iterable[str] = (),
134
+ conditional_require_env: Iterable[str] = (),
135
+ conditional_predicate: Optional[Callable[[], bool]] = None,
136
+ on_missing_selector: Optional[object] = None,
137
+ on_missing_priority: int = 0,
138
+ ):
139
+ def dec(c):
140
+ setattr(c, PICO_INFRA, "factory")
141
+ setattr(c, PICO_NAME, name if name is not None else getattr(c, "__name__", str(c)))
142
+ m = _meta_get(c)
143
+ m["qualifier"] = tuple(str(q) for q in qualifiers or ())
144
+ m["scope"] = scope
145
+ if primary:
146
+ m["primary"] = True
147
+ if lazy:
148
+ m["lazy"] = True
149
+ if conditional_profiles or conditional_require_env or conditional_predicate is not None:
150
+ m["conditional"] = {
151
+ "profiles": tuple(p for p in conditional_profiles or ()),
152
+ "require_env": tuple(e for e in conditional_require_env or ()),
153
+ "predicate": conditional_predicate,
154
+ }
155
+ if on_missing_selector is not None:
156
+ m["on_missing"] = {"selector": on_missing_selector, "priority": int(on_missing_priority)}
157
+ return c
158
+ return dec(cls) if cls else dec
116
159
 
117
- def primary(obj):
118
- m = _meta_get(obj)
119
- m["primary"] = True
120
- return obj
160
+ def provides(*dargs, **dkwargs):
161
+ def _apply(fn, key_hint, *, name=None, qualifiers=(), scope="singleton", primary=False, lazy=False, conditional_profiles=(), conditional_require_env=(), conditional_predicate=None, on_missing_selector=None, on_missing_priority=0):
162
+ target = fn.__func__ if isinstance(fn, (staticmethod, classmethod)) else fn
163
+ inferred_key = key_hint
164
+ if inferred_key is MISSING:
165
+ rt = _get_return_type(target)
166
+ if isinstance(rt, type):
167
+ inferred_key = rt
168
+ else:
169
+ inferred_key = getattr(target, "__name__", str(target))
170
+ setattr(target, PICO_INFRA, "provides")
171
+ pico_name = name if name is not None else (inferred_key if isinstance(inferred_key, str) else getattr(target, "__name__", str(target)))
172
+ setattr(target, PICO_NAME, pico_name)
173
+ setattr(target, PICO_KEY, inferred_key)
174
+ m = _meta_get(target)
175
+ m["qualifier"] = tuple(str(q) for q in qualifiers or ())
176
+ m["scope"] = scope
177
+ if primary:
178
+ m["primary"] = True
179
+ if lazy:
180
+ m["lazy"] = True
181
+ if conditional_profiles or conditional_require_env or conditional_predicate is not None:
182
+ m["conditional"] = {
183
+ "profiles": tuple(p for p in conditional_profiles or ()),
184
+ "require_env": tuple(e for e in conditional_require_env or ()),
185
+ "predicate": conditional_predicate,
186
+ }
187
+ if on_missing_selector is not None:
188
+ m["on_missing"] = {"selector": on_missing_selector, "priority": int(on_missing_priority)}
189
+ return fn
121
190
 
122
- def conditional(*, profiles: Tuple[str, ...] = (), require_env: Tuple[str, ...] = (), predicate: Optional[Callable[[], bool]] = None):
123
- def dec(obj):
124
- m = _meta_get(obj)
125
- m["conditional"] = {"profiles": tuple(profiles), "require_env": tuple(require_env), "predicate": predicate}
126
- return obj
127
- return dec
191
+ if dargs and len(dargs) == 1 and inspect.isfunction(dargs[0]) and not dkwargs:
192
+ fn = dargs[0]
193
+ return _apply(fn, MISSING)
194
+ else:
195
+ key = dargs[0] if dargs else MISSING
196
+ def _decorator(fn):
197
+ return _apply(fn, key, **dkwargs)
198
+ return _decorator
128
199
 
129
- def lazy(obj):
130
- m = _meta_get(obj)
131
- m["lazy"] = True
132
- return obj
200
+ class Qualifier(str):
201
+ __slots__ = ()
133
202
 
134
203
  def configuration(cls=None, *, prefix: Optional[str] = None):
135
204
  def dec(c):
@@ -150,13 +219,6 @@ def cleanup(fn):
150
219
  m["cleanup"] = True
151
220
  return fn
152
221
 
153
- def scope(name: str):
154
- def dec(obj):
155
- m = _meta_get(obj)
156
- m["scope"] = name
157
- return obj
158
- return dec
159
-
160
222
  def configured(target: Any, *, prefix: Optional[str] = None):
161
223
  def dec(cls):
162
224
  setattr(cls, PICO_INFRA, "configured")
@@ -278,27 +340,82 @@ def _collect_by_type(locator: ComponentLocator, t: type, q: Optional[str]):
278
340
  out.append(k)
279
341
  return out
280
342
 
281
- def _resolve_args(callable_obj: Callable[..., Any], pico: "PicoContainer") -> Dict[str, Any]:
282
- sig = inspect.signature(callable_obj)
283
- kwargs: Dict[str, Any] = {}
343
+ def _find_key_by_name(locator: Optional[ComponentLocator], name: str) -> Optional[KeyT]:
344
+ if not locator:
345
+ return None
346
+ for k, md in locator._metadata.items():
347
+ if md.pico_name == name:
348
+ return k
349
+ typ = md.provided_type or md.concrete_class
350
+ if isinstance(typ, type) and getattr(typ, "__name__", "") == name:
351
+ return k
352
+ return None
353
+
354
+ def _normalize_callable(obj):
355
+ return getattr(obj, '__func__', obj)
356
+
357
+ def _get_signature_safe(callable_obj):
358
+ try:
359
+ return inspect.signature(callable_obj)
360
+ except (ValueError, TypeError):
361
+ wrapped = getattr(callable_obj, '__wrapped__', None)
362
+ if wrapped is not None:
363
+ return inspect.signature(wrapped)
364
+ raise
365
+
366
+ @functools.lru_cache(maxsize=1024)
367
+ def _analyze_signature_template(callable_obj):
368
+ callable_obj = _normalize_callable(callable_obj)
369
+ sig = _get_signature_safe(callable_obj)
370
+ plan = []
284
371
  for name, param in sig.parameters.items():
285
- if name in ("self", "cls"):
286
- continue
287
- if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
288
- continue
372
+ if name in ("self", "cls"): continue
373
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): continue
289
374
  ann = param.annotation
290
375
  is_list, elem_t, qual = _extract_list_req(ann)
291
- if is_list and pico._locator is not None and isinstance(elem_t, type):
292
- keys = _collect_by_type(pico._locator, elem_t, qual)
293
- kwargs[name] = [pico.get(k) for k in keys]
376
+ if is_list:
377
+ plan.append(("list", name, (elem_t, qual)))
378
+ else:
379
+ plan.append(("key", name, ann))
380
+ return tuple(plan)
381
+
382
+ def _compile_argplan(callable_obj, pico):
383
+ template = _analyze_signature_template(callable_obj)
384
+ locator = pico._locator
385
+ items = []
386
+ for kind, name, data in template:
387
+ if kind == "list":
388
+ elem_t, qual = data
389
+ if locator is not None and isinstance(elem_t, type):
390
+ keys = _collect_by_type(locator, elem_t, qual)
391
+ items.append(("list", name, tuple(keys)))
294
392
  continue
393
+ ann = data
295
394
  if ann is not inspect._empty and isinstance(ann, type):
296
- key: KeyT = ann
297
- elif ann is not inspect._empty and isinstance(ann, str):
298
395
  key = ann
396
+ elif ann is not inspect._empty and isinstance(ann, str):
397
+ mapped = _find_key_by_name(locator, ann)
398
+ key = mapped if mapped is not None else ann
299
399
  else:
300
400
  key = name
301
- kwargs[name] = pico.get(key)
401
+ items.append(("key", name, key))
402
+ return tuple(items)
403
+
404
+ def _resolve_args(callable_obj: Callable[..., Any], pico: "PicoContainer") -> Dict[str, Any]:
405
+ plan = _compile_argplan(callable_obj, pico)
406
+ kwargs: Dict[str, Any] = {}
407
+ tracer = pico._tracer
408
+ prev = tracer.override_via("constructor")
409
+ try:
410
+ for kind, name, data in plan:
411
+ if kind == "key":
412
+ tracer.note_param(data if isinstance(data, (str, type)) else name, name)
413
+ kwargs[name] = pico.get(data)
414
+ else:
415
+ vals = [pico.get(k) for k in data]
416
+ kwargs[name] = vals
417
+ finally:
418
+ tracer.restore_via(prev)
302
419
  return kwargs
303
420
 
304
421
  def _needs_async_configure(obj: Any) -> bool:
@@ -445,9 +562,12 @@ class Registrar:
445
562
  self._resolver = ConfigResolver(tuple(tree_sources))
446
563
  self._adapters = TypeAdapterRegistry()
447
564
  self._graph = ObjectGraphBuilder(self._resolver, self._adapters)
565
+ self._provides_functions: Dict[KeyT, Callable[..., Any]] = {}
448
566
 
449
567
  def locator(self) -> ComponentLocator:
450
- return ComponentLocator(self._metadata, self._indexes)
568
+ loc = ComponentLocator(self._metadata, self._indexes)
569
+ setattr(loc, "_provides_functions", dict(self._provides_functions))
570
+ return loc
451
571
 
452
572
  def attach_runtime(self, pico, locator: ComponentLocator) -> None:
453
573
  for deferred in self._deferred:
@@ -476,24 +596,21 @@ class Registrar:
476
596
  return True
477
597
  p = set(c.get("profiles") or ())
478
598
  if p and not (p & self._profiles):
479
- self._log.info("excluded_by_profile name=%s need=%s active=%s", getattr(obj, "__name__", str(obj)), sorted(p), sorted(self._profiles))
480
599
  return False
481
600
  req = c.get("require_env") or ()
482
601
  for k in req:
483
602
  if k not in self._environ or not self._environ.get(k):
484
- self._log.info("excluded_by_env name=%s env=%s", getattr(obj, "__name__", str(obj)), k)
485
603
  return False
486
604
  pred = c.get("predicate")
487
605
  if pred is None:
488
606
  return True
489
607
  try:
490
608
  ok = bool(pred())
491
- except Exception as e:
492
- self._log.info("excluded_by_predicate_error name=%s error=%s", getattr(obj, "__name__", str(obj)), repr(e))
609
+ except Exception:
493
610
  return False
494
611
  if not ok:
495
- self._log.info("excluded_by_predicate name=%s", getattr(obj, "__name__", str(obj)))
496
- return ok
612
+ return False
613
+ return True
497
614
 
498
615
  def _register_component_class(self, cls: type) -> None:
499
616
  if not self._enabled_by_condition(cls):
@@ -510,19 +627,48 @@ class Registrar:
510
627
  return
511
628
  for name in dir(cls):
512
629
  try:
513
- real = getattr(cls, name)
630
+ raw = inspect.getattr_static(cls, name)
514
631
  except Exception:
515
632
  continue
516
- if callable(real) and getattr(real, PICO_INFRA, None) == "provides":
517
- if not self._enabled_by_condition(real):
518
- continue
519
- k = getattr(real, PICO_KEY)
633
+ fn = None
634
+ kind = None
635
+ if isinstance(raw, staticmethod):
636
+ fn = raw.__func__
637
+ kind = "static"
638
+ elif isinstance(raw, classmethod):
639
+ fn = raw.__func__
640
+ kind = "class"
641
+ elif inspect.isfunction(raw):
642
+ fn = raw
643
+ kind = "instance"
644
+ else:
645
+ continue
646
+ if getattr(fn, PICO_INFRA, None) != "provides":
647
+ continue
648
+ if not self._enabled_by_condition(fn):
649
+ continue
650
+ k = getattr(fn, PICO_KEY)
651
+ if kind == "instance":
520
652
  provider = DeferredProvider(lambda pico, loc, fc=cls, mn=name: _build_method(getattr(_build_class(fc, pico, loc), mn), pico, loc))
521
- rt = _get_return_type(real)
522
- qset = set(str(q) for q in getattr(real, PICO_META, {}).get("qualifier", ()))
523
- sc = getattr(real, PICO_META, {}).get("scope", getattr(cls, PICO_META, {}).get("scope", "singleton"))
524
- 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(real, PICO_META, {}).get("primary")), lazy=bool(getattr(real, PICO_META, {}).get("lazy", False)), infra=getattr(cls, PICO_INFRA, None), pico_name=getattr(real, PICO_NAME, None), scope=sc)
525
- self._queue(k, provider, md)
653
+ else:
654
+ provider = DeferredProvider(lambda pico, loc, f=fn: _build_method(f, pico, loc))
655
+ rt = _get_return_type(fn)
656
+ qset = set(str(q) for q in getattr(fn, PICO_META, {}).get("qualifier", ()))
657
+ sc = getattr(fn, PICO_META, {}).get("scope", getattr(cls, PICO_META, {}).get("scope", "singleton"))
658
+ md = ProviderMetadata(
659
+ key=k,
660
+ provided_type=rt if isinstance(rt, type) else (k if isinstance(k, type) else None),
661
+ concrete_class=None,
662
+ factory_class=cls,
663
+ factory_method=name,
664
+ qualifiers=qset,
665
+ primary=bool(getattr(fn, PICO_META, {}).get("primary")),
666
+ lazy=bool(getattr(fn, PICO_META, {}).get("lazy", False)),
667
+ infra=getattr(cls, PICO_INFRA, None),
668
+ pico_name=getattr(fn, PICO_NAME, None),
669
+ scope=sc
670
+ )
671
+ self._queue(k, provider, md)
526
672
 
527
673
  def _register_configuration_class(self, cls: type) -> None:
528
674
  if not self._enabled_by_condition(cls):
@@ -551,6 +697,30 @@ class Registrar:
551
697
  md = ProviderMetadata(key=target, provided_type=target, concrete_class=None, factory_class=None, factory_method=None, qualifiers=qset, primary=True, lazy=False, infra="configured", pico_name=prefix, scope=sc)
552
698
  self._queue(target, provider, md)
553
699
 
700
+ def _register_provides_function(self, fn: Callable[..., Any]) -> None:
701
+ if not self._enabled_by_condition(fn):
702
+ return
703
+ k = getattr(fn, PICO_KEY)
704
+ provider = DeferredProvider(lambda pico, loc, f=fn: _build_method(f, pico, loc))
705
+ rt = _get_return_type(fn)
706
+ qset = set(str(q) for q in getattr(fn, PICO_META, {}).get("qualifier", ()))
707
+ sc = getattr(fn, PICO_META, {}).get("scope", "singleton")
708
+ md = ProviderMetadata(
709
+ key=k,
710
+ provided_type=rt if isinstance(rt, type) else (k if isinstance(k, type) else None),
711
+ concrete_class=None,
712
+ factory_class=None,
713
+ factory_method=getattr(fn, "__name__", None),
714
+ qualifiers=qset,
715
+ primary=bool(getattr(fn, PICO_META, {}).get("primary")),
716
+ lazy=bool(getattr(fn, PICO_META, {}).get("lazy", False)),
717
+ infra="provides",
718
+ pico_name=getattr(fn, PICO_NAME, None),
719
+ scope=sc
720
+ )
721
+ self._queue(k, provider, md)
722
+ self._provides_functions[k] = fn
723
+
554
724
  def register_module(self, module: Any) -> None:
555
725
  for _, obj in inspect.getmembers(module):
556
726
  if inspect.isclass(obj):
@@ -569,6 +739,9 @@ class Registrar:
569
739
  self._register_configuration_class(obj)
570
740
  elif infra == "configured":
571
741
  self._register_configured_class(obj)
742
+ for _, fn in inspect.getmembers(module, predicate=inspect.isfunction):
743
+ if getattr(fn, PICO_INFRA, None) == "provides":
744
+ self._register_provides_function(fn)
572
745
 
573
746
  def _prefix_exists(self, md: ProviderMetadata) -> bool:
574
747
  if md.infra != "configured":
@@ -634,6 +807,13 @@ class Registrar:
634
807
  dep = self._find_md_for_type(t)
635
808
  if dep and dep.scope != "singleton":
636
809
  return dep.scope
810
+ if md.infra == "provides":
811
+ fn = self._provides_functions.get(md.key)
812
+ if callable(fn):
813
+ for t in self._iter_param_types(fn):
814
+ dep = self._find_md_for_type(t)
815
+ if dep and dep.scope != "singleton":
816
+ return dep.scope
637
817
  return None
638
818
 
639
819
  def _promote_scopes(self) -> None:
@@ -681,9 +861,13 @@ class Registrar:
681
861
  if isinstance(t, type) and getattr(t, "__name__", "") == name:
682
862
  return k
683
863
  return None
684
-
864
+
685
865
  def _validate_bindings(self) -> None:
686
866
  errors: List[str] = []
867
+
868
+ def _fmt(k: KeyT) -> str:
869
+ return getattr(k, '__name__', str(k))
870
+
687
871
  def _skip_type(t: type) -> bool:
688
872
  if t in (str, int, float, bool, bytes):
689
873
  return True
@@ -692,6 +876,7 @@ class Registrar:
692
876
  if getattr(t, "_is_protocol", False):
693
877
  return True
694
878
  return False
879
+
695
880
  def _should_validate(param: inspect.Parameter) -> bool:
696
881
  if param.default is not inspect._empty:
697
882
  return False
@@ -702,18 +887,44 @@ class Registrar:
702
887
  if type(None) in args:
703
888
  return False
704
889
  return True
890
+
705
891
  loc = ComponentLocator(self._metadata, self._indexes)
892
+
706
893
  for k, md in self._metadata.items():
707
894
  if md.infra == "configuration":
708
895
  continue
896
+
709
897
  callables_to_check: List[Callable[..., Any]] = []
898
+ loc_name = "unknown"
899
+
710
900
  if md.concrete_class is not None:
711
901
  callables_to_check.append(md.concrete_class.__init__)
712
- if md.factory_class is not None and md.factory_method is not None:
713
- fn = getattr(md.factory_class, md.factory_method)
714
- callables_to_check.append(fn)
902
+ loc_name = "constructor"
903
+ elif md.factory_class is not None and md.factory_method is not None:
904
+ try:
905
+ fn = getattr(md.factory_class, md.factory_method)
906
+ callables_to_check.append(fn)
907
+ loc_name = f"factory {_fmt(md.factory_class)}.{md.factory_method}"
908
+ except AttributeError:
909
+ errors.append(f"Component '{_fmt(k)}' refers to missing factory method '{md.factory_method}' on class '{_fmt(md.factory_class)}'")
910
+ continue
911
+ elif md.infra == "provides":
912
+ fn = self._provides_functions.get(k)
913
+ if callable(fn):
914
+ callables_to_check.append(fn)
915
+ loc_name = f"function {getattr(fn, '__name__', 'unknown')}"
916
+ else:
917
+ continue
918
+ else:
919
+ if not md.concrete_class and not md.factory_class and md.override:
920
+ continue
921
+
715
922
  for callable_obj in callables_to_check:
716
- sig = inspect.signature(callable_obj)
923
+ try:
924
+ sig = inspect.signature(callable_obj)
925
+ except (ValueError, TypeError):
926
+ continue
927
+
717
928
  for name, param in sig.parameters.items():
718
929
  if name in ("self", "cls"):
719
930
  continue
@@ -721,27 +932,45 @@ class Registrar:
721
932
  continue
722
933
  if not _should_validate(param):
723
934
  continue
935
+
724
936
  ann = param.annotation
725
937
  is_list, elem_t, qual = _extract_list_req(ann)
938
+
726
939
  if is_list:
727
940
  if qual:
728
941
  matching = loc.with_qualifier_any(qual).keys()
729
- if not matching:
730
- errors.append(f"{getattr(k,'__name__',k)} expects List[{getattr(elem_t,'__name__',elem_t)}] with qualifier '{qual}' but no matching components exist")
942
+ if not matching and isinstance(elem_t, type) and not _skip_type(elem_t):
943
+ errors.append(f"{_fmt(k)} ({loc_name}) expects List[{_fmt(elem_t)}] with qualifier '{qual}' but no matching components exist")
944
+ continue
945
+
946
+ elif isinstance(ann, str):
947
+ key_found_by_name = self._find_md_for_name(ann)
948
+ directly_bound = ann in self._metadata or self._factory.has(ann)
949
+ if not key_found_by_name and not directly_bound:
950
+ errors.append(f"{_fmt(k)} ({loc_name}) depends on string key '{ann}' which is not bound")
951
+ continue
952
+
953
+ elif isinstance(ann, type) and not _skip_type(ann):
954
+ dep_key_found = self._factory.has(ann) or ann in self._metadata
955
+ if not dep_key_found:
956
+ assignable_md = self._find_md_for_type(ann)
957
+ if assignable_md is None:
958
+ by_name_key = self._find_md_for_name(getattr(ann, "__name__", ""))
959
+ if by_name_key is None:
960
+ errors.append(f"{_fmt(k)} ({loc_name}) depends on {_fmt(ann)} which is not bound")
731
961
  continue
732
- if isinstance(ann, str):
733
- if ann in self._metadata or self._factory.has(ann) or self._find_md_for_name(ann) is not None:
734
- continue
735
- errors.append(f"{getattr(k,'__name__',k)} depends on string key '{ann}' which is not bound")
962
+
963
+ elif ann is inspect._empty:
964
+ name_key = name
965
+ key_found_by_name = self._find_md_for_name(name_key)
966
+ directly_bound = name_key in self._metadata or self._factory.has(name_key)
967
+ if not key_found_by_name and not directly_bound:
968
+ errors.append(f"{_fmt(k)} ({loc_name}) depends on parameter '{name}' with no type hint, and key '{name_key}' is not bound")
736
969
  continue
737
- if isinstance(ann, type) and not _skip_type(ann):
738
- dep = self._find_md_for_type(ann)
739
- if dep is None:
740
- loc_name = "constructor" if callable_obj.__name__ == "__init__" else f"factory {md.factory_class.__name__}.{md.factory_method}"
741
- errors.append(f"{getattr(k,'__name__',k)} {loc_name} depends on {getattr(ann,'__name__',ann)} which is not bound")
970
+
742
971
  if errors:
743
972
  raise InvalidBindingError(errors)
744
-
973
+
745
974
  def finalize(self, overrides: Optional[Dict[KeyT, Any]]) -> None:
746
975
  self.select_and_bind()
747
976
  self._promote_scopes()
@@ -774,7 +1003,8 @@ class Registrar:
774
1003
  self._rebuild_indexes()
775
1004
  self._validate_bindings()
776
1005
 
777
- def init(modules: Union[Any, Iterable[Any]], *, profiles: Tuple[str, ...] = (), allowed_profiles: Optional[Iterable[str]] = None, environ: Optional[Dict[str, str]] = None, overrides: Optional[Dict[KeyT, Any]] = None, logger: Optional[logging.Logger] = None, config: Tuple[ConfigSource, ...] = (), custom_scopes: Optional[Dict[str, "ScopeProtocol"]] = None, validate_only: bool = False, container_id: Optional[str] = None, tree_config: Tuple["TreeSource", ...] = ()) -> PicoContainer:
1006
+
1007
+ def init(modules: Union[Any, Iterable[Any]], *, profiles: Tuple[str, ...] = (), allowed_profiles: Optional[Iterable[str]] = None, environ: Optional[Dict[str, str]] = None, overrides: Optional[Dict[KeyT, Any]] = None, logger: Optional[logging.Logger] = None, config: Tuple[ConfigSource, ...] = (), custom_scopes: Optional[Dict[str, "ScopeProtocol"]] = None, validate_only: bool = False, container_id: Optional[str] = None, tree_config: Tuple["TreeSource", ...] = (), observers: Optional[List["ContainerObserver"]] = None,) -> PicoContainer:
778
1008
  active = tuple(p.strip() for p in profiles if p)
779
1009
  allowed_set = set(a.strip() for a in allowed_profiles) if allowed_profiles is not None else None
780
1010
  if allowed_set is not None:
@@ -787,7 +1017,14 @@ def init(modules: Union[Any, Iterable[Any]], *, profiles: Tuple[str, ...] = (),
787
1017
  if custom_scopes:
788
1018
  for n, impl in custom_scopes.items():
789
1019
  scopes.register_scope(n, impl)
790
- pico = PicoContainer(factory, caches, scopes, container_id=container_id, profiles=active)
1020
+ pico = PicoContainer(
1021
+ factory,
1022
+ caches,
1023
+ scopes,
1024
+ container_id=container_id,
1025
+ profiles=active,
1026
+ observers=observers or [],
1027
+ )
791
1028
  registrar = Registrar(factory, profiles=active, environ=environ, logger=logger, config=config, tree_sources=tree_config)
792
1029
  for m in _iter_input_modules(modules):
793
1030
  registrar.register_module(m)
@@ -797,9 +1034,153 @@ def init(modules: Union[Any, Iterable[Any]], *, profiles: Tuple[str, ...] = (),
797
1034
  factory.bind(k, prov)
798
1035
  registrar.finalize(overrides)
799
1036
  if validate_only:
1037
+ locator = registrar.locator()
1038
+ pico.attach_locator(locator)
1039
+ _fail_fast_cycle_check(pico)
800
1040
  return pico
801
1041
  locator = registrar.locator()
802
1042
  registrar.attach_runtime(pico, locator)
803
1043
  pico.attach_locator(locator)
1044
+ _fail_fast_cycle_check(pico)
804
1045
  return pico
805
1046
 
1047
+ def _dependency_keys_for(key: KeyT, pico: "PicoContainer") -> Tuple[KeyT, ...]:
1048
+ loc = pico._locator
1049
+ if not loc:
1050
+ return ()
1051
+ md = loc._metadata.get(key)
1052
+ if not md:
1053
+ return ()
1054
+ if md.concrete_class is not None:
1055
+ fn = md.concrete_class.__init__
1056
+ elif md.factory_class is not None and md.factory_method is not None:
1057
+ fn = getattr(md.factory_class, md.factory_method)
1058
+ else:
1059
+ return ()
1060
+ plan = _compile_argplan(fn, pico)
1061
+ deps: List[KeyT] = []
1062
+ for kind, _, data in plan:
1063
+ if kind == "key":
1064
+ deps.append(data)
1065
+ else:
1066
+ for k in data:
1067
+ deps.append(k)
1068
+ return tuple(deps)
1069
+
1070
+ def _get_signature_static(fn: Callable[..., Any]) -> inspect.Signature:
1071
+ return inspect.signature(fn)
1072
+
1073
+ def _compile_argplan_static(callable_obj: Callable[..., Any], locator: ComponentLocator) -> Tuple[Tuple[str, str, Any], ...]:
1074
+ sig = _get_signature_static(callable_obj)
1075
+ items: List[Tuple[str, str, Any]] = []
1076
+ for name, param in sig.parameters.items():
1077
+ if name in ("self", "cls"):
1078
+ continue
1079
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
1080
+ continue
1081
+ ann = param.annotation
1082
+ is_list, elem_t, qual = _extract_list_req(ann)
1083
+ if is_list and locator is not None and isinstance(elem_t, type):
1084
+ keys = _collect_by_type(locator, elem_t, qual)
1085
+ items.append(("list", name, tuple(keys)))
1086
+ continue
1087
+ if ann is not inspect._empty and isinstance(ann, type):
1088
+ key: KeyT = ann
1089
+ elif ann is not inspect._empty and isinstance(ann, str):
1090
+ mapped = _find_key_by_name(locator, ann)
1091
+ key = mapped if mapped is not None else ann
1092
+ else:
1093
+ key = name
1094
+ items.append(("key", name, key))
1095
+ return tuple(items)
1096
+
1097
+ def _dependency_keys_for_static(md: ProviderMetadata, locator: ComponentLocator) -> Tuple[KeyT, ...]:
1098
+ if md.concrete_class is not None:
1099
+ fn = md.concrete_class.__init__
1100
+ elif md.factory_class is not None and md.factory_method is not None:
1101
+ fn = getattr(md.factory_class, md.factory_method)
1102
+ elif md.infra == "provides":
1103
+ fn = getattr(locator, "_provides_functions", {}).get(md.key)
1104
+ if not callable(fn):
1105
+ return ()
1106
+ else:
1107
+ return ()
1108
+ plan = _compile_argplan_static(fn, locator)
1109
+ deps: List[KeyT] = []
1110
+ for kind, _, data in plan:
1111
+ if kind == "key":
1112
+ deps.append(data)
1113
+ else:
1114
+ for k in data:
1115
+ deps.append(k)
1116
+ return tuple(deps)
1117
+
1118
+ def _build_resolution_graph(pico: "PicoContainer") -> Dict[KeyT, Tuple[KeyT, ...]]:
1119
+ loc = pico._locator
1120
+ if not loc:
1121
+ return {}
1122
+
1123
+ def _map_dep_to_bound_key(dep_key: KeyT) -> KeyT:
1124
+ if dep_key in loc._metadata:
1125
+ return dep_key
1126
+ if isinstance(dep_key, type):
1127
+ for k, md in loc._metadata.items():
1128
+ typ = md.provided_type or md.concrete_class
1129
+ if isinstance(typ, type):
1130
+ try:
1131
+ if issubclass(typ, dep_key):
1132
+ return k
1133
+ except Exception:
1134
+ continue
1135
+ return dep_key
1136
+
1137
+ graph: Dict[KeyT, Tuple[KeyT, ...]] = {}
1138
+ for key, md in list(loc._metadata.items()):
1139
+ deps: List[KeyT] = []
1140
+ for d in _dependency_keys_for_static(md, loc):
1141
+ mapped = _map_dep_to_bound_key(d)
1142
+ deps.append(mapped)
1143
+ graph[key] = tuple(deps)
1144
+ return graph
1145
+
1146
+
1147
+ def _find_cycle(graph: Dict[KeyT, Tuple[KeyT, ...]]) -> Optional[Tuple[KeyT, ...]]:
1148
+ temp: Set[KeyT] = set()
1149
+ perm: Set[KeyT] = set()
1150
+ stack: List[KeyT] = []
1151
+ def visit(n: KeyT) -> Optional[Tuple[KeyT, ...]]:
1152
+ if n in perm:
1153
+ return None
1154
+ if n in temp:
1155
+ try:
1156
+ idx = stack.index(n)
1157
+ return tuple(stack[idx:] + [n])
1158
+ except ValueError:
1159
+ return tuple([n, n])
1160
+ temp.add(n)
1161
+ stack.append(n)
1162
+ for m in graph.get(n, ()):
1163
+ c = visit(m)
1164
+ if c:
1165
+ return c
1166
+ stack.pop()
1167
+ temp.remove(n)
1168
+ perm.add(n)
1169
+ return None
1170
+ for node in graph.keys():
1171
+ c = visit(node)
1172
+ if c:
1173
+ return c
1174
+ return None
1175
+
1176
+ def _format_key(k: KeyT) -> str:
1177
+ return getattr(k, "__name__", str(k))
1178
+
1179
+ def _fail_fast_cycle_check(pico: "PicoContainer") -> None:
1180
+ graph = _build_resolution_graph(pico)
1181
+ cyc = _find_cycle(graph)
1182
+ if not cyc:
1183
+ return
1184
+ path = " -> ".join(_format_key(k) for k in cyc)
1185
+ raise InvalidBindingError([f"Circular dependency detected: {path}"])
1186
+