krons 0.1.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 (101) hide show
  1. kronos/__init__.py +0 -0
  2. kronos/core/__init__.py +145 -0
  3. kronos/core/broadcaster.py +116 -0
  4. kronos/core/element.py +225 -0
  5. kronos/core/event.py +316 -0
  6. kronos/core/eventbus.py +116 -0
  7. kronos/core/flow.py +356 -0
  8. kronos/core/graph.py +442 -0
  9. kronos/core/node.py +982 -0
  10. kronos/core/pile.py +575 -0
  11. kronos/core/processor.py +494 -0
  12. kronos/core/progression.py +296 -0
  13. kronos/enforcement/__init__.py +57 -0
  14. kronos/enforcement/common/__init__.py +34 -0
  15. kronos/enforcement/common/boolean.py +85 -0
  16. kronos/enforcement/common/choice.py +97 -0
  17. kronos/enforcement/common/mapping.py +118 -0
  18. kronos/enforcement/common/model.py +102 -0
  19. kronos/enforcement/common/number.py +98 -0
  20. kronos/enforcement/common/string.py +140 -0
  21. kronos/enforcement/context.py +129 -0
  22. kronos/enforcement/policy.py +80 -0
  23. kronos/enforcement/registry.py +153 -0
  24. kronos/enforcement/rule.py +312 -0
  25. kronos/enforcement/service.py +370 -0
  26. kronos/enforcement/validator.py +198 -0
  27. kronos/errors.py +146 -0
  28. kronos/operations/__init__.py +32 -0
  29. kronos/operations/builder.py +228 -0
  30. kronos/operations/flow.py +398 -0
  31. kronos/operations/node.py +101 -0
  32. kronos/operations/registry.py +92 -0
  33. kronos/protocols.py +414 -0
  34. kronos/py.typed +0 -0
  35. kronos/services/__init__.py +81 -0
  36. kronos/services/backend.py +286 -0
  37. kronos/services/endpoint.py +608 -0
  38. kronos/services/hook.py +471 -0
  39. kronos/services/imodel.py +465 -0
  40. kronos/services/registry.py +115 -0
  41. kronos/services/utilities/__init__.py +36 -0
  42. kronos/services/utilities/header_factory.py +87 -0
  43. kronos/services/utilities/rate_limited_executor.py +271 -0
  44. kronos/services/utilities/rate_limiter.py +180 -0
  45. kronos/services/utilities/resilience.py +414 -0
  46. kronos/session/__init__.py +41 -0
  47. kronos/session/exchange.py +258 -0
  48. kronos/session/message.py +60 -0
  49. kronos/session/session.py +411 -0
  50. kronos/specs/__init__.py +25 -0
  51. kronos/specs/adapters/__init__.py +0 -0
  52. kronos/specs/adapters/_utils.py +45 -0
  53. kronos/specs/adapters/dataclass_field.py +246 -0
  54. kronos/specs/adapters/factory.py +56 -0
  55. kronos/specs/adapters/pydantic_adapter.py +309 -0
  56. kronos/specs/adapters/sql_ddl.py +946 -0
  57. kronos/specs/catalog/__init__.py +36 -0
  58. kronos/specs/catalog/_audit.py +39 -0
  59. kronos/specs/catalog/_common.py +43 -0
  60. kronos/specs/catalog/_content.py +59 -0
  61. kronos/specs/catalog/_enforcement.py +70 -0
  62. kronos/specs/factory.py +120 -0
  63. kronos/specs/operable.py +314 -0
  64. kronos/specs/phrase.py +405 -0
  65. kronos/specs/protocol.py +140 -0
  66. kronos/specs/spec.py +506 -0
  67. kronos/types/__init__.py +60 -0
  68. kronos/types/_sentinel.py +311 -0
  69. kronos/types/base.py +369 -0
  70. kronos/types/db_types.py +260 -0
  71. kronos/types/identity.py +66 -0
  72. kronos/utils/__init__.py +40 -0
  73. kronos/utils/_hash.py +234 -0
  74. kronos/utils/_json_dump.py +392 -0
  75. kronos/utils/_lazy_init.py +63 -0
  76. kronos/utils/_to_list.py +165 -0
  77. kronos/utils/_to_num.py +85 -0
  78. kronos/utils/_utils.py +375 -0
  79. kronos/utils/concurrency/__init__.py +205 -0
  80. kronos/utils/concurrency/_async_call.py +333 -0
  81. kronos/utils/concurrency/_cancel.py +122 -0
  82. kronos/utils/concurrency/_errors.py +96 -0
  83. kronos/utils/concurrency/_patterns.py +363 -0
  84. kronos/utils/concurrency/_primitives.py +328 -0
  85. kronos/utils/concurrency/_priority_queue.py +135 -0
  86. kronos/utils/concurrency/_resource_tracker.py +110 -0
  87. kronos/utils/concurrency/_run_async.py +67 -0
  88. kronos/utils/concurrency/_task.py +95 -0
  89. kronos/utils/concurrency/_utils.py +79 -0
  90. kronos/utils/fuzzy/__init__.py +14 -0
  91. kronos/utils/fuzzy/_extract_json.py +90 -0
  92. kronos/utils/fuzzy/_fuzzy_json.py +288 -0
  93. kronos/utils/fuzzy/_fuzzy_match.py +149 -0
  94. kronos/utils/fuzzy/_string_similarity.py +187 -0
  95. kronos/utils/fuzzy/_to_dict.py +396 -0
  96. kronos/utils/sql/__init__.py +13 -0
  97. kronos/utils/sql/_sql_validation.py +142 -0
  98. krons-0.1.0.dist-info/METADATA +70 -0
  99. krons-0.1.0.dist-info/RECORD +101 -0
  100. krons-0.1.0.dist-info/WHEEL +4 -0
  101. krons-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,296 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from __future__ import annotations
5
+
6
+ import contextlib
7
+ from typing import Any, overload
8
+ from uuid import UUID
9
+
10
+ from pydantic import Field, PrivateAttr, field_validator
11
+
12
+ from kronos.errors import NotFoundError
13
+ from kronos.protocols import Containable, implements
14
+
15
+ from .element import Element
16
+
17
+ __all__ = ("Progression",)
18
+
19
+
20
+ @implements(Containable)
21
+ class Progression(Element):
22
+ """Ordered UUID sequence with O(1) membership via auxiliary set.
23
+
24
+ Uses list for ordered storage + set for O(1) `in` checks.
25
+ Allows duplicates in order (set tracks presence, not count).
26
+
27
+ Warning:
28
+ Do NOT mutate `order` directly - use provided methods to keep
29
+ internal `_members` set synchronized.
30
+
31
+ Attributes:
32
+ name: Optional identifier for this progression.
33
+ order: Ordered UUID sequence (allows duplicates).
34
+
35
+ Example:
36
+ >>> prog = Progression(name="queue")
37
+ >>> prog.append(some_element)
38
+ >>> some_element.id in prog # O(1)
39
+ True
40
+ """
41
+
42
+ name: str | None = Field(
43
+ default=None,
44
+ description="Optional name for this progression (e.g., 'execution_order')",
45
+ )
46
+ order: list[UUID] = Field(
47
+ default_factory=list,
48
+ description="Ordered sequence of UUIDs",
49
+ )
50
+ # Auxiliary set for O(1) membership checks (not serialized)
51
+ _members: set[UUID] = PrivateAttr(default_factory=set)
52
+
53
+ @field_validator("order", mode="before")
54
+ @classmethod
55
+ def _validate_order(cls, value: Any) -> list[UUID]:
56
+ """Coerce input to list[UUID]. Accepts None, single item, or iterable."""
57
+ if value is None:
58
+ return []
59
+
60
+ # Normalize single values to list
61
+ if isinstance(value, (UUID, str, Element)):
62
+ value = [value]
63
+ elif not isinstance(value, list):
64
+ value = list(value)
65
+
66
+ # Coerce all items to UUIDs (let coercion errors raise)
67
+ return [cls._coerce_id(item) for item in value]
68
+
69
+ def model_post_init(self, __context: Any) -> None:
70
+ """Initialize _members set from order."""
71
+ super().model_post_init(__context)
72
+ self._members = set(self.order)
73
+
74
+ def _rebuild_members(self) -> None:
75
+ """Rebuild _members from order (after slice assignment)."""
76
+ self._members = set(self.order)
77
+
78
+ # ==================== Core Operations ====================
79
+
80
+ def append(self, item_id: UUID | Element) -> None:
81
+ """Append item to end. O(1)."""
82
+ uid = self._coerce_id(item_id)
83
+ self.order.append(uid)
84
+ self._members.add(uid)
85
+
86
+ def insert(self, index: int, item_id: UUID | Element) -> None:
87
+ """Insert item at index. O(n)."""
88
+ uid = self._coerce_id(item_id)
89
+ self.order.insert(index, uid)
90
+ self._members.add(uid)
91
+
92
+ def remove(self, item_id: UUID | Element) -> None:
93
+ """Remove first occurrence. O(n). Raises ValueError if not found."""
94
+ uid = self._coerce_id(item_id)
95
+ self.order.remove(uid)
96
+ if uid not in self.order:
97
+ self._members.discard(uid)
98
+
99
+ def pop(self, index: int = -1, default: Any = ...) -> UUID | Any:
100
+ """Remove and return item at index.
101
+
102
+ Args:
103
+ index: Position to pop (default: -1, last item).
104
+ default: Return value if index invalid (default: raise NotFoundError).
105
+
106
+ Returns:
107
+ UUID at index, or default if provided and index invalid.
108
+
109
+ Raises:
110
+ NotFoundError: If index out of bounds and no default.
111
+ """
112
+ try:
113
+ uid = self.order.pop(index)
114
+ if uid not in self.order:
115
+ self._members.discard(uid)
116
+ return uid
117
+ except IndexError as e:
118
+ if default is ...:
119
+ raise NotFoundError(
120
+ f"Index {index} not found in progression of length {len(self)}",
121
+ details={"index": index, "length": len(self)},
122
+ ) from e
123
+ return default
124
+
125
+ def popleft(self) -> UUID:
126
+ """Remove and return first item. O(n) due to list shift.
127
+
128
+ Raises:
129
+ NotFoundError: If empty. Use deque for frequent popleft.
130
+ """
131
+ if not self.order:
132
+ raise NotFoundError("Cannot pop from empty progression")
133
+ uid = self.order.pop(0)
134
+ if uid not in self.order:
135
+ self._members.discard(uid)
136
+ return uid
137
+
138
+ def clear(self) -> None:
139
+ """Remove all items."""
140
+ self.order.clear()
141
+ self._members.clear()
142
+
143
+ def extend(self, items: list[UUID | Element]) -> None:
144
+ """Append multiple items. O(k) where k = len(items)."""
145
+ uids = [self._coerce_id(item) for item in items]
146
+ self.order.extend(uids)
147
+ self._members.update(uids)
148
+
149
+ # ==================== Query Operations ====================
150
+
151
+ def __contains__(self, item: UUID | Element) -> bool:
152
+ """O(1) membership check via auxiliary set."""
153
+ with contextlib.suppress(Exception):
154
+ uid = self._coerce_id(item)
155
+ return uid in self._members
156
+ return False
157
+
158
+ def __len__(self) -> int:
159
+ return len(self.order)
160
+
161
+ def __bool__(self) -> bool:
162
+ return len(self.order) > 0
163
+
164
+ def __iter__(self):
165
+ return iter(self.order)
166
+
167
+ @overload
168
+ def __getitem__(self, index: int) -> UUID: ...
169
+
170
+ @overload
171
+ def __getitem__(self, index: slice) -> list[UUID]: ...
172
+
173
+ def __getitem__(self, index: int | slice) -> UUID | list[UUID]:
174
+ return self.order[index]
175
+
176
+ def __setitem__(self, index: int | slice, value: UUID | Element | list) -> None:
177
+ """Set item(s) at index. Slice assignment requires list value."""
178
+ if isinstance(index, slice):
179
+ if not isinstance(value, list):
180
+ raise TypeError(f"Cannot assign {type(value).__name__} to slice, expected list")
181
+ new_uids = [self._coerce_id(v) for v in value]
182
+ self.order[index] = new_uids
183
+ self._rebuild_members()
184
+ else:
185
+ old_uid = self.order[index]
186
+ new_uid = self._coerce_id(value)
187
+ self.order[index] = new_uid
188
+ if old_uid not in self.order:
189
+ self._members.discard(old_uid)
190
+ self._members.add(new_uid)
191
+
192
+ def index(self, item_id: UUID | Element) -> int:
193
+ """Return first index of item. O(n). Raises ValueError if not found."""
194
+ uid = self._coerce_id(item_id)
195
+ return self.order.index(uid)
196
+
197
+ def __reversed__(self):
198
+ return reversed(self.order)
199
+
200
+ def __list__(self) -> list[UUID]:
201
+ return list(self.order)
202
+
203
+ def _validate_index(self, index: int, allow_end: bool = False) -> int:
204
+ """Normalize and validate index (supports negative indexing).
205
+
206
+ Args:
207
+ index: Index to validate.
208
+ allow_end: If True, allows index == len (for insertion).
209
+
210
+ Returns:
211
+ Normalized non-negative index.
212
+
213
+ Raises:
214
+ NotFoundError: If index out of bounds.
215
+ """
216
+ length = len(self.order)
217
+ if length == 0 and not allow_end:
218
+ raise NotFoundError("Progression is empty")
219
+
220
+ if index < 0:
221
+ index = length + index
222
+
223
+ max_index = length if allow_end else length - 1
224
+ if index < 0 or index > max_index:
225
+ raise NotFoundError(
226
+ f"Index {index} out of range for progression of length {length}",
227
+ details={"index": index, "length": length, "allow_end": allow_end},
228
+ )
229
+
230
+ return index
231
+
232
+ # ==================== Workflow Operations ====================
233
+
234
+ def move(self, from_index: int, to_index: int) -> None:
235
+ """Move item from one position to another. O(n).
236
+
237
+ Args:
238
+ from_index: Source position (supports negative).
239
+ to_index: Target position (supports negative).
240
+ """
241
+ from_index = self._validate_index(from_index)
242
+ to_index = self._validate_index(to_index, allow_end=True)
243
+
244
+ item = self.order.pop(from_index)
245
+ if from_index < to_index:
246
+ to_index -= 1
247
+ self.order.insert(to_index, item)
248
+
249
+ def swap(self, index1: int, index2: int) -> None:
250
+ """Swap items at two positions. O(1).
251
+
252
+ Args:
253
+ index1: First position (supports negative).
254
+ index2: Second position (supports negative).
255
+ """
256
+ index1 = self._validate_index(index1)
257
+ index2 = self._validate_index(index2)
258
+
259
+ self.order[index1], self.order[index2] = self.order[index2], self.order[index1]
260
+
261
+ def reverse(self) -> None:
262
+ """Reverse order in-place. O(n)."""
263
+ self.order.reverse()
264
+
265
+ # ==================== Set-like Operations ====================
266
+
267
+ def include(self, item: UUID | Element) -> bool:
268
+ """Add item if not present (idempotent). O(1) check + O(1) append.
269
+
270
+ Returns:
271
+ True if added, False if already present.
272
+ """
273
+ uid = self._coerce_id(item)
274
+ if uid not in self._members:
275
+ self.order.append(uid)
276
+ self._members.add(uid)
277
+ return True
278
+ return False
279
+
280
+ def exclude(self, item: UUID | Element) -> bool:
281
+ """Remove item if present (idempotent). O(1) check + O(n) remove.
282
+
283
+ Returns:
284
+ True if removed, False if not present.
285
+ """
286
+ uid = self._coerce_id(item)
287
+ if uid in self._members:
288
+ self.order.remove(uid)
289
+ if uid not in self.order:
290
+ self._members.discard(uid)
291
+ return True
292
+ return False
293
+
294
+ def __repr__(self) -> str:
295
+ name_str = f" name='{self.name}'" if self.name else ""
296
+ return f"Progression(len={len(self)}{name_str})"
@@ -0,0 +1,57 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Enforcement module - Validation and policy protocols.
5
+
6
+ Provides:
7
+ - Rule: Base validation rule with auto-correction support
8
+ - Validator: Validates data against Spec/Operable using rules
9
+ - RuleRegistry: Maps types to validation rules
10
+ - Policy protocols: Abstract contracts for policy evaluation
11
+
12
+ Mental model:
13
+ Rule = validation (is data valid?) - with optional auto-fix
14
+ Policy = external evaluation protocol (implementations in domain libs)
15
+
16
+ Usage:
17
+ from kronos.enforcement import Rule, Validator, RuleRegistry
18
+
19
+ # Register rules
20
+ registry = RuleRegistry()
21
+ registry.register(str, StringRule(min_length=1))
22
+
23
+ # Validate
24
+ validator = Validator(registry=registry)
25
+ result = await validator.validate_spec(spec, value)
26
+ """
27
+
28
+ from .context import QueryFn, RequestContext
29
+ from .policy import EnforcementLevel, PolicyEngine, PolicyResolver, ResolvedPolicy
30
+ from .registry import RuleRegistry, get_default_registry
31
+ from .rule import Rule, RuleParams, RuleQualifier, ValidationError
32
+ from .service import ActionMeta, KronConfig, KronService, action, get_action_meta
33
+ from .validator import Validator
34
+
35
+ __all__ = (
36
+ # Rule system
37
+ "Rule",
38
+ "RuleParams",
39
+ "RuleQualifier",
40
+ "RuleRegistry",
41
+ "ValidationError",
42
+ "Validator",
43
+ "get_default_registry",
44
+ # Policy protocols
45
+ "EnforcementLevel",
46
+ "PolicyEngine",
47
+ "PolicyResolver",
48
+ "ResolvedPolicy",
49
+ # Service
50
+ "ActionMeta",
51
+ "KronConfig",
52
+ "KronService",
53
+ "QueryFn",
54
+ "RequestContext",
55
+ "action",
56
+ "get_action_meta",
57
+ )
@@ -0,0 +1,34 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Common validation rules for basic types.
5
+
6
+ Provides built-in rules for:
7
+ - StringRule: String validation with patterns, length constraints
8
+ - NumberRule: Numeric validation with range constraints
9
+ - BooleanRule: Boolean validation with auto-conversion
10
+ - ChoiceRule: Enumerated choice validation
11
+ - MappingRule: Dict/mapping validation
12
+ - BaseModelRule: Pydantic model validation
13
+ - RuleRegistry: Type-to-rule mapping with inheritance
14
+ """
15
+
16
+ from ..registry import RuleRegistry, get_default_registry, reset_default_registry
17
+ from .boolean import BooleanRule
18
+ from .choice import ChoiceRule
19
+ from .mapping import MappingRule
20
+ from .model import BaseModelRule
21
+ from .number import NumberRule
22
+ from .string import StringRule
23
+
24
+ __all__ = (
25
+ "BaseModelRule",
26
+ "BooleanRule",
27
+ "ChoiceRule",
28
+ "MappingRule",
29
+ "NumberRule",
30
+ "RuleRegistry",
31
+ "StringRule",
32
+ "get_default_registry",
33
+ "reset_default_registry",
34
+ )
@@ -0,0 +1,85 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from typing import Any
5
+
6
+ from ..rule import Rule, RuleParams, RuleQualifier
7
+
8
+ __all__ = ("BooleanRule",)
9
+
10
+
11
+ def _get_boolean_params() -> RuleParams:
12
+ """Default params: applies to bool via ANNOTATION qualifier, auto_fix enabled."""
13
+ return RuleParams(
14
+ apply_types={bool},
15
+ apply_fields=set(),
16
+ default_qualifier=RuleQualifier.ANNOTATION,
17
+ auto_fix=True,
18
+ kw={},
19
+ )
20
+
21
+
22
+ class BooleanRule(Rule):
23
+ """Rule for validating and converting boolean values.
24
+
25
+ Features:
26
+ - Type checking (must be bool)
27
+ - Auto-conversion from strings ("true", "false", "yes", "no", "1", "0")
28
+ - Auto-conversion from numbers (0 = False, non-zero = True)
29
+
30
+ Usage:
31
+ rule = BooleanRule()
32
+ result = await rule.invoke("active", "true", bool) # → True
33
+ """
34
+
35
+ def __init__(self, params: RuleParams | None = None, **kw):
36
+ """Initialize boolean rule.
37
+
38
+ Args:
39
+ params: Custom RuleParams (uses default if None)
40
+ **kw: Additional validation kwargs
41
+ """
42
+ if params is None:
43
+ params = _get_boolean_params()
44
+ super().__init__(params, **kw)
45
+
46
+ async def validate(self, v: Any, t: type, **kw) -> None:
47
+ """Validate that value is a boolean.
48
+
49
+ Raises:
50
+ ValueError: If not a boolean
51
+ """
52
+ if not isinstance(v, bool):
53
+ raise ValueError(f"Invalid boolean value: expected bool, got {type(v).__name__}")
54
+
55
+ async def perform_fix(self, v: Any, _t: type) -> Any:
56
+ """Attempt to convert value to boolean.
57
+
58
+ Conversion rules:
59
+ - Strings: "true", "yes", "1", "on" → True (case-insensitive)
60
+ - Strings: "false", "no", "0", "off" → False (case-insensitive)
61
+ - Numbers: 0 → False, non-zero → True
62
+ - Other: bool(v)
63
+
64
+ Returns:
65
+ Boolean value
66
+
67
+ Raises:
68
+ ValueError: If conversion fails
69
+ """
70
+ try:
71
+ if isinstance(v, str):
72
+ v_lower = v.strip().lower()
73
+ if v_lower in ("true", "yes", "1", "on"):
74
+ return True
75
+ elif v_lower in ("false", "no", "0", "off"):
76
+ return False
77
+ else:
78
+ raise ValueError(f"Cannot parse '{v}' as boolean")
79
+
80
+ if isinstance(v, (int, float)):
81
+ return bool(v)
82
+
83
+ return bool(v)
84
+ except Exception as e:
85
+ raise ValueError(f"Failed to convert {v} to boolean") from e
@@ -0,0 +1,97 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from typing import Any
5
+
6
+ from ..rule import Rule, RuleParams, RuleQualifier
7
+
8
+ __all__ = ("ChoiceRule",)
9
+
10
+
11
+ class ChoiceRule(Rule):
12
+ """Rule for validating values against allowed choices.
13
+
14
+ Features:
15
+ - Validates value is in allowed set
16
+ - Optional case-insensitive matching for strings
17
+ - Auto-correction to closest match (fuzzy matching)
18
+
19
+ Usage:
20
+ rule = ChoiceRule(
21
+ choices=["low", "medium", "high"],
22
+ case_sensitive=False
23
+ )
24
+ result = await rule.invoke("priority", "HIGH", str) # → "high"
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ choices: set[Any] | list[Any],
30
+ case_sensitive: bool = True,
31
+ apply_fields: set[str] | None = None,
32
+ apply_types: set[type] | None = None,
33
+ params: RuleParams | None = None,
34
+ **kw,
35
+ ):
36
+ """Initialize choice rule.
37
+
38
+ Args:
39
+ choices: Allowed values
40
+ case_sensitive: Whether string matching is case-sensitive
41
+ apply_fields: Field names to apply to
42
+ apply_types: Types to apply to
43
+ params: Custom RuleParams (overrides other settings)
44
+ **kw: Additional validation kwargs
45
+ """
46
+ if params is None:
47
+ params = RuleParams(
48
+ apply_types=set(apply_types) if apply_types else set(),
49
+ apply_fields=set(apply_fields) if apply_fields else set(),
50
+ default_qualifier=(
51
+ RuleQualifier.FIELD if apply_fields else RuleQualifier.ANNOTATION
52
+ ),
53
+ auto_fix=True,
54
+ kw={},
55
+ )
56
+ super().__init__(params, **kw)
57
+ self.choices = set(choices) if not isinstance(choices, set) else choices
58
+ self.case_sensitive = case_sensitive
59
+
60
+ if not case_sensitive:
61
+ self._lower_map = {str(c).lower(): c for c in self.choices if isinstance(c, str)}
62
+
63
+ async def validate(self, v: Any, t: type, **kw) -> None:
64
+ """Validate that value is in allowed choices (exact match only).
65
+
66
+ For case-insensitive matching, validation will fail for non-canonical
67
+ values, triggering perform_fix() to normalize.
68
+
69
+ Raises:
70
+ ValueError: If value not in choices (exact match)
71
+ """
72
+ if v in self.choices:
73
+ return
74
+
75
+ raise ValueError(f"Invalid choice: {v} not in {sorted(str(c) for c in self.choices)}")
76
+
77
+ async def perform_fix(self, v: Any, _t: type) -> Any:
78
+ """Attempt to fix value to closest choice.
79
+
80
+ For strings with case_sensitive=False, returns canonical case.
81
+ Otherwise, raises error.
82
+
83
+ Returns:
84
+ Canonical choice value
85
+
86
+ Raises:
87
+ ValueError: If cannot fix
88
+ """
89
+ if v in self.choices:
90
+ return v
91
+
92
+ if not self.case_sensitive and isinstance(v, str):
93
+ v_lower = v.lower()
94
+ if v_lower in self._lower_map:
95
+ return self._lower_map[v_lower]
96
+
97
+ raise ValueError(f"Cannot fix choice: {v} not in {sorted(str(c) for c in self.choices)}")
@@ -0,0 +1,118 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from collections.abc import Mapping
5
+ from typing import Any
6
+
7
+ import orjson
8
+
9
+ from ..rule import Rule, RuleParams, RuleQualifier
10
+
11
+ __all__ = ("MappingRule",)
12
+
13
+
14
+ def _get_mapping_params() -> RuleParams:
15
+ """Default params: applies to dict via ANNOTATION qualifier, auto_fix enabled."""
16
+ return RuleParams(
17
+ apply_types={dict},
18
+ apply_fields=set(),
19
+ default_qualifier=RuleQualifier.ANNOTATION,
20
+ auto_fix=True,
21
+ kw={},
22
+ )
23
+
24
+
25
+ class MappingRule(Rule):
26
+ """Rule for validating and converting mapping/dict values.
27
+
28
+ Features:
29
+ - Type checking (must be dict/Mapping)
30
+ - Required keys validation
31
+ - Optional keys validation
32
+ - Auto-conversion from JSON string
33
+ - Fuzzy key matching (optional, case-insensitive)
34
+
35
+ Usage:
36
+ rule = MappingRule(
37
+ required_keys={"name", "value"},
38
+ optional_keys={"description"},
39
+ fuzzy_keys=True
40
+ )
41
+ result = await rule.invoke("config", '{"Name": "test"}', dict)
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ required_keys: set[str] | None = None,
47
+ optional_keys: set[str] | None = None,
48
+ fuzzy_keys: bool = False,
49
+ params: RuleParams | None = None,
50
+ **kw,
51
+ ):
52
+ """Initialize mapping rule.
53
+
54
+ Args:
55
+ required_keys: Keys that must be present
56
+ optional_keys: Keys that may be present (for validation of known keys)
57
+ fuzzy_keys: Enable case-insensitive key matching
58
+ params: Custom RuleParams (uses default if None)
59
+ **kw: Additional validation kwargs
60
+ """
61
+ if params is None:
62
+ params = _get_mapping_params()
63
+ super().__init__(params, **kw)
64
+ self.required_keys = required_keys or set()
65
+ self.optional_keys = optional_keys or set()
66
+ self.fuzzy_keys = fuzzy_keys
67
+
68
+ if fuzzy_keys:
69
+ all_keys = self.required_keys | self.optional_keys
70
+ self._key_map = {k.lower(): k for k in all_keys}
71
+
72
+ async def validate(self, v: Any, t: type, **kw) -> None:
73
+ """Validate that value is a mapping with required keys (exact match).
74
+
75
+ For fuzzy_keys mode, validation uses exact key matching so that
76
+ non-canonical keys trigger perform_fix() for normalization.
77
+
78
+ Raises:
79
+ ValueError: If not a mapping or missing required keys
80
+ """
81
+ if not isinstance(v, Mapping):
82
+ raise ValueError(
83
+ f"Invalid mapping value: expected dict/Mapping, got {type(v).__name__}"
84
+ )
85
+
86
+ if self.required_keys:
87
+ missing = self.required_keys - set(v.keys())
88
+ if missing:
89
+ raise ValueError(f"Missing required keys: {sorted(missing)}")
90
+
91
+ async def perform_fix(self, v: Any, t: type) -> Any:
92
+ """Attempt to convert value to mapping and normalize keys.
93
+
94
+ Returns:
95
+ Dict with normalized keys (if fuzzy_keys enabled)
96
+
97
+ Raises:
98
+ ValueError: If conversion fails
99
+ """
100
+ if isinstance(v, str):
101
+ try:
102
+ v = orjson.loads(v)
103
+ except orjson.JSONDecodeError as e:
104
+ raise ValueError(f"Failed to parse JSON string: {e}") from e
105
+
106
+ if not isinstance(v, Mapping):
107
+ raise ValueError(f"Cannot convert {type(v).__name__} to mapping")
108
+
109
+ if self.fuzzy_keys and self._key_map:
110
+ fixed = {}
111
+ for k, val in v.items():
112
+ k_lower = k.lower() if isinstance(k, str) else k
113
+ fixed[self._key_map.get(k_lower, k)] = val
114
+ v = fixed
115
+
116
+ result = dict(v) if not isinstance(v, dict) else v
117
+ await self.validate(result, t)
118
+ return result