Skip to content

Pydantic package organization episode 3: "System update complete" - #403

Merged
Seth Fitzsimmons (sethfitz) merged 19 commits into
pydanticfrom
pp3
Oct 19, 2025
Merged

Pydantic package organization episode 3: "System update complete"#403
Seth Fitzsimmons (sethfitz) merged 19 commits into
pydanticfrom
pp3

Conversation

@vcschapp

@vcschapp Victor Schappert (vcschapp) commented Oct 16, 2025

Copy link
Copy Markdown
Collaborator

tl;dr

This is part #3. Part #4 is expected early in the week of 10/20 if all goes according to plan.

At this point the system package (George) is pretty much finished, documented, and tested, and there's nothing more that can obviously move into it. It's my hope that the majority of the refactoring is done now. Part #4 will focus on organizing the core package.

Squash Message

The destination is as described in PR #397 (1/N), PR #400, and PR #403.
In this commit, the following value is added:

1. 🐞 Fix bug: The `not_required_if` mixin wasn't meeting the original
   schema intent, which result in the `DivisionBoundary` feature type
   having the wrong JSON Schema. The expected behavior is "if the
   boundary is at the country level, then the `country` pair should be
   omitted" but instead it did two variations: in the Python validator,
   it required the country pair to be present; and in the JSON Schema,
   it required the country pair to be present, but only if the condition
   was not true. Both were subtly wrong. The bug is fixed in this commit
   and the mixin is renamed to `forbid_if`, which I hope will make the
   semantics more obvious.
2. 🐞 Fix bug: Very subtle, but the mixin model constraint validators
   did not work correctly if added directly to a `Feature` model,
   because they aren't aware of the quirky multi-tiered GeoJSON JSON
   Schema where most properties get pushed down into "properties", but
   some do not. This is (hopefully ... 🤞) fixed in the new `Feature`
   model in the system package.
3. 🐞 Fix bug: Very subtle, but the previous `Feature` model's JSON
   Schema did not prohibit fields named `"id"`, `"bbox"`, and
   `"geometry"` within the properties object. This created a weird
   quirk where in a poisoned JSON object containing those values under
   properties, the properties version would overwrite the root version. 
   This is fixed by explicitly prohibiting those fields in the JSON
   Schema for the `Feature` class' properties object.
4. 🐞 Fix bug: The `ExtensibleBaseModel`'s `patternProperties` got put
   both at the top level of the GeoJSON Feature schema and in the
   "properties" object schema. This would have allowed the input JSON
   to contain `ext_*` at two levels, which doesn't meet the original
   schema intent. As part of this bug fix, `ExtensibleBaseModel` is
   collapsed directly into `OvertureFeature` and the `ext_*` feature
   is documented as being on a deprecation path.
5. The `validation` package is officially deleted as its essential
   George functions have all been re-homed either into `system` or
   `core`.
6. All model constraint mixins have been refactored along the lines
   started in PR #400. They are now called `@forbid_if`,
   `@min_fields_set`, `@no_extra_fields`, `@radio_group`,
   `@require_any_of`, and `@require_if`.
7. A type hint, `Omitable[T]` has been added to the system package.
   This type hint allows a Pydantic model to "naturally" get JSON
   Schema semantics for omitted values - it de-conflates optionality
   and nullability, which Pydantic currently conflates. In an
   upcoming episode, I hope to replace the `X | None` unions in the
   existing model with `Omitable[X]`.
8. The existing `Feature` class from the `core` package has been split
   into two: (i) `Feature` in the `system` package now acts as a
   generic feature and owns all the funky GeoJSON logic, allowing
   George enthusiasts to work with a nice ergonomic feature type without
   having to use an "Overture"-flavored feature; (ii) `OvertureFeature`
   in the `core` package adds the Overture schema specific flavoring
   on top of `Feature`.
9. The `ref` module from `core` has been pushed down into `system` and
   the dependency on `(Overture)Feature` has been factored out in favor
   of a generic dependency on an `Identified` model, which is basically
   just a `BaseModel` with a mandatory ID.

Stay tuned for episode 4, where we will re-organize the core package.

Journey

Destination

  1. Packages.
  2. Documentation.
    • ✅ All doctests will pass and automatically be enforced to pass by make check.
    • ▶️ All documentation will pass pydocstyle with the NumPy convention. (As of now, the tool reports too many complaints to add it into make check.)
    • ⬜️ pydocstyle will be enforced by make check.
    • ⬜️ docformatter will be run by make check.
    • ▶️ Package-level README content will migrate into the package's __init__.py docstring.
    • ⬜️ There will be a doc target in the Makefile that does uv run pdoc to a useful place.
  3. Modules.
    • ▶️ Re-exports will be standardized and everything of value will be re-exported into the main module.
    • ⬜️ In theme packages, the primary module will be feature, which will contain the feature models and the main simple types that support them.
    • ⬜️ In theme packages, if a field contains a theme-specific "deep model" then it will be given its own module, e.g. perspectives.

