Skip to content

Worked example: modeling pyproject.toml

The first of four real-world worked examples — a real, external format, not built for Omnist, run through the whole pipeline: OSD schema, TOML fixtures, validation, OML output. It's also a stress test: read_toml/write_oml against real-world TOML syntax, and a candid look at what OSD's algebra can and can't express.

Worth being upfront about the ceiling here: pyproject.toml's real "schema" is a prose specification plus the Python code of whichever build backend actually reads it — not bound by any decidability constraint. No static schema language, OSD included, can fully match that: the real validator can run arbitrary conditional logic (resolve an entry-point reference, parse an SPDX expression, cross-check one field against another). JSON Schema gets further than OSD (it has oneOf, dependentRequired) but hits the same ceiling on anything truly dynamic. OSD's ceiling is lower still, by design — it trades expressiveness for guarantees (compatible_with, equivalent, normalize being well-defined at all require a closed, finite label alphabet). What follows is where that tradeoff actually bites, on a real file.

Source, code, and fixtures: examples/pyproject/. Schema: pyproject.osd. Script: convert.py.

Modeled against the pyproject.toml specification, retrieved 2026-07-11 — PEP 517 ([build-system]), PEP 621 (core [project] metadata), PEP 639 (license/license-files), PEP 794 (import-names/ import-namespaces, Accepted, added October 2025).

Run it

python3 examples/pyproject/convert.py

read_toml is called with schema= — the idiomatic call once the schema is already in hand, which upgrades value-exact leaves (ISO strings to real date/time/datetime, ints to number) to match it. It's a no-op here: pyproject.osd has no date/time/datetime/ number field, only string, boolean, any, and Ref — most real-world TOML/JSON metadata files are string/bool-heavy at the top level, so there's nothing for schema-directed reading to upgrade in practice. See schema-directed deserialization for where it does matter.

For each fixture it prints valid: True/False and, on success, the document as OML. All three fixtures currently validate. Each fixture's OML is also committed alongside it as <name>.oml — generated by this same write_oml call, not hand-written — so you can read the mapping without running anything: omnist.oml, spam-eggs.oml, invented-dynamic-version.oml. tests/test_examples_pyproject.py recomputes each and asserts an exact match, so these can't silently drift from what convert.py actually produces.

The fixtures, and why each one exists

File OML Arrays OML What it is What it exercises
omnist.toml .oml .arrays.oml This repo's own pyproject.toml, copied verbatim The legacy license = {text=...} table form; two real [tool.*] sections ([tool.setuptools], [tool.ruff])
spam-eggs.toml .oml .arrays.oml The official "full example" from the Python Packaging User Guide, verbatim Partial author entries (name-only, email-only); current SPDX-string license; two optional-dependencies extras; a quoted-label urls key; scripts, gui-scripts, and a custom entry-points group
invented-dynamic-version.toml .oml .arrays.oml Synthetic, written for this example — not sourced from any real project dynamic = ["version"], the one field the other two fixtures don't touch

The .arrays.oml siblings are write_oml(node, arrays=True) — the same Document, with keywords/classifiers/dependencies (and similar repeated fields) collapsed into [...] array syntax instead of repeated edges. See OML arrays.

No fixture was hand-edited to force a pass. External projects' own files weren't used as fixtures at all, to avoid any copyright ambiguity around redistributing a third party's file verbatim — everything here is either first-party, an official PSF teaching example explicitly meant to be copied, or synthetic.

What OSD can model exactly

Of Project's 20 fields, 13 are fully closed and checked: a wrong type, an extra label, or a missing required field fails validation with an exact path. That includes authors/maintainers as a Ref to a proper Author record (not any): the fields are checked, the record is closed, the array cardinality ([0,]) is enforced.

What it can't, and why — three structural gaps

1. Unions. readme can be a plain string (a path) or a table ({file=..., text=..., content-type=...}). Pre-PEP 639, license had the same shape: a string or a {text=...}/{file=...} table — omnist.toml itself still uses the table form today. OSD's own model is explicit that a field's type is "always exactly one" scalar or Ref, never a union (docs/schema.md). There's no way to say "string or this record." Both fields are typed any here, which means: present, but its shape is completely unchecked. A license table with a typo'd key ({txt = "MIT"}) would validate as cleanly as a correct one.

2. Open key sets. urls, scripts, gui-scripts, optional-dependencies, and entry-points are all label -> value maps where the label set is chosen by whoever writes the file — an extra name, a URL label, a script name. OSD records are closed by design: "an unexpected label is a validation error, not silently ignored" (docs/schema.md). There's no map type. [tool] is the purest case: its entire purpose, per PEP 518 §3, is giving every tool its own unbounded namespace — any isn't a workaround here, it's the only honest way to model something deliberately open-ended.

