Skip to content

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

polspec schema new NAME -o OUTPUT.py

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

polspec test SOURCE -o OUTPUT_test.py [options]

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

polspec validate --all specs/ data/
polspec validate --all specs/ data/ --json > reports.json

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:

polspec diff main/orders.yaml pr/orders.yaml --markdown --fail-on breaking

--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

polspec drift --all specs/ nightly/
polspec drift --all specs/ nightly/ --json > drift.json

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.

$ polspec schema infer nope.csv -o out.yaml
error: no such file: nope.csv