lionagi 0.13.0__py3-none-any.whl → 0.13.1__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.
- lionagi/_errors.py +4 -0
- lionagi/models/field_model.py +645 -174
- lionagi/models/model_params.py +17 -8
- lionagi/models/operable_model.py +4 -2
- lionagi/protocols/operatives/operative.py +205 -36
- lionagi/protocols/operatives/step.py +2 -1
- lionagi/service/connections/providers/claude_code_.py +3 -3
- lionagi/service/connections/providers/oai_.py +1 -13
- lionagi/traits/__init__.py +58 -0
- lionagi/traits/base.py +216 -0
- lionagi/traits/composer.py +343 -0
- lionagi/traits/protocols.py +495 -0
- lionagi/traits/registry.py +1071 -0
- lionagi/version.py +1 -1
- {lionagi-0.13.0.dist-info → lionagi-0.13.1.dist-info}/METADATA +6 -2
- {lionagi-0.13.0.dist-info → lionagi-0.13.1.dist-info}/RECORD +18 -13
- {lionagi-0.13.0.dist-info → lionagi-0.13.1.dist-info}/WHEEL +0 -0
- {lionagi-0.13.0.dist-info → lionagi-0.13.1.dist-info}/licenses/LICENSE +0 -0
lionagi/models/field_model.py
CHANGED
@@ -1,212 +1,683 @@
|
|
1
|
-
|
2
|
-
#
|
3
|
-
# SPDX-License-Identifier: Apache-2.0
|
1
|
+
"""Field model implementation for compositional field definitions.
|
4
2
|
|
3
|
+
This module provides FieldModel, a frozen dataclass that enables
|
4
|
+
compositional field definitions with lazy materialization and aggressive caching.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from __future__ import annotations
|
8
|
+
|
9
|
+
import os
|
10
|
+
import threading
|
11
|
+
from collections import OrderedDict
|
5
12
|
from collections.abc import Callable
|
6
|
-
from
|
13
|
+
from dataclasses import dataclass, field
|
14
|
+
from typing import Annotated, Any
|
15
|
+
|
16
|
+
from typing_extensions import Self, override
|
17
|
+
|
18
|
+
from .._errors import ValidationError
|
19
|
+
from ..utils import UNDEFINED
|
20
|
+
|
21
|
+
# Cache of valid Pydantic Field parameters
|
22
|
+
_PYDANTIC_FIELD_PARAMS: set[str] | None = None
|
23
|
+
|
7
24
|
|
8
|
-
|
9
|
-
|
10
|
-
|
25
|
+
def _get_pydantic_field_params() -> set[str]:
|
26
|
+
"""Get valid Pydantic Field parameters (cached)."""
|
27
|
+
global _PYDANTIC_FIELD_PARAMS
|
28
|
+
if _PYDANTIC_FIELD_PARAMS is None:
|
29
|
+
import inspect
|
11
30
|
|
12
|
-
from
|
13
|
-
|
14
|
-
|
31
|
+
from pydantic import Field as PydanticField
|
32
|
+
|
33
|
+
_PYDANTIC_FIELD_PARAMS = set(
|
34
|
+
inspect.signature(PydanticField).parameters.keys()
|
35
|
+
)
|
36
|
+
_PYDANTIC_FIELD_PARAMS.discard("kwargs")
|
37
|
+
return _PYDANTIC_FIELD_PARAMS
|
38
|
+
|
39
|
+
|
40
|
+
# Global cache for annotated types with bounded size
|
41
|
+
_MAX_CACHE_SIZE = int(os.environ.get("LIONAGI_FIELD_CACHE_SIZE", "10000"))
|
42
|
+
_annotated_cache: OrderedDict[tuple[type, tuple[FieldMeta, ...]], type] = (
|
43
|
+
OrderedDict()
|
15
44
|
)
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
class
|
24
|
-
"""
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
45
|
+
_cache_lock = threading.RLock() # Thread-safe access to cache
|
46
|
+
|
47
|
+
# Configurable limit on metadata items to prevent explosion
|
48
|
+
METADATA_LIMIT = int(os.environ.get("LIONAGI_FIELD_META_LIMIT", "10"))
|
49
|
+
|
50
|
+
|
51
|
+
@dataclass(slots=True, frozen=True)
|
52
|
+
class FieldMeta:
|
53
|
+
"""Immutable metadata container for field templates."""
|
54
|
+
|
55
|
+
key: str
|
56
|
+
value: Any
|
57
|
+
|
58
|
+
@override
|
59
|
+
def __hash__(self) -> int:
|
60
|
+
"""Make metadata hashable for caching.
|
61
|
+
|
62
|
+
Note: For callables, we hash by id to maintain identity semantics.
|
63
|
+
"""
|
64
|
+
# For callables, use their id
|
65
|
+
if callable(self.value):
|
66
|
+
return hash((self.key, id(self.value)))
|
67
|
+
# For other values, try to hash directly
|
68
|
+
try:
|
69
|
+
return hash((self.key, self.value))
|
70
|
+
except TypeError:
|
71
|
+
# Fallback for unhashable types
|
72
|
+
return hash((self.key, str(self.value)))
|
73
|
+
|
74
|
+
@override
|
75
|
+
def __eq__(self, other: object) -> bool:
|
76
|
+
"""Compare metadata for equality.
|
77
|
+
|
78
|
+
For callables, compare by id to increase cache hits when the same
|
79
|
+
validator instance is reused. For other values, use standard equality.
|
80
|
+
"""
|
81
|
+
if not isinstance(other, FieldMeta):
|
82
|
+
return NotImplemented
|
83
|
+
|
84
|
+
if self.key != other.key:
|
85
|
+
return False
|
86
|
+
|
87
|
+
# For callables, compare by identity
|
88
|
+
if callable(self.value) and callable(other.value):
|
89
|
+
return id(self.value) == id(other.value)
|
90
|
+
|
91
|
+
# For other values, use standard equality
|
92
|
+
return bool(self.value == other.value)
|
93
|
+
|
94
|
+
|
95
|
+
@dataclass(slots=True, frozen=True, init=False)
|
96
|
+
class FieldModel:
|
97
|
+
"""Field model for compositional field definitions.
|
98
|
+
|
99
|
+
This class provides a way to define field models that can be composed
|
100
|
+
and materialized lazily with aggressive caching for performance.
|
101
|
+
|
102
|
+
Attributes:
|
103
|
+
base_type: The base Python type for this field
|
104
|
+
metadata: Tuple of metadata to attach via Annotated
|
105
|
+
|
106
|
+
Environment Variables:
|
107
|
+
LIONAGI_FIELD_CACHE_SIZE: Maximum number of cached annotated types (default: 10000)
|
108
|
+
LIONAGI_FIELD_META_LIMIT: Maximum metadata items per template (default: 10)
|
109
|
+
|
110
|
+
Example:
|
111
|
+
>>> field = FieldModel(str)
|
112
|
+
>>> nullable_field = field.as_nullable()
|
113
|
+
>>> annotated_type = nullable_field.annotated()
|
54
114
|
"""
|
55
115
|
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
exclude=True,
|
69
|
-
)
|
70
|
-
|
71
|
-
annotation: type | Any = Field(
|
72
|
-
default=UNDEFINED,
|
73
|
-
description="Type annotation for the field",
|
74
|
-
exclude=True,
|
75
|
-
)
|
76
|
-
|
77
|
-
validator: Callable | Any = Field(
|
78
|
-
default=UNDEFINED,
|
79
|
-
description="Optional validation function",
|
80
|
-
exclude=True,
|
81
|
-
)
|
82
|
-
|
83
|
-
validator_kwargs: dict | None = Field(
|
84
|
-
default_factory=dict,
|
85
|
-
description="Configuration for validator decorator",
|
86
|
-
exclude=True,
|
87
|
-
)
|
88
|
-
|
89
|
-
# Field configuration
|
90
|
-
default: Any = Field(
|
91
|
-
default=UNDEFINED, description="Default value for the field"
|
92
|
-
)
|
93
|
-
|
94
|
-
default_factory: Callable | UndefinedType = Field(
|
95
|
-
default=UNDEFINED, description="Function to generate default values"
|
96
|
-
)
|
97
|
-
|
98
|
-
title: str | UndefinedType = Field(
|
99
|
-
default=UNDEFINED, description="Human-readable field title"
|
100
|
-
)
|
101
|
-
|
102
|
-
description: str | UndefinedType = Field(
|
103
|
-
default=UNDEFINED, description="Detailed field description"
|
104
|
-
)
|
105
|
-
|
106
|
-
examples: list | UndefinedType = Field(
|
107
|
-
default=UNDEFINED, description="Example values for documentation"
|
108
|
-
)
|
109
|
-
|
110
|
-
exclude: bool | UndefinedType = Field(
|
111
|
-
default=UNDEFINED, description="Whether to exclude from serialization"
|
112
|
-
)
|
113
|
-
|
114
|
-
deprecated: bool | UndefinedType = Field(
|
115
|
-
default=UNDEFINED, description="Whether the field is deprecated"
|
116
|
-
)
|
117
|
-
|
118
|
-
frozen: bool | UndefinedType = Field(
|
119
|
-
default=UNDEFINED, description="Whether the field is immutable"
|
120
|
-
)
|
121
|
-
|
122
|
-
alias: str | UndefinedType = Field(
|
123
|
-
default=UNDEFINED, description="Alternative field name"
|
124
|
-
)
|
125
|
-
|
126
|
-
alias_priority: int | UndefinedType = Field(
|
127
|
-
default=UNDEFINED, description="Priority for alias resolution"
|
128
|
-
)
|
129
|
-
|
130
|
-
@field_validator("validator_kwargs", mode="before")
|
131
|
-
def _validate_validator_kwargs(cls, value):
|
132
|
-
"""Validate validator kwargs.
|
116
|
+
base_type: type[Any]
|
117
|
+
metadata: tuple[FieldMeta, ...] = field(default_factory=tuple)
|
118
|
+
|
119
|
+
def __init__(
|
120
|
+
self,
|
121
|
+
base_type: type[Any] = None,
|
122
|
+
metadata: tuple[FieldMeta, ...] | None = None,
|
123
|
+
name: str = "field",
|
124
|
+
annotation: type[Any] = None,
|
125
|
+
**kwargs: Any,
|
126
|
+
) -> None:
|
127
|
+
"""Initialize FieldModel with optional metadata and kwargs.
|
133
128
|
|
134
129
|
Args:
|
135
|
-
|
130
|
+
base_type: The base Python type for this field (or use annotation)
|
131
|
+
metadata: Tuple of metadata to attach via Annotated
|
132
|
+
name: Field name for backward compatibility
|
133
|
+
annotation: Type annotation (alias for base_type for backward compatibility)
|
134
|
+
**kwargs: Additional metadata as keyword arguments that will be converted to FieldMeta
|
135
|
+
"""
|
136
|
+
# Handle backward compatibility: use annotation if base_type not provided
|
137
|
+
if base_type is None and annotation is not None:
|
138
|
+
base_type = annotation
|
139
|
+
elif base_type is None:
|
140
|
+
base_type = Any
|
141
|
+
|
142
|
+
# Convert kwargs to FieldMeta objects
|
143
|
+
meta_list = list(metadata) if metadata else []
|
144
|
+
|
145
|
+
# Handle special kwargs that trigger method calls
|
146
|
+
if "nullable" in kwargs and kwargs["nullable"]:
|
147
|
+
# Add nullable marker
|
148
|
+
meta_list.append(FieldMeta("nullable", True))
|
149
|
+
kwargs.pop("nullable")
|
150
|
+
if "listable" in kwargs and kwargs["listable"]:
|
151
|
+
# Change base type to list
|
152
|
+
base_type = list[base_type] # type: ignore
|
153
|
+
meta_list.append(FieldMeta("listable", True))
|
154
|
+
kwargs.pop("listable")
|
155
|
+
|
156
|
+
# Add name as metadata if provided
|
157
|
+
if name != "field": # Only add if non-default
|
158
|
+
meta_list.append(FieldMeta("name", name))
|
159
|
+
|
160
|
+
# Validate for conflicting defaults
|
161
|
+
if "default" in kwargs and "default_factory" in kwargs:
|
162
|
+
raise ValueError("Cannot have both default and default_factory")
|
163
|
+
|
164
|
+
# Validate validators if provided
|
165
|
+
if "validator" in kwargs:
|
166
|
+
validator = kwargs["validator"]
|
167
|
+
if not callable(validator) and not (
|
168
|
+
isinstance(validator, list)
|
169
|
+
and all(callable(v) for v in validator)
|
170
|
+
):
|
171
|
+
raise ValueError(
|
172
|
+
"Validators must be a list of functions or a function"
|
173
|
+
)
|
174
|
+
|
175
|
+
# Convert remaining kwargs to FieldMeta
|
176
|
+
for key, value in kwargs.items():
|
177
|
+
meta_list.append(FieldMeta(key, value))
|
178
|
+
|
179
|
+
# Use object.__setattr__ to set frozen dataclass fields
|
180
|
+
object.__setattr__(self, "base_type", base_type)
|
181
|
+
object.__setattr__(self, "metadata", tuple(meta_list))
|
182
|
+
|
183
|
+
# Manually call __post_init__ since we have a custom __init__
|
184
|
+
self.__post_init__()
|
185
|
+
|
186
|
+
def __getattr__(self, name: str) -> Any:
|
187
|
+
"""Handle access to custom attributes stored in metadata."""
|
188
|
+
# Check if the attribute exists in metadata
|
189
|
+
for meta in self.metadata:
|
190
|
+
if meta.key == name:
|
191
|
+
return meta.value
|
192
|
+
# If not found, raise AttributeError as usual
|
193
|
+
raise AttributeError(
|
194
|
+
f"'{self.__class__.__name__}' object has no attribute '{name}'"
|
195
|
+
)
|
196
|
+
|
197
|
+
def __post_init__(self) -> None:
|
198
|
+
"""Validate metadata limit after initialization."""
|
199
|
+
if len(self.metadata) > METADATA_LIMIT:
|
200
|
+
import warnings
|
201
|
+
|
202
|
+
warnings.warn(
|
203
|
+
f"FieldModel has {len(self.metadata)} metadata items, "
|
204
|
+
f"exceeding recommended limit of {METADATA_LIMIT}. "
|
205
|
+
"Consider simplifying the field definition.",
|
206
|
+
stacklevel=3, # Show user's call site, not __post_init__
|
207
|
+
)
|
208
|
+
|
209
|
+
# ---- factory helpers -------------------------------------------------- #
|
210
|
+
|
211
|
+
def as_nullable(self) -> Self:
|
212
|
+
"""Create a new field model that allows None values.
|
136
213
|
|
137
214
|
Returns:
|
138
|
-
|
215
|
+
New FieldModel with nullable metadata added
|
139
216
|
"""
|
140
|
-
|
217
|
+
# Add nullable marker to metadata
|
218
|
+
new_metadata = (*self.metadata, FieldMeta("nullable", True))
|
219
|
+
return type(self)(self.base_type, new_metadata)
|
220
|
+
|
221
|
+
def as_listable(self) -> Self:
|
222
|
+
"""Create a new field model that wraps the type in a list.
|
223
|
+
|
224
|
+
Note: This produces list[T] which is a types.GenericAlias in Python 3.11+,
|
225
|
+
not typing.List. This is intentional for better performance and native support.
|
141
226
|
|
142
|
-
|
143
|
-
|
144
|
-
"""
|
227
|
+
Returns:
|
228
|
+
New FieldModel with list wrapper
|
229
|
+
"""
|
230
|
+
# Change base type to list of current type
|
231
|
+
new_base = list[self.base_type] # type: ignore
|
232
|
+
# Add listable marker to metadata
|
233
|
+
new_metadata = (*self.metadata, FieldMeta("listable", True))
|
234
|
+
return type(self)(new_base, new_metadata)
|
235
|
+
|
236
|
+
def with_validator(self, f: Callable[[Any], bool]) -> Self:
|
237
|
+
"""Add a validator function to this field model.
|
145
238
|
|
146
239
|
Args:
|
147
|
-
|
240
|
+
f: Validator function that takes a value and returns bool
|
148
241
|
|
149
242
|
Returns:
|
150
|
-
|
243
|
+
New FieldModel with validator added
|
244
|
+
"""
|
245
|
+
# Add validator to metadata
|
246
|
+
new_metadata = (*self.metadata, FieldMeta("validator", f))
|
247
|
+
return type(self)(self.base_type, new_metadata)
|
151
248
|
|
152
|
-
|
153
|
-
|
249
|
+
def with_description(self, description: str) -> Self:
|
250
|
+
"""Add a description to this field model.
|
251
|
+
|
252
|
+
Args:
|
253
|
+
description: Human-readable description of the field
|
254
|
+
|
255
|
+
Returns:
|
256
|
+
New FieldModel with description added
|
154
257
|
"""
|
155
|
-
|
258
|
+
# Remove any existing description
|
259
|
+
filtered_metadata = tuple(
|
260
|
+
m for m in self.metadata if m.key != "description"
|
261
|
+
)
|
262
|
+
new_metadata = (
|
263
|
+
*filtered_metadata,
|
264
|
+
FieldMeta("description", description),
|
265
|
+
)
|
266
|
+
return type(self)(self.base_type, new_metadata)
|
156
267
|
|
157
|
-
|
158
|
-
|
159
|
-
"""Generate Pydantic FieldInfo from current configuration.
|
268
|
+
def with_default(self, default: Any) -> Self:
|
269
|
+
"""Add a default value to this field model.
|
160
270
|
|
161
|
-
|
162
|
-
|
271
|
+
Args:
|
272
|
+
default: Default value for the field
|
163
273
|
|
164
274
|
Returns:
|
165
|
-
|
275
|
+
New FieldModel with default added
|
166
276
|
"""
|
167
|
-
|
168
|
-
|
277
|
+
# Remove any existing default metadata to avoid conflicts
|
278
|
+
filtered_metadata = tuple(
|
279
|
+
m for m in self.metadata if m.key != "default"
|
169
280
|
)
|
170
|
-
|
171
|
-
|
172
|
-
return field_obj
|
281
|
+
new_metadata = (*filtered_metadata, FieldMeta("default", default))
|
282
|
+
return type(self)(self.base_type, new_metadata)
|
173
283
|
|
174
|
-
|
175
|
-
|
176
|
-
"""Create field validator configuration.
|
284
|
+
def with_frozen(self, frozen: bool = True) -> Self:
|
285
|
+
"""Mark this field as frozen (immutable after creation).
|
177
286
|
|
178
|
-
|
179
|
-
|
287
|
+
Args:
|
288
|
+
frozen: Whether the field should be frozen
|
180
289
|
|
181
290
|
Returns:
|
182
|
-
|
183
|
-
None otherwise.
|
291
|
+
New FieldModel with frozen setting
|
184
292
|
"""
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
)
|
192
|
-
}
|
293
|
+
# Remove any existing frozen metadata
|
294
|
+
filtered_metadata = tuple(
|
295
|
+
m for m in self.metadata if m.key != "frozen"
|
296
|
+
)
|
297
|
+
new_metadata = (*filtered_metadata, FieldMeta("frozen", frozen))
|
298
|
+
return type(self)(self.base_type, new_metadata)
|
193
299
|
|
194
|
-
|
195
|
-
|
196
|
-
"""Ensure default value configuration is valid.
|
300
|
+
def with_alias(self, alias: str) -> Self:
|
301
|
+
"""Add an alias to this field.
|
197
302
|
|
198
|
-
|
199
|
-
|
303
|
+
Args:
|
304
|
+
alias: Alternative name for the field
|
200
305
|
|
201
306
|
Returns:
|
202
|
-
|
307
|
+
New FieldModel with alias
|
308
|
+
"""
|
309
|
+
filtered_metadata = tuple(m for m in self.metadata if m.key != "alias")
|
310
|
+
new_metadata = (*filtered_metadata, FieldMeta("alias", alias))
|
311
|
+
return type(self)(self.base_type, new_metadata)
|
203
312
|
|
204
|
-
|
205
|
-
|
313
|
+
def with_title(self, title: str) -> Self:
|
314
|
+
"""Add a title to this field.
|
315
|
+
|
316
|
+
Args:
|
317
|
+
title: Human-readable title for the field
|
318
|
+
|
319
|
+
Returns:
|
320
|
+
New FieldModel with title
|
321
|
+
"""
|
322
|
+
filtered_metadata = tuple(m for m in self.metadata if m.key != "title")
|
323
|
+
new_metadata = (*filtered_metadata, FieldMeta("title", title))
|
324
|
+
return type(self)(self.base_type, new_metadata)
|
325
|
+
|
326
|
+
def with_exclude(self, exclude: bool = True) -> Self:
|
327
|
+
"""Mark this field to be excluded from serialization.
|
328
|
+
|
329
|
+
Args:
|
330
|
+
exclude: Whether to exclude the field
|
331
|
+
|
332
|
+
Returns:
|
333
|
+
New FieldModel with exclude setting
|
334
|
+
"""
|
335
|
+
filtered_metadata = tuple(
|
336
|
+
m for m in self.metadata if m.key != "exclude"
|
337
|
+
)
|
338
|
+
new_metadata = (*filtered_metadata, FieldMeta("exclude", exclude))
|
339
|
+
return type(self)(self.base_type, new_metadata)
|
340
|
+
|
341
|
+
def with_metadata(self, key: str, value: Any) -> Self:
|
342
|
+
"""Add custom metadata to this field.
|
343
|
+
|
344
|
+
Args:
|
345
|
+
key: Metadata key
|
346
|
+
value: Metadata value
|
347
|
+
|
348
|
+
Returns:
|
349
|
+
New FieldModel with custom metadata
|
206
350
|
"""
|
351
|
+
# Replace existing metadata with same key
|
352
|
+
filtered_metadata = tuple(m for m in self.metadata if m.key != key)
|
353
|
+
new_metadata = (*filtered_metadata, FieldMeta(key, value))
|
354
|
+
return type(self)(self.base_type, new_metadata)
|
355
|
+
|
356
|
+
def with_json_schema_extra(self, **kwargs: Any) -> Self:
|
357
|
+
"""Add JSON schema extra information.
|
358
|
+
|
359
|
+
Args:
|
360
|
+
**kwargs: Key-value pairs for json_schema_extra
|
361
|
+
|
362
|
+
Returns:
|
363
|
+
New FieldModel with json_schema_extra
|
364
|
+
"""
|
365
|
+
# Get existing json_schema_extra or create new dict
|
366
|
+
existing = self.extract_metadata("json_schema_extra") or {}
|
367
|
+
updated = {**existing, **kwargs}
|
368
|
+
|
369
|
+
filtered_metadata = tuple(
|
370
|
+
m for m in self.metadata if m.key != "json_schema_extra"
|
371
|
+
)
|
372
|
+
new_metadata = (
|
373
|
+
*filtered_metadata,
|
374
|
+
FieldMeta("json_schema_extra", updated),
|
375
|
+
)
|
376
|
+
return type(self)(self.base_type, new_metadata)
|
377
|
+
|
378
|
+
def create_field(self) -> Any:
|
379
|
+
"""Create a Pydantic FieldInfo object from this template.
|
380
|
+
|
381
|
+
Returns:
|
382
|
+
A Pydantic FieldInfo object with all metadata applied
|
383
|
+
"""
|
384
|
+
from pydantic import Field as PydanticField
|
385
|
+
|
386
|
+
# Get valid Pydantic Field parameters (cached)
|
387
|
+
pydantic_field_params = _get_pydantic_field_params()
|
388
|
+
|
389
|
+
# Extract metadata for FieldInfo
|
390
|
+
field_kwargs = {}
|
391
|
+
|
392
|
+
for meta in self.metadata:
|
393
|
+
if meta.key == "default":
|
394
|
+
# Handle callable defaults as default_factory
|
395
|
+
if callable(meta.value):
|
396
|
+
field_kwargs["default_factory"] = meta.value
|
397
|
+
else:
|
398
|
+
field_kwargs["default"] = meta.value
|
399
|
+
elif meta.key == "validator":
|
400
|
+
# Validators are handled separately in create_model
|
401
|
+
continue
|
402
|
+
elif meta.key in pydantic_field_params:
|
403
|
+
# Pass through standard Pydantic field attributes
|
404
|
+
field_kwargs[meta.key] = meta.value
|
405
|
+
elif meta.key in {"nullable", "listable"}:
|
406
|
+
# These are FieldTemplate markers, don't pass to FieldInfo
|
407
|
+
pass
|
408
|
+
else:
|
409
|
+
# Any other metadata goes in json_schema_extra
|
410
|
+
if "json_schema_extra" not in field_kwargs:
|
411
|
+
field_kwargs["json_schema_extra"] = {}
|
412
|
+
field_kwargs["json_schema_extra"][meta.key] = meta.value
|
413
|
+
|
414
|
+
# Handle nullable case - ensure default is set if not already
|
207
415
|
if (
|
208
|
-
self.
|
209
|
-
and
|
416
|
+
self.is_nullable
|
417
|
+
and "default" not in field_kwargs
|
418
|
+
and "default_factory" not in field_kwargs
|
210
419
|
):
|
211
|
-
|
212
|
-
|
420
|
+
field_kwargs["default"] = None
|
421
|
+
|
422
|
+
field_info = PydanticField(**field_kwargs)
|
423
|
+
|
424
|
+
# Set the annotation from base_type for backward compatibility
|
425
|
+
field_info.annotation = self.base_type
|
426
|
+
|
427
|
+
return field_info
|
428
|
+
|
429
|
+
# ---- materialization -------------------------------------------------- #
|
430
|
+
|
431
|
+
def annotated(self) -> type[Any]:
|
432
|
+
"""Materialize this template into an Annotated type.
|
433
|
+
|
434
|
+
This method is cached to ensure repeated calls return the same
|
435
|
+
type object for performance and identity checks. The cache is bounded
|
436
|
+
using LRU eviction to prevent unbounded memory growth.
|
437
|
+
|
438
|
+
Returns:
|
439
|
+
Annotated type with all metadata attached
|
440
|
+
"""
|
441
|
+
# Check cache first with thread safety
|
442
|
+
cache_key = (self.base_type, self.metadata)
|
443
|
+
|
444
|
+
with _cache_lock:
|
445
|
+
if cache_key in _annotated_cache:
|
446
|
+
# Move to end to mark as recently used
|
447
|
+
_annotated_cache.move_to_end(cache_key)
|
448
|
+
return _annotated_cache[cache_key]
|
449
|
+
|
450
|
+
# Handle nullable case - wrap in Optional-like union
|
451
|
+
actual_type = self.base_type
|
452
|
+
if any(m.key == "nullable" and m.value for m in self.metadata):
|
453
|
+
# Use union syntax for nullable
|
454
|
+
actual_type = actual_type | None # type: ignore
|
455
|
+
|
456
|
+
if self.metadata:
|
457
|
+
# Python 3.10 doesn't support unpacking in Annotated, so we need to build it differently
|
458
|
+
# We'll use Annotated.__class_getitem__ to build the type dynamically
|
459
|
+
args = [actual_type] + list(self.metadata)
|
460
|
+
result = Annotated.__class_getitem__(tuple(args)) # type: ignore
|
461
|
+
else:
|
462
|
+
result = actual_type # type: ignore[misc]
|
463
|
+
|
464
|
+
# Cache the result with LRU eviction
|
465
|
+
_annotated_cache[cache_key] = result # type: ignore[assignment]
|
466
|
+
|
467
|
+
# Evict oldest if cache is too large (guard against empty cache)
|
468
|
+
while len(_annotated_cache) > _MAX_CACHE_SIZE:
|
469
|
+
try:
|
470
|
+
_annotated_cache.popitem(last=False) # Remove oldest
|
471
|
+
except KeyError:
|
472
|
+
# Cache became empty during race, safe to continue
|
473
|
+
break
|
474
|
+
|
475
|
+
return result # type: ignore[return-value]
|
476
|
+
|
477
|
+
def extract_metadata(self, key: str) -> Any:
|
478
|
+
"""Extract metadata value by key.
|
479
|
+
|
480
|
+
Args:
|
481
|
+
key: Metadata key to look for
|
482
|
+
|
483
|
+
Returns:
|
484
|
+
Metadata value if found, None otherwise
|
485
|
+
"""
|
486
|
+
for m in self.metadata:
|
487
|
+
if m.key == key:
|
488
|
+
return m.value
|
489
|
+
return None
|
490
|
+
|
491
|
+
def has_validator(self) -> bool:
|
492
|
+
"""Check if this template has a validator.
|
493
|
+
|
494
|
+
Returns:
|
495
|
+
True if validator exists in metadata
|
496
|
+
"""
|
497
|
+
return any(m.key == "validator" for m in self.metadata)
|
498
|
+
|
499
|
+
def is_valid(self, value: Any) -> bool:
|
500
|
+
"""Check if a value is valid against all validators in this template.
|
501
|
+
|
502
|
+
Args:
|
503
|
+
value: Value to validate
|
504
|
+
|
505
|
+
Returns:
|
506
|
+
True if all validators pass, False otherwise
|
507
|
+
"""
|
508
|
+
for m in self.metadata:
|
509
|
+
if m.key == "validator":
|
510
|
+
validator = m.value
|
511
|
+
if not validator(value):
|
512
|
+
return False
|
513
|
+
return True
|
514
|
+
|
515
|
+
def validate(self, value: Any, field_name: str | None = None) -> None:
|
516
|
+
"""Validate a value against all validators, raising ValidationError on failure.
|
517
|
+
|
518
|
+
Args:
|
519
|
+
value: Value to validate
|
520
|
+
field_name: Optional field name for error context
|
521
|
+
|
522
|
+
Raises:
|
523
|
+
ValidationError: If any validator fails
|
524
|
+
"""
|
525
|
+
# Early exit if no validators
|
526
|
+
if not self.has_validator():
|
527
|
+
return
|
528
|
+
|
529
|
+
for i, m in enumerate(self.metadata):
|
530
|
+
if m.key == "validator":
|
531
|
+
validator = m.value
|
532
|
+
# Try to call validator with correct signature
|
533
|
+
try:
|
534
|
+
# Try Pydantic-style validator (cls, value) - pass None for cls
|
535
|
+
result = validator(None, value)
|
536
|
+
# For Pydantic validators that return the value or raise exceptions,
|
537
|
+
# if we get here without exception, validation passed
|
538
|
+
except TypeError:
|
539
|
+
# Try simple validator that just takes value and returns boolean
|
540
|
+
result = validator(value)
|
541
|
+
# If validator returns False (simple boolean validator), raise error
|
542
|
+
if result is False:
|
543
|
+
validator_name = getattr(
|
544
|
+
validator, "__name__", f"validator_{i}"
|
545
|
+
)
|
546
|
+
raise ValidationError(
|
547
|
+
f"Validation failed for {validator_name}",
|
548
|
+
field_name=field_name,
|
549
|
+
value=value,
|
550
|
+
validator_name=validator_name,
|
551
|
+
)
|
552
|
+
except Exception:
|
553
|
+
# If validator raises any other exception, let it propagate
|
554
|
+
raise
|
555
|
+
|
556
|
+
@property
|
557
|
+
def is_nullable(self) -> bool:
|
558
|
+
"""Check if this field allows None values."""
|
559
|
+
return any(m.key == "nullable" and m.value for m in self.metadata)
|
560
|
+
|
561
|
+
@property
|
562
|
+
def is_listable(self) -> bool:
|
563
|
+
"""Check if this field is a list type."""
|
564
|
+
return any(m.key == "listable" and m.value for m in self.metadata)
|
565
|
+
|
566
|
+
@override
|
567
|
+
def __repr__(self) -> str:
|
568
|
+
"""String representation of the field model."""
|
569
|
+
attrs = []
|
570
|
+
if self.is_nullable:
|
571
|
+
attrs.append("nullable")
|
572
|
+
if self.is_listable:
|
573
|
+
attrs.append("listable")
|
574
|
+
if self.has_validator():
|
575
|
+
attrs.append("validated")
|
576
|
+
|
577
|
+
attr_str = f" [{', '.join(attrs)}]" if attrs else ""
|
578
|
+
return f"FieldModel({self.base_type.__name__}{attr_str})"
|
579
|
+
|
580
|
+
|
581
|
+
# Add backward compatibility properties and methods
|
582
|
+
@property
|
583
|
+
def name(self) -> str:
|
584
|
+
"""Get field name from metadata for backward compatibility."""
|
585
|
+
return self.extract_metadata("name") or "field"
|
586
|
+
|
587
|
+
|
588
|
+
@property
|
589
|
+
def default(self) -> Any:
|
590
|
+
"""Get field default value from metadata for backward compatibility."""
|
591
|
+
return self.extract_metadata("default")
|
592
|
+
|
593
|
+
|
594
|
+
@property
|
595
|
+
def title(self) -> str | None:
|
596
|
+
"""Get field title from metadata for backward compatibility."""
|
597
|
+
return self.extract_metadata("title")
|
598
|
+
|
599
|
+
|
600
|
+
@property
|
601
|
+
def description(self) -> str | None:
|
602
|
+
"""Get field description from metadata for backward compatibility."""
|
603
|
+
return self.extract_metadata("description")
|
604
|
+
|
605
|
+
|
606
|
+
@property
|
607
|
+
def annotation(self) -> type[Any]:
|
608
|
+
"""Get field annotation (base_type) for backward compatibility."""
|
609
|
+
return self.base_type
|
610
|
+
|
611
|
+
|
612
|
+
@property
|
613
|
+
def field_info(self) -> Any:
|
614
|
+
"""Generate Pydantic FieldInfo from current configuration for backward compatibility.
|
615
|
+
|
616
|
+
This property provides compatibility with the old FieldModel API.
|
617
|
+
|
618
|
+
Returns:
|
619
|
+
Configured Pydantic FieldInfo object.
|
620
|
+
"""
|
621
|
+
return self.create_field()
|
622
|
+
|
623
|
+
|
624
|
+
@property
|
625
|
+
def field_validator(self) -> dict[str, Any] | None:
|
626
|
+
"""Create field validator configuration for backward compatibility.
|
627
|
+
|
628
|
+
Returns:
|
629
|
+
Dictionary mapping validator name to validator function if defined,
|
630
|
+
None otherwise.
|
631
|
+
"""
|
632
|
+
if not self.has_validator():
|
633
|
+
return None
|
634
|
+
|
635
|
+
# Extract validators and create field_validator config
|
636
|
+
from pydantic import field_validator
|
637
|
+
|
638
|
+
validators = {}
|
639
|
+
|
640
|
+
# Get field name from metadata or use default
|
641
|
+
field_name = self.extract_metadata("name") or "field"
|
642
|
+
|
643
|
+
for meta in self.metadata:
|
644
|
+
if meta.key == "validator":
|
645
|
+
validator_name = f"{field_name}_validator"
|
646
|
+
validators[validator_name] = field_validator(field_name)(
|
647
|
+
meta.value
|
648
|
+
)
|
649
|
+
|
650
|
+
return validators if validators else None
|
651
|
+
|
652
|
+
|
653
|
+
def to_dict(self) -> dict[str, Any]:
|
654
|
+
"""Convert field model to dictionary for backward compatibility.
|
655
|
+
|
656
|
+
Returns:
|
657
|
+
Dictionary representation of field configuration.
|
658
|
+
"""
|
659
|
+
result = {}
|
660
|
+
|
661
|
+
# Convert metadata to dictionary
|
662
|
+
for meta in self.metadata:
|
663
|
+
if meta.key not in ("nullable", "listable", "validator"):
|
664
|
+
result[meta.key] = meta.value
|
665
|
+
|
666
|
+
# Add annotation if available
|
667
|
+
if hasattr(self, "annotation"):
|
668
|
+
result["annotation"] = self.base_type
|
669
|
+
|
670
|
+
return result
|
671
|
+
|
672
|
+
|
673
|
+
# Monkey patch the methods onto FieldModel for backward compatibility
|
674
|
+
FieldModel.name = name
|
675
|
+
FieldModel.default = default
|
676
|
+
FieldModel.title = title
|
677
|
+
FieldModel.description = description
|
678
|
+
FieldModel.annotation = annotation
|
679
|
+
FieldModel.field_info = field_info
|
680
|
+
FieldModel.field_validator = field_validator
|
681
|
+
FieldModel.to_dict = to_dict
|
682
|
+
|
683
|
+
__all__ = ("FieldModel", "FieldMeta")
|