modelfill 0.1.0__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.
modelfill/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Generate validated model completions from existing constraints."""
2
+
3
+ from .errors import (
4
+ GenerationExhausted,
5
+ ModelFillError,
6
+ SolverTimeout,
7
+ SolverUnknown,
8
+ UnsatisfiableConstraints,
9
+ UnsupportedConstraint,
10
+ )
11
+ from .filler import ModelFiller, fill
12
+
13
+ __all__ = [
14
+ "GenerationExhausted",
15
+ "ModelFillError",
16
+ "ModelFiller",
17
+ "SolverTimeout",
18
+ "SolverUnknown",
19
+ "UnsatisfiableConstraints",
20
+ "UnsupportedConstraint",
21
+ "fill",
22
+ ]
modelfill/adapters.py ADDED
@@ -0,0 +1,422 @@
1
+ """Model discovery and normal construction; no solver dependency.
2
+
3
+ Pydantic's original decorator functions are not exposed by its public field API.
4
+ That compatibility-sensitive discovery is confined to ``_pydantic_validators``;
5
+ the only other private API is the guarded ``init_private_attributes`` import.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import inspect
12
+ import math
13
+ import types
14
+ from collections.abc import Callable
15
+ from copy import deepcopy
16
+ from enum import Enum
17
+ from typing import TYPE_CHECKING, Any, Literal, Union, get_args, get_origin, get_type_hints
18
+
19
+ from annotated_types import Ge, Gt, Le, Lt, MaxLen, MinLen, MultipleOf
20
+ from pydantic import BaseModel, ConfigDict
21
+ from pydantic.types import Strict
22
+ from pydantic_core import PydanticUndefined
23
+
24
+ from .errors import UnsupportedConstraint
25
+ from .ir import (
26
+ INT,
27
+ PRIMITIVES,
28
+ TIME_TYPES,
29
+ Domain,
30
+ Expr,
31
+ const,
32
+ encodable,
33
+ evaluate,
34
+ node,
35
+ plain_enum,
36
+ var,
37
+ )
38
+
39
+ if TYPE_CHECKING:
40
+ from _typeshed import DataclassInstance
41
+
42
+ try: # If a future Pydantic moves it, PrivateAttr models just fall back to guidance.
43
+ from pydantic._internal._model_construction import init_private_attributes
44
+ except ImportError:
45
+ init_private_attributes = None
46
+
47
+ MISSING = dataclasses.MISSING
48
+
49
+
50
+ @dataclasses.dataclass(frozen=True)
51
+ class FieldSpec:
52
+ name: str
53
+ domain: Domain | None # None: an external field, supplied by the caller or its default.
54
+ default: Any
55
+ default_factory: Callable[..., Any] | None = None # Never data-dependent here.
56
+ input_name: str | None = None
57
+ unchecked_default: bool = False
58
+ required: bool = True
59
+ annotation: Any = None
60
+
61
+ def preferred(self) -> Any:
62
+ """Evaluate an ordinary default once per fill; it is a preference."""
63
+ if self.default_factory is not None:
64
+ return self.default_factory()
65
+ return MISSING if self.default is MISSING else deepcopy(self.default)
66
+
67
+
68
+ @dataclasses.dataclass(frozen=True)
69
+ class ValidatorSpec:
70
+ name: str
71
+ function: Callable[..., Any]
72
+ kind: Literal["model", "dataclass", "field"]
73
+ mode: str
74
+ field_name: str | None = None
75
+ earlier_fields: tuple[str, ...] = ()
76
+
77
+
78
+ @dataclasses.dataclass(frozen=True)
79
+ class Adapter:
80
+ model: type
81
+ fields: tuple[FieldSpec, ...]
82
+ constraints: tuple[tuple[str, Expr], ...] # (label, rule) for explaining contradictions
83
+ validators: tuple[ValidatorSpec, ...]
84
+ unsupported: tuple[str, ...]
85
+
86
+ def construct(self, values: dict[str, Any]) -> Any:
87
+ """Run the model's real constructor, including its original validators."""
88
+ inputs = {
89
+ field.input_name or field.name: values[field.name]
90
+ for field in self.fields
91
+ if field.name in values
92
+ }
93
+ return self.model(**inputs)
94
+
95
+ def check_fixed(self, values: dict[str, Any]) -> None:
96
+ """The public API takes canonical field names and exact Python types."""
97
+ fields = {field.name: field for field in self.fields}
98
+ unknown = values.keys() - fields.keys()
99
+ if unknown:
100
+ raise ValueError(f"Unknown field(s): {', '.join(sorted(unknown))}")
101
+ for name, value in values.items():
102
+ domain = fields[name].domain
103
+ if domain is not None and not domain.accepts(value):
104
+ expected = domain.kind + (" or None" if domain.nullable else "")
105
+ raise TypeError(
106
+ f"Fixed field {name!r} requires a canonical {expected} value, got {value!r}"
107
+ )
108
+ missing = [
109
+ field.name
110
+ for field in self.fields
111
+ if field.domain is None and field.required and field.name not in values
112
+ ]
113
+ if missing:
114
+ raise UnsupportedConstraint(
115
+ f"modelfill cannot generate {', '.join(missing)}; pass values for them"
116
+ )
117
+
118
+
119
+ def _domain(annotation: Any) -> Domain | None:
120
+ """The solver domain of a field type, or None for an external field."""
121
+ if annotation in PRIMITIVES:
122
+ return PRIMITIVES[annotation]
123
+ arguments = get_args(annotation)
124
+ if get_origin(annotation) in (Union, types.UnionType) and type(None) in arguments:
125
+ options = [option for option in arguments if option is not type(None)]
126
+ inner = _domain(options[0]) if len(options) == 1 else None
127
+ return None if inner is None else dataclasses.replace(inner, nullable=True)
128
+ if inspect.isclass(annotation) and issubclass(annotation, Enum) and plain_enum(annotation):
129
+ return Domain("enum", tuple(annotation)) if len(annotation) else None
130
+ if get_origin(annotation) is Literal:
131
+ values = tuple(choice for choice in arguments if choice is not None)
132
+ homogeneous = values and all(type(x) is type(values[0]) for x in values)
133
+ if homogeneous and all(encodable(value) for value in values):
134
+ base = _domain(type(values[0]))
135
+ if base is not None:
136
+ return Domain(base.kind, values, nullable=len(values) < len(arguments))
137
+ return None
138
+
139
+
140
+ _SYMBOLS = {Gt: ">", Ge: ">=", Lt: "<", Le: "<="}
141
+
142
+
143
+ def _metadata_constraints(
144
+ name: str, domain: Domain, metadata: list[Any], unsupported: list[str]
145
+ ) -> list[tuple[str, Expr]]:
146
+ """A field's own constraints, as (label, rule) pairs."""
147
+ result: list[tuple[str, Expr]] = []
148
+ expression = var(name, domain)
149
+ for item in metadata:
150
+ if isinstance(item, Strict):
151
+ continue # All generated/fixed inputs already have their canonical type.
152
+ if isinstance(item, (Gt, Ge, Lt, Le)) and domain.kind in TIME_TYPES:
153
+ operation = type(item).__name__.lower()
154
+ bound = getattr(item, operation)
155
+ if type(bound) is not TIME_TYPES[domain.kind] or getattr(bound, "tzinfo", None):
156
+ unsupported.append(f"Field {name!r}: unsupported {domain.kind} bound {bound!r}")
157
+ continue
158
+ label = f"{name} {_SYMBOLS[type(item)]} {bound!r}"
159
+ result.append((label, node(operation, expression, const(bound))))
160
+ elif isinstance(item, MultipleOf) and domain.kind == "int":
161
+ step = item.multiple_of
162
+ if type(step) is not int or step < 1:
163
+ unsupported.append(f"Field {name!r}: unsupported multiple_of {step!r}")
164
+ continue
165
+ remainder = node("mod", expression, const(step), sort=INT)
166
+ result.append((f"{name} % {step} == 0", node("eq", remainder, const(0))))
167
+ elif isinstance(item, (Gt, Ge, Lt, Le)) and domain.kind == "int":
168
+ bound = getattr(item, type(item).__name__.lower())
169
+ if type(bound) not in (int, float) or (
170
+ type(bound) is float and not math.isfinite(bound)
171
+ ):
172
+ unsupported.append(f"Field {name!r}: unsupported numeric bound {bound!r}")
173
+ continue
174
+ if isinstance(item, Gt):
175
+ operation, value = "ge", math.floor(bound) + 1
176
+ elif isinstance(item, Ge):
177
+ operation, value = "ge", math.ceil(bound)
178
+ elif isinstance(item, Lt):
179
+ operation, value = "le", math.ceil(bound) - 1
180
+ else:
181
+ operation, value = "le", math.floor(bound)
182
+ label = f"{name} {_SYMBOLS[type(item)]} {bound!r}"
183
+ result.append((label, node(operation, expression, const(value))))
184
+ elif isinstance(item, (MinLen, MaxLen)) and domain.kind == "str":
185
+ minimum = isinstance(item, MinLen)
186
+ value = item.min_length if minimum else item.max_length
187
+ if type(value) is not int:
188
+ unsupported.append(f"Field {name!r}: unsupported length bound {value!r}")
189
+ continue
190
+ symbol, operation = (">=", "ge") if minimum else ("<=", "le")
191
+ length = node("len", expression, sort=INT)
192
+ result.append((f"len({name}) {symbol} {value}", node(operation, length, const(value))))
193
+ else:
194
+ unsupported.append(f"Field {name!r}: unsupported metadata {item!r}")
195
+ if domain.nullable: # Pydantic applies these constraints to non-None values only.
196
+ return [(label, node("or", node("isnone", expression), rule)) for label, rule in result]
197
+ return result
198
+
199
+
200
+ def _pydantic_validators(
201
+ model: type[BaseModel], names: tuple[str, ...], unsupported: list[str]
202
+ ) -> tuple[ValidatorSpec, ...]:
203
+ """Validators from Pydantic's private decorator registry, isolated here."""
204
+ decorators = getattr(model, "__pydantic_decorators__", None)
205
+ if decorators is None:
206
+ unsupported.append("Pydantic decorator registry is unavailable")
207
+ return ()
208
+ validators = []
209
+ for name, decorator in decorators.model_validators.items():
210
+ validators.append(ValidatorSpec(name, decorator.func, "model", decorator.info.mode))
211
+ for name, decorator in decorators.field_validators.items():
212
+ targets = decorator.info.fields
213
+ for index, field_name in enumerate(names):
214
+ if field_name in targets or "*" in targets:
215
+ validators.append(
216
+ ValidatorSpec(
217
+ name,
218
+ decorator.func,
219
+ "field",
220
+ decorator.info.mode,
221
+ field_name,
222
+ names[:index],
223
+ )
224
+ )
225
+ if decorators.validators or decorators.root_validators:
226
+ unsupported.append("Legacy Pydantic v1-style validators are unsupported")
227
+ return tuple(validators)
228
+
229
+
230
+ # Configuration that cannot transform canonical values or bypass validation.
231
+ _SAFE_CONFIG = {
232
+ "title",
233
+ "model_title_generator",
234
+ "field_title_generator",
235
+ "extra",
236
+ "frozen",
237
+ "populate_by_name",
238
+ "validate_by_alias",
239
+ "validate_by_name",
240
+ "loc_by_alias",
241
+ "alias_generator",
242
+ "validate_assignment",
243
+ "arbitrary_types_allowed",
244
+ "from_attributes",
245
+ "ignored_types",
246
+ "json_schema_extra",
247
+ "json_encoders",
248
+ "strict",
249
+ "revalidate_instances",
250
+ "validate_default",
251
+ "protected_namespaces",
252
+ "hide_input_in_errors",
253
+ "defer_build",
254
+ "schema_generator",
255
+ "json_schema_serialization_defaults_required",
256
+ "json_schema_mode_override",
257
+ "validation_error_cause",
258
+ "use_attribute_docstrings",
259
+ "cache_strings",
260
+ "serialize_by_alias",
261
+ "url_preserve_empty_path",
262
+ }
263
+
264
+
265
+ def _pydantic(model: type[BaseModel]) -> Adapter:
266
+ unsupported: list[str] = []
267
+ constraints: list[tuple[str, Expr]] = []
268
+ fields = []
269
+ config = model.model_config
270
+ for key, value in config.items():
271
+ if key in ("str_min_length", "str_max_length") or key in _SAFE_CONFIG:
272
+ continue
273
+ if key in ("str_to_lower", "str_to_upper", "str_strip_whitespace", "use_enum_values"):
274
+ if not value:
275
+ continue
276
+ unsupported.append(f"Unsupported Pydantic configuration: {key}={value!r}")
277
+ if model.__init__ is not BaseModel.__init__:
278
+ unsupported.append("Custom Pydantic __init__ is unsupported")
279
+ if model.__new__ is not BaseModel.__new__:
280
+ unsupported.append("Custom Pydantic __new__ is unsupported")
281
+ for hook in ("__getattribute__", "__getattr__", "__setattr__"):
282
+ if getattr(model, hook, None) is not getattr(BaseModel, hook, None):
283
+ unsupported.append(f"Custom Pydantic {hook} is unsupported")
284
+ if type(model).__call__ is not type(BaseModel).__call__:
285
+ unsupported.append("Custom Pydantic metaclass __call__ is unsupported")
286
+ # Pydantic installs init_private_attributes for PrivateAttr; it never touches fields.
287
+ if model.model_post_init not in (BaseModel.model_post_init, init_private_attributes):
288
+ unsupported.append("Pydantic model_post_init is unsupported")
289
+ for parent in model.__mro__:
290
+ if "__get_pydantic_core_schema__" in parent.__dict__:
291
+ if parent is not BaseModel:
292
+ unsupported.append("Custom Pydantic core schema is unsupported")
293
+ break
294
+ validators = _pydantic_validators(model, tuple(model.model_fields), unsupported)
295
+ # A field validator could reject a default that the field's constraints accept.
296
+ validated = {validator.field_name for validator in validators if validator.field_name}
297
+ # Validation aliases do not apply when validate_by_alias=False.
298
+ by_alias = (
299
+ config.get("validate_by_alias", True)
300
+ if "validate_by_alias" in ConfigDict.__annotations__
301
+ else True
302
+ )
303
+ for name, info in model.model_fields.items():
304
+ domain = _domain(info.annotation)
305
+ alias = info.validation_alias
306
+ if alias is not None and not isinstance(alias, str):
307
+ raise UnsupportedConstraint(
308
+ f"Field {name!r}: complex validation aliases are unsupported"
309
+ )
310
+ input_name = alias if by_alias else name
311
+ factory = info.default_factory
312
+ if info.default_factory_takes_validated_data: # None without a factory.
313
+ unsupported.append(f"Field {name!r}: data-dependent default factory is unsupported")
314
+ factory = None
315
+ default = MISSING if info.default is PydanticUndefined else info.default
316
+ validates_default = (
317
+ info.validate_default
318
+ if info.validate_default is not None
319
+ else config.get("validate_default", False)
320
+ )
321
+ rules: list[tuple[str, Expr]] = []
322
+ if domain is not None: # Pydantic itself validates values of external fields.
323
+ metadata = list(info.metadata)
324
+ if domain.kind == "str" and domain.choices is None:
325
+ # A field's own length limit overrides the global configuration.
326
+ shortest, longest = config.get("str_min_length"), config.get("str_max_length")
327
+ if shortest is not None and not any(isinstance(m, MinLen) for m in metadata):
328
+ metadata.append(MinLen(shortest))
329
+ if longest is not None and not any(isinstance(m, MaxLen) for m in metadata):
330
+ metadata.append(MaxLen(longest))
331
+ rules = _metadata_constraints(name, domain, metadata, unsupported)
332
+ constraints.extend(rules)
333
+ # Pydantic skips field validation for an omitted default; that only matters
334
+ # when the validation would have rejected the default.
335
+ passes = (
336
+ domain is not None
337
+ and default is not MISSING
338
+ and name not in validated
339
+ and domain.accepts(default)
340
+ and encodable(default)
341
+ and all(evaluate(rule, {name: default}) for _, rule in rules)
342
+ )
343
+ unchecked = not info.is_required() and not validates_default and not passes
344
+ fields.append(
345
+ FieldSpec(
346
+ name,
347
+ domain,
348
+ default,
349
+ factory,
350
+ input_name,
351
+ unchecked,
352
+ info.is_required(),
353
+ info.annotation,
354
+ )
355
+ )
356
+ inputs = [field.input_name or field.name for field in fields]
357
+ if len(set(inputs)) != len(inputs):
358
+ raise UnsupportedConstraint("Multiple fields share the same validation alias")
359
+ return Adapter(model, tuple(fields), tuple(constraints), validators, tuple(unsupported))
360
+
361
+
362
+ def _dataclass(model: type[DataclassInstance]) -> Adapter:
363
+ unsupported: list[str] = []
364
+ try:
365
+ annotations = get_type_hints(model)
366
+ except (NameError, TypeError) as exc:
367
+ raise UnsupportedConstraint(f"Cannot resolve dataclass annotations: {exc}") from exc
368
+ if any(isinstance(annotation, dataclasses.InitVar) for annotation in annotations.values()):
369
+ raise UnsupportedConstraint("Dataclass InitVar fields are unsupported")
370
+ initializer = model.__init__
371
+ if not hasattr(initializer, "__code__") or initializer.__code__.co_filename != "<string>":
372
+ unsupported.append("Custom dataclass __init__ is unsupported")
373
+ if model.__new__ is not object.__new__:
374
+ unsupported.append("Custom dataclass __new__ is unsupported")
375
+ for hook in ("__getattribute__", "__getattr__"):
376
+ if getattr(model, hook, None) is not getattr(object, hook, None):
377
+ unsupported.append(f"Custom dataclass {hook} is unsupported")
378
+ if type(model).__call__ is not type.__call__:
379
+ unsupported.append("Custom dataclass metaclass __call__ is unsupported")
380
+ frozen = model.__dataclass_params__.frozen # ty: ignore[unresolved-attribute]
381
+ if model.__setattr__ is not object.__setattr__ and not frozen:
382
+ unsupported.append("Custom dataclass __setattr__ is unsupported")
383
+ fields = []
384
+ for info in dataclasses.fields(model):
385
+ if not info.init:
386
+ raise UnsupportedConstraint(f"Dataclass init=False field {info.name!r} is unsupported")
387
+ annotation = annotations[info.name]
388
+ domain = _domain(annotation)
389
+ factory = None if info.default_factory is MISSING else info.default_factory
390
+ required = info.default is MISSING and factory is None
391
+ fields.append(
392
+ FieldSpec(
393
+ info.name, domain, info.default, factory, required=required, annotation=annotation
394
+ )
395
+ )
396
+ descriptor = inspect.getattr_static(model, info.name, None)
397
+ if hasattr(descriptor, "__set__") and not isinstance(
398
+ descriptor, types.MemberDescriptorType
399
+ ):
400
+ unsupported.append(f"Dataclass field {info.name!r} uses a custom descriptor")
401
+ post_init = getattr(model, "__post_init__", None)
402
+ validators = (
403
+ (ValidatorSpec("__post_init__", post_init, "dataclass", "after"),)
404
+ if post_init is not None
405
+ else ()
406
+ )
407
+ return Adapter(model, tuple(fields), (), validators, tuple(unsupported))
408
+
409
+
410
+ def introspect(model: type) -> Adapter:
411
+ """Discover supported domains and all known validation hooks conservatively."""
412
+ if not inspect.isclass(model):
413
+ raise TypeError("modelfill expects a model class, not an instance")
414
+ if issubclass(model, BaseModel):
415
+ return _pydantic(model)
416
+ if dataclasses.is_dataclass(model):
417
+ if hasattr(model, "__pydantic_validator__"):
418
+ raise UnsupportedConstraint(
419
+ "Pydantic dataclasses are unsupported; use a standard dataclass"
420
+ )
421
+ return _dataclass(model)
422
+ raise TypeError("modelfill supports Pydantic BaseModel and standard dataclass classes")