lionherd-core 1.0.0a3__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.
- lionherd_core/__init__.py +84 -0
- lionherd_core/base/__init__.py +30 -0
- lionherd_core/base/_utils.py +295 -0
- lionherd_core/base/broadcaster.py +128 -0
- lionherd_core/base/element.py +300 -0
- lionherd_core/base/event.py +322 -0
- lionherd_core/base/eventbus.py +112 -0
- lionherd_core/base/flow.py +236 -0
- lionherd_core/base/graph.py +616 -0
- lionherd_core/base/node.py +212 -0
- lionherd_core/base/pile.py +811 -0
- lionherd_core/base/progression.py +261 -0
- lionherd_core/errors.py +104 -0
- lionherd_core/libs/__init__.py +2 -0
- lionherd_core/libs/concurrency/__init__.py +60 -0
- lionherd_core/libs/concurrency/_cancel.py +85 -0
- lionherd_core/libs/concurrency/_errors.py +80 -0
- lionherd_core/libs/concurrency/_patterns.py +238 -0
- lionherd_core/libs/concurrency/_primitives.py +253 -0
- lionherd_core/libs/concurrency/_priority_queue.py +135 -0
- lionherd_core/libs/concurrency/_resource_tracker.py +66 -0
- lionherd_core/libs/concurrency/_task.py +58 -0
- lionherd_core/libs/concurrency/_utils.py +61 -0
- lionherd_core/libs/schema_handlers/__init__.py +35 -0
- lionherd_core/libs/schema_handlers/_function_call_parser.py +122 -0
- lionherd_core/libs/schema_handlers/_minimal_yaml.py +88 -0
- lionherd_core/libs/schema_handlers/_schema_to_model.py +251 -0
- lionherd_core/libs/schema_handlers/_typescript.py +153 -0
- lionherd_core/libs/string_handlers/__init__.py +15 -0
- lionherd_core/libs/string_handlers/_extract_json.py +65 -0
- lionherd_core/libs/string_handlers/_fuzzy_json.py +103 -0
- lionherd_core/libs/string_handlers/_string_similarity.py +347 -0
- lionherd_core/libs/string_handlers/_to_num.py +63 -0
- lionherd_core/ln/__init__.py +45 -0
- lionherd_core/ln/_async_call.py +314 -0
- lionherd_core/ln/_fuzzy_match.py +166 -0
- lionherd_core/ln/_fuzzy_validate.py +151 -0
- lionherd_core/ln/_hash.py +141 -0
- lionherd_core/ln/_json_dump.py +347 -0
- lionherd_core/ln/_list_call.py +110 -0
- lionherd_core/ln/_to_dict.py +373 -0
- lionherd_core/ln/_to_list.py +190 -0
- lionherd_core/ln/_utils.py +156 -0
- lionherd_core/lndl/__init__.py +62 -0
- lionherd_core/lndl/errors.py +30 -0
- lionherd_core/lndl/fuzzy.py +321 -0
- lionherd_core/lndl/parser.py +427 -0
- lionherd_core/lndl/prompt.py +137 -0
- lionherd_core/lndl/resolver.py +323 -0
- lionherd_core/lndl/types.py +287 -0
- lionherd_core/protocols.py +181 -0
- lionherd_core/py.typed +0 -0
- lionherd_core/types/__init__.py +46 -0
- lionherd_core/types/_sentinel.py +131 -0
- lionherd_core/types/base.py +341 -0
- lionherd_core/types/operable.py +133 -0
- lionherd_core/types/spec.py +313 -0
- lionherd_core/types/spec_adapters/__init__.py +10 -0
- lionherd_core/types/spec_adapters/_protocol.py +125 -0
- lionherd_core/types/spec_adapters/pydantic_field.py +177 -0
- lionherd_core-1.0.0a3.dist-info/METADATA +502 -0
- lionherd_core-1.0.0a3.dist-info/RECORD +64 -0
- lionherd_core-1.0.0a3.dist-info/WHEEL +4 -0
- lionherd_core-1.0.0a3.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from typing import Any, Protocol, runtime_checkable
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
__all__ = (
|
|
8
|
+
"Adaptable",
|
|
9
|
+
"Allowable",
|
|
10
|
+
"AsyncAdaptable",
|
|
11
|
+
"Containable",
|
|
12
|
+
"Deserializable",
|
|
13
|
+
"Hashable",
|
|
14
|
+
"Invocable",
|
|
15
|
+
"Observable",
|
|
16
|
+
"Serializable",
|
|
17
|
+
"implements",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class ObservableProto(Protocol):
|
|
23
|
+
"""Objects with unique UUID identifier. Check via isinstance()."""
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def id(self) -> UUID:
|
|
27
|
+
"""Unique identifier."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@runtime_checkable
|
|
32
|
+
class Serializable(Protocol):
|
|
33
|
+
"""Objects that can be serialized to dict via to_dict()."""
|
|
34
|
+
|
|
35
|
+
def to_dict(self, **kwargs: Any) -> dict[str, Any]:
|
|
36
|
+
"""Serialize to dict. Args: serialization options (mode, format, etc.)."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@runtime_checkable
|
|
41
|
+
class Deserializable(Protocol):
|
|
42
|
+
"""Objects that can be deserialized from dict via from_dict() classmethod."""
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_dict(cls, data: dict[str, Any], **kwargs: Any) -> Any:
|
|
46
|
+
"""Deserialize from dict. Args: data dict, deserialization options."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@runtime_checkable
|
|
51
|
+
class Adaptable(Protocol):
|
|
52
|
+
"""Sync adapter protocol for format conversion (TOML/YAML/JSON/SQL). Use AsyncAdaptable for async."""
|
|
53
|
+
|
|
54
|
+
def adapt_to(self, obj_key: str, many: bool = False, **kwargs: Any) -> Any:
|
|
55
|
+
"""Convert to external format. Args: adapter key, many flag, adapter kwargs."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def adapt_from(cls, obj: Any, obj_key: str, many: bool = False, **kwargs: Any) -> Any:
|
|
60
|
+
"""Create from external format. Args: source object, adapter key, many flag."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def register_adapter(cls, adapter: Any) -> None:
|
|
65
|
+
"""Register adapter for this class."""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@runtime_checkable
|
|
70
|
+
class AsyncAdaptable(Protocol):
|
|
71
|
+
"""Async adapter protocol for I/O-bound format conversion (DBs, network, files). Use Adaptable for sync."""
|
|
72
|
+
|
|
73
|
+
async def adapt_to_async(self, obj_key: str, many: bool = False, **kwargs: Any) -> Any:
|
|
74
|
+
"""Async convert to external format. Args: adapter key, many flag, adapter kwargs."""
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
async def adapt_from_async(
|
|
79
|
+
cls, obj: Any, obj_key: str, many: bool = False, **kwargs: Any
|
|
80
|
+
) -> Any:
|
|
81
|
+
"""Async create from external format. Args: source object, adapter key, many flag."""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def register_async_adapter(cls, adapter: Any) -> None:
|
|
86
|
+
"""Register async adapter for this class."""
|
|
87
|
+
...
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@runtime_checkable
|
|
91
|
+
class Containable(Protocol):
|
|
92
|
+
"""Objects that support membership testing via 'in' operator (__contains__)."""
|
|
93
|
+
|
|
94
|
+
def __contains__(self, item: Any) -> bool:
|
|
95
|
+
"""Check if item is in collection (by UUID or instance)."""
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@runtime_checkable
|
|
100
|
+
class Invocable(Protocol):
|
|
101
|
+
"""Objects that can be invoked/executed via async invoke() method."""
|
|
102
|
+
|
|
103
|
+
async def invoke(self) -> Any:
|
|
104
|
+
"""Invoke/execute the object. Returns: execution result (any value or None)."""
|
|
105
|
+
...
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@runtime_checkable
|
|
109
|
+
class Hashable(Protocol):
|
|
110
|
+
"""Objects that can be hashed via __hash__() for use in sets/dicts."""
|
|
111
|
+
|
|
112
|
+
def __hash__(self) -> int:
|
|
113
|
+
"""Return hash value for object (must be immutable or ID-based)."""
|
|
114
|
+
...
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@runtime_checkable
|
|
118
|
+
class Allowable(Protocol):
|
|
119
|
+
"""Objects with defined set of allowed values/keys via allowed()."""
|
|
120
|
+
|
|
121
|
+
def allowed(self) -> set[str]:
|
|
122
|
+
"""Return set of allowed keys/values."""
|
|
123
|
+
...
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
Observable = ObservableProto
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def implements(*protocols: type):
|
|
130
|
+
"""Declare protocol implementations (Rust-like: MUST define in class body).
|
|
131
|
+
|
|
132
|
+
CRITICAL SEMANTICS (strictest interpretation):
|
|
133
|
+
@implements() means the class **LITERALLY** implements/overrides the method
|
|
134
|
+
or declares the attribute IN ITS OWN CLASS BODY. Inheritance does NOT count.
|
|
135
|
+
|
|
136
|
+
This is Rust-like trait implementation: you must provide the implementation
|
|
137
|
+
in the impl block, not rely on inheritance.
|
|
138
|
+
|
|
139
|
+
Rules:
|
|
140
|
+
- Method must be defined in class body (even if it calls super())
|
|
141
|
+
- Property must be declared in class body (cannot inherit from parent)
|
|
142
|
+
- Classmethod must be defined in class body
|
|
143
|
+
- NO inheritance: @implements means "I define this, not my parent"
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
*protocols: Protocol classes that the decorated class **literally** implements
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Class decorator that stores protocols on cls.__protocols__
|
|
150
|
+
|
|
151
|
+
Usage:
|
|
152
|
+
✓ CORRECT: Literal implementation
|
|
153
|
+
@implements(Serializable, Deserializable)
|
|
154
|
+
class MyClass:
|
|
155
|
+
def to_dict(self, **kwargs): ... # Defined in this class
|
|
156
|
+
@classmethod
|
|
157
|
+
def from_dict(cls, data, **kwargs): ... # Defined in this class
|
|
158
|
+
|
|
159
|
+
✗ WRONG: Relying on inheritance
|
|
160
|
+
@implements(Serializable) # VIOLATION!
|
|
161
|
+
class Child(Parent): # Parent has to_dict()
|
|
162
|
+
pass # No to_dict in Child body → not allowed!
|
|
163
|
+
|
|
164
|
+
✓ CORRECT: Explicit override
|
|
165
|
+
@implements(Serializable)
|
|
166
|
+
class Child(Parent):
|
|
167
|
+
def to_dict(self, **kwargs): # Explicit in Child body
|
|
168
|
+
return super().to_dict(**kwargs) # Can call parent
|
|
169
|
+
|
|
170
|
+
Rationale:
|
|
171
|
+
- Explicit > Implicit (Rust philosophy)
|
|
172
|
+
- Clear ownership: each class declares what it implements
|
|
173
|
+
- No ambiguity about where implementation lives
|
|
174
|
+
- Prevents accidental protocol claims through inheritance
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def decorator(cls):
|
|
178
|
+
cls.__protocols__ = protocols
|
|
179
|
+
return cls
|
|
180
|
+
|
|
181
|
+
return decorator
|
lionherd_core/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Copyright (c) 2025, 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
|
+
not_sentinel,
|
|
16
|
+
)
|
|
17
|
+
from .base import DataClass, Enum, KeysDict, KeysLike, Meta, ModelConfig, Params
|
|
18
|
+
from .operable import Operable
|
|
19
|
+
from .spec import CommonMeta, Spec
|
|
20
|
+
|
|
21
|
+
__all__ = (
|
|
22
|
+
"CommonMeta",
|
|
23
|
+
"DataClass",
|
|
24
|
+
"Enum",
|
|
25
|
+
"KeysDict",
|
|
26
|
+
"KeysLike",
|
|
27
|
+
"MaybeSentinel",
|
|
28
|
+
"MaybeUndefined",
|
|
29
|
+
"MaybeUnset",
|
|
30
|
+
"Meta",
|
|
31
|
+
# Base classes
|
|
32
|
+
"ModelConfig",
|
|
33
|
+
"Operable",
|
|
34
|
+
"Params",
|
|
35
|
+
"SingletonType",
|
|
36
|
+
# Spec system
|
|
37
|
+
"Spec",
|
|
38
|
+
"T",
|
|
39
|
+
# Sentinel types
|
|
40
|
+
"Undefined",
|
|
41
|
+
"UndefinedType",
|
|
42
|
+
"Unset",
|
|
43
|
+
"UnsetType",
|
|
44
|
+
"is_sentinel",
|
|
45
|
+
"not_sentinel",
|
|
46
|
+
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, ClassVar, Final, Literal, Self, TypeAlias, TypeGuard, TypeVar
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"MaybeSentinel",
|
|
10
|
+
"MaybeUndefined",
|
|
11
|
+
"MaybeUnset",
|
|
12
|
+
"SingletonType",
|
|
13
|
+
"T",
|
|
14
|
+
"Undefined",
|
|
15
|
+
"UndefinedType",
|
|
16
|
+
"Unset",
|
|
17
|
+
"UnsetType",
|
|
18
|
+
"is_sentinel",
|
|
19
|
+
"not_sentinel",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _SingletonMeta(type):
|
|
26
|
+
"""Metaclass: ensures one instance per subclass for safe 'is' checks."""
|
|
27
|
+
|
|
28
|
+
_cache: ClassVar[dict[type, SingletonType]] = {}
|
|
29
|
+
|
|
30
|
+
def __call__(cls, *a, **kw):
|
|
31
|
+
if cls not in cls._cache:
|
|
32
|
+
cls._cache[cls] = super().__call__(*a, **kw)
|
|
33
|
+
return cls._cache[cls]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SingletonType(metaclass=_SingletonMeta):
|
|
37
|
+
"""Base for singleton sentinels. Falsy, identity-preserving across copy/deepcopy."""
|
|
38
|
+
|
|
39
|
+
__slots__: tuple[str, ...] = ()
|
|
40
|
+
|
|
41
|
+
def __deepcopy__(self, memo: dict[int, Any]) -> Self:
|
|
42
|
+
"""Preserve singleton identity across deepcopy."""
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
def __copy__(self) -> Self:
|
|
46
|
+
"""Preserve singleton identity across copy."""
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
# concrete classes *must* override the two methods below
|
|
50
|
+
def __bool__(self) -> bool:
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
def __repr__(self) -> str:
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class UndefinedType(SingletonType):
|
|
58
|
+
"""Sentinel: field/key entirely missing from namespace. Use for missing keys, never-set fields."""
|
|
59
|
+
|
|
60
|
+
__slots__ = ()
|
|
61
|
+
|
|
62
|
+
def __bool__(self) -> Literal[False]:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
def __repr__(self) -> Literal["Undefined"]:
|
|
66
|
+
return "Undefined"
|
|
67
|
+
|
|
68
|
+
def __str__(self) -> Literal["Undefined"]:
|
|
69
|
+
return "Undefined"
|
|
70
|
+
|
|
71
|
+
def __reduce__(self) -> tuple[type[UndefinedType], tuple[()]]:
|
|
72
|
+
"""Preserve singleton identity across pickle/unpickle."""
|
|
73
|
+
return (UndefinedType, ())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class UnsetType(SingletonType):
|
|
77
|
+
"""Sentinel: key present but value not provided. Use to distinguish None from 'not provided'."""
|
|
78
|
+
|
|
79
|
+
__slots__ = ()
|
|
80
|
+
|
|
81
|
+
def __bool__(self) -> Literal[False]:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
def __repr__(self) -> Literal["Unset"]:
|
|
85
|
+
return "Unset"
|
|
86
|
+
|
|
87
|
+
def __str__(self) -> Literal["Unset"]:
|
|
88
|
+
return "Unset"
|
|
89
|
+
|
|
90
|
+
def __reduce__(self) -> tuple[type[UnsetType], tuple[()]]:
|
|
91
|
+
"""Preserve singleton identity across pickle/unpickle."""
|
|
92
|
+
return (UnsetType, ())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
Undefined: Final[UndefinedType] = UndefinedType()
|
|
96
|
+
"""A key or field entirely missing from a namespace"""
|
|
97
|
+
Unset: Final[UnsetType] = UnsetType()
|
|
98
|
+
"""A key present but value not yet provided."""
|
|
99
|
+
|
|
100
|
+
MaybeUndefined: TypeAlias = T | UndefinedType
|
|
101
|
+
MaybeUnset: TypeAlias = T | UnsetType
|
|
102
|
+
MaybeSentinel: TypeAlias = T | UndefinedType | UnsetType
|
|
103
|
+
|
|
104
|
+
_EMPTY_TUPLE: tuple[Any, ...] = (tuple(), set(), frozenset(), dict(), list(), "")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def is_sentinel(
|
|
108
|
+
value: Any,
|
|
109
|
+
*,
|
|
110
|
+
none_as_sentinel: bool = False,
|
|
111
|
+
empty_as_sentinel: bool = False,
|
|
112
|
+
) -> bool:
|
|
113
|
+
"""Check if a value is any sentinel (Undefined or Unset)."""
|
|
114
|
+
if none_as_sentinel and value is None:
|
|
115
|
+
return True
|
|
116
|
+
if empty_as_sentinel and value in _EMPTY_TUPLE:
|
|
117
|
+
return True
|
|
118
|
+
return value is Undefined or value is Unset
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def not_sentinel(
|
|
122
|
+
value: T | UndefinedType | UnsetType,
|
|
123
|
+
none_as_sentinel: bool = False,
|
|
124
|
+
empty_as_sentinel: bool = False,
|
|
125
|
+
) -> TypeGuard[T]:
|
|
126
|
+
"""Type-narrowing check: NOT a sentinel. Narrows MaybeSentinel[T] to T for type checkers."""
|
|
127
|
+
return not is_sentinel(
|
|
128
|
+
value,
|
|
129
|
+
none_as_sentinel=none_as_sentinel,
|
|
130
|
+
empty_as_sentinel=empty_as_sentinel,
|
|
131
|
+
)
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import MutableMapping, MutableSequence, MutableSet, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import (
|
|
9
|
+
Enum as _Enum,
|
|
10
|
+
StrEnum,
|
|
11
|
+
)
|
|
12
|
+
from typing import Any, ClassVar, Literal, Self, TypedDict
|
|
13
|
+
|
|
14
|
+
from typing_extensions import override
|
|
15
|
+
|
|
16
|
+
from ..protocols import Allowable, Hashable, Serializable, implements
|
|
17
|
+
from ._sentinel import Undefined, Unset, is_sentinel
|
|
18
|
+
|
|
19
|
+
__all__ = (
|
|
20
|
+
"DataClass",
|
|
21
|
+
"Enum",
|
|
22
|
+
"KeysDict",
|
|
23
|
+
"KeysLike",
|
|
24
|
+
"Meta",
|
|
25
|
+
"ModelConfig",
|
|
26
|
+
"Params",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@implements(Allowable)
|
|
31
|
+
class Enum(StrEnum):
|
|
32
|
+
"""String-backed enum (Python 3.11+). Members are strings, support JSON serialization."""
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def allowed(cls) -> tuple[str, ...]:
|
|
36
|
+
"""Return tuple of all allowed string values."""
|
|
37
|
+
return tuple(e.value for e in cls)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class KeysDict(TypedDict, total=False):
|
|
41
|
+
"""TypedDict for keys dictionary."""
|
|
42
|
+
|
|
43
|
+
key: Any # Represents any key-type pair
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True, frozen=True)
|
|
47
|
+
class ModelConfig:
|
|
48
|
+
"""Config for Params/DataClass: sentinel handling, validation, serialization."""
|
|
49
|
+
|
|
50
|
+
# Sentinel handling (controls what gets excluded from to_dict)
|
|
51
|
+
none_as_sentinel: bool = False
|
|
52
|
+
empty_as_sentinel: bool = False
|
|
53
|
+
|
|
54
|
+
# Validation
|
|
55
|
+
strict: bool = False
|
|
56
|
+
prefill_unset: bool = True
|
|
57
|
+
|
|
58
|
+
# Serialization
|
|
59
|
+
use_enum_values: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@implements(Serializable, Allowable, Hashable)
|
|
63
|
+
@dataclass(slots=True, frozen=True, init=False)
|
|
64
|
+
class Params:
|
|
65
|
+
"""Base for function parameters with sentinel handling. Configure via _config."""
|
|
66
|
+
|
|
67
|
+
_config: ClassVar[ModelConfig] = ModelConfig()
|
|
68
|
+
_allowed_keys: ClassVar[set[str]] = set()
|
|
69
|
+
|
|
70
|
+
def __init__(self, **kwargs: Any):
|
|
71
|
+
"""Init from kwargs. Validates and sets attributes."""
|
|
72
|
+
# Set all attributes from kwargs, allowing for sentinel values
|
|
73
|
+
for k, v in kwargs.items():
|
|
74
|
+
if k in self.allowed():
|
|
75
|
+
object.__setattr__(self, k, v)
|
|
76
|
+
else:
|
|
77
|
+
raise ValueError(f"Invalid parameter: {k}")
|
|
78
|
+
|
|
79
|
+
# Validate after setting all attributes
|
|
80
|
+
self._validate()
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def _is_sentinel(cls, value: Any) -> bool:
|
|
84
|
+
"""Check if value is sentinel (respects config)."""
|
|
85
|
+
return is_sentinel(
|
|
86
|
+
value,
|
|
87
|
+
none_as_sentinel=cls._config.none_as_sentinel,
|
|
88
|
+
empty_as_sentinel=cls._config.empty_as_sentinel,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def _normalize_value(cls, value: Any) -> Any:
|
|
93
|
+
"""Normalize value for serialization (enum handling, etc.)."""
|
|
94
|
+
if cls._config.use_enum_values and isinstance(value, _Enum):
|
|
95
|
+
return value.value
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def allowed(cls) -> set[str]:
|
|
100
|
+
"""Return the keys of the parameters."""
|
|
101
|
+
if cls._allowed_keys:
|
|
102
|
+
return cls._allowed_keys
|
|
103
|
+
cls._allowed_keys = {i for i in cls.__dataclass_fields__ if not i.startswith("_")}
|
|
104
|
+
return cls._allowed_keys
|
|
105
|
+
|
|
106
|
+
def _validate(self) -> None:
|
|
107
|
+
"""Validate params. Collects errors in ExceptionGroup. Prefills unset if configured."""
|
|
108
|
+
missing: list[Exception] = []
|
|
109
|
+
for k in self.allowed():
|
|
110
|
+
if self._config.strict and self._is_sentinel(getattr(self, k, Unset)):
|
|
111
|
+
missing.append(ValueError(f"Missing required parameter: {k}"))
|
|
112
|
+
if self._config.prefill_unset and getattr(self, k, Undefined) is Undefined:
|
|
113
|
+
object.__setattr__(self, k, Unset)
|
|
114
|
+
if missing:
|
|
115
|
+
raise ExceptionGroup("Missing required parameters", missing)
|
|
116
|
+
|
|
117
|
+
def default_kw(self) -> Any:
|
|
118
|
+
# create a partial function with the current parameters
|
|
119
|
+
dict_ = self.to_dict()
|
|
120
|
+
|
|
121
|
+
# handle kwargs if present, handle both 'kwargs' and 'kw'
|
|
122
|
+
kw_ = {}
|
|
123
|
+
kw_.update(dict_.pop("kwargs", {}))
|
|
124
|
+
kw_.update(dict_.pop("kw", {}))
|
|
125
|
+
dict_.update(kw_)
|
|
126
|
+
return dict_
|
|
127
|
+
|
|
128
|
+
def to_dict(self, exclude: set[str] | None = None) -> dict[str, Any]:
|
|
129
|
+
data = {}
|
|
130
|
+
exclude = exclude or set()
|
|
131
|
+
for k in self.allowed():
|
|
132
|
+
if k not in exclude:
|
|
133
|
+
v = getattr(self, k, Undefined)
|
|
134
|
+
if not self._is_sentinel(v):
|
|
135
|
+
data[k] = self._normalize_value(v)
|
|
136
|
+
return data
|
|
137
|
+
|
|
138
|
+
def __hash__(self) -> int:
|
|
139
|
+
from ..ln._hash import hash_dict
|
|
140
|
+
|
|
141
|
+
return hash_dict(self.to_dict())
|
|
142
|
+
|
|
143
|
+
def __eq__(self, other: object) -> bool:
|
|
144
|
+
"""Equality via hash comparison. Returns NotImplemented for non-Params types."""
|
|
145
|
+
if not isinstance(other, Params):
|
|
146
|
+
return NotImplemented
|
|
147
|
+
return hash(self) == hash(other)
|
|
148
|
+
|
|
149
|
+
def with_updates(
|
|
150
|
+
self, copy_containers: Literal["shallow", "deep"] | None = None, **kwargs: Any
|
|
151
|
+
) -> Self:
|
|
152
|
+
"""Return new instance with updated fields.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
copy_containers: None (no copy), "shallow" (top-level), or "deep" (recursive).
|
|
156
|
+
**kwargs: Field updates.
|
|
157
|
+
"""
|
|
158
|
+
dict_ = self.to_dict()
|
|
159
|
+
|
|
160
|
+
def _out(d: dict):
|
|
161
|
+
d.update(kwargs)
|
|
162
|
+
return type(self)(**d)
|
|
163
|
+
|
|
164
|
+
if copy_containers is None:
|
|
165
|
+
return _out(dict_)
|
|
166
|
+
|
|
167
|
+
match copy_containers:
|
|
168
|
+
case "shallow":
|
|
169
|
+
for k, v in dict_.items():
|
|
170
|
+
if k not in kwargs and isinstance(
|
|
171
|
+
v, (MutableSequence, MutableMapping, MutableSet)
|
|
172
|
+
):
|
|
173
|
+
dict_[k] = v.copy()
|
|
174
|
+
return _out(dict_)
|
|
175
|
+
|
|
176
|
+
case "deep":
|
|
177
|
+
import copy
|
|
178
|
+
|
|
179
|
+
for k, v in dict_.items():
|
|
180
|
+
if k not in kwargs and isinstance(
|
|
181
|
+
v, (MutableSequence, MutableMapping, MutableSet)
|
|
182
|
+
):
|
|
183
|
+
dict_[k] = copy.deepcopy(v)
|
|
184
|
+
return _out(dict_)
|
|
185
|
+
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"Invalid copy_containers: {copy_containers!r}. Must be 'shallow', 'deep', or None."
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@implements(Serializable, Allowable, Hashable)
|
|
192
|
+
@dataclass(slots=True)
|
|
193
|
+
class DataClass:
|
|
194
|
+
"""Base for dataclasses with strict parameter handling. Configure via _config."""
|
|
195
|
+
|
|
196
|
+
_config: ClassVar[ModelConfig] = ModelConfig()
|
|
197
|
+
_allowed_keys: ClassVar[set[str]] = set()
|
|
198
|
+
|
|
199
|
+
def __post_init__(self):
|
|
200
|
+
"""Post-init: validates all fields."""
|
|
201
|
+
self._validate()
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def allowed(cls) -> set[str]:
|
|
205
|
+
"""Return the keys of the parameters."""
|
|
206
|
+
if cls._allowed_keys:
|
|
207
|
+
return cls._allowed_keys
|
|
208
|
+
cls._allowed_keys = {i for i in cls.__dataclass_fields__ if not i.startswith("_")}
|
|
209
|
+
return cls._allowed_keys
|
|
210
|
+
|
|
211
|
+
def _validate(self) -> None:
|
|
212
|
+
"""Validate params. Collects errors in ExceptionGroup. Prefills unset if configured."""
|
|
213
|
+
missing: list[Exception] = []
|
|
214
|
+
for k in self.allowed():
|
|
215
|
+
if self._config.strict and self._is_sentinel(getattr(self, k, Unset)):
|
|
216
|
+
missing.append(ValueError(f"Missing required parameter: {k}"))
|
|
217
|
+
if self._config.prefill_unset and getattr(self, k, Undefined) is Undefined:
|
|
218
|
+
self.__setattr__(k, Unset)
|
|
219
|
+
if missing:
|
|
220
|
+
raise ExceptionGroup("Missing required parameters", missing)
|
|
221
|
+
|
|
222
|
+
def to_dict(self, exclude: set[str] | None = None) -> dict[str, Any]:
|
|
223
|
+
data = {}
|
|
224
|
+
exclude = exclude or set()
|
|
225
|
+
for k in type(self).allowed():
|
|
226
|
+
if k not in exclude:
|
|
227
|
+
v = getattr(self, k)
|
|
228
|
+
if not self._is_sentinel(v):
|
|
229
|
+
data[k] = self._normalize_value(v)
|
|
230
|
+
return data
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def _is_sentinel(cls, value: Any) -> bool:
|
|
234
|
+
"""Check if value is sentinel (respects config)."""
|
|
235
|
+
return is_sentinel(
|
|
236
|
+
value,
|
|
237
|
+
none_as_sentinel=cls._config.none_as_sentinel,
|
|
238
|
+
empty_as_sentinel=cls._config.empty_as_sentinel,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
@classmethod
|
|
242
|
+
def _normalize_value(cls, value: Any) -> Any:
|
|
243
|
+
"""Normalize value for serialization (enum handling, etc.)."""
|
|
244
|
+
from enum import Enum as _Enum
|
|
245
|
+
|
|
246
|
+
if cls._config.use_enum_values and isinstance(value, _Enum):
|
|
247
|
+
return value.value
|
|
248
|
+
return value
|
|
249
|
+
|
|
250
|
+
def with_updates(
|
|
251
|
+
self, copy_containers: Literal["shallow", "deep"] | None = None, **kwargs: Any
|
|
252
|
+
) -> Self:
|
|
253
|
+
"""Return new instance with updated fields.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
copy_containers: None (no copy), "shallow" (top-level), or "deep" (recursive).
|
|
257
|
+
**kwargs: Field updates.
|
|
258
|
+
"""
|
|
259
|
+
dict_ = self.to_dict()
|
|
260
|
+
|
|
261
|
+
def _out(d: dict):
|
|
262
|
+
d.update(kwargs)
|
|
263
|
+
return type(self)(**d)
|
|
264
|
+
|
|
265
|
+
if copy_containers is None:
|
|
266
|
+
return _out(dict_)
|
|
267
|
+
|
|
268
|
+
match copy_containers:
|
|
269
|
+
case "shallow":
|
|
270
|
+
for k, v in dict_.items():
|
|
271
|
+
if k not in kwargs and isinstance(
|
|
272
|
+
v, (MutableSequence, MutableMapping, MutableSet)
|
|
273
|
+
):
|
|
274
|
+
dict_[k] = v.copy()
|
|
275
|
+
return _out(dict_)
|
|
276
|
+
|
|
277
|
+
case "deep":
|
|
278
|
+
import copy
|
|
279
|
+
|
|
280
|
+
for k, v in dict_.items():
|
|
281
|
+
if k not in kwargs and isinstance(
|
|
282
|
+
v, (MutableSequence, MutableMapping, MutableSet)
|
|
283
|
+
):
|
|
284
|
+
dict_[k] = copy.deepcopy(v)
|
|
285
|
+
return _out(dict_)
|
|
286
|
+
|
|
287
|
+
raise ValueError(
|
|
288
|
+
f"Invalid copy_containers: {copy_containers!r}. Must be 'shallow', 'deep', or None."
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def __hash__(self) -> int:
|
|
292
|
+
from ..ln._hash import hash_dict
|
|
293
|
+
|
|
294
|
+
return hash_dict(self.to_dict())
|
|
295
|
+
|
|
296
|
+
def __eq__(self, other: object) -> bool:
|
|
297
|
+
"""Equality via hash comparison. Returns NotImplemented for non-DataClass types."""
|
|
298
|
+
if not isinstance(other, DataClass):
|
|
299
|
+
return NotImplemented
|
|
300
|
+
return hash(self) == hash(other)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
KeysLike = Sequence[str] | KeysDict
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@implements(Hashable)
|
|
307
|
+
@dataclass(slots=True, frozen=True)
|
|
308
|
+
class Meta:
|
|
309
|
+
"""Immutable metadata container. Hashable for caching (callables hashed by id)."""
|
|
310
|
+
|
|
311
|
+
key: str
|
|
312
|
+
value: Any
|
|
313
|
+
|
|
314
|
+
@override
|
|
315
|
+
def __hash__(self) -> int:
|
|
316
|
+
"""Hash metadata. Callables use id() for identity semantics."""
|
|
317
|
+
# For callables, use their id
|
|
318
|
+
if callable(self.value):
|
|
319
|
+
return hash((self.key, id(self.value)))
|
|
320
|
+
# For other values, try to hash directly
|
|
321
|
+
try:
|
|
322
|
+
return hash((self.key, self.value))
|
|
323
|
+
except TypeError:
|
|
324
|
+
# Fallback for unhashable types
|
|
325
|
+
return hash((self.key, str(self.value)))
|
|
326
|
+
|
|
327
|
+
@override
|
|
328
|
+
def __eq__(self, other: object) -> bool:
|
|
329
|
+
"""Equality: callables compared by id, others by standard equality."""
|
|
330
|
+
if not isinstance(other, Meta):
|
|
331
|
+
return NotImplemented
|
|
332
|
+
|
|
333
|
+
if self.key != other.key:
|
|
334
|
+
return False
|
|
335
|
+
|
|
336
|
+
# For callables, compare by identity
|
|
337
|
+
if callable(self.value) and callable(other.value):
|
|
338
|
+
return id(self.value) == id(other.value)
|
|
339
|
+
|
|
340
|
+
# For other values, use standard equality
|
|
341
|
+
return bool(self.value == other.value)
|