mixinv2 0.3.0.post56.dev0__tar.gz → 0.3.0.post58.dev0__tar.gz

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.
Files changed (20) hide show
  1. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/PKG-INFO +1 -1
  2. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/src/mixinv2/__init__.py +4 -0
  3. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/src/mixinv2/_core.py +374 -14
  4. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/src/mixinv2/_runtime.py +48 -29
  5. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/.gitignore +0 -0
  6. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/README.md +0 -0
  7. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/docs/Makefile +0 -0
  8. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/docs/_static/favicon.svg +0 -0
  9. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/docs/_static/logo.svg +0 -0
  10. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/docs/conf.py +0 -0
  11. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/docs/index.rst +0 -0
  12. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/docs/installation.rst +0 -0
  13. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/docs/mixinv2-tutorial.rst +0 -0
  14. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/docs/specification.md +0 -0
  15. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/docs/tutorial.rst +0 -0
  16. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/pyproject.toml +0 -0
  17. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/src/mixinv2/_config.py +0 -0
  18. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/src/mixinv2/_interned_linked_list.py +0 -0
  19. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/src/mixinv2/_mixin_directory.py +0 -0
  20. {mixinv2-0.3.0.post56.dev0 → mixinv2-0.3.0.post58.dev0}/src/mixinv2/_mixin_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mixinv2
3
- Version: 0.3.0.post56.dev0
3
+ Version: 0.3.0.post58.dev0
4
4
  Summary: A dependency injection framework with pytest-fixture syntax, plus a configuration language for declarative programming
5
5
  Project-URL: Repository, https://github.com/Atry/MIXINv2
6
6
  Author-email: "Yang, Bo" <yang-bo@yang-bo.com>
@@ -18,6 +18,9 @@ Decorators:
18
18
  Runtime:
19
19
  - :func:`evaluate`
20
20
 
21
+ Exceptions:
22
+ - :class:`Bottom`
23
+
21
24
  Reference types (parameters to :func:`extend`):
22
25
  - :class:`AbsoluteReference`
23
26
  - :class:`RelativeReference`
@@ -40,6 +43,7 @@ from mixinv2._core import patch_many as patch_many
40
43
  from mixinv2._core import public as public
41
44
  from mixinv2._core import resource as resource
42
45
  from mixinv2._core import scope as scope
46
+ from mixinv2._core import Bottom as Bottom
43
47
  from mixinv2._runtime import evaluate as evaluate
44
48
 
45
49
  if TYPE_CHECKING:
@@ -530,6 +530,7 @@ from __future__ import annotations
530
530
 
531
531
  from abc import ABC, abstractmethod
532
532
  from collections import defaultdict, deque
533
+ from contextvars import ContextVar
533
534
  from dataclasses import dataclass, field, replace
534
535
  from enum import Enum, auto
535
536
  from functools import cached_property
@@ -603,6 +604,334 @@ TResult_co = TypeVar("TResult_co", covariant=True)
603
604
  P = ParamSpec("P")
604
605
 
605
606
 
607
+ class _FixpointContext:
608
+ """Tracks the state of a fixpoint iteration (digest cycle).
609
+
610
+ Stored in a ContextVar so that nested/concurrent fixpoint computations
611
+ are isolated per-thread/per-coroutine.
612
+ """
613
+
614
+ __slots__ = ("computing", "reentrant", "participant_ids", "participant_refs",
615
+ "_clearable_attr_names", "approximations")
616
+
617
+ def __init__(self, clearable_attr_names: frozenset[str]) -> None:
618
+ self.computing: set[tuple[int, str]] = set()
619
+ self.reentrant: bool = False
620
+ self.participant_ids: set[int] = set()
621
+ self.participant_refs: list[object] = []
622
+ self._clearable_attr_names = clearable_attr_names
623
+ self.approximations: dict[tuple[int, str], object] = {}
624
+
625
+ def add_participant(self, instance: object) -> None:
626
+ instance_id = id(instance)
627
+ if instance_id not in self.participant_ids:
628
+ self.participant_ids.add(instance_id)
629
+ self.participant_refs.append(instance)
630
+
631
+ def clear_participant_caches(self) -> None:
632
+ """Clear all fixpoint-related cached values on all participants.
633
+
634
+ Before clearing, save each value into ``approximations`` so that
635
+ intermediate fixpoint_cached_property computations can use their
636
+ previous iteration's result as an approximation instead of bottom
637
+ when they encounter reentry.
638
+ """
639
+ for instance in self.participant_refs:
640
+ instance_dict = instance.__dict__
641
+ instance_id = id(instance)
642
+ for attr_name in self._clearable_attr_names:
643
+ value = instance_dict.pop(attr_name, None)
644
+ if value is not None:
645
+ self.approximations[(instance_id, attr_name)] = value
646
+
647
+
648
+ _fixpoint_context_var: ContextVar[_FixpointContext | None] = ContextVar(
649
+ "_fixpoint_context_var", default=None
650
+ )
651
+
652
+
653
+ class Bottom(RecursionError):
654
+ """Raised when fixpoint iteration is exhausted or reentry is detected with no iterations remaining.
655
+
656
+ Carries the best approximation computed so far in ``incomplete_result``.
657
+ As a ``RecursionError`` subclass, existing code that catches ``RecursionError``
658
+ will also catch ``Bottom``.
659
+ """
660
+
661
+ incomplete_result: object
662
+
663
+ def __init__(self, message: str, *, incomplete_result: object) -> None:
664
+ super().__init__(message)
665
+ self.incomplete_result = incomplete_result
666
+
667
+
668
+ _max_fixpoint_iterations_var: ContextVar[int] = ContextVar(
669
+ "_max_fixpoint_iterations_var", default=100
670
+ )
671
+ _FIXPOINT_SENTINEL = object()
672
+
673
+ # Registry of attribute names that need clearing during fixpoint digest cycles.
674
+ # Populated by fixpoint_cached_property and fixpoint_dependent decorators.
675
+ _fixpoint_clearable_attrs: set[str] = set()
676
+
677
+
678
+ def _accumulate_defaultdict_set(
679
+ accumulator: defaultdict[object, set[object]],
680
+ new_result: defaultdict[object, set[object]],
681
+ ) -> bool:
682
+ """Merge new_result into accumulator (pointwise set union).
683
+
684
+ Returns True if accumulator grew (new entries were added).
685
+ """
686
+ changed = False
687
+ for key, values in new_result.items():
688
+ existing = accumulator[key]
689
+ old_size = len(existing)
690
+ existing.update(values)
691
+ if len(existing) > old_size:
692
+ changed = True
693
+ return changed
694
+
695
+
696
+ class fixpoint_cached_property:
697
+ """A cached_property that supports mutual-recursion via least fixpoint iteration.
698
+
699
+ API-compatible with functools.cached_property. When reentry is detected
700
+ (mutual recursion), returns the previous iteration's approximation
701
+ (or ``bottom()`` on the first iteration). The outermost caller drives
702
+ a digest loop until values stabilize (no reentry occurs in a round).
703
+
704
+ Usage::
705
+
706
+ @fixpoint_cached_property(bottom=lambda: defaultdict(set))
707
+ def qualified_this(self):
708
+ ...
709
+ """
710
+
711
+ def __init__(
712
+ self,
713
+ func: Callable = None,
714
+ *,
715
+ bottom: Callable[[], object],
716
+ accumulate: Callable[[object, object], bool] | None = None,
717
+ ) -> None:
718
+ # Support both @fixpoint_cached_property(bottom=...) and direct call
719
+ self._bottom = bottom
720
+ self._accumulate = accumulate
721
+ if func is not None:
722
+ self.func: Callable = func
723
+ self.attrname: str = func.__name__
724
+ self.__doc__ = func.__doc__
725
+ _fixpoint_clearable_attrs.add(self.attrname)
726
+
727
+ def __call__(self, func: Callable) -> "fixpoint_cached_property":
728
+ """Support @fixpoint_cached_property(bottom=...) decorator syntax."""
729
+ self.func = func
730
+ self.attrname = func.__name__
731
+ self.__doc__ = func.__doc__
732
+ _fixpoint_clearable_attrs.add(self.attrname)
733
+ return self
734
+
735
+ def __set_name__(self, owner: type, name: str) -> None:
736
+ if not hasattr(self, "attrname"):
737
+ self.attrname = name
738
+ _fixpoint_clearable_attrs.add(self.attrname)
739
+
740
+ def __get__(self, instance: object, owner: type = None) -> object:
741
+ if instance is None:
742
+ return self
743
+
744
+ # Fast path: already cached
745
+ cache = instance.__dict__
746
+ value = cache.get(self.attrname)
747
+ if value is not None:
748
+ max_iterations = _max_fixpoint_iterations_var.get()
749
+ if max_iterations == 0:
750
+ return value
751
+ # Detect reentry: if this key is currently being computed
752
+ # (on the call stack), accessing its cached approximation
753
+ # means the fixpoint has not converged yet.
754
+ context = _fixpoint_context_var.get()
755
+ if context is not None:
756
+ key = (id(instance), self.attrname)
757
+ if key in context.computing:
758
+ context.reentrant = True
759
+ context.add_participant(instance)
760
+ return value
761
+
762
+ max_iterations = _max_fixpoint_iterations_var.get()
763
+ context = _fixpoint_context_var.get()
764
+ instance_id = id(instance)
765
+ key = (instance_id, self.attrname)
766
+
767
+ if context is None:
768
+ # I am the driver — start a digest loop (or single-pass for max_iterations=0)
769
+ context = _FixpointContext(
770
+ clearable_attr_names=frozenset(_fixpoint_clearable_attrs)
771
+ )
772
+ token = _fixpoint_context_var.set(context)
773
+ try:
774
+ if max_iterations == 0:
775
+ # Zero-iteration mode: compute once with reentry detection.
776
+ # Reentry raises Bottom instead of infinite recursion.
777
+ context.computing.add(key)
778
+ result = self.func(instance)
779
+ cache[self.attrname] = result
780
+ return result
781
+
782
+ approximation = self._bottom()
783
+ accumulator = self._bottom() if self._accumulate is not None else None
784
+ previous_result = _FIXPOINT_SENTINEL
785
+ for iteration in range(max_iterations):
786
+ context.computing.add(key)
787
+ context.add_participant(instance)
788
+ result = self.func(instance)
789
+
790
+ if not context.reentrant:
791
+ # No reentry this round — fixpoint reached
792
+ cache[self.attrname] = result
793
+ return result
794
+
795
+ if self._accumulate is not None:
796
+ # Monotonic accumulation: merge each iteration's
797
+ # result into an accumulator that only grows.
798
+ # This prevents oscillation when intermediate
799
+ # computations encounter cycles in varying order.
800
+ changed = self._accumulate(accumulator, result)
801
+ if not changed and iteration > 0:
802
+ cache[self.attrname] = accumulator
803
+ return accumulator
804
+ # Use the accumulator as next round's approximation
805
+ approximation = accumulator
806
+ else:
807
+ # Exact equality convergence (original behavior)
808
+ if result == previous_result:
809
+ cache[self.attrname] = result
810
+ return result
811
+ previous_result = result
812
+ approximation = result
813
+
814
+ # Cache current approximation, clear all intermediate
815
+ # caches, and re-run
816
+ cache[self.attrname] = approximation
817
+ context.clear_participant_caches()
818
+ # Restore driver's own approximation
819
+ cache[self.attrname] = approximation
820
+ context.computing.clear()
821
+ context.reentrant = False
822
+
823
+ raise Bottom(
824
+ f"fixpoint_cached_property '{self.attrname}' did not converge "
825
+ f"after {max_iterations} iterations",
826
+ incomplete_result=approximation,
827
+ )
828
+ finally:
829
+ _fixpoint_context_var.reset(token)
830
+ elif key in context.computing:
831
+ # Reentry detected — return previous approximation or bottom.
832
+ context.reentrant = True
833
+ context.add_participant(instance)
834
+ if max_iterations == 0:
835
+ raise Bottom(
836
+ f"fixpoint_cached_property '{self.attrname}': "
837
+ f"reentry detected with max_fixpoint_iterations=0",
838
+ incomplete_result=self._bottom(),
839
+ )
840
+ # Check the instance cache first, then fall back to saved
841
+ # approximations from the previous iteration.
842
+ approximation = cache.get(self.attrname)
843
+ if approximation is not None:
844
+ return approximation
845
+ saved = context.approximations.get(key)
846
+ if saved is not None:
847
+ return saved
848
+ return self._bottom()
849
+ else:
850
+ # Inside a fixpoint context but this is a fresh (instance, attr)
851
+ # pair — compute normally. Keep the key in ``computing`` only
852
+ # while ``self.func`` runs so that cycles through this key are
853
+ # detected by the ``elif`` branch above. Once computation
854
+ # finishes, remove the key so the fast-path cache check does
855
+ # not misidentify a later read of this cached value as reentry.
856
+ context.computing.add(key)
857
+ context.add_participant(instance)
858
+ result = self.func(instance)
859
+ context.computing.discard(key)
860
+ cache[self.attrname] = result
861
+ return result
862
+
863
+ def __set__(self, instance: object, value: object) -> None:
864
+ """Data descriptor setter to ensure __get__ is always called."""
865
+ instance.__dict__[self.attrname] = value
866
+
867
+
868
+ class _fixpoint_dependent_property:
869
+ """A cached_property that registers its instance as a fixpoint participant.
870
+
871
+ Behaves like ``functools.cached_property`` but, when computed inside an
872
+ active fixpoint context, registers the instance so that
873
+ ``clear_participant_caches`` will clear the cached value between
874
+ iterations. Without this, stale values computed from an incomplete
875
+ fixpoint approximation survive across iterations.
876
+ """
877
+
878
+ def __init__(self, func: Callable) -> None:
879
+ self.func = func
880
+ self.attrname = func.__name__
881
+ self.__doc__ = func.__doc__
882
+ _fixpoint_clearable_attrs.add(self.attrname)
883
+
884
+ def __set_name__(self, owner: type, name: str) -> None:
885
+ if not hasattr(self, "attrname"):
886
+ self.attrname = name
887
+ _fixpoint_clearable_attrs.add(self.attrname)
888
+
889
+ def __get__(self, instance: object, owner: type = None) -> object:
890
+ if instance is None:
891
+ return self
892
+
893
+ cache = instance.__dict__
894
+ value = cache.get(self.attrname)
895
+ if value is not None:
896
+ return value
897
+
898
+ if _max_fixpoint_iterations_var.get() > 0:
899
+ # Register as participant so clear_participant_caches can
900
+ # invalidate this cached value between fixpoint iterations.
901
+ context = _fixpoint_context_var.get()
902
+ if context is not None:
903
+ context.add_participant(instance)
904
+
905
+ value = self.func(instance)
906
+ cache[self.attrname] = value
907
+ return value
908
+
909
+
910
+ def fixpoint_dependent(func: Callable) -> _fixpoint_dependent_property:
911
+ """Mark a cached_property as dependent on fixpoint_cached_property values.
912
+
913
+ During fixpoint digest cycles, these caches are cleared between iterations
914
+ so they are recomputed with updated approximations.
915
+
916
+ Usage::
917
+
918
+ @fixpoint_dependent
919
+ @cached_property
920
+ def symbol_kind(self):
921
+ ...
922
+
923
+ Or equivalently::
924
+
925
+ @fixpoint_dependent
926
+ def symbol_kind(self):
927
+ ...
928
+ """
929
+ if isinstance(func, cached_property):
930
+ return _fixpoint_dependent_property(func.func)
931
+ else:
932
+ return _fixpoint_dependent_property(func)
933
+
934
+
606
935
  class HasDict:
607
936
  """
608
937
  Extendable helper class that adds ``__dict__`` slot for classes that need ``@cached_property``.
@@ -892,7 +1221,7 @@ class MixinSymbol(HasDict, Mapping[Hashable, "MixinSymbol"], Symbol):
892
1221
  return tuple(definitions)
893
1222
 
894
1223
  @final
895
- @cached_property
1224
+ @fixpoint_dependent
896
1225
  def normalized_references(self) -> tuple["ResolvedReference", ...]:
897
1226
  """
898
1227
  Flatten all bases from all definitions into a single tuple.
@@ -1051,9 +1380,14 @@ class MixinSymbol(HasDict, Mapping[Hashable, "MixinSymbol"], Symbol):
1051
1380
  # Use Nested to create child symbol with lazy definition resolution
1052
1381
  compiled_symbol = MixinSymbol(origin=Nested(outer=self, key=key))
1053
1382
 
1054
- # Check if any super union of self has this key as an own key
1055
- if not any(super_union.has_own_key(key) for super_union in self.qualified_this):
1056
- raise KeyError(key)
1383
+ # Check if self or any super union of self has this key as an own key.
1384
+ # During fixpoint iteration, qualified_this may not have converged yet,
1385
+ # so skip this validation to avoid false negatives.
1386
+ if _fixpoint_context_var.get() is None:
1387
+ if not self.has_own_key(key) and not any(
1388
+ super_union.has_own_key(key) for super_union in self.qualified_this
1389
+ ):
1390
+ raise KeyError(key)
1057
1391
 
1058
1392
  self._nested[key] = compiled_symbol
1059
1393
  return compiled_symbol
@@ -1072,16 +1406,19 @@ class MixinSymbol(HasDict, Mapping[Hashable, "MixinSymbol"], Symbol):
1072
1406
  case MixinSymbol() as outer_symbol:
1073
1407
  return outer_symbol.depth + 1
1074
1408
 
1075
- @cached_property
1409
+ @fixpoint_dependent
1076
1410
  def is_public(self):
1077
- # Check if any definition is public
1411
+ # Check own definitions first (does not depend on qualified_this)
1412
+ if any(definition.is_public for definition in self.definitions):
1413
+ return True
1414
+ # Check inherited definitions via qualified_this
1078
1415
  return any(
1079
1416
  definition.is_public
1080
1417
  for super_symbol in self.qualified_this
1081
1418
  for definition in super_symbol.definitions
1082
1419
  )
1083
1420
 
1084
- @cached_property
1421
+ @fixpoint_dependent
1085
1422
  def is_eager(self):
1086
1423
  return any(
1087
1424
  definition.is_eager
@@ -1090,7 +1427,7 @@ class MixinSymbol(HasDict, Mapping[Hashable, "MixinSymbol"], Symbol):
1090
1427
  if isinstance(definition, MergerDefinition)
1091
1428
  )
1092
1429
 
1093
- @cached_property
1430
+ @fixpoint_dependent
1094
1431
  def symbol_kind(self) -> "SymbolKind":
1095
1432
  """Classify this symbol into one of three categories.
1096
1433
 
@@ -1176,7 +1513,10 @@ class MixinSymbol(HasDict, Mapping[Hashable, "MixinSymbol"], Symbol):
1176
1513
  case _:
1177
1514
  raise ValueError("Multiple pure merger definitions found")
1178
1515
 
1179
- @cached_property
1516
+ @fixpoint_cached_property(
1517
+ bottom=lambda: defaultdict(set),
1518
+ accumulate=_accumulate_defaultdict_set,
1519
+ )
1180
1520
  def qualified_this(self) -> Mapping["MixinSymbol", Collection[MixinSymbol]]:
1181
1521
  """Map each overlay of ``self`` to the outer scopes that instantiate it.
1182
1522
 
@@ -1220,7 +1560,7 @@ class MixinSymbol(HasDict, Mapping[Hashable, "MixinSymbol"], Symbol):
1220
1560
  if outer_union.has_own_key(key):
1221
1561
  yield outer_union[key]
1222
1562
 
1223
- @cached_property
1563
+ @fixpoint_dependent
1224
1564
  def overlays(self):
1225
1565
  return frozenset(self._generate_overlays())
1226
1566
 
@@ -2475,9 +2815,15 @@ class AbsoluteReference:
2475
2815
  for part in self.path:
2476
2816
  child_symbol = current_symbol.get(part)
2477
2817
  if child_symbol is None:
2818
+ kind_hint = (
2819
+ f" (unexpected kind {current_symbol.symbol_kind.name})"
2820
+ if current_symbol.symbol_kind is not SymbolKind.SCOPE
2821
+ else ""
2822
+ )
2478
2823
  raise ValueError(
2479
2824
  f"Cannot navigate path {self.path!r}: "
2480
- f"'{current_symbol.key}' has no child '{part}'"
2825
+ f"{current_symbol.path}{kind_hint}"
2826
+ f" has no child '{part}'"
2481
2827
  )
2482
2828
  current_symbol = child_symbol
2483
2829
 
@@ -2533,7 +2879,9 @@ class RelativeReference:
2533
2879
  if child_symbol is None:
2534
2880
  raise ValueError(
2535
2881
  f"Cannot navigate path {self.path!r}: "
2536
- f"'{current_symbol.key}' has no child '{part}'"
2882
+ f"{current_symbol.path} (unexpected kind"
2883
+ f" {current_symbol.symbol_kind.name})"
2884
+ f" has no child '{part}'"
2537
2885
  )
2538
2886
  current_symbol = child_symbol
2539
2887
 
@@ -2738,9 +3086,15 @@ class LexicalReference:
2738
3086
  for part in hashable_path:
2739
3087
  child_symbol = current_symbol.get(part)
2740
3088
  if child_symbol is None:
3089
+ kind_hint = (
3090
+ f" (unexpected kind {current_symbol.symbol_kind.name})"
3091
+ if current_symbol.symbol_kind is not SymbolKind.SCOPE
3092
+ else ""
3093
+ )
2741
3094
  raise ValueError(
2742
3095
  f"Cannot navigate path {hashable_path!r}: "
2743
- f"'{current_symbol.key}' has no child '{part}'"
3096
+ f"{current_symbol.path}{kind_hint}"
3097
+ f" has no child '{part}'"
2744
3098
  )
2745
3099
  current_symbol = child_symbol
2746
3100
 
@@ -2828,9 +3182,15 @@ class QualifiedThisReference:
2828
3182
  for part in hashable_path:
2829
3183
  child_symbol = current_symbol.get(part)
2830
3184
  if child_symbol is None:
3185
+ kind_hint = (
3186
+ f" (unexpected kind {current_symbol.symbol_kind.name})"
3187
+ if current_symbol.symbol_kind is not SymbolKind.SCOPE
3188
+ else ""
3189
+ )
2831
3190
  raise ValueError(
2832
3191
  f"Cannot navigate path {hashable_path!r}: "
2833
- f"'{current_symbol.key}' has no child '{part}'"
3192
+ f"{current_symbol.path}{kind_hint}"
3193
+ f" has no child '{part}'"
2834
3194
  )
2835
3195
  current_symbol = child_symbol
2836
3196
 
@@ -30,7 +30,12 @@ from typing import (
30
30
  final,
31
31
  )
32
32
 
33
- from mixinv2._core import HasDict, OuterSentinel, SymbolKind
33
+ from mixinv2._core import (
34
+ HasDict,
35
+ OuterSentinel,
36
+ SymbolKind,
37
+ _max_fixpoint_iterations_var,
38
+ )
34
39
 
35
40
 
36
41
  class KwargsSentinel(Enum):
@@ -634,6 +639,7 @@ class MultiplePatcher(Patcher[TPatch_co]):
634
639
  def evaluate(
635
640
  *namespaces: "ModuleType | ScopeDefinition",
636
641
  modules_public: bool = False,
642
+ max_fixpoint_iterations: int = 100,
637
643
  ) -> Scope:
638
644
  """
639
645
  Resolves a Scope from the given namespaces.
@@ -650,6 +656,10 @@ def evaluate(
650
656
  :param namespaces: Modules or namespace definitions (decorated with @scope) to resolve.
651
657
  :param modules_public: If True, modules are marked as public, making their submodules
652
658
  accessible via attribute access. Defaults to False (private by default).
659
+ :param max_fixpoint_iterations: Maximum number of fixpoint iterations for
660
+ resolving cyclic dependencies. ``0`` disables fixpoint iteration and
661
+ raises ``Bottom`` on reentry. Default ``100`` iterates until convergence
662
+ or raises ``Bottom`` if not converged.
653
663
  :return: The root Scope.
654
664
 
655
665
  Example::
@@ -657,6 +667,7 @@ def evaluate(
657
667
  root = evaluate(MyNamespace)
658
668
  root = evaluate(Base, Override) # Union mount
659
669
  root = evaluate(my_package, modules_public=True) # Make modules accessible
670
+ root = evaluate(MyNamespace, max_fixpoint_iterations=0) # No fixpoint iteration
660
671
 
661
672
  """
662
673
  from dataclasses import replace
@@ -671,36 +682,44 @@ def evaluate(
671
682
  )
672
683
 
673
684
  assert namespaces, "evaluate() requires at least one namespace"
685
+ if max_fixpoint_iterations < 0:
686
+ raise ValueError(
687
+ f"max_fixpoint_iterations must be non-negative, got {max_fixpoint_iterations}"
688
+ )
674
689
 
675
- def to_scope_definition(
676
- namespace: ModuleType | ScopeDefinition,
677
- ) -> ScopeDefinition:
678
- if isinstance(namespace, ScopeDefinition):
679
- return namespace
680
- if isinstance(namespace, ModuleType):
681
- definition = _parse_package(namespace)
682
- if modules_public:
683
- return replace(definition, is_public=True)
684
- return definition
685
- assert_never(namespace)
686
-
687
- definitions = tuple(to_scope_definition(namespace) for namespace in namespaces)
688
-
689
- root_symbol = MixinSymbol(origin=definitions)
690
-
691
- # Create a synthetic root Mixin to enable lexical scope navigation
692
- # This is needed so that children of the root scope can navigate up
693
- # to find parent scope dependencies (via get_mixin)
694
- root_mixin = Mixin(
695
- symbol=root_symbol,
696
- outer=OuterSentinel.ROOT,
697
- kwargs=KwargsSentinel.STATIC, # Root is always static
698
- )
690
+ token = _max_fixpoint_iterations_var.set(max_fixpoint_iterations)
691
+ try:
692
+ def to_scope_definition(
693
+ namespace: ModuleType | ScopeDefinition,
694
+ ) -> ScopeDefinition:
695
+ if isinstance(namespace, ScopeDefinition):
696
+ return namespace
697
+ if isinstance(namespace, ModuleType):
698
+ definition = _parse_package(namespace)
699
+ if modules_public:
700
+ return replace(definition, is_public=True)
701
+ return definition
702
+ assert_never(namespace)
703
+
704
+ definitions = tuple(to_scope_definition(namespace) for namespace in namespaces)
705
+
706
+ root_symbol = MixinSymbol(origin=definitions)
707
+
708
+ # Create a synthetic root Mixin to enable lexical scope navigation
709
+ # This is needed so that children of the root scope can navigate up
710
+ # to find parent scope dependencies (via get_mixin)
711
+ root_mixin = Mixin(
712
+ symbol=root_symbol,
713
+ outer=OuterSentinel.ROOT,
714
+ kwargs=KwargsSentinel.STATIC, # Root is always static
715
+ )
699
716
 
700
- # Evaluate the root mixin to get the Scope
701
- result = root_mixin.evaluated
702
- assert isinstance(result, Scope)
703
- return result
717
+ # Evaluate the root mixin to get the Scope
718
+ result = root_mixin.evaluated
719
+ assert isinstance(result, Scope)
720
+ return result
721
+ finally:
722
+ _max_fixpoint_iterations_var.reset(token)
704
723
 
705
724
 
706
725
  # Re-export types needed by TYPE_CHECKING imports