tnfr 4.5.1__py3-none-any.whl → 6.0.0__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.
Files changed (170) hide show
  1. tnfr/__init__.py +270 -90
  2. tnfr/__init__.pyi +40 -0
  3. tnfr/_compat.py +11 -0
  4. tnfr/_version.py +7 -0
  5. tnfr/_version.pyi +7 -0
  6. tnfr/alias.py +631 -0
  7. tnfr/alias.pyi +140 -0
  8. tnfr/cache.py +732 -0
  9. tnfr/cache.pyi +232 -0
  10. tnfr/callback_utils.py +381 -0
  11. tnfr/callback_utils.pyi +105 -0
  12. tnfr/cli/__init__.py +89 -0
  13. tnfr/cli/__init__.pyi +47 -0
  14. tnfr/cli/arguments.py +199 -0
  15. tnfr/cli/arguments.pyi +33 -0
  16. tnfr/cli/execution.py +322 -0
  17. tnfr/cli/execution.pyi +80 -0
  18. tnfr/cli/utils.py +34 -0
  19. tnfr/cli/utils.pyi +8 -0
  20. tnfr/config/__init__.py +12 -0
  21. tnfr/config/__init__.pyi +8 -0
  22. tnfr/config/constants.py +104 -0
  23. tnfr/config/constants.pyi +12 -0
  24. tnfr/config/init.py +36 -0
  25. tnfr/config/init.pyi +8 -0
  26. tnfr/config/operator_names.py +106 -0
  27. tnfr/config/operator_names.pyi +28 -0
  28. tnfr/config/presets.py +104 -0
  29. tnfr/config/presets.pyi +7 -0
  30. tnfr/constants/__init__.py +228 -0
  31. tnfr/constants/__init__.pyi +104 -0
  32. tnfr/constants/core.py +158 -0
  33. tnfr/constants/core.pyi +17 -0
  34. tnfr/constants/init.py +31 -0
  35. tnfr/constants/init.pyi +12 -0
  36. tnfr/constants/metric.py +102 -0
  37. tnfr/constants/metric.pyi +19 -0
  38. tnfr/constants_glyphs.py +16 -0
  39. tnfr/constants_glyphs.pyi +12 -0
  40. tnfr/dynamics/__init__.py +136 -0
  41. tnfr/dynamics/__init__.pyi +83 -0
  42. tnfr/dynamics/adaptation.py +201 -0
  43. tnfr/dynamics/aliases.py +22 -0
  44. tnfr/dynamics/coordination.py +343 -0
  45. tnfr/dynamics/dnfr.py +2315 -0
  46. tnfr/dynamics/dnfr.pyi +33 -0
  47. tnfr/dynamics/integrators.py +561 -0
  48. tnfr/dynamics/integrators.pyi +35 -0
  49. tnfr/dynamics/runtime.py +521 -0
  50. tnfr/dynamics/sampling.py +34 -0
  51. tnfr/dynamics/sampling.pyi +7 -0
  52. tnfr/dynamics/selectors.py +680 -0
  53. tnfr/execution.py +216 -0
  54. tnfr/execution.pyi +65 -0
  55. tnfr/flatten.py +283 -0
  56. tnfr/flatten.pyi +28 -0
  57. tnfr/gamma.py +320 -89
  58. tnfr/gamma.pyi +40 -0
  59. tnfr/glyph_history.py +337 -0
  60. tnfr/glyph_history.pyi +53 -0
  61. tnfr/grammar.py +23 -153
  62. tnfr/grammar.pyi +13 -0
  63. tnfr/helpers/__init__.py +151 -0
  64. tnfr/helpers/__init__.pyi +66 -0
  65. tnfr/helpers/numeric.py +88 -0
  66. tnfr/helpers/numeric.pyi +12 -0
  67. tnfr/immutable.py +214 -0
  68. tnfr/immutable.pyi +37 -0
  69. tnfr/initialization.py +199 -0
  70. tnfr/initialization.pyi +73 -0
  71. tnfr/io.py +311 -0
  72. tnfr/io.pyi +11 -0
  73. tnfr/locking.py +37 -0
  74. tnfr/locking.pyi +7 -0
  75. tnfr/metrics/__init__.py +41 -0
  76. tnfr/metrics/__init__.pyi +20 -0
  77. tnfr/metrics/coherence.py +1469 -0
  78. tnfr/metrics/common.py +149 -0
  79. tnfr/metrics/common.pyi +15 -0
  80. tnfr/metrics/core.py +259 -0
  81. tnfr/metrics/core.pyi +13 -0
  82. tnfr/metrics/diagnosis.py +840 -0
  83. tnfr/metrics/diagnosis.pyi +89 -0
  84. tnfr/metrics/export.py +151 -0
  85. tnfr/metrics/glyph_timing.py +369 -0
  86. tnfr/metrics/reporting.py +152 -0
  87. tnfr/metrics/reporting.pyi +12 -0
  88. tnfr/metrics/sense_index.py +294 -0
  89. tnfr/metrics/sense_index.pyi +9 -0
  90. tnfr/metrics/trig.py +216 -0
  91. tnfr/metrics/trig.pyi +12 -0
  92. tnfr/metrics/trig_cache.py +105 -0
  93. tnfr/metrics/trig_cache.pyi +10 -0
  94. tnfr/node.py +255 -177
  95. tnfr/node.pyi +161 -0
  96. tnfr/observers.py +154 -150
  97. tnfr/observers.pyi +46 -0
  98. tnfr/ontosim.py +135 -134
  99. tnfr/ontosim.pyi +33 -0
  100. tnfr/operators/__init__.py +452 -0
  101. tnfr/operators/__init__.pyi +31 -0
  102. tnfr/operators/definitions.py +181 -0
  103. tnfr/operators/definitions.pyi +92 -0
  104. tnfr/operators/jitter.py +266 -0
  105. tnfr/operators/jitter.pyi +11 -0
  106. tnfr/operators/registry.py +80 -0
  107. tnfr/operators/registry.pyi +15 -0
  108. tnfr/operators/remesh.py +569 -0
  109. tnfr/presets.py +10 -23
  110. tnfr/presets.pyi +7 -0
  111. tnfr/py.typed +0 -0
  112. tnfr/rng.py +440 -0
  113. tnfr/rng.pyi +14 -0
  114. tnfr/selector.py +217 -0
  115. tnfr/selector.pyi +19 -0
  116. tnfr/sense.py +307 -142
  117. tnfr/sense.pyi +30 -0
  118. tnfr/structural.py +69 -164
  119. tnfr/structural.pyi +46 -0
  120. tnfr/telemetry/__init__.py +13 -0
  121. tnfr/telemetry/verbosity.py +37 -0
  122. tnfr/tokens.py +61 -0
  123. tnfr/tokens.pyi +41 -0
  124. tnfr/trace.py +520 -95
  125. tnfr/trace.pyi +68 -0
  126. tnfr/types.py +382 -17
  127. tnfr/types.pyi +145 -0
  128. tnfr/utils/__init__.py +158 -0
  129. tnfr/utils/__init__.pyi +133 -0
  130. tnfr/utils/cache.py +755 -0
  131. tnfr/utils/cache.pyi +156 -0
  132. tnfr/utils/data.py +267 -0
  133. tnfr/utils/data.pyi +73 -0
  134. tnfr/utils/graph.py +87 -0
  135. tnfr/utils/graph.pyi +10 -0
  136. tnfr/utils/init.py +746 -0
  137. tnfr/utils/init.pyi +85 -0
  138. tnfr/utils/io.py +157 -0
  139. tnfr/utils/io.pyi +10 -0
  140. tnfr/utils/validators.py +130 -0
  141. tnfr/utils/validators.pyi +19 -0
  142. tnfr/validation/__init__.py +25 -0
  143. tnfr/validation/__init__.pyi +17 -0
  144. tnfr/validation/compatibility.py +59 -0
  145. tnfr/validation/compatibility.pyi +8 -0
  146. tnfr/validation/grammar.py +149 -0
  147. tnfr/validation/grammar.pyi +11 -0
  148. tnfr/validation/rules.py +194 -0
  149. tnfr/validation/rules.pyi +18 -0
  150. tnfr/validation/syntax.py +151 -0
  151. tnfr/validation/syntax.pyi +7 -0
  152. tnfr-6.0.0.dist-info/METADATA +135 -0
  153. tnfr-6.0.0.dist-info/RECORD +157 -0
  154. tnfr/cli.py +0 -322
  155. tnfr/config.py +0 -41
  156. tnfr/constants.py +0 -277
  157. tnfr/dynamics.py +0 -814
  158. tnfr/helpers.py +0 -264
  159. tnfr/main.py +0 -47
  160. tnfr/metrics.py +0 -597
  161. tnfr/operators.py +0 -525
  162. tnfr/program.py +0 -176
  163. tnfr/scenarios.py +0 -34
  164. tnfr/validators.py +0 -38
  165. tnfr-4.5.1.dist-info/METADATA +0 -221
  166. tnfr-4.5.1.dist-info/RECORD +0 -28
  167. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/WHEEL +0 -0
  168. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/entry_points.txt +0 -0
  169. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/licenses/LICENSE.md +0 -0
  170. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,151 @@
