pico-ioc 2.0.0__py3-none-any.whl → 2.0.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.
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
@@ -12,7 +11,6 @@ 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,
@@ -63,74 +61,120 @@ def _meta_get(obj: Any) -> Dict[str, Any]:
63
61
  setattr(obj, PICO_META, m)
64
62
  return m
65
63
 
66
- def component(cls=None, *, name: Any = None):
64
+ def component(
65
+ cls=None,
66
+ *,
67
+ name: Any = None,
68
+ qualifiers: Iterable[str] = (),
69
+ scope: str = "singleton",
70
+ primary: bool = False,
71
+ lazy: bool = False,
72
+ conditional_profiles: Iterable[str] = (),
73
+ conditional_require_env: Iterable[str] = (),
74
+ conditional_predicate: Optional[Callable[[], bool]] = None,
75
+ on_missing_selector: Optional[object] = None,
76
+ on_missing_priority: int = 0,
77
+ ):
67
78
  def dec(c):
68
79
  setattr(c, PICO_INFRA, "component")
69
80
  setattr(c, PICO_NAME, name if name is not None else getattr(c, "__name__", str(c)))
70
81
  setattr(c, PICO_KEY, name if name is not None else c)
71
- _meta_get(c)
82
+ m = _meta_get(c)
83
+ m["qualifier"] = tuple(str(q) for q in qualifiers or ())
84
+ m["scope"] = scope
85
+ if primary:
86
+ m["primary"] = True
87
+ if lazy:
88
+ m["lazy"] = True
89
+ if conditional_profiles or conditional_require_env or conditional_predicate is not None:
90
+ m["conditional"] = {
91
+ "profiles": tuple(p for p in conditional_profiles or ()),
92
+ "require_env": tuple(e for e in conditional_require_env or ()),
93
+ "predicate": conditional_predicate,
94
+ }
95
+ if on_missing_selector is not None:
96
+ m["on_missing"] = {"selector": on_missing_selector, "priority": int(on_missing_priority)}
72
97
  return c
73
98
  return dec(cls) if cls else dec
74
99
 
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
100
+ def factory(
101
+ cls=None,
102
+ *,
103
+ name: Any = None,
104
+ qualifiers: Iterable[str] = (),
105
+ scope: str = "singleton",
106
+ primary: bool = False,
107
+ lazy: bool = False,
108
+ conditional_profiles: Iterable[str] = (),
109
+ conditional_require_env: Iterable[str] = (),
110
+ conditional_predicate: Optional[Callable[[], bool]] = None,
111
+ on_missing_selector: Optional[object] = None,
112
+ on_missing_priority: int = 0,
113
+ ):
114
+ def dec(c):
115
+ setattr(c, PICO_INFRA, "factory")
116
+ setattr(c, PICO_NAME, name if name is not None else getattr(c, "__name__", str(c)))
117
+ m = _meta_get(c)
118
+ m["qualifier"] = tuple(str(q) for q in qualifiers or ())
119
+ m["scope"] = scope
120
+ if primary:
121
+ m["primary"] = True
122
+ if lazy:
123
+ m["lazy"] = True
124
+ if conditional_profiles or conditional_require_env or conditional_predicate is not None:
125
+ m["conditional"] = {
126
+ "profiles": tuple(p for p in conditional_profiles or ()),
127
+ "require_env": tuple(e for e in conditional_require_env or ()),
128
+ "predicate": conditional_predicate,
129
+ }
130
+ if on_missing_selector is not None:
131
+ m["on_missing"] = {"selector": on_missing_selector, "priority": int(on_missing_priority)}
132
+ return c
133
+ return dec(cls) if cls else dec
80
134
 
81
- def provides(key: Any):
135
+ def provides(
136
+ key: Any,
137
+ *,
138
+ name: Any = None,
139
+ qualifiers: Iterable[str] = (),
140
+ scope: str = "singleton",
141
+ primary: bool = False,
142
+ lazy: bool = False,
143
+ conditional_profiles: Iterable[str] = (),
144
+ conditional_require_env: Iterable[str] = (),
145
+ conditional_predicate: Optional[Callable[[], bool]] = None,
146
+ on_missing_selector: Optional[object] = None,
147
+ on_missing_priority: int = 0,
148
+ ):
82
149
  def dec(fn):
