Specs as values¶
A FrameSpec class body is the convenient way to write a spec. What it
builds is a TableSpec: an immutable value holding the columns, checks,
composite keys and foreign keys, reachable as .spec on the class.
class Orders(FrameSpec):
order_id = ColSpec(pl.Int64, unique=True)
total = ColSpec(pl.Float64, bounds=(0.0, None))
internal_note = ColSpec(pl.String, nullable=True)
Orders.spec # TableSpec(name='Orders', columns={...}, ...)
Orders.spec.name # 'Orders'
list(Orders.spec) # ['order_id', 'total', 'internal_note']
Orders.spec["total"] # the ColSpec
Orders.spec.schema() # the same pl.Schema as Orders.schema()
Every verb the library offers is a function over a TableSpec; the
classmethods on FrameSpec are one-line forwards that pass cls.spec. So a
TableSpec is the thing being operated on either way:
import polspec
Orders.generate(1_000, seed=1) # the class
polspec.generate(Orders.spec, 1_000, seed=1) # the function, over the value
Both reach the same code. The functions are exported from polspec itself:
from polspec import generate, generate_batches, inspect, validate
from polspec import sink_csv, sink_ipc, sink_ndjson, sink_parquet
df = generate(Orders.spec, 1_000, seed=1)
report = inspect(Orders.spec, df)
validate(Orders.spec, df)
Each takes a TableSpec as its first argument, and each has a FrameSpec
classmethod that forwards to it with cls.spec. Use whichever suits the code:
the classmethods read better when a class body declared the spec, the
functions when the spec is a value that was built, loaded or derived.
Building one directly¶
A TableSpec can be constructed without a class body, which is how
from_yaml and from_dataframe work internally:
from polspec import TableSpec
spec = TableSpec(
"Orders",
{"order_id": ColSpec(pl.Int64, unique=True), "total": ColSpec(pl.Float64)},
unique_together=[["order_id"]],
)
Everything a class body validates at declaration is validated here too. A
TableSpec that constructs is one that can be used.
To get the class-shaped API back, wrap it:
Rebuilt = FrameSpec.from_spec(spec) # a subclass named Orders
Renamed = FrameSpec.from_spec(spec, name="Orders2026")
Deriving one spec from another¶
Each operation returns a new TableSpec; the original is never changed.
| Operation | Effect |
|---|---|
with_columns({...}, **cols) |
Add columns, or replace existing ones in place |
drop(*names) |
Remove columns, and any composite or foreign key that used them |
select(*names) |
Keep only the named columns, in that order |
rename({old: new}) |
Rename columns, rewriting rules, composite keys and foreign keys |
with_checks(*checks), with_foreign_keys(*fks), with_unique_together(*groups) |
Append constraints |
with_name(name) |
Change the name |
with_catspec(registry) |
Re-type columns against a CatSpec; see Shared categories |
staging = Orders.spec.drop("internal_note").rename({"total": "amount"})
Staging = FrameSpec.from_spec(staging, name="StagingOrders")
Two deliberate limits. drop leaves a rule on a surviving column that points
at a dropped one for validation to reject, since silently dropping a rule
would change what the surviving column generates. rename refuses a column
carrying validators, because a validator is a Polars expression naming the
column, and rewriting expressions is not something this library does.
Column names and method names¶
Because the class body's ColSpec attributes are taken out of the namespace
before the class exists, a column may share a name with a method. The method
wins on attribute access; the column is reachable by name:
class Raw(FrameSpec):
schema = ColSpec(pl.String)
tag = ColSpec(pl.String)
Raw.schema() # the method: Schema({'schema': String, 'tag': String})
Raw.col("schema") # the column
Raw.spec["tag"] # also the column
An ordinary column is still an attribute (Orders.order_id), through a
fallback that runs only when normal lookup fails.
Foreign keys point at names¶
ForeignKey.references is stored as the target spec's name. Declaring
references=Customers binds the target for declaration-time checks and
stores "Customers"; a key can also be declared against a bare name, which
nothing checks until a spec of that name is supplied:
ForeignKey("customer_id", references=Customers, ref_columns="id") # checked now
ForeignKey("customer_id", references="Customers", ref_columns="id") # checked later
generate(references={...}) and validate(references={...}) accept the
parent frame keyed by the class, the TableSpec, or the name. A
Registry holding both specs binds the name and runs the
checks the class form would have run at declaration:
class Shipments(FrameSpec):
customer_id = ColSpec(pl.Int64, bounds=(1, 10_000))
__foreign_keys__ = [
ForeignKey("customer_id", references="Customers", ref_columns="id")
]
Shipments.spec.foreign_keys[0].target # None -- nothing to check against
bound = Registry(Customers, Shipments).resolve()
bound["Shipments"].foreign_keys[0].target # Customers.spec