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,311 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Sentinel types for distinguishing missing vs unset values.
|
|
5
|
+
|
|
6
|
+
Provides two distinct sentinel states:
|
|
7
|
+
- Undefined: Field/key entirely absent from namespace (never existed)
|
|
8
|
+
- Unset: Key present but value not provided (explicit "no value")
|
|
9
|
+
|
|
10
|
+
This distinction enables precise handling in serialization, validation,
|
|
11
|
+
and API parameter processing where None has semantic meaning.
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> def fetch(timeout: int | UnsetType = Unset) -> Response:
|
|
15
|
+
... if is_unset(timeout):
|
|
16
|
+
... timeout = DEFAULT_TIMEOUT # user didn't specify
|
|
17
|
+
... # vs timeout=None which could mean "no timeout"
|
|
18
|
+
|
|
19
|
+
Usage patterns:
|
|
20
|
+
- Field defaults: `field: str = Unset` (user can provide or omit)
|
|
21
|
+
- Dict access: `d.get(key, Undefined)` (distinguish missing from None)
|
|
22
|
+
- Type hints: `MaybeSentinel[T]` for T | Undefined | Unset
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from typing import (
|
|
29
|
+
Any,
|
|
30
|
+
ClassVar,
|
|
31
|
+
Final,
|
|
32
|
+
Literal,
|
|
33
|
+
Self,
|
|
34
|
+
TypeAlias,
|
|
35
|
+
TypeGuard,
|
|
36
|
+
TypeVar,
|
|
37
|
+
Union,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = (
|
|
41
|
+
"MaybeSentinel",
|
|
42
|
+
"MaybeUndefined",
|
|
43
|
+
"MaybeUnset",
|
|
44
|
+
"SingletonType",
|
|
45
|
+
"T",
|
|
46
|
+
"Undefined",
|
|
47
|
+
"UndefinedType",
|
|
48
|
+
"Unset",
|
|
49
|
+
"UnsetType",
|
|
50
|
+
"is_sentinel",
|
|
51
|
+
"is_undefined",
|
|
52
|
+
"is_unset",
|
|
53
|
+
"not_sentinel",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
T = TypeVar("T")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _SingletonMeta(type):
|
|
60
|
+
"""Metaclass ensuring single instance per subclass for identity checks."""
|
|
61
|
+
|
|
62
|
+
_cache: ClassVar[dict[type, SingletonType]] = {}
|
|
63
|
+
|
|
64
|
+
def __call__(cls, *a, **kw):
|
|
65
|
+
if cls not in cls._cache:
|
|
66
|
+
cls._cache[cls] = super().__call__(*a, **kw)
|
|
67
|
+
return cls._cache[cls]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SingletonType(metaclass=_SingletonMeta):
|
|
71
|
+
"""Base for singleton sentinels.
|
|
72
|
+
|
|
73
|
+
Guarantees:
|
|
74
|
+
- Single instance per subclass (safe `is` checks)
|
|
75
|
+
- Falsy evaluation (bool returns False)
|
|
76
|
+
- Identity preserved across copy/deepcopy/pickle
|
|
77
|
+
|
|
78
|
+
Subclasses must implement __bool__ and __repr__.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
__slots__: tuple[str, ...] = ()
|
|
82
|
+
|
|
83
|
+
def __deepcopy__(self, memo: dict[int, Any]) -> Self:
|
|
84
|
+
"""Return self; singleton identity survives deepcopy."""
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __copy__(self) -> Self:
|
|
88
|
+
"""Return self; singleton identity survives copy."""
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def __bool__(self) -> bool:
|
|
92
|
+
"""Subclasses must return False."""
|
|
93
|
+
raise NotImplementedError
|
|
94
|
+
|
|
95
|
+
def __repr__(self) -> str:
|
|
96
|
+
"""Subclasses must return sentinel name."""
|
|
97
|
+
raise NotImplementedError
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class UndefinedType(SingletonType):
|
|
101
|
+
"""Sentinel for field/key entirely absent from namespace.
|
|
102
|
+
|
|
103
|
+
Semantics: The key was never present; the field never existed.
|
|
104
|
+
|
|
105
|
+
Use cases:
|
|
106
|
+
- dict.get(key, Undefined) to distinguish missing from None
|
|
107
|
+
- Dataclass fields that may not exist in source data
|
|
108
|
+
- API responses with optional fields
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
>>> config = {"timeout": None}
|
|
112
|
+
>>> config.get("timeout", Undefined) # None (explicitly set)
|
|
113
|
+
>>> config.get("retries", Undefined) # Undefined (missing)
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
__slots__ = ()
|
|
117
|
+
|
|
118
|
+
def __bool__(self) -> Literal[False]:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
def __repr__(self) -> Literal["Undefined"]:
|
|
122
|
+
return "Undefined"
|
|
123
|
+
|
|
124
|
+
def __str__(self) -> Literal["Undefined"]:
|
|
125
|
+
return "Undefined"
|
|
126
|
+
|
|
127
|
+
def __reduce__(self) -> tuple[type[UndefinedType], tuple[()]]:
|
|
128
|
+
"""Preserve singleton across pickle."""
|
|
129
|
+
return (UndefinedType, ())
|
|
130
|
+
|
|
131
|
+
def __or__(self, other: type) -> Any:
|
|
132
|
+
"""Enable union syntax: str | Undefined."""
|
|
133
|
+
other_type = type(other) if isinstance(other, SingletonType) else other
|
|
134
|
+
return Union[type(self), other_type]
|
|
135
|
+
|
|
136
|
+
def __ror__(self, other: type) -> Any:
|
|
137
|
+
"""Enable reverse union: Undefined | str."""
|
|
138
|
+
other_type = type(other) if isinstance(other, SingletonType) else other
|
|
139
|
+
return Union[other_type, type(self)]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class UnsetType(SingletonType):
|
|
143
|
+
"""Sentinel for key present but value explicitly not provided.
|
|
144
|
+
|
|
145
|
+
Semantics: The slot exists but user chose not to fill it.
|
|
146
|
+
|
|
147
|
+
Use cases:
|
|
148
|
+
- Function params: distinguish "not passed" from "passed None"
|
|
149
|
+
- Form fields: distinguish "left blank" from "cleared"
|
|
150
|
+
- Config: distinguish "use default" from "disabled"
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
>>> def request(timeout: int | None | UnsetType = Unset):
|
|
154
|
+
... if is_unset(timeout):
|
|
155
|
+
... timeout = 30 # default
|
|
156
|
+
... elif timeout is None:
|
|
157
|
+
... timeout = float('inf') # no timeout
|
|
158
|
+
... return make_request(timeout=timeout)
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
__slots__ = ()
|
|
162
|
+
|
|
163
|
+
def __bool__(self) -> Literal[False]:
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
def __repr__(self) -> Literal["Unset"]:
|
|
167
|
+
return "Unset"
|
|
168
|
+
|
|
169
|
+
def __str__(self) -> Literal["Unset"]:
|
|
170
|
+
return "Unset"
|
|
171
|
+
|
|
172
|
+
def __reduce__(self) -> tuple[type[UnsetType], tuple[()]]:
|
|
173
|
+
"""Preserve singleton across pickle."""
|
|
174
|
+
return (UnsetType, ())
|
|
175
|
+
|
|
176
|
+
def __or__(self, other: type) -> Any:
|
|
177
|
+
"""Enable union syntax: str | Unset."""
|
|
178
|
+
other_type = type(other) if isinstance(other, SingletonType) else other
|
|
179
|
+
return Union[type(self), other_type]
|
|
180
|
+
|
|
181
|
+
def __ror__(self, other: type) -> Any:
|
|
182
|
+
"""Enable reverse union: Unset | str."""
|
|
183
|
+
other_type = type(other) if isinstance(other, SingletonType) else other
|
|
184
|
+
return Union[other_type, type(self)]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
Undefined: Final[UndefinedType] = UndefinedType()
|
|
188
|
+
"""Singleton: key/field entirely absent from namespace."""
|
|
189
|
+
|
|
190
|
+
Unset: Final[UnsetType] = UnsetType()
|
|
191
|
+
"""Singleton: key present but value not provided."""
|
|
192
|
+
|
|
193
|
+
MaybeUndefined: TypeAlias = T | UndefinedType
|
|
194
|
+
"""Type alias: T or Undefined (for optional fields that may not exist)."""
|
|
195
|
+
|
|
196
|
+
MaybeUnset: TypeAlias = T | UnsetType
|
|
197
|
+
"""Type alias: T or Unset (for params with explicit 'not provided' state)."""
|
|
198
|
+
|
|
199
|
+
MaybeSentinel: TypeAlias = T | UndefinedType | UnsetType
|
|
200
|
+
"""Type alias: T or either sentinel (full optionality)."""
|
|
201
|
+
|
|
202
|
+
_EMPTY_TUPLE: tuple[Any, ...] = (tuple(), set(), frozenset(), dict(), list(), "")
|
|
203
|
+
|
|
204
|
+
AdditionalSentinels = Literal["none", "empty", "pydantic", "dataclass"]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _is_builtin_sentinel(value: Any) -> bool:
|
|
208
|
+
return isinstance(value, (UndefinedType, UnsetType))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _is_pydantic_sentinel(value: Any) -> bool:
|
|
212
|
+
from pydantic_core import PydanticUndefinedType
|
|
213
|
+
|
|
214
|
+
return isinstance(value, PydanticUndefinedType)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _is_none(value: Any) -> bool:
|
|
218
|
+
return value is None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _is_empty(value: Any) -> bool:
|
|
222
|
+
return value in _EMPTY_TUPLE
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _is_dataclass_missing(value: Any) -> bool:
|
|
226
|
+
from dataclasses import MISSING
|
|
227
|
+
|
|
228
|
+
return value is MISSING
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
SENTINEL_HANDLERS: dict[str, Callable[[Any], bool]] = {
|
|
232
|
+
"none": _is_none,
|
|
233
|
+
"empty": _is_empty,
|
|
234
|
+
"pydantic": _is_pydantic_sentinel,
|
|
235
|
+
"dataclass": _is_dataclass_missing,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
HANDLE_SEQUENCE: tuple[str, ...] = ("none", "empty", "pydantic", "dataclass")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def is_undefined(value: Any) -> bool:
|
|
242
|
+
"""Check if value is Undefined sentinel.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
value: Any value to check.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
True if value is Undefined type instance.
|
|
249
|
+
|
|
250
|
+
Note:
|
|
251
|
+
Uses isinstance (not `is`) for robustness across module reloads.
|
|
252
|
+
"""
|
|
253
|
+
return isinstance(value, UndefinedType)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def is_unset(value: Any) -> bool:
|
|
257
|
+
"""Check if value is Unset sentinel.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
value: Any value to check.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
True if value is Unset type instance.
|
|
264
|
+
|
|
265
|
+
Note:
|
|
266
|
+
Uses isinstance (not `is`) for robustness across module reloads.
|
|
267
|
+
"""
|
|
268
|
+
return isinstance(value, UnsetType)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def is_sentinel(
|
|
272
|
+
value: Any,
|
|
273
|
+
additions: set[AdditionalSentinels] = frozenset(),
|
|
274
|
+
) -> bool:
|
|
275
|
+
"""Check if value is any sentinel type.
|
|
276
|
+
|
|
277
|
+
Always checks Undefined and Unset. Additional sentinel categories
|
|
278
|
+
can be opted into via the additions set.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
value: Any value to check.
|
|
282
|
+
additions: Extra categories to treat as sentinel:
|
|
283
|
+
"none" - treat None as sentinel
|
|
284
|
+
"empty" - treat empty containers/strings as sentinel
|
|
285
|
+
"pydantic" - treat PydanticUndefined as sentinel
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
True if value matches sentinel criteria.
|
|
289
|
+
"""
|
|
290
|
+
if _is_builtin_sentinel(value):
|
|
291
|
+
return True
|
|
292
|
+
for key in HANDLE_SEQUENCE:
|
|
293
|
+
if key in additions and SENTINEL_HANDLERS[key](value):
|
|
294
|
+
return True
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def not_sentinel(
|
|
299
|
+
value: T | UndefinedType | UnsetType,
|
|
300
|
+
additions: set[AdditionalSentinels] = frozenset(),
|
|
301
|
+
) -> TypeGuard[T]:
|
|
302
|
+
"""Type-narrowing guard: value is NOT a sentinel.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
value: Value to check, typically MaybeSentinel[T].
|
|
306
|
+
additions: Extra categories to treat as sentinel (see is_sentinel).
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
TypeGuard narrowing MaybeSentinel[T] to T.
|
|
310
|
+
"""
|
|
311
|
+
return not is_sentinel(value, additions)
|
kronos/types/base.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Base types for kron: dataclasses with sentinel-aware serialization.
|
|
5
|
+
|
|
6
|
+
Provides:
|
|
7
|
+
- Enum: String-backed enum for JSON-friendly enumerations
|
|
8
|
+
- ModelConfig: Configuration for sentinel handling and validation
|
|
9
|
+
- Params: Immutable parameter container (frozen dataclass, custom __init__)
|
|
10
|
+
- DataClass: Mutable dataclass with validation hooks
|
|
11
|
+
- Meta: Hashable key-value metadata container
|
|
12
|
+
|
|
13
|
+
Key concepts:
|
|
14
|
+
- Sentinel handling: Undefined/Unset fields excluded from to_dict()
|
|
15
|
+
- Configurable validation: strict mode, prefill behavior
|
|
16
|
+
- Hash-based equality: enables caching and deduplication
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import MutableMapping, MutableSequence, MutableSet, Sequence
|
|
22
|
+
from dataclasses import MISSING as DATACLASS_MISSING
|
|
23
|
+
from dataclasses import dataclass, field, fields
|
|
24
|
+
from enum import Enum as _Enum
|
|
25
|
+
from enum import StrEnum
|
|
26
|
+
from typing import Any, ClassVar, Literal, Self, TypedDict
|
|
27
|
+
|
|
28
|
+
from pydantic import BaseModel as _PydanticBaseModel
|
|
29
|
+
from typing_extensions import override
|
|
30
|
+
|
|
31
|
+
from kronos.protocols import Allowable, Hashable, Serializable, implements
|
|
32
|
+
from kronos.utils._hash import hash_obj
|
|
33
|
+
|
|
34
|
+
from ._sentinel import Undefined, Unset, is_sentinel, is_undefined
|
|
35
|
+
|
|
36
|
+
__all__ = (
|
|
37
|
+
"DataClass",
|
|
38
|
+
"Enum",
|
|
39
|
+
"HashableModel",
|
|
40
|
+
"KeysDict",
|
|
41
|
+
"KeysLike",
|
|
42
|
+
"Meta",
|
|
43
|
+
"ModelConfig",
|
|
44
|
+
"Params",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@implements(Allowable)
|
|
49
|
+
class Enum(StrEnum):
|
|
50
|
+
"""String-backed enum with Allowable protocol.
|
|
51
|
+
|
|
52
|
+
Members serialize directly to their string values. Python 3.11+.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def allowed(cls) -> tuple[str, ...]:
|
|
57
|
+
"""Return tuple of all valid member values."""
|
|
58
|
+
return tuple(e.value for e in cls)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class KeysDict(TypedDict, total=False):
|
|
62
|
+
"""TypedDict for flexible key-type mappings."""
|
|
63
|
+
|
|
64
|
+
key: Any
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(slots=True, frozen=True)
|
|
68
|
+
class ModelConfig:
|
|
69
|
+
"""Configuration for Params/DataClass behavior.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
sentinel_additions: Additional sentinel categories beyond Undefined/Unset.
|
|
73
|
+
Valid values: "none", "empty", "pydantic", "dataclass".
|
|
74
|
+
strict: Require all fields have values (raise if sentinel).
|
|
75
|
+
prefill_unset: Convert Undefined fields to Unset on validation.
|
|
76
|
+
use_enum_values: Serialize enums as their values (not names).
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
sentinel_additions: frozenset[str] = field(default_factory=frozenset)
|
|
80
|
+
strict: bool = False
|
|
81
|
+
prefill_unset: bool = True
|
|
82
|
+
use_enum_values: bool = False
|
|
83
|
+
|
|
84
|
+
def is_sentinel(self, value: Any) -> bool:
|
|
85
|
+
"""Check if value is sentinel per this config's additions."""
|
|
86
|
+
return is_sentinel(value, self.sentinel_additions)
|
|
87
|
+
|
|
88
|
+
def is_sentinel_field(self, allowable: Allowable, field_name: str, /) -> bool:
|
|
89
|
+
"""Check if a field holds a sentinel value in the allowable namespace."""
|
|
90
|
+
if field_name not in allowable.allowed():
|
|
91
|
+
raise ValueError(f"Invalid field name: {field_name}")
|
|
92
|
+
value = getattr(allowable, field_name, Undefined)
|
|
93
|
+
return self.is_sentinel(value)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _SentinelMixin:
|
|
97
|
+
"""Shared sentinel-aware serialization logic for Params and DataClass.
|
|
98
|
+
|
|
99
|
+
Provides: allowed(), _is_sentinel(), _normalize_value(), _validate(),
|
|
100
|
+
to_dict(), with_updates(), __hash__().
|
|
101
|
+
|
|
102
|
+
Subclasses must define:
|
|
103
|
+
_config: ClassVar[ModelConfig]
|
|
104
|
+
_allowed_keys: ClassVar[set[str]]
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
__slots__ = ()
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def allowed(cls) -> set[str]:
|
|
111
|
+
"""Return set of valid field names (excludes private/ClassVar)."""
|
|
112
|
+
if cls._allowed_keys:
|
|
113
|
+
return cls._allowed_keys
|
|
114
|
+
cls._allowed_keys = set(f.name for f in fields(cls) if not f.name.startswith("_"))
|
|
115
|
+
return cls._allowed_keys
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def _is_sentinel(cls, value: Any) -> bool:
|
|
119
|
+
"""Check if value is sentinel per _config settings."""
|
|
120
|
+
return is_sentinel(value, cls._config.sentinel_additions)
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def _normalize_value(cls, value: Any) -> Any:
|
|
124
|
+
"""Normalize value for serialization (enum to value if configured)."""
|
|
125
|
+
if cls._config.use_enum_values and isinstance(value, _Enum):
|
|
126
|
+
return value.value
|
|
127
|
+
return value
|
|
128
|
+
|
|
129
|
+
def _validate(self) -> None:
|
|
130
|
+
"""Validate fields per _config. Raises ExceptionGroup if strict violations."""
|
|
131
|
+
missing: list[Exception] = []
|
|
132
|
+
for k in self.allowed():
|
|
133
|
+
if self._config.strict and self._is_sentinel(getattr(self, k, Unset)):
|
|
134
|
+
missing.append(ValueError(f"Missing required parameter: {k}"))
|
|
135
|
+
if self._config.prefill_unset and is_undefined(getattr(self, k, Undefined)):
|
|
136
|
+
object.__setattr__(self, k, Unset)
|
|
137
|
+
if missing:
|
|
138
|
+
raise ExceptionGroup("Missing required parameters", missing)
|
|
139
|
+
|
|
140
|
+
def to_dict(
|
|
141
|
+
self,
|
|
142
|
+
mode: Literal["python", "json"] = "python",
|
|
143
|
+
exclude: set[str] | None = None,
|
|
144
|
+
**kwargs: Any,
|
|
145
|
+
) -> dict[str, Any]:
|
|
146
|
+
"""Serialize to dict, excluding sentinel values."""
|
|
147
|
+
data = {}
|
|
148
|
+
exclude = exclude or set()
|
|
149
|
+
for k in self.allowed():
|
|
150
|
+
if k not in exclude:
|
|
151
|
+
v = getattr(self, k, Undefined)
|
|
152
|
+
if not self._is_sentinel(v):
|
|
153
|
+
data[k] = self._normalize_value(v)
|
|
154
|
+
if mode == "json":
|
|
155
|
+
from kronos.utils._json_dump import json_dump
|
|
156
|
+
|
|
157
|
+
return json_dump(data, decode=True, as_loaded=True, **kwargs)
|
|
158
|
+
|
|
159
|
+
return data
|
|
160
|
+
|
|
161
|
+
def with_updates(
|
|
162
|
+
self, copy_containers: Literal["shallow", "deep"] | None = None, **kwargs: Any
|
|
163
|
+
) -> Self:
|
|
164
|
+
"""Return new instance with updated fields.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
copy_containers: "shallow", "deep", or None (share references).
|
|
168
|
+
**kwargs: Field values to update.
|
|
169
|
+
"""
|
|
170
|
+
dict_ = self.to_dict()
|
|
171
|
+
|
|
172
|
+
def _out(d: dict):
|
|
173
|
+
d.update(kwargs)
|
|
174
|
+
return type(self)(**d)
|
|
175
|
+
|
|
176
|
+
if copy_containers is None:
|
|
177
|
+
return _out(dict_)
|
|
178
|
+
|
|
179
|
+
match copy_containers:
|
|
180
|
+
case "shallow":
|
|
181
|
+
for k, v in dict_.items():
|
|
182
|
+
if k not in kwargs and isinstance(
|
|
183
|
+
v, (MutableSequence, MutableMapping, MutableSet)
|
|
184
|
+
):
|
|
185
|
+
dict_[k] = v.copy() if hasattr(v, "copy") else list(v)
|
|
186
|
+
return _out(dict_)
|
|
187
|
+
|
|
188
|
+
case "deep":
|
|
189
|
+
import copy
|
|
190
|
+
|
|
191
|
+
for k, v in dict_.items():
|
|
192
|
+
if k not in kwargs and isinstance(
|
|
193
|
+
v, (MutableSequence, MutableMapping, MutableSet)
|
|
194
|
+
):
|
|
195
|
+
dict_[k] = copy.deepcopy(v)
|
|
196
|
+
return _out(dict_)
|
|
197
|
+
|
|
198
|
+
raise ValueError(
|
|
199
|
+
f"Invalid copy_containers: {copy_containers!r}. Must be 'shallow', 'deep', or None."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def is_sentinel_field(self, field_name: str) -> bool:
|
|
203
|
+
"""Check if field holds a sentinel value.
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
ValueError: If field_name not in allowed().
|
|
207
|
+
"""
|
|
208
|
+
if field_name not in self.allowed():
|
|
209
|
+
raise ValueError(f"Invalid field name: {field_name}")
|
|
210
|
+
value = getattr(self, field_name, Undefined)
|
|
211
|
+
return self._is_sentinel(value)
|
|
212
|
+
|
|
213
|
+
def __hash__(self) -> int:
|
|
214
|
+
"""Hash based on serialized dict contents."""
|
|
215
|
+
return hash_obj(self)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@implements(Serializable, Allowable, Hashable, allow_inherited=True)
|
|
219
|
+
@dataclass(slots=True, frozen=True, init=False)
|
|
220
|
+
class Params(_SentinelMixin):
|
|
221
|
+
"""Immutable parameter container with sentinel-aware serialization.
|
|
222
|
+
|
|
223
|
+
Frozen dataclass with custom __init__ for sentinel support.
|
|
224
|
+
Subclass and override _config for custom behavior.
|
|
225
|
+
|
|
226
|
+
Example:
|
|
227
|
+
>>> @dataclass(slots=True, frozen=True, init=False)
|
|
228
|
+
... class RequestParams(Params):
|
|
229
|
+
... timeout: int = Unset
|
|
230
|
+
... retries: int = 3
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
_config: ClassVar[ModelConfig] = ModelConfig()
|
|
234
|
+
_allowed_keys: ClassVar[set[str]] = set()
|
|
235
|
+
|
|
236
|
+
def __init__(self, **kwargs: Any):
|
|
237
|
+
"""Initialize from kwargs with validation.
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
ValueError: If kwargs contains invalid field names.
|
|
241
|
+
ExceptionGroup: If strict mode and required fields missing.
|
|
242
|
+
"""
|
|
243
|
+
for f in fields(self):
|
|
244
|
+
if f.name.startswith("_"):
|
|
245
|
+
continue
|
|
246
|
+
if f.name not in kwargs:
|
|
247
|
+
if f.default is not DATACLASS_MISSING:
|
|
248
|
+
object.__setattr__(self, f.name, f.default)
|
|
249
|
+
elif f.default_factory is not DATACLASS_MISSING:
|
|
250
|
+
object.__setattr__(self, f.name, f.default_factory())
|
|
251
|
+
|
|
252
|
+
for k, v in kwargs.items():
|
|
253
|
+
if k in self.allowed():
|
|
254
|
+
object.__setattr__(self, k, v)
|
|
255
|
+
else:
|
|
256
|
+
raise ValueError(f"Invalid parameter: {k}")
|
|
257
|
+
|
|
258
|
+
self._validate()
|
|
259
|
+
|
|
260
|
+
def default_kw(self) -> Any:
|
|
261
|
+
"""Return dict with kwargs/kw fields merged into top level."""
|
|
262
|
+
dict_ = self.to_dict()
|
|
263
|
+
kw_ = {}
|
|
264
|
+
kw_.update(dict_.pop("kwargs", {}))
|
|
265
|
+
kw_.update(dict_.pop("kw", {}))
|
|
266
|
+
dict_.update(kw_)
|
|
267
|
+
return dict_
|
|
268
|
+
|
|
269
|
+
def __eq__(self, other: object) -> bool:
|
|
270
|
+
"""Equality via hash. Returns NotImplemented for incompatible types."""
|
|
271
|
+
if not isinstance(other, Params):
|
|
272
|
+
return NotImplemented
|
|
273
|
+
return hash(self) == hash(other)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@implements(Serializable, Allowable, Hashable, allow_inherited=True)
|
|
277
|
+
@dataclass(slots=True)
|
|
278
|
+
class DataClass(_SentinelMixin):
|
|
279
|
+
"""Mutable dataclass with sentinel-aware serialization.
|
|
280
|
+
|
|
281
|
+
Like Params but mutable (not frozen). Validates on __post_init__.
|
|
282
|
+
Subclass and override _config for custom behavior.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
_config: ClassVar[ModelConfig] = ModelConfig()
|
|
286
|
+
_allowed_keys: ClassVar[set[str]] = set()
|
|
287
|
+
|
|
288
|
+
def __post_init__(self):
|
|
289
|
+
"""Validate fields after initialization."""
|
|
290
|
+
self._validate()
|
|
291
|
+
|
|
292
|
+
def __hash__(self) -> int:
|
|
293
|
+
"""Hash based on serialized dict contents."""
|
|
294
|
+
return hash_obj(self)
|
|
295
|
+
|
|
296
|
+
def __eq__(self, other: object) -> bool:
|
|
297
|
+
"""Equality via hash. Returns NotImplemented for incompatible types."""
|
|
298
|
+
if not isinstance(other, DataClass):
|
|
299
|
+
return NotImplemented
|
|
300
|
+
return hash(self) == hash(other)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
KeysLike = Sequence[str] | KeysDict
|
|
304
|
+
"""Type alias for key specifications: sequence of names or KeysDict."""
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@implements(Hashable)
|
|
308
|
+
@dataclass(slots=True, frozen=True)
|
|
309
|
+
class Meta:
|
|
310
|
+
"""Immutable key-value metadata container.
|
|
311
|
+
|
|
312
|
+
Hashable for use in sets/dicts. Special handling for callables
|
|
313
|
+
(hashed by id for identity semantics).
|
|
314
|
+
|
|
315
|
+
Attributes:
|
|
316
|
+
key: Metadata key identifier.
|
|
317
|
+
value: Associated value (any type).
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
key: str
|
|
321
|
+
value: Any
|
|
322
|
+
|
|
323
|
+
@override
|
|
324
|
+
def __hash__(self) -> int:
|
|
325
|
+
"""Hash by (key, value). Callables use id(), unhashables use str()."""
|
|
326
|
+
if callable(self.value):
|
|
327
|
+
return hash((self.key, id(self.value)))
|
|
328
|
+
try:
|
|
329
|
+
return hash((self.key, self.value))
|
|
330
|
+
except TypeError:
|
|
331
|
+
return hash((self.key, str(self.value)))
|
|
332
|
+
|
|
333
|
+
@override
|
|
334
|
+
def __eq__(self, other: object) -> bool:
|
|
335
|
+
"""Equality by key then value. Callables compared by id."""
|
|
336
|
+
if not isinstance(other, Meta):
|
|
337
|
+
return NotImplemented
|
|
338
|
+
if self.key != other.key:
|
|
339
|
+
return False
|
|
340
|
+
if callable(self.value) and callable(other.value):
|
|
341
|
+
return id(self.value) == id(other.value)
|
|
342
|
+
return bool(self.value == other.value)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# --- Pydantic-based hashable model ---
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@implements(Hashable)
|
|
349
|
+
class HashableModel(_PydanticBaseModel):
|
|
350
|
+
"""Pydantic BaseModel with hash and equality support.
|
|
351
|
+
|
|
352
|
+
Provides content-based hashing for use in sets/dicts. Same semantics
|
|
353
|
+
as DataClass but for Pydantic models.
|
|
354
|
+
|
|
355
|
+
Usage:
|
|
356
|
+
class ServiceConfig(HashableModel):
|
|
357
|
+
provider: str
|
|
358
|
+
name: str
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
def __hash__(self) -> int:
|
|
362
|
+
"""Hash based on model's dict representation."""
|
|
363
|
+
return hash_obj(self.model_dump())
|
|
364
|
+
|
|
365
|
+
def __eq__(self, other: object) -> bool:
|
|
366
|
+
"""Equality via hash comparison."""
|
|
367
|
+
if not isinstance(other, HashableModel):
|
|
368
|
+
return NotImplemented
|
|
369
|
+
return hash(self) == hash(other)
|