1
+ """Curated high-level helpers exposed by :mod:`tnfr.helpers`.
2
+
3
+ The module is intentionally small and surfaces utilities that are stable for
4
+ external use, covering data preparation, glyph history management, and graph
5
+ cache invalidation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import Counter
11
+ from typing import TYPE_CHECKING, Any, Callable, Mapping, MutableMapping, Protocol, cast
12
+
13
+ from ..types import TNFRGraph
14
+
15
+ if TYPE_CHECKING: # pragma: no cover - import-time only for typing
16
+ from ..utils import (
17
+ CacheManager,
18
+ EdgeCacheManager,
19
+ cached_node_list,
20
+ cached_nodes_and_A,
21
+ edge_version_cache,
22
+ edge_version_update,
23
+ ensure_node_index_map,
24
+ ensure_node_offset_map,
25
+ get_graph,
26
+ get_graph_mapping,
27
+ increment_edge_version,
28
+ mark_dnfr_prep_dirty,
29
+ node_set_checksum,
30
+ stable_json,
31
+ )
32
+ from ..glyph_history import HistoryDict
33
+ from .numeric import (
34
+ angle_diff,
35
+ clamp,
36
+ clamp01,
37
+ kahan_sum_nd,
38
+ )
39
+
40
+ __all__ = (
41
+ "CacheManager",
42
+ "EdgeCacheManager",
43
+ "angle_diff",
44
+ "cached_node_list",
45
+ "cached_nodes_and_A",
46
+ "clamp",
47
+ "clamp01",
48
+ "edge_version_cache",
49
+ "edge_version_update",
50
+ "ensure_node_index_map",
51
+ "ensure_node_offset_map",
52
+ "get_graph",
53
+ "get_graph_mapping",
54
+ "increment_edge_version",
55
+ "kahan_sum_nd",
56
+ "mark_dnfr_prep_dirty",
57
+ "node_set_checksum",
58
+ "stable_json",
59
+ "count_glyphs",
60
+ "ensure_history",
61
+ "last_glyph",
62
+ "push_glyph",
63
+ "recent_glyph",
64
+ "__getattr__",
65
+ )
66
+
67
+
68
+ _UTIL_EXPORTS = {
69
+ "CacheManager",
70
+ "EdgeCacheManager",
71
+ "cached_node_list",
72
+ "cached_nodes_and_A",
73
+ "edge_version_cache",
74
+ "edge_version_update",
75
+ "ensure_node_index_map",
76
+ "ensure_node_offset_map",
77
+ "get_graph",
78
+ "get_graph_mapping",
79
+ "increment_edge_version",
80
+ "mark_dnfr_prep_dirty",
81
+ "node_set_checksum",
82
+ "stable_json",
83
+ }
84
+
85
+
86
+ def __getattr__(name: str) -> Any: # pragma: no cover - simple delegation
87
+ if name in _UTIL_EXPORTS:
88
+ from .. import utils as _utils
89
+
90
+ value = getattr(_utils, name)
91
+ globals()[name] = value
92
+ return value
93
+ raise AttributeError(name)
94
+
95
+
96
+ def __dir__() -> list[str]: # pragma: no cover - simple reflection
97
+ return sorted(set(__all__))
98
+
99
+
100
+ class _PushGlyphCallable(Protocol):
101
+ def __call__(self, nd: MutableMapping[str, Any], glyph: str, window: int) -> None:
102
+ ...
103
+
104
+
105
+ class _RecentGlyphCallable(Protocol):
106
+ def __call__(self, nd: MutableMapping[str, Any], glyph: str, window: int) -> bool:
107
+ ...
108
+
109
+
110
+ class _EnsureHistoryCallable(Protocol):
111
+ def __call__(self, G: TNFRGraph) -> "HistoryDict | dict[str, Any]":
112
+ ...
113
+
114
+
115
+ class _LastGlyphCallable(Protocol):
116
+ def __call__(self, nd: Mapping[str, Any]) -> str | None:
117
+ ...
118
+
119
+
120
+ class _CountGlyphsCallable(Protocol):
121
+ def __call__(
122
+ self, G: TNFRGraph, window: int | None = ..., *, last_only: bool = ...
123
+ ) -> Counter[str]:
124
+ ...
125
+
126
+
127
+ def _glyph_history_proxy(name: str) -> Callable[..., Any]:
128
+ """Return a wrapper that delegates to :mod:`tnfr.glyph_history` lazily."""
129
+
130
+ target: dict[str, Callable[..., Any] | None] = {"func": None}
131
+
132
+ def _call(*args: Any, **kwargs: Any) -> Any:
133
+ func = target["func"]
134
+ if func is None:
135
+ from .. import glyph_history as _glyph_history
136
+
137
+ func = getattr(_glyph_history, name)
138
+ target["func"] = func
139
+ return func(*args, **kwargs)
140
+
141
+ _call.__name__ = name
142
+ _call.__qualname__ = name
143
+ _call.__doc__ = f"Proxy for :func:`tnfr.glyph_history.{name}`."
144
+ return _call
145
+
146
+
147
+ count_glyphs = cast(_CountGlyphsCallable, _glyph_history_proxy("count_glyphs"))
148
+ ensure_history = cast(_EnsureHistoryCallable, _glyph_history_proxy("ensure_history"))
149
+ last_glyph = cast(_LastGlyphCallable, _glyph_history_proxy("last_glyph"))
150
+ push_glyph = cast(_PushGlyphCallable, _glyph_history_proxy("push_glyph"))
151
+ recent_glyph = cast(_RecentGlyphCallable, _glyph_history_proxy("recent_glyph"))
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..cache import CacheManager as CacheManager
6
+ from ..glyph_history import (
7
+ HistoryDict,
8
+ count_glyphs as count_glyphs,
9
+ ensure_history as ensure_history,
10
+ last_glyph as last_glyph,
11
+ push_glyph as push_glyph,
12
+ recent_glyph as recent_glyph,
13
+ )
14
+ from ..utils.cache import (
15
+ EdgeCacheManager as EdgeCacheManager,
16
+ cached_node_list as cached_node_list,
17
+ cached_nodes_and_A as cached_nodes_and_A,
18
+ edge_version_cache as edge_version_cache,
19
+ edge_version_update as edge_version_update,
20
+ ensure_node_index_map as ensure_node_index_map,
21
+ ensure_node_offset_map as ensure_node_offset_map,
22
+ node_set_checksum as node_set_checksum,
23
+ stable_json as stable_json,
24
+ )
25
+ from ..utils.graph import (
26
+ get_graph as get_graph,
27
+ get_graph_mapping as get_graph_mapping,
28
+ increment_edge_version as increment_edge_version,
29
+ mark_dnfr_prep_dirty as mark_dnfr_prep_dirty,
30
+ )
31
+ from .numeric import (
32
+ angle_diff as angle_diff,
33
+ clamp as clamp,
34
+ clamp01 as clamp01,
35
+ kahan_sum_nd as kahan_sum_nd,
36
+ )
37
+
38
+ __all__ = (
39
+ "CacheManager",
40
+ "EdgeCacheManager",
41
+ "angle_diff",
42
+ "cached_node_list",
43
+ "cached_nodes_and_A",
44
+ "clamp",
45
+ "clamp01",
46
+ "edge_version_cache",
47
+ "edge_version_update",
48
+ "ensure_node_index_map",
49
+ "ensure_node_offset_map",
50
+ "get_graph",
51
+ "get_graph_mapping",
52
+ "increment_edge_version",
53
+ "kahan_sum_nd",
54
+ "mark_dnfr_prep_dirty",
55
+ "node_set_checksum",
56
+ "stable_json",
57
+ "count_glyphs",
58
+ "ensure_history",
59
+ "last_glyph",
60
+ "push_glyph",
61
+ "recent_glyph",
62
+ "__getattr__",
63
+ )
64
+
65
+
66
+ def __getattr__(name: str) -> Any: ...
@@ -0,0 +1,88 @@
1
+ """Numeric helper functions and compensated summation utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Sequence
6
+ import math
7
+
8
+ __all__ = (
9
+ "clamp",
10
+ "clamp01",
11
+ "within_range",
12
+ "similarity_abs",
13
+ "kahan_sum_nd",
14
+ "angle_diff",
15
+ )
16
+
17
+
18
+ def clamp(x: float, a: float, b: float) -> float:
19
+ """Return ``x`` clamped to the ``[a, b]`` interval."""
20
+ return max(a, min(b, x))
21
+
22
+
23
+ def clamp01(x: float) -> float:
24
+ """Clamp ``x`` to the ``[0,1]`` interval."""
25
+ return clamp(float(x), 0.0, 1.0)
26
+
27
+
28
+ def within_range(val: float, lower: float, upper: float, tol: float = 1e-9) -> bool:
29
+ """Return ``True`` if ``val`` lies in ``[lower, upper]`` within ``tol``.
30
+
31
+ The comparison uses absolute differences instead of :func:`math.isclose`.
32
+ """
33
+
34
+ v = float(val)
35
+ return lower <= v <= upper or abs(v - lower) <= tol or abs(v - upper) <= tol
36
+
37
+
38
+ def _norm01(x: float, lo: float, hi: float) -> float:
39
+ """Normalize ``x`` to the unit interval given bounds.
40
+
41
+ ``lo`` and ``hi`` delimit the original value range. When ``hi`` is not
42
+ greater than ``lo`` the function returns ``0.0`` to avoid division by
43
+ zero. The result is clamped to ``[0,1]``.
44
+ """
45
+
46
+ if hi <= lo:
47
+ return 0.0
48
+ return clamp01((float(x) - float(lo)) / (float(hi) - float(lo)))
49
+
50
+
51
+ def similarity_abs(a: float, b: float, lo: float, hi: float) -> float:
52
+ """Return absolute similarity of ``a`` and ``b`` over ``[lo, hi]``.
53
+
54
+ It computes ``1`` minus the normalized absolute difference between
55
+ ``a`` and ``b``. Values are scaled using :func:`_norm01` so the result
56
+ falls within ``[0,1]``.
57
+ """
58
+
59
+ return 1.0 - _norm01(abs(float(a) - float(b)), 0.0, hi - lo)
60
+
61
+
62
+ def kahan_sum_nd(
63
+ values: Iterable[Sequence[float]], dims: int
64
+ ) -> tuple[float, ...]:
65
+ """Return compensated sums of ``values`` with ``dims`` components.
66
+
67
+ Each component of the tuples in ``values`` is summed independently using the
68
+ Kahan–Babuška (Neumaier) algorithm to reduce floating point error.
69
+ """
70
+ if dims < 1:
71
+ raise ValueError("dims must be >= 1")
72
+ totals = [0.0] * dims
73
+ comps = [0.0] * dims
74
+ for vs in values:
75
+ for i in range(dims):
76
+ v = vs[i]
77
+ t = totals[i] + v
78
+ if abs(totals[i]) >= abs(v):
79
+ comps[i] += (totals[i] - t) + v
80
+ else:
81
+ comps[i] += (v - t) + totals[i]
82
+ totals[i] = t
83
+ return tuple(float(totals[i] + comps[i]) for i in range(dims))
84
+
85
+
86
+ def angle_diff(a: float, b: float) -> float:
87
+ """Return the minimal difference between two angles in radians."""
88
+ return (float(a) - float(b) + math.pi) % math.tau - math.pi
@@ -0,0 +1,12 @@
1
+ from typing import Any
2
+
3
+ __all__: Any
4
+
5
+ def __getattr__(name: str) -> Any: ...
6
+
7
+ angle_diff: Any
8
+ clamp: Any
9
+ clamp01: Any
10
+ kahan_sum_nd: Any
11
+ similarity_abs: Any
12
+ within_range: Any
tnfr/immutable.py ADDED
@@ -0,0 +1,214 @@
1
+ """Utilities for freezing objects and checking immutability.
2
+
3
+ Handlers registered via :func:`functools.singledispatch` live in this module
4
+ and are triggered indirectly by the dispatcher when matching types are
5
+ encountered.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from contextlib import contextmanager
11
+ from dataclasses import asdict, is_dataclass
12
+ from functools import lru_cache, partial, singledispatch, wraps
13
+ from typing import Any, Callable, Iterable, Iterator, cast
14
+ from collections.abc import Mapping
15
+ from types import MappingProxyType
16
+ import threading
17
+ import weakref
18
+
19
+ from ._compat import TypeAlias
20
+
21
+ # Types considered immutable without further inspection
22
+ IMMUTABLE_SIMPLE = frozenset(
23
+ {int, float, complex, str, bool, bytes, type(None)}
24
+ )
25
+
26
+
27
+ FrozenPrimitive: TypeAlias = int | float | complex | str | bool | bytes | None
28
+ """Primitive immutable values handled directly by :func:`_freeze`."""
29
+
30
+ FrozenCollectionItems: TypeAlias = tuple["FrozenSnapshot", ...]
31
+ """Frozen representation for generic iterables."""
32
+
33
+ FrozenMappingItems: TypeAlias = tuple[tuple[Any, "FrozenSnapshot"], ...]
34
+ """Frozen representation for mapping ``items()`` snapshots."""
35
+
36
+ FrozenTaggedCollection: TypeAlias = tuple[str, FrozenCollectionItems]
37
+ """Tagged iterable snapshot identifying the original container type."""
38
+
39
+ FrozenTaggedMapping: TypeAlias = tuple[str, FrozenMappingItems]
40
+ """Tagged mapping snapshot identifying the original mapping flavour."""
41
+
42
+ FrozenSnapshot: TypeAlias = (
43
+ FrozenPrimitive | FrozenCollectionItems | FrozenTaggedCollection | FrozenTaggedMapping
44
+ )
45
+ """Union describing the immutable snapshot returned by :func:`_freeze`."""
46
+
47
+
48
+ @contextmanager
49
+ def _cycle_guard(value: Any, seen: set[int] | None = None) -> Iterator[set[int]]:
50
+ """Context manager that detects reference cycles during freezing."""
51
+ if seen is None:
52
+ seen = set()
53
+ obj_id = id(value)
54
+ if obj_id in seen:
55
+ raise ValueError("cycle detected")
56
+ seen.add(obj_id)
57
+ try:
58
+ yield seen
59
+ finally:
60
+ seen.remove(obj_id)
61
+
62
+
63
+ def _check_cycle(
64
+ func: Callable[[Any, set[int] | None], FrozenSnapshot]
65
+ ) -> Callable[[Any, set[int] | None], FrozenSnapshot]:
66
+ """Decorator applying :func:`_cycle_guard` to ``func``."""
67
+
68
+ @wraps(func)
69
+ def wrapper(value: Any, seen: set[int] | None = None) -> FrozenSnapshot:
70
+ with _cycle_guard(value, seen) as guard_seen:
71
+ return func(value, guard_seen)
72
+
73
+ return wrapper
74
+
75
+
76
+ def _freeze_dataclass(value: Any, seen: set[int]) -> FrozenTaggedMapping:
77
+ params = getattr(type(value), "__dataclass_params__", None)
78
+ frozen = bool(params and params.frozen)
79
+ data = asdict(value)
80
+ tag = "mapping" if frozen else "dict"
81
+ return (tag, tuple((k, _freeze(v, seen)) for k, v in data.items()))
82
+
83
+
84
+ @singledispatch
85
+ @_check_cycle
86
+ def _freeze(value: Any, seen: set[int] | None = None) -> FrozenSnapshot:
87
+ """Recursively convert ``value`` into an immutable representation."""
88
+ if is_dataclass(value) and not isinstance(value, type):
89
+ assert seen is not None
90
+ return _freeze_dataclass(value, seen)
91
+ if type(value) in IMMUTABLE_SIMPLE:
92
+ return value
93
+ raise TypeError
94
+
95
+
96
+ @_freeze.register(tuple)
97
+ @_check_cycle
98
+ def _freeze_tuple(value: tuple[Any, ...], seen: set[int] | None = None) -> FrozenCollectionItems: # noqa: F401
99
+ assert seen is not None
100
+ return tuple(_freeze(v, seen) for v in value)
101
+
102
+
103
+ def _freeze_iterable(
104
+ container: Iterable[Any], tag: str, seen: set[int]
105
+ ) -> FrozenTaggedCollection:
106
+ return (tag, tuple(_freeze(v, seen) for v in container))
107
+
108
+
109
+ def _freeze_iterable_with_tag(
110
+ value: Iterable[Any], seen: set[int] | None = None, *, tag: str
111
+ ) -> FrozenTaggedCollection:
112
+ assert seen is not None
113
+ return _freeze_iterable(value, tag, seen)
114
+
115
+
116
+ def _register_iterable(cls: type, tag: str) -> None:
117
+ handler = _check_cycle(partial(_freeze_iterable_with_tag, tag=tag))
118
+ _freeze.register(cls)(cast(Callable[[Any, set[int] | None], FrozenSnapshot], handler))
119
+
120
+
121
+ for _cls, _tag in (
122
+ (list, "list"),
123
+ (set, "set"),
124
+ (frozenset, "frozenset"),
125
+ (bytearray, "bytearray"),
126
+ ):
127
+ _register_iterable(_cls, _tag)
128
+
129
+
130
+ @_freeze.register(Mapping)
131
+ @_check_cycle
132
+ def _freeze_mapping(
133
+ value: Mapping[Any, Any], seen: set[int] | None = None
134
+ ) -> FrozenTaggedMapping: # noqa: F401
135
+ assert seen is not None
136
+ tag = "dict" if hasattr(value, "__setitem__") else "mapping"
137
+ return (tag, tuple((k, _freeze(v, seen)) for k, v in value.items()))
138
+
139
+
140
+ def _all_immutable(iterable: Iterable[Any]) -> bool:
141
+ return all(_is_immutable_inner(v) for v in iterable)
142
+
143
+
144
+ # Dispatch table kept immutable to avoid accidental mutation.
145
+ ImmutableTagHandler: TypeAlias = Callable[[tuple[Any, ...]], bool]
146
+
147
+ _IMMUTABLE_TAG_DISPATCH: Mapping[str, ImmutableTagHandler] = MappingProxyType(
148
+ {
149
+ "mapping": lambda v: _all_immutable(v[1]),
150
+ "frozenset": lambda v: _all_immutable(v[1]),
151
+ "list": lambda v: False,
152
+ "set": lambda v: False,
153
+ "bytearray": lambda v: False,
154
+ "dict": lambda v: False,
155
+ }
156
+ )
157
+
158
+
159
+ @lru_cache(maxsize=1024)
160
+ @singledispatch
161
+ def _is_immutable_inner(value: Any) -> bool:
162
+ return type(value) in IMMUTABLE_SIMPLE
163
+
164
+
165
+ @_is_immutable_inner.register(tuple)
166
+ def _is_immutable_inner_tuple(value: tuple[Any, ...]) -> bool: # noqa: F401
167
+ if value and isinstance(value[0], str):
168
+ handler = _IMMUTABLE_TAG_DISPATCH.get(value[0])
169
+ if handler is not None:
170
+ return handler(value)
171
+ return _all_immutable(value)
172
+
173
+
174
+ @_is_immutable_inner.register(frozenset)
175
+ def _is_immutable_inner_frozenset(value: frozenset[Any]) -> bool: # noqa: F401
176
+ return _all_immutable(value)
177
+
178
+
179
+ _IMMUTABLE_CACHE: weakref.WeakKeyDictionary[Any, bool] = (
180
+ weakref.WeakKeyDictionary()
181
+ )
182
+ _IMMUTABLE_CACHE_LOCK = threading.Lock()
183
+
184
+
185
+ def _is_immutable(value: Any) -> bool:
186
+ """Check recursively if ``value`` is immutable with caching."""
187
+ with _IMMUTABLE_CACHE_LOCK:
188
+ try:
189
+ return _IMMUTABLE_CACHE[value]
190
+ except (KeyError, TypeError):
191
+ pass
192
+
193
+ try:
194
+ frozen = _freeze(value)
195
+ except (TypeError, ValueError):
196
+ result = False
197
+ else:
198
+ result = _is_immutable_inner(frozen)
199
+
200
+ with _IMMUTABLE_CACHE_LOCK:
201
+ try:
202
+ _IMMUTABLE_CACHE[value] = result
203
+ except TypeError:
204
+ pass
205
+
206
+ return result
207
+
208
+
209
+ __all__ = (
210
+ "_freeze",
211
+ "_is_immutable",
212
+ "_is_immutable_inner",
213
+ "_IMMUTABLE_CACHE",
214
+ )
tnfr/immutable.pyi ADDED
@@ -0,0 +1,37 @@
1
+ from typing import Any, Callable, Iterator, Mapping
2
+
3
+ from ._compat import TypeAlias
4
+
5
+ FrozenPrimitive: TypeAlias = int | float | complex | str | bool | bytes | None
6
+ FrozenCollectionItems: TypeAlias = tuple["FrozenSnapshot", ...]
7
+ FrozenMappingItems: TypeAlias = tuple[tuple[Any, "FrozenSnapshot"], ...]
8
+ FrozenTaggedCollection: TypeAlias = tuple[str, FrozenCollectionItems]
9
+ FrozenTaggedMapping: TypeAlias = tuple[str, FrozenMappingItems]
10
+ FrozenSnapshot: TypeAlias = (
11
+ FrozenPrimitive | FrozenCollectionItems | FrozenTaggedCollection | FrozenTaggedMapping
12
+ )
13
+ ImmutableTagHandler: TypeAlias = Callable[[tuple[Any, ...]], bool]
14
+
15
+ __all__: tuple[str, ...]
16
+
17
+ def __getattr__(name: str) -> Any: ...
18
+
19
+ def _cycle_guard(value: Any, seen: set[int] | None = ...) -> Iterator[set[int]]: ...
20
+
21
+ def _check_cycle(
22
+ func: Callable[[Any, set[int] | None], FrozenSnapshot],
23
+ ) -> Callable[[Any, set[int] | None], FrozenSnapshot]: ...
24
+
25
+ def _freeze(value: Any, seen: set[int] | None = ...) -> FrozenSnapshot: ...
26
+
27
+ def _freeze_mapping(
28
+ value: Mapping[Any, Any],
29
+ seen: set[int] | None = ...,
30
+ ) -> FrozenTaggedMapping: ...
31
+
32
+ def _is_immutable(value: Any) -> bool: ...
33
+
34
+ def _is_immutable_inner(value: Any) -> bool: ...
35
+
36
+ _IMMUTABLE_CACHE: Any
37
+ _IMMUTABLE_TAG_DISPATCH: Mapping[str, ImmutableTagHandler]