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,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