3. Cross-field constraints. Three real ones in this one file: an Author needs at least one of name/email; version is required unless "version" appears in dynamic; a key can't be listed in dynamic and given a static value at the same time. OSD's cardinality is per-field only — there's no "at least one of A, B" or "A implies not B" across sibling fields.

The honest number: 7 of 20 Project fields are any (readme, license, optional-dependencies, urls, scripts, gui-scripts, entry-points), plus [tool] entirely. Roughly a third of what a pyproject.toml actually contains, field-by-field, gets presence-and-cardinality checking only, not shape checking.

Why any doesn't close every gap: false-rejects, not just false-accepts

Everything above is about false-accepts — a malformed value slipping through an any field unchecked. There's a separate failure direction, false-rejects: a genuinely valid pyproject.toml that OSD wrongly refuses. any widens a known field's value type; it does nothing for a record's label set. import-names/import-namespaces (PEP 794, Accepted, added October 2025) are a real example: until they were added to this schema, any file using them would have been rejected as an "unexpected label," even though every other field validated cleanly — because OSD records are closed by design (docs/schema.md: "an unexpected label is a validation error, not silently ignored"), and that closure applies regardless of what type any other field carries.

This isn't an oversight in OSD — it's the same tradeoff any already makes, just refused at the record level too. docs/schema.md states it directly: "Open keys remain refused (no map type, no open records) — that would open the label alphabet the whole algebra reasons over." compatible_with/equivalent/normalize need a closed, finite label set per record to be well-defined; an "open record" (declared labels plus an arbitrary catch-all) would break that closure the same way any already makes those operations vacuous wherever it's used. The schema fix here was mechanical (declare the two fields) — but the general version of this problem, any future field the schema author didn't anticipate, has no fix short of adding it, because OSD has deliberately never grown a catch-all/open-record concept.

A second, milder case: build-system.requires's minimum length. The spec never states whether requires = [] is legal — genuinely ambiguous, not a modeling limitation at all. Resolved permissively here ([0,]), on the same principle as below: for a pre-flight gate, a false-reject blocks legitimate work outright, while a false-accept just gets caught by the real backend downstream — so when a constraint is unenforceable or unclear, leaning permissive is usually the right default.

Case What's missing Failure direction Fixed by any?
import-names/import-namespaces Open record (label-set) False-reject No — different axis than value typing
requires = [] Nothing — spec is silent, not an OSD limit Would-be false-reject N/A, resolved by cardinality choice
readme/license union Union type Either, depending on resolution Shifts risk, doesn't remove it
dynamicversion absent Cross-field constraint False-accept (given the permissive choice made here) No

The manifest use case: validate before the expensive tooling runs

The practical pitch for a schema like this: run it as a cheap, dependency-free pre-flight gate, before any Python-specific tooling — pip, build, hatchling, a linter, CI — ever parses the file for real. A misspelled key (authohrs), a dependencies table where an array was required, or a [project] section with an unrecognized field fails fast, at an exact path, before the real build backend gets involved with its own (often less specific) error messages.

The limit sits right next to the pitch, not hidden in a table further down: because 7 fields degrade to any, this gate cannot catch a malformed license table, an entry-points group pointing at a nonexistent object, or a urls value that isn't actually a string — the exact cases a human is most likely to typo. It's a fast structural smoke test, not a replacement for the tools that actually consume the file.

OSD vs. hand-written JSON Schema, on the same three gaps

TOML itself has no native schema language — tools that validate TOML (taplo, tombi, the "Even Better TOML" VS Code extension) convert it to a JSON-equivalent tree and run JSON Schema against that. So the real comparison is OSD vs. JSON Schema, not OSD vs. some TOML-native competitor.

JSON Schema can express two of the three gaps above: oneOf for the readme/license union, additionalProperties (typed, or true) for the open maps. What it can't express any better than OSD: the name-or-email/dynamic-implies-version cross-field rules still need anyOf/dependentRequired gymnastics that are easy to get subtly wrong. In exchange, OSD gets closed-by-default and per-field min/max cardinality for free — in JSON Schema those require explicit additionalProperties: false plus minItems/maxItems on every array, easy to forget on any given field. Neither is strictly more expressive; they trade which mistakes are easy to make.

See also

This is one of four real-world worked examples — see the overview for the full comparison table, the four gap categories found across all of them, and the consolidated "designing a format for OSD" lessons list. Also: the any type, the openness design record, and the order/address/line-item walkthrough in example.md for the other, fully-closed worked example.