… @require_if - NEED TESTS THOUGH

build is passing but there's a unit test deficit growing around all the new classes
Comment on lines -78 to -82
"patternProperties": {
"^ext_.*$": {
"description": "Additional top-level properties must be prefixed with `ext_`."
}
},

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is one of the subtle 🐞 bugs addressed: patternProperties shouldn't exist at the top level because that would allow extra properties to be put into the root of the GeoJSON document, like this:

{
  "type": "Feature",
  "geometry": {},
  "properties": {},
  "ext_foo": "bar",
  "ext_bar": "baz"
}

The extension properties would then get lost by the model validator that imports the GeoJSON into the Pydantic model class instance.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmm, so it wasn't getting pivoted into properties correctly. Good catch.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was getting cloned into properties, but was somehow also getting replicated at the top level.

},
"properties": {
"additionalProperties": false,
"not": {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes another subtle 🐞 bug: if these properties were allowed in under "properties", then you'd be open to this scenario in a GeoJSON input:

{
  "type": "Feature",
  "id": "outer-id",
  "bbox": [0, 0, 0, 0],
  "geometry": {},
  "properties": {
    "id": "inner-id",
    "bbox": [1, 1, 1, 1],
    "geometry": "anything"
  }
}

The model validator would then copy everything from "properties" up to the root (overwriting the values in the root).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does not.required achieve this? It seems more like it would say that those fields aren't required, which was already the case. additionalProperties: false will prevent it, but is contingent on the shape of properties filtering out references to id, bbox, geometry.

@vcschapp Victor Schappert (vcschapp) Oct 20, 2025

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

required is a funny beast.

The positive version means "it must be the case that it is there" and the negative version means "it must not be the case that it is there".

So if you check out this mini-schema at https://www.jsonschemavalidator.net/...

{
  "type": "object",
  "not": { "required": ["foo" ] },
  "required": ["bar"]
}

You find that this passes: {"bar": 42}, but this fails: {"foo": "anything", "bar": 42}.


Edit: Fixed an example that wrongly ended in a bracket ] instead of a brace }.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL, wow. That's worth capturing (if it's not already) in the code that produces expressions like that.

"version"
],
"type": "object",
"unevaluatedProperties": false

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant - because "additionalProperties": false is stricter...

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean by "stricter"?

IIRC, unevaluatedProperties applies to sub-schemas (and structures introduced through allOf, etc.), which is probably what we want at the OvertureFeature level (to prevent people from accidentally making their schemas more open than intended, as is the case in a few parts of our current JSON Schema).

@vcschapp Victor Schappert (vcschapp) Oct 20, 2025

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I mean by stricter is that "additionalProperties": false will prohibit any properties from being included if they are not either explicitly listed in properties or covered by an allowed pattern in patternProperties. So the fact that it won't traverse sub-schemas introduced through aggregators allOf, anyOf, etc.) makes it stricter.

e.g. consider this JSON Schema:

{
  "type": "object",
  "additionalProperties": false,
  "properties": {
     "foo": { "type": "integer" } 
  },
  "patternProperties": {
    "bar.*": { }
  },
  "allOf": [{
    "properties": {
      "baz": { }
    }
  }]
}

The above will work for {"foo":42,"bark": "anything"} but not {"foo":42,"bark": "anything", "baz":"anything else"}.

The reason this makes sense to me in the Pydantic context is that Pydantic models always have enumerated properties in the properties section. The one exception so far is ext_*, which is covered by patternProperties. All of our other applicable constraints don't add allowed properties, they just constrain the values the existing properties are allowed to have.


Edit: Added a missing not that was super confusing.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aie, that's confusing.

Given the behavior you're showing, we might do well to avoid ever creating aggregators that introduce sub-schemas.

Comment on lines +141 to +142
@model_validator(mode="after")
def validate_model(self) -> Self:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just applying the ext_* validation and can be dropped when we drop that.

Comment on lines +159 to +162
cls,
schema: core_schema.CoreSchema,
handler: GetJsonSchemaHandler,
) -> JsonSchemaValue:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just applying the ext_* schema and can be dropped when we drop that.

