pico-ioc 1.5.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/container.py CHANGED
@@ -1,168 +1,440 @@
1
- from __future__ import annotations
2
-
1
+ # src/pico_ioc/container.py
3
2
  import inspect
4
- from typing import Any, Dict, get_origin, get_args, Annotated
5
- import typing as _t
3
+ import contextvars
4
+ from typing import Any, Dict, List, Optional, Tuple, overload, Union
5
+ from contextlib import contextmanager
6
+ from .constants import LOGGER, PICO_META
7
+ from .exceptions import CircularDependencyError, ComponentCreationError, ProviderNotFoundError
8
+ from .factory import ComponentFactory
9
+ from .locator import ComponentLocator
10
+ from .scope import ScopedCaches, ScopeManager
11
+ from .aop import UnifiedComponentProxy, ContainerObserver
6
12
 
7
- from .proxy import IoCProxy
8
- from .interceptors import MethodInterceptor, ContainerInterceptor, MethodCtx, ResolveCtx, CreateCtx, run_resolve_chain, run_create_chain
9
- from .decorators import QUALIFIERS_KEY
10
- from . import _state
13
+ KeyT = Union[str, type]
14
+ _resolve_chain: contextvars.ContextVar[Tuple[KeyT, ...]] = contextvars.ContextVar("pico_resolve_chain", default=())
11
15
 
12
- class Binder:
13
- def __init__(self, container: PicoContainer):
14
- self._c = container
16
+ class _TracerFrame:
17
+ __slots__ = ("parent_key", "via")
18
+ def __init__(self, parent_key: KeyT, via: str):
19
+ self.parent_key = parent_key
20
+ self.via = via
15
21
 
16
- def bind(self, key: Any, provider, *, lazy: bool, tags: tuple[str, ...] = ()):
17
- self._c.bind(key, provider, lazy=lazy, tags=tags)
22
+ class ResolutionTracer:
23
+ def __init__(self, container: "PicoContainer") -> None:
24
+ self._container = container
25
+ self._stack_var: contextvars.ContextVar[List[_TracerFrame]] = contextvars.ContextVar("pico_tracer_stack", default=[])
26
+ self._edges: Dict[Tuple[KeyT, KeyT], Tuple[str, str]] = {}
18
27
 
19
- def has(self, key: Any) -> bool:
20
- return self._c.has(key)
28
+ def enter(self, parent_key: KeyT, via: str) -> contextvars.Token:
29
+ stack = list(self._stack_var.get())
30
+ stack.append(_TracerFrame(parent_key, via))
31
+ return self._stack_var.set(stack)
21
32
 
22
- def get(self, key: Any):
23
- return self._c.get(key)
33
+ def leave(self, token: contextvars.Token) -> None:
34
+ self._stack_var.reset(token)
24
35
 
25
- class PicoContainer:
26
- def __init__(self, providers: Dict[Any, Dict[str, Any]] | None = None):
27
- self._providers = providers or {}
28
- self._singletons: Dict[Any, Any] = {}
29
- self._method_interceptors: tuple[MethodInterceptor, ...] = ()
30
- self._container_interceptors: tuple[ContainerInterceptor, ...] = ()
31
- self._active_profiles: tuple[str, ...] = ()
32
- self._seen_interceptor_types: set[type] = set()
33
- self._method_cap: int | None = None
34
-
35
- def add_method_interceptor(self, it: MethodInterceptor) -> None:
36
- self._method_interceptors = self._method_interceptors + (it,)
37
-
38
- def add_container_interceptor(self, it: ContainerInterceptor) -> None:
39
- t = type(it)
40
- if t in self._seen_interceptor_types:
36
+ def override_via(self, new_via: str) -> Optional[str]:
37
+ stack = self._stack_var.get()
38
+ if not stack:
39
+ return None
40
+ prev = stack[-1].via
41
+ stack[-1].via = new_via
42
+ return prev
43
+
44
+ def restore_via(self, previous: Optional[str]) -> None:
45
+ if previous is None:
46
+ return
47
+ stack = self._stack_var.get()
48
+ if not stack:
41
49
  return
42
- self._seen_interceptor_types.add(t)
43
- self._container_interceptors = self._container_interceptors + (it,)
50
+ stack[-1].via = previous
44
51
 
