overture-schema-codegen 0.1.1.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. overture/schema/codegen/__init__.py +1 -0
  2. overture/schema/codegen/cli.py +228 -0
  3. overture/schema/codegen/extraction/__init__.py +0 -0
  4. overture/schema/codegen/extraction/docstring.py +46 -0
  5. overture/schema/codegen/extraction/enum_extraction.py +40 -0
  6. overture/schema/codegen/extraction/examples.py +367 -0
  7. overture/schema/codegen/extraction/field.py +172 -0
  8. overture/schema/codegen/extraction/field_constraints.py +185 -0
  9. overture/schema/codegen/extraction/field_walk.py +275 -0
  10. overture/schema/codegen/extraction/length_constraints.py +49 -0
  11. overture/schema/codegen/extraction/literal_alternatives.py +26 -0
  12. overture/schema/codegen/extraction/model_constraints.py +252 -0
  13. overture/schema/codegen/extraction/model_extraction.py +240 -0
  14. overture/schema/codegen/extraction/newtype_extraction.py +73 -0
  15. overture/schema/codegen/extraction/numeric_extraction.py +74 -0
  16. overture/schema/codegen/extraction/pydantic_extraction.py +33 -0
  17. overture/schema/codegen/extraction/specs.py +295 -0
  18. overture/schema/codegen/extraction/type_analyzer.py +693 -0
  19. overture/schema/codegen/extraction/type_registry.py +137 -0
  20. overture/schema/codegen/extraction/union_extraction.py +270 -0
  21. overture/schema/codegen/layout/__init__.py +0 -0
  22. overture/schema/codegen/layout/module_layout.py +139 -0
  23. overture/schema/codegen/layout/type_collection.py +122 -0
  24. overture/schema/codegen/markdown/__init__.py +0 -0
  25. overture/schema/codegen/markdown/link_computation.py +70 -0
  26. overture/schema/codegen/markdown/path_assignment.py +114 -0
  27. overture/schema/codegen/markdown/pipeline.py +198 -0
  28. overture/schema/codegen/markdown/renderer.py +641 -0
  29. overture/schema/codegen/markdown/reverse_references.py +169 -0
  30. overture/schema/codegen/markdown/templates/_used_by.md.jinja2 +10 -0
  31. overture/schema/codegen/markdown/templates/enum.md.jinja2 +13 -0
  32. overture/schema/codegen/markdown/templates/feature.md.jinja2 +45 -0
  33. overture/schema/codegen/markdown/templates/geometric.md.jinja2 +11 -0
  34. overture/schema/codegen/markdown/templates/newtype.md.jinja2 +17 -0
  35. overture/schema/codegen/markdown/templates/numeric.md.jinja2 +27 -0
  36. overture/schema/codegen/markdown/templates/pydantic_type.md.jinja2 +8 -0
  37. overture/schema/codegen/markdown/type_format.py +383 -0
  38. overture/schema/codegen/py.typed +0 -0
  39. overture/schema/codegen/pyspark/__init__.py +1 -0
  40. overture/schema/codegen/pyspark/_primitive_fill.py +23 -0
  41. overture/schema/codegen/pyspark/_render_common.py +477 -0
  42. overture/schema/codegen/pyspark/check_builder.py +961 -0
  43. overture/schema/codegen/pyspark/check_ir.py +223 -0
  44. overture/schema/codegen/pyspark/constraint_dispatch.py +753 -0
  45. overture/schema/codegen/pyspark/pipeline.py +220 -0
  46. overture/schema/codegen/pyspark/renderer.py +816 -0
  47. overture/schema/codegen/pyspark/schema_builder.py +187 -0
  48. overture/schema/codegen/pyspark/templates/_check_function.py.jinja2 +10 -0
  49. overture/schema/codegen/pyspark/templates/model_module.py.jinja2 +83 -0
  50. overture/schema/codegen/pyspark/templates/test_module.py.jinja2 +129 -0
  51. overture/schema/codegen/pyspark/test_data/__init__.py +9 -0
  52. overture/schema/codegen/pyspark/test_data/base_row.py +835 -0
  53. overture/schema/codegen/pyspark/test_data/constraint_values.py +203 -0
  54. overture/schema/codegen/pyspark/test_data/invalid_value.py +105 -0
  55. overture/schema/codegen/pyspark/test_data/scaffold.py +390 -0
  56. overture/schema/codegen/pyspark/test_renderer.py +708 -0
  57. overture/schema/codegen/spec_discovery.py +66 -0
  58. overture_schema_codegen-0.1.1.dev0.dist-info/METADATA +13 -0
  59. overture_schema_codegen-0.1.1.dev0.dist-info/RECORD +61 -0
  60. overture_schema_codegen-0.1.1.dev0.dist-info/WHEEL +4 -0
  61. overture_schema_codegen-0.1.1.dev0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,187 @@