83
- @functools.wraps(fn)
150
+ target = fn.__func__ if isinstance(fn, (staticmethod, classmethod)) else fn
151
+ @functools.wraps(target)
84
152
  def w(*a, **k):
85
- return fn(*a, **k)
153
+ return target(*a, **k)
86
154
  setattr(w, PICO_INFRA, "provides")
87
- setattr(w, PICO_NAME, key)
155
+ setattr(w, PICO_NAME, name if name is not None else key)
88
156
  setattr(w, PICO_KEY, key)
89
- _meta_get(w)
157
+ m = _meta_get(w)
158
+ m["qualifier"] = tuple(str(q) for q in qualifiers or ())
159
+ m["scope"] = scope
160
+ if primary:
161
+ m["primary"] = True
162
+ if lazy:
163
+ m["lazy"] = True
164
+ if conditional_profiles or conditional_require_env or conditional_predicate is not None:
165
+ m["conditional"] = {
166
+ "profiles": tuple(p for p in conditional_profiles or ()),
167
+ "require_env": tuple(e for e in conditional_require_env or ()),
168
+ "predicate": conditional_predicate,
169
+ }
170
+ if on_missing_selector is not None:
171
+ m["on_missing"] = {"selector": on_missing_selector, "priority": int(on_missing_priority)}
90
172
  return w
91
173
  return dec
92
174
 
93
175
  class Qualifier(str):
94
176
  __slots__ = ()
95
177
 
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
116
-
117
- def primary(obj):
118
- m = _meta_get(obj)
119
- m["primary"] = True
120
- return obj
121
-
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
128
-
129
- def lazy(obj):
130
- m = _meta_get(obj)
131
- m["lazy"] = True
132
- return obj
133
-
134
178
  def configuration(cls=None, *, prefix: Optional[str] = None):
135
179
  def dec(c):
136
180
  setattr(c, PICO_INFRA, "configuration")
@@ -150,13 +194,6 @@ def cleanup(fn):
150
194
  m["cleanup"] = True
151
195
  return fn
152
196
 
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
197
  def configured(target: Any, *, prefix: Optional[str] = None):
161
198
  def dec(cls):
162
199
  setattr(cls, PICO_INFRA, "configured")
@@ -278,27 +315,82 @@ def _collect_by_type(locator: ComponentLocator, t: type, q: Optional[str]):
278
315
  out.append(k)
279
316
  return out
280
317
 
281
- def _resolve_args(callable_obj: Callable[..., Any], pico: "PicoContainer") -> Dict[str, Any]:
282
- sig = inspect.signature(callable_obj)
283
- kwargs: Dict[str, Any] = {}
318
+ def _find_key_by_name(locator: Optional[ComponentLocator], name: str) -> Optional[KeyT]:
319
+ if not locator:
320
+ return None
321
+ for k, md in locator._metadata.items():
322
+ if md.pico_name == name:
323
+ return k
324
+ typ = md.provided_type or md.concrete_class
325
+ if isinstance(typ, type) and getattr(typ, "__name__", "") == name:
326
+ return k
327
+ return None
328
+
329
+ def _normalize_callable(obj):
330
+ return getattr(obj, '__func__', obj)
331
+
332
+ def _get_signature_safe(callable_obj):
333
+ try:
334
+ return inspect.signature(callable_obj)
335
+ except (ValueError, TypeError):
336
+ wrapped = getattr(callable_obj, '__wrapped__', None)
337
+ if wrapped is not None:
338
+ return inspect.signature(wrapped)
339
+ raise
340
+
341
+ @functools.lru_cache(maxsize=1024)
342
+ def _analyze_signature_template(callable_obj):
343
+ callable_obj = _normalize_callable(callable_obj)
344
+ sig = _get_signature_safe(callable_obj)
345
+ plan = []
284
346
  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
347
+ if name in ("self", "cls"): continue
348
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): continue
289
349
  ann = param.annotation
290
350
  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]
