mixinv2 0.4.0.post82.dev0__tar.gz → 0.4.0.post88.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 (51) hide show
  1. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/.gitignore +2 -2
  2. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/PKG-INFO +4 -2
  3. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/docs/tutorial.rst +6 -3
  4. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/pyproject.toml +6 -2
  5. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/src/mixinv2/_core.py +7 -357
  6. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/src/mixinv2/_runtime.py +3 -2
  7. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/test_evaluation_semantics.py +5 -266
  8. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/README.md +0 -0
  9. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/docs/Makefile +0 -0
  10. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/docs/_static/favicon.svg +0 -0
  11. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/docs/_static/logo.svg +0 -0
  12. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/docs/conf.py +0 -0
  13. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/docs/index.rst +0 -0
  14. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/docs/installation.rst +0 -0
  15. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/docs/mixinv2-tutorial.rst +0 -0
  16. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/docs/specification.md +0 -0
  17. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/src/mixinv2/__init__.py +0 -0
  18. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/src/mixinv2/_config.py +0 -0
  19. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/src/mixinv2/_interned_linked_list.py +0 -0
  20. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/src/mixinv2/_mixin_directory.py +0 -0
  21. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/src/mixinv2/_mixin_parser.py +0 -0
  22. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/LetXEqualsXInX.mixin.yaml +0 -0
  23. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/SelfReferenceTest.mixin.yaml +0 -0
  24. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/__snapshots__/test_multi_module_composition.ambr +0 -0
  25. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/fixtures/CircularLazy.mixin.yaml +0 -0
  26. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/fixtures/MultiModuleComposition.mixin.yaml +0 -0
  27. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/fixtures/OuterVsLexicalOuter.mixin.yaml +0 -0
  28. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/fixtures/__init__.py +0 -0
  29. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/fixtures/nested_pkg/__init__.py +0 -0
  30. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/fixtures/nested_pkg/child/__init__.py +0 -0
  31. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/fixtures/nested_pkg/child/grandchild.py +0 -0
  32. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/fixtures/ns_pkg/mod_a.py +0 -0
  33. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/fixtures/ns_pkg/mod_b.py +0 -0
  34. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/fixtures/regular_mod.py +0 -0
  35. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/fixtures/regular_pkg/__init__.py +0 -0
  36. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/fixtures/regular_pkg/child.py +0 -0
  37. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/fixtures/union_mount/__init__.py +0 -0
  38. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/fixtures/union_mount/branch0.py +0 -0
  39. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/fixtures/union_mount/branch1.py +0 -0
  40. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/fixtures/union_mount/branch2.py +0 -0
  41. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/test_circular_lazy.py +0 -0
  42. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/test_debug.py +0 -0
  43. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/test_get_symbol.py +0 -0
  44. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/test_interned_linked_list.py +0 -0
  45. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/test_mixin_parser.py +0 -0
  46. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/test_mixinject.py +0 -0
  47. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/test_multi_module_composition.py +0 -0
  48. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/test_outer_vs_lexical_outer.py +0 -0
  49. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/test_resource_reference.py +0 -0
  50. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/test_stdlib_python_port.py +0 -0
  51. {mixinv2-0.4.0.post82.dev0 → mixinv2-0.4.0.post88.dev0}/tests/test_v2.py +0 -0
@@ -236,6 +236,6 @@ experiment_results.db
236
236
  # nixago: ignore-linked-files
237
237
  /.vscode/extensions.json
238
238
 
239
- /inheritance-calculus/arxiv-submission.tar.gz
239
+ arxiv-submission.tar.gz
240
240
  node_modules/
241
- inheritance-calculus/comment.cut
241
+ comment.cut
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mixinv2
3
- Version: 0.4.0.post82.dev0
3
+ Version: 0.4.0.post88.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>
@@ -10,10 +10,12 @@ Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Programming Language :: Python :: 3.13
11
11
  Classifier: Programming Language :: Python :: 3.14
12
12
  Requires-Python: >=3.13
13
+ Requires-Dist: fixpoints
13
14
  Requires-Dist: pyyaml>=6.0.3
15
+ Requires-Dist: typing-extensions>=4.0.0
14
16
  Provides-Extra: docs
15
17
  Requires-Dist: sphinx-mdinclude>=0.6.2; extra == 'docs'
16
- Requires-Dist: sphinx>=9.1.0; extra == 'docs'
18
+ Requires-Dist: sphinx<9,>=8.1.0; extra == 'docs'
17
19
  Description-Content-Type: text/markdown
18
20
 
19
21
  # mixinv2