45
- def set_method_cap(self, n: int | None) -> None:
46
- self._method_cap = (int(n) if n is not None else None)
52
+ def note_param(self, child_key: KeyT, param_name: str) -> None:
53
+ stack = self._stack_var.get()
54
+ if not stack:
55
+ return
56
+ parent = stack[-1].parent_key
57
+ via = stack[-1].via
58
+ self._edges[(parent, child_key)] = (via, param_name)
47
59
 
48
- def binder(self) -> Binder:
49
- return Binder(self)
60
+ def describe_cycle(self, chain: Tuple[KeyT, ...], current: KeyT, locator: Optional[ComponentLocator]) -> str:
61
+ def name_of(k: KeyT) -> str:
62
+ return getattr(k, "__name__", str(k))
63
+ def scope_of(k: KeyT) -> str:
64
+ if not locator:
65
+ return "singleton"
66
+ md = locator._metadata.get(k)
67
+ return md.scope if md else "singleton"
68
+ lines: List[str] = []
69
+ lines.append("Circular dependency detected.")
70
+ lines.append("")
71
+ lines.append("Resolution chain:")
72
+ full = tuple(chain) + (current,)
73
+ for idx, k in enumerate(full, 1):
74
+ mark = " ❌" if idx == len(full) else ""
75
+ lines.append(f" {idx}. {name_of(k)} [scope={scope_of(k)}]{mark}")
76
+ if idx < len(full):
77
+ parent = k
78
+ child = full[idx]
79
+ via, param = self._edges.get((parent, child), ("provider", "?"))
80
+ lines.append(f" └─ via {via} param '{param}' → {name_of(child)}")
81
+ lines.append("")
82
+ lines.append("Hint: break the cycle with a @configure setter or use a factory/provider.")
83
+ return "\n".join(lines)
50
84
 
51
- def bind(self, key: Any, provider, *, lazy: bool, tags: tuple[str, ...] = ()):
52
- self._singletons.pop(key, None)
53
- meta = {"factory": provider, "lazy": bool(lazy)}
54
- try:
55
- q = getattr(key, QUALIFIERS_KEY, ())
56
- except Exception:
57
- q = ()
58
- meta["qualifiers"] = tuple(q) if q else ()
59
- meta["tags"] = tuple(tags) if tags else ()
60
- self._providers[key] = meta
61
-
62
- def has(self, key: Any) -> bool:
63
- return key in self._providers
64
-
65
- def _notify_resolve(self, key: Any, ann: Any, quals: tuple[str, ...] | tuple()):
66
- ctx = ResolveCtx(key=key, qualifiers={q: True for q in quals or ()}, requested_by=None, profiles=self._active_profiles)
67
- run_resolve_chain(self._container_interceptors, ctx)
68
-
69
- def get(self, key: Any):
70
- if _state._scanning.get() and not _state._resolving.get():
71
- raise RuntimeError("re-entrant container access during scan")
72
- prov = self._providers.get(key)
73
- if prov is None:
74
- raise NameError(f"No provider found for key {key!r}")
75
- if key in self._singletons:
76
- return self._singletons[key]
77
- def base_provider():
78
- return prov["factory"]()
79
- cls = key if isinstance(key, type) else None
80
- ctx = CreateCtx(key=key, component=cls, provider=base_provider, profiles=self._active_profiles)
81
- tok = _state._resolving.set(True)
85
+ class PicoContainer:
86
+ _container_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("pico_container_id", default=None)
87
+ _container_registry: Dict[str, "PicoContainer"] = {}
88
+
89
+ class _Ctx:
90
+ def __init__(self, container_id: str, profiles: Tuple[str, ...], created_at: float) -> None:
91
+ self.container_id = container_id
92
+ self.profiles = profiles
93
+ self.created_at = created_at
94
+ self.resolve_count = 0
95
+ self.cache_hit_count = 0
96
+
97
+ def __init__(self, component_factory: ComponentFactory, caches: ScopedCaches, scopes: ScopeManager, observers: Optional[List["ContainerObserver"]] = None, container_id: Optional[str] = None, profiles: Tuple[str, ...] = ()) -> None:
98
+ self._factory = component_factory
99
+ self._caches = caches
100
+ self.scopes = scopes
101
+ self._locator: Optional[ComponentLocator] = None
102
+ self._observers = list(observers or [])
103
+ self.container_id = container_id or self._generate_container_id()
104
+ import time as _t
105
+ self.context = PicoContainer._Ctx(container_id=self.container_id, profiles=profiles, created_at=_t.time())
106
+ PicoContainer._container_registry[self.container_id] = self
107
+ self._tracer = ResolutionTracer(self)
108
+
109
+ @staticmethod
110
+ def _generate_container_id() -> str:
111
+ import time as _t, random as _r
112
+ return f"c{_t.time_ns():x}{_r.randrange(1<<16):04x}"
113
+
114
+ @classmethod
115
+ def get_current(cls) -> Optional["PicoContainer"]:
116
+ cid = cls._container_id_var.get()
117
+ return cls._container_registry.get(cid) if cid else None
118
+
119
+ @classmethod
120
+ def get_current_id(cls) -> Optional[str]:
121
+ return cls._container_id_var.get()
122
+
123
+ @classmethod
124
+ def all_containers(cls) -> Dict[str, "PicoContainer"]:
125
+ return dict(cls._container_registry)
126
+
127
+ def activate(self) -> contextvars.Token:
128
+ return PicoContainer._container_id_var.set(self.container_id)
129
+
130
+ def deactivate(self, token: contextvars.Token) -> None:
131
+ PicoContainer._container_id_var.reset(token)
132
+
133
+ @contextmanager
134
+ def as_current(self):
135
+ token = self.activate()
82
136
  try:
