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.
Files changed (64) hide show
  1. lionherd_core/__init__.py +84 -0
  2. lionherd_core/base/__init__.py +30 -0
  3. lionherd_core/base/_utils.py +295 -0
  4. lionherd_core/base/broadcaster.py +128 -0
  5. lionherd_core/base/element.py +300 -0
  6. lionherd_core/base/event.py +322 -0
  7. lionherd_core/base/eventbus.py +112 -0
  8. lionherd_core/base/flow.py +236 -0
  9. lionherd_core/base/graph.py +616 -0
  10. lionherd_core/base/node.py +212 -0
  11. lionherd_core/base/pile.py +811 -0
  12. lionherd_core/base/progression.py +261 -0
  13. lionherd_core/errors.py +104 -0
  14. lionherd_core/libs/__init__.py +2 -0
  15. lionherd_core/libs/concurrency/__init__.py +60 -0
  16. lionherd_core/libs/concurrency/_cancel.py +85 -0
  17. lionherd_core/libs/concurrency/_errors.py +80 -0
  18. lionherd_core/libs/concurrency/_patterns.py +238 -0
  19. lionherd_core/libs/concurrency/_primitives.py +253 -0
  20. lionherd_core/libs/concurrency/_priority_queue.py +135 -0
  21. lionherd_core/libs/concurrency/_resource_tracker.py +66 -0
  22. lionherd_core/libs/concurrency/_task.py +58 -0
  23. lionherd_core/libs/concurrency/_utils.py +61 -0
  24. lionherd_core/libs/schema_handlers/__init__.py +35 -0
  25. lionherd_core/libs/schema_handlers/_function_call_parser.py +122 -0
  26. lionherd_core/libs/schema_handlers/_minimal_yaml.py +88 -0
  27. lionherd_core/libs/schema_handlers/_schema_to_model.py +251 -0
  28. lionherd_core/libs/schema_handlers/_typescript.py +153 -0
  29. lionherd_core/libs/string_handlers/__init__.py +15 -0
  30. lionherd_core/libs/string_handlers/_extract_json.py +65 -0
  31. lionherd_core/libs/string_handlers/_fuzzy_json.py +103 -0
  32. lionherd_core/libs/string_handlers/_string_similarity.py +347 -0
  33. lionherd_core/libs/string_handlers/_to_num.py +63 -0
  34. lionherd_core/ln/__init__.py +45 -0
  35. lionherd_core/ln/_async_call.py +314 -0
  36. lionherd_core/ln/_fuzzy_match.py +166 -0
  37. lionherd_core/ln/_fuzzy_validate.py +151 -0
  38. lionherd_core/ln/_hash.py +141 -0
  39. lionherd_core/ln/_json_dump.py +347 -0
  40. lionherd_core/ln/_list_call.py +110 -0
  41. lionherd_core/ln/_to_dict.py +373 -0
  42. lionherd_core/ln/_to_list.py +190 -0
  43. lionherd_core/ln/_utils.py +156 -0
  44. lionherd_core/lndl/__init__.py +62 -0
  45. lionherd_core/lndl/errors.py +30 -0
  46. lionherd_core/lndl/fuzzy.py +321 -0
  47. lionherd_core/lndl/parser.py +427 -0
  48. lionherd_core/lndl/prompt.py +137 -0
  49. lionherd_core/lndl/resolver.py +323 -0
  50. lionherd_core/lndl/types.py +287 -0
  51. lionherd_core/protocols.py +181 -0
  52. lionherd_core/py.typed +0 -0
  53. lionherd_core/types/__init__.py +46 -0
  54. lionherd_core/types/_sentinel.py +131 -0
  55. lionherd_core/types/base.py +341 -0
  56. lionherd_core/types/operable.py +133 -0
  57. lionherd_core/types/spec.py +313 -0
  58. lionherd_core/types/spec_adapters/__init__.py +10 -0
  59. lionherd_core/types/spec_adapters/_protocol.py +125 -0
  60. lionherd_core/types/spec_adapters/pydantic_field.py +177 -0
  61. lionherd_core-1.0.0a3.dist-info/METADATA +502 -0
  62. lionherd_core-1.0.0a3.dist-info/RECORD +64 -0
  63. lionherd_core-1.0.0a3.dist-info/WHEEL +4 -0
  64. 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)