Changelog¶
All notable changes to polspec are recorded here. The format follows Keep a Changelog. Until 1.0, minor versions may break the Python API, the YAML format, and the values a given seed produces; see Roadmap and stability.
Unreleased¶
0.9.1 - 2026-09-24¶
A patch release around one question: someone hands you a CSV, and there
is a spec it should meet -- what do you do? The CLI now reads a text
file's dates the way the spec declares them, where before every CSV with
a date column failed polspec validate; the docs walk the flow end to
end; and bounds can be set aside while you find out what the ranges
really are. No seeded output changes, and no spec file needs migrating.
Added¶
-
Checking a file you were given, a section of Validating data: read the file with
try_parse_dates=Truerather than the spec's schema (which stops at the first bad value),inspect()it for every problem at once, decide whether the file or the spec is wrong, thenvalidate(cast=True)for the typed frame. The example runs in the suite. -
polspec validate --skip CHECK, repeatable, for any of thevalidate_*switches ----skip bounds --skip checks. Its choices come from the switch list itself, so a switch added later reaches the CLI. -
polspec.__version__, the installed version. -
validate_bounds=Falseturns off the bounds checks, beside the othervalidate_*switches and asValidationOptions(bounds=False). The other switches cover what generation cannot yet satisfy; this one is for real data, whose ranges you may want to look at before holding it to them while every other claim is still checked. It covers everybounds, aList's elements and a struct's fields included;string_lengthandlist_lengthstay on.
Fixed¶
-
The CLI reads a text file's dates in the spec's terms. A CSV or JSON file has no date type, so a
Datecolumn arrived as text and validation reported its dtype and checked nothing else -- every CSV with a date column failedpolspec validate, including onepolspec generatehad just written.validate,driftand both--allmodes now parse each column the spec declares as aDate,DatetimeorTimethat arrived as text, keeping the parse only when every value parses: a column with a bad value stays text, so itsdtypefinding stays true, and aStringcolumn of date-shaped text is left alone.schema infer, with no spec to go by, reads a CSV withtry_parse_datesand declares a date column as aDate. -
estimated_sizeno longer charges a null row for content it does not hold. A nullable text orListcolumn was estimated as if every row held a value -- 23% high for a string column at a 30% null rate, 32-38% for a list. A string's bytes past its view and a list's elements now scale by the share of rows expected to be present; views, offsets, fixed-width values, anArray's slots and a struct's fields are paid on a null row too and are unchanged. -
The API reference for
FrameSpec.validate,diffanddriftrendered prose after their parameter lists as parameters namedA,thatandUniqueness. It is a Notes section now, and a test parses every docstring in the package as the reference does, failing on a warning.
0.9.0 - 2026-09-24¶
Two things are removed, as 0.8.0 announced: generate(lazy=True),
which is now a TypeError -- scan() is the lazy frame -- and the
polspec[arrow] extra, which no longer resolves. Both are under
Removed below. Everything else in the release is additive.
The headline is Struct, the last dtype polspec declared and could not
generate. ColSpec(fields={name: ColSpec(...)}) says what is claimed about
each field's values, so a field is described exactly as a column of its
dtype would be, and every part of polspec reads it: generation, validation,
the spec file, diff and drift, from_dataframe, the data dictionary
and estimated_size. The same recursion generates a List(Struct), a
List(List) and an Array(Struct), so every dtype now generates, nested
to any depth. No seeded output of an existing spec changes, and no spec
file needs migrating.
Added¶
- Every dtype generates. A
Structcolumn is generated from itsfields-- one column per field, gathered -- and the same recursion generates aList(Struct), aList(List), anArray(Struct)and a struct of either, nested as deeply as the dtype goes. There is no dtype left that polspec declares and cannot generate, andtest_roundtrip.pypins that claim rather than the list of exceptions it used to hold.
A field is drawn by the code that draws a column of its dtype, and is
seeded under its parent by name: renaming a struct column with
seed_name keeps every field, adding a field beside one moves nothing,
and a struct column is a window under generate_batches and scan()
like any other. The column's nullable is the cell -- a null struct
-- and a field's own nullable says whether it may be null inside a
struct that is present.
Validation checks each field's claims in place, and a finding names the
field: key point.lat__bounds, message Column 'point.lat', while its
columns stay ("point",) so report.rows() returns the offending
rows. Inside a list the samples are the offending lists, and a list of
lists tells its levels apart (c__element_null, c[]__element_null).
A struct in the data matches a declared one by field name rather than
order, each field compatible by the usual rules -- an Int32 field
stands in for a declared Int64 outside strict_dtypes.
ColSpec(fields=…): what is claimed about a struct's field values. AStructcolumn's dtype is its schema -- every field's name and type comes from it -- andfieldsis aColSpecper field saying what its values are, so a field is described exactly as a column of the same dtype would be:
ColSpec(
pl.Struct({"lat": pl.Float64, "lon": pl.Float64}),
fields={"lat": ColSpec(pl.Float64, bounds=(-90, 90))},
nullable=True,
)
It is partial: a struct of twenty fields where one needs bounds
spells one field. A name the dtype does not declare, or a field spec
whose dtype disagrees with the struct's, is refused where it is written,
as are unique, rules, validators, seed_name and col_name --
each a claim about a column among columns, which a value inside a
struct is not. A List of a Struct takes fields too, and a field
may itself be a struct, so a declaration nests as deeply as the dtype
does.
A Struct dtype now has a spec-file form ({Struct: {name: <dtype>}}),
so a struct column round-trips through YAML and to_python -- and so do
List(Struct) and List(List), which had no written form before. The
format version stays at 3: fields: is an added optional key.
- The rest of polspec reads a struct too.
diffanddriftcompare a struct's fields as they compare columns -- every comparator, keyed by path, so describingpoint.latwith bounds isdomain_narrowedonpoint.lat(breaking, as on a column) and data escaping them isbounds_exceededthere; a field's null rate is measured inside the structs that are present.from_dataframere-declares a struct column by its fields, aListof structs by its element's, and aListof lists by its outer length, where it used to record the dtype alone. The data dictionary gives each field a row (point.lat) under its column, andestimated_sizecosts a struct as the sum of its fields.
Removed¶
generate(lazy=True), deprecated in 0.8.0. It built the whole frame and called.lazy()on it, so it looked like the lazy verb while being the eager one.scan()is the frame that has not been built;.lazy()on a generated frame is a handle on one that has. Passinglazy=is now aTypeError.- The
polspec[arrow]extra, redundant since 0.8.0 made the sinks Polars' own. Nothing in polspec needs PyArrow, sopolspec[arrow]no longer resolves -- installpolspecand, if you want PyArrow for your own code, PyArrow.
0.8.0 - 2026-09-22¶
The headline is scan(): a LazyFrame that generates rows as they are
collected, so a plan that wants five rows of one column out of fifty
million generates five rows of one column. Projection is exact rather than
approximate, because 0.7.0 keyed every column and every pass by name.
Around it, a batch becomes a window onto one frame -- the same rows
whatever the batch size -- the sinks become scan() written out and stop
needing PyArrow, estimated_size() says how large a frame will be before
it is allocated, and generate(lazy=True) is deprecated in favour of the
verb that means it. Every seeded batched or sunk output changes, once;
generate() is unchanged, and no spec file needs migrating.
Added¶
-
estimated_size(n), and a word before a large frame is allocated.Orders.estimated_size(50_000_000)reads the answer off the declaration -- the width of each dtype, the lengths the spec declares -- so it costs nothing and needs no data.generate()now estimates before it allocates: past four gibibytes it warns, naming the figure and pointing atscan()andgenerate_batches();max_bytes=makes it a refusal instead, for a CI job that should fail rather than swap, andmax_bytes=0silences both.polspec generateprints the same note. The estimate is the frame, not the process peak -- generation holds working buffers on top, most visibly forDecimalandList-- and for a frame of scalar columns it is within a percent of measured. -
scan(): aLazyFramethat generates rows as they are collected.Orders.scan(50_000_000, seed=1)builds nothing; the plan decides what is drawn..sink_parquet(...)streams in bounded memory,.select("total").head(5).collect()generates five rows of one column, and a predicate filters rows that were drawn rather than narrowing the draw. Projection is exact: every column is seeded by its name and every pass by what it is for, solf.select(cols).collect()is alwayslf.collect().select(cols)-- where a column depends on others (a rule'swhencolumns, a composite key's members) the closure is generated and dropped again on the way out. A scan is batched, so it carriesgenerate_batches' terms: a__hierarchy__is refused, and uniqueness holds within a batch.Registry.scan_all()does the same for a set of specs, generating parents eagerly -- a foreign key needs the whole parent column -- and the children lazily. Built on polars'register_io_source, which polars marks unstable; the tests pin behaviour rather than the API surface. -
polspec drift --all specs/ data/, completing the--alltrio: every spec under a directory measured against the data file named after it, specs without one listed and skipped,--fail-ondeciding the exit status across all of them, and--jsonprinting one report per spec.diffcompares two declarations rather than a declaration and data, so it takes no--all.
Changed¶
- The four
sink_*functions arescan()written out. Each is nowscan(...)handed to the matchingLazyFrame.sink_*, so PyArrow is no longer needed for the Parquet and IPC sinks -- nothing beyond Polars is needed at runtime, and thearrowextra is redundant. Signatures, defaults and then=0behaviour are unchanged; what a sink's**kwargsreach is now polars' own sink rather thanpyarrow.parquet.ParquetWriterorpyarrow.ipc.new_file, so a call passing a PyArrow-only writer option needs the polars spelling instead.compression=is typed as the literal polars accepts rather thanstr, so a misspelling is caught where it is written. Thepolspec[arrow]extra still resolves and is removed in 0.9. generate(lazy=True)is deprecated, and removed in 0.9. It builds the whole frame and calls.lazy()on it, so the memory is already spent: usescan()for a frame that generates as it is collected, or.lazy()on the result for a handle on an eager one.- A batch is a window onto one frame.
generate_batchesand thesink_*functions used to seed batch k from the k-th draw of the caller's seed, so the same seed with a differentbatch_sizewas a different frame and no batch could be made without the ones before it. The engine now takes a row offset and numbers its chunks from it, so for a column no pass rewritespl.concat(generate_batches(n, batch_size=b, seed=s))equalsgenerate(n, seed=s)for everyb; aListcolumn's lengths are a window too. Rules, foreign keys, composite keys and List elements are still drawn per batch -- deterministic, keyed by the batch's offset, but not row for row the whole frame's -- and uniqueness still holds within a batch. Every seeded batched or sunk output changes, once;generate()is unchanged. A batch smaller than the engine's 65,536-row chunk costs up to one chunk of extra draws.
Internal¶
- A link to a heading that does not exist fails the test suite, not
just
zensical build --strict.tests/test_docs.pychecked that a linked page existed but never its anchor, so a wrong slug reached CI before anyone saw it. polspec.validation.constraintsis a package, one module per kind of claim:_values(what one value must be, lifted over a List's elements),_rules,_table(composite keys, checks) and_relations(foreign keys, the hierarchy), with the_Constraintbase and the dtype rule in_base. No import outside the package changed.- A generated column is dropped as it is finished, so a temporal, Binary or gathered column never exists twice while the frame is assembled.
0.7.0 - 2026-09-22¶
The headline is nested and decimal dtypes: List, Array and Decimal
columns generate, validate and round-trip, described by the same fields a
scalar column takes. Around it, one change to what a seed produces -- the
passes that run after the columns are filled are now keyed by name, so
inserting a column never changes its neighbours -- and, alongside it, the
type checker moves to ty with nothing suppressed, the CLI gains
--all, and four long-standing limitations close, the last strict xfail
among them. Specs with rules, foreign keys, a hierarchy or a composite key
produce different values for the same seed than 0.6 did, once; see
Changed. No spec file needs migrating.
Added¶
pl.Listandpl.Arraycolumns generate, validate, and round-trip. A nested column is described by the same fields as a scalar one, read as claims about each element:ColSpec(pl.List(pl.Int64), bounds=(0, 10), list_length=(1, 5)).bounds,choices,weights,format,pattern,string_lengthanddistributiondescribe the values inside the list; the newlist_lengthdescribes the list (anArraytakes its length from the dtype);nullabledescribes the cell, and an element is never null. Generation draws the lengths and the elements as two columns of the inner dtype and wraps one by the other, so an element is made by the same code that makes a scalar of its dtype -- every generatable dtype,Enum,Decimaland aformatincluded -- and aListcolumn keeps its data across a rename throughseed_name. Validation runs every element claim inside the list and fails a list where any element does;list_lengthis a new finding code and a null element anullabilityone.diffcompareslist_lengthlikestring_length;driftmeasures a List column as its elements and its lengths;from_dataframedeclares one by its elements;{List: <dtype>}and{Array: {inner: <dtype>, width: N}}are the spec-file forms.uniqueandrulesare refused on a nested column, and aListof aListorStructdeclares and validates by dtype only. What generation cannot fill is nowStructand those.pl.Decimalcolumns generate. ADecimal(precision, scale)is an integer and a scale, so it is drawn as the integer through the engine's 64-bit kind and scaled back to the declared type -- withbounds, adistribution,choices, nullability and cartesian coverage like any other numeric column, and a default range of the float default or the widest the precision allows. Bounds are held exactly (int,decimal.Decimal, or a string read exactly, which is how a spec file writes one); an endpoint finer than the scale is refused rather than rounded. Validation compares values whatever the frame holds them as (a CSV hands a Decimal back as a float), drift measures the extent in the column's own type,from_dataframedeclares one with its extent, and{Decimal: {precision: P, scale: S}}is its spec-file form. The ungeneratable dtypes are now the three nested ones.ColSpec(seed_name=...): rename a column without changing the data it generates. Each column is seeded from the frame seed and its name, so a rename had always changed a column's values -- which matters when generated frames are snapshots other things are compared against. A column declared withseed_name="old"is seeded as"old"and keeps producing what it did. Declared, not guessed:rename()does not set it, and two columns of one spec cannot share one (they would draw identical values). It holds across a rename and nothing else: the passes that run after the columns are filled draw their seeds in declaration order, so an inserted rules column still reshuffles the ones after it, and that boundary is pinned and stated in Known limitations.
Documentation¶
-
Generating data said each column derives its seed "from its position". It is from its name -- the reason
seed_nameis needed at all. -
polspec generate --allandpolspec validate --all. With--all,SPECis a directory of specs:generatewrites one<name>.<format>per spec into-o DIR, parents first with their keys threaded into their children, asRegistry.generate_alldoes;validatechecks each spec againstDATA/<name>.<suffix>, every spec seeing the others' files as parents, asRegistry.inspect_alldoes, exiting 1 when any fails and printing one report per spec with--json.
Changed¶
- A rule leaves a null a null. A nullable column with rules used to
lose nulls on the rows a rule matched, ending up below its declared
null_probability. The column's nullability is decided when it is drawn; a rule says what a value on a matched row is, which is also all validation checks it against. to_mermaidmarks one primary key. A loneunique=Truecolumn is the entity'sPK; when several columns are unique each is aUK, as a__unique_together__member already was.- A
CatSpecrefuses two entries differing only in case (STATUSandstatus), naming both: lookup is case-insensitive, so they were one name with two answers. - Pass seeds are keyed by name, not drawn in order. The passes that
run after the columns are filled -- rules, the hierarchy, foreign keys,
composite uniqueness, a bounded categorical's pool, cartesian coverage
representatives -- used to take their seeds from one
random.Randomin declaration order, so inserting a rules column shifted every later pass andseed_namecould not hold a ruled column's data. Each now mixes the frame seed with a key naming what it is for, by the same construction the engine uses for columns. A spec with any of those produces different values for the same seed than 0.6 did, once; a spec with plain columns is unchanged, and so is every column's own draw. Thelimitations.mdbullet that described the old behaviour is gone.
Fixed¶
generate(0, method="cartesian")returns the empty typed framegenerate(0)and the sinks do, rather than the whole coverage set. The last strictxfailintests/test_roundtrip.pyis gone with it.
Internal¶
tyreplaces mypy, and nothing is suppressed. The 23-module ignore list was one pattern:ColSpec,CheckandForeignKeyannotated their fields with what the constructor accepts (pl.Int64orpl.Int64(), a tuple or aBound, one validator or several) while__post_init__narrows every one to what the instance holds. The fields now say what they hold, and each class declares the accepted signature in an__init__that exists only for type checkers, held to the fields by a test. Everyspec.dtype.is_integer()in the library type-checks; thecast()accessors drift needed are gone; the API reference andllms-full.txtshow the accepted signature. CI runsuv run ty check.polspec.cliis a package, one module per verb, with the readers, writers and spec loaders every verb shares in_io.validate()/inspect()anddiff()/drift()shared 25 lines of options handling twice; it is onepolspec._options.options_from.Bound.closed(): both endpoints of a bound that has both.
0.6.0 - 2026-09-14¶
The headline is drift: a report of what changed between two specs, or
between a spec and the data it describes, with a severity per finding under
one mechanical rule -- and three CLI verbs so that report can gate a pull
request or a nightly load with no Python. Alongside it, pattern= completes
the split format= began. Nothing in the engine changes; no seed produces a
different frame, and no spec file needs migrating.
Added¶
- Drift as a report.
polspec.drift.diff(old, new)says what changed between two declarations;polspec.drift.drift(spec, df)says how data has moved relative to what its spec declares -- values outside the domain, a bound exceeded and by how much, a format no longer matched, a null rate that moved, declared values never seen. Both return oneDriftReport, also reachable asOrders.diff(Other)andOrders.drift(df):
report = OrdersV1.diff(OrdersV2)
report.breaking # a narrowed bound, an added column, a new constraint
report.compatible # a widened domain, a removed check
report.to_markdown() # the shape of a pull-request comment
Every finding carries a severity under one mechanical rule: breaking
when a frame that satisfied the old side could fail the new one --
decided, for dtypes, by the same function validation uses. Two tests hold
the two sides to it: what generate() produces never drifts breakingly
from its own spec, and every breaking finding against data is a column
validate() reports. Sixteen closed finding codes, a DriftOptions
with explicit tolerances, and a comparator per ColSpec and TableSpec
field with a parity test, so a field added to a declaration is one entry
or one red test. DriftReport, DriftFinding and DriftOptions are
exported from polspec.
-
ColSpec(pattern=...): a regular expression every value of aStringcolumn must match, checked by validation only. Generation does not read it -- the column is filled with ordinary random text, and the round trip holds only withvalidate_pattern=False, exactly as forvalidators. That is the honest half of the splitformat=made: any regex can be checked, the curated set can be generated. Apatternfinding code, avalidate_patternswitch onValidationOptions, onepattern:key in spec files (the format version stays at 3: an added optional key is not a new version, andmigrations.pynow says so). Cannot be combined withformat; compiled by Polars' own engine at declaration, so a pattern Polars cannot run is refused with its message. -
Three CLI verbs.
polspec generate SPEC -n N -o FILEwrites generated rows to any format the CLI reads (--seed,--method,--referencesas forvalidate);polspec diff OLD NEWandpolspec drift SPEC DATAprint a drift report as text,--jsonor--markdown, and gate on it with--fail-on breaking|any|none-- so a schema change in a pull request, or a nightly load, can fail CI with no Python:
polspec generate orders.yaml -n 1000 -o orders.parquet --seed 1
polspec drift orders.yaml orders.parquet
polspec diff main/orders.yaml pr/orders.yaml --markdown --fail-on breaking
Documentation¶
- Five claims the docs had stopped being true about are fixed, and one of
them is pinned:
unique=Trueand__unique_together__had been listed as work generation does not attempt (generated since 0.2.0);generate()on an unsupported dtype raisesSpecError, notTypeError; every yaml example carriedversion: 2at format version 3, and a test now holds eachversion:in the docs toFORMAT_VERSION. - The roadmap
gains a Deferred on purpose section, starting with the
ColSpec->Domainrestructure that two release plans had set aside without saying so on the page, and the condition under which it would be revisited.
0.5.0 - 2026-09-11¶
The headline is format=: a String column that says what its values look
like, and is generated to satisfy its own validator. Around it, the release
closes five silent edges, settles the validation option surface, spells out
the FrameSpec signatures, and puts a type checker in CI. One narrow
breaking change, under Changed.
Added¶
ColSpec(format=...): aStringcolumn that says what its values look like, and is generated to satisfy its own validator. Eight formats --uuid4,email,ipv4,ipv6,mac,hostname,iso_country,iso_currency-- each with a sampler in the engine and a check in validation declared side by side inpolspec.formats, and pinned by a round trip per format:
class Users(FrameSpec):
user_id = ColSpec(pl.String, format="uuid4", unique=True)
email = ColSpec(pl.String, format="email")
Users.validate(Users.generate(1_000_000, seed=42)) # passes
Validation reports a new format finding. A format is one format: key in
a spec file and takes part in the domain check a foreign key runs at
declaration. It cannot be combined with choices or string_length, and
only a String column can carry one; each is refused with a message
saying which to drop. What a format promises is syntax: see Known
limitations.
ValidationOptionsis exported frompolspec, andvalidate()andinspect()take it asoptions=. Every switch as one value, for when the same settings go through several calls:
lenient = ValidationOptions(extra_cols="drop", checks=False)
Orders.validate(df, options=lenient)
Customers.validate(other_df, options=lenient)
options= and the individual keywords are alternatives rather than a base
and an override, so passing both raises instead of quietly picking one.
Changed¶
-
FrameSpec.validate,inspectand the foursink_*classmethods spell their options out instead of forwarding**kwargs. Editors complete them, a type checker sees a typo, and a mistyped option is aTypeErrorfrom the classmethod's own signature rather than from a function several frames away. The validation keywords default toNone, meaning "theValidationOptionsdefault", so the defaults are still defined once. The sinks keep their trailing**kwargs: that is the documented passthrough to the underlying writer, not a gap. -
A misspelled spec name in
references={...}now warns instead of passing silently. A key nothing points at was skipped without a word, so the column was generated freely while the caller believed the parent had been used -- andvalidate()then reported the key as unresolved, which is the round trip failing with only its second half audible. The warning names the key that went unfilled and suggests the one supplied:
Orders: references={...} supplied ['Custmers'] that no foreign key points
at, while 'Customers' (supplied 'Custmers'?) went unfilled.
It takes both halves to warn -- something supplied that went unused and
something unused that went unfilled -- so supplying no parent at all stays
silent, as documented, and a Registry handing every spec the whole set of
frames says nothing either. generate_batches warns once per call rather
than once per batch.
- ColSpec(pl.Int64, null_probability=0.9) now warns. nullable=False still
wins and the rate is still ignored -- turning nullability off should not
also require deleting the rate beside it -- but the same silence covered
asking for nulls and forgetting nullable=True, where the column generates
none and nothing says why. Only a rate that cannot be a leftover warns: the
default and an explicit 0.0 already agree with nullable=False.
- references= given something that is not a mapping raises SpecError
naming the three key forms it accepts, rather than an AttributeError from
inside resolve_references. Every collection argument elsewhere in the API
is a sequence, so passing one here was an easy mistake with an unhelpful
answer, and it was the one complaint polspec made that was not a
PolspecError.
- An unknown validation option names the option you meant. It used to surface
as ValidationOptions.__init__() got an unexpected keyword argument
'validate_uniqe', naming a private class that is not exported and not the
option intended; it is now Unknown validation option(s): 'validate_uniqe'
(did you mean 'validate_unique'?), with the accepted list.
- Breaking, narrowly: the check switches have one spelling.
inspect(spec, df, unique=False)used to work alongsidevalidate_unique=False, whilevalidate(spec, df, unique=False)raised -- a second public spelling reachable through half the API, from a rename map that was meant to be internal. Only thevalidate_*form is accepted now, by both verbs. The bare names live on as the fields ofValidationOptions, which is where they were always meant to be.
Documentation¶
DecimaljoinsList,StructandArrayon the list of dtypes that declare and validate but cannot be generated. It had been missing from both Dtype coverage and Known limitations, and it is the one people miss, being the only one of the four that is not a nested type.- A
Datetimecarrying atime_zonegenerates, which the docs had never said either way and readers assumed meant no. Both claims are now pinned by tests intests/test_roundtrip.py, so neither page can go stale.
Internal¶
- The four copies of "declares no ColSpec columns" are one
tablespec.require_columns. - mypy runs in CI. The package ships
py.typedand a stub for the Rust extension, so every consumer's type checker trusts these signatures, and nothing was checking them. The first run found 247 errors, of which 87 trace to two declarations:ColSpec.dtypeandForeignKey.referencesare both annotated with what the constructor accepts rather than what the instance ends up holding. Narrowing either is a design decision on a public field, so the modules carrying that backlog are listed inpyproject.tomland everything else is enforced -- the list can only shrink. - The predicate nodes in
expr.pyno longer each carry their ownroot_names,literalsandrename. A node reports its operands throughchildren()and the three traversals are derived from that onPred, which turns thirty-six implementations into twelve.renameis the one that mattered: the base implementation returnedself, so a node that forgot to override it left a renamed spec pointing at a column that no longer existed, with nothing raised. Forgettingchildren()now raises. ReferencesandMethodwere declared identically in four and two modules;_collect/_to_lazyin three. They are onepolspec.frames.- The four
sink_*functions built the same six-argument batch-stream call each._preparenow returns the checked call as one value. The public signatures stay spelled out, which is what makes a typo in one of them fail at the call site. registry.pyimportedserializationlazily in five methods andreportin a sixth, while importinggenerationandvalidationat module level. There was no cycle; all six are hoisted.
0.4.1 - 2026-09-09¶
The hierarchy release. A self-referencing ForeignKey says that every parent
value exists somewhere in the frame, and nothing more. This adds the
declaration that says the rest of it -- one parent per reference, a bounded
depth, no cycles -- and makes generate() satisfy it rather than leaving it to
the draw.
Added¶
Hierarchy, declaring that two columns of a spec are a parent/child edge list drawn on one pool of references: a child pointing at its parent, and that parent pointing at its own parent, are the same row shape.
class Links(FrameSpec):
PARENT_REF = ColSpec(pl.String)
CHILD_REF = ColSpec(pl.String)
__hierarchy__ = Hierarchy(
child="CHILD_REF", parent="PARENT_REF", max_depth=5
)
generate() produces a real forest: one parent per reference, so every row
resolves to a single ultimate parent, and no chain longer than max_depth
with at least one reaching it exactly -- so a test of a graph walk exercises
the boundary rather than whatever the draw happened to give. branching
shapes the tree, or roots pins the number of ultimate parents.
generate(n, cycles=10, self_references=5) then breaks it on purpose, which
is the other half of testing a graph walk: a resolver written without a
visited set does not fail on a loop, it runs forever. The spec still says the
data should be acyclic, so validate() reports what was injected --
hierarchy_cycle, hierarchy_depth and hierarchy_multi_parent are new
finding codes -- and a test can assert that its own resolver and polspec
agree about what is broken.
Both checks are bounded, so validating deliberately cyclic data terminates:
depth costs max_depth steps and cycle detection walks by pointer doubling,
covering a million-row chain in about twenty. A validator that walked until
it reached a root would hang on the fixtures this feature exists to make.
generate_batches and the sink_* functions refuse a spec carrying one: a
forest spans the whole frame, and batches are generated independently. See
Hierarchies and link tables.
Documentation¶
- The spec file format is version 3, which adds the
hierarchy:key. A version 2 file loads unchanged. - Known limitations
now points a self-referencing
ForeignKeyatHierarchyfor the case that wants a real tree, rather than carrying a recipe of its own. Two tests pin the two apart.
0.4.0 - 2026-09-08¶
The internals release. 0.2.0 and 0.3.0 settled the vocabulary; this one goes underneath it, to the Rust generator and the places where the same table was being maintained in two or three languages.
Nothing about how a spec is written changes. One thing does break, and it is the same thing the roadmap has always reserved: the values a given seed produces are different. Any test asserting on specific generated values needs re-baselining; a test asserting on their properties -- bounds, distinctness, null share, distribution shape -- does not. polspec's own suite needed no changes, which is the shape of test this library is built to support.
Generation got faster, by between a tenth and a third depending on the column.
Measured A/B against v0.3.0 on one machine, same build profile, twenty million
rows: the four-column frame in benchmarks/bench.py 1.30x, a
unique=True Int64 column 1.21x, a bounded nullable Int64 column 1.17x, an
Enum column 1.25x, a String column unchanged. Treat the ratios rather than the
absolute numbers as the claim.
Added¶
generate,generate_batches,inspect,validate,sink_parquet,sink_ipc,sink_csvandsink_ndjsonare exported frompolspecitself. Each takes aTableSpecas its first argument and each is what the matchingFrameSpecclassmethod already called -- but they lived inpolspec.generation/polspec.validation, which the API reference calls internal and free to change in a patch release. So theTableSpec-first half of the library had no stable import path; now it does, and both halves appear in the API reference.
Changed¶
- Breaking: the values a given seed produces have changed. polspec now
builds on
rand0.10 (from 0.8), whose samplers draw differently. Same seed, same version, same frame -- as before; across this version boundary, not. - A generated numeric, boolean, temporal or categorical column arrives as
one chunk rather than one per 65,536 rows. The values buffer and the
validity bitmap are each allocated once at full length and filled in parallel
through disjoint slices, instead of being built per chunk and appended
together. Nothing downstream now pays for a column split into hundreds of
pieces -- the gather behind a
choicesdomain, the cast behind a temporal dtype, everysink_*write.
String columns are the exception and stay chunked, because Polars backs them
with view arrays: merging those copies no string bytes, but it does copy
sixteen bytes of view per row, which costs more than the split it removes.
They still gain the other half of the change -- the chunks are collected in
one go rather than appended one at a time, and an append rescanned both sides
for their first and last non-null value to maintain a sorted flag that random
strings will not have set anyway.
- Drawing a unique=True column no longer materialises its domain. A domain
only a little wider than the row count used to be built in full and partially
shuffled, which allocates in proportion to the domain rather than to the
output: ten million distinct values from a range of eighty million reserved
1.4 GB before writing anything. That branch is now Floyd's algorithm, which
holds only the values it has chosen -- the same case now peaks at 491 MB.
Roomier domains keep drawing and rejecting, which is faster there and was
never the memory problem.
- Every character of the generated-string alphabet is now exactly equally
likely. Six random bits give 64 values for a 62-character alphabet, and the
two spare ones fell back on % 62 over a fresh 32-bit draw, which is biased
by about one part in 70 million -- far too little to see, but free to remove:
the fallback now rejects properly instead.
- rand 0.8 was compiled alongside the rand 0.10 that Polars already links,
so the extension carried two copies of rand, rand_core and their chacha
backends. There is now one of each.
- The release profile builds the crate as a single codegen unit. Measured on
one machine against otherwise identical v0.3.0 code, that alone is worth
2.1x on the unique=True path, for about twenty seconds of build time. Fat
LTO on top of it was tried and dropped: a further 5% for eight more minutes
per build.
Documentation¶
- A self-referencing
ForeignKeyguarantees that every parent value exists, and nothing more -- in particular not that the result is a tree. Parents are sampled from the whole frame, so some rows end up in a cycle or pointing at themselves, which is what makes a recursive query fail to terminate, andvalidate()does not report it because no part of a spec can say "acyclic". Known limitations now says so, with a recipe for a genuine hierarchy, and the roadmap carries what closing the gap would need. Two tests pin the behaviour.
Fixed¶
Registry.validate_allapplies each report's structural transformations using the same bound spec the report was produced against, rather than the unbound copy.
Internal¶
ColumnPlan::buildtakes aPlanArgsstruct instead of thirteen positional arguments, so a call site names what it sets and leaves the rest toDefault. Four#[allow(clippy::too_many_arguments)]and a great manyNones went with it.Kind's three parallel lists -- the names, the parse, the reverse lookup -- are generated from one declaration, so a new column kind cannot be added to two of them and forgotten in the third.- The fixed-width integer ranges were written three times: once as polspec's default generation range, once as the limits a declared bound may not exceed, and once as the Rust samplers' defaults. The first is now read from the second.
- The four
sink_*functions share a typed batch-stream helper rather than forwarding**kwargs. benchmarks/bench_generate.pyis replaced bybenchmarks/bench.py, which measures the same comparison and adds a regression mode. The old harness timed one run per case, in a process shared with the implementations it was comparing against, and recorded nothing about the machine -- so its numbers varied by around 25% between runs and could not be compared across days. It also measured exactly one column shape, which is how a change that made theuniquepath half as fast came within an afternoon of being released as a speed-up. The new one takes the fastest of several runs, gives every measurement its own process, repeats a short case until its floor settles, records the CPU, thread count, Polars version and cargo profile, and covers each column kind, both branches of the unique draw, the cartesian, rule, foreign-key and composite-key passes, and a sink.recordwrites a local baseline andcheckexits non-zero when a case regresses past a tolerance; repeated measurements now agree to within about 2%.- The benchmark table in the comparison guide is re-measured. It had been recorded on 2026-09-03, which is before both 0.2.0 and 0.3.0, so it had been describing an engine two releases old: the four-column frame at twenty million rows was published as 0.0827s, measures 0.1409s on v0.3.0, and 0.1127s here. Some of that gap is still unaccounted for and is worth chasing. The NumPy and pure-Python columns re-measure to within 1% of what was published, which is what says the difference is polspec's and not the machine's.
0.3.0 - 2026-09-06¶
The refactor 0.2.0 started, finished. CatSpec was the one declarative
surface left doing everything in one class; it is now a value with a
metaclass facade, like TableSpec and FrameSpec. Behind it came the fixes
that were waiting for a release allowed to break something.
One thing breaks, and it is worth reading before you upgrade: naming a
CatSpec entry now always gives back the dtype, whichever form declared the
registry. pl.Enum(cats.STATUS) becomes cats.STATUS, and
cats.CURRENCY.physical() becomes
cats.get_categorical("CURRENCY").physical(). Nothing else in the public API
changed shape.
The documentation gained a test: every Python example in docs/ is executed
by the suite, which found five broken examples that had been shipping.
Changed¶
-
Breaking.
CatSpecis a value, and the class body that declares one is read by a metaclass rather than left in the namespace -- the same splitTableSpecandFrameSpecalready had. What follows: -
Naming an entry always gives back the dtype. It used to depend on how the registry was built: a class-body entry was a real class attribute and returned the dtype, while a dict-built registry's
.STATUSreturned the raw category list, and only one of the two could be handed to aColSpec. Both now return the dtype, soColSpec(cats.STATUS)andColSpec(Categories.STATUS)mean the same thing.cats["STATUS"]andcats.get("STATUS")follow the same rule. Replacepl.Enum(cats.STATUS)withcats.STATUS, andcats.CURRENCY.physical()withcats.get_categorical("CURRENCY").physical(). - An entry may share a name with a method. Entries are removed from the
class body before the class exists, so declaring one called
getno longer warns and no longer costs youCatSpec.get. The entry is reached through the registry (Categories.spec.get("get")). CatSpec.infer_from_dataframeandCatSpec.infer_from_framespecare removed;CatSpec.infer(target, ...)dispatches on what it is given, as it already did.from_dataframeandfrom_framespecare unchanged -- those read what is declared rather than inferring what could be.Categories.specis theCatSpeca class body declares. Anywhere a registry is expected --with_catspec,Registry(categories=...),FrameSpec.from_yaml(categories=...)-- the class and the value are now interchangeable.CatSpechas value semantics. Two registries that say the same thing compare equal and hash equal, so one loaded from a file can be checked against one a class body declares.CatSpec.dtype_of(name)is the one lookup everything else is built on: the dtype an entry names, or None.resolve_keystill says which kind of entry a name binds to.enums,categoricalsandchoicesare read-only mappings rather than fresh dicts.dict(cats.enums)if you need a mutable copy.
Added¶
polspec.MultiValidationError, raised byRegistry.validate_allwhen several frames fail at once. It is aValidationError, so an existingexceptclause still catches it, and it carries every failing spec'sValidationReportasreports, keyed by spec name -- previouslyvalidate_allraised with a joined string and the reports were lost, sofailing_rows(),by_code()andto_json()were unreachable from the registry path.polspec.CliErroris exported, soexcept polspec.CliErrorworks. It was the one exception in the hierarchy reachable only frompolspec.errors.- Every Python example in the documentation is executed by
tests/test_doc_examples.py. A block that cannot run standalone says so in an HTML comment (<!-- docs: skip -->), and one that demonstrates an error is checked to still raise (<!-- docs: raises -->). This found four broken examples, fixed here: adrop()of a column the page never declared, aTableSpecexample rebinding the name a later block used, and two blocks naming frames (broken_df,existing_df) that were never built.
Fixed¶
ColSpec(tags={...})is reproducible. Asetwas kept in its own iteration order, which Python salts per process, soto_yamlwrote a differenttags:line on every run and two identically-written specs compared unequal across processes. A set is now sorted; a list or tuple keeps the order it was written in.Registry.generate_all,generate_relatedandinspect_allbind their cross-spec foreign keys before doing anything, so a key whose dtypes do not match is aRegistryErrornaming both columns rather than a Polars cast error from inside generation. Onlyresolve()used to run that check, and nothing said it had to be called first. A key whose target is supplied throughreferences=rather than held by the registry is still accepted, as it was.- A
ColSpeccarrying the same validator twice keeps it once, so it produces one finding rather than two identical ones.TableSpecalready collapsed identical checks and foreign keys. generate_batchesand thesink_*functions resolvereferencesonce per call rather than once per batch. ALazyFrameparent was collected inside every batch, so a scan-backed parent was re-read as many times as there were batches.inspect()no longer raises a raw Polars error for a column whose dtype is wrong and whose spec declareschoicesor anEnum. The domain check was built before the dtype check could bail out, and comparing values against choices of another type is not something Polars will compile at all, so the frame most likely to arrive -- a column read back from CSV or JSON as the wrong type -- crashed instead of reporting adtypefinding.- A
ColRulewhose condition is null on a row no longer excuses every later rule on that row. Generation folds a nullwhentoFalsebefore testing it and before accumulating it into the claimed mask; validation did neither, so the null propagated through Kleene logic and left rows that generation had rewritten unchecked. TableSpecis hashable, soreferences={Orders.spec: df}works. It is one of the three formsgenerate()andvalidate()document, and the only one that could not be put in a dict: the dataclass's generated__hash__cannot hash a mapping of columns, nor aColSpeccarryingdistribution_params.
Documentation¶
- The install sections of the README and the documentation home said polspec
was not published to PyPI, directly below a
pip install polspecblock. Both now describe the published wheels, and point atCONTRIBUTING.mdfor building from a checkout. - The documentation workflow runs on changes to
python/**andscripts/generate_llms_txt.py. The API reference is:::directives filled in by mkdocstrings from the live docstrings, so a docstring that breaks--strictused to pass its own pull request and fail the next one to touchdocs/. - The roadmap's "YAML format may change" section described the missing format
version key that 0.2.0 shipped, and said an unsupported dtype raises
TypeErrorrather thanSpecError. FINDING_COLUMNandValidationOptionsare documented in the validation guide; both are exported and appeared nowhere.how-to/tablespec.mdtaughtpolspec.generation.generate(spec, ...)while the API reference says anything unlisted may change in a patch. The page now says which of the two it is.CONTRIBUTING.mdgives the runnable form of the Windowscargo testworkaround, and names theSTATUS_DLL_NOT_FOUNDfailure it fixes.
0.2.0 - 2026-09-05¶
The architecture release. Specs became data, constraints gained one definition each, and the generator learned to satisfy claims it used to only check.
This release breaks a lot. Every incompatible change below is marked
Breaking and says what to do instead. Three are worth knowing before you
upgrade: Spec._columns and friends are now Spec.spec.columns; the values a
given seed produces have changed, so any test asserting on generated values
needs re-baselining; and several declarations that used to be accepted and
quietly misbehave are now refused at declaration time.
Added¶
docs/llms.txtanddocs/llms-full.txt, published at the documentation site root in the llms.txt format: an index of every page, and the full text of all of them in one file. Generated byscripts/generate_llms_txt.pyfrom the nav, the pages, and -- for the API reference, whose source is:::directives -- the live docstrings, so a language model reads signatures rather than an empty page. A test fails if either file is stale.polspec.constraints: the definitions generation and validation both read, so they cannot drift.Domainis what a column may hold (itschoices, anEnum's categories, itsbounds);Passandorderdecide which rewrite of a generated frame runs first, from the columns each one reads and writes.unique=Trueis generated, not just validated. The engine draws the column without replacement (src/unique.rs): it shuffles a materialised domain when the domain is barely larger than the frame, and rejects against a set when it is roomy. Every dtype is covered, nulls are exempt (a nullable unique column may repeat nulls and nothing else), and a domain too small to cover the row count is refused by name instead of quietly producing duplicates.method="cartesian"holds unique columns out of the coverage product and draws them once over the finished frame.-
__unique_together__is generated. A pass resamples the rows repeating a combination an earlier row already used, so only the repeats move and the rest keep the values their own columns' weights and bounds gave them. Rows with a null member are exempt, matching validation. A group whose columns cannot take enough distinct combinations is refused, naming the group; a foreign-keyed member is never resampled, since that would break its key. -
The Rust boundary is typed. Python builds one
ColumnPlanper column (a#[pyclass]validated at construction, with errors naming the column) instead of a positional tuple. Bounds cross as ani64,u64orf64, soInt64/UInt64bounds beyond 2^53 are exact and the generation clamp for an unbounded distribution reaches the dtype's true limits. Columns with a finite domain (choices,Enum) receive indices back and the typed values are gathered on the Python side, so adatetime,bytesorTruechoice never passes through a string; choices need only be distinct in the column's dtype, not as strings.python/polspec/_polspec.pyiis a stub for the extension;src/is split intoplan.rs,dist.rsandsample.rswith unit tests undercargo test; a test compares the distribution parameter tables on both sides. import polspecworks without the Rust extension: validation, spec files, the registry and the report renderers need no build. Only generation imports it, and raises one actionableImportErrorwhen it is missing.Registry: a declared set of specs.Registry(Customers, Orders, ...)resolves foreign keys declared against names (resolve(), running the checks a class-bound key gets at declaration), orders parents before children (order()), generates the whole set with every key satisfied (generate_all, with a per-spec seed so adding a table changes no other;generate_relatedfor one spec and its ancestors), validates it in one call (inspect_all,validate_all), merges or checks shared categories (catspec(),categories=), writes and reads one file for the set (to_yaml/from_yaml, aspecs:mapping pluscategories:), collects specs from modules and directories (from_module,discover), and draws one entity-relationship diagram (to_mermaid). See the new Multiple specs guide.inspect(): validation results as data.FrameSpec.inspect(df)(andpolspec.validation.inspect(spec, df)) returns aValidationReportofFindingrecords -- each with a code, a stable key, the columns involved, a count, samples and code-specific details -- and never raises for a bad frame.report.rows(finding)andreport.failing_rows()return the offending rows lazily;by_column(),by_code()andto_json()slice and export them. Checks and validators now carry samples too.ValidationError.reportcarries the sameValidationReport;.errorsis still the list of messages.polspec validate SPEC DATA [--references NAME=PATH] [--json]on the command line, exiting 1 on findings, so a spec can gate a pipeline in CI.- A foreign key whose parent was not supplied is a
foreign_key_unresolvedfinding rather than aValueError, matching howgenerate()already treats it; a parent lacking the referenced columns is aforeign_keyfinding. - Spec files carry a
version:(now 2). Files from version 1 are migrated on read; a file from a newer polspec is refused with a clear message. A key the reader does not know is an error naming the closest known key;from_yaml(..., strict=False)downgrades it to a warning. - Foreign keys to other specs are written to YAML and Python as the target's name and read back unresolved, instead of being dropped with a warning.
polspec.serializationis a package driven by one field registry (fields.py): YAML in both directions, generated Python, and theimport datetimedecision all derive from it, and a test asserts every dataclass field has an entry.to_dict/from_dictare public.CatSpecfiles keep choices recorded for plain string columns.polspec.col(), a small predicate language for rules, validators and checks:col("total") >= col("subtotal"),col("email").str.contains("@"),is_in,is_between,is_null,&/|/~, arithmetic, and string operations. A predicate evaluates like the Polars expression it stands for and, unlike one, is written to and read from YAML and generated Python.__checks__andColSpec.validatorswritten withcol()now round-trip throughto_yaml/from_yamlandto_python. Rawpl.Expris still accepted and still warns on export.ColRule.whenaccepts a predicate, so a rule may depend on several columns. The one-column dict form is still accepted and converted.TableSpec: the spec as an immutable value. AFrameSpecclass body now builds one, reachable asSpec.spec, and every verb (generate,validate,to_yaml,to_markdown, ...) is a function over it inpolspec.generation,polspec.validation,polspec.serializationandpolspec.report.TableSpecofferswith_columns,drop,select,rename,with_checks,with_foreign_keys,with_unique_together,with_nameandwith_catspec;FrameSpec.from_specwraps one in a class. See the new Specs as values guide.FrameSpec.col(name)reaches a column whatever it is called.ForeignKey.referencesmay be a spec's name, for keys whose target is not importable where the key is declared.- An exception hierarchy under
PolspecError:SpecErrorfor declarations that cannot mean anything,ValidationErrorfor data that fails its spec,GenerationErrorwhen a spec cannot be turned into data (including every error raised inside the Rust engine),SerializationErrorfor files that cannot be written or read, andRegistryError, reserved for the spec registry. All are exported frompolspec; see the new Errors reference page.
Changed¶
- Breaking.
ColRule.whenno longer accepts the one-column dict ({"column": "region", "equals": "UK"}).col()is the only spelling: writecol("region") == "UK". A spec file written by an earlier version still loads -- its conditions are converted as the file migrates -- but a file declaring the current version must carry the predicate form. The error names the column and says what to write. - Breaking. The
leandgecondition keys are gone; they were undocumented duplicates oflteandgte. (le/geremain the canonical operator names in a predicate's data form, which is unrelated.) - Breaking.
polspec.serializationno longer re-exports the names of the pre-package module layout:_YAML_DTYPES,_YAML_NAME_TO_DTYPE,_dtype_to_yaml,_dtype_from_yaml,_dtype_to_python,_colspec_to_yaml,_colspec_from_yamland_colspec_to_python. Use the names inpolspec.serialization.fieldsandpolspec.serialization.dtypes. - Breaking.
ColRule.whenis evaluated against the frame as it stands when the rule runs, not against the freely generated values. Rules and foreign keys are applied in dependency order, so a rule keyed on a column that another rule or a foreign key rewrites now reads the rewritten values -- the onesvalidate()checks it against. Chained rules, chained foreign keys, and a rule keyed on a foreign-keyed column all round-trip; the values a given seed produces for such a spec change. - Breaking. Two columns whose rules each read what the other writes have
no order that satisfies both, and are now refused at declaration with a
SpecErrornaming them. - Breaking. A
ForeignKeywhose parent's declared domain does not fit inside its own column's is refused at declaration (or when aRegistryresolves a key that names its target as a string). A key overwrites its column with the parent's values, sobounds=(1, 50)on a column referencing keys in100..200could only ever generate data that fails its own validation. A column declaring noboundsorchoicesstill accepts anything. - Breaking.
unique=Truecan no longer be combined withweights, a non-uniformdistribution, orrules. The first two describe how often a value recurs, which a draw without replacement has no room for; a rule assigns from a fixed set, which is how duplicates would get back in. Each is refused at declaration rather than silently ignored. - Breaking. A column carrying
rulesmay not also be part of a__unique_together__group, for the same reason: the repair that separates repeated combinations would overwrite what the rule put there. - Breaking. A
ForeignKeyfilling aunique=Truecolumn now refuses when the parent holds fewer distinct values than there are rows, instead of falling back to sampling with replacement and producing the duplicates the column forbids. polspec testno longer emitsvalidate_unique=Falsein generated tests. Uniqueness is generated now, so the generated test asserts it.-
A foreign key spanning textual dtypes -- a
Stringcolumn referencing anEnumkey, which declaration has always allowed and generation has always handled -- now validates instead of raisingSchemaErrorfrom the anti-join. The parent's keys are cast to the local dtype for the join, soValidationReport.rows()still returns the frame's own rows unchanged. -
Breaking. Each column's generation seed is derived from the frame seed and the column's name, not its position, so inserting a column no longer reshuffles the columns after it. The values a given seed produces change from previous versions.
ColRuleapplication samples only as many values as there are matched rows and scatters them into place, instead of filling the whole column per rule.polspec.validationis a package (report.py,constraints.py); foreign key anti-joins are collected together withpl.collect_allinstead of onecollectper key.- Breaking.
ColSpec.distributionanddistribution_paramsare stored in canonical form ("exp"becomes"exponential",mu/sigmabecomemean/std, and so on), so spec files are canonical. Every alias is still accepted when declaring. - Breaking. An unrecognised physical dtype in a
CatSpecentry is now aSerializationErrorinstead of silently becomingUInt32. - Breaking.
ColRule.whenis a predicate after construction rather than a dict (rule.when.root_names()lists the columns it reads); rules in YAML are written in the predicate data form, and the old dict form is still read. - Breaking. A column may now share a name with a
FrameSpecmethod: the metaclass takesColSpecattributes out of the class namespace, soschema,tagand friends no longer shadow anything and no longer warn. The private_columns,_checks,_unique_togetherand_foreign_keysclass attributes are gone; readSpec.spec.columnsand friends instead. - Breaking.
ForeignKey.referencesis the target's name after construction (the bound spec is available asForeignKey.target), andreferences={...}ongenerate/validateaccepts the class, theTableSpecor the name as key. - Breaking. Removed: the
FrameSchemaalias;FrameSpec.generate_catspec,write_catspec,infer_catspecandwith_inferred_catspec(usecatspec(),catspec().to_yaml(),CatSpec.infer(...)andwith_catspec(CatSpec.infer(...))); themax_uniqueandboundsalias keyword arguments offrom_dataframe(usemax_unique_enumandcalculate_bounds). to_yamlandto_pythonshare one set of warnings about what a file cannot hold.- Breaking, mildly. Errors that were bare
ValueErrororTypeErrorare now the subclass above. Each keeps the built-in type it replaced, soexcept ValueErrorstill catches it; only code matching on the exact type (type(exc) is ValueError) sees a difference. Plain argument misuse (n < 0, an unknownmethod=) is unchanged. - The command line prints any
PolspecErroras a one-lineerror: ...instead of aTypeName: messageline.
Fixed¶
- Foreign-key sampling during generation drew parent keys from an unordered
unique(), so the same seed could give different child rows between runs. The parent's distinct keys now keep their order and generation is reproducible.
0.1.5 - 2026-09-03¶
Added¶
python/polspec/py.typed, so type checkers use the package's annotations.CONTRIBUTING.md, this changelog, and a.python-versionfile.examples/related_specs.py: a worked example of four related specs (foreign keys, shared categories, rules, checks, a YAML-declared spec). It runs in CI as a smoke test.- A release-workflow job that refuses a
vX.Y.Ztag whose version does not matchpyproject.toml. - CI now runs
ruff checkwith a wider rule set,ruff format --check,cargo fmt --check,cargo clippy -D warningsandcargo test, tests on macOS as well as Linux and Windows, and tests against the newest Polars release inside the declared bound. The docs build runs strictly on pull requests. - Release builds now produce wheels for Linux aarch64 and macOS (x86_64 and arm64) alongside Linux and Windows x86_64, plus an sdist, and only publish when the test workflow is green.
- An optional
.pre-commit-config.yamlwith ruff and cargo fmt hooks. tests/test_colspec.py(2,000 lines, unsectioned) is split intotest_generation.py,test_rules.py,test_serialization.py,test_profiler.py,test_framespec.py,test_report.pyandtest_foreign_key.py, each with a docstring saying what it covers.
Changed¶
- The crate version in
Cargo.tomlis a placeholder;pyproject.tomlis the only place the version is set, souv version --bumpworks. - The
parquet,ipcandallextras (all identical) are replaced by a singlearrowextra. Install withpolspec[arrow]for the Parquet and Arrow IPC sinks. polarsis bounded to<2; the Rust extension is coupled to a Polars release line.- The abi3 floor is now Python 3.12, matching
requires-python. cargo testlinks again (extension-moduleis no longer an unconditional crate feature; maturin enables it).
Fixed¶
- Repository URL in package metadata pointed at the repository's old name.
- README claimed the license was unspecified; it is MIT.
- Documentation:
ColSpec(col_name=...)is now described in Declaring columns,FrameSpec.to_python()in YAML specs, the getting-started example importsdate, and the architecture page lists theclimodule and its tests.
0.1.4 - 2026-09-02¶
Added¶
polspec schema infer --output spec.pyandFrameSpec.to_python(), which write a spec as an editable Python module rather than YAML.
0.1.3 - 2026-09-01¶
Added¶
ColSpec(col_name=...), so a column's name in data may differ from the attribute name used to declare it.
Changed¶
- Roadmap expanded with detailed plans for a spec registry, structured validation results, and generation guardrails.
0.1.2 - 2026-08-31¶
Version bump only; no user-facing change.
0.1.1 - 2026-08-31¶
Added¶
- Test workflow on GitHub Actions (Linux and Windows, Python 3.12 to 3.14).
Fixed¶
ColSpec.dtypeaccepts a dtype class as well as an instance.
0.1.0 - 2026-08-31¶
First tagged release.
ColSpecandFrameSpec: declare a Polars schema with nullability, bounds, string lengths, choices and weights, distributions, tags, and conditionalColRules.generate()backed by a parallel Rust extension,method="cartesian"for coverage sets, batched generation and Parquet/CSV/IPC/NDJSON sinks.validate()collecting every violation in one Polars aggregation, with column validators, multi-columnChecks, composite uniqueness andForeignKeys.CatSpecregistries for sharedEnum/Categoricaldomains.- YAML round-trip,
from_dataframe()profiling, Markdown and Mermaid output. - CLI:
polspec schema infer,polspec schema new,polspec test. - Documentation site, comparison guide, and release automation.