351
+ if is_list:
352
+ plan.append(("list", name, (elem_t, qual)))
353
+ else:
354
+ plan.append(("key", name, ann))
355
+ return tuple(plan)
356
+
357
+ def _compile_argplan(callable_obj, pico):
358
+ template = _analyze_signature_template(callable_obj)
359
+ locator = pico._locator
360
+ items = []
361
+ for kind, name, data in template:
362
+ if kind == "list":
363
+ elem_t, qual = data
364
+ if locator is not None and isinstance(elem_t, type):
365
+ keys = _collect_by_type(locator, elem_t, qual)
366
+ items.append(("list", name, tuple(keys)))
294
367
  continue
368
+ ann = data
295
369
  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
370
  key = ann
371
+ elif ann is not inspect._empty and isinstance(ann, str):
372
+ mapped = _find_key_by_name(locator, ann)
373
+ key = mapped if mapped is not None else ann
299
374
  else:
300
375
  key = name
301
- kwargs[name] = pico.get(key)
376
+ items.append(("key", name, key))
377
+ return tuple(items)
378
+
379
+ def _resolve_args(callable_obj: Callable[..., Any], pico: "PicoContainer") -> Dict[str, Any]:
380
+ plan = _compile_argplan(callable_obj, pico)
381
+ kwargs: Dict[str, Any] = {}
382
+ tracer = pico._tracer
383
+ prev = tracer.override_via("constructor")
384
+ try:
385
+ for kind, name, data in plan:
386
+ if kind == "key":
387
+ tracer.note_param(data if isinstance(data, (str, type)) else name, name)
388
+ kwargs[name] = pico.get(data)
389
+ else:
390
+ vals = [pico.get(k) for k in data]
391
+ kwargs[name] = vals
392
+ finally:
393
+ tracer.restore_via(prev)
302
394
  return kwargs
303
395
 
304
396
  def _needs_async_configure(obj: Any) -> bool:
@@ -445,9 +537,12 @@ class Registrar:
445
537
  self._resolver = ConfigResolver(tuple(tree_sources))
446
538
  self._adapters = TypeAdapterRegistry()
447
539
  self._graph = ObjectGraphBuilder(self._resolver, self._adapters)
540
+ self._provides_functions: Dict[KeyT, Callable[..., Any]] = {}
448
541
 
449
542
  def locator(self) -> ComponentLocator:
450
- return ComponentLocator(self._metadata, self._indexes)
543
+ loc = ComponentLocator(self._metadata, self._indexes)
544
+ setattr(loc, "_provides_functions", dict(self._provides_functions))
545
+ return loc
451
546
 
452
547
  def attach_runtime(self, pico, locator: ComponentLocator) -> None:
453
548
  for deferred in self._deferred:
@@ -476,24 +571,21 @@ class Registrar:
476
571
  return True
477
572
  p = set(c.get("profiles") or ())
478
573
  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
574
  return False
481
575
  req = c.get("require_env") or ()
482
576
  for k in req:
483
577
  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
578
  return False
486
579
  pred = c.get("predicate")
487
580
  if pred is None:
488
581
  return True
489
582
  try:
490
583
  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))
584
+ except Exception:
493
585
  return False
494
586
  if not ok:
495
- self._log.info("excluded_by_predicate name=%s", getattr(obj, "__name__", str(obj)))
496
- return ok
587
+ return False
588
+ return True
497
589
 
498
590
  def _register_component_class(self, cls: type) -> None:
499
591
  if not self._enabled_by_condition(cls):
@@ -510,19 +602,48 @@ class Registrar:
510
602
  return
511
603
  for name in dir(cls):
512
604
  try:
513
- real = getattr(cls, name)
605
+ raw = inspect.getattr_static(cls, name)
514
606
  except Exception:
515
607
  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)
608
+ fn = None
609
+ kind = None
610
+ if isinstance(raw, staticmethod):
611
+ fn = raw.__func__
612
+ kind = "static"
613
+ elif isinstance(raw, classmethod):
614
+ fn = raw.__func__
615
+ kind = "class"
616
+ elif inspect.isfunction(raw):
617
+ fn = raw
618
+ kind = "instance"
619
+ else:
620
+ continue
621
+ if getattr(fn, PICO_INFRA, None) != "provides":
622
+ continue
623
+ if not self._enabled_by_condition(fn):
624
+ continue
625
+ k = getattr(fn, PICO_KEY)
626
+ if kind == "instance":
520
627
  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)