83
- instance = run_create_chain(self._container_interceptors, ctx)
137
+ yield self
84
138
  finally:
85
- _state._resolving.reset(tok)
86
- if self._method_interceptors and not isinstance(instance, IoCProxy):
87
- chain = self._method_interceptors
88
- cap = getattr(self, "_method_cap", None)
89
- if isinstance(cap, int) and cap >= 0:
90
- chain = chain[:cap]
91
- instance = IoCProxy(instance, chain, container=self, request_key=key)
92
- self._singletons[key] = instance
93
- return instance
139
+ self.deactivate(token)
94
140
 
95
- def eager_instantiate_all(self):
96
- for key, prov in list(self._providers.items()):
97
- if not prov["lazy"]:
98
- self.get(key)
141
+ def attach_locator(self, locator: ComponentLocator) -> None:
142
+ self._locator = locator
99
143
 
100
- def get_all(self, base_type: Any):
101
- return tuple(self._resolve_all_for_base(base_type, qualifiers=()))
144
+ def _cache_for(self, key: KeyT):
145
+ md = self._locator._metadata.get(key) if self._locator else None
146
+ sc = (md.scope if md else "singleton")
147
+ return self._caches.for_scope(self.scopes, sc)
102
148
 
103
- def get_all_qualified(self, base_type: Any, *qualifiers: str):
104
- return tuple(self._resolve_all_for_base(base_type, qualifiers=qualifiers))
149
+ def has(self, key: KeyT) -> bool:
150
+ cache = self._cache_for(key)
151
+ return cache.get(key) is not None or self._factory.has(key)
105
152
 