@@ -118,9 +118,12 @@ by ``HttpHandlers.Request``) flows automatically into ``currentUser`` (looked up
118
118
  in the DB by ``UserRepository.Request``) without any glue code.
119
119
 
120
120
  ``responseSent`` is an IO resource: it sends the HTTP response as a side effect and
121
- returns ``None``. The handler body is a single attribute access all logic lives in
122
- the DI graph. In an async framework (e.g. FastAPI), return an ``asyncio.Task[None]``
123
- instead of a coroutine, which cannot be safely awaited in multiple dependents.
121
+ returns ``None``. The ``_Handler`` class is defined at module level and accesses the
122
+ ``Request`` factory through ``self.server.request_scope_factory``; the ``server``
123
+ resource stores it on the ``HTTPServer`` instance. The handler body is a single
124
+ attribute access; all logic lives in the DI graph. In an async framework (e.g.
125
+ FastAPI), return an ``asyncio.Task[None]`` instead of a coroutine, which cannot be
126
+ safely awaited in multiple dependents.
124
127
 
125
128
  .. literalinclude:: ../../mixinv2-examples/src/mixinv2_examples/app_decorator/step4_http_server.py
126
129
  :language: python
@@ -26,10 +26,14 @@ classifiers = [
26
26
  "Programming Language :: Python :: 3.13",
27
27
  "Programming Language :: Python :: 3.14",
28
28
  ]
29
- dependencies = ["pyyaml>=6.0.3"]
29
+ dependencies = [
30
+ "pyyaml>=6.0.3",
31
+ "fixpoints",
32
+ "typing-extensions>=4.0.0",
33
+ ]
30
34
 
31
35
  [project.optional-dependencies]
32
- docs = ["sphinx>=9.1.0", "sphinx-mdinclude>=0.6.2"]
36
+ docs = ["sphinx>=8.1.0,<9", "sphinx-mdinclude>=0.6.2"]
33
37
 
34
38
  [tool.hatch.build.targets.wheel]
35
39
  packages = ["src/mixinv2"]
@@ -530,15 +530,12 @@ 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
534
533
  from dataclasses import dataclass, field, replace
535
534
  from enum import Enum, auto
536
535
  from functools import cached_property
537
536
  import importlib
538
537
  import importlib.util
539
- import math
540
538
  from inspect import Parameter, signature
541
- import itertools
542
539
  import logging
543
540
  import os
544
541
 