1
+ """Build StructType schema source from ModelSpec field trees."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from ..extraction.field import (
8
+ AnyScalar,
9
+ ArrayOf,
10
+ FieldShape,
11
+ LiteralScalar,
12
+ MapOf,
13
+ ModelRef,
14
+ NewTypeShape,
15
+ Primitive,
16
+ Scalar,
17
+ UnionRef,
18
+ )
19
+ from ..extraction.field_walk import enum_source
20
+ from ..extraction.specs import FieldSpec, ModelSpec, UnionSpec
21
+ from ..extraction.type_registry import get_type_mapping
22
+
23
+ __all__ = [
24
+ "SHARED_TYPE_REFS",
25
+ "SchemaField",
26
+ "build_schema",
27
+ ]
28
+
29
+ # Types whose base_type name maps to a _schema_structs.py StructType constant.
30
+ # Reserved for types the codegen cannot walk (BBox is a plain class, not a
31
+ # Pydantic BaseModel). Pydantic BaseModels are inlined.
32
+ SHARED_TYPE_REFS: dict[str, str] = {
33
+ "BBox": "BBOX_STRUCT",
34
+ }
35
+
36
+ # Literal and Enum fields both serialize as strings in Parquet.
37
+ _STRING_FALLBACK = "StringType()"
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class SchemaField:
42
+ """One field in the generated StructType.
43
+
44
+ Parameters
45
+ ----------
46
+ name
47
+ Column name.
48
+ type_expr
49
+ Spark type expression string (e.g. `"StringType()"`) or
50
+ a `_schema_structs.py` constant name.
51
+ """
52
+
53
+ name: str
54
+ type_expr: str
55
+
56
+
57
+ def _spark_for_base(base_type: str, source_type: type | None) -> str:
58
+ """Return a Spark type expression for a primitive base type.
59
+
60
+ Tries `base_type` first, then falls back to `source_type.__name__`.
61
+ Returns `StringType()` when neither maps to a known Spark type.
62
+ """
63
+ mapping = get_type_mapping(base_type)
64
+ if mapping is not None and mapping.spark is not None:
65
+ return mapping.spark
66
+ if source_type is not None:
67
+ fallback = get_type_mapping(source_type.__name__)
68
+ if fallback is not None and fallback.spark is not None:
69
+ return fallback.spark
70
+ return _STRING_FALLBACK
71
+
72
+
73
+ def _spark_for_scalar(scalar: Scalar) -> str:
74
+ """Map a `Scalar` variant to a Spark type expression.
75
+
76
+ `LiteralScalar` and `AnyScalar` serialize as strings. `Primitive`
77
+ scalars look up the type registry; enum primitives and BBox short-
78
+ circuit to strings / shared constants before the registry.
79
+ """
80
+ if isinstance(scalar, (LiteralScalar, AnyScalar)):
81
+ return _STRING_FALLBACK
82
+ if scalar.base_type in SHARED_TYPE_REFS:
83
+ return SHARED_TYPE_REFS[scalar.base_type]
84
+ if enum_source(scalar) is not None:
85
+ return _STRING_FALLBACK
86
+ return _spark_for_base(scalar.base_type, scalar.source_type)
87
+
88
+
89
+ def _deduplicate_by_name(fields: list[FieldSpec]) -> list[FieldSpec]:
90
+ """Keep one FieldSpec per name, requiring every arm to agree on Spark type.
91
+
92
+ Union annotated_fields may contain the same field name declared by
93
+ multiple arms with different `FieldSpec`s -- a `Literal` discriminator
94
+ whose value differs per arm, or a field whose per-arm constraints
95
+ diverge (see `union_extraction.extract_union`). A columnar sink stores
96
+ one type per column name, so the schema needs exactly one entry. Two
97
+ same-named fields are compatible when they resolve to the SAME Spark
98
+ type -- the first-seen `FieldSpec`'s shape is kept (arbitrarily; the
99
+ column type is identical either way). Two same-named fields that resolve
100
+ to DIFFERENT Spark types cannot share one generated column, so this
101
+ always raises, whether the mismatch is numeric (a narrower int type vs a
102
+ float) or not.
103
+
104
+ Widening the two to their common type would often work in practice --
105
+ Spark and Parquet can promote a narrower numeric column to a wider one
106
+ (reading an int where the schema declares a double, say). It is forbidden
107
+ anyway: a widened column makes the union's type an implicit property
108
+ inferred from whichever arms happen to disagree, rather than a decision
109
+ stated in the model. Raising forces that decision to the surface at
110
+ generation instead of leaving it as a silent compatibility trap.
111
+ """
112
+ seen: dict[str, FieldSpec] = {}
113
+ for f in fields:
114
+ existing = seen.get(f.name)
115
+ if existing is None:
116
+ seen[f.name] = f
117
+ continue
118
+ spark_f, spark_existing = (
119
+ _shape_to_spark(f.shape),
120
+ _shape_to_spark(existing.shape),
121
+ )
122
+ if spark_f != spark_existing:
123
+ raise ValueError(
124
+ f"Union field {f.name!r} resolves to incompatible Spark "
125
+ f"types across arms ({spark_existing} vs {spark_f}); a "
126
+ "single Parquet column cannot represent both."
127
+ )
128
+ return list(seen.values())
129
+
130
+
131
+ def _struct_type_expr(fields: list[FieldSpec]) -> str:
132
+ """Build an inline `StructType([...])` expression from a list of fields."""
133
+ if not fields:
134
+ raise ValueError(
135
+ "Cannot build a StructType for a model with no fields; an empty "
136
+ "struct column cannot carry data and signals an upstream "
137
+ "extraction problem."
138
+ )
139
+ parts = [
140
+ f'StructField("{f.name}", {_shape_to_spark(f.shape)}, True)' for f in fields
141
+ ]
142
+ return f"StructType([{', '.join(parts)}])"
143
+
144
+
145
+ def _shape_to_spark(shape: FieldShape) -> str:
146
+ """Convert a FieldShape to a Spark type expression string."""
147
+ match shape:
148
+ case ArrayOf(element=element):
149
+ return f"ArrayType({_shape_to_spark(element)}, True)"
150
+ case NewTypeShape(inner=inner):
151
+ return _shape_to_spark(inner)
152
+ case ModelRef(model=m):
153
+ return _struct_type_expr(m.fields)
154
+ case UnionRef(union=u):
155
+ return _struct_type_expr(_deduplicate_by_name(u.fields))
156
+ case MapOf(key=k, value=v):
157
+ return f"MapType({_shape_to_spark(k)}, {_shape_to_spark(v)}, True)"
158
+ case Primitive() | LiteralScalar() | AnyScalar() as s:
159
+ return _spark_for_scalar(s)
160
+ raise TypeError(f"Unhandled FieldShape: {shape!r}")
161
+
162
+
163
+ def build_schema(spec: ModelSpec) -> list[SchemaField]:
164
+ """Build schema fields for a feature spec.
165
+
166
+ Walks the field tree and maps types to Spark type expressions.
167
+ Recognizes shared types and emits fields in model order.
168
+
169
+ Parameters
170
+ ----------
171
+ spec
172
+ The feature spec to build schema fields for.
173
+
174
+ Returns
175
+ -------
176
+ list[SchemaField]
177
+ One entry per schema column in model order.
178
+ """
179
+ source_fields = (
180
+ _deduplicate_by_name(spec.fields)
181
+ if isinstance(spec, UnionSpec)
182
+ else spec.fields
183
+ )
184
+ return [
185
+ SchemaField(name=f.name, type_expr=_shape_to_spark(f.shape))
186
+ for f in source_fields
187
+ ]
@@ -0,0 +1,10 @@
1
+ {%- macro check_function(c) -%}
2
+ def {{ c.func_name }}() -> Check:
3
+ return Check(
4
+ field={{ c.field | py_literal }},
5
+ name={{ c.check_name | py_literal }},
6
+ expr={{ c.expr }},
7
+ shape=CheckShape.{{ c.shape }},
8
+ read_columns={{ c.read_columns | py_literal }},
9
+ )
10
+ {% endmacro %}
@@ -0,0 +1,83 @@
1
+ {% from '_check_function.py.jinja2' import check_function -%}
2
+ # This file is auto-generated by overture-schema-codegen. Do not edit.
3
+
4
+ """{{ model_title }} validation expression builders."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from pyspark.sql import functions as F
9
+ {% if spark_types %}
10
+ from pyspark.sql.types import (
11
+ {% for t in spark_types %}
12
+ {{ t }},
13
+ {% endfor %}
14
+ )
15
+ {% endif %}
16
+ {% if geometry_type %}
17
+ from overture.schema.system.geometric import GeometryType
18
+
19
+ {% endif %}
20
+ from overture.schema.pyspark.check import Check, CheckShape, ModelValidation
21
+ {% if schema_struct_refs %}
22
+ from overture.schema.pyspark.expressions._schema_structs import (
23
+ {% for r in schema_struct_refs %}
24
+ {{ r }},
25
+ {% endfor %}
26
+ )
27
+ {% endif %}
28
+ {% if column_pattern_fns %}
29
+ from overture.schema.pyspark.expressions.column_patterns import (
30
+ {% for f in column_pattern_fns %}
31
+ {{ f }},
32
+ {% endfor %}
33
+ )
34
+ {% endif %}
35
+ {% if constraint_expr_fns %}
36
+ from overture.schema.pyspark.expressions.constraint_expressions import (
37
+ {% for f in constraint_expr_fns %}
38
+ {{ f }},
39
+ {% endfor %}
40
+ )
41
+ {% endif %}
42
+
43
+
44
+ {% for c in check_functions %}
45
+ {{ check_function(c) }}
46
+ {% endfor %}
47
+
48
+ def {{ model_name }}_checks() -> list[Check]:
49
+ """All validation checks for {{ model_name }}."""
50
+ {% if check_functions %}
51
+ return [
52
+ {% for c in check_functions %}
53
+ {{ c.func_name }}(),
54
+ {% endfor %}
55
+ ]
56
+ {% else %}
57
+ return []
58
+ {% endif %}
59
+
60
+
61
+ {{ schema_const_name }} = StructType(
62
+ [
63
+ {%- for sf in schema_fields %}
64
+ StructField("{{ sf.name }}", {{ sf.type_expr }}, True),
65
+ {%- endfor %}
66
+ ]
67
+ )
68
+ {% if geometry_types_literal %}
69
+
70
+ GEOMETRY_TYPES: tuple[GeometryType, ...] = {{ geometry_types_literal }}
71
+ {% endif %}
72
+
73
+ ENTRY_POINT = "{{ entry_point }}"
74
+
75
+ PARTITIONS: dict[str, str] = {{ partitions | py_literal }}
76
+
77
+ MODEL_VALIDATION = ModelValidation(
78
+ schema={{ schema_const_name }},
79
+ checks={{ model_name }}_checks,
80
+ {%- if geometry_types_literal %}
81
+ geometry_types=GEOMETRY_TYPES,
82
+ {%- endif %}
83
+ )
@@ -0,0 +1,129 @@
1
+ # Auto-generated — do not edit.
2
+
3
+ """Generated conformance tests for {{ model_name }}."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import pytest
8
+ from {{ expression_import }} import (
9
+ {{ model_name | upper }}_SCHEMA,
10
+ {{ model_name }}_checks,
11
+ )
12
+ from pyspark.sql import SparkSession
13
+
14
+ from _support.harness import (
15
+ ValidationResults,
16
+ run_validation_pipeline,
17
+ )
18
+ {% if mutation_imports %}
19
+ from _support.mutations import {{ mutation_imports | join(", ") }}
20
+ {% endif %}
21
+ {% if needs_set_at_path %}
22
+ from _support.helpers import set_at_path
23
+ {% endif %}
24
+ from _support.scenarios import Scenario
25
+
26
+ BASE_ROW_SPARSE: dict = {{ base_row_sparse }}
27
+
28
+
29
+ BASE_ROW_POPULATED: dict = {{ base_row_populated }}
30
+
31
+
32
+ SCENARIOS: list[Scenario] = [
33
+ {% for entry in scenarios %}
34
+ Scenario(
35
+ {% for k, v in entry %}
36
+ {{ k }}={{ v }},
37
+ {% endfor %}
38
+ ),
39
+ {% endfor %}
40
+ ]
41
+
42
+
43
+ @pytest.fixture(scope="module")
44
+ def checks() -> list:
45
+ return {{ model_name }}_checks()
46
+
47
+
48
+ @pytest.fixture(scope="module")
49
+ def sparse_results(spark: SparkSession, checks: list) -> ValidationResults:
50
+ return run_validation_pipeline(
51
+ spark,
52
+ {{ schema_name }},
53
+ checks,
54
+ BASE_ROW_SPARSE,
55
+ SCENARIOS,
56
+ model_name="{{ model_name }}",
57
+ )
58
+
59
+
60
+ @pytest.fixture(scope="module")
61
+ def populated_results(spark: SparkSession, checks: list) -> ValidationResults:
62
+ return run_validation_pipeline(
63
+ spark,
64
+ {{ schema_name }},
65
+ checks,
66
+ BASE_ROW_POPULATED,
67
+ SCENARIOS,
68
+ model_name="{{ model_name }}",
69
+ )
70
+
71
+
72
+ def test_baseline_sparse(sparse_results: ValidationResults) -> None:
73
+ """Sparse base row passes every check the codegen produced.
74
+
75
+ Catches drift between base_row synthesis, schema_builder, and
76
+ check_builder -- if any of those produce output inconsistent with
77
+ the others (e.g. a check that rejects values the synthesizer emits
78
+ for required-only fields), the baseline fails here before any
79
+ scenario runs.
80
+ """
81
+ baseline = sparse_results.violations.get("{{ model_name }}::baseline", set())
82
+ assert baseline == set(), f"Sparse baseline has violations: {baseline}"
83
+
84
+
85
+ def test_baseline_populated(populated_results: ValidationResults) -> None:
86
+ """Fully-populated base row passes every check the codegen produced.
87
+
88
+ Mirrors `test_baseline_sparse` but with all optional fields
89
+ filled, exercising codegen paths that only fire when a value is
90
+ present.
91
+ """
92
+ baseline = populated_results.violations.get("{{ model_name }}::baseline", set())
93
+ assert baseline == set(), f"Populated baseline has violations: {baseline}"
94
+
95
+
96
+ @pytest.mark.parametrize("scenario", SCENARIOS, ids=lambda s: s.id)
97
+ def test_scenario_sparse(
98
+ scenario: Scenario,
99
+ sparse_results: ValidationResults,
100
+ ) -> None:
101
+ _assert_scenario(scenario, sparse_results)
102
+
103
+
104
+ @pytest.mark.parametrize("scenario", SCENARIOS, ids=lambda s: s.id)
105
+ def test_scenario_populated(
106
+ scenario: Scenario,
107
+ populated_results: ValidationResults,
108
+ ) -> None:
109
+ _assert_scenario(scenario, populated_results)
110
+
111
+
112
+ def _assert_scenario(
113
+ scenario: Scenario,
114
+ validation_results: ValidationResults,
115
+ ) -> None:
116
+ expected = (scenario.expected_field, scenario.expected_check)
117
+ if scenario.id in validation_results.skipped:
118
+ # An unbuildable scenario exercises nothing; fail loud rather than skip
119
+ # (a skip reads as a pass and hides codegen/scaffold gaps).
120
+ pytest.fail(
121
+ f"unbuildable scenario {scenario.id!r}: "
122
+ f"{validation_results.skipped[scenario.id]}"
123
+ )
124
+ valid_violations = validation_results.violations.get(f"{scenario.id}::valid", set())
125
+ assert expected not in valid_violations
126
+ invalid_violations = validation_results.violations.get(
127
+ f"{scenario.id}::invalid", set()
128
+ )
129
+ assert expected in invalid_violations
@@ -0,0 +1,9 @@
1
+ """Test-data generation for the rendered PySpark conformance tests.
2
+
3
+ Three modules cover three flavors of data:
4
+
5
+ - `invalid_value`: constraint-violating values for triggering each check.
6
+ - `base_row`: minimal and fully populated valid rows.
7
+ - `scaffold`: sparse path scaffolds that supply the nested intermediates
8
+ (optional structs, arrays) a check's field path requires.
9
+ """