106
- def _resolve_all_for_base(self, base_type: Any, qualifiers=()):
107
- matches = []
108
- for provider_key, meta in self._providers.items():
109
- cls = provider_key if isinstance(provider_key, type) else None
110
- if cls is None:
111
- continue
112
- if _requires_collection_of_base(cls, base_type):
153
+ def _canonical_key(self, key: KeyT) -> KeyT:
154
+ if self._factory.has(key):
155
+ return key
156
+ if isinstance(key, type) and self._locator:
157
+ cands: List[Tuple[bool, Any]] = []
158
+ for k, md in self._locator._metadata.items():
159
+ typ = md.provided_type or md.concrete_class
160
+ if not isinstance(typ, type):
161
+ continue
162
+ try:
163
+ if typ is not key and issubclass(typ, key):
164
+ cands.append((md.primary, k))
165
+ except Exception:
166
+ continue
167
+ if cands:
168
+ prim = [k for is_p, k in cands if is_p]
169
+ return prim[0] if prim else cands[0][1]
170
+ if isinstance(key, str) and self._locator:
171
+ for k, md in self._locator._metadata.items():
172
+ if md.pico_name == key:
173
+ return k
174
+ return key
175
+
176
+ @overload
177
+ def get(self, key: type) -> Any: ...
178
+ @overload
179
+ def get(self, key: str) -> Any: ...
180
+ def get(self, key: KeyT) -> Any:
181
+ key = self._canonical_key(key)
182
+ cache = self._cache_for(key)
183
+ cached = cache.get(key)
184
+ if cached is not None:
185
+ self.context.cache_hit_count += 1
186
+ for o in self._observers: o.on_cache_hit(key)
187
+ return cached
188
+ import time as _tm
189
+ t0 = _tm.perf_counter()
190
+ chain = list(_resolve_chain.get())
191
+ for k in chain:
192
+ if k == key:
193
+ details = self._tracer.describe_cycle(tuple(chain), key, self._locator)
194
+ raise ComponentCreationError(key, CircularDependencyError(chain, key, details=details))
195
+ token_chain = _resolve_chain.set(tuple(chain + [key]))
196
+ token_container = self.activate()
197
+ token_tracer = self._tracer.enter(key, via="provider")
198
+ try:
199
+ provider = self._factory.get(key)
200
+ try:
201
+ instance = provider()
202
+ except ProviderNotFoundError as e:
203
+ raise
204
+ except Exception as e:
205
+ raise ComponentCreationError(key, e) from e
206
+ instance = self._maybe_wrap_with_aspects(key, instance)
207
+ cache.put(key, instance)
208
+ self.context.resolve_count += 1
209
+ took_ms = (_tm.perf_counter() - t0) * 1000
210
+ for o in self._observers: o.on_resolve(key, took_ms)
211
+ return instance
212
+ finally:
213
+ self._tracer.leave(token_tracer)
214
+ _resolve_chain.reset(token_chain)
215
+ self.deactivate(token_container)
216
+
217
+ async def aget(self, key: KeyT) -> Any:
218
+ key = self._canonical_key(key)
219
+ cache = self._cache_for(key)
220
+ cached = cache.get(key)
221
+ if cached is not None:
222
+ self.context.cache_hit_count += 1
223
+ for o in self._observers: o.on_cache_hit(key)
224
+ return cached
225
+ import time as _tm
226
+ t0 = _tm.perf_counter()
227
+ chain = list(_resolve_chain.get())
228
+ for k in chain:
229
+ if k == key:
230
+ details = self._tracer.describe_cycle(tuple(chain), key, self._locator)
231
+ raise ComponentCreationError(key, CircularDependencyError(chain, key, details=details))
232
+ token_chain = _resolve_chain.set(tuple(chain + [key]))
233
+ token_container = self.activate()
234
+ token_tracer = self._tracer.enter(key, via="provider")
235
+ try:
236
+ provider = self._factory.get(key)
237
+ try:
238
+ instance = provider()
239
+ if inspect.isawaitable(instance):
240
+ instance = await instance
241
+ except ProviderNotFoundError as e:
242
+ raise
243
+ except Exception as e:
244
+ raise ComponentCreationError(key, e) from e
245
+ instance = self._maybe_wrap_with_aspects(key, instance)
246
+ cache.put(key, instance)
247
+ self.context.resolve_count += 1
248
+ took_ms = (_tm.perf_counter() - t0) * 1000
249
+ for o in self._observers: o.on_resolve(key, took_ms)
250
+ return instance
251
+ finally:
252
+ self._tracer.leave(token_tracer)
253
+ _resolve_chain.reset(token_chain)
254
+ self.deactivate(token_container)
255
+
256
+ def _resolve_type_key(self, key: type):
257
+ if not self._locator:
258
+ return None
259
+ cands: List[Tuple[bool, Any]] = []
260
+ for k, md in self._locator._metadata.items():
261
+ typ = md.provided_type or md.concrete_class
262
+ if not isinstance(typ, type):
113
263
  continue
114
- if _is_compatible(cls, base_type):
115
- prov_qs = meta.get("qualifiers", ())
116
- if all(q in prov_qs for q in qualifiers):
117
- inst = self.get(provider_key)
118
- matches.append(inst)
119
- return matches
120
-
121
- def get_providers(self) -> Dict[Any, Dict]:
122
- return self._providers.copy()
123
-
124
- def _is_protocol(t) -> bool:
125
- return getattr(t, "_is_protocol", False) is True
126
-
127
- def _is_compatible(cls, base) -> bool:
128
- try:
129
- if isinstance(base, type) and issubclass(cls, base):
130
- return True
131
- except TypeError:
132
- pass
133
- if _is_protocol(base):
134
- names = set(getattr(base, "__annotations__", {}).keys())
135
- names.update(n for n in getattr(base, "__dict__", {}).keys() if not n.startswith("_"))
136
- for n in names:
137
- if n.startswith("__") and n.endswith("__"):
264
+ try:
265
+ if typ is not key and issubclass(typ, key):
266
+ cands.append((md.primary, k))
267
+ except Exception:
138
268
  continue
