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.
- kronos/__init__.py +0 -0
- kronos/core/__init__.py +145 -0
- kronos/core/broadcaster.py +116 -0
- kronos/core/element.py +225 -0
- kronos/core/event.py +316 -0
- kronos/core/eventbus.py +116 -0
- kronos/core/flow.py +356 -0
- kronos/core/graph.py +442 -0
- kronos/core/node.py +982 -0
- kronos/core/pile.py +575 -0
- kronos/core/processor.py +494 -0
- kronos/core/progression.py +296 -0
- kronos/enforcement/__init__.py +57 -0
- kronos/enforcement/common/__init__.py +34 -0
- kronos/enforcement/common/boolean.py +85 -0
- kronos/enforcement/common/choice.py +97 -0
- kronos/enforcement/common/mapping.py +118 -0
- kronos/enforcement/common/model.py +102 -0
- kronos/enforcement/common/number.py +98 -0
- kronos/enforcement/common/string.py +140 -0
- kronos/enforcement/context.py +129 -0
- kronos/enforcement/policy.py +80 -0
- kronos/enforcement/registry.py +153 -0
- kronos/enforcement/rule.py +312 -0
- kronos/enforcement/service.py +370 -0
- kronos/enforcement/validator.py +198 -0
- kronos/errors.py +146 -0
- kronos/operations/__init__.py +32 -0
- kronos/operations/builder.py +228 -0
- kronos/operations/flow.py +398 -0
- kronos/operations/node.py +101 -0
- kronos/operations/registry.py +92 -0
- kronos/protocols.py +414 -0
- kronos/py.typed +0 -0
- kronos/services/__init__.py +81 -0
- kronos/services/backend.py +286 -0
- kronos/services/endpoint.py +608 -0
- kronos/services/hook.py +471 -0
- kronos/services/imodel.py +465 -0
- kronos/services/registry.py +115 -0
- kronos/services/utilities/__init__.py +36 -0
- kronos/services/utilities/header_factory.py +87 -0
- kronos/services/utilities/rate_limited_executor.py +271 -0
- kronos/services/utilities/rate_limiter.py +180 -0
- kronos/services/utilities/resilience.py +414 -0
- kronos/session/__init__.py +41 -0
- kronos/session/exchange.py +258 -0
- kronos/session/message.py +60 -0
- kronos/session/session.py +411 -0
- kronos/specs/__init__.py +25 -0
- kronos/specs/adapters/__init__.py +0 -0
- kronos/specs/adapters/_utils.py +45 -0
- kronos/specs/adapters/dataclass_field.py +246 -0
- kronos/specs/adapters/factory.py +56 -0
- kronos/specs/adapters/pydantic_adapter.py +309 -0
- kronos/specs/adapters/sql_ddl.py +946 -0
- kronos/specs/catalog/__init__.py +36 -0
- kronos/specs/catalog/_audit.py +39 -0
- kronos/specs/catalog/_common.py +43 -0
- kronos/specs/catalog/_content.py +59 -0
- kronos/specs/catalog/_enforcement.py +70 -0
- kronos/specs/factory.py +120 -0
- kronos/specs/operable.py +314 -0
- kronos/specs/phrase.py +405 -0
- kronos/specs/protocol.py +140 -0
- kronos/specs/spec.py +506 -0
- kronos/types/__init__.py +60 -0
- kronos/types/_sentinel.py +311 -0
- kronos/types/base.py +369 -0
- kronos/types/db_types.py +260 -0
- kronos/types/identity.py +66 -0
- kronos/utils/__init__.py +40 -0
- kronos/utils/_hash.py +234 -0
- kronos/utils/_json_dump.py +392 -0
- kronos/utils/_lazy_init.py +63 -0
- kronos/utils/_to_list.py +165 -0
- kronos/utils/_to_num.py +85 -0
- kronos/utils/_utils.py +375 -0
- kronos/utils/concurrency/__init__.py +205 -0
- kronos/utils/concurrency/_async_call.py +333 -0
- kronos/utils/concurrency/_cancel.py +122 -0
- kronos/utils/concurrency/_errors.py +96 -0
- kronos/utils/concurrency/_patterns.py +363 -0
- kronos/utils/concurrency/_primitives.py +328 -0
- kronos/utils/concurrency/_priority_queue.py +135 -0
- kronos/utils/concurrency/_resource_tracker.py +110 -0
- kronos/utils/concurrency/_run_async.py +67 -0
- kronos/utils/concurrency/_task.py +95 -0
- kronos/utils/concurrency/_utils.py +79 -0
- kronos/utils/fuzzy/__init__.py +14 -0
- kronos/utils/fuzzy/_extract_json.py +90 -0
- kronos/utils/fuzzy/_fuzzy_json.py +288 -0
- kronos/utils/fuzzy/_fuzzy_match.py +149 -0
- kronos/utils/fuzzy/_string_similarity.py +187 -0
- kronos/utils/fuzzy/_to_dict.py +396 -0
- kronos/utils/sql/__init__.py +13 -0
- kronos/utils/sql/_sql_validation.py +142 -0
- krons-0.1.0.dist-info/METADATA +70 -0
- krons-0.1.0.dist-info/RECORD +101 -0
- krons-0.1.0.dist-info/WHEEL +4 -0
- 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
|