Command line¶
polspec does at the shell what a spec does in Python: create one from data,
turn one into a test, generate data from one, check data against one, and
say what moved. Every verb is a thin wrapper over a FrameSpec method that
already exists — from_dataframe, to_yaml, generate, validate, diff,
drift — so the CLI is argument parsing and templating, not new behaviour.
polspec schema infer orders.parquet -o orders.yaml
polspec schema new Orders -o orders.py
polspec test orders.yaml -o test_orders.py
polspec generate orders.yaml -n 1000 -o orders.parquet --seed 1
polspec validate orders.yaml orders.parquet
polspec diff orders_v1.yaml orders_v2.yaml --markdown
polspec drift orders.yaml orders.parquet
schema infer — profile data into a spec¶
polspec schema infer SOURCE -o OUTPUT.yaml [options]
polspec schema infer SOURCE -o OUTPUT.py [options]
SOURCE is a .csv, .tsv, .parquet, .ndjson/.jsonl, .json, or Arrow
IPC (.arrow/.ipc/.feather) file. It is read with the matching Polars
reader and profiled with FrameSpec.from_dataframe, the same function behind
Getting started.
OUTPUT's extension picks the format: .yaml/.yml writes a YAML spec via
FrameSpec.to_yaml; .py writes a FrameSpec subclass via
FrameSpec.to_python — a starting-point module you can edit like any other
source file, rather than a data file from_yaml re-parses.
$ polspec schema infer orders.parquet -o orders.yaml --weights
Inferred 3 column(s) from 12,483 row(s) of orders.parquet -> orders.yaml
$ polspec schema infer orders.parquet -o orders.py --weights
Inferred 3 column(s) from 12,483 row(s) of orders.parquet -> orders.py
"""Declares the Orders schema."""
import polars as pl
from polspec import ColSpec, FrameSpec
class Orders(FrameSpec):
__columns__ = {
"order_id": ColSpec(pl.Int64, bounds=(1, 12483)),
"status": ColSpec(pl.Enum(["NEW", "PAID", "SHIPPED"]), weights=[0.4, 0.3, 0.3]),
"total": ColSpec(pl.Float64, bounds=(10.0, 500.0)),
}
Columns are declared through __columns__ rather than as class attributes,
same as from_yaml — see Column names that are not
identifiers. The .py
output is passed through ruff format when it's on PATH, same as schema
new.
| Option | Effect |
|---|---|
--name NAME |
Class name (default: derived from the file name) |
--weights |
Record each category's observed frequency |
--max-unique-enum N |
Max distinct values for a string column to become an Enum (default 50) |
--no-bounds |
Skip computing numeric/temporal bounds and string lengths |
--sample N |
Profile only the first N rows |
Treat the output as a draft. It describes the sample it saw — edit bounds, add rules, tighten a domain — before trusting it as a contract.
schema new — start from nothing¶
Writes a blank FrameSpec with the two imports it will need and a few
commented ColSpec examples, for the case where there's no data yet to
profile.
test — a round-trip test from a schema¶
SOURCE is a .yaml/.yml spec (from schema infer, or written by
FrameSpec.to_yaml) or a .py file defining one or more FrameSpec
subclasses (from schema new, filled in). The generated file asserts the
property this project's own test suite is built around:
def test_orders_roundtrip():
df = Orders.generate(500, seed=42)
Orders.validate(df)
def test_orders_cartesian_coverage():
df = Orders.generate(500, method="cartesian", seed=42)
Orders.validate(df)
| Option | Effect |
|---|---|
--rows N |
Rows to generate (default 500) |
--seed N |
Generation seed (default 42) |
--no-cartesian |
Skip the coverage-guaranteeing test |
--class NAME |
Generate a test for only this class, when the source defines several |
It will not hand you a test that fails on the spot¶
generate() does not attempt everything validate() checks — see
Known limitations. A spec using __checks__ or
ColSpec.validators would otherwise generate a test that fails the moment it
runs, because both wrap arbitrary expressions nothing can be generated to
satisfy. The generator checks for each and disables the corresponding
validate() flag, with a comment explaining why:
def test_invoices_roundtrip():
# __checks__ wraps arbitrary expressions that generation cannot be made
# to satisfy
df = Invoices.generate(500, seed=42)
Invoices.validate(df, validate_checks=False)
unique=True and __unique_together__ used to be on that list. They are
generated now, so the generated test validates them like anything else.
A spec with a foreign key referencing another spec needs that spec's data
via references=, which the CLI cannot supply on its own — that test is
marked @pytest.mark.skip with a reason, rather than guessed at:
@pytest.mark.skip(
reason=(
"Child has foreign key(s) 'fk_parent_id__Parent' referencing another "
"FrameSpec. generate()/validate() need a parent DataFrame via "
"references={OtherSpec: parent_df} -- see "
"docs/how-to/constraints.md#referential-integrity-foreignkey."
)
)
def test_child_roundtrip():
pass
Similarly, the cartesian test is only emitted when the spec actually has
something for method="cartesian" to build coverage from — an Enum,
Boolean, or bounded numeric column. A spec of only unbounded strings gets a
comment instead of a test that would raise ValueError on the first run.
Regenerating¶
The generated file names the command that made it:
"""Generated by `polspec test orders.yaml`.
Regenerate with:
polspec test orders.yaml -o test_orders.py
This file is only overwritten by running that command again -- edit freely.
"""
It is a plain file, not managed state — add assertions, rename the functions, delete the parts you don't want. Nothing re-reads it.
generate — data from a schema¶
polspec generate orders.yaml -n 1000 -o orders.parquet --seed 1
polspec generate specs.py --class Orders -n 500 -o orders.csv --references Customers=customers.parquet
polspec generate orders.yaml -n 50 -o edge_cases.ndjson --method cartesian
Generates -n rows and writes one file; the extension picks the format
(.parquet/.pq, .csv, .tsv, .ndjson/.jsonl, .json,
.arrow/.ipc/.feather — the same set validate and drift read).
--seed makes the file reproducible; --method cartesian guarantees
coverage the way generate()
does; --references NAME=PATH supplies parent data for a foreign key, as
for validate.
The frame is built in memory and written once. For a file too large to hold,
the streaming sink_* functions
are a Python surface. A CSV or TSV cannot hold a Duration, List or
Struct column; Polars refuses to write one, so use Parquet or Arrow IPC
for a spec that has any.
--all — every spec in a directory¶
polspec generate --all specs/ -n 1000 -o data/ --seed 1
polspec generate --all specs/ -n 1000 -o data/ --format csv
With --all, SPEC is a directory (or any file Registry.discover
accepts) and -o is a directory: every spec found is generated, parents
first with their keys threaded into their children as
Registry.generate_all does, and
written as <name>.<format> (Parquet by default). No --references are
needed for keys between the discovered specs; supply them for a parent
outside the directory.
validate — check data against a schema¶
polspec validate orders.yaml orders.parquet
polspec validate specs.py orders.parquet --class Orders --references Customers=customers.parquet
polspec validate orders.yaml orders.csv --json > report.json
Reads a data file (CSV, Parquet, NDJSON or Arrow IPC), runs
inspect() against the spec, and
prints the report: the same text validate() would raise, or the full
structured report with --json. The exit status is 0 when the data passes
and 1 when it does not, so a spec can gate a pipeline step in CI with no
Python at all.
--references NAME=PATH supplies parent data for a foreign key to another
spec, by that spec's name; repeat it for several. --allow-extra and
--allow-missing relax the structural checks; --strict-dtypes tightens the
dtype check. --skip CHECK turns off one kind of check, as the matching
validate_*=False does in Python -- --skip bounds --skip checks -- and
takes any of rules, validators, unique, checks, foreign_keys,
hierarchy, pattern and bounds.
A CSV, TSV or JSON file has no date type, so a date arrives as text. The CLI
reads each column the spec declares as a Date, Datetime or Time as
that type when every value in it parses; a column holding a value that does
not stays text, and is reported as a dtype finding rather than turned into
a null. A String column of date-shaped text is left alone. drift reads
the same way, and schema infer, with no spec to go by, recognises a CSV's
dates itself.
--all — every spec against the file named after it¶
With --all, SPEC is a directory of specs and DATA a directory of data
files named after them (Orders.parquet for Orders, in any format the
CLI reads). Every spec with a file is checked, each seeing the others'
files as its parents, as Registry.inspect_all
does; specs with no file are listed and skipped. The exit status is 1 when
any report fails; --json prints one report per spec, keyed by name.
diff and drift — what moved¶
polspec diff orders_v1.yaml orders_v2.yaml # two schemas
polspec diff specs_v1.py specs_v2.py --class Orders --rename id=order_id
polspec drift orders.yaml last_night.parquet # a schema and data
polspec drift orders.yaml last_night.parquet --markdown > drift.md
diff runs diff() between two spec files;
drift runs drift() between a spec and
a data file. Both print the report as text, --json, or --markdown (the
shape of a pull-request comment, breaking findings first).
The exit status is decided by --fail-on: breaking (the default) exits
1 when any finding would break validation; any exits 1 on any finding
at all, a widened bound included; none always exits 0, for posting a
report without gating on it. So a schema change in a pull request, or a
nightly load, can be gated with no Python:
--strict-dtypes makes any dtype change breaking, as it does for
validate. drift also takes --null-rate-tolerance, --no-unseen,
--max-samples and --sample N — see
DriftOptions.
drift --all — every spec against the file named after it¶
As for validate --all: SPEC is a directory of specs and DATA a
directory of files named after them. Every spec with a file is measured,
specs without one are listed and skipped, and --fail-on decides the exit
status across all of them. diff compares two declarations rather than a
declaration and data, so it has no --all.
Exit codes and errors¶
Every subcommand returns 0 on success and 1 on a reported error, printed
as error: ... on stderr rather than a traceback — a missing file, an
unreadable format, an invalid class name. validate, diff and drift
also return 1 when the report itself fails, so a 1 means "look at the
output", whichever kind of problem it was.