lionagi 0.18.1__py3-none-any.whl → 0.18.2__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.
@@ -0,0 +1,221 @@
1
+ """Operable - Container for Spec collections with model generation.
2
+
3
+ This module provides the Operable class for managing collections of Spec objects
4
+ and generating framework-specific models via adapters.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING, Literal
11
+
12
+ from ._sentinel import MaybeUnset, Unset
13
+
14
+ if TYPE_CHECKING:
15
+ from .spec import Spec
16
+
17
+ __all__ = ("Operable",)
18
+
19
+
20
+ @dataclass(frozen=True, slots=True, init=False)
21
+ class Operable:
22
+ """Collection of Spec objects with model generation capabilities.
23
+
24
+ Operable manages an ordered collection of Spec objects and provides
25
+ methods to generate framework-specific models via adapters.
26
+
27
+ Attributes:
28
+ __op_fields__: Ordered tuple of Spec objects
29
+ name: Optional name for this operable
30
+
31
+ Example:
32
+ >>> from lionagi.ln.types import Spec, Operable
33
+ >>> specs = (
34
+ ... Spec(str, name="username"),
35
+ ... Spec(int, name="age"),
36
+ ... )
37
+ >>> operable = Operable(specs, name="User")
38
+ >>> UserModel = operable.create_model(adapter="pydantic")
39
+ """
40
+
41
+ __op_fields__: tuple[Spec, ...]
42
+ name: str | None
43
+
44
+ def __init__(
45
+ self,
46
+ specs: tuple[Spec, ...] | list[Spec] = (),
47
+ *,
48
+ name: str | None = None,
49
+ ):
50
+ """Initialize Operable with specs.
51
+
52
+ Args:
53
+ specs: Tuple or list of Spec objects
54
+ name: Optional name for this operable
55
+
56
+ Raises:
57
+ TypeError: If specs contains non-Spec objects
58
+ ValueError: If specs contains duplicate field names
59
+ """
60
+ # Import here to avoid circular import
61
+ from .spec import Spec
62
+
63
+ # Convert to tuple if list
64
+ if isinstance(specs, list):
65
+ specs = tuple(specs)
66
+
67
+ # Validate all items are Spec objects
68
+ for i, item in enumerate(specs):
69
+ if not isinstance(item, Spec):
70
+ raise TypeError(
71
+ f"All specs must be Spec objects, got {type(item).__name__} "
72
+ f"at index {i}"
73
+ )
74
+
75
+ # Check for duplicate names
76
+ names = [s.name for s in specs if s.name is not None]
77
+ if len(names) != len(set(names)):
78
+ from collections import Counter
79
+
80
+ duplicates = [
81
+ name for name, count in Counter(names).items() if count > 1
82
+ ]
83
+ raise ValueError(
84
+ f"Duplicate field names found: {duplicates}. "
85
+ "Each spec must have a unique name."
86
+ )
87
+
88
+ object.__setattr__(self, "__op_fields__", specs)
89
+ object.__setattr__(self, "name", name)
90
+
91
+ def allowed(self) -> set[str]:
92
+ """Get set of allowed field names.
93
+
94
+ Returns:
95
+ Set of field names from specs
96
+ """
97
+ return {i.name for i in self.__op_fields__}
98
+
99
+ def check_allowed(self, *args, as_boolean: bool = False):
100
+ """Check if field names are allowed.
101
+
102
+ Args:
103
+ *args: Field names to check
104
+ as_boolean: If True, return bool instead of raising
105
+
106
+ Returns:
107
+ True if all allowed, False if as_boolean=True and not all allowed
108
+
109
+ Raises:
110
+ ValueError: If as_boolean=False and not all allowed
111
+ """
112
+ if not set(args).issubset(self.allowed()):
113
+ if as_boolean:
114
+ return False
115
+ raise ValueError(
116
+ "Some specified fields are not allowed: "
117
+ f"{set(args).difference(self.allowed())}"
118
+ )
119
+ return True
120
+
121
+ def get(self, key: str, /, default=Unset) -> MaybeUnset[Spec]:
122
+ """Get Spec by field name.
123
+
124
+ Args:
125
+ key: Field name
126
+ default: Default value if not found
127
+
128
+ Returns:
129
+ Spec object or default
130
+ """
131
+ if not self.check_allowed(key, as_boolean=True):
132
+ return default
133
+ for i in self.__op_fields__:
134
+ if i.name == key:
135
+ return i
136
+ return default
137
+
138
+ def get_specs(
139
+ self,
140
+ *,
141
+ include: set[str] | None = None,
142
+ exclude: set[str] | None = None,
143
+ ) -> tuple[Spec, ...]:
144
+ """Get filtered tuple of Specs.
145
+
146
+ Args:
147
+ include: Only include these field names
148
+ exclude: Exclude these field names
149
+
150
+ Returns:
151
+ Filtered tuple of Specs
152
+
153
+ Raises:
154
+ ValueError: If both include and exclude specified, or if invalid names
155
+ """
156
+ if include is not None and exclude is not None:
157
+ raise ValueError("Cannot specify both include and exclude")
158
+
159
+ if include:
160
+ if self.check_allowed(*include, as_boolean=True) is False:
161
+ raise ValueError(
162
+ "Some specified fields are not allowed: "
163
+ f"{set(include).difference(self.allowed())}"
164
+ )
165
+ return tuple(
166
+ self.get(i) for i in include if self.get(i) is not Unset
167
+ )
168
+
169
+ if exclude:
170
+ _discards = {
171
+ self.get(i) for i in exclude if self.get(i) is not Unset
172
+ }
173
+ return tuple(s for s in self.__op_fields__ if s not in _discards)
174
+
175
+ return self.__op_fields__
176
+
177
+ def create_model(
178
+ self,
179
+ adapter: Literal["pydantic"] = "pydantic",
180
+ model_name: str | None = None,
181
+ include: set[str] | None = None,
182
+ exclude: set[str] | None = None,
183
+ **kw,
184
+ ):
185
+ """Create framework-specific model from specs.
186
+
187
+ Args:
188
+ adapter: Adapter type (currently only "pydantic")
189
+ model_name: Name for generated model
190
+ include: Only include these fields
191
+ exclude: Exclude these fields
192
+ **kw: Additional adapter-specific kwargs
193
+
194
+ Returns:
195
+ Generated model class
196
+
197
+ Raises:
198
+ ImportError: If adapter not installed
199
+ ValueError: If adapter not supported
200
+ """
201
+ match adapter:
202
+ case "pydantic":
203
+ try:
204
+ from lionagi.adapters.spec_adapters import (
205
+ PydanticSpecAdapter,
206
+ )
207
+ except ImportError as e:
208
+ raise ImportError(
209
+ "PydanticSpecAdapter requires Pydantic. "
210
+ "Install with: pip install pydantic"
211
+ ) from e
212
+
213
+ kws = {
214
+ "model_name": model_name or self.name or "DynamicModel",
215
+ "include": include,
216
+ "exclude": exclude,
217
+ **kw,
218
+ }
219
+ return PydanticSpecAdapter.create_model(self, **kws)
220
+ case _:
221
+ raise ValueError(f"Unsupported adapter: {adapter}")
@@ -0,0 +1,441 @@
1
+ """Spec - Universal type specification for framework-agnostic field definitions.
2
+
3
+ This module provides the Spec class for defining field specifications that can be
4
+ adapted to any framework (Pydantic, attrs, dataclasses, etc.) via adapters.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import inspect
11
+ import os
12
+ import threading
13
+ from dataclasses import dataclass
14
+ from enum import Enum
15
+ from typing import Any, Callable
16
+
17
+ from typing_extensions import Annotated, OrderedDict
18
+
19
+ from lionagi.ln.concurrency.utils import is_coro_func
20
+
21
+ from ._sentinel import MaybeUndefined, Undefined, is_sentinel, not_sentinel
22
+ from .base import Meta
23
+
24
+ # Global cache for annotated types with bounded size
25
+ _MAX_CACHE_SIZE = int(os.environ.get("LIONAGI_FIELD_CACHE_SIZE", "10000"))
26
+ _annotated_cache: OrderedDict[tuple[type, tuple[Meta, ...]], type] = (
27
+ OrderedDict()
28
+ )
29
+ _cache_lock = threading.RLock() # Thread-safe access to cache
30
+
31
+
32
+ __all__ = ("Spec", "CommonMeta")
33
+
34
+
35
+ class CommonMeta(Enum):
36
+ """Common metadata keys used across field specifications."""
37
+
38
+ NAME = "name"
39
+ NULLABLE = "nullable"
40
+ LISTABLE = "listable"
41
+ VALIDATOR = "validator"
42
+ DEFAULT = "default"
43
+ DEFAULT_FACTORY = "default_factory"
44
+
45
+ @classmethod
46
+ def allowed(cls) -> set[str]:
47
+ """Return all allowed common metadata keys."""
48
+ return {i.value for i in cls}
49
+
50
+ @classmethod
51
+ def _validate_common_metas(cls, **kw):
52
+ """Validate common metadata constraints."""
53
+ if kw.get("default") and kw.get("default_factory"):
54
+ raise ValueError(
55
+ "Cannot provide both 'default' and 'default_factory'"
56
+ )
57
+ if _df := kw.get("default_factory"):
58
+ if not callable(_df):
59
+ raise ValueError("'default_factory' must be callable")
60
+ if _val := kw.get("validator"):
61
+ _val = [_val] if not isinstance(_val, list) else _val
62
+ if not all(callable(v) for v in _val):
63
+ raise ValueError(
64
+ "Validators must be a list of functions or a function"
65
+ )
66
+
67
+ @classmethod
68
+ def prepare(
69
+ cls, *args: Meta, metadata: tuple[Meta, ...] = None, **kw: Any
70
+ ) -> tuple[Meta, ...]:
71
+ """Prepare metadata tuple from various inputs, checking for duplicates.
72
+
73
+ Args:
74
+ *args: Individual Meta objects
75
+ metadata: Existing metadata tuple
76
+ **kw: Keyword arguments to convert to Meta objects
77
+
78
+ Returns:
79
+ Tuple of Meta objects
80
+
81
+ Raises:
82
+ ValueError: If duplicate keys are found
83
+ """
84
+ # Lazy import to avoid circular dependency
85
+ from .._to_list import to_list
86
+
87
+ seen_keys = set()
88
+ metas = []
89
+
90
+ # Process existing metadata
91
+ if metadata:
92
+ for meta in metadata:
93
+ if meta.key in seen_keys:
94
+ raise ValueError(f"Duplicate metadata key: {meta.key}")
95
+ seen_keys.add(meta.key)
96
+ metas.append(meta)
97
+
98
+ # Process args
99
+ if args:
100
+ _args = to_list(
101
+ args, flatten=True, flatten_tuple_set=True, dropna=True
102
+ )
103
+ for meta in _args:
104
+ if meta.key in seen_keys:
105
+ raise ValueError(f"Duplicate metadata key: {meta.key}")
106
+ seen_keys.add(meta.key)
107
+ metas.append(meta)
108
+
109
+ # Process kwargs
110
+ for k, v in kw.items():
111
+ if k in seen_keys:
112
+ raise ValueError(f"Duplicate metadata key: {k}")
113
+ seen_keys.add(k)
114
+ metas.append(Meta(k, v))
115
+
116
+ # Validate common metadata constraints
117
+ meta_dict = {m.key: m.value for m in metas}
118
+ cls._validate_common_metas(**meta_dict)
119
+
120
+ return tuple(metas)
121
+
122
+
123
+ @dataclass(frozen=True, slots=True, init=False)
124
+ class Spec:
125
+ """Framework-agnostic field specification.
126
+
127
+ A Spec defines the type and metadata for a field without coupling to any
128
+ specific framework. Use adapters to convert Spec to framework-specific
129
+ field definitions (e.g., Pydantic Field, attrs attribute).
130
+
131
+ Attributes:
132
+ base_type: The base Python type for this field
133
+ metadata: Tuple of metadata objects attached to this spec
134
+
135
+ Example:
136
+ >>> spec = Spec(str, name="username", nullable=False)
137
+ >>> spec.name
138
+ 'username'
139
+ >>> spec.annotation
140
+ str
141
+ """
142
+
143
+ base_type: type
144
+ metadata: tuple[Meta, ...]
145
+
146
+ def __init__(
147
+ self,
148
+ base_type: type = None,
149
+ *args,
150
+ metadata: tuple[Meta, ...] = None,
151
+ **kw,
152
+ ) -> None:
153
+ """Initialize Spec with type and metadata.
154
+
155
+ Args:
156
+ base_type: Base Python type
157
+ *args: Additional Meta objects
158
+ metadata: Existing metadata tuple
159
+ **kw: Keyword arguments converted to Meta objects
160
+ """
161
+ metas = CommonMeta.prepare(*args, metadata=metadata, **kw)
162
+
163
+ if not_sentinel(base_type, True):
164
+ import types
165
+
166
+ is_valid_type = (
167
+ isinstance(base_type, type)
168
+ or hasattr(base_type, "__origin__")
169
+ or isinstance(base_type, types.UnionType)
170
+ or str(type(base_type)) == "<class 'types.UnionType'>"
171
+ )
172
+ if not is_valid_type:
173
+ raise ValueError(
174
+ f"base_type must be a type or type annotation, got {base_type}"
175
+ )
176
+
177
+ # Check for async default factory and warn
178
+ if kw.get("default_factory") and is_coro_func(kw["default_factory"]):
179
+ import warnings
180
+
181
+ warnings.warn(
182
+ "Async default factories are not yet fully supported by all adapters. "
183
+ "Consider using sync factories for compatibility.",
184
+ UserWarning,
185
+ stacklevel=2,
186
+ )
187
+
188
+ object.__setattr__(self, "base_type", base_type)
189
+ object.__setattr__(self, "metadata", metas)
190
+
191
+ def __getitem__(self, key: str) -> Any:
192
+ """Get metadata value by key.
193
+
194
+ Args:
195
+ key: Metadata key
196
+
197
+ Returns:
198
+ Metadata value
199
+
200
+ Raises:
201
+ KeyError: If key not found
202
+ """
203
+ for meta in self.metadata:
204
+ if meta.key == key:
205
+ return meta.value
206
+ raise KeyError(f"Metadata key '{key}' undefined in Spec.")
207
+
208
+ def get(self, key: str, default: Any = Undefined) -> Any:
209
+ """Get metadata value by key with default.
210
+
211
+ Args:
212
+ key: Metadata key
213
+ default: Default value if key not found
214
+
215
+ Returns:
216
+ Metadata value or default
217
+ """
218
+ with contextlib.suppress(KeyError):
219
+ return self[key]
220
+ return default
221
+
222
+ @property
223
+ def name(self) -> MaybeUndefined[str]:
224
+ """Get the field name from metadata."""
225
+ return self.get(CommonMeta.NAME.value)
226
+
227
+ @property
228
+ def is_nullable(self) -> bool:
229
+ """Check if field is nullable."""
230
+ return self.get(CommonMeta.NULLABLE.value) is True
231
+
232
+ @property
233
+ def is_listable(self) -> bool:
234
+ """Check if field is listable."""
235
+ return self.get(CommonMeta.LISTABLE.value) is True
236
+
237
+ @property
238
+ def default(self) -> MaybeUndefined[Any]:
239
+ """Get default value or factory."""
240
+ return self.get(
241
+ CommonMeta.DEFAULT.value,
242
+ self.get(CommonMeta.DEFAULT_FACTORY.value),
243
+ )
244
+
245
+ @property
246
+ def has_default_factory(self) -> bool:
247
+ """Check if this spec has a default factory."""
248
+ return _is_factory(self.get(CommonMeta.DEFAULT_FACTORY.value))[0]
249
+
250
+ @property
251
+ def has_async_default_factory(self) -> bool:
252
+ """Check if this spec has an async default factory."""
253
+ return _is_factory(self.get(CommonMeta.DEFAULT_FACTORY.value))[1]
254
+
255
+ def create_default_value(self) -> Any:
256
+ """Create default value synchronously.
257
+
258
+ Returns:
259
+ Default value
260
+
261
+ Raises:
262
+ ValueError: If no default or factory defined, or if factory is async
263
+ """
264
+ if self.default is Undefined:
265
+ raise ValueError("No default value or factory defined in Spec.")
266
+ if self.has_async_default_factory:
267
+ raise ValueError(
268
+ "Default factory is asynchronous; cannot create default synchronously. "
269
+ "Use 'await spec.acreate_default_value()' instead."
270
+ )
271
+ if self.has_default_factory:
272
+ return self.default()
273
+ return self.default
274
+
275
+ async def acreate_default_value(self) -> Any:
276
+ """Create default value asynchronously.
277
+
278
+ Returns:
279
+ Default value
280
+ """
281
+ if self.has_async_default_factory:
282
+ return await self.default()
283
+ return self.create_default_value()
284
+
285
+ def with_updates(self, **kw):
286
+ """Create new Spec with updated metadata.
287
+
288
+ Args:
289
+ **kw: Metadata updates
290
+
291
+ Returns:
292
+ New Spec instance with updates
293
+ """
294
+ _filtered = [meta for meta in self.metadata if meta.key not in kw]
295
+ for k, v in kw.items():
296
+ if not_sentinel(v):
297
+ _filtered.append(Meta(k, v))
298
+ _metas = tuple(_filtered)
299
+ return type(self)(self.base_type, metadata=_metas)
300
+
301
+ def as_nullable(self) -> Spec:
302
+ """Create nullable version of this spec."""
303
+ return self.with_updates(nullable=True)
304
+
305
+ def as_listable(self) -> Spec:
306
+ """Create listable version of this spec."""
307
+ return self.with_updates(listable=True)
308
+
309
+ def with_default(self, default: Any) -> Spec:
310
+ """Create spec with default value or factory.
311
+
312
+ Args:
313
+ default: Default value or factory function
314
+
315
+ Returns:
316
+ New Spec with default
317
+ """
318
+ if callable(default):
319
+ return self.with_updates(default_factory=default)
320
+ return self.with_updates(default=default)
321
+
322
+ def with_validator(
323
+ self, validator: Callable[..., Any] | list[Callable[..., Any]]
324
+ ) -> Spec:
325
+ """Create spec with validator(s).
326
+
327
+ Args:
328
+ validator: Single validator or list of validators
329
+
330
+ Returns:
331
+ New Spec with validator(s)
332
+ """
333
+ return self.with_updates(validator=validator)
334
+
335
+ @property
336
+ def annotation(self) -> type[Any]:
337
+ """Plain type annotation representing base type, nullable, and listable.
338
+
339
+ Returns:
340
+ Type annotation
341
+ """
342
+ if is_sentinel(self.base_type, none_as_sentinel=True):
343
+ return Any
344
+ t_ = self.base_type
345
+ if self.is_listable:
346
+ t_ = list[t_]
347
+ if self.is_nullable:
348
+ return t_ | None
349
+ return t_
350
+
351
+ def annotated(self) -> type[Any]:
352
+ """Materialize this spec into an Annotated type.
353
+
354
+ This method is cached to ensure repeated calls return the same
355
+ type object for performance and identity checks. The cache is bounded
356
+ using LRU eviction to prevent unbounded memory growth.
357
+
358
+ Returns:
359
+ Annotated type with all metadata attached
360
+ """
361
+ # Check cache first with thread safety
362
+ cache_key = (self.base_type, self.metadata)
363
+
364
+ with _cache_lock:
365
+ if cache_key in _annotated_cache:
366
+ # Move to end to mark as recently used
367
+ _annotated_cache.move_to_end(cache_key)
368
+ return _annotated_cache[cache_key]
369
+
370
+ # Handle nullable case - wrap in Optional-like union
371
+ actual_type = (
372
+ Any
373
+ if is_sentinel(self.base_type, none_as_sentinel=True)
374
+ else self.base_type
375
+ )
376
+ current_metadata = (
377
+ ()
378
+ if is_sentinel(self.metadata, none_as_sentinel=True)
379
+ else self.metadata
380
+ )
381
+
382
+ if any(m.key == "nullable" and m.value for m in current_metadata):
383
+ # Use union syntax for nullable
384
+ actual_type = actual_type | None # type: ignore
385
+
386
+ if current_metadata:
387
+ args = [actual_type] + list(current_metadata)
388
+ result = Annotated.__class_getitem__(tuple(args)) # type: ignore
389
+ else:
390
+ result = actual_type # type: ignore[misc]
391
+
392
+ # Cache the result with LRU eviction
393
+ _annotated_cache[cache_key] = result # type: ignore[assignment]
394
+
395
+ # Evict oldest if cache is too large (guard against empty cache)
396
+ while len(_annotated_cache) > _MAX_CACHE_SIZE:
397
+ try:
398
+ _annotated_cache.popitem(last=False) # Remove oldest
399
+ except KeyError:
400
+ # Cache became empty during race, safe to continue
401
+ break
402
+
403
+ return result # type: ignore[return-value]
404
+
405
+ def metadict(
406
+ self, exclude: set[str] | None = None, exclude_common: bool = False
407
+ ) -> dict[str, Any]:
408
+ """Get metadata as dictionary.
409
+
410
+ Args:
411
+ exclude: Keys to exclude
412
+ exclude_common: Exclude all common metadata keys
413
+
414
+ Returns:
415
+ Dictionary of metadata
416
+ """
417
+ if exclude is None:
418
+ exclude = set()
419
+ if exclude_common:
420
+ exclude = exclude | CommonMeta.allowed()
421
+ return {
422
+ meta.key: meta.value
423
+ for meta in self.metadata
424
+ if meta.key not in exclude
425
+ }
426
+
427
+
428
+ def _is_factory(obj: Any) -> tuple[bool, bool]:
429
+ """Check if object is a factory function.
430
+
431
+ Args:
432
+ obj: Object to check
433
+
434
+ Returns:
435
+ Tuple of (is_factory, is_async)
436
+ """
437
+ if not callable(obj):
438
+ return (False, False)
439
+ if is_coro_func(obj):
440
+ return (True, True)
441
+ return (True, False)