628
+ else:
629
+ provider = DeferredProvider(lambda pico, loc, f=fn: _build_method(f, pico, loc))
630
+ rt = _get_return_type(fn)
631
+ qset = set(str(q) for q in getattr(fn, PICO_META, {}).get("qualifier", ()))
632
+ sc = getattr(fn, PICO_META, {}).get("scope", getattr(cls, PICO_META, {}).get("scope", "singleton"))
633
+ md = ProviderMetadata(
634
+ key=k,
635
+ provided_type=rt if isinstance(rt, type) else (k if isinstance(k, type) else None),
636
+ concrete_class=None,
637
+ factory_class=cls,
638
+ factory_method=name,
639
+ qualifiers=qset,
640
+ primary=bool(getattr(fn, PICO_META, {}).get("primary")),
641
+ lazy=bool(getattr(fn, PICO_META, {}).get("lazy", False)),
642
+ infra=getattr(cls, PICO_INFRA, None),
643
+ pico_name=getattr(fn, PICO_NAME, None),
644
+ scope=sc
645
+ )
646
+ self._queue(k, provider, md)
526
647
 
527
648
  def _register_configuration_class(self, cls: type) -> None:
528
649
  if not self._enabled_by_condition(cls):
@@ -551,6 +672,30 @@ class Registrar:
551
672
  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
673
  self._queue(target, provider, md)
553
674
 
675
+ def _register_provides_function(self, fn: Callable[..., Any]) -> None:
676
+ if not self._enabled_by_condition(fn):
677
+ return
678
+ k = getattr(fn, PICO_KEY)
679
+ provider = DeferredProvider(lambda pico, loc, f=fn: _build_method(f, pico, loc))
680
+ rt = _get_return_type(fn)
681
+ qset = set(str(q) for q in getattr(fn, PICO_META, {}).get("qualifier", ()))
682
+ sc = getattr(fn, PICO_META, {}).get("scope", "singleton")
683
+ md = ProviderMetadata(
684
+ key=k,
685
+ provided_type=rt if isinstance(rt, type) else (k if isinstance(k, type) else None),
686
+ concrete_class=None,
687
+ factory_class=None,
688
+ factory_method=getattr(fn, "__name__", None),
689
+ qualifiers=qset,
690
+ primary=bool(getattr(fn, PICO_META, {}).get("primary")),
691
+ lazy=bool(getattr(fn, PICO_META, {}).get("lazy", False)),
692
+ infra="provides",
693
+ pico_name=getattr(fn, PICO_NAME, None),
694
+ scope=sc
695
+ )
696
+ self._queue(k, provider, md)
697
+ self._provides_functions[k] = fn
698
+
554
699
  def register_module(self, module: Any) -> None:
555
700
  for _, obj in inspect.getmembers(module):
556
701
  if inspect.isclass(obj):
@@ -569,6 +714,9 @@ class Registrar:
569
714
  self._register_configuration_class(obj)
570
715
  elif infra == "configured":
571
716
  self._register_configured_class(obj)
717
+ for _, fn in inspect.getmembers(module, predicate=inspect.isfunction):
718
+ if getattr(fn, PICO_INFRA, None) == "provides":
719
+ self._register_provides_function(fn)
572
720
 
573
721
  def _prefix_exists(self, md: ProviderMetadata) -> bool:
574
722
  if md.infra != "configured":
@@ -634,6 +782,13 @@ class Registrar:
634
782
  dep = self._find_md_for_type(t)
635
783
  if dep and dep.scope != "singleton":
636
784
  return dep.scope
785
+ if md.infra == "provides":
786
+ fn = self._provides_functions.get(md.key)
787
+ if callable(fn):
788
+ for t in self._iter_param_types(fn):
789
+ dep = self._find_md_for_type(t)
790
+ if dep and dep.scope != "singleton":
791
+ return dep.scope
637
792
  return None
638
793
 
639
794
  def _promote_scopes(self) -> None:
@@ -681,9 +836,13 @@ class Registrar:
681
836
  if isinstance(t, type) and getattr(t, "__name__", "") == name:
682
837
  return k
683
838
  return None
684
-
839
+
685
840
  def _validate_bindings(self) -> None:
686
841
  errors: List[str] = []
