[FEATURE](pyspark) PySpark validation generated from the Pydantic schema#569
Open
Seth Fitzsimmons (sethfitz) wants to merge 8 commits into
Open
[FEATURE](pyspark) PySpark validation generated from the Pydantic schema#569Seth Fitzsimmons (sethfitz) wants to merge 8 commits into
Seth Fitzsimmons (sethfitz) wants to merge 8 commits into
Conversation
pytest-testmon tracks which tests cover which source files and skips unaffected tests on subsequent runs. Activated via a TESTMON Makefile variable so the default `make check` uses incremental selection while `make check TESTMON=` runs the full suite. Lock the dependency in the dev group, gitignore the local cache file, and thread $(TESTMON) through the test, test-all, and test-only targets. Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
Pull the shared `dimension` and `comparison` fields of the five vehicle selector subtypes into a `VehicleSelectorBase` parent, and thread `discriminator="dimension"` through the `VehicleSelector` annotated union. The discriminator turns the union into a Pydantic discriminated union, so it serializes as JSON Schema's `oneOf` + `discriminator` rather than `anyOf`. Regenerated segment_baseline_schema.json captures the new shape. This is a prerequisite for downstream tooling that walks discriminated unions structurally (e.g. PySpark codegen for segment's nested vehicle scoping). Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
Replace the Tonga-based Division/DivisionArea/DivisionBoundary fixtures with Kauaʻi County samples that exercise admin_level, capital_division_ids, wikidata, and source license alongside the existing fields. Replace the Tonga-based Connector/Segment fixtures with a Vermooten Street junction in Pretoria that exercises access_restrictions with when.vehicle, speed_limits with when.heading, routes with ref, road_surface, and multi-source attribution. Reformat the TOML with 4-space indents and sorted keys to match sibling theme packages. Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
overture-schema-codegen and overture-schema-common shipped with no requires-python at all, so their published metadata advertised support for every Python version. pip would resolve and install them on 3.9 and below, where they fail at import: both packages depend on overture-schema-system, which declares >=3.10 and uses 3.10-only syntax. The floor existed in practice and went unstated in the one place installers read. Both now declare >=3.10, matching the root workspace and the other eleven packages. Placement follows each file's local key ordering -- last in the alphabetized codegen table, between description and license in common, where its sibling overture-schema-system puts it. uv.lock is unchanged: >=3.10 is what uv already resolved against. Raising the floor to 3.11 is deliberately not part of this change; it waits on Python 3.10's end of life (2026-10-31). Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
Add overture-schema-pyspark, a runtime PySpark validation package whose
per-model expression modules and conformance tests are generated from the
same Pydantic models that define the schema, plus an `overture-validate`
CLI for validating Parquet on disk or in S3. PySpark plugs in as a peer of
the Markdown output target: same ModelSpec extraction, same four-layer
architecture (Discovery -> Extraction -> Output Layout -> Rendering), a new
pipeline module.
Runtime (overture-schema-pyspark/src/overture/schema/pyspark/):
- validate.py -- public API: validate_model(), evaluate_checks(),
explain_errors(). The explain stage unpivots per-row check results into
one row per violation, preserving input columns for join-back.
- schema_check.py -- compares a Spark schema against the expected
StructType, reporting structural mismatches.
- check.py -- Check / ModelValidation dataclasses.
- cli.py -- `overture-validate FEATURE_TYPE PATH` runs the pipeline in a
single pass, keeping memory bounded for arbitrarily large inputs. Output
is one row per violation: feature ID, theme/type, field, check, message,
offending value.
- expressions/ -- shared building blocks (constraint_expressions,
column_patterns, _schema_structs). Per-model expression modules are
generated under expressions/generated/.
- _registry.py -- walks the generated tree at import time, exposing
REGISTRY and PARTITION_MAP keyed by each module's ENTRY_POINT.
Codegen output target (overture-schema-codegen/src/.../pyspark/):
ModelSpec
|
constraint_dispatch constraints -> ExpressionDescriptor /
ModelConstraintDescriptor
|
check_builder FieldShape tree -> Check / ModelCheck IR
(FieldPath = ScalarPath | ArrayPath | MapPath and
Guard sum types; resolves array nesting, map
key/value projection, variant gating)
schema_builder FieldShape tree -> SchemaField list (StructType)
test_data/ ModelSpec -> BASE_ROW_SPARSE / BASE_ROW_POPULATED,
scaffolds, invalid values
|
renderer Check IR -> per-model expression module
test_renderer Check IR -> per-model conformance test module
|
pipeline orchestrates, returns GeneratedModule list
Every constraint Pydantic enforces is dispatched to a PySpark expression:
field constraints (bounds, typed length split by attachment layer, stripped,
pattern, unique items, geometry type, JSON pointer), map key/value
constraints (projecting map_keys / map_values and descending into sub-model
values), NewType overrides (LinearlyReferencedRange), base-type overrides
(HttpUrl, EmailStr, BBox completeness -- matching Pydantic), and model
constraints (require_any_of, radio_group, require_if, forbid_if,
min_fields_set, require_any_true).
Validation degrades gracefully when columns are skipped or structurally
absent: every check declares the top-level columns it reads, and a check
over a missing column -- top-level, nested inside an array or map
(sources[].confidence resolves to sources), or model-level -- is dropped
before Spark planning rather than raising a raw AnalysisException.
ValidationResult.absent_columns surfaces what was dropped; the CLI backstop
classifies residual planning errors through the structured PySpark error
API, so a generator bug propagates as a traceback instead of being mistaken
for a missing column.
Discriminated unions (segment is the canonical hard case) split into
per-arm test files, and colliding (field, check) identities across arms are
disambiguated with symmetric suffixes computed over the unfiltered check
list, so per-arm test filtering cannot hide a collision the shared
expression module still carries.
The generated trees under expressions/generated/ and tests/generated/ are
regenerable output of `make generate-pyspark` and are not tracked in git;
`make check` and `make test-all` regenerate before running.
Tested against Spark 3.4.0 - 4.1.1; the lowest-direct CI cell verifies the
declared PySpark floor.
Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
PySpark 3.4 (the declared floor) doesn't run on Java 21, the default JDK on ubuntu-latest runners -- it hits NoSuchMethodException on java.nio.DirectByteBuffer.<init>(long, int), removed in JDK 21. Pin the lowest-direct cell to Java 17 so the resolved pyspark==3.4.0 can actually start. The default cell (which resolves to a current pyspark 4.x) keeps the runner's default Java 21. Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
Seth Fitzsimmons (sethfitz)
requested review from
a team and
John McCall (lowlydba)
as code owners
July 17, 2026 19:23
Seth Fitzsimmons (sethfitz)
temporarily deployed
to
staging
July 17, 2026 19:24 — with
GitHub Actions
Inactive
🗺️ Schema reference docs preview is live!
Note ♻️ This preview updates automatically with each push to this PR. |
…cation The absent-column CLI backstop used pyspark-4-only error APIs, failing the lowest-direct CI cell (pyspark==3.4.0): - absent_column called AnalysisException.getCondition(), added in 4.0 (renaming getErrorClass, which 4.0 deprecates via FutureWarning). Prefer getCondition() where present, fall back to getErrorClass() on 3.4. - test_cli built AnalysisExceptions with the 4.x camelCase errorClass / messageParameters kwargs. 3.4 uses snake_case, forbids a message alongside an error class, and cannot build an UNRESOLVED_COLUMN through the public constructor at all (its message template is JVM-side). Fake the two accessors absent_column reads instead. Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
The version-baselining job maps each workspace package to a topological level; the new overture-schema-pyspark package was absent, so `compare` raised "Unknown package for level computation". Add it alongside the other top-level consumers (cli, codegen, annex). Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a new runtime package (
overture-schema-pyspark) plus a new output target inoverture-schema-codegenthat emits PySpark validation expressions and conformance tests from the same Pydantic models that define the schema. Ships anoverture-validateCLI (to be merged with theoverture-schemaCLI later; that will be an opportunity to improve its ergonomics) for running the validation against Parquet on disk or in S3.PySpark plugs in as a peer of the existing Markdown output target: same
ModelSpecextraction, same four-layer architecture (Discovery -> Extraction -> Output Layout -> Rendering), new pipeline module. The codegen abstraction is model-general (ModelSpec = RecordSpec | UnionSpec: one record that compiles to a SparkStructType, or a tagged union of records); in this schema every model is an Overture feature, so the generated modules are organized by theme/feature. Seepackages/overture-schema-codegen/docs/design.mdfor the full picture; the "PySpark Pipeline" section there covers the new stages in detail.What's in the PR
packages/overture-schema-pyspark/-- runtime. Public API invalidate.py(validate_model,explain_errors), schema comparison inschema_check.py, dataclasses incheck.py, theoverture-validateCLI incli.py, and shared expression building blocks inexpressions/{constraint_expressions,column_patterns,_schema_structs}.py. The per-feature expression modules underexpressions/generated/overture/schema/<theme>/<feature>.pyand per-feature conformance tests undertests/generated/overture/schema/<theme>/test_<feature>.pyare emitted by codegen into a gitignoredgenerated/boundary thatmake generate-pysparkwipes and recreates -- they are not committed to the repository._registry.pywalks that tree at import time and exposesREGISTRY: dict[str, ModelValidation]keyed by each module'sENTRY_POINTvalue (e.g."overture.schema.transportation:Segment"), reading each module's emittedMODEL_VALIDATIONconstant, plus a parallelPARTITION_MAPderived from each module'sPARTITIONSdict for partitioned features.Validation degrades gracefully when columns are skipped (
--skip-columns/--suppress) or structurally absent from the data. Every check declares the top-level columns it reads, and a check over a missing column -- top-level, nested inside an array or map (sources[].confidenceresolves tosources), or model-level -- is dropped before Spark planning rather than raising a rawAnalysisException.ValidationResult.absent_columnssurfaces the columns dropped for this reason, and the CLI backstop classifies any residual planning error through the structured PySpark error API so a generator bug propagates as a traceback instead of being mistaken for a missing column.This has been tested to work with Spark versions 3.4.0 - 4.1.1 (and the
lowest-directCI check will verify that it continues to work with the lowest declared PySpark version, which is currently3.4).Adam Lastowka (@Rachmanin0xFF) the public API changed since the previous version you tested (the entry point is now
validate_model, returning aValidationResult, in an attempt to simplify how it gets integrated with the CDP). If you point me to its current state, I can propose an update that uses the new API.packages/overture-schema-codegen/src/overture/schema/codegen/pyspark/-- new output target. Pipeline stages:make generate-pysparkwipes bothgenerated/trees and recreates them;make check(andmake test-all) regenerate them and run the generated conformance tests. The trees are not tracked in git.What's covered
Every constraint Pydantic enforces today is dispatched to a PySpark expression, which allows Spark to push down predicates and take advantage of Catalyst to avoid unnecessary deserialization:
Ge/Gt/Le/Lt/Interval,ArrayMinLen/ArrayMaxLen/ScalarMinLen/ScalarMaxLen(typed length constraints split by attachment layer),StrippedConstraint,PatternConstraint,UniqueItemsConstraint,GeometryTypeConstraint,JsonPointerConstraint.names.common, adict[LanguageTag, StrippedString]present on most named features) have their per-key and per-value constraints validated by projectingmap_keys/map_valuesand applying the same field checks. A map value that is a sub-model is descended into, validating its field and model-level constraints.dict[K, Any]maps (e.g.Infrastructure.source_tags) carry no constraint and emit no checks; container-valued map values (a list or map inside a map value) are not yet supported and raise at generation time rather than emit an unanchored check.LinearlyReferencedRange(length / bounds / order).HttpUrl(format + length),EmailStr,BBox(completeness -- at parity with Pydantic; coordinate ordering and range validation is deferred to Define validation policy for antimeridian-crossing bboxes and geometries #531).RequireAnyOfConstraint,RadioGroupConstraint,RequireIfConstraint,ForbidIfConstraint,MinFieldsSetConstraint.MinFieldsSetConstraintmirrors Pydantic'smodel_fields_setsemantics: required fields are always set (the constructor enforces them) and count toward the threshold alongside any explicitly-set optional fields.NoExtraFieldsConstraintis intentionally skipped, as its behavior is implicit when using DataFrames (extra top-level columns are caught by the schema comparison before per-row validation).Known semantic gaps / differences
Divergences from Pydantic:
UniqueItemsConstraintuses Spark'sarray_distinct, which compares whole elements with structural equality on raw stored values. Pydantic compares normalized Python objects -- e.g.,list[HttpUrl]is compared after URL normalization (such as ensuring that URLs likehttp://example.comcontain a trailing/:http://example.com/). The PySpark check catches exact duplicates, not duplicates after normalization.require_any_ofchecksisNotNullas a proxy for Pydantic'smodel_fields_set. Parquet has no equivalent of "explicitly provided" because of the tabular nature of DataFrames;isNotNullis stricter (it rejects fields explicitly set to null). This matches the current Pydantic behavior, which also rejects fields explicitly set toNone.PatternConstraintis evaluated with Java's regex dialect rather than Python's -- the two agree for the schema's patterns, but ASCII-vs-Unicode shorthand classes (\w,\d) and interior-newline handling differ in general.A general code-generation gap is tracked separately in #568: collection types
set, variable-lengthtuple[T, ...], andSequenceare mis-handled by the extractor for both the Markdown and PySpark targets. No Overture model uses these types today.CLI
Output is one row per violation: feature ID, theme/type, failing field, check name, message, offending value.
Testing
overture-schema-codegengenerates conformance tests alongside the PySpark expressions as a sanity-check by evaluating expected-good and expected-bad rows within a single DataFrame (setup/teardown overhead for DataFrame-per-test-case was much too high). Each feature gets two baseline rows --BASE_ROW_SPARSE(required fields only) andBASE_ROW_POPULATED(every optional field filled in) -- and every scenario is exercised against both, so checks that depend on optional structure being present are covered as well as the minimal-row case.make checkgenerates tests and runs the full suite over everything, which use PySpark and increase test runtime (they run in just over a minute).testmonwas introduced to improve the developer experience by skipping tests that weren't affected by recent edits.Notes for review
expressions/generated/andtests/generated/trees are produced bymake generate-pysparkand gitignored;make checkandmake test-allregenerate them before running. The review surface is the codegen and runtime:pyspark/{constraint_dispatch,check_builder,check_ir,schema_builder,renderer,test_renderer,pipeline}.py, the path IR inoverture-schema-system'sfield_path.py, and the runtime inoverture-schema-pyspark/src/overture/schema/pyspark/.VehicleSelectorBaseextraction, an example-data refresh), arequires-pythonpackaging fix, the main pyspark feature commit, and the Java 17 CI pin. Build/CI hardening (unconditionaltest-all, the check job triggering on the Makefile/workflow themselves,typing-extensionsdeclared) rides along in the feature commit.Closes #517.