139
- if not hasattr(cls, n):
140
- return False
141
- return True
142
- return False
143
-
144
- def _requires_collection_of_base(cls, base) -> bool:
145
- try:
146
- sig = inspect.signature(cls.__init__)
147
- except Exception:
148
- return False
149
- try:
150
- from .resolver import _get_hints
151
- hints = _get_hints(cls.__init__, owner_cls=cls)
152
- except Exception:
153
- hints = {}
154
- for name, param in sig.parameters.items():
155
- if name == "self":
156
- continue
157
- ann = hints.get(name, param.annotation)
158
- origin = get_origin(ann) or ann
159
- if origin in (list, tuple, _t.List, _t.Tuple):
160
- inner = (get_args(ann) or (object,))[0]
161
- if get_origin(inner) is Annotated:
162
- args = get_args(inner)
163
- if args:
164
- inner = args[0]
165
- if inner is base:
166
- return True
167
- return False
269
+ if not cands:
270
+ return None
271
+ prim = [k for is_p, k in cands if is_p]
272
+ return prim[0] if prim else cands[0][1]
273
+
274
+ def _maybe_wrap_with_aspects(self, key, instance: Any) -> Any:
275
+ if isinstance(instance, UnifiedComponentProxy):
276
+ return instance
277
+ cls = type(instance)
278
+ for _, fn in inspect.getmembers(cls, predicate=lambda m: inspect.isfunction(m) or inspect.ismethod(m) or inspect.iscoroutinefunction(m)):
279
+ if getattr(fn, "_pico_interceptors_", None):
280
+ return UnifiedComponentProxy(container=self, target=instance)
281
+ return instance
282
+
283
+ def cleanup_all(self) -> None:
284
+ for _, obj in self._caches.all_items():
285
+ for _, m in inspect.getmembers(obj, predicate=inspect.ismethod):
286
+ meta = getattr(m, PICO_META, {})
287
+ if meta.get("cleanup", False):
288
+ from .api import _resolve_args
289
+ kwargs = _resolve_args(m, self)
290
+ m(**kwargs)
291
+ if self._locator:
292
+ seen = set()
293
+ for md in self._locator._metadata.values():
294
+ fc = md.factory_class
295
+ if fc and fc not in seen:
296
+ seen.add(fc)
297
+ inst = self.get(fc) if self._factory.has(fc) else fc()
298
+ for _, m in inspect.getmembers(inst, predicate=inspect.ismethod):
299
+ meta = getattr(m, PICO_META, {})
300
+ if meta.get("cleanup", False):
301
+ from .api import _resolve_args
302
+ kwargs = _resolve_args(m, self)
303
+ m(**kwargs)
304
+
305
+ def activate_scope(self, name: str, scope_id: Any):
306
+ return self.scopes.activate(name, scope_id)
307
+
308
+ def deactivate_scope(self, name: str, token: Optional[contextvars.Token]) -> None:
309
+ self.scopes.deactivate(name, token)
310
+
311
+ def info(self, msg: str) -> None:
312
+ LOGGER.info(f"[{self.container_id[:8]}] {msg}")
313
+
314
+ @contextmanager
315
+ def scope(self, name: str, scope_id: Any):
316
+ tok = self.activate_scope(name, scope_id)
317
+ try:
318
+ yield self
319
+ finally:
320
+ self.deactivate_scope(name, tok)
321
+
322
+ def health_check(self) -> Dict[str, bool]:
323
+ out: Dict[str, bool] = {}
324
+ for k, obj in self._caches.all_items():
325
+ for name, m in inspect.getmembers(obj, predicate=callable):
326
+ if getattr(m, PICO_META, {}).get("health_check", False):
327
+ try:
328
+ out[f"{getattr(k,'__name__',k)}.{name}"] = bool(m())
329
+ except Exception:
330
+ out[f"{getattr(k,'__name__',k)}.{name}"] = False
331
+ return out
332
+
333
+ async def cleanup_all_async(self) -> None:
334
+ for _, obj in self._caches.all_items():
335
+ for _, m in inspect.getmembers(obj, predicate=inspect.ismethod):
336
+ meta = getattr(m, PICO_META, {})
337
+ if meta.get("cleanup", False):
338
+ from .api import _resolve_args
339
+ res = m(**_resolve_args(m, self))
340
+ import inspect as _i
341
+ if _i.isawaitable(res):
342
+ await res
343
+ if self._locator:
344
+ seen = set()
345
+ for md in self._locator._metadata.values():
346
+ fc = md.factory_class
347
+ if fc and fc not in seen:
348
+ seen.add(fc)
349
+ inst = self.get(fc) if self._factory.has(fc) else fc()
350
+ for _, m in inspect.getmembers(inst, predicate=inspect.ismethod):
351
+ meta = getattr(m, PICO_META, {})
352
+ if meta.get("cleanup", False):
353
+ from .api import _resolve_args
354
+ res = m(**_resolve_args(m, self))
355
+ import inspect as _i
356
+ if _i.isawaitable(res):
357
+ await res
358
+ try:
359
+ from .event_bus import EventBus
360
+ for _, obj in self._caches.all_items():
361
+ if isinstance(obj, EventBus):
362
+ await obj.aclose()
363
+ except Exception:
364
+ pass
365
+
366
+ def stats(self) -> Dict[str, Any]:
367
+ import time as _t
368
+ resolves = self.context.resolve_count
369
+ hits = self.context.cache_hit_count
370
+ total = resolves + hits
371
+ return {
372
+ "container_id": self.container_id,
373
+ "profiles": self.context.profiles,
374
+ "uptime_seconds": _t.time() - self.context.created_at,
375
+ "total_resolves": resolves,
376
+ "cache_hits": hits,
377
+ "cache_hit_rate": (hits / total) if total > 0 else 0.0,
378
+ "registered_components": len(self._locator._metadata) if self._locator else 0,
379
+ }
380
+
381
+ def shutdown(self) -> None:
382
+ self.cleanup_all()
383
+ PicoContainer._container_registry.pop(self.container_id, None)
384
+
385
+ def export_graph(
386
+ self,
387
+ path: str,
388
+ *,
389
+ include_scopes: bool = True,
390
+ include_qualifiers: bool = False,
391
+ rankdir: str = "LR",
392
+ title: Optional[str] = None,
393
+ ) -> None:
394
+
395
+ if not self._locator:
396
+ raise RuntimeError("No locator attached; cannot export dependency graph.")
397
+
398
+ from .api import _build_resolution_graph
399
+
400
+ md_by_key = self._locator._metadata
401
+ graph = _build_resolution_graph(self)
402
+
403
+ lines: List[str] = []
404
+ lines.append("digraph Pico {")
405
+ lines.append(f' rankdir="{rankdir}";')
406
+ lines.append(" node [shape=box, fontsize=10];")
407
+ if title:
408
+ lines.append(f' labelloc="t";')
409
+ lines.append(f' label="{title}";')
410
+
411
+ def _node_id(k: KeyT) -> str:
412
+ return f'n_{abs(hash(k))}'
413
+
414
+ def _node_label(k: KeyT) -> str:
415
+ name = getattr(k, "__name__", str(k))
416
+ md = md_by_key.get(k)
417
+ parts = [name]
418
+ if md is not None and include_scopes:
419
+ parts.append(f"[scope={md.scope}]")
420
+ if md is not None and include_qualifiers and md.qualifiers:
421
+ q = ",".join(sorted(md.qualifiers))
422
+ parts.append(f"\\n⟨{q}⟩")
423
+ return "\\n".join(parts)
424
+
425
+ for key in md_by_key.keys():
426
+ nid = _node_id(key)
427
+ label = _node_label(key)
428
+ lines.append(f' {nid} [label="{label}"];')
429
+
430
+ for parent, deps in graph.items():
431
+ pid = _node_id(parent)
432
+ for child in deps:
433
+ cid = _node_id(child)
434
+ lines.append(f" {pid} -> {cid};")
435
+
436
+ lines.append("}")
437
+
438
+ with open(path, "w", encoding="utf-8") as f:
439
+ f.write("\n".join(lines))
168
440