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,84 @@
|
|
|
1
|
+
# Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
__version__ = "1.0.0-alpha3" # pragma: no cover
|
|
5
|
+
|
|
6
|
+
from . import ln as ln
|
|
7
|
+
from .base import (
|
|
8
|
+
Broadcaster,
|
|
9
|
+
Edge,
|
|
10
|
+
EdgeCondition,
|
|
11
|
+
Element,
|
|
12
|
+
Event,
|
|
13
|
+
EventBus,
|
|
14
|
+
EventStatus,
|
|
15
|
+
Execution,
|
|
16
|
+
Flow,
|
|
17
|
+
Graph,
|
|
18
|
+
Node,
|
|
19
|
+
Pile,
|
|
20
|
+
Progression,
|
|
21
|
+
)
|
|
22
|
+
from .errors import LionherdError
|
|
23
|
+
from .libs import (
|
|
24
|
+
concurrency as concurrency,
|
|
25
|
+
schema_handlers as schema_handlers,
|
|
26
|
+
string_handlers as string_handlers,
|
|
27
|
+
)
|
|
28
|
+
from .types import (
|
|
29
|
+
CommonMeta,
|
|
30
|
+
DataClass,
|
|
31
|
+
Enum,
|
|
32
|
+
MaybeSentinel,
|
|
33
|
+
MaybeUndefined,
|
|
34
|
+
MaybeUnset,
|
|
35
|
+
Meta,
|
|
36
|
+
ModelConfig,
|
|
37
|
+
Operable,
|
|
38
|
+
Params,
|
|
39
|
+
Spec,
|
|
40
|
+
Undefined,
|
|
41
|
+
UndefinedType,
|
|
42
|
+
Unset,
|
|
43
|
+
UnsetType,
|
|
44
|
+
is_sentinel,
|
|
45
|
+
not_sentinel,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = (
|
|
49
|
+
"Broadcaster",
|
|
50
|
+
"CommonMeta",
|
|
51
|
+
"DataClass",
|
|
52
|
+
"Edge",
|
|
53
|
+
"EdgeCondition",
|
|
54
|
+
"Element",
|
|
55
|
+
"Enum",
|
|
56
|
+
"Event",
|
|
57
|
+
"EventBus",
|
|
58
|
+
"EventStatus",
|
|
59
|
+
"Execution",
|
|
60
|
+
"Flow",
|
|
61
|
+
"Graph",
|
|
62
|
+
"LionherdError",
|
|
63
|
+
"MaybeSentinel",
|
|
64
|
+
"MaybeUndefined",
|
|
65
|
+
"MaybeUnset",
|
|
66
|
+
"Meta",
|
|
67
|
+
"ModelConfig",
|
|
68
|
+
"Node",
|
|
69
|
+
"Operable",
|
|
70
|
+
"Params",
|
|
71
|
+
"Pile",
|
|
72
|
+
"Progression",
|
|
73
|
+
"Spec",
|
|
74
|
+
"Undefined",
|
|
75
|
+
"UndefinedType",
|
|
76
|
+
"Unset",
|
|
77
|
+
"UnsetType",
|
|
78
|
+
"concurrency",
|
|
79
|
+
"is_sentinel",
|
|
80
|
+
"ln",
|
|
81
|
+
"not_sentinel",
|
|
82
|
+
"schema_handlers",
|
|
83
|
+
"string_handlers",
|
|
84
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from .broadcaster import Broadcaster
|
|
5
|
+
from .element import Element
|
|
6
|
+
from .event import Event, EventStatus, Execution
|
|
7
|
+
from .eventbus import EventBus, Handler
|
|
8
|
+
from .flow import Flow
|
|
9
|
+
from .graph import Edge, EdgeCondition, Graph
|
|
10
|
+
from .node import NODE_REGISTRY, Node
|
|
11
|
+
from .pile import Pile
|
|
12
|
+
from .progression import Progression
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"NODE_REGISTRY",
|
|
16
|
+
"Broadcaster",
|
|
17
|
+
"Edge",
|
|
18
|
+
"EdgeCondition",
|
|
19
|
+
"Element",
|
|
20
|
+
"Event",
|
|
21
|
+
"EventBus",
|
|
22
|
+
"EventStatus",
|
|
23
|
+
"Execution",
|
|
24
|
+
"Flow",
|
|
25
|
+
"Graph",
|
|
26
|
+
"Handler",
|
|
27
|
+
"Node",
|
|
28
|
+
"Pile",
|
|
29
|
+
"Progression",
|
|
30
|
+
]
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import types
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from functools import wraps
|
|
10
|
+
from typing import Any, Union, get_args, get_origin
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
|
|
13
|
+
from ..protocols import Observable, Serializable
|
|
14
|
+
|
|
15
|
+
__all__ = (
|
|
16
|
+
"async_synchronized",
|
|
17
|
+
"coerce_created_at",
|
|
18
|
+
"extract_types",
|
|
19
|
+
"get_element_serializer_config",
|
|
20
|
+
"get_json_serializable",
|
|
21
|
+
"load_type_from_string",
|
|
22
|
+
"synchronized",
|
|
23
|
+
"to_uuid",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def synchronized(func: Callable) -> Callable:
|
|
28
|
+
"""Decorator for thread-safe method execution.
|
|
29
|
+
|
|
30
|
+
Executes method within self._lock context. The class must have a _lock
|
|
31
|
+
attribute (typically threading.RLock()).
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
@synchronized
|
|
35
|
+
def append(self, item):
|
|
36
|
+
self.items.append(item)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
@wraps(func)
|
|
40
|
+
def wrapper(self, *args, **kwargs):
|
|
41
|
+
with self._lock:
|
|
42
|
+
return func(self, *args, **kwargs)
|
|
43
|
+
|
|
44
|
+
return wrapper
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def async_synchronized(func: Callable) -> Callable:
|
|
48
|
+
"""Decorator for async-safe method execution.
|
|
49
|
+
|
|
50
|
+
Executes async method within self._async_lock context. The class must have a
|
|
51
|
+
_async_lock attribute (typically asyncio.Lock()).
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
@async_synchronized
|
|
55
|
+
async def append_async(self, item):
|
|
56
|
+
self.items.append(item)
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
@wraps(func)
|
|
60
|
+
async def wrapper(self, *args, **kwargs):
|
|
61
|
+
async with self._async_lock:
|
|
62
|
+
return await func(self, *args, **kwargs)
|
|
63
|
+
|
|
64
|
+
return wrapper
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_TYPE_CACHE: dict[str, type] = {}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_type_from_string(type_str: str) -> type:
|
|
71
|
+
"""Load type from fully qualified module path string.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
type_str: Fully qualified type path (e.g., 'lionherd.base.Node')
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Type class
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ValueError: If type string is invalid or type cannot be loaded
|
|
81
|
+
|
|
82
|
+
Example:
|
|
83
|
+
>>> load_type_from_string("lionherd.base.Node")
|
|
84
|
+
<class 'lionherd.base.node.Node'>
|
|
85
|
+
"""
|
|
86
|
+
# Check cache first
|
|
87
|
+
if type_str in _TYPE_CACHE:
|
|
88
|
+
return _TYPE_CACHE[type_str]
|
|
89
|
+
|
|
90
|
+
if not isinstance(type_str, str):
|
|
91
|
+
raise ValueError(f"Expected string, got {type(type_str)}")
|
|
92
|
+
|
|
93
|
+
if "." not in type_str:
|
|
94
|
+
raise ValueError(f"Invalid type path (no module): {type_str}")
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
module_path, class_name = type_str.rsplit(".", 1)
|
|
98
|
+
|
|
99
|
+
# Import module using importlib for correct behavior
|
|
100
|
+
import importlib
|
|
101
|
+
|
|
102
|
+
module = importlib.import_module(module_path)
|
|
103
|
+
if module is None:
|
|
104
|
+
raise ImportError(f"Module '{module_path}' not found")
|
|
105
|
+
|
|
106
|
+
type_class = getattr(module, class_name)
|
|
107
|
+
|
|
108
|
+
# Validate it's actually a type
|
|
109
|
+
if not isinstance(type_class, type):
|
|
110
|
+
raise ValueError(f"'{type_str}' is not a type")
|
|
111
|
+
|
|
112
|
+
# Cache result
|
|
113
|
+
_TYPE_CACHE[type_str] = type_class
|
|
114
|
+
|
|
115
|
+
return type_class
|
|
116
|
+
|
|
117
|
+
except (ValueError, ImportError, AttributeError) as e:
|
|
118
|
+
raise ValueError(f"Failed to load type '{type_str}': {e}") from e
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def extract_types(item_type: Any) -> set[type]:
|
|
122
|
+
"""Extract types from union types or convert single types to set.
|
|
123
|
+
|
|
124
|
+
Handles Python 3.10+ pipe syntax (int | str), typing.Union, lists, and sets.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
item_type: Single type, list of types, set of types, or Union type
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Set of extracted types
|
|
131
|
+
|
|
132
|
+
Examples:
|
|
133
|
+
extract_types(int) -> {int}
|
|
134
|
+
extract_types([int, str]) -> {int, str}
|
|
135
|
+
extract_types(int | str) -> {int, str}
|
|
136
|
+
extract_types(Union[int, str]) -> {int, str}
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
# Helper to check if type is a union
|
|
140
|
+
def is_union(t):
|
|
141
|
+
origin = get_origin(t)
|
|
142
|
+
# Python 3.10+ pipe syntax (Element | Node) creates types.UnionType
|
|
143
|
+
# typing.Union creates Union origin
|
|
144
|
+
return origin is Union or isinstance(t, types.UnionType)
|
|
145
|
+
|
|
146
|
+
extracted: set[type] = set()
|
|
147
|
+
|
|
148
|
+
# Already a set
|
|
149
|
+
if isinstance(item_type, set):
|
|
150
|
+
# Check if any items in set are unions and extract them
|
|
151
|
+
for t in item_type:
|
|
152
|
+
if is_union(t):
|
|
153
|
+
extracted.update(get_args(t))
|
|
154
|
+
else:
|
|
155
|
+
extracted.add(t)
|
|
156
|
+
return extracted
|
|
157
|
+
|
|
158
|
+
# List of types
|
|
159
|
+
if isinstance(item_type, list):
|
|
160
|
+
for t in item_type:
|
|
161
|
+
if is_union(t):
|
|
162
|
+
extracted.update(get_args(t))
|
|
163
|
+
else:
|
|
164
|
+
extracted.add(t)
|
|
165
|
+
return extracted
|
|
166
|
+
|
|
167
|
+
# Union type (int | str or Union[int, str])
|
|
168
|
+
if is_union(item_type):
|
|
169
|
+
return set(get_args(item_type))
|
|
170
|
+
|
|
171
|
+
# Single type
|
|
172
|
+
return {item_type}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def to_uuid(value: Any) -> UUID:
|
|
176
|
+
"""Convert various ID representations to UUID.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
value: UUID, UUID string, or Observable object with id property
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
UUID instance
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
ValueError: If value cannot be converted to UUID or Observable.id is callable
|
|
186
|
+
|
|
187
|
+
Examples:
|
|
188
|
+
>>> to_uuid(UUID("12345678-1234-5678-1234-567812345678"))
|
|
189
|
+
UUID('12345678-1234-5678-1234-567812345678')
|
|
190
|
+
>>> to_uuid("12345678-1234-5678-1234-567812345678")
|
|
191
|
+
UUID('12345678-1234-5678-1234-567812345678')
|
|
192
|
+
"""
|
|
193
|
+
if isinstance(value, Observable):
|
|
194
|
+
id_value = value.id
|
|
195
|
+
if callable(id_value):
|
|
196
|
+
raise ValueError(
|
|
197
|
+
f"Observable.id must be a property, not a method. Got callable: {type(value).__name__}.id()"
|
|
198
|
+
)
|
|
199
|
+
return id_value
|
|
200
|
+
if isinstance(value, UUID):
|
|
201
|
+
return value
|
|
202
|
+
if isinstance(value, str):
|
|
203
|
+
return UUID(value)
|
|
204
|
+
raise ValueError("Cannot get ID from item.")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def coerce_created_at(v) -> dt.datetime:
|
|
208
|
+
"""Coerce datetime, timestamp, or ISO string to UTC-aware datetime.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
v: datetime, float timestamp, or ISO 8601 string
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
UTC-aware datetime
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
ValueError: If cannot coerce to datetime
|
|
218
|
+
"""
|
|
219
|
+
# datetime object
|
|
220
|
+
if isinstance(v, dt.datetime):
|
|
221
|
+
if v.tzinfo is None:
|
|
222
|
+
return v.replace(tzinfo=dt.UTC)
|
|
223
|
+
return v
|
|
224
|
+
|
|
225
|
+
# Float timestamp (seconds since epoch)
|
|
226
|
+
if isinstance(v, (int, float)):
|
|
227
|
+
return dt.datetime.fromtimestamp(v, tz=dt.UTC)
|
|
228
|
+
|
|
229
|
+
if isinstance(v, str):
|
|
230
|
+
# Try parsing as float timestamp
|
|
231
|
+
with contextlib.suppress(ValueError):
|
|
232
|
+
timestamp = float(v)
|
|
233
|
+
return dt.datetime.fromtimestamp(timestamp, tz=dt.UTC)
|
|
234
|
+
# Try parsing as ISO format
|
|
235
|
+
with contextlib.suppress(ValueError):
|
|
236
|
+
return dt.datetime.fromisoformat(v)
|
|
237
|
+
|
|
238
|
+
raise ValueError(f"String '{v}' is neither a valid timestamp nor ISO format datetime")
|
|
239
|
+
|
|
240
|
+
raise ValueError(
|
|
241
|
+
f"created_at must be datetime, timestamp (int/float), or string, got {type(v).__name__}"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
_SIMPLE_TYPE = (str, bytes, bytearray, int, float, type(None), Enum)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def get_json_serializable(data) -> dict[str, Any] | Any:
|
|
249
|
+
"""Serialize to dict."""
|
|
250
|
+
from ..ln import json_dumpb, to_dict
|
|
251
|
+
from ..types._sentinel import Unset
|
|
252
|
+
|
|
253
|
+
if data is Unset:
|
|
254
|
+
return Unset
|
|
255
|
+
|
|
256
|
+
if isinstance(data, _SIMPLE_TYPE):
|
|
257
|
+
return data
|
|
258
|
+
|
|
259
|
+
with contextlib.suppress(Exception):
|
|
260
|
+
# Check if response is JSON serializable
|
|
261
|
+
json_dumpb(data)
|
|
262
|
+
return data
|
|
263
|
+
|
|
264
|
+
with contextlib.suppress(Exception):
|
|
265
|
+
# Attempt to force convert to dict recursively
|
|
266
|
+
d_ = to_dict(
|
|
267
|
+
data,
|
|
268
|
+
recursive=True,
|
|
269
|
+
recursive_python_only=False,
|
|
270
|
+
use_enum_values=True,
|
|
271
|
+
)
|
|
272
|
+
json_dumpb(d_)
|
|
273
|
+
return d_
|
|
274
|
+
|
|
275
|
+
return Unset
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def get_element_serializer_config() -> tuple[list, dict]:
|
|
279
|
+
"""Get serializer config for Element.to_json().
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Tuple of (order, additional) for get_orjson_default():
|
|
283
|
+
- order: [Serializable, BaseModel]
|
|
284
|
+
- additional: {type: serializer_func}
|
|
285
|
+
"""
|
|
286
|
+
from pydantic import BaseModel
|
|
287
|
+
|
|
288
|
+
order = [Serializable, BaseModel]
|
|
289
|
+
|
|
290
|
+
additional = {
|
|
291
|
+
Serializable: lambda o: o.to_dict(),
|
|
292
|
+
BaseModel: lambda o: o.model_dump(mode="json"),
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return order, additional
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
import logging
|
|
7
|
+
import weakref
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from typing import Any, ClassVar
|
|
10
|
+
|
|
11
|
+
from ..libs.concurrency import is_coro_func
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
__all__ = ["Broadcaster"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Broadcaster:
|
|
19
|
+
"""Singleton pub/sub for O(1) memory event broadcasting.
|
|
20
|
+
|
|
21
|
+
Memory Management:
|
|
22
|
+
Uses weakref for automatic subscriber cleanup when callback objects are
|
|
23
|
+
garbage collected. Prevents memory leaks in long-running services with
|
|
24
|
+
dynamic agent/tenant lifecycles.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
_instance: ClassVar[Broadcaster | None] = None
|
|
28
|
+
_subscribers: ClassVar[
|
|
29
|
+
list[weakref.ref[Callable[[Any], None] | Callable[[Any], Awaitable[None]]]]
|
|
30
|
+
] = []
|
|
31
|
+
_event_type: ClassVar[type]
|
|
32
|
+
|
|
33
|
+
def __new__(cls):
|
|
34
|
+
if cls._instance is None:
|
|
35
|
+
cls._instance = super().__new__(cls)
|
|
36
|
+
return cls._instance
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def subscribe(cls, callback: Callable[[Any], None] | Callable[[Any], Awaitable[None]]) -> None:
|
|
40
|
+
"""Add subscriber callback.
|
|
41
|
+
|
|
42
|
+
Callbacks stored as weak references for automatic cleanup when callback
|
|
43
|
+
objects are garbage collected. Prevents cross-tenant/session leaks.
|
|
44
|
+
|
|
45
|
+
Supports both regular callables and bound methods via WeakMethod.
|
|
46
|
+
"""
|
|
47
|
+
# Check if callback already subscribed (compare actual callbacks, not weakrefs)
|
|
48
|
+
for weak_ref in cls._subscribers:
|
|
49
|
+
if weak_ref() is callback:
|
|
50
|
+
return # Already subscribed
|
|
51
|
+
|
|
52
|
+
# Store as weakref for automatic cleanup
|
|
53
|
+
# Use WeakMethod for bound methods, weakref for regular callables
|
|
54
|
+
if hasattr(callback, "__self__"):
|
|
55
|
+
weak_callback = weakref.WeakMethod(callback)
|
|
56
|
+
else:
|
|
57
|
+
weak_callback = weakref.ref(callback)
|
|
58
|
+
cls._subscribers.append(weak_callback)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def unsubscribe(
|
|
62
|
+
cls, callback: Callable[[Any], None] | Callable[[Any], Awaitable[None]]
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Remove subscriber callback."""
|
|
65
|
+
# Find and remove weakref that points to this callback
|
|
66
|
+
for weak_ref in list(cls._subscribers):
|
|
67
|
+
if weak_ref() is callback:
|
|
68
|
+
cls._subscribers.remove(weak_ref)
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def _cleanup_dead_refs(cls) -> list[Callable[[Any], None] | Callable[[Any], Awaitable[None]]]:
|
|
73
|
+
"""Remove garbage-collected callbacks and return list of live callbacks.
|
|
74
|
+
|
|
75
|
+
Lazily cleans up dead weakrefs during normal operations (broadcast/get_subscriber_count).
|
|
76
|
+
Updates ClassVar subscription list in-place to preserve identity.
|
|
77
|
+
|
|
78
|
+
Note: Uses in-place slice assignment (cls._subscribers[:] = alive_refs) rather than
|
|
79
|
+
reassignment to maintain ClassVar identity across subclasses.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
List of live callback callables (weakrefs resolved).
|
|
83
|
+
"""
|
|
84
|
+
callbacks = []
|
|
85
|
+
alive_refs = []
|
|
86
|
+
|
|
87
|
+
for weak_ref in cls._subscribers:
|
|
88
|
+
callback = weak_ref()
|
|
89
|
+
if callback is not None:
|
|
90
|
+
callbacks.append(callback)
|
|
91
|
+
alive_refs.append(weak_ref)
|
|
92
|
+
|
|
93
|
+
# In-place update to maintain ClassVar identity
|
|
94
|
+
cls._subscribers[:] = alive_refs
|
|
95
|
+
|
|
96
|
+
return callbacks
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
async def broadcast(cls, event: Any) -> None:
|
|
100
|
+
"""Broadcast event to all subscribers.
|
|
101
|
+
|
|
102
|
+
Dead weakrefs are lazily cleaned up during broadcast.
|
|
103
|
+
"""
|
|
104
|
+
if not isinstance(event, cls._event_type):
|
|
105
|
+
raise ValueError(f"Event must be of type {cls._event_type.__name__}")
|
|
106
|
+
|
|
107
|
+
callbacks = cls._cleanup_dead_refs()
|
|
108
|
+
|
|
109
|
+
# Broadcast to live callbacks
|
|
110
|
+
for callback in callbacks:
|
|
111
|
+
try:
|
|
112
|
+
if is_coro_func(callback):
|
|
113
|
+
result = callback(event)
|
|
114
|
+
if result is not None: # Coroutine functions return awaitable
|
|
115
|
+
await result
|
|
116
|
+
else:
|
|
117
|
+
callback(event)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.error(f"Error in subscriber callback: {e}", exc_info=True)
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def get_subscriber_count(cls) -> int:
|
|
123
|
+
"""Get live subscriber count (excludes garbage-collected callbacks).
|
|
124
|
+
|
|
125
|
+
Note: This method mutates state by cleaning up dead weakrefs as a side effect.
|
|
126
|
+
"""
|
|
127
|
+
callbacks = cls._cleanup_dead_refs()
|
|
128
|
+
return len(callbacks)
|