modelfill
Define validation rules once. Generate valid objects automatically.
modelfill completes Pydantic models and standard dataclasses by translating their existing validation rules into solver constraints. It solves for missing fields in either direction and runs the original constructor before returning anything. Models require no changes, factory classes, decorators, or duplicate rules. It also plugs into Hypothesis: Hypothesis draws the fields you care about, and modelfill solves the rest consistently.
modelfill reads a documented subset of Python: pure, deterministic validators that reject invalid input. It reports logic outside that subset, and it never claims a contradiction it cannot prove.
Install and try
modelfill needs Python 3.12 or later:
pip install modelfill # core
pip install "modelfill[hypothesis]" # with the Hypothesis strategy
To run the example from a checkout, use uv:
uv sync
uv run python examples/meeting.py
Save models in a Python source file: extraction requires inspect.getsource().
Interactive definitions without retrievable source use automatic fallback or
fail in strict mode.
from typing import Self
from pydantic import BaseModel, Field, model_validator
from modelfill import fill, ModelFiller, UnsatisfiableConstraints
class Meeting(BaseModel):
start: int = Field(ge=8, le=17)
end: int = Field(ge=9, le=18)
duration: int = Field(ge=1, le=4)
@model_validator(mode="after")
def check(self) -> Self:
if self.end != self.start + self.duration:
raise ValueError("Invalid meeting")
return self
assert fill(Meeting, end=18, duration=4).start == 14
assert fill(Meeting, start=10, end=13).duration == 3
assert fill(Meeting, start=10, duration=2).end == 12
meeting = fill(Meeting)
assert meeting.end == meeting.start + meeting.duration
try:
fill(Meeting, start=17, duration=4)
except UnsatisfiableConstraints:
pass # end would be 21, but end <= 18
else:
raise AssertionError("Expected an impossible assignment")
filler = ModelFiller(Meeting, mode="strict", seed=42)
assert filler.fill(end=18, duration=4).start == 14
meetings = filler.batch(100)
print(filler.explain())
The same analysis works on ordinary dataclasses:
from dataclasses import dataclass
from modelfill import fill
@dataclass
class Interval:
start: int
end: int
duration: int
def __post_init__(self):
if self.duration < 1 or self.end != self.start + self.duration:
raise ValueError("Invalid interval")
assert fill(Interval, end=18, duration=4).start == 14
Dates, datetimes, durations and optional fields work the same way:
from datetime import datetime, timedelta
from pydantic import BaseModel, model_validator
from modelfill import fill
class Booking(BaseModel):
start: datetime
end: datetime
length: timedelta
note: str | None = None
@model_validator(mode="after")
def check(self):
if self.end != self.start + self.length:
raise ValueError("end must equal start + length")
if not timedelta(minutes=30) <= self.length <= timedelta(hours=4):
raise ValueError("bookings last from 30 minutes to 4 hours")
return self
booking = fill(Booking, end=datetime(2025, 3, 1, 18), length=timedelta(hours=2))
assert booking.start == datetime(2025, 3, 1, 16)
API and input semantics
fill(Model, **fixed_fields)
ModelFiller(Model, *, mode="auto", seed=None, max_attempts=100, timeout_ms=1000)
filler.fill(**fixed_fields)
filler.batch(count, **fixed_fields)
filler.explain() # fresh JSON-friendly dictionary
- All
fill()keywords are canonical model field names, even if a model uses input aliases. Configuration lives onModelFiller; fields namedmode,seed,model,count, orselfwork. - Fixed inputs are exact Python
int,bool,str,date, naivedatetime,timedelta, Enum members, allowed Literal values, orNonefor optional fields. There is no coercion:"10"orTruefor anintfield, or an aware datetime, raisesTypeError. Unknown field names raiseValueError. - Fields of any other type (UUIDs, floats, lists, nested models, …) are
external: modelfill passes your value to the constructor unchanged, or
leaves the field to the model's default. A required external field without a
value raises
UnsupportedConstraint. Validators that read external fields are unsupported logic. - Explicit values are hard constraints. A returned object's fixed fields must retain both their type and value; validator normalization cannot silently replace them.
- Defaults are preferences, used when compatible with the solved constraints. A missing field's zero-argument default factory runs once per fill call. Standard defaults are copied. A conflicting default can be replaced.
- All generated fields, including selected defaults, are passed explicitly to
the original constructor and validated. This is stronger than Pydantic's
default behavior of leaving omitted defaults unchecked, and modelfill does not
search by omitting fields. A default Pydantic would leave unchecked can
therefore make a contradiction unprovable: while such a field is omitted, a
contradiction is reported as
GenerationExhausted, never UNSAT.explain()["unsat_requires_explicit_fields"]lists these fields; passing them explicitly, or enabling Pydantic'svalidate_default, makes UNSAT provable. - Dataclasses run their normal
__init__and__post_init__. They do not gain Pydantic validation; supported type domains are enforced by modelfill. - Every returned object has successfully completed its original construction
pipeline, has canonical field types, and preserves fixed values. Neither
model_construct()nor any other validation bypass is used.
batch() returns a list, permits duplicates, and raises on the first failed fill.
It is a convenience loop over the reusable filler.
Supported features
| Area | Support |
|---|---|
| Pydantic | v2 BaseModel, inherited validators, scalar fields |
| Dataclasses | Standard dataclasses, including frozen and slotted classes; pure __post_init__ |
| Types | Exact int, bool, str, date, naive datetime, timedelta; ordinary Enum, IntEnum, StrEnum; homogeneous Literal subsets; any of these | None (Optional, or None inside a Literal); other types as external fields |
| Numeric metadata | Integer, date, datetime and timedelta Field(gt, ge, lt, le), respecting strict and inclusive bounds; integer Field(multiple_of); on optional fields they constrain non-None values, as in Pydantic |
| String metadata | Field(min_length, max_length) and global str_min_length / str_max_length |
| Model validators | Instance @model_validator(mode="after"), returning the original self on successful paths |
| Field validators | @field_validator(mode="after"), returning the original value, including literal info.data["earlier_field"] reads |
| Aliases | Simple, distinct string input/validation aliases; API still uses field names |
| Defaults | Copied values and zero-argument factories as preferences |
| Fixed fields | Any subset, solved simultaneously; no left-to-right dependency order required |
Pydantic field metadata is read through public model_fields. Validator
discovery uses Pydantic's private __pydantic_decorators__ registry; CI tests it
against the oldest supported Pydantic release. Dataclass Annotated metadata is
not a validation rule unless the dataclass's own code enforces it, so modelfill
does not impose it.
Exact recognized validator language
| Construct | Accepted forms |
|---|---|
| Statements | if / elif / else, pass, string docstrings, assert, supported raise, supported return, single-name local assignments (x = …, x: T = …) outside branches, other than to validator parameters |
| Rejection | raise ValueError with any message, including f-strings (messages only run on paths that already reject), without a from clause; a failing assert, which python -O removes as usual |
| Successful return | Original self for model validators, original value for field validators; bare return, return None, or fallthrough for dataclasses |
| References | self.field; field validator value parameter; info.data["earlier_field"]; previously assigned locals; named constants and standard Enum members from globals/closures |
| Constants | Integer, Boolean, string, None; date(…), datetime(…) and timedelta(…) calls with integer literal arguments |
| Arithmetic | Integer/Boolean +, -, unary +/-; multiplication with an integer literal operand; % and // by a positive integer literal; Python's date rules: datetime ± timedelta, datetime - datetime, date ± timedelta (whole days, like timedelta.days), date - date, timedelta ± timedelta, timedelta * int literal, -timedelta |
| Comparisons | ==, !=; <, <=, >, >= between numbers or two values of one date/time type; chained comparisons; identity checks only against None |
None |
x is None, x is not None, x == None, truthiness, x or default; an operation that raises TypeError on None (x < y, x + 1, len(x)) rejects that path, so guard it as Python code does: x is not None and x < y |
| Boolean operations | and, or, not, with Python truthiness and short circuit meaning; operand-valued and/or when operands have the same supported type and cannot be None (x or default excepted) |
| Membership | in / not in a literal tuple, list or set of supported constants or Enum members, outside comparison chains |
| Strings | Equality, inequality, truthiness, membership in finite literal collections, builtin len(field) |
| Enums | Equality/inequality within the same Enum type, finite membership, exact member truthiness |
Early returns and nested branches are analyzed as paths. Rejection conditions
are not blindly negated. The entire relevant validator must be understood;
partially translating it would risk false claims of impossibility. Operators
follow Python's parsed precedence, including operand-returning and and or.
Unsupported features and fallback
Field types modelfill cannot generate (floats, containers, nested models, UUIDs,
aware datetimes, other unions, mixed-type Literals, Flags, custom Enum behavior,
…) become external fields, described above. Unsupported constructor shapes
fail explicitly: Pydantic dataclasses, dataclass InitVar / init=False
fields, and complex or duplicate aliases.
Validator code outside the language above
is unsupported validation logic, for example loops, calls and string methods,
try, with and match, mutation, other exception classes and transformed
return values. So are async, generator and wrapped validators, validators
without retrievable source, and these Pydantic features: before, plain or
wrap validators; legacy validators; Annotated validation callbacks;
unrecognized field metadata such as patterns; transforming model config; custom
initialization, core schema, attribute hooks and a custom model_post_init
(private attributes are fine); and data-dependent default factories.
In strict mode, unsupported validation logic raises UnsupportedConstraint
when the filler is created. In auto mode it appears in
explain()["unsupported"]. If any model behavior is unsupported, auto mode
demotes the extracted validator predicates to guidance, since unknown code may
transform values before another validator runs. Some attempts prefer candidates
that satisfy that guidance, but it never rules a candidate out, so misleading
guidance cannot starve the search. Field constraints remain candidate bounds;
normal construction still runs every original validator. It tries at most
max_attempts candidates and never claims UNSAT in this fallback mode. This
bounded search can miss valid solutions, especially narrow conditions or
transformations.
Only ValueError (including Pydantic ValidationError) and AssertionError
from ordinary construction are treated as candidate rejection. Other Python
exceptions propagate so programming errors are visible. Default-factory
exceptions also propagate. Validation is not sandboxed: do not use side effects
or nondeterministic validators. Solver timeouts cannot interrupt Python code.
Z3 string values cover U+0000–U+2FFFF, including ordinary text and emoji. Fixed
strings outside this range are explicitly unsupported. Such literals in a
validator cause strict rejection or automatic fallback; such defaults are simply
not preferred, and a Literal with such a choice makes its field external.
Arbitrary Python string behavior is not modeled.
Failure categories
| Exception | Meaning |
|---|---|
UnsatisfiableConstraints |
A clean solver check proves the supported constraints plus canonical fixed assignments contradictory. No random preferences, blocked candidates or artificial bounds contribute to this result. When the solver isolates the conflict, the message names the fixed inputs, field constraints and validators involved, such as start=17, duration=4 against end <= 18 and Meeting.check. |
UnsupportedConstraint |
Unsupported constructor shape, a required external field without a value, or unsupported validation in strict mode. |
GenerationExhausted |
The bounded search did not find a normally validated object; also used for contradictions when unknown behavior or omitted unchecked defaults prevent an impossibility claim. |
SolverTimeout |
A solver query exhausted its time budget. Subclass of SolverUnknown. |
SolverUnknown |
Z3 could not decide SAT/UNSAT for another reason. |
These exceptions inherit from ModelFillError. Incorrect API arguments raise
TypeError or ValueError. timeout_ms is a per-query budget; a fill can make
several solver calls per field and attempt. This is not a wall-clock deadline.
Diagnostics include field domains, defaults, each validator's extraction status,
unsupported reasons, whether extracted predicates are active, sampling settings
and proof eligibility. In a fallback model, successfully extracted validators
report the status guidance rather than modeled.
Sampling and performance
The solver's hard domains have no arbitrary integer or string-length bounds. Sampling tries seeded soft preferences and discards infeasible ones. Integer, date and time values spread across each field's feasible range and often sit on its bounds, where tests usually find bugs; a field the solver cannot bound samples near typical values instead. Assignments are varied but nonuniform, with duplicates allowed; for distribution control and shrinking, use the Hypothesis strategy.
The same seed, model, dependency versions and serial per-thread call order give
repeatable sequences. Cross-version or cross-thread ordering is not guaranteed.
Each thread owns a separate Z3 context, solver and RNG; immutable compiled
information is shared. Separate threads with the same seed may repeat sequences.
Compiled models and the convenience fill() fillers live in bounded caches.
Treat model definitions and the Enum classes and constants their validators read
as immutable after first compilation; restart the process after monkeypatching
or rebuilding a model.
uv run python -m benchmarks.benchmark --count 100 --repeat 5
Measured results, the design and comparisons with Polyfactory and CrossHair are in the design document.
Hypothesis
modelfill.hypothesis.from_model is a Hypothesis strategy. Pass strategies for
the fields you want Hypothesis to drive; required external fields default to
st.from_type, and modelfill solves the rest from the validators. To set
mode, max_attempts or timeout_ms, pass a configured ModelFiller in place
of the class, so options never collide with field names:
from datetime import datetime, timedelta
from uuid import UUID
from hypothesis import given, strategies as st
from pydantic import BaseModel, model_validator
from modelfill.hypothesis import from_model
class Shift(BaseModel):
id: UUID # External: drawn with st.from_type(UUID).
start: datetime
end: datetime
break_minutes: int
@model_validator(mode="after")
def check(self):
if not self.start < self.end <= self.start + timedelta(hours=10):
raise ValueError("shifts last up to ten hours")
if not 0 <= self.break_minutes <= 60:
raise ValueError("breaks last up to an hour")
return self
@given(from_model(Shift, start=st.datetimes(), break_minutes=st.integers(-30, 90)))
def test_shift_rules(shift):
assert shift.end > shift.start
Draws that make the rules impossible (here break_minutes outside 0–60) are
rejected rather than failing the test, and cross-field rules never need
.filter(). Each example uses a fresh solver seeded from the draw, so failing
examples replay and the drawn fields shrink as usual.
Development
uv sync
uv run pytest
CI runs the full set of checks.
MIT licensed. See architecture and tradeoffs.
Metadata
Release files for modelfill 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| modelfill-0.1.0.tar.gz | 31.6 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| modelfill-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 66.0 kB
Release files / modelfill-0.1.0.tar.gz
| Download URL | modelfill-0.1.0.tar.gz |
|---|---|
| Size | 31.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c20a44403f1c210df8d646dffb8f8d4d7f7e9450961f289235f64bc34f51851f
|
|
BLAKE2b-256 checksum How to use checksums |
d99c82af3e2e847dc8210a99fab97f4fe21c7bd248f2cc4ff191671aa31663ad
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency logRelease files / modelfill-0.1.0-py3-none-any.whl
| Download URL | modelfill-0.1.0-py3-none-any.whl |
|---|---|
| Size | 34.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
84e8204bc4aa642f8a7dcec7e98fba84de00c3d4782bf0b2f5aa5601c66ffe1c
|
|
BLAKE2b-256 checksum How to use checksums |
13890380388255b879d47f320950e9a7e801b17d32f339247201992303d062cf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency logSecurity for modelfill 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for modelfill-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.