Comment on lines +239 to +254
},
{
"if": {
"properties": {
"subtype": {
"const": "country"
}
}
},
"then": {
"not": {
"required": [
"country"
]
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is another bug 🐞 fix.

The old decoration was:

@not_required_if("subtype", PlaceType.COUNTRY, ["country"])

This only captured half the original constraint (if-then but not if-then-else).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not required is the default though, yes? I agree that creating explicit rules is good (particularly to override something that's been marked as required, but that feels like a modeling mistake depending on guidance for semi-required fields), but I don't see the bug.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, IIRC, that @forbid_if won't let you override something that is required. Meaning, if it is required then @forbid_if will throw.

It's limited to the case of "this already optional field is also prohibited if this other condition holds".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, IIRC, that @forbid_if won't let you override something that is required. Meaning, if it is required then @forbid_if will throw.

It's limited to the case of "this already optional field is also prohibited if this other condition holds".

Yup it inherits this behavior from OptionalFieldGroupConstraint: https://github.com/OvertureMaps/schema/pull/403/files#r2445351121.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on ☝️, my understanding is that not.required is not the default (because it actively blocks). "Not required" (or optional) is. Following from that, this means that the not.required in the annotated code means "shouldn't be present" and that's the bug fix, correct?

The raising behavior is nice. Will it raise at definition time (runtime), so it'll pop up when you try to generate JSON Schema from it? I stuck a note on @forbid_if to document the "won't let you override something that is required" behavior.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It raises at the time of applying the decorator, so basically you'll have to cure the error before you can go on to generate the JSON Schema from the model.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect!

@@ -0,0 +1,319 @@
from collections.abc import (

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metadata is a formalization and generalization of the model_constraint metadata introduced in #400 track the model constraints applied on a model for code generation support.

__validators__: dict[str, Callable[..., Any]] | None = None,
__cls_kwargs__: dict[str, Any] | None = None,
__qualname__: str | None = None,
__metadata__: Metadata | None = None,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#It's the addition of this line that makes our create_model different from Pydantic's.

@@ -0,0 +1,146 @@
from collections.abc import Callable

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm expecting one more pass may be necessary to make @forbid_if, @radio_group, @require_any_of, and @require_if aware of Omitable and behave differently if the field is omitable or nullable (nullable being a union with None).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the factored-out GeoJSON-enabled feature model.

Everything line 388 and below is in support of "on-the-fly refactoring" the JSON Schema to support model_constraint decorators to support three cases:

  1. Constraints that refer only to the top-level fields "id", "bbox". and "geometry"`.
  2. Constraints that refer only to non-top-level fields.
  3. Constraints that refer to a mix of top-level and non-top-level fields.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to approve and merge to tee you up for episode 4.

Please look through my comments though; there are some minor tweaks that deserve follow-up commits. I'm also unclear on how not.required fixes the bugs you flagged (this appears in a couple places).

I want to raise 2 pseudo-blocking discussions:

First, the use of additionalProperties vs. unevaluatedProperties (which only applies to JSON Schema validation, but has the potential to allow nested properties through if authors don't use @no_extra_fields).

The meta-issue is that I believe that all models that compose an OvertureFeature subclass should behave as if @no_extra_fields were applied, even if it's not explicit, due to the constraints that most format serializations introduce (fixed set of columns, explicit struct members). If you need a dict, use a dict. Apply patterns if you need to, but don't make it a hybrid.

This is somewhat similar to another implicit constraint where we disallow the mode where a JSON array or "map" (i.e., not modeled as a struct) can contain mixed value (or key) types. I can't remember the specifics, but I remember issues early on when we tried to translate JSON data sketches into Parquet.

Second is Omitable (beyond the potential typo). I don't think it's necessary. Outside of document-targeted modeling (i.e., JSON Schema for JSON), the distinction between omittable and nullable doesn't have meaning.

Comment on lines -78 to -82
"patternProperties": {
"^ext_.*$": {
"description": "Additional top-level properties must be prefixed with `ext_`."
}
},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmm, so it wasn't getting pivoted into properties correctly. Good catch.

"version"
],
"type": "object",
"unevaluatedProperties": false

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean by "stricter"?

IIRC, unevaluatedProperties applies to sub-schemas (and structures introduced through allOf, etc.), which is probably what we want at the OvertureFeature level (to prevent people from accidentally making their schemas more open than intended, as is the case in a few parts of our current JSON Schema).

},
"properties": {
"additionalProperties": false,
"not": {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does not.required achieve this? It seems more like it would say that those fields aren't required, which was already the case. additionalProperties: false will prevent it, but is contingent on the shape of properties filtering out references to id, bbox, geometry.

)

# FIXME: Use of `default` on this "floating" type declaration results in a Pydantic warning that the
# default has no effect. Default value should be migrated to site usage in the actual models.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or removed, since Pydantic defaults only apply to programmatic use of the models (see #354 (comment)). A default of 0 for an int field isn't weird though.

(This is present as a side-effect of the port from JSON Schema.)

json_schema = get_static_json_schema(config)

try:
prev = json_schema["minProperties"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm torn on the @min_fields_set name, but I think I'm persuaded that the presence of "set" as a way to more clearly indicate its purpose outweighs following JSON Schema terminology because assuming that schema authors have JSON Schema experience is likely incorrect (and for those that do, we can provide a mapping in the guide; I've left myself a note there).

@vcschapp Victor Schappert (vcschapp) Oct 20, 2025

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used fields in lieu of properties because it is the Pydantic term and I think we should assume Pydantic-oriented authoring.


I think "set" is a Pydantic term. It's used in the property model_fields_set.

>>> from pydantic import BaseModel
>>> class Foo(BaseModel):
...     bar: int | None = None
... 
>>> Foo().model_fields_set
set()
>>> Foo(bar=42).model_fields_set
{'bar'}
>>> 

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should assume Pydantic-oriented authoring.

I agree! (This feeds into the meta-issue about open vs. closed behavior, where we're falling into the trap of paying attention to the JSON Schema behavior over Pydantic (my bad).)

That's not the meaning of "set" that I was expecting ("make sure to set at least n fields"), but good that it matches the other meaning too!

Comment on lines +239 to +254
},
{
"if": {
"properties": {
"subtype": {
"const": "country"
}
}
},
"then": {
"not": {
"required": [
"country"
]
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not required is the default though, yes? I agree that creating explicit rules is good (particularly to override something that's been marked as required, but that feels like a modeling mistake depending on guidance for semi-required fields), but I don't see the bug.

Comment thread README.pydantic.md
- **`overture-schema-core`** - Base classes, geometry models, and common structures
shared across all themes
- **`overture-schema-validation`** - Validation system with constraints and mixins
- **`overture-schema-system`** - Foundational system of primitivef types and constraints

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- **`overture-schema-system`** - Foundational system of primitivef types and constraints
- **`overture-schema-system`** - Foundational system of primitive types and constraints

@sethfitz
Seth Fitzsimmons (sethfitz) merged commit c72e4ad into pydantic Oct 19, 2025
3 checks passed
Comment on lines +328 to +337
def validate_class(self, model_class: type[BaseModel]) -> None:
super().validate_class(model_class)

required_fields = [
f for f in self.field_names if model_class.model_fields[f].is_required()
]
if required_fields:
raise TypeError(
f"`{self.name}` expects all the fields to be optional, but at least one is required in the model class `{model_class.__name__}`: {', '.join(required_fields)}"
)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Comment for linking purposes.)

@vcschapp

Copy link
Copy Markdown
Collaborator Author

I'm going to approve and merge to tee you up for episode 4.

Please look through my comments though; there are some minor tweaks that deserve follow-up commits. I'm also unclear on how not.required fixes the bugs you flagged (this appears in a couple places).

I want to raise 2 pseudo-blocking discussions:

First, the use of additionalProperties vs. unevaluatedProperties (which only applies to JSON Schema validation, but has the potential to allow nested properties through if authors don't use @no_extra_fields).

The meta-issue is that I believe that all models that compose an OvertureFeature subclass should behave as if @no_extra_fields were applied, even if it's not explicit, due to the constraints that most format serializations introduce (fixed set of columns, explicit struct members). If you need a dict, use a dict. Apply patterns if you need to, but don't make it a hybrid.

This is somewhat similar to another implicit constraint where we disallow the mode where a JSON array or "map" (i.e., not modeled as a struct) can contain mixed value (or key) types. I can't remember the specifics, but I remember issues early on when we tried to translate JSON data sketches into Parquet.

Second is Omitable (beyond the potential typo). I don't think it's necessary. Outside of document-targeted modeling (i.e., JSON Schema for JSON), the distinction between omittable and nullable doesn't have meaning.

Yeah. Let's keep up a conversation to try to resolve each of the threads.

not.required | additionalProperties

I responded to some comments about not.required and additionalProperties. Let me know what you think. For not.required, I can always pull the baseline schema from before and after and show both how it differs from the hand-written JSON Schema and also how the behavior differs, but hopefully my explanation is clear enough.

Extra fields on OvertureFeature

The meta-issue is that I believe that all models that compose an OvertureFeature subclass should behave as if @no_extra_fields were applied, even if it's not explicit, due to the constraints that most format serializations introduce (fixed set of columns, explicit struct members). If you need a dict, use a dict. Apply patterns if you need to, but don't make it a hybrid.

I agree with this. I think today that is quasi-handled by the ext_* enabling code which already puts on "additionalProperties": false but allows through the narrow exception of ext_*. The ext_* exception is problematic, but when we remove it we should be left with all OvertureFeatures behaving as if @no_extra_fields was applied.

Let me know what your thoughts are on this.

Omitable

FWIW, it was a deliberate spelling choice so I'd argue not a typo. The word wasn't in the dictionary so I picked a spelling.

The choice of word is to differentiate it from Python Optional even though that's being phased out.

Agree that the primary purpose is to give the Pydantic model the same amount of richness as JSON Schema by de-conflating "nullable" from "may be omitted". It would also be useful in any other context in which these two concepts are separated - more may arise.

I think it's valuable. The reason I prefer it is that there IS a difference between a required field with { "anyOf": [ { "type": "string" }, { "type": "null" ] } and an optional field of type "string". That's why our current code base has a custom JSON Schema serializer that tries to make these edits on the fly. The problem in my mind is that means the Pydantic does not specify the JSON Schema, as it should, it specifies one JSON Schema that isn't intended and if you want to get to the one that is intended, you have to know to use a custom schema serializer. That seems wrong to me... In my mind, the Pydantic model should automatically embed the correct JSON Schema.

Comment on lines +19 to +25
Decorates a Pydantic model class with a constraint forbidding any of the named fields from
holding an explicitly-assigned value, but only if a field value condition is true.

To ensure parity between Python and JSON Schema validation, a field's value must be explicitly
set to violate the constraint. This means in particular that fields whose value was set by
Pydantic using a default value do not count as having a set value, and fields containing the
value `None`, if this value was explicitly set rather than being inherited by default, do count.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing: a comment that this won't let you override something that is required (and will raise...when?).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this is a good idea. Could even be a doctest example. I'll try to wedge it in the next iteration.

@sethfitz

Seth Fitzsimmons (sethfitz) commented Oct 20, 2025

Copy link
Copy Markdown
Collaborator

I responded to some comments about not.required and additionalProperties. Let me know what you think. For not.required, I can always pull the baseline schema from before and after and show both how it differs from the hand-written JSON Schema and also how the behavior differs, but hopefully my explanation is clear enough.

Yes, thanks for clarifying. Definitely confusing behavior on the JSON Schema side, but not something we need to surface to Pydantic builders (esp. if we get it right).

I agree with this. I think today that is quasi-handled by the ext_* enabling code which already puts on "additionalProperties": false but allows through the narrow exception of ext_. The ext_ exception is problematic, but when we remove it we should be left with all OvertureFeatures behaving as if @no_extra_fields was applied.

Let me know what your thoughts are on this.

Re: OvertureFeature directly, that's also my understanding/expectation.

I was referring to other BaseModels that get attached to OvertureFeature as fields. When modeling our particular flavor of tabular data (including potentially-nested lists, dicts, and structs (schemas/models)), "closed" (no additional fields) should be the default, as there's no mechanism in the formats used to serialize this data (other than JSON) to support "open" lists of fields. This is in contrast to API definitions (because they use JSON), where supporting extra fields is no big deal, and could support undocumented functionality in a useful way.

Pydantic optimizes for the API definition use case, so it's open by default. I believe that our "flavo[u]r" of Pydantic should default to "closed", even when schema authors don't include @no_extra_fields in their sub-schemas. That might lead to confusion for people used to Pydantic, but they'll find that the behavior is wrong otherwise when used with real data.

FWIW, it was a deliberate spelling choice so I'd argue not a typo. The word wasn't in the dictionary so I picked a spelling.

Fair enough ;-) This is from the New Oxford American Dictionary, which is the one that ships with macOS (and is neither Canadian nor old):

image

The choice of word is to differentiate it from Python Optional even though that's being phased out.

Yep, that was the same reason I remember from the GitHub issue I can't find. Makes sense.

Agree that the primary purpose is to give the Pydantic model the same amount of richness as JSON Schema by de-conflating "nullable" from "may be omitted". It would also be useful in any other context in which these two concepts are separated - more may arise.

I think it's valuable. The reason I prefer it is that there IS a difference between a required field with { "anyOf": [ { "type": "string" }, { "type": "null" ] } and an optional field of type "string". That's why our current code base has a custom JSON Schema serializer that tries to make these edits on the fly. The problem in my mind is that means the Pydantic does not specify the JSON Schema, as it should, it specifies one JSON Schema that isn't intended and if you want to get to the one that is intended, you have to know to use a custom schema serializer. That seems wrong to me... In my mind, the Pydantic model should automatically embed the correct JSON Schema.

From a JSON/JSON Schema point of view, I agree with you. However, I think that's the wrong lens; coming from JSON Schema doesn't mean that we need to support its semantics, especially if they don't have value for the type of data we're describing. I think we should start from the Pydantic perspective (which is what people will be authoring, even if it doesn't fully support the richness(?) of JSON Schema) and allow that to influence us vs. letting an implementation that we're moving away from (in part because it's confusing) drive the bus.

Alternately, I'd be ok saying that Overture only has optional fields (nothing can be omitted, and removing the custom serializer), which maps cleanly to tabular representations. It has the downside of producing more verbose JSON Schema (which also differs from our hand-written version) and making all of our JSON/YAML representations invalid, eliminating the convenience we've derived from them. I think the convenience wins though, since it doesn't change the actual validation behavior.

What's a concrete example where Omitable's semantics make sense in our world other than the convenience of JSON/YAML representations?

@vcschapp

Copy link
Copy Markdown
Collaborator Author

I was referring to other BaseModels that get attached to OvertureFeature as fields. When modeling our particular flavor of tabular data (including potentially-nested lists, dicts, and structs (schemas/models)), "closed" (no additional fields) should be the default, as there's no mechanism in the formats used to serialize this data (other than JSON) to support "open" lists of fields. This is in contrast to API definitions (because they use JSON), where supporting extra fields is no big deal, and could support undocumented functionality in a useful way.

Pydantic optimizes for the API definition use case, so it's open by default. I believe that our "flavo[u]r" of Pydantic should default to "closed", even when schema authors don't include @no_extra_fields in their sub-schemas. That might lead to confusion for people used to Pydantic, but they'll find that the behavior is wrong otherwise when used with real data.

There's an argument that we're over-thinking this some.

When looking at code-generated tools it may be mostly a non-issue. For something like a Spark dataframe writer, we can just generate the code to ignore all the "extra" fields, and I would be tempted to have the generator emit an optional warning/error if the model allows extra fields. For a reader, again we can just ignore any extra columns (top-level model) or again have the code generator emit an optional warning/error if the model allows extra fields. I'd go this way: allow authors to do whatever they say they want, but warn them if what they say they want would produce unexpected results in, say, Spark reading.

From a JSON/JSON Schema point of view, I agree with you. However, I think that's the wrong lens; coming from JSON Schema doesn't mean that we need to support its semantics, especially if they don't have value for the type of data we're describing. I think we should start from the Pydantic perspective (which is what people will be authoring, even if it doesn't fully support the richness(?) of JSON Schema) and allow that to influence us vs. letting an implementation that we're moving away from (in part because it's confusing) drive the bus.

Alternately, I'd be ok saying that Overture only has optional fields (nothing can be omitted, and removing the custom serializer), which maps cleanly to tabular representations. It has the downside of producing more verbose JSON Schema (which also differs from our hand-written version) and making all of our JSON/YAML representations invalid, eliminating the convenience we've derived from them. I think the convenience wins though, since it doesn't change the actual validation behavior.

What's a concrete example where Omitable's semantics make sense in our world other than the convenience of JSON/YAML representations?

The way I'm currently thinking about this is that JSON has two alternative models of optionality: you can go the omit route (which I have an aesthetic preference for) or the specify null route. JSON Schema is flexible enough to capture both of these models.

It also allows a middle ground where explicit null means omitted but you can also provide a default value of null to allow the null to be omitted, which is more or less the same as full omit[t]ability.

Pydantic supports two of the three models of optionality: explicit null or explicit null which can be omitted due to being a default value.

For e.g., this Pydantic model does allow the bar field to be omitted...

>>> class Foo(BaseModel):
...   bar: int | None = None
... 
>>> Foo()
Foo(bar=None)

And it produces this JSON Schema, which also allows the bar field to be omitted...:

{
    "title": "Foo",
    "type": "object",
    "properties": {
        "bar": {
            "anyOf": [{
                "type": "integer"
            }, {
                "type": "null"
            }],
            "default": null,
            "title": "Bar"
        }
    }
}

The one thing Pydantic is missing is "full omit[t]ability" without a default, which is semantically the same as the others but just represented differently at the JSON level. This is what EnhancedJsonSchemaGenerator is meant to solve, and my complaint has been that the solution is an optional post-processing step you have to know about and apply, rather than allowing the desired semantics to be embedded in the model. The other problem with it is that it can cause the JSON Schema to diverge from Pydantic behavior since if you do Foo().model_dump_json() in my above example, you will get {"bar":null} (unless you set exclude_none=True).

Finally getting to your point, it's true that for most of the non-JSON representations we're looking to serialize to, the different representations of omit[t]ability do not matter: the value is either there or it's NULL. So we don't want to get bogged down in worrying about something that only affects the JSON representation.

Maybe it's just the laziness/need to move on speaking, but given we mostly have a workable solution without Omitable today, this is what I would propose:

  1. Drop Omitable.
  2. Re-home EnhancedJsonSchemaGenerator into the system package (maybe with a more meaningful name).
  3. Have the our CLI default behavior be "full omission" using EnhancedJsonSchemaGenerator and exclude_none=True so that the Overture schema continues to work the way it always has, but allow this default to be turned off via some command line option.

@sethfitz

Copy link
Copy Markdown
Collaborator

There's an argument that we're over-thinking this some.

Probably ;-)

When looking at code-generated tools it may be mostly a non-issue. For something like a Spark dataframe writer, we can just generate the code to ignore all the "extra" fields, and I would be tempted to have the generator emit an optional warning/error if the model allows extra fields. For a reader, again we can just ignore any extra columns (top-level model) or again have the code generator emit an optional warning/error if the model allows extra fields. I'd go this way: allow authors to do whatever they say they want, but warn them if what they say they want would produce unexpected results in, say, Spark reading.

Agreed. Spark Datasets will ignore columns that aren't present in the class definition (X for Dataset[X]), although they'll be accessible when accessing the underlying DataFrame. Same when working with JSON (or dicts) and Pydantic / JSON Schema validation. I have some lingering discomfort about not flagging unexpected columns that "shouldn't" be present but that will end up getting dropped when serialized.

which I have an aesthetic preference for

As do I. It's downright convenient to be able to work with sparse JSON.

The one thing Pydantic is missing is "full omit[t]ability" without a default, which is semantically the same as the others but just represented differently at the JSON level. This is what EnhancedJsonSchemaGenerator is meant to solve, and my complaint has been that the solution is an optional post-processing step you have to know about and apply, rather than allowing the desired semantics to be embedded in the model. The other problem with it is that it can cause the JSON Schema to diverge from Pydantic behavior since if you do Foo().model_dump_json() in my above example, you will get {"bar":null} (unless you set exclude_none=True).

parse_feature currently sets exclude_unset=True (which I think is more appropriate than exclude_none; in addition to using EnhancedJsonSchemaGenerator) for that reason:

parsed_model.model_dump(exclude_unset=True, mode=mode, by_alias=True),

  1. Drop Omitable.
  2. Re-home EnhancedJsonSchemaGenerator into the system package (maybe with a more meaningful name).
  3. Have the our CLI default behavior be "full omission" using EnhancedJsonSchemaGenerator and exclude_none=True so that the Overture schema continues to work the way it always has, but allow this default to be turned off via some command line option.

Aligned, other than switching to exclude_unset=True. This is already how the CLI behaves.

@vcschapp

Copy link
Copy Markdown
Collaborator Author

There's an argument that we're over-thinking this some.

Probably ;-)

When looking at code-generated tools it may be mostly a non-issue. For something like a Spark dataframe writer, we can just generate the code to ignore all the "extra" fields, and I would be tempted to have the generator emit an optional warning/error if the model allows extra fields. For a reader, again we can just ignore any extra columns (top-level model) or again have the code generator emit an optional warning/error if the model allows extra fields. I'd go this way: allow authors to do whatever they say they want, but warn them if what they say they want would produce unexpected results in, say, Spark reading.

Agreed. Spark Datasets will ignore columns that aren't present in the class definition (X for Dataset[X]), although they'll be accessible when accessing the underlying DataFrame. Same when working with JSON (or dicts) and Pydantic / JSON Schema validation. I have some lingering discomfort about not flagging unexpected columns that "shouldn't" be present but that will end up getting dropped when serialized.

which I have an aesthetic preference for

As do I. It's downright convenient to be able to work with sparse JSON.

The one thing Pydantic is missing is "full omit[t]ability" without a default, which is semantically the same as the others but just represented differently at the JSON level. This is what EnhancedJsonSchemaGenerator is meant to solve, and my complaint has been that the solution is an optional post-processing step you have to know about and apply, rather than allowing the desired semantics to be embedded in the model. The other problem with it is that it can cause the JSON Schema to diverge from Pydantic behavior since if you do Foo().model_dump_json() in my above example, you will get {"bar":null} (unless you set exclude_none=True).

parse_feature currently sets exclude_unset=True (which I think is more appropriate than exclude_none; in addition to using EnhancedJsonSchemaGenerator) for that reason:

parsed_model.model_dump(exclude_unset=True, mode=mode, by_alias=True),

  1. Drop Omitable.
  2. Re-home EnhancedJsonSchemaGenerator into the system package (maybe with a more meaningful name).
  3. Have the our CLI default behavior be "full omission" using EnhancedJsonSchemaGenerator and exclude_none=True so that the Overture schema continues to work the way it always has, but allow this default to be turned off via some command line option.

Aligned, other than switching to exclude_unset=True. This is already how the CLI behaves.

I think we have the path forward!

And agreed that exclude_unset=True is the right way, not exclude_none.

Victor Schappert (vcschapp) added a commit that referenced this pull request Nov 26, 2025
The destination is as described in PR #397 (1/N), PR #400, and PR #403.
In this commit, the following value is added:

1. 🐞 Fix bug: The `not_required_if` mixin wasn't meeting the original
   schema intent, which result in the `DivisionBoundary` feature type
   having the wrong JSON Schema. The expected behavior is "if the
   boundary is at the country level, then the `country` pair should be
   omitted" but instead it did two variations: in the Python validator,
   it required the country pair to be present; and in the JSON Schema,
   it required the country pair to be present, but only if the condition
   was not true. Both were subtly wrong. The bug is fixed in this commit
   and the mixin is renamed to `forbid_if`, which I hope will make the
   semantics more obvious.
2. 🐞 Fix bug: Very subtle, but the mixin model constraint validators
   did not work correctly if added directly to a `Feature` model,
   because they aren't aware of the quirky multi-tiered GeoJSON JSON
   Schema where most properties get pushed down into "properties", but
   some do not. This is (hopefully ... 🤞) fixed in the new `Feature`
   model in the system package.
3. 🐞 Fix bug: Very subtle, but the previous `Feature` model's JSON
   Schema did not prohibit fields named `"id"`, `"bbox"`, and
   `"geometry"` within the properties object. This created a weird
   quirk where in a poisoned JSON object containing those values under
   properties, the properties version would overwrite the root version. 
   This is fixed by explicitly prohibiting those fields in the JSON
   Schema for the `Feature` class' properties object.
4. 🐞 Fix bug: The `ExtensibleBaseModel`'s `patternProperties` got put
   both at the top level of the GeoJSON Feature schema and in the
   "properties" object schema. This would have allowed the input JSON
   to contain `ext_*` at two levels, which doesn't meet the original
   schema intent. As part of this bug fix, `ExtensibleBaseModel` is
   collapsed directly into `OvertureFeature` and the `ext_*` feature
   is documented as being on a deprecation path.
5. The `validation` package is officially deleted as its essential
   George functions have all been re-homed either into `system` or
   `core`.
6. All model constraint mixins have been refactored along the lines
   started in PR #400. They are now called `@forbid_if`,
   `@min_fields_set`, `@no_extra_fields`, `@radio_group`,
   `@require_any_of`, and `@require_if`.
7. A type hint, `Omitable[T]` has been added to the system package.
   This type hint allows a Pydantic model to "naturally" get JSON
   Schema semantics for omitted values - it de-conflates optionality
   and nullability, which Pydantic currently conflates. In an
   upcoming episode, I hope to replace the `X | None` unions in the
   existing model with `Omitable[X]`.
8. The existing `Feature` class from the `core` package has been split
   into two: (i) `Feature` in the `system` package now acts as a
   generic feature and owns all the funky GeoJSON logic, allowing
   George enthusiasts to work with a nice ergonomic feature type without
   having to use an "Overture"-flavored feature; (ii) `OvertureFeature`
   in the `core` package adds the Overture schema specific flavoring
   on top of `Feature`.
9. The `ref` module from `core` has been pushed down into `system` and
   the dependency on `(Overture)Feature` has been factored out in favor
   of a generic dependency on an `Identified` model, which is basically
   just a `BaseModel` with a mandatory ID.

Stay tuned for episode 4, where we will re-organize the core package.
Victor Schappert (vcschapp) added a commit that referenced this pull request Dec 4, 2025
The destination is as described in PR #397 (1/N), PR #400, and PR #403.
In this commit, the following value is added:

1. 🐞 Fix bug: The `not_required_if` mixin wasn't meeting the original
   schema intent, which result in the `DivisionBoundary` feature type
   having the wrong JSON Schema. The expected behavior is "if the
   boundary is at the country level, then the `country` pair should be
   omitted" but instead it did two variations: in the Python validator,
   it required the country pair to be present; and in the JSON Schema,
   it required the country pair to be present, but only if the condition
   was not true. Both were subtly wrong. The bug is fixed in this commit
   and the mixin is renamed to `forbid_if`, which I hope will make the
   semantics more obvious.
2. 🐞 Fix bug: Very subtle, but the mixin model constraint validators
   did not work correctly if added directly to a `Feature` model,
   because they aren't aware of the quirky multi-tiered GeoJSON JSON
   Schema where most properties get pushed down into "properties", but
   some do not. This is (hopefully ... 🤞) fixed in the new `Feature`
   model in the system package.
3. 🐞 Fix bug: Very subtle, but the previous `Feature` model's JSON
   Schema did not prohibit fields named `"id"`, `"bbox"`, and
   `"geometry"` within the properties object. This created a weird
   quirk where in a poisoned JSON object containing those values under
   properties, the properties version would overwrite the root version. 
   This is fixed by explicitly prohibiting those fields in the JSON
   Schema for the `Feature` class' properties object.
4. 🐞 Fix bug: The `ExtensibleBaseModel`'s `patternProperties` got put
   both at the top level of the GeoJSON Feature schema and in the
   "properties" object schema. This would have allowed the input JSON
   to contain `ext_*` at two levels, which doesn't meet the original
   schema intent. As part of this bug fix, `ExtensibleBaseModel` is
   collapsed directly into `OvertureFeature` and the `ext_*` feature
   is documented as being on a deprecation path.
5. The `validation` package is officially deleted as its essential
   George functions have all been re-homed either into `system` or
   `core`.
6. All model constraint mixins have been refactored along the lines
   started in PR #400. They are now called `@forbid_if`,
   `@min_fields_set`, `@no_extra_fields`, `@radio_group`,
   `@require_any_of`, and `@require_if`.
7. A type hint, `Omitable[T]` has been added to the system package.
   This type hint allows a Pydantic model to "naturally" get JSON
   Schema semantics for omitted values - it de-conflates optionality
   and nullability, which Pydantic currently conflates. In an
   upcoming episode, I hope to replace the `X | None` unions in the
   existing model with `Omitable[X]`.
8. The existing `Feature` class from the `core` package has been split
   into two: (i) `Feature` in the `system` package now acts as a
   generic feature and owns all the funky GeoJSON logic, allowing
   George enthusiasts to work with a nice ergonomic feature type without
   having to use an "Overture"-flavored feature; (ii) `OvertureFeature`
   in the `core` package adds the Overture schema specific flavoring
   on top of `Feature`.
9. The `ref` module from `core` has been pushed down into `system` and
   the dependency on `(Overture)Feature` has been factored out in favor
   of a generic dependency on an `Identified` model, which is basically
   just a `BaseModel` with a mandatory ID.

Stay tuned for episode 4, where we will re-organize the core package.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants