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,58 @@
|
|
|
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 AsyncIterator, Awaitable, Callable
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from typing import Any, TypeVar
|
|
9
|
+
|
|
10
|
+
import anyio
|
|
11
|
+
import anyio.abc
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
R = TypeVar("R")
|
|
15
|
+
|
|
16
|
+
__all__ = (
|
|
17
|
+
"TaskGroup",
|
|
18
|
+
"create_task_group",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TaskGroup:
|
|
23
|
+
"""Structured concurrency task group."""
|
|
24
|
+
|
|
25
|
+
__slots__ = ("_tg",)
|
|
26
|
+
|
|
27
|
+
def __init__(self, tg: anyio.abc.TaskGroup) -> None:
|
|
28
|
+
self._tg = tg
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def cancel_scope(self) -> anyio.CancelScope:
|
|
32
|
+
"""Cancel scope for task group lifetime."""
|
|
33
|
+
return self._tg.cancel_scope
|
|
34
|
+
|
|
35
|
+
def start_soon(
|
|
36
|
+
self,
|
|
37
|
+
func: Callable[..., Awaitable[Any]],
|
|
38
|
+
*args: Any,
|
|
39
|
+
name: str | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Start task without waiting for initialization."""
|
|
42
|
+
self._tg.start_soon(func, *args, name=name)
|
|
43
|
+
|
|
44
|
+
async def start(
|
|
45
|
+
self,
|
|
46
|
+
func: Callable[..., Awaitable[R]],
|
|
47
|
+
*args: Any,
|
|
48
|
+
name: str | None = None,
|
|
49
|
+
) -> R:
|
|
50
|
+
"""Start task and wait for initialization."""
|
|
51
|
+
return await self._tg.start(func, *args, name=name)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@asynccontextmanager
|
|
55
|
+
async def create_task_group() -> AsyncIterator[TaskGroup]:
|
|
56
|
+
"""Create task group for structured concurrency."""
|
|
57
|
+
async with anyio.create_task_group() as tg:
|
|
58
|
+
yield TaskGroup(tg)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import inspect
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from functools import cache, partial
|
|
7
|
+
from typing import Any, ParamSpec, TypeVar
|
|
8
|
+
|
|
9
|
+
import anyio
|
|
10
|
+
import anyio.to_thread
|
|
11
|
+
|
|
12
|
+
P = ParamSpec("P")
|
|
13
|
+
R = TypeVar("R")
|
|
14
|
+
|
|
15
|
+
__all__ = ("current_time", "is_coro_func", "run_sync", "sleep")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@cache
|
|
19
|
+
def _is_coro_func(func: Callable[..., Any]) -> bool:
|
|
20
|
+
"""Cached check for coroutine function, handles partials."""
|
|
21
|
+
# Unwrap partials to get the underlying function
|
|
22
|
+
while isinstance(func, partial):
|
|
23
|
+
func = func.func
|
|
24
|
+
|
|
25
|
+
# Check if it's a coroutine function
|
|
26
|
+
return inspect.iscoroutinefunction(func)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_coro_func(func: Callable[..., Any]) -> bool:
|
|
30
|
+
"""Check if function is coroutine function."""
|
|
31
|
+
return _is_coro_func(func)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def current_time() -> float:
|
|
35
|
+
"""Get current time in seconds (monotonic clock)."""
|
|
36
|
+
return anyio.current_time()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def run_sync(func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
|
|
40
|
+
"""Run synchronous function in thread pool.
|
|
41
|
+
|
|
42
|
+
Preserves the function signature using ParamSpec, improving type inference.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
func: Synchronous function to run in thread pool
|
|
46
|
+
*args: Positional arguments for func
|
|
47
|
+
**kwargs: Keyword arguments for func
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Result of func
|
|
51
|
+
"""
|
|
52
|
+
if kwargs:
|
|
53
|
+
# anyio.to_thread.run_sync doesn't accept **kwargs, use partial
|
|
54
|
+
func_with_kwargs = partial(func, **kwargs)
|
|
55
|
+
return await anyio.to_thread.run_sync(func_with_kwargs, *args)
|
|
56
|
+
return await anyio.to_thread.run_sync(func, *args)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def sleep(seconds: float) -> None:
|
|
60
|
+
"""Sleep without blocking event loop."""
|
|
61
|
+
await anyio.sleep(seconds)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from ._function_call_parser import (
|
|
7
|
+
map_positional_args,
|
|
8
|
+
nest_arguments_by_schema,
|
|
9
|
+
parse_function_call,
|
|
10
|
+
)
|
|
11
|
+
from ._minimal_yaml import minimal_yaml
|
|
12
|
+
from ._typescript import typescript_schema
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ._schema_to_model import load_pydantic_model_from_schema
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = (
|
|
19
|
+
"load_pydantic_model_from_schema",
|
|
20
|
+
"map_positional_args",
|
|
21
|
+
"minimal_yaml",
|
|
22
|
+
"nest_arguments_by_schema",
|
|
23
|
+
"parse_function_call",
|
|
24
|
+
"typescript_schema",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def __getattr__(name: str):
|
|
29
|
+
"""Lazy import for optional dependencies."""
|
|
30
|
+
if name == "load_pydantic_model_from_schema":
|
|
31
|
+
from ._schema_to_model import load_pydantic_model_from_schema
|
|
32
|
+
|
|
33
|
+
return load_pydantic_model_from_schema
|
|
34
|
+
msg = f"module {__name__!r} has no attribute {name!r}"
|
|
35
|
+
raise AttributeError(msg)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
from typing import Any, get_args, get_origin
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_function_call(call_str: str) -> dict[str, Any]:
|
|
11
|
+
"""Parse Python function call syntax into JSON tool invocation format."""
|
|
12
|
+
try:
|
|
13
|
+
# Parse the call as a Python expression
|
|
14
|
+
tree = ast.parse(call_str, mode="eval")
|
|
15
|
+
call = tree.body
|
|
16
|
+
|
|
17
|
+
if not isinstance(call, ast.Call):
|
|
18
|
+
raise ValueError("Not a function call")
|
|
19
|
+
|
|
20
|
+
# Extract function name
|
|
21
|
+
if isinstance(call.func, ast.Name):
|
|
22
|
+
tool_name = call.func.id
|
|
23
|
+
elif isinstance(call.func, ast.Attribute):
|
|
24
|
+
# Handle chained calls like client.search()
|
|
25
|
+
tool_name = call.func.attr
|
|
26
|
+
else:
|
|
27
|
+
raise ValueError(f"Unsupported function type: {type(call.func)}")
|
|
28
|
+
|
|
29
|
+
# Extract arguments
|
|
30
|
+
arguments = {}
|
|
31
|
+
|
|
32
|
+
# Positional arguments (will be mapped by parameter order in schema)
|
|
33
|
+
for i, arg in enumerate(call.args):
|
|
34
|
+
# For now, use position-based keys; will be mapped to param names later
|
|
35
|
+
arguments[f"_pos_{i}"] = ast.literal_eval(arg)
|
|
36
|
+
|
|
37
|
+
# Keyword arguments
|
|
38
|
+
for keyword in call.keywords:
|
|
39
|
+
if keyword.arg is None:
|
|
40
|
+
# **kwargs syntax
|
|
41
|
+
raise ValueError("**kwargs not supported")
|
|
42
|
+
arguments[keyword.arg] = ast.literal_eval(keyword.value)
|
|
43
|
+
|
|
44
|
+
return {"tool": tool_name, "arguments": arguments}
|
|
45
|
+
|
|
46
|
+
except (SyntaxError, ValueError) as e:
|
|
47
|
+
raise ValueError(f"Invalid function call syntax: {e}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def map_positional_args(arguments: dict[str, Any], param_names: list[str]) -> dict[str, Any]:
|
|
51
|
+
"""Map positional arguments (_pos_0, _pos_1, ...) to actual parameter names."""
|
|
52
|
+
mapped = {}
|
|
53
|
+
pos_count = 0
|
|
54
|
+
|
|
55
|
+
for key, value in arguments.items():
|
|
56
|
+
if key.startswith("_pos_"):
|
|
57
|
+
if pos_count >= len(param_names):
|
|
58
|
+
raise ValueError(f"Too many positional arguments (expected {len(param_names)})")
|
|
59
|
+
mapped[param_names[pos_count]] = value
|
|
60
|
+
pos_count += 1
|
|
61
|
+
else:
|
|
62
|
+
# Keep keyword arguments as-is
|
|
63
|
+
mapped[key] = value
|
|
64
|
+
|
|
65
|
+
return mapped
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def nest_arguments_by_schema(arguments: dict[str, Any], schema_cls) -> dict[str, Any]:
|
|
69
|
+
"""Restructure flat arguments into nested format based on schema structure."""
|
|
70
|
+
if not schema_cls or not hasattr(schema_cls, "model_fields"):
|
|
71
|
+
return arguments
|
|
72
|
+
|
|
73
|
+
# Get top-level field names
|
|
74
|
+
top_level_fields = set(schema_cls.model_fields.keys())
|
|
75
|
+
|
|
76
|
+
# Find fields that are nested objects (Pydantic models or unions)
|
|
77
|
+
nested_field_mappings = {}
|
|
78
|
+
for field_name, field_info in schema_cls.model_fields.items():
|
|
79
|
+
annotation = field_info.annotation
|
|
80
|
+
|
|
81
|
+
# Check if it's a union type
|
|
82
|
+
if get_origin(annotation) is type(int | str): # UnionType check
|
|
83
|
+
union_members = get_args(annotation)
|
|
84
|
+
# Collect all fields from union members
|
|
85
|
+
union_fields = set()
|
|
86
|
+
for member in union_members:
|
|
87
|
+
if hasattr(member, "model_fields"):
|
|
88
|
+
union_fields.update(member.model_fields.keys())
|
|
89
|
+
if union_fields:
|
|
90
|
+
nested_field_mappings[field_name] = union_fields
|
|
91
|
+
# Check if it's a Pydantic model
|
|
92
|
+
elif isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
|
93
|
+
nested_field_mappings[field_name] = set(annotation.model_fields.keys())
|
|
94
|
+
|
|
95
|
+
# If no nested fields detected, return as-is
|
|
96
|
+
if not nested_field_mappings:
|
|
97
|
+
return arguments
|
|
98
|
+
|
|
99
|
+
# Separate top-level args from nested args
|
|
100
|
+
result = {}
|
|
101
|
+
nested_args = {}
|
|
102
|
+
|
|
103
|
+
for key, value in arguments.items():
|
|
104
|
+
if key in top_level_fields:
|
|
105
|
+
# This is a top-level field
|
|
106
|
+
result[key] = value
|
|
107
|
+
else:
|
|
108
|
+
# Check if this belongs to a nested field
|
|
109
|
+
for nested_field, nested_keys in nested_field_mappings.items():
|
|
110
|
+
if key in nested_keys:
|
|
111
|
+
if nested_field not in nested_args:
|
|
112
|
+
nested_args[nested_field] = {}
|
|
113
|
+
nested_args[nested_field][key] = value
|
|
114
|
+
break
|
|
115
|
+
else:
|
|
116
|
+
# Unknown field - keep at top level (will fail validation later)
|
|
117
|
+
result[key] = value
|
|
118
|
+
|
|
119
|
+
# Add nested structures to result
|
|
120
|
+
result.update(nested_args)
|
|
121
|
+
|
|
122
|
+
return result
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
|
7
|
+
|
|
8
|
+
import orjson
|
|
9
|
+
import yaml # type: ignore[import-untyped]
|
|
10
|
+
|
|
11
|
+
__all__ = ("minimal_yaml",)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MinimalDumper(yaml.SafeDumper):
|
|
15
|
+
"""YAML dumper with minimal, readable settings."""
|
|
16
|
+
|
|
17
|
+
def ignore_aliases(self, data: Any) -> bool: # type: ignore[override]
|
|
18
|
+
"""Disable anchors/aliases (&id001, *id001) for repeated objects."""
|
|
19
|
+
return True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _represent_str(dumper: yaml.SafeDumper, data: str):
|
|
23
|
+
"""Use block scalars for multiline text; plain style otherwise."""
|
|
24
|
+
if "\n" in data:
|
|
25
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
|
|
26
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
MinimalDumper.add_representer(str, _represent_str)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_empty(x: Any) -> bool:
|
|
33
|
+
"""Define 'empty' for pruning. Keeps 0 and False."""
|
|
34
|
+
if x is None:
|
|
35
|
+
return True
|
|
36
|
+
if isinstance(x, str):
|
|
37
|
+
return x.strip() == ""
|
|
38
|
+
if isinstance(x, dict):
|
|
39
|
+
return len(x) == 0
|
|
40
|
+
if isinstance(x, list | tuple | set):
|
|
41
|
+
return len(x) == 0
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _prune(x: Any) -> Any:
|
|
46
|
+
"""Recursively remove empty leaves and empty containers."""
|
|
47
|
+
if isinstance(x, dict):
|
|
48
|
+
pruned = {k: _prune(v) for k, v in x.items() if not _is_empty(v)}
|
|
49
|
+
return {k: v for k, v in pruned.items() if not _is_empty(v)}
|
|
50
|
+
if isinstance(x, list):
|
|
51
|
+
pruned_list = [_prune(v) for v in x if not _is_empty(v)]
|
|
52
|
+
return [v for v in pruned_list if not _is_empty(v)]
|
|
53
|
+
if isinstance(x, tuple):
|
|
54
|
+
pruned_list = [_prune(v) for v in x if not _is_empty(v)]
|
|
55
|
+
return tuple(v for v in pruned_list if not _is_empty(v))
|
|
56
|
+
if isinstance(x, set):
|
|
57
|
+
pruned_set = {_prune(v) for v in x if not _is_empty(v)}
|
|
58
|
+
return {v for v in pruned_set if not _is_empty(v)}
|
|
59
|
+
return x
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def minimal_yaml(
|
|
63
|
+
value: Any,
|
|
64
|
+
*,
|
|
65
|
+
drop_empties: bool = True,
|
|
66
|
+
indent: int = 2,
|
|
67
|
+
line_width: int = 2**31 - 1,
|
|
68
|
+
sort_keys: bool = False,
|
|
69
|
+
) -> str:
|
|
70
|
+
"""Convert value to minimal YAML string."""
|
|
71
|
+
# Auto-parse JSON strings for convenience (fails gracefully on invalid JSON)
|
|
72
|
+
if isinstance(value, str):
|
|
73
|
+
try:
|
|
74
|
+
value = orjson.loads(value)
|
|
75
|
+
except orjson.JSONDecodeError:
|
|
76
|
+
# Not valid JSON - treat as plain string
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
data = _prune(value) if drop_empties else value
|
|
80
|
+
return yaml.dump(
|
|
81
|
+
data,
|
|
82
|
+
Dumper=MinimalDumper,
|
|
83
|
+
default_flow_style=False,
|
|
84
|
+
sort_keys=sort_keys,
|
|
85
|
+
allow_unicode=True,
|
|
86
|
+
indent=indent,
|
|
87
|
+
width=line_width,
|
|
88
|
+
)
|
|
@@ -0,0 +1,251 @@
|
|
|
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 importlib.util
|
|
7
|
+
import string
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, TypeVar
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, PydanticUserError
|
|
14
|
+
|
|
15
|
+
from lionherd_core import ln
|
|
16
|
+
|
|
17
|
+
B = TypeVar("B", bound=BaseModel)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_python_version_enum(python_version_module: Any) -> Any:
|
|
21
|
+
"""Auto-detect Python version from environment."""
|
|
22
|
+
version_info = sys.version_info
|
|
23
|
+
version_map = {
|
|
24
|
+
(3, 11): "PY_311",
|
|
25
|
+
(3, 12): "PY_312",
|
|
26
|
+
(3, 13): "PY_313",
|
|
27
|
+
(3, 14): "PY_314",
|
|
28
|
+
}
|
|
29
|
+
version_key = (version_info.major, version_info.minor)
|
|
30
|
+
enum_name = version_map.get(version_key, "PY_312")
|
|
31
|
+
return getattr(python_version_module, enum_name, python_version_module.PY_312)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _sanitize_model_name(name: str) -> str:
|
|
35
|
+
"""Extract valid Python identifier from string.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ValueError: If name cannot be converted to valid Python identifier.
|
|
39
|
+
"""
|
|
40
|
+
valid_chars = string.ascii_letters + string.digits + "_"
|
|
41
|
+
sanitized = "".join(c for c in name.replace(" ", "") if c in valid_chars)
|
|
42
|
+
|
|
43
|
+
if not sanitized or not sanitized[0].isalpha():
|
|
44
|
+
msg = f"Cannot extract valid Python identifier from: {name!r}"
|
|
45
|
+
raise ValueError(msg)
|
|
46
|
+
|
|
47
|
+
return sanitized
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _extract_model_name_from_schema(schema_dict: dict[str, Any], default: str) -> str:
|
|
51
|
+
"""Extract model name from schema title or use default."""
|
|
52
|
+
title = schema_dict.get("title")
|
|
53
|
+
if title and isinstance(title, str):
|
|
54
|
+
try:
|
|
55
|
+
return _sanitize_model_name(title)
|
|
56
|
+
except ValueError:
|
|
57
|
+
pass # Fall back to default if title cannot be sanitized
|
|
58
|
+
return default
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _prepare_schema_input(
|
|
62
|
+
schema: str | dict[str, Any],
|
|
63
|
+
model_name: str,
|
|
64
|
+
) -> tuple[str, dict[str, Any], str]:
|
|
65
|
+
"""Convert schema to JSON string and extract model name."""
|
|
66
|
+
if isinstance(schema, dict):
|
|
67
|
+
try:
|
|
68
|
+
schema_dict = schema
|
|
69
|
+
schema_json = ln.json_dumps(schema_dict)
|
|
70
|
+
except TypeError as e:
|
|
71
|
+
msg = "Invalid dictionary provided for schema"
|
|
72
|
+
raise ValueError(msg) from e
|
|
73
|
+
elif isinstance(schema, str):
|
|
74
|
+
try:
|
|
75
|
+
schema_dict = ln.to_dict(schema)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
msg = "Invalid JSON schema string provided"
|
|
78
|
+
raise ValueError(msg) from e
|
|
79
|
+
schema_json = schema
|
|
80
|
+
else:
|
|
81
|
+
msg = "Schema must be a JSON string or a dictionary"
|
|
82
|
+
raise TypeError(msg)
|
|
83
|
+
|
|
84
|
+
resolved_name = _extract_model_name_from_schema(schema_dict, model_name)
|
|
85
|
+
return schema_json, schema_dict, resolved_name
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _generate_model_code(
|
|
89
|
+
schema_json: str,
|
|
90
|
+
output_file: Path,
|
|
91
|
+
pydantic_version: Any,
|
|
92
|
+
python_version: Any,
|
|
93
|
+
generate_func: Any,
|
|
94
|
+
input_file_type_enum: Any,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Generate Pydantic model code from schema."""
|
|
97
|
+
try:
|
|
98
|
+
generate_func(
|
|
99
|
+
schema_json,
|
|
100
|
+
input_file_type=input_file_type_enum.JsonSchema,
|
|
101
|
+
input_filename="schema.json",
|
|
102
|
+
output=output_file,
|
|
103
|
+
output_model_type=pydantic_version,
|
|
104
|
+
target_python_version=python_version,
|
|
105
|
+
base_class="pydantic.BaseModel",
|
|
106
|
+
)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
msg = "Failed to generate model code from schema"
|
|
109
|
+
raise RuntimeError(msg) from e
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _load_generated_module(output_file: Path, module_name: str) -> Any:
|
|
113
|
+
"""Dynamically import generated Python module."""
|
|
114
|
+
if not output_file.exists():
|
|
115
|
+
msg = f"Generated model file not created: {output_file}"
|
|
116
|
+
raise FileNotFoundError(msg)
|
|
117
|
+
|
|
118
|
+
spec = importlib.util.spec_from_file_location(module_name, str(output_file))
|
|
119
|
+
if spec is None or spec.loader is None:
|
|
120
|
+
msg = f"Could not create module spec for {output_file}"
|
|
121
|
+
raise ImportError(msg)
|
|
122
|
+
|
|
123
|
+
module = importlib.util.module_from_spec(spec)
|
|
124
|
+
try:
|
|
125
|
+
spec.loader.exec_module(module)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
msg = f"Failed to load generated module from {output_file}"
|
|
128
|
+
raise RuntimeError(msg) from e
|
|
129
|
+
|
|
130
|
+
return module
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _extract_model_class(
|
|
134
|
+
module: Any,
|
|
135
|
+
model_name: str,
|
|
136
|
+
output_file: Path,
|
|
137
|
+
) -> type[BaseModel]:
|
|
138
|
+
"""Find BaseModel class in generated module."""
|
|
139
|
+
|
|
140
|
+
def _is_valid_model(obj: Any) -> bool:
|
|
141
|
+
return isinstance(obj, type) and issubclass(obj, BaseModel)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
model_class = getattr(module, model_name)
|
|
145
|
+
if not _is_valid_model(model_class):
|
|
146
|
+
msg = f"'{model_name}' is not a Pydantic BaseModel class"
|
|
147
|
+
raise TypeError(msg)
|
|
148
|
+
return model_class
|
|
149
|
+
except AttributeError:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
# Fallback to 'Model'
|
|
153
|
+
try:
|
|
154
|
+
model_class = module.Model
|
|
155
|
+
if not _is_valid_model(model_class):
|
|
156
|
+
msg = "Fallback 'Model' is not a Pydantic BaseModel class"
|
|
157
|
+
raise TypeError(msg)
|
|
158
|
+
return model_class
|
|
159
|
+
except AttributeError:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
# List available models for debugging
|
|
163
|
+
available = [
|
|
164
|
+
attr
|
|
165
|
+
for attr in dir(module)
|
|
166
|
+
if _is_valid_model(getattr(module, attr, None)) and getattr(module, attr) is not BaseModel
|
|
167
|
+
]
|
|
168
|
+
msg = (
|
|
169
|
+
f"Could not find '{model_name}' or 'Model' in {output_file}. "
|
|
170
|
+
f"Available BaseModel classes: {available}"
|
|
171
|
+
)
|
|
172
|
+
raise AttributeError(msg)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _rebuild_model(model_class: type[BaseModel], module: Any, model_name: str) -> None:
|
|
176
|
+
"""Rebuild model with proper type resolution."""
|
|
177
|
+
try:
|
|
178
|
+
model_class.model_rebuild(_types_namespace=module.__dict__, force=True)
|
|
179
|
+
except (PydanticUserError, NameError) as e:
|
|
180
|
+
msg = f"Type resolution failed during rebuild for {model_name}"
|
|
181
|
+
raise RuntimeError(msg) from e
|
|
182
|
+
except Exception as e:
|
|
183
|
+
msg = f"Unexpected error during model_rebuild for {model_name}"
|
|
184
|
+
raise RuntimeError(msg) from e
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def load_pydantic_model_from_schema(
|
|
188
|
+
schema: str | dict[str, Any],
|
|
189
|
+
model_name: str = "DynamicModel",
|
|
190
|
+
/,
|
|
191
|
+
pydantic_version: Any = None,
|
|
192
|
+
python_version: Any = None,
|
|
193
|
+
) -> type[BaseModel]:
|
|
194
|
+
"""Generate Pydantic model dynamically from JSON schema.
|
|
195
|
+
|
|
196
|
+
Creates model class via datamodel-code-generator, imports it,
|
|
197
|
+
and rebuilds with proper type resolution.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
schema: JSON schema (string or dict)
|
|
201
|
+
model_name: Base name for model (schema title takes precedence)
|
|
202
|
+
pydantic_version: DataModelType enum (default: PydanticV2BaseModel)
|
|
203
|
+
python_version: PythonVersion enum (default: PY_312)
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Dynamically created BaseModel class
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
ImportError: datamodel-code-generator not installed
|
|
210
|
+
ValueError: Invalid schema format
|
|
211
|
+
TypeError: Invalid schema type
|
|
212
|
+
RuntimeError: Generation or loading failed
|
|
213
|
+
|
|
214
|
+
Example:
|
|
215
|
+
>>> schema = {"title": "User", "type": "object", ...}
|
|
216
|
+
>>> UserModel = load_pydantic_model_from_schema(schema)
|
|
217
|
+
>>> user = UserModel(name="Alice", age=30)
|
|
218
|
+
"""
|
|
219
|
+
try:
|
|
220
|
+
from datamodel_code_generator import (
|
|
221
|
+
DataModelType,
|
|
222
|
+
InputFileType,
|
|
223
|
+
PythonVersion,
|
|
224
|
+
generate,
|
|
225
|
+
)
|
|
226
|
+
except ImportError as e:
|
|
227
|
+
msg = (
|
|
228
|
+
"datamodel-code-generator not installed. "
|
|
229
|
+
"Install with: pip install 'lionherd-core[schema-gen]' "
|
|
230
|
+
"or: pip install datamodel-code-generator"
|
|
231
|
+
)
|
|
232
|
+
raise ImportError(msg) from e
|
|
233
|
+
|
|
234
|
+
pydantic_version = pydantic_version or DataModelType.PydanticV2BaseModel
|
|
235
|
+
python_version = python_version or _get_python_version_enum(PythonVersion)
|
|
236
|
+
|
|
237
|
+
schema_json, _, resolved_name = _prepare_schema_input(schema, model_name)
|
|
238
|
+
|
|
239
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
240
|
+
tmpdir_path = Path(tmpdir)
|
|
241
|
+
output_file = tmpdir_path / f"{resolved_name.lower()}_model_{hash(schema_json)}.py"
|
|
242
|
+
module_name = output_file.stem
|
|
243
|
+
|
|
244
|
+
_generate_model_code(
|
|
245
|
+
schema_json, output_file, pydantic_version, python_version, generate, InputFileType
|
|
246
|
+
)
|
|
247
|
+
module = _load_generated_module(output_file, module_name)
|
|
248
|
+
model_class = _extract_model_class(module, resolved_name, output_file)
|
|
249
|
+
_rebuild_model(model_class, module, resolved_name)
|
|
250
|
+
|
|
251
|
+
return model_class
|