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
kronos/specs/spec.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
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
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
from collections import OrderedDict
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Annotated, Any, Self
|
|
13
|
+
|
|
14
|
+
from kronos.protocols import Hashable, implements
|
|
15
|
+
from kronos.types._sentinel import (
|
|
16
|
+
MaybeUndefined,
|
|
17
|
+
Undefined,
|
|
18
|
+
is_sentinel,
|
|
19
|
+
is_undefined,
|
|
20
|
+
not_sentinel,
|
|
21
|
+
)
|
|
22
|
+
from kronos.types.base import Enum, Meta
|
|
23
|
+
from kronos.utils.concurrency import is_coro_func
|
|
24
|
+
|
|
25
|
+
# Global cache for annotated types with bounded size
|
|
26
|
+
_MAX_CACHE_SIZE = int(os.environ.get("kron_FIELD_CACHE_SIZE", "10000"))
|
|
27
|
+
_annotated_cache: OrderedDict[tuple[type, tuple[Meta, ...]], type] = OrderedDict()
|
|
28
|
+
_cache_lock = threading.RLock() # Thread-safe access to cache
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
__all__ = ("CommonMeta", "Spec")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CommonMeta(Enum):
|
|
35
|
+
"""Standard metadata keys for Spec field configuration.
|
|
36
|
+
|
|
37
|
+
Keys:
|
|
38
|
+
NAME: Field identifier for serialization/composition
|
|
39
|
+
NULLABLE: Allows None values (becomes T | None)
|
|
40
|
+
LISTABLE: Wraps type in list[T]
|
|
41
|
+
VALIDATOR: Callable(s) for value validation
|
|
42
|
+
DEFAULT: Static default value
|
|
43
|
+
DEFAULT_FACTORY: Callable producing default (mutually exclusive with DEFAULT)
|
|
44
|
+
FROZEN: Marks field as immutable after creation
|
|
45
|
+
AS_FK: Foreign key target (str model name or type). When set,
|
|
46
|
+
annotated() includes FKMeta in the Annotated type.
|
|
47
|
+
|
|
48
|
+
Used by Spec to define field semantics in a framework-agnostic way.
|
|
49
|
+
Adapters translate these to framework-specific constructs.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
NAME = "name"
|
|
53
|
+
NULLABLE = "nullable"
|
|
54
|
+
LISTABLE = "listable"
|
|
55
|
+
VALIDATOR = "validator"
|
|
56
|
+
DEFAULT = "default"
|
|
57
|
+
DEFAULT_FACTORY = "default_factory"
|
|
58
|
+
FROZEN = "frozen"
|
|
59
|
+
AS_FK = "as_fk"
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def _validate_common_metas(cls, **kw):
|
|
63
|
+
"""Validate metadata constraints. Raises ExceptionGroup for multiple errors."""
|
|
64
|
+
errors: list[Exception] = []
|
|
65
|
+
|
|
66
|
+
if kw.get("default") and kw.get("default_factory"):
|
|
67
|
+
errors.append(ValueError("Cannot provide both 'default' and 'default_factory'"))
|
|
68
|
+
if (_df := kw.get("default_factory")) and not callable(_df):
|
|
69
|
+
errors.append(ValueError("'default_factory' must be callable"))
|
|
70
|
+
if _val := kw.get("validator"):
|
|
71
|
+
_val = [_val] if not isinstance(_val, list) else _val
|
|
72
|
+
if not all(callable(v) for v in _val):
|
|
73
|
+
errors.append(ValueError("Validators must be a list of functions or a function"))
|
|
74
|
+
|
|
75
|
+
if errors:
|
|
76
|
+
raise ExceptionGroup("Metadata validation failed", errors)
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def prepare(
|
|
80
|
+
cls, *args: Meta, metadata: tuple[Meta, ...] | None = None, **kw: Any
|
|
81
|
+
) -> tuple[Meta, ...]:
|
|
82
|
+
"""Prepare metadata tuple from args/kw. Validates no duplicates, constraints."""
|
|
83
|
+
# Lazy import to avoid circular dependency
|
|
84
|
+
from kronos.utils._to_list import to_list
|
|
85
|
+
|
|
86
|
+
seen_keys = set()
|
|
87
|
+
metas = []
|
|
88
|
+
|
|
89
|
+
if metadata:
|
|
90
|
+
for meta in metadata:
|
|
91
|
+
if meta.key in seen_keys:
|
|
92
|
+
raise ValueError(f"Duplicate metadata key: {meta.key}")
|
|
93
|
+
seen_keys.add(meta.key)
|
|
94
|
+
metas.append(meta)
|
|
95
|
+
|
|
96
|
+
if args:
|
|
97
|
+
_args = to_list(args, flatten=True, flatten_tuple_set=True, dropna=True)
|
|
98
|
+
for meta in _args:
|
|
99
|
+
if meta.key in seen_keys:
|
|
100
|
+
raise ValueError(f"Duplicate metadata key: {meta.key}")
|
|
101
|
+
seen_keys.add(meta.key)
|
|
102
|
+
metas.append(meta)
|
|
103
|
+
|
|
104
|
+
for k, v in kw.items():
|
|
105
|
+
if k in seen_keys:
|
|
106
|
+
raise ValueError(f"Duplicate metadata key: {k}")
|
|
107
|
+
seen_keys.add(k)
|
|
108
|
+
metas.append(Meta(k, v))
|
|
109
|
+
|
|
110
|
+
meta_dict = {m.key: m.value for m in metas}
|
|
111
|
+
cls._validate_common_metas(**meta_dict)
|
|
112
|
+
|
|
113
|
+
return tuple(metas)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@implements(Hashable)
|
|
117
|
+
@dataclass(frozen=True, slots=True, init=False)
|
|
118
|
+
class Spec:
|
|
119
|
+
"""Framework-agnostic field specification for type-safe data modeling.
|
|
120
|
+
|
|
121
|
+
Spec is the fundamental building block for defining typed fields that can be
|
|
122
|
+
translated to any target framework (Pydantic, SQL, dataclass, etc.) via adapters.
|
|
123
|
+
|
|
124
|
+
Design:
|
|
125
|
+
- Immutable: frozen dataclass ensures hashability and cacheability
|
|
126
|
+
- Composable: chain methods (as_nullable, with_default) for derived specs
|
|
127
|
+
- Adapter-agnostic: metadata interpreted by SpecAdapter implementations
|
|
128
|
+
|
|
129
|
+
Attributes:
|
|
130
|
+
base_type: The Python type (int, str, custom class, generic like list[str])
|
|
131
|
+
metadata: Tuple of Meta(key, value) pairs defining field semantics
|
|
132
|
+
|
|
133
|
+
Usage:
|
|
134
|
+
# Basic field
|
|
135
|
+
name_spec = Spec(str, name="username")
|
|
136
|
+
|
|
137
|
+
# With modifiers
|
|
138
|
+
tags_spec = Spec(str, name="tags").as_listable().as_nullable()
|
|
139
|
+
|
|
140
|
+
# With validation
|
|
141
|
+
age_spec = Spec(int, name="age", validator=lambda x: x >= 0)
|
|
142
|
+
|
|
143
|
+
# Convert to framework type
|
|
144
|
+
annotated_type = spec.annotated() # -> Annotated[str, Meta(...)]
|
|
145
|
+
|
|
146
|
+
Adapter Integration:
|
|
147
|
+
Specs are collected in Operable, then composed via adapter:
|
|
148
|
+
>>> op = Operable([name_spec, age_spec], adapter="pydantic")
|
|
149
|
+
>>> Model = op.compose_structure("User") # -> Pydantic BaseModel
|
|
150
|
+
|
|
151
|
+
See Also:
|
|
152
|
+
CommonMeta: Standard metadata keys
|
|
153
|
+
Operable: Spec collection with adapter interface
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
base_type: type
|
|
157
|
+
metadata: tuple[Meta, ...]
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
base_type: type | None = None,
|
|
162
|
+
*args,
|
|
163
|
+
metadata: tuple[Meta, ...] | None = None,
|
|
164
|
+
**kw,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Initialize Spec with type and metadata.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
base_type: Python type or type annotation (int, str, list[T], etc.)
|
|
170
|
+
*args: Meta objects to include in metadata
|
|
171
|
+
metadata: Pre-built metadata tuple (merged with args/kw)
|
|
172
|
+
**kw: Key-value pairs converted to Meta objects
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
ValueError: If base_type is not a valid type, name is invalid,
|
|
176
|
+
or conflicting defaults provided
|
|
177
|
+
"""
|
|
178
|
+
metas = CommonMeta.prepare(*args, metadata=metadata, **kw)
|
|
179
|
+
|
|
180
|
+
meta_dict = {m.key: m.value for m in metas}
|
|
181
|
+
if "name" in meta_dict:
|
|
182
|
+
name_value = meta_dict["name"]
|
|
183
|
+
if not isinstance(name_value, str) or not name_value:
|
|
184
|
+
raise ValueError("Spec name must be a non-empty string")
|
|
185
|
+
|
|
186
|
+
if not_sentinel(base_type, {"none"}):
|
|
187
|
+
import types
|
|
188
|
+
|
|
189
|
+
is_valid_type = (
|
|
190
|
+
isinstance(base_type, type)
|
|
191
|
+
or hasattr(base_type, "__origin__")
|
|
192
|
+
or isinstance(base_type, types.UnionType)
|
|
193
|
+
)
|
|
194
|
+
if not is_valid_type:
|
|
195
|
+
raise ValueError(f"base_type must be a type or type annotation, got {base_type}")
|
|
196
|
+
|
|
197
|
+
if kw.get("default_factory") and is_coro_func(kw["default_factory"]):
|
|
198
|
+
import warnings
|
|
199
|
+
|
|
200
|
+
warnings.warn(
|
|
201
|
+
"Async default factories are not yet fully supported by all adapters. "
|
|
202
|
+
"Consider using sync factories for compatibility.",
|
|
203
|
+
UserWarning,
|
|
204
|
+
stacklevel=2,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
object.__setattr__(self, "base_type", base_type)
|
|
208
|
+
object.__setattr__(self, "metadata", metas)
|
|
209
|
+
|
|
210
|
+
def __getitem__(self, key: str) -> Any:
|
|
211
|
+
"""Get metadata value by key.
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
KeyError: If key not found in metadata
|
|
215
|
+
"""
|
|
216
|
+
for meta in self.metadata:
|
|
217
|
+
if meta.key == key:
|
|
218
|
+
return meta.value
|
|
219
|
+
raise KeyError(f"Metadata key '{key}' undefined in Spec.")
|
|
220
|
+
|
|
221
|
+
def get(self, key: str, default: Any = Undefined) -> Any:
|
|
222
|
+
"""Get metadata value by key, returning default if not found."""
|
|
223
|
+
with contextlib.suppress(KeyError):
|
|
224
|
+
return self[key]
|
|
225
|
+
return default
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def name(self) -> MaybeUndefined[str]:
|
|
229
|
+
"""Get the field name from metadata."""
|
|
230
|
+
return self.get(CommonMeta.NAME.value)
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def is_nullable(self) -> bool:
|
|
234
|
+
"""Check if field is nullable."""
|
|
235
|
+
return self.get(CommonMeta.NULLABLE.value) is True
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def is_listable(self) -> bool:
|
|
239
|
+
"""Check if field is listable."""
|
|
240
|
+
return self.get(CommonMeta.LISTABLE.value) is True
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def default(self) -> MaybeUndefined[Any]:
|
|
244
|
+
"""Get default value or factory."""
|
|
245
|
+
return self.get(
|
|
246
|
+
CommonMeta.DEFAULT.value,
|
|
247
|
+
self.get(CommonMeta.DEFAULT_FACTORY.value),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def has_default_factory(self) -> bool:
|
|
252
|
+
"""Check if this spec has a default factory."""
|
|
253
|
+
return _is_factory(self.get(CommonMeta.DEFAULT_FACTORY.value))[0]
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def has_async_default_factory(self) -> bool:
|
|
257
|
+
"""Check if this spec has an async default factory."""
|
|
258
|
+
return _is_factory(self.get(CommonMeta.DEFAULT_FACTORY.value))[1]
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def is_frozen(self) -> bool:
|
|
262
|
+
"""Check if this spec is marked as frozen (immutable)."""
|
|
263
|
+
return self.get(CommonMeta.FROZEN.value) is True
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def is_fk(self) -> bool:
|
|
267
|
+
"""Check if this spec is marked as a foreign key."""
|
|
268
|
+
val = self.get(CommonMeta.AS_FK.value)
|
|
269
|
+
return not is_undefined(val) and val is not False
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def fk_target(self) -> MaybeUndefined[str | type]:
|
|
273
|
+
"""Get the FK target model reference (str name or type).
|
|
274
|
+
|
|
275
|
+
Resolution order:
|
|
276
|
+
1. Explicit target (str or type) from as_fk(target)
|
|
277
|
+
2. base_type itself if Observable (has UUID id)
|
|
278
|
+
3. Undefined otherwise
|
|
279
|
+
"""
|
|
280
|
+
val = self.get(CommonMeta.AS_FK.value)
|
|
281
|
+
if is_undefined(val) or val is False:
|
|
282
|
+
return Undefined
|
|
283
|
+
if val is not True:
|
|
284
|
+
return val
|
|
285
|
+
# as_fk=True: resolve from base_type if Observable
|
|
286
|
+
if isinstance(self.base_type, type) and _is_observable(self.base_type):
|
|
287
|
+
return self.base_type
|
|
288
|
+
return Undefined
|
|
289
|
+
|
|
290
|
+
def as_fk(self, target: str | type | None = None) -> Self:
|
|
291
|
+
"""Return new Spec marked as a foreign key.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
target: Referenced model (str name or type). When provided,
|
|
295
|
+
annotated() will include FKMeta(target) in the Annotated type.
|
|
296
|
+
If None and base_type is Observable, target resolves to base_type.
|
|
297
|
+
|
|
298
|
+
Example:
|
|
299
|
+
>>> Spec(UUID, name="user_id").as_fk("User")
|
|
300
|
+
>>> Spec(Person, name="person_id").as_fk() # target = Person
|
|
301
|
+
"""
|
|
302
|
+
return self.with_updates(as_fk=target if target is not None else True)
|
|
303
|
+
|
|
304
|
+
def as_frozen(self) -> Self:
|
|
305
|
+
"""Return new Spec with frozen=True metadata."""
|
|
306
|
+
return self.with_updates(frozen=True)
|
|
307
|
+
|
|
308
|
+
def create_default_value(self) -> Any:
|
|
309
|
+
"""Create default value (sync). Raises ValueError if no default or async factory."""
|
|
310
|
+
if is_undefined(self.default):
|
|
311
|
+
raise ValueError("No default value or factory defined in Spec.")
|
|
312
|
+
if self.has_async_default_factory:
|
|
313
|
+
raise ValueError(
|
|
314
|
+
"Default factory is asynchronous; cannot create default synchronously. "
|
|
315
|
+
"Use 'await spec.acreate_default_value()' instead."
|
|
316
|
+
)
|
|
317
|
+
if self.has_default_factory:
|
|
318
|
+
return self.default() # type: ignore[operator]
|
|
319
|
+
return self.default
|
|
320
|
+
|
|
321
|
+
async def acreate_default_value(self) -> Any:
|
|
322
|
+
"""Create default value (async). Handles both sync/async factories."""
|
|
323
|
+
if self.has_async_default_factory:
|
|
324
|
+
return await self.default() # type: ignore[operator]
|
|
325
|
+
return self.create_default_value()
|
|
326
|
+
|
|
327
|
+
def with_updates(self, **kw) -> Self:
|
|
328
|
+
"""Create new Spec with updated/added metadata keys. Sentinel values are excluded."""
|
|
329
|
+
_filtered = [meta for meta in self.metadata if meta.key not in kw]
|
|
330
|
+
for k, v in kw.items():
|
|
331
|
+
if not_sentinel(v):
|
|
332
|
+
_filtered.append(Meta(k, v))
|
|
333
|
+
_metas = tuple(_filtered)
|
|
334
|
+
return type(self)(self.base_type, metadata=_metas)
|
|
335
|
+
|
|
336
|
+
def as_nullable(self) -> Self:
|
|
337
|
+
"""Return new Spec with nullable=True (allows None values)."""
|
|
338
|
+
return self.with_updates(nullable=True)
|
|
339
|
+
|
|
340
|
+
def as_listable(self) -> Self:
|
|
341
|
+
"""Return new Spec with listable=True (wraps type in list[T])."""
|
|
342
|
+
return self.with_updates(listable=True)
|
|
343
|
+
|
|
344
|
+
def as_optional(self) -> Self:
|
|
345
|
+
"""Return new Spec that is nullable with default=None."""
|
|
346
|
+
return self.as_nullable().with_default(None)
|
|
347
|
+
|
|
348
|
+
def with_default(self, default: Any) -> Self:
|
|
349
|
+
"""Return new Spec with default. Callables become default_factory."""
|
|
350
|
+
if callable(default):
|
|
351
|
+
return self.with_updates(default_factory=default)
|
|
352
|
+
return self.with_updates(default=default)
|
|
353
|
+
|
|
354
|
+
@classmethod
|
|
355
|
+
def from_model(
|
|
356
|
+
cls,
|
|
357
|
+
model: type,
|
|
358
|
+
name: str | None = None,
|
|
359
|
+
*,
|
|
360
|
+
nullable: bool = False,
|
|
361
|
+
listable: bool = False,
|
|
362
|
+
default: Any = Undefined,
|
|
363
|
+
) -> Self:
|
|
364
|
+
"""Create Spec from a model class (e.g., Pydantic BaseModel).
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
model: The model class to use as base_type
|
|
368
|
+
name: Field name (defaults to lowercase class name)
|
|
369
|
+
nullable: Whether field is nullable
|
|
370
|
+
listable: Whether field is a list
|
|
371
|
+
default: Default value (Undefined means no default)
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Spec configured for the model type
|
|
375
|
+
|
|
376
|
+
Example:
|
|
377
|
+
>>> Spec.from_model(ProgressReport) # name="progressreport"
|
|
378
|
+
>>> Spec.from_model(CodeBlock, name="blocks", listable=True, nullable=True)
|
|
379
|
+
"""
|
|
380
|
+
field_name = name if name is not None else model.__name__.lower()
|
|
381
|
+
spec = cls(base_type=model, name=field_name)
|
|
382
|
+
|
|
383
|
+
if listable:
|
|
384
|
+
spec = spec.as_listable()
|
|
385
|
+
if nullable:
|
|
386
|
+
spec = spec.as_nullable()
|
|
387
|
+
if not_sentinel(default):
|
|
388
|
+
spec = spec.with_default(default)
|
|
389
|
+
|
|
390
|
+
return spec
|
|
391
|
+
|
|
392
|
+
def with_validator(self, validator: Callable[..., Any] | list[Callable[..., Any]]) -> Self:
|
|
393
|
+
"""Return new Spec with validator function(s) attached."""
|
|
394
|
+
return self.with_updates(validator=validator)
|
|
395
|
+
|
|
396
|
+
@property
|
|
397
|
+
def annotation(self) -> type[Any]:
|
|
398
|
+
"""Type annotation with fk/listable/nullable modifiers applied.
|
|
399
|
+
|
|
400
|
+
When FK target resolves, produces FK[target] = Annotated[UUID, FKMeta(target)].
|
|
401
|
+
Order: FK[target] -> list[T] -> T | None
|
|
402
|
+
"""
|
|
403
|
+
if is_sentinel(self.base_type, {"none"}):
|
|
404
|
+
return Any
|
|
405
|
+
t_ = self.base_type # type: ignore[valid-type]
|
|
406
|
+
fk = self.fk_target
|
|
407
|
+
if not is_undefined(fk):
|
|
408
|
+
from uuid import UUID
|
|
409
|
+
|
|
410
|
+
from kronos.types.db_types import FKMeta
|
|
411
|
+
|
|
412
|
+
t_ = Annotated[UUID, FKMeta(fk)] # type: ignore[valid-type]
|
|
413
|
+
if self.is_listable:
|
|
414
|
+
t_ = list[t_] # type: ignore[valid-type]
|
|
415
|
+
if self.is_nullable:
|
|
416
|
+
t_ = t_ | None # type: ignore[assignment]
|
|
417
|
+
return t_ # type: ignore[return-value]
|
|
418
|
+
|
|
419
|
+
def annotated(self) -> type[Any]:
|
|
420
|
+
"""Create Annotated[base_type, metadata...] with thread-safe LRU cache.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
Annotated type with metadata attached, suitable for Pydantic/dataclass fields.
|
|
424
|
+
Nullable specs produce T | None annotation.
|
|
425
|
+
FK specs produce Annotated[UUID, ..., FKMeta(target)] when target resolves.
|
|
426
|
+
"""
|
|
427
|
+
cache_key = (self.base_type, self.metadata)
|
|
428
|
+
|
|
429
|
+
with _cache_lock:
|
|
430
|
+
if cache_key in _annotated_cache:
|
|
431
|
+
_annotated_cache.move_to_end(cache_key)
|
|
432
|
+
return _annotated_cache[cache_key]
|
|
433
|
+
|
|
434
|
+
actual_type = Any if is_sentinel(self.base_type, {"none"}) else self.base_type
|
|
435
|
+
current_metadata = self.metadata
|
|
436
|
+
|
|
437
|
+
# Resolve FK target (explicit or Observable base_type)
|
|
438
|
+
extra_annotations: list[Any] = []
|
|
439
|
+
resolved_fk = self.fk_target
|
|
440
|
+
if not is_undefined(resolved_fk):
|
|
441
|
+
from uuid import UUID
|
|
442
|
+
|
|
443
|
+
from kronos.types.db_types import FKMeta
|
|
444
|
+
|
|
445
|
+
actual_type = UUID # FK fields are UUID references
|
|
446
|
+
extra_annotations.append(FKMeta(resolved_fk))
|
|
447
|
+
|
|
448
|
+
if any(m.key == "nullable" and m.value for m in current_metadata):
|
|
449
|
+
actual_type = actual_type | None # type: ignore
|
|
450
|
+
|
|
451
|
+
if current_metadata or extra_annotations:
|
|
452
|
+
args = [actual_type, *list(current_metadata), *extra_annotations]
|
|
453
|
+
# Python 3.11-3.12 vs 3.13+ compatibility
|
|
454
|
+
try:
|
|
455
|
+
result = Annotated.__class_getitem__(tuple(args)) # type: ignore
|
|
456
|
+
except AttributeError:
|
|
457
|
+
import operator
|
|
458
|
+
|
|
459
|
+
result = operator.getitem(Annotated, tuple(args)) # type: ignore
|
|
460
|
+
else:
|
|
461
|
+
result = actual_type # type: ignore[misc]
|
|
462
|
+
|
|
463
|
+
_annotated_cache[cache_key] = result # type: ignore[assignment]
|
|
464
|
+
|
|
465
|
+
while len(_annotated_cache) > _MAX_CACHE_SIZE:
|
|
466
|
+
_annotated_cache.popitem(last=False)
|
|
467
|
+
|
|
468
|
+
return result # type: ignore[return-value]
|
|
469
|
+
|
|
470
|
+
def metadict(
|
|
471
|
+
self, exclude: set[str] | None = None, exclude_common: bool = False
|
|
472
|
+
) -> dict[str, Any]:
|
|
473
|
+
"""Convert metadata to dict, optionally excluding keys or CommonMeta keys."""
|
|
474
|
+
if exclude is None:
|
|
475
|
+
exclude = set()
|
|
476
|
+
if exclude_common:
|
|
477
|
+
exclude = exclude | set(CommonMeta.allowed())
|
|
478
|
+
return {meta.key: meta.value for meta in self.metadata if meta.key not in exclude}
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _is_observable(cls: type) -> bool:
|
|
482
|
+
"""Check if a type satisfies the Observable protocol (has UUID id property).
|
|
483
|
+
|
|
484
|
+
Uses structural check rather than issubclass() for Python 3.11 compatibility
|
|
485
|
+
with runtime_checkable protocols that have property members.
|
|
486
|
+
"""
|
|
487
|
+
id_attr = getattr(cls, "id", None)
|
|
488
|
+
return isinstance(id_attr, property) or (
|
|
489
|
+
hasattr(cls, "__annotations__") and "id" in getattr(cls, "__annotations__", {})
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _is_factory(obj: Any) -> tuple[bool, bool]:
|
|
494
|
+
"""Check if object is a factory function.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
obj: Object to check
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
Tuple of (is_factory, is_async)
|
|
501
|
+
"""
|
|
502
|
+
if not callable(obj):
|
|
503
|
+
return (False, False)
|
|
504
|
+
if is_coro_func(obj):
|
|
505
|
+
return (True, True)
|
|
506
|
+
return (True, False)
|
kronos/types/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from ._sentinel import (
|
|
5
|
+
MaybeSentinel,
|
|
6
|
+
MaybeUndefined,
|
|
7
|
+
MaybeUnset,
|
|
8
|
+
SingletonType,
|
|
9
|
+
T,
|
|
10
|
+
Undefined,
|
|
11
|
+
UndefinedType,
|
|
12
|
+
Unset,
|
|
13
|
+
UnsetType,
|
|
14
|
+
is_sentinel,
|
|
15
|
+
is_undefined,
|
|
16
|
+
is_unset,
|
|
17
|
+
not_sentinel,
|
|
18
|
+
)
|
|
19
|
+
from .base import (
|
|
20
|
+
DataClass,
|
|
21
|
+
Enum,
|
|
22
|
+
HashableModel,
|
|
23
|
+
KeysDict,
|
|
24
|
+
KeysLike,
|
|
25
|
+
Meta,
|
|
26
|
+
ModelConfig,
|
|
27
|
+
Params,
|
|
28
|
+
)
|
|
29
|
+
from .db_types import FK, FKMeta, Vector, VectorMeta, extract_kron_db_meta
|
|
30
|
+
from .identity import ID
|
|
31
|
+
|
|
32
|
+
__all__ = (
|
|
33
|
+
"MaybeSentinel",
|
|
34
|
+
"MaybeUndefined",
|
|
35
|
+
"MaybeUnset",
|
|
36
|
+
"SingletonType",
|
|
37
|
+
"T",
|
|
38
|
+
"Undefined",
|
|
39
|
+
"UndefinedType",
|
|
40
|
+
"Unset",
|
|
41
|
+
"UnsetType",
|
|
42
|
+
"is_sentinel",
|
|
43
|
+
"is_undefined",
|
|
44
|
+
"is_unset",
|
|
45
|
+
"not_sentinel",
|
|
46
|
+
"DataClass",
|
|
47
|
+
"Enum",
|
|
48
|
+
"HashableModel",
|
|
49
|
+
"KeysDict",
|
|
50
|
+
"KeysLike",
|
|
51
|
+
"Meta",
|
|
52
|
+
"ModelConfig",
|
|
53
|
+
"Params",
|
|
54
|
+
"FK",
|
|
55
|
+
"FKMeta",
|
|
56
|
+
"Vector",
|
|
57
|
+
"VectorMeta",
|
|
58
|
+
"extract_kron_db_meta",
|
|
59
|
+
"ID",
|
|
60
|
+
)
|