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,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)