842
+
843
+ def _fmt(k: KeyT) -> str:
844
+ return getattr(k, '__name__', str(k))
845
+
687
846
  def _skip_type(t: type) -> bool:
688
847
  if t in (str, int, float, bool, bytes):
689
848
  return True
@@ -692,6 +851,7 @@ class Registrar:
692
851
  if getattr(t, "_is_protocol", False):
693
852
  return True
694
853
  return False
854
+
695
855
  def _should_validate(param: inspect.Parameter) -> bool:
696
856
  if param.default is not inspect._empty:
697
857
  return False
@@ -702,18 +862,44 @@ class Registrar:
702
862
  if type(None) in args:
703
863
  return False
704
864
  return True
865
+
705
866
  loc = ComponentLocator(self._metadata, self._indexes)
867
+
706
868
  for k, md in self._metadata.items():
707
869
  if md.infra == "configuration":
708
870
  continue
871
+
709
872
  callables_to_check: List[Callable[..., Any]] = []
873
+ loc_name = "unknown"
874
+
710
875
  if md.concrete_class is not None:
711
876
  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)
877
+ loc_name = "constructor"
878
+ elif md.factory_class is not None and md.factory_method is not None:
879
+ try:
880
+ fn = getattr(md.factory_class, md.factory_method)
881
+ callables_to_check.append(fn)
882
+ loc_name = f"factory {_fmt(md.factory_class)}.{md.factory_method}"
883
+ except AttributeError:
884
+ errors.append(f"Component '{_fmt(k)}' refers to missing factory method '{md.factory_method}' on class '{_fmt(md.factory_class)}'")
885
+ continue
886
+ elif md.infra == "provides":
887
+ fn = self._provides_functions.get(k)
888
+ if callable(fn):
889
+ callables_to_check.append(fn)
890
+ loc_name = f"function {getattr(fn, '__name__', 'unknown')}"
891
+ else:
892
+ continue
893
+ else:
894
+ if not md.concrete_class and not md.factory_class and md.override:
895
+ continue
896
+
715
897
  for callable_obj in callables_to_check:
716
- sig = inspect.signature(callable_obj)
898
+ try:
899
+ sig = inspect.signature(callable_obj)
900
+ except (ValueError, TypeError):
901
+ continue
902
+
717
903
  for name, param in sig.parameters.items():
718
904
  if name in ("self", "cls"):
719
905
  continue
@@ -721,27 +907,45 @@ class Registrar:
721
907
  continue
722
908
  if not _should_validate(param):
723
909
  continue
910
+
724
911
  ann = param.annotation
725
912
  is_list, elem_t, qual = _extract_list_req(ann)
913
+
726
914
  if is_list:
727
915
  if qual:
728
916
  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")
917
+ if not matching and isinstance(elem_t, type) and not _skip_type(elem_t):
918
+ errors.append(f"{_fmt(k)} ({loc_name}) expects List[{_fmt(elem_t)}] with qualifier '{qual}' but no matching components exist")
731
919
  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")
920
+
921
+ elif isinstance(ann, str):
922
+ key_found_by_name = self._find_md_for_name(ann)
923
+ directly_bound = ann in self._metadata or self._factory.has(ann)
924
+ if not key_found_by_name and not directly_bound:
925
+ errors.append(f"{_fmt(k)} ({loc_name}) depends on string key '{ann}' which is not bound")
926
+ continue
927
+
928
+ elif isinstance(ann, type) and not _skip_type(ann):
929
+ dep_key_found = self._factory.has(ann) or ann in self._metadata
930
+ if not dep_key_found:
931
+ assignable_md = self._find_md_for_type(ann)
932
+ if assignable_md is None:
933
+ by_name_key = self._find_md_for_name(getattr(ann, "__name__", ""))
934
+ if by_name_key is None:
935
+ errors.append(f"{_fmt(k)} ({loc_name}) depends on {_fmt(ann)} which is not bound")
736
936
  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")
937
+
938
+ elif ann is inspect._empty:
939
+ name_key = name
940
+ key_found_by_name = self._find_md_for_name(name_key)
941
+ directly_bound = name_key in self._metadata or self._factory.has(name_key)
942
+ if not key_found_by_name and not directly_bound:
943
+ errors.append(f"{_fmt(k)} ({loc_name}) depends on parameter '{name}' with no type hint, and key '{name_key}' is not bound")
944
+ continue
945
+
742
946
  if errors:
743
947
  raise InvalidBindingError(errors)
744
-
948
+
745
949
  def finalize(self, overrides: Optional[Dict[KeyT, Any]]) -> None:
746
950
  self.select_and_bind()
747
951
  self._promote_scopes()
@@ -774,7 +978,8 @@ class Registrar:
774
978
  self._rebuild_indexes()
775
979
  self._validate_bindings()
776
980
 
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:
981
+
982
+ 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
983
  active = tuple(p.strip() for p in profiles if p)
779
984
  allowed_set = set(a.strip() for a in allowed_profiles) if allowed_profiles is not None else None
780
985
  if allowed_set is not None:
@@ -787,7 +992,14 @@ def init(modules: Union[Any, Iterable[Any]], *, profiles: Tuple[str, ...] = (),
787
992
  if custom_scopes:
788
993
  for n, impl in custom_scopes.items():
789
994
  scopes.register_scope(n, impl)
790
- pico = PicoContainer(factory, caches, scopes, container_id=container_id, profiles=active)
995
+ pico = PicoContainer(
996
+ factory,
997
+ caches,
998
+ scopes,
999
+ container_id=container_id,
1000
+ profiles=active,
1001
+ observers=observers or [],
1002
+ )
791
1003
  registrar = Registrar(factory, profiles=active, environ=environ, logger=logger, config=config, tree_sources=tree_config)
792
1004
  for m in _iter_input_modules(modules):
793
1005
  registrar.register_module(m)
@@ -797,9 +1009,153 @@ def init(modules: Union[Any, Iterable[Any]], *, profiles: Tuple[str, ...] = (),
797
1009
  factory.bind(k, prov)
798
1010
  registrar.finalize(overrides)
799
1011
  if validate_only:
1012
+ locator = registrar.locator()
1013
+ pico.attach_locator(locator)
1014
+ _fail_fast_cycle_check(pico)
800
1015
  return pico
801
1016
  locator = registrar.locator()
802
1017
  registrar.attach_runtime(pico, locator)
803
1018
  pico.attach_locator(locator)
1019
+ _fail_fast_cycle_check(pico)
804
1020
  return pico
805
1021
 
1022
+ def _dependency_keys_for(key: KeyT, pico: "PicoContainer") -> Tuple[KeyT, ...]:
1023
+ loc = pico._locator
1024
+ if not loc:
1025
+ return ()
1026
+ md = loc._metadata.get(key)
1027
+ if not md:
1028
+ return ()
1029
+ if md.concrete_class is not None:
1030
+ fn = md.concrete_class.__init__
1031
+ elif md.factory_class is not None and md.factory_method is not None:
1032
+ fn = getattr(md.factory_class, md.factory_method)
1033
+ else:
1034
+ return ()
1035
+ plan = _compile_argplan(fn, pico)
1036
+ deps: List[KeyT] = []
1037
+ for kind, _, data in plan:
1038
+ if kind == "key":
1039
+ deps.append(data)
1040
+ else:
1041
+ for k in data:
1042
+ deps.append(k)
1043
+ return tuple(deps)
1044
+
1045
+ def _get_signature_static(fn: Callable[..., Any]) -> inspect.Signature:
1046
+ return inspect.signature(fn)
1047
+
1048
+ def _compile_argplan_static(callable_obj: Callable[..., Any], locator: ComponentLocator) -> Tuple[Tuple[str, str, Any], ...]:
1049
+ sig = _get_signature_static(callable_obj)
1050
+ items: List[Tuple[str, str, Any]] = []
1051
+ for name, param in sig.parameters.items():
1052
+ if name in ("self", "cls"):
1053
+ continue
1054
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
1055
+ continue
1056
+ ann = param.annotation
1057
+ is_list, elem_t, qual = _extract_list_req(ann)
1058
+ if is_list and locator is not None and isinstance(elem_t, type):
1059
+ keys = _collect_by_type(locator, elem_t, qual)
1060
+ items.append(("list", name, tuple(keys)))
1061
+ continue
1062
+ if ann is not inspect._empty and isinstance(ann, type):
1063
+ key: KeyT = ann
1064
+ elif ann is not inspect._empty and isinstance(ann, str):
1065
+ mapped = _find_key_by_name(locator, ann)
1066
+ key = mapped if mapped is not None else ann
1067
+ else:
1068
+ key = name
1069
+ items.append(("key", name, key))
1070
+ return tuple(items)
1071
+
1072
+ def _dependency_keys_for_static(md: ProviderMetadata, locator: ComponentLocator) -> Tuple[KeyT, ...]:
1073
+ if md.concrete_class is not None:
1074
+ fn = md.concrete_class.__init__
1075
+ elif md.factory_class is not None and md.factory_method is not None:
1076
+ fn = getattr(md.factory_class, md.factory_method)
1077
+ elif md.infra == "provides":
1078
+ fn = getattr(locator, "_provides_functions", {}).get(md.key)
1079
+ if not callable(fn):
1080
+ return ()
1081
+ else:
1082
+ return ()
1083
+ plan = _compile_argplan_static(fn, locator)
1084
+ deps: List[KeyT] = []
1085
+ for kind, _, data in plan:
1086
+ if kind == "key":
1087
+ deps.append(data)
1088
+ else:
1089
+ for k in data:
1090
+ deps.append(k)
1091
+ return tuple(deps)
1092
+
1093
+ def _build_resolution_graph(pico: "PicoContainer") -> Dict[KeyT, Tuple[KeyT, ...]]:
1094
+ loc = pico._locator
1095
+ if not loc:
1096
+ return {}
1097
+
1098
+ def _map_dep_to_bound_key(dep_key: KeyT) -> KeyT:
1099
+ if dep_key in loc._metadata:
1100
+ return dep_key
1101
+ if isinstance(dep_key, type):
1102
+ for k, md in loc._metadata.items():
1103
+ typ = md.provided_type or md.concrete_class
1104
+ if isinstance(typ, type):
1105
+ try:
1106
+ if issubclass(typ, dep_key):
1107
+ return k
1108
+ except Exception:
1109
+ continue
1110
+ return dep_key
1111
+
1112
+ graph: Dict[KeyT, Tuple[KeyT, ...]] = {}
1113
+ for key, md in list(loc._metadata.items()):
1114
+ deps: List[KeyT] = []
1115
+ for d in _dependency_keys_for_static(md, loc):
1116
+ mapped = _map_dep_to_bound_key(d)
1117
+ deps.append(mapped)
1118
+ graph[key] = tuple(deps)
1119
+ return graph
1120
+
1121
+
1122
+ def _find_cycle(graph: Dict[KeyT, Tuple[KeyT, ...]]) -> Optional[Tuple[KeyT, ...]]:
1123
+ temp: Set[KeyT] = set()
1124
+ perm: Set[KeyT] = set()
1125
+ stack: List[KeyT] = []
1126
+ def visit(n: KeyT) -> Optional[Tuple[KeyT, ...]]:
1127
+ if n in perm:
1128
+ return None
1129
+ if n in temp:
1130
+ try:
1131
+ idx = stack.index(n)
1132
+ return tuple(stack[idx:] + [n])
1133
+ except ValueError:
1134
+ return tuple([n, n])
1135
+ temp.add(n)
1136
+ stack.append(n)
1137
+ for m in graph.get(n, ()):
1138
+ c = visit(m)
1139
+ if c:
1140
+ return c
1141
+ stack.pop()
1142
+ temp.remove(n)
1143
+ perm.add(n)
1144
+ return None
1145
+ for node in graph.keys():
1146
+ c = visit(node)
1147
+ if c:
1148
+ return c
1149
+ return None
1150
+
1151
+ def _format_key(k: KeyT) -> str:
1152
+ return getattr(k, "__name__", str(k))
1153
+
1154
+ def _fail_fast_cycle_check(pico: "PicoContainer") -> None:
1155
+ graph = _build_resolution_graph(pico)
1156
+ cyc = _find_cycle(graph)
1157
+ if not cyc:
1158
+ return
1159
+ path = " -> ".join(_format_key(k) for k in cyc)
1160
+ raise InvalidBindingError([f"Circular dependency detected: {path}"])
1161
+