mixinv2 0.3.0.post57.dev0__tar.gz → 0.3.0.post65.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.post57.dev0 → mixinv2-0.3.0.post65.dev0}/PKG-INFO +1 -1
  2. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.dev0}/src/mixinv2/__init__.py +5 -0
  3. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.dev0}/src/mixinv2/_core.py +388 -14
  4. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.dev0}/src/mixinv2/_runtime.py +11 -1
  5. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.dev0}/.gitignore +0 -0
  6. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.dev0}/README.md +0 -0
  7. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.dev0}/docs/Makefile +0 -0
  8. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.dev0}/docs/_static/favicon.svg +0 -0
  9. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.dev0}/docs/_static/logo.svg +0 -0
  10. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.dev0}/docs/conf.py +0 -0
  11. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.dev0}/docs/index.rst +0 -0
  12. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.dev0}/docs/installation.rst +0 -0
  13. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.dev0}/docs/mixinv2-tutorial.rst +0 -0
  14. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.dev0}/docs/specification.md +0 -0
  15. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.dev0}/docs/tutorial.rst +0 -0
  16. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.dev0}/pyproject.toml +0 -0
  17. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.dev0}/src/mixinv2/_config.py +0 -0
  18. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.dev0}/src/mixinv2/_interned_linked_list.py +0 -0
  19. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.dev0}/src/mixinv2/_mixin_directory.py +0 -0
  20. {mixinv2-0.3.0.post57.dev0 → mixinv2-0.3.0.post65.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.post57.dev0
3
+ Version: 0.3.0.post65.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,8 @@ 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
47
+ from mixinv2._core import fixpoint_cached_property as fixpoint_cached_property
43
48
  from mixinv2._runtime import evaluate as evaluate
44
49
 
45
50
  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
@@ -552,6 +553,7 @@ from typing import (
552
553
  Awaitable,
553
554
  Callable,
554
555
  ChainMap,
556
+ ClassVar,
555
557
  Collection,
556
558
  ContextManager,
557
559
  Final,
@@ -603,6 +605,347 @@ TResult_co = TypeVar("TResult_co", covariant=True)
603
605
  P = ParamSpec("P")
604
606
 
605
607
 
608
+ class _FixpointContext:
609
+ """Tracks the state of a fixpoint iteration (digest cycle).
610
+
611
+ Stored in a ContextVar so that nested/concurrent fixpoint computations
612
+ are isolated per-thread/per-coroutine.
613
+ """
614
+
615
+ __slots__ = ("computing", "reentrant", "participant_ids", "participant_refs",
616
+ "_clearable_attr_names", "approximations")
617
+
618
+ def __init__(self, clearable_attr_names: frozenset[str]) -> None:
619
+ self.computing: set[tuple[int, str]] = set()
620
+ self.reentrant: bool = False
621
+ self.participant_ids: set[int] = set()
622
+ self.participant_refs: list[object] = []
623
+ self._clearable_attr_names = clearable_attr_names
624
+ self.approximations: dict[tuple[int, str], object] = {}
625
+
626
+ def add_participant(self, instance: object) -> None:
627
+ instance_id = id(instance)
628
+ if instance_id not in self.participant_ids:
629
+ self.participant_ids.add(instance_id)
630
+ self.participant_refs.append(instance)
631
+
632
+ def clear_participant_caches(self) -> None:
633
+ """Clear all fixpoint-related cached values on all participants.
634
+
635
+ Before clearing, save each value into ``approximations`` so that
636
+ intermediate fixpoint_cached_property computations can use their
637
+ previous iteration's result as an approximation instead of bottom
638
+ when they encounter reentry.
639
+ """
640
+ for instance in self.participant_refs:
641
+ instance_dict = instance.__dict__
642
+ instance_id = id(instance)
643
+ for attr_name in self._clearable_attr_names:
644
+ value = instance_dict.pop(attr_name, None)
645
+ if value is not None:
646
+ self.approximations[(instance_id, attr_name)] = value
647
+
648
+
649
+ _fixpoint_context_var: ContextVar[_FixpointContext | None] = ContextVar(
650
+ "_fixpoint_context_var", default=None
651
+ )
652
+
653
+
654
+ class Bottom(RecursionError):
655
+ """Raised when fixpoint iteration is exhausted or reentry is detected with no iterations remaining.
656
+
657
+ Carries the best approximation computed so far in ``incomplete_result``.
658
+ As a ``RecursionError`` subclass, existing code that catches ``RecursionError``
659
+ will also catch ``Bottom``.
660
+ """
661
+
662
+ incomplete_result: object
663
+
664
+ def __init__(self, message: str, *, incomplete_result: object) -> None:
665
+ super().__init__(message)
666
+ self.incomplete_result = incomplete_result
667
+
668
+
669
+ _FIXPOINT_SENTINEL = object()
670
+
671
+ # Registry of attribute names that need clearing during fixpoint digest cycles.
672
+ # Populated by fixpoint_cached_property and fixpoint_dependent decorators.
673
+ _fixpoint_clearable_attrs: set[str] = set()
674
+
675
+
676
+ def _accumulate_defaultdict_set(
677
+ accumulator: defaultdict[object, set[object]],
678
+ new_result: defaultdict[object, set[object]],
679
+ ) -> bool:
680
+ """Merge new_result into accumulator (pointwise set union).
681
+
682
+ Returns True if accumulator grew (new entries were added).
683
+ """
684
+ changed = False
685
+ for key, values in new_result.items():
686
+ existing = accumulator[key]
687
+ old_size = len(existing)
688
+ existing.update(values)
689
+ if len(existing) > old_size:
690
+ changed = True
691
+ return changed
692
+
693
+
694
+ class fixpoint_cached_property:
695
+ """A cached_property that supports mutual-recursion via least fixpoint iteration.
696
+
697
+ API-compatible with functools.cached_property. When reentry is detected
698
+ (mutual recursion), returns the previous iteration's approximation
699
+ (or ``bottom()`` on the first iteration). The outermost caller drives
700
+ a digest loop until values stabilize (no reentry occurs in a round).
701
+
702
+ Usage::
703
+
704
+ @fixpoint_cached_property(bottom=lambda: defaultdict(set))
705
+ def qualified_this(self):
706
+ ...
707
+
708
+ The class-level ``max_fixpoint_iterations`` ContextVar controls the
709
+ maximum number of digest rounds. ``0`` disables fixpoint iteration
710
+ and raises ``Bottom`` on reentry. Default ``100`` iterates until
711
+ convergence or raises ``Bottom`` if not converged::
712
+
713
+ fixpoint_cached_property.max_fixpoint_iterations.set(0) # single-pass
714
+ fixpoint_cached_property.max_fixpoint_iterations.set(100) # multi-pass (default)
715
+ """
716
+
717
+ max_fixpoint_iterations: ClassVar[ContextVar[int]] = ContextVar(
718
+ "fixpoint_cached_property.max_fixpoint_iterations", default=100
719
+ )
720
+
721
+ def __init__(
722
+ self,
723
+ func: Callable = None,
724
+ *,
725
+ bottom: Callable[[], object],
726
+ accumulate: Callable[[object, object], bool] | None = None,
727
+ ) -> None:
728
+ # Support both @fixpoint_cached_property(bottom=...) and direct call
729
+ self._bottom = bottom
730
+ self._accumulate = accumulate
731
+ if func is not None:
732
+ self.func: Callable = func
733
+ self.attrname: str = func.__name__
734
+ self.__doc__ = func.__doc__
735
+ _fixpoint_clearable_attrs.add(self.attrname)
736
+
737
+ def __call__(self, func: Callable) -> "fixpoint_cached_property":
738
+ """Support @fixpoint_cached_property(bottom=...) decorator syntax."""
739
+ self.func = func
740
+ self.attrname = func.__name__
741
+ self.__doc__ = func.__doc__
742
+ _fixpoint_clearable_attrs.add(self.attrname)
743
+ return self
744
+
745
+ def __set_name__(self, owner: type, name: str) -> None:
746
+ if not hasattr(self, "attrname"):
747
+ self.attrname = name
748
+ _fixpoint_clearable_attrs.add(self.attrname)
749
+
750
+ @classmethod
751
+ def _get_max_iterations(cls) -> int:
752
+ return cls.max_fixpoint_iterations.get()
753
+
754
+ def __get__(self, instance: object, owner: type = None) -> object:
755
+ if instance is None:
756
+ return self
757
+
758
+ # Fast path: already cached
759
+ cache = instance.__dict__
760
+ value = cache.get(self.attrname)
761
+ if value is not None:
762
+ max_iterations = self._get_max_iterations()
763
+ if max_iterations == 0:
764
+ return value
765
+ # Detect reentry: if this key is currently being computed
766
+ # (on the call stack), accessing its cached approximation
767
+ # means the fixpoint has not converged yet.
768
+ context = _fixpoint_context_var.get()
769
+ if context is not None:
770
+ key = (id(instance), self.attrname)
771
+ if key in context.computing:
772
+ context.reentrant = True
773
+ context.add_participant(instance)
774
+ return value
775
+
776
+ max_iterations = self._get_max_iterations()
777
+ context = _fixpoint_context_var.get()
778
+ instance_id = id(instance)
779
+ key = (instance_id, self.attrname)
780
+
781
+ if context is None:
782
+ # I am the driver — start a digest loop (or single-pass for max_iterations=0)
783
+ context = _FixpointContext(
784
+ clearable_attr_names=frozenset(_fixpoint_clearable_attrs)
785
+ )
786
+ token = _fixpoint_context_var.set(context)
787
+ try:
788
+ if max_iterations == 0:
789
+ # Zero-iteration mode: compute once with reentry detection.
790
+ # Reentry raises Bottom instead of infinite recursion.
791
+ context.computing.add(key)
792
+ result = self.func(instance)
793
+ cache[self.attrname] = result
794
+ return result
795
+
796
+ approximation = self._bottom()
797
+ accumulator = self._bottom() if self._accumulate is not None else None
798
+ previous_result = _FIXPOINT_SENTINEL
799
+ for iteration in range(max_iterations):
800
+ context.computing.add(key)
801
+ context.add_participant(instance)
802
+ result = self.func(instance)
803
+
804
+ if not context.reentrant:
805
+ # No reentry this round — fixpoint reached
806
+ cache[self.attrname] = result
807
+ return result
808
+
809
+ if self._accumulate is not None:
810
+ # Monotonic accumulation: merge each iteration's
811
+ # result into an accumulator that only grows.
812
+ # This prevents oscillation when intermediate
813
+ # computations encounter cycles in varying order.
814
+ changed = self._accumulate(accumulator, result)
815
+ if not changed and iteration > 0:
816
+ cache[self.attrname] = accumulator
817
+ return accumulator
818
+ # Use the accumulator as next round's approximation
819
+ approximation = accumulator
820
+ else:
821
+ # Exact equality convergence (original behavior)
822
+ if result == previous_result:
823
+ cache[self.attrname] = result
824
+ return result
825
+ previous_result = result
826
+ approximation = result
827
+
828
+ # Cache current approximation, clear all intermediate
829
+ # caches, and re-run
830
+ cache[self.attrname] = approximation
831
+ context.clear_participant_caches()
832
+ # Restore driver's own approximation
833
+ cache[self.attrname] = approximation
834
+ context.computing.clear()
835
+ context.reentrant = False
836
+
837
+ raise Bottom(
838
+ f"fixpoint_cached_property '{self.attrname}' did not converge "
839
+ f"after {max_iterations} iterations",
840
+ incomplete_result=approximation,
841
+ )
842
+ finally:
843
+ _fixpoint_context_var.reset(token)
844
+ elif key in context.computing:
845
+ # Reentry detected — return previous approximation or bottom.
846
+ context.reentrant = True
847
+ context.add_participant(instance)
848
+ if max_iterations == 0:
849
+ raise Bottom(
850
+ f"fixpoint_cached_property '{self.attrname}': "
851
+ f"reentry detected with max_fixpoint_iterations=0",
852
+ incomplete_result=self._bottom(),
853
+ )
854
+ # Check the instance cache first, then fall back to saved
855
+ # approximations from the previous iteration.
856
+ approximation = cache.get(self.attrname)
857
+ if approximation is not None:
858
+ return approximation
859
+ saved = context.approximations.get(key)
860
+ if saved is not None:
861
+ return saved
862
+ return self._bottom()
863
+ else:
864
+ # Inside a fixpoint context but this is a fresh (instance, attr)
865
+ # pair — compute normally. Keep the key in ``computing`` only
866
+ # while ``self.func`` runs so that cycles through this key are
867
+ # detected by the ``elif`` branch above. Once computation
868
+ # finishes, remove the key so the fast-path cache check does
869
+ # not misidentify a later read of this cached value as reentry.
870
+ context.computing.add(key)
871
+ context.add_participant(instance)
872
+ result = self.func(instance)
873
+ context.computing.discard(key)
874
+ cache[self.attrname] = result
875
+ return result
876
+
877
+ def __set__(self, instance: object, value: object) -> None:
878
+ """Data descriptor setter to ensure __get__ is always called."""
879
+ instance.__dict__[self.attrname] = value
880
+
881
+
882
+ class _fixpoint_dependent_property:
883
+ """A cached_property that registers its instance as a fixpoint participant.
884
+
885
+ Behaves like ``functools.cached_property`` but, when computed inside an
886
+ active fixpoint context, registers the instance so that
887
+ ``clear_participant_caches`` will clear the cached value between
888
+ iterations. Without this, stale values computed from an incomplete
889
+ fixpoint approximation survive across iterations.
890
+ """
891
+
892
+ def __init__(self, func: Callable) -> None:
893
+ self.func = func
894
+ self.attrname = func.__name__
895
+ self.__doc__ = func.__doc__
896
+ _fixpoint_clearable_attrs.add(self.attrname)
897
+
898
+ def __set_name__(self, owner: type, name: str) -> None:
899
+ if not hasattr(self, "attrname"):
900
+ self.attrname = name
901
+ _fixpoint_clearable_attrs.add(self.attrname)
902
+
903
+ def __get__(self, instance: object, owner: type = None) -> object:
904
+ if instance is None:
905
+ return self
906
+
907
+ cache = instance.__dict__
908
+ value = cache.get(self.attrname)
909
+ if value is not None:
910
+ return value
911
+
912
+ if fixpoint_cached_property.max_fixpoint_iterations.get() > 0:
913
+ # Register as participant so clear_participant_caches can
914
+ # invalidate this cached value between fixpoint iterations.
915
+ context = _fixpoint_context_var.get()
916
+ if context is not None:
917
+ context.add_participant(instance)
918
+
919
+ value = self.func(instance)
920
+ cache[self.attrname] = value
921
+ return value
922
+
923
+
924
+ def fixpoint_dependent(func: Callable) -> _fixpoint_dependent_property:
925
+ """Mark a cached_property as dependent on fixpoint_cached_property values.
926
+
927
+ During fixpoint digest cycles, these caches are cleared between iterations
928
+ so they are recomputed with updated approximations.
929
+
930
+ Usage::
931
+
932
+ @fixpoint_dependent
933
+ @cached_property
934
+ def symbol_kind(self):
935
+ ...
936
+
937
+ Or equivalently::
938
+
939
+ @fixpoint_dependent
940
+ def symbol_kind(self):
941
+ ...
942
+ """
943
+ if isinstance(func, cached_property):
944
+ return _fixpoint_dependent_property(func.func)
945
+ else:
946
+ return _fixpoint_dependent_property(func)
947
+
948
+
606
949
  class HasDict:
607
950
  """
608
951
  Extendable helper class that adds ``__dict__`` slot for classes that need ``@cached_property``.
@@ -892,7 +1235,7 @@ class MixinSymbol(HasDict, Mapping[Hashable, "MixinSymbol"], Symbol):
892
1235
  return tuple(definitions)
893
1236
 
894
1237
  @final
895
- @cached_property
1238
+ @fixpoint_dependent
896
1239
  def normalized_references(self) -> tuple["ResolvedReference", ...]:
897
1240
  """
898
1241
  Flatten all bases from all definitions into a single tuple.
@@ -1051,9 +1394,14 @@ class MixinSymbol(HasDict, Mapping[Hashable, "MixinSymbol"], Symbol):
1051
1394
  # Use Nested to create child symbol with lazy definition resolution
1052
1395
  compiled_symbol = MixinSymbol(origin=Nested(outer=self, key=key))
1053
1396
 
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)
1397
+ # Check if self or any super union of self has this key as an own key.
1398
+ # During fixpoint iteration, qualified_this may not have converged yet,
1399
+ # so skip this validation to avoid false negatives.
1400
+ if _fixpoint_context_var.get() is None:
1401
+ if not self.has_own_key(key) and not any(
1402
+ super_union.has_own_key(key) for super_union in self.qualified_this
1403
+ ):
1404
+ raise KeyError(key)
1057
1405
 
1058
1406
  self._nested[key] = compiled_symbol
1059
1407
  return compiled_symbol
@@ -1072,16 +1420,19 @@ class MixinSymbol(HasDict, Mapping[Hashable, "MixinSymbol"], Symbol):
1072
1420
  case MixinSymbol() as outer_symbol:
1073
1421
  return outer_symbol.depth + 1
1074
1422
 
1075
- @cached_property
1423
+ @fixpoint_dependent
1076
1424
  def is_public(self):
1077
- # Check if any definition is public
1425
+ # Check own definitions first (does not depend on qualified_this)
1426
+ if any(definition.is_public for definition in self.definitions):
1427
+ return True
1428
+ # Check inherited definitions via qualified_this
1078
1429
  return any(
1079
1430
  definition.is_public
1080
1431
  for super_symbol in self.qualified_this
1081
1432
  for definition in super_symbol.definitions
1082
1433
  )
1083
1434
 
1084
- @cached_property
1435
+ @fixpoint_dependent
1085
1436
  def is_eager(self):
1086
1437
  return any(
1087
1438
  definition.is_eager
@@ -1090,7 +1441,7 @@ class MixinSymbol(HasDict, Mapping[Hashable, "MixinSymbol"], Symbol):
1090
1441
  if isinstance(definition, MergerDefinition)
1091
1442
  )
1092
1443
 
1093
- @cached_property
1444
+ @fixpoint_dependent
1094
1445
  def symbol_kind(self) -> "SymbolKind":
1095
1446
  """Classify this symbol into one of three categories.
1096
1447
 
@@ -1176,7 +1527,10 @@ class MixinSymbol(HasDict, Mapping[Hashable, "MixinSymbol"], Symbol):
1176
1527
  case _:
1177
1528
  raise ValueError("Multiple pure merger definitions found")
1178
1529
 
1179
- @cached_property
1530
+ @fixpoint_cached_property(
1531
+ bottom=lambda: defaultdict(set),
1532
+ accumulate=_accumulate_defaultdict_set,
1533
+ )
1180
1534
  def qualified_this(self) -> Mapping["MixinSymbol", Collection[MixinSymbol]]:
1181
1535
  """Map each overlay of ``self`` to the outer scopes that instantiate it.
1182
1536
 
@@ -1220,7 +1574,7 @@ class MixinSymbol(HasDict, Mapping[Hashable, "MixinSymbol"], Symbol):
1220
1574
  if outer_union.has_own_key(key):
1221
1575
  yield outer_union[key]
1222
1576
 
1223
- @cached_property
1577
+ @fixpoint_dependent
1224
1578
  def overlays(self):
1225
1579
  return frozenset(self._generate_overlays())
1226
1580
 
@@ -2475,9 +2829,15 @@ class AbsoluteReference:
2475
2829
  for part in self.path:
2476
2830
  child_symbol = current_symbol.get(part)
2477
2831
  if child_symbol is None:
2832
+ kind_hint = (
2833
+ f" (unexpected kind {current_symbol.symbol_kind.name})"
2834
+ if current_symbol.symbol_kind is not SymbolKind.SCOPE
2835
+ else ""
2836
+ )
2478
2837
  raise ValueError(
2479
2838
  f"Cannot navigate path {self.path!r}: "
2480
- f"'{current_symbol.key}' has no child '{part}'"
2839
+ f"{current_symbol.path}{kind_hint}"
2840
+ f" has no child '{part}'"
2481
2841
  )
2482
2842
  current_symbol = child_symbol
2483
2843
 
@@ -2533,7 +2893,9 @@ class RelativeReference:
2533
2893
  if child_symbol is None:
2534
2894
  raise ValueError(
2535
2895
  f"Cannot navigate path {self.path!r}: "
2536
- f"'{current_symbol.key}' has no child '{part}'"
2896
+ f"{current_symbol.path} (unexpected kind"
2897
+ f" {current_symbol.symbol_kind.name})"
2898
+ f" has no child '{part}'"
2537
2899
  )
2538
2900
  current_symbol = child_symbol
2539
2901
 
@@ -2738,9 +3100,15 @@ class LexicalReference:
2738
3100
  for part in hashable_path:
2739
3101
  child_symbol = current_symbol.get(part)
2740
3102
  if child_symbol is None:
3103
+ kind_hint = (
3104
+ f" (unexpected kind {current_symbol.symbol_kind.name})"
3105
+ if current_symbol.symbol_kind is not SymbolKind.SCOPE
3106
+ else ""
3107
+ )
2741
3108
  raise ValueError(
2742
3109
  f"Cannot navigate path {hashable_path!r}: "
2743
- f"'{current_symbol.key}' has no child '{part}'"
3110
+ f"{current_symbol.path}{kind_hint}"
3111
+ f" has no child '{part}'"
2744
3112
  )
2745
3113
  current_symbol = child_symbol
2746
3114
 
@@ -2828,9 +3196,15 @@ class QualifiedThisReference:
2828
3196
  for part in hashable_path:
2829
3197
  child_symbol = current_symbol.get(part)
2830
3198
  if child_symbol is None:
3199
+ kind_hint = (
3200
+ f" (unexpected kind {current_symbol.symbol_kind.name})"
3201
+ if current_symbol.symbol_kind is not SymbolKind.SCOPE
3202
+ else ""
3203
+ )
2831
3204
  raise ValueError(
2832
3205
  f"Cannot navigate path {hashable_path!r}: "
2833
- f"'{current_symbol.key}' has no child '{part}'"
3206
+ f"{current_symbol.path}{kind_hint}"
3207
+ f" has no child '{part}'"
2834
3208
  )
2835
3209
  current_symbol = child_symbol
2836
3210
 
@@ -30,7 +30,11 @@ 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
+ )
34
38
 
35
39
 
36
40
  class KwargsSentinel(Enum):
@@ -647,6 +651,12 @@ def evaluate(
647
651
  When multiple namespaces are provided, they are union-mounted at the root level.
648
652
  Resources from all namespaces are merged according to the merger election algorithm.
649
653
 
654
+ To control fixpoint iteration, set the class-level ContextVar before calling::
655
+
656
+ from mixinv2._core import fixpoint_cached_property
657
+ fixpoint_cached_property.max_fixpoint_iterations.set(0) # single-pass
658
+ fixpoint_cached_property.max_fixpoint_iterations.set(100) # multi-pass (default)
659
+
650
660
  :param namespaces: Modules or namespace definitions (decorated with @scope) to resolve.
651
661
  :param modules_public: If True, modules are marked as public, making their submodules
652
662
  accessible via attribute access. Defaults to False (private by default).