@@ -554,7 +551,6 @@ from typing import (
554
551
  Awaitable,
555
552
  Callable,
556
553
  ChainMap,
557
- ClassVar,
558
554
  Collection,
559
555
  ContextManager,
560
556
  Final,
@@ -570,9 +566,8 @@ from typing import (
570
566
  TypeVar,
571
567
  cast,
572
568
  final,
573
- override,
574
- assert_never,
575
569
  )
570
+ from typing_extensions import assert_never, override
576
571
 
577
572
 
578
573
  if TYPE_CHECKING:
@@ -597,6 +592,12 @@ else:
597
592
 
598
593
  import weakref
599
594
 
595
+ from fixpoints._core import FixpointRecursionError
596
+ from fixpoints._core import _accumulate_defaultdict_set
597
+ from fixpoints._core import _fixpoint_context_var
598
+ from fixpoints._core import fixpoint_cached_property
599
+ from fixpoints._core import fixpoint_dependent
600
+
600
601
  T = TypeVar("T")
601
602
  T_co = TypeVar("T_co", covariant=True)
602
603
  TPatch_co = TypeVar("TPatch_co", covariant=True)
@@ -606,357 +607,6 @@ TResult_co = TypeVar("TResult_co", covariant=True)
606
607
  P = ParamSpec("P")
607
608
 
608
609
 
609
- class _FixpointContext:
610
- """Tracks the state of a fixpoint iteration (digest cycle).
611
-
612
- Stored in a ContextVar so that nested/concurrent fixpoint computations
613
- are isolated per-thread/per-coroutine.
614
- """
615
-
616
- __slots__ = ("computing", "reentrant", "participant_ids", "participant_refs",
617
- "_clearable_attr_names", "approximations")
618
-
619
- def __init__(self, clearable_attr_names: frozenset[str]) -> None:
620
- self.computing: set[tuple[int, str]] = set()
621
- self.reentrant: bool = False
622
- self.participant_ids: set[int] = set()
623
- self.participant_refs: list[object] = []
624
- self._clearable_attr_names = clearable_attr_names
625
- self.approximations: dict[tuple[int, str], object] = {}
626
-
627
- def add_participant(self, instance: object) -> None:
628
- instance_id = id(instance)
629
- if instance_id not in self.participant_ids:
630
- self.participant_ids.add(instance_id)
631
- self.participant_refs.append(instance)
632
-
633
- def clear_participant_caches(self) -> None:
634
- """Clear all fixpoint-related cached values on all participants.
635
-
636
- Before clearing, save each value into ``approximations`` so that
637
- intermediate fixpoint_cached_property computations can use their
638
- previous iteration's result as an approximation instead of bottom
639
- when they encounter reentry.
640
- """
641
- for instance in self.participant_refs:
642
- instance_dict = instance.__dict__
643
- instance_id = id(instance)
644
- for attr_name in self._clearable_attr_names:
645
- value = instance_dict.pop(attr_name, None)
646
- if value is not None:
647
- self.approximations[(instance_id, attr_name)] = value
648
-
649
-
650
- _fixpoint_context_var: ContextVar[_FixpointContext | None] = ContextVar(
651
- "_fixpoint_context_var", default=None
652
- )
653
-
654
-
655
- class FixpointRecursionError(RecursionError):
656
- """Raised when fixpoint iteration is exhausted or reentry is detected with no iterations remaining.
657
-
658
- Carries the best approximation computed so far in ``incomplete_result``.
659
- As a ``RecursionError`` subclass, existing code that catches ``RecursionError``
660
- will also catch ``FixpointRecursionError``.
661
- """
662
-
663
- incomplete_result: object
664
-
665
- def __init__(self, message: str, *, incomplete_result: object) -> None:
666
- super().__init__(message)
667
- self.incomplete_result = incomplete_result
668
-
669
-
670
- _FIXPOINT_SENTINEL = object()
671
-
672
- # Registry of attribute names that need clearing during fixpoint digest cycles.
673
- # Populated by fixpoint_cached_property and fixpoint_dependent decorators.
674
- _fixpoint_clearable_attrs: set[str] = set()
675
-
676
-
677
- def _accumulate_defaultdict_set(
678
- accumulator: defaultdict[object, set[object]],
679
- new_result: defaultdict[object, set[object]],
680
- ) -> bool:
681
- """Merge new_result into accumulator (pointwise set union).
682
-
683
- Returns True if accumulator grew (new entries were added).
684
- """
685
- changed = False
686
- for key, values in new_result.items():
687
- existing = accumulator[key]
688
- old_size = len(existing)
689
- existing.update(values)
690
- if len(existing) > old_size:
691
- changed = True
692
- return changed
693
-
694
-
695
- class FixpointIterationSentinel(Enum):
696
- UNLIMITED = math.inf
697
-
698
-
699
- class fixpoint_cached_property:
700
- """A cached_property that supports mutual-recursion via least fixpoint iteration.
701
-
702
- API-compatible with functools.cached_property. When reentry is detected
703
- (mutual recursion), returns the previous iteration's approximation
704
- (or ``bottom()`` on the first iteration). The outermost caller drives
705
- a digest loop until values stabilize (no reentry occurs in a round).
706
-
707
- Usage::
708
-
709
- @fixpoint_cached_property(bottom=lambda: defaultdict(set))
710
- def qualified_this(self):
711
- ...
712
-
713
- The class-level ``max_fixpoint_iterations`` ContextVar controls the
714
- maximum number of digest rounds. ``0`` disables fixpoint iteration
715
- and raises ``FixpointRecursionError`` on reentry. Default
716
- ``FixpointIterationSentinel.UNLIMITED`` iterates until convergence or
717
- until Python's stack is exhausted::
718
-
719
- fixpoint_cached_property.max_fixpoint_iterations.set(0) # single-pass
720
- fixpoint_cached_property.max_fixpoint_iterations.set(100) # bounded multi-pass
721
- fixpoint_cached_property.max_fixpoint_iterations.set(FixpointIterationSentinel.UNLIMITED) # unbounded (default)
722
- """
723
-
724
- max_fixpoint_iterations: ClassVar[ContextVar[int | FixpointIterationSentinel]] = ContextVar(
725
- "fixpoint_cached_property.max_fixpoint_iterations", default=FixpointIterationSentinel.UNLIMITED
726
- )
727
-
728
- def __init__(
729
- self,
730
- func: Callable = None,
731
- *,
732
- bottom: Callable[[], object],
733
- accumulate: Callable[[object, object], bool] | None = None,
734
- ) -> None:
735
- # Support both @fixpoint_cached_property(bottom=...) and direct call
736
- self._bottom = bottom
737
- self._accumulate = accumulate
738
- if func is not None:
739
- self.func: Callable = func
740
- self.attrname: str = func.__name__
741
- self.__doc__ = func.__doc__
742
- _fixpoint_clearable_attrs.add(self.attrname)
743
-
744
- def __call__(self, func: Callable) -> "fixpoint_cached_property":
745
- """Support @fixpoint_cached_property(bottom=...) decorator syntax."""
746
- self.func = func
747
- self.attrname = func.__name__
748
- self.__doc__ = func.__doc__
749
- _fixpoint_clearable_attrs.add(self.attrname)
750
- return self
751
-
752
- def __set_name__(self, owner: type, name: str) -> None:
753
- if not hasattr(self, "attrname"):
754
- self.attrname = name
755
- _fixpoint_clearable_attrs.add(self.attrname)
756
-
757
- @classmethod
758
- def _get_max_iterations(cls) -> int | float:
759
- raw = cls.max_fixpoint_iterations.get()
760
- if isinstance(raw, FixpointIterationSentinel):
761
- return raw.value
762
- return raw
763
-
764
- def __get__(self, instance: object, owner: type = None) -> object:
765
- if instance is None:
766
- return self
767
-
768
- # Fast path: already cached
769
- cache = instance.__dict__
770
- value = cache.get(self.attrname, _FIXPOINT_SENTINEL)
771
- if value is not _FIXPOINT_SENTINEL:
772
- max_iterations = self._get_max_iterations()
773
- if max_iterations == 0:
774
- return value
775
- # Detect reentry: if this key is currently being computed
776
- # (on the call stack), accessing its cached approximation
777
- # means the fixpoint has not converged yet.
778
- context = _fixpoint_context_var.get()
779
- if context is not None:
780
- key = (id(instance), self.attrname)
781
- if key in context.computing:
782
- context.reentrant = True
783
- context.add_participant(instance)
784
- return value
785
-
786
- max_iterations = self._get_max_iterations()
787
- context = _fixpoint_context_var.get()
788
- instance_id = id(instance)
789
- key = (instance_id, self.attrname)
790
-
791
- if context is None:
792
- # I am the driver — start a digest loop (or single-pass for max_iterations=0)
793
- context = _FixpointContext(
794
- clearable_attr_names=frozenset(_fixpoint_clearable_attrs)
795
- )
796
- token = _fixpoint_context_var.set(context)
797
- try:
798
- if max_iterations == 0:
799
- # Zero-iteration mode: compute once with reentry detection.
800
- # Reentry raises FixpointRecursionError instead of infinite recursion.
801
- context.computing.add(key)
802
- result = self.func(instance)
803
- cache[self.attrname] = result
804
- return result
805
-
806
- approximation = self._bottom()
807
- accumulator = self._bottom() if self._accumulate is not None else None
808
- previous_result = _FIXPOINT_SENTINEL
809
- for iteration in itertools.count():
810
- context.computing.add(key)
811
- context.add_participant(instance)
812
- result = self.func(instance)
813
-
814
- if not context.reentrant:
815
- # No reentry this round — fixpoint reached
816
- cache[self.attrname] = result
817
- return result
818
-
819
- if self._accumulate is not None:
820
- # Monotonic accumulation: merge each iteration's
821
- # result into an accumulator that only grows.
822
- # This prevents oscillation when intermediate
823
- # computations encounter cycles in varying order.
824
- changed = self._accumulate(accumulator, result)
825
- if not changed and iteration > 0:
826
- cache[self.attrname] = accumulator
827
- return accumulator
828
- # Use the accumulator as next round's approximation
829
- approximation = accumulator
830
- else:
831
- # Exact equality convergence (original behavior)
832
- if result == previous_result:
833
- cache[self.attrname] = result
834
- return result
835
- previous_result = result
836
- approximation = result
837
-
838
- # Cache current approximation, clear all intermediate
839
- # caches, and re-run
840
- cache[self.attrname] = approximation
841
- context.clear_participant_caches()
842
- # Restore driver's own approximation
843
- cache[self.attrname] = approximation
844
- context.computing.clear()
845
- context.reentrant = False
846
-
847
- if iteration + 1 >= max_iterations:
848
- raise FixpointRecursionError(
849
- f"fixpoint_cached_property '{self.attrname}' did not converge "
850
- f"after {max_iterations} iterations",
851
- incomplete_result=approximation,
852
- )
853
- finally:
854
- _fixpoint_context_var.reset(token)
855
- elif key in context.computing:
856
- # Reentry detected — return previous approximation or bottom.
857
- context.reentrant = True
858
- context.add_participant(instance)
859
- if max_iterations == 0:
860
- raise FixpointRecursionError(
861
- f"fixpoint_cached_property '{self.attrname}': "
862
- f"reentry detected with max_fixpoint_iterations=0",
863
- incomplete_result=self._bottom(),
864
- )
865
- # Check the instance cache first, then fall back to saved
866
- # approximations from the previous iteration.
867
- approximation = cache.get(self.attrname, _FIXPOINT_SENTINEL)
868
- if approximation is not _FIXPOINT_SENTINEL:
869
- return approximation
870
- saved = context.approximations.get(key, _FIXPOINT_SENTINEL)
871
- if saved is not _FIXPOINT_SENTINEL:
872
- return saved
873
- return self._bottom()
874
- else:
875
- # Inside a fixpoint context but this is a fresh (instance, attr)
876
- # pair — compute normally. Keep the key in ``computing`` only
877
- # while ``self.func`` runs so that cycles through this key are
878
- # detected by the ``elif`` branch above. Once computation
879
- # finishes, remove the key so the fast-path cache check does
880
- # not misidentify a later read of this cached value as reentry.
881
- context.computing.add(key)
882
- context.add_participant(instance)
883
- result = self.func(instance)
884
- context.computing.discard(key)
885
- cache[self.attrname] = result
886
- return result
887
-
888
- def __set__(self, instance: object, value: object) -> None:
889
- """Data descriptor setter to ensure __get__ is always called."""
890
- instance.__dict__[self.attrname] = value
891
-
892
-
893
- class _fixpoint_dependent_property:
894
- """A cached_property that registers its instance as a fixpoint participant.
895
-
896
- Behaves like ``functools.cached_property`` but, when computed inside an
897
- active fixpoint context, registers the instance so that
898
- ``clear_participant_caches`` will clear the cached value between
899
- iterations. Without this, stale values computed from an incomplete
900
- fixpoint approximation survive across iterations.
901
- """
902
-
903
- def __init__(self, func: Callable) -> None:
904
- self.func = func
905
- self.attrname = func.__name__
906
- self.__doc__ = func.__doc__
907
- _fixpoint_clearable_attrs.add(self.attrname)
908
-
909
- def __set_name__(self, owner: type, name: str) -> None:
910
- if not hasattr(self, "attrname"):
911
- self.attrname = name
912
- _fixpoint_clearable_attrs.add(self.attrname)
913
-
914
- def __get__(self, instance: object, owner: type = None) -> object:
915
- if instance is None:
916
- return self
917
-
918
- cache = instance.__dict__
919
- value = cache.get(self.attrname)
920
- if value is not None:
921
- return value
922
-
923
- if fixpoint_cached_property._get_max_iterations() > 0:
924
- # Register as participant so clear_participant_caches can
925
- # invalidate this cached value between fixpoint iterations.
926
- context = _fixpoint_context_var.get()
927
- if context is not None:
928
- context.add_participant(instance)
929
-
930
- value = self.func(instance)
931
- cache[self.attrname] = value
932
- return value
933
-
934
-
935
- def fixpoint_dependent(func: Callable) -> _fixpoint_dependent_property:
936
- """Mark a cached_property as dependent on fixpoint_cached_property values.
937
-
938
- During fixpoint digest cycles, these caches are cleared between iterations
939
- so they are recomputed with updated approximations.
940
-
941
- Usage::
942
-
943
- @fixpoint_dependent
944
- @cached_property
945
- def symbol_kind(self):
946
- ...
947
-
948
- Or equivalently::
949
-
950
- @fixpoint_dependent
951
- def symbol_kind(self):
952
- ...
953
- """
954
- if isinstance(func, cached_property):
955
- return _fixpoint_dependent_property(func.func)
956
- else:
957
- return _fixpoint_dependent_property(func)
958
-
959
-
960
610
  class HasDict:
961
611
  """
962
612
  Extendable helper class that adds ``__dict__`` slot for classes that need ``@cached_property``.
@@ -653,7 +653,7 @@ def evaluate(
653
653
 
654
654
  To control fixpoint iteration, set the class-level ContextVar before calling::
655
655
 
656
- from mixinv2._core import fixpoint_cached_property
656
+ from fixpoints._core import fixpoint_cached_property
657
657
  fixpoint_cached_property.max_fixpoint_iterations.set(0) # single-pass
658
658
  fixpoint_cached_property.max_fixpoint_iterations.set(100) # bounded multi-pass
659
659
  fixpoint_cached_property.max_fixpoint_iterations.set(FixpointIterationSentinel.UNLIMITED) # unbounded (default)
@@ -672,7 +672,8 @@ def evaluate(
672
672
  """
673
673
  from dataclasses import replace
674
674
  from types import ModuleType
675
- from typing import assert_never
675
+
676
+ from typing_extensions import assert_never
676
677
 
677
678
  from mixinv2._core import (
678
679
  MixinSymbol,
@@ -1,10 +1,13 @@
1
1
  """Tests for fixpoint_cached_property.max_fixpoint_iterations and FixpointRecursionError exception behavior."""
2
2
 
3
- from collections import defaultdict
4
3
  from typing import Callable
5
4
 
6
5
  import pytest
7
6
 
7
+ from fixpoints._core import (
8
+ FixpointIterationSentinel,
9
+ fixpoint_cached_property,
10
+ )
8
11
  from mixinv2 import (
9
12
  FixpointRecursionError,
10
13
  LexicalReference,
@@ -14,12 +17,7 @@ from mixinv2 import (
14
17
  resource,
15
18
  scope,
16
19
  )
17
- from mixinv2._core import (
18
- FixpointIterationSentinel,
19
- MixinSymbol,
20
- _accumulate_defaultdict_set,
21
- fixpoint_cached_property,
22
- )
20
+ from mixinv2._core import MixinSymbol
23
21
  from mixinv2._runtime import (
24
22
  Scope,
25
23
  evaluate,
@@ -264,265 +262,6 @@ class TestZeroIterationSpecific:
264
262
  fixpoint_cached_property.max_fixpoint_iterations.reset(token)
265
263
 
266
264
 
267
- class TestDivergentConvergenceBehavior:
268
- """Tests showing different convergence behavior with different max_fixpoint_iterations.
269
-
270
- The inheritance-calculus paper (Section 7) defines a translation T from
271
- the lazy λ-calculus to mixin trees. The mixin-tree equations for the
272
- ``this`` function (qualified-this resolution) form a monotone system
273
- whose least fixpoint is computed iteratively when max_fixpoint_iterations > 0.
274
-
275
- With max_fixpoint_iterations=0, cyclic dependencies in the ``this``
276
- function raise ``FixpointRecursionError`` because reentry is detected with no iterations
277
- remaining to converge.
278
-
279
- The cycle pattern arises from self-referential λ-terms such as the
280
- self-application combinator Ω = (λx. x x)(λx. x x). The T
281
- translation maps Ω to a mixin tree where the ``tailCall`` scope
282
- inherits from ``↑1.argument`` (the enclosing lambda's argument slot).
283
- After composition, this creates a cycle in the ``this`` function:
284
- computing ``this(p, p_def)`` for one scope requires ``this`` for
285
- another scope, which in turn requires the first.
286
-
287
- The tests below use ``fixpoint_cached_property`` directly — the same
288
- mechanism that implements ``qualified_this`` in the MixinSymbol —
289
- to demonstrate the divergence/convergence difference.
290
- """
291
-
292
- def _make_transitive_closure_nodes(
293
- self,
294
- initial_a: dict[str, set[int]],
295
- initial_b: dict[str, set[int]],
296
- ) -> tuple[object, object]:
297
- """Create two nodes with mutually recursive transitive closure.
298
-
299
- Each node's ``reachable`` property is the union of its own values
300
- and everything reachable from the other node. This is analogous
301
- to the ``this(p, p_def)`` function: ``this(p) = own(p) ∪
302
- ⋃{this(q) | q ∈ supers(p)}``, which forms a monotone system
303
- over set-valued lattices.
304
-
305
- The mutual dependence mirrors the cycle that arises in
306
- ``qualified_this`` when a scope's overrides depend on the
307
- qualified-this of another scope, which in turn depends on the
308
- first scope's overrides.
309
- """
310
-
311
- class TransitiveClosureNode:
312
- def __init__(self, initial_values: dict[str, set[int]]) -> None:
313
- self.__dict__["_initial_values"] = initial_values
314
- self.__dict__["_other"] = None
315
-
316
- def set_other(self, other: "TransitiveClosureNode") -> None:
317
- self.__dict__["_other"] = other
318
-
319
- @fixpoint_cached_property(
320
- bottom=lambda: defaultdict(set),
321
- accumulate=_accumulate_defaultdict_set,
322
- )
323
- def reachable(self) -> defaultdict[str, set[int]]:
324
- result: defaultdict[str, set[int]] = defaultdict(set)
325
- for key, values in self._initial_values.items():
326
- result[key].update(values)
327
- if self._other is not None:
328
- for key, values in self._other.reachable.items():
329
- result[key].update(values)
330
- return result
331
-
332
- node_a = TransitiveClosureNode(initial_a)
333
- node_b = TransitiveClosureNode(initial_b)
334
- node_a.set_other(node_b)
335
- node_b.set_other(node_a)
336
- return node_a, node_b
337
-
338
- def test_fixpoint_converges_on_mutual_recursion(self) -> None:
339
- """max_fixpoint_iterations=100 resolves mutual recursion via iterative approximation.
340
-
341
- Analogous to Datalog transitive closure or the ``this`` fixpoint:
342
- the computation starts with ⊥ (empty set), and each iteration
343
- discovers more reachable elements until convergence.
344
- """
345
- token = fixpoint_cached_property.max_fixpoint_iterations.set(100)
346
- try:
347
- node_a, node_b = self._make_transitive_closure_nodes(
348
- initial_a={"x": {1, 2}},
349
- initial_b={"y": {3, 4}},
350
- )
351
- reachable_a = dict(node_a.reachable)
352
- reachable_b = dict(node_b.reachable)
353
- finally:
354
- fixpoint_cached_property.max_fixpoint_iterations.reset(token)
355
-
356
- # Both nodes discover each other's values through fixpoint iteration
357
- assert reachable_a["x"] == {1, 2}
358
- assert reachable_a["y"] == {3, 4}
359
- assert reachable_b["x"] == {1, 2}
360
- assert reachable_b["y"] == {3, 4}
361
-
362
- def test_zero_iterations_raises_bottom_on_mutual_recursion(self) -> None:
363
- """max_fixpoint_iterations=0 raises FixpointRecursionError on mutual recursion.
364
-
365
- With no fixpoint iterations allowed, the mutual dependency between
366
- A and B triggers reentry detection. Unlike the old
367
- INDEXED_HYLOMORPHISM (which had no reentry detection and caused
368
- Python's natural stack overflow), max_fixpoint_iterations=0 detects
369
- the reentry immediately and raises FixpointRecursionError with the incomplete result.
370
- """
371
- token = fixpoint_cached_property.max_fixpoint_iterations.set(0)
372
- try:
373
- node_a, _node_b = self._make_transitive_closure_nodes(
374
- initial_a={"x": {1, 2}},
375
- initial_b={"y": {3, 4}},
376
- )
377
- with pytest.raises(FixpointRecursionError) as exception_info:
378
- node_a.reachable
379
- assert isinstance(exception_info.value.incomplete_result, defaultdict)
380
- finally:
381
- fixpoint_cached_property.max_fixpoint_iterations.reset(token)
382
-
383
- def test_fixpoint_converges_three_node_cycle(self) -> None:
384
- """max_fixpoint_iterations=100 handles N-way cycles (A→B→C→A), not just 2-cycles.
385
-
386
- This mirrors the 3-cycle in RelationalCycle.mixin.yaml (a→b→c→a),
387
- where the transitive closure requires multiple fixpoint iterations
388
- to discover all reachable pairs.
389
- """
390
-
391
- class TriCycleNode:
392
- def __init__(self, initial_values: dict[str, set[int]]) -> None:
393
- self.__dict__["_initial_values"] = initial_values
394
- self.__dict__["_next"] = None
395
-
396
- def set_next(self, other: "TriCycleNode") -> None:
397
- self.__dict__["_next"] = other
398
-
399
- @fixpoint_cached_property(
400
- bottom=lambda: defaultdict(set),
401
- accumulate=_accumulate_defaultdict_set,
402
- )
403
- def reachable(self) -> defaultdict[str, set[int]]:
404
- result: defaultdict[str, set[int]] = defaultdict(set)
405
- for key, values in self._initial_values.items():
406
- result[key].update(values)
407
- if self._next is not None:
408
- for key, values in self._next.reachable.items():
409
- result[key].update(values)
410
- return result
411
-
412
- token = fixpoint_cached_property.max_fixpoint_iterations.set(100)
413
- try:
414
- node_a = TriCycleNode({"a": {1}})
415
- node_b = TriCycleNode({"b": {2}})
416
- node_c = TriCycleNode({"c": {3}})
417
- node_a.set_next(node_b)
418
- node_b.set_next(node_c)
419
- node_c.set_next(node_a)
420
-
421
- reachable_a = dict(node_a.reachable)
422
- finally:
423
- fixpoint_cached_property.max_fixpoint_iterations.reset(token)
424
-
425
- # All three values discovered through the cycle
426
- assert reachable_a["a"] == {1}
427
- assert reachable_a["b"] == {2}
428
- assert reachable_a["c"] == {3}
429
-
430
-
431
- class TestUnlimitedIterationsOmega:
432
- """Tests that UNLIMITED iterations causes RecursionError (not FixpointRecursionError) for divergent computations."""
433
-
434
- def test_omega_raises_recursion_error_not_bottom(self) -> None:
435
- """With UNLIMITED, a divergent fixpoint hits Python's native RecursionError.
436
-
437
- This simulates the Omega combinator: a computation that never converges.
438
- With a finite limit, the fixpoint loop would raise FixpointRecursionError after exhausting
439
- iterations. With UNLIMITED, the itertools.count() loop runs indefinitely,
440
- and eventually Python's recursion limit is hit within a single iteration's
441
- computation, raising a native RecursionError (not FixpointRecursionError).
442
- """
443
- iteration_count = 0
444
-
445
- class OmegaNode:
446
- def __init__(self) -> None:
447
- self.__dict__["_other"] = None
448
-
449
- def set_other(self, other: "OmegaNode") -> None:
450
- self.__dict__["_other"] = other
451
-
452
- @fixpoint_cached_property(bottom=lambda: 0)
453
- def divergent(self) -> int:
454
- nonlocal iteration_count
455
- iteration_count += 1
456
- if iteration_count > 200:
457
- raise RecursionError("simulated stack overflow after 200 iterations")
458
- # Return alternating values so it never converges
459
- return self._other.divergent + 1
460
-
461
- node_a = OmegaNode()
462
- node_b = OmegaNode()
463
- node_a.set_other(node_b)
464
- node_b.set_other(node_a)
465
-
466
- with pytest.raises(RecursionError) as exception_info:
467
- node_a.divergent
468
- # The error should be a native RecursionError, NOT a FixpointRecursionError
469
- assert not isinstance(exception_info.value, FixpointRecursionError)
470
- # Verify we actually ran past the old default of 100
471
- assert iteration_count > 100
472
-
473
-
474
- class TestFixpointRecursionErrorException:
475
- """Tests for the FixpointRecursionError exception class."""
476
-
477
- def test_bottom_is_recursion_error_subclass(self) -> None:
478
- assert issubclass(FixpointRecursionError, RecursionError)
479
-
480
- def test_negative_max_fixpoint_iterations_raises_bottom(self) -> None:
481
- """Negative max_fixpoint_iterations is meaningless; ContextVar accepts any int."""
482
- # ContextVar accepts any int value, but negative values are nonsensical.
483
- # The fixpoint loop uses range(max_iterations), so negative values
484
- # produce zero iterations and raise FixpointRecursionError on reentry (same as 0).
485
- pass
486
-
487
- def test_bottom_carries_incomplete_result(self) -> None:
488
- """max_fixpoint_iterations=1 on a system needing 2+ iterations raises FixpointRecursionError with partial result."""
489
- token = fixpoint_cached_property.max_fixpoint_iterations.set(1)
490
- try:
491
-
492
- class MutualNode:
493
- def __init__(self, initial_values: dict[str, set[int]]) -> None:
494
- self.__dict__["_initial_values"] = initial_values
495
- self.__dict__["_other"] = None
496
-
497
- def set_other(self, other: "MutualNode") -> None:
498
- self.__dict__["_other"] = other
499
-
500
- @fixpoint_cached_property(
501
- bottom=lambda: defaultdict(set),
502
- accumulate=_accumulate_defaultdict_set,
503
- )
504
- def reachable(self) -> defaultdict[str, set[int]]:
505
- result: defaultdict[str, set[int]] = defaultdict(set)
506
- for key, values in self._initial_values.items():
507
- result[key].update(values)
508
- if self._other is not None:
509
- for key, values in self._other.reachable.items():
510
- result[key].update(values)
511
- return result
512
-
513
- node_a = MutualNode({"x": {1}})
514
- node_b = MutualNode({"y": {2}})
515
- node_a.set_other(node_b)
516
- node_b.set_other(node_a)
517
-
518
- with pytest.raises(FixpointRecursionError) as exception_info:
519
- node_a.reachable
520
- # The incomplete result should be a defaultdict(set) with partial data
521
- assert isinstance(exception_info.value.incomplete_result, defaultdict)
522
- finally:
523
- fixpoint_cached_property.max_fixpoint_iterations.reset(token)
524
-
525
-
526
265
  class TestMixinYamlFixpointIteration:
527
266
  """Tests proving max_fixpoint_iterations affects .mixin.yaml evaluation.
528
267