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.
@@ -1,212 +1,683 @@
1
- # Copyright (c) 2023 - 2025, HaiyangLi <quantocean.li at gmail dot com>
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 typing import Any
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
- from pydantic import ConfigDict, Field, field_validator, model_validator
9
- from pydantic.fields import FieldInfo
10
- from typing_extensions import Self
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 lionagi.libs.validate.common_field_validators import (
13
- validate_callable,
14
- validate_dict_kwargs_params,
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
- from lionagi.utils import UNDEFINED, UndefinedType
17
-
18
- from .schema_model import SchemaModel
19
-
20
- __all__ = ("FieldModel",)
21
-
22
-
23
- class FieldModel(SchemaModel):
24
- """Model for defining and configuring Pydantic field attributes.
25
-
26
- This class provides a structured way to define fields with comprehensive
27
- configuration options including type validation, default values, documentation,
28
- and validation rules.
29
-
30
- Args:
31
- name: Required field identifier.
32
- annotation: Field type annotation. Defaults to Any.
33
- default: Default value for the field.
34
- default_factory: Function to generate default values.
35
- validator: Optional validation function.
36
- validator_kwargs: Parameters for validator configuration.
37
- title: Human-readable field title.
38
- description: Detailed field description.
39
- examples: Example values for documentation.
40
- exclude: Whether to exclude from serialization.
41
- deprecated: Whether the field is deprecated.
42
- frozen: Whether the field is immutable.
43
- alias: Alternative field name.
44
- alias_priority: Priority for alias resolution.
45
-
46
- Examples:
47
- >>> field = FieldModel(
48
- ... name="age",
49
- ... annotation=int,
50
- ... default=0,
51
- ... description="User age in years",
52
- ... validator=lambda cls, v: v if v >= 0 else 0
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
- model_config = ConfigDict(
57
- extra="allow",
58
- validate_default=False,
59
- populate_by_name=True,
60
- arbitrary_types_allowed=True,
61
- use_enum_values=True,
62
- )
63
-
64
- # Required core attributes
65
- name: str | UndefinedType = Field(
66
- ...,
67
- description="Field identifier, used as attribute name",
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
- value: Validator kwargs to validate.
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
- Validated kwargs dictionary.
215
+ New FieldModel with nullable metadata added
139
216
  """
140
- return validate_dict_kwargs_params(cls, value)
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
- @field_validator("validator", mode="before")
143
- def _validate_field_validator(cls, value) -> Callable | Any:
144
- """Validate field validator function.
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
- value: Validator function to check.
240
+ f: Validator function that takes a value and returns bool
148
241
 
149
242
  Returns:
150
- Validated validator function.
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
- Raises:
153
- ValueError: If validator is not callable.
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
- return validate_callable(cls, value)
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
- @property
158
- def field_info(self) -> FieldInfo:
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
- Converts the current field configuration into a Pydantic FieldInfo object,
162
- handling annotation defaults and field attributes.
271
+ Args:
272
+ default: Default value for the field
163
273
 
164
274
  Returns:
165
- Configured Pydantic FieldInfo object.
275
+ New FieldModel with default added
166
276
  """
167
- annotation = (
168
- self.annotation if self.annotation is not UNDEFINED else Any
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
- field_obj = Field(**self.to_dict()) # type: ignore
171
- field_obj.annotation = annotation
172
- return field_obj
281
+ new_metadata = (*filtered_metadata, FieldMeta("default", default))
282
+ return type(self)(self.base_type, new_metadata)
173
283
 
174
- @property
175
- def field_validator(self) -> dict[str, Callable] | None:
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
- Generates a validator configuration dictionary if a validator function
179
- is defined, otherwise returns None.
287
+ Args:
288
+ frozen: Whether the field should be frozen
180
289
 
181
290
  Returns:
182
- Dictionary mapping validator name to validator function if defined,
183
- None otherwise.
291
+ New FieldModel with frozen setting
184
292
  """
185
- if self.validator is UNDEFINED:
186
- return None
187
- kwargs = self.validator_kwargs or {}
188
- return {
189
- f"{self.name}_validator": field_validator(self.name, **kwargs)(
190
- self.validator
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
- @model_validator(mode="after")
195
- def _validate_defaults(self) -> Self:
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
- Validates that default and default_factory are not both set, as this
199
- would create ambiguity about which default to use.
303
+ Args:
304
+ alias: Alternative name for the field
200
305
 
201
306
  Returns:
202
- The validated model instance.
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
- Raises:
205
- ValueError: If both default and default_factory are set.
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.default is not UNDEFINED
209
- and self.default_factory is not UNDEFINED
416
+ self.is_nullable
417
+ and "default" not in field_kwargs
418
+ and "default_factory" not in field_kwargs
210
419
  ):
211
- raise ValueError("Cannot have both default and default_factory")
212
- return self
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")