Skip to content

Make pydantic config models reject unknown keys (extra="forbid") - #762

Merged
thusser merged 6 commits into
developfrom
feature/pydantic-extra-forbid
Aug 16, 2026
Merged

Make pydantic config models reject unknown keys (extra="forbid")#762
thusser merged 6 commits into
developfrom
feature/pydantic-extra-forbid

Conversation

@thusser

@thusser thusser commented Aug 16, 2026

Copy link
Copy Markdown
Member

Summary

  • Sets extra="forbid" globally on pyobs.utils.serialization.BaseModel/PolymorphicBaseModel, so a misspelled or misplaced config key raises ValidationError at load instead of being silently dropped. Root cause: a task YAML put guiding_config/acquisition_config inside instrument_configs instead of one level up on Configuration; pydantic dropped both, the Configuration-level defaults applied instead, and the task failed can_run forever with no error pointing at the config.
  • Declares the LCO portal models' previously-undeclared fields (state/submitter on LcoSchedulableRequest; instrument_name/guide_camera_name/summary on LcoConfiguration; eight fields on LcoObservation) instead of opting the family out with extra="ignore" — the portal is self-hosted, so a schema mismatch should fail loudly at upgrade time rather than being silently absorbed forever.
  • create_object/get_object now route comm/timezone/vfs/observer through pydantic's validation context for pydantic models instead of passing them as constructor kwargs (which extra="forbid" would otherwise reject).
  • Task is now a PolymorphicBaseModel so it pops its own class key.
  • Fixed a previously-hidden bug surfaced along the way: Merit.create() left a stale type key in the config dict after deriving class from it.

Full design/decision history: specs/plans/2026-08-15-pydantic-extra-validation.md.

Test plan

  • pytest -m "not integration and not xmpp": 1460 passed, 25 skipped, 0 failed
  • ruff check clean on all changed files
  • pyrefly check: 0 errors
  • Added regression test (tests/robotic/scripts/test_imaging.py) reproducing the original misplaced-key bug and asserting it now raises

@thusser

thusser commented Aug 16, 2026

Copy link
Copy Markdown
Member Author

The core change is sound and the tests back it up. One bug should be fixed in this PR, plus two things to close before merge.

Blocking: Constraint.create has the same stale-type bug this PR fixes in Merit.create. pyobs/robotic/scheduler/constraints/constraint.py:47 sets config["class"] from config["type"] and never deletes type. Constraint is a PolymorphicBaseModel, so it now inherits extra="forbid". Any scheduler config that lists global constraints as dicts with the type shorthand (OnDemandScheduler(constraints=[{"type": "Airmass", ...}]), ondemandscheduler.py:59) now raises ValidationError at load. No test catches it because every test passes Constraint instances, not type dicts. Add del config["type"] there, mirroring the Merit.create fix, plus a regression test.

Verify before merge: required (no-default) fields on the LCO models. LcoObservation gains created, modified, ipp_value, name, observation_type, proposal, request_group_id, submitter (all required), and LcoSchedulableRequest gains state, submitter (required). The plan itself notes the types are inferred from fixtures and should be confirmed against a live portal response. If any endpoint omits one (e.g. a not-yet-run observation without ipp_value), it hard-fails now. Confirm against real responses, or default the less-guaranteed fields.

Rebase: the PR base is stale. Recorded base is 04fd5187, two commits behind develop (7066350f). Both doc commits are already on develop, so the UI shows 14 files / 3 commits when the true net diff is 9 files. Rebase onto develop so the review surface and merge match reality.

Minor, non-blocking:

  • create_object's new branch drops *args and calls model_validate(cfg) without by_alias=True. No current caller trips this, but it's a latent mismatch worth a comment or an explicit by_alias=True.
  • del config["type"] in Merit.create sits inside if "." not in config["type"], so the dotted-type branch still passes type through. That branch was already broken (the polymorphic model keys on class, not type), so it's dead code worth removing rather than leaving half-fixed.

Verified correct: model_config merges across inheritance (so Task and LcoRequest keep extra="forbid"), and the comm/timezone/vfs/observer context-routing in create_object is right, with no circular import.

@thusser
thusser force-pushed the feature/pydantic-extra-forbid branch from d472467 to ec15dd7 Compare August 16, 2026 15:14
@thusser

thusser commented Aug 16, 2026

Copy link
Copy Markdown
Member Author

Re-checked the new head (ec15dd75). All review points are addressed:

  • Constraint.create now deletes type after deriving class (constraint.py:48), plus a regression test (test_constraint.py).
  • Merit.create's dead dotted-type branch is gone; the type deletion is now unconditional, plus test_merit.py.
  • create_object's pydantic branch now passes by_alias=True and asserts against positional *args.
  • Ran the affected tests against the branch: 59 passed (constraints/merits/imaging/LCO).

Two things left before merge:

  1. LCO required fields still unconfirmed. LcoObservation.created/modified/ipp_value/name/observation_type/proposal/request_group_id/submitter and LcoSchedulableRequest.state/submitter are still required with no default. This is flagged in the plan as "needs the user" and can't be verified client-side. Confirm against the live self-hosted portal, or default the less-certain fields (e.g. ipp_value, request_group_id) so a not-yet-run observation doesn't hard-fail.

  2. The rebase landed on the stale tip, not current develop. The plan says "rebased onto origin/develop's actual tip", but the branch base is 04fd5187, two commits behind develop (7066350f). It's benign (mergeable=true, the diff is a clean 12 files with the doc commits correctly excluded), but the branch will show as 2 commits behind develop and the claim in the plan is inaccurate. Worth a git rebase origin/develop to land on 7066350f before merge.

A task YAML misplaced guiding_config/acquisition_config inside
instrument_configs; pydantic silently dropped both keys instead of
erroring, and the task then failed can_run forever with no error
pointing at the config. Every pyobs BaseModel/PolymorphicBaseModel
now rejects unrecognized keys at load time instead of dropping them.

Also declares the LCO portal models' previously-undeclared fields
(state/submitter on LcoSchedulableRequest, instrument_name/
guide_camera_name/summary on LcoConfiguration, and eight fields on
LcoObservation) rather than opting them out with extra="ignore" -
the portal is self-hosted, so a schema mismatch should fail loudly
at upgrade time, not get silently absorbed forever.

Fixes surfaced along the way:
- create_object/get_object now route comm/timezone/vfs/observer
  through pydantic's validation context for pydantic models instead
  of passing them as constructor kwargs, which extra="forbid" would
  otherwise reject.
- Task is now a PolymorphicBaseModel so it pops its own `class` key.
- Merit.create() left a stale `type` key in the config dict after
  deriving `class` from it; only surfaced once the LCO fixture-setup
  error that had been masking it was fixed.
- Constraint.create() had the same stale-type bug as Merit.create():
  it derived config["class"] from config["type"] but never removed
  type, so extra="forbid" now rejects it (Constraint is a
  PolymorphicBaseModel). Fixed the same way, plus a regression test
  reproducing the type-shorthand config path used by e.g.
  OnDemandScheduler(constraints=[{"type": "Airmass", ...}]), which no
  existing test covered (they all pass Constraint instances).
- Removed Merit.create()'s dead "dotted type" branch: it never set
  class in the first place, so it was already broken before this PR;
  half-fixing it by conditionally deleting type left it half-broken
  in a different way. Added the equivalent regression test for
  Merit.create()'s type-shorthand path.
- create_object()'s pydantic branch now passes by_alias=True to
  model_validate (matching Merit.create/Constraint.create's existing
  convention) and asserts against positional args, which model_validate
  can't accept.
Checked the review's "verify required fields against a live portal"
concern against the actual portal source (LCO's Django app, our
self-hosted deployment) instead of guessing from fixtures.

Portal.observations() and Portal.download_schedule() hit different
endpoints with different response shapes:
- download_schedule() -> GET /api/observations/, routed through
  ListAsDictMixin.list() -> Observation.as_dict() with no args ->
  no_request=False -> observation_as_dict() sets all 8 fields
  unconditionally. This is the shape the test fixtures modeled.
- observations() -> GET /api/requests/{id}/observations/, a custom
  action that explicitly calls o.as_dict(no_request=True) -> those
  8 fields are omitted entirely, and `request` stays a bare FK id
  instead of an expanded object.

Made created/modified/ipp_value/name/observation_type/proposal/
request_group_id/submitter optional on LcoObservation. Added a
regression test against the actual no_request=True shape; verified
it fails with exactly the 8 missing-field errors against the
pre-fix code before restoring the fix.
@thusser
thusser force-pushed the feature/pydantic-extra-forbid branch from 0390b44 to 31b451b Compare August 16, 2026 15:29
…hand logic

create_object's pydantic branch now raises instead of silently letting
kwargs clobber colliding cfg keys, and the positional-args guard is a
real raise instead of an assert (stripped under python -O). Also
extracts the duplicated type->class shorthand resolution out of
Merit.create/Constraint.create into a shared helper.
…pyobs-robotic-backend

Verified against pyobs-robotic-backend source: task.id is never None on
the reachable BackendObservationArchive+BackendTaskArchive path, and the
hypothetical mixed-backend case was already broken pre-PR (PK-only
ForeignKey field) independent of the class key, which the backend
strips defensively anyway.
@thusser

thusser commented Aug 16, 2026

Copy link
Copy Markdown
Member Author

Reviewed the full diff plus final state of every changed file; ran tests/robotic + tests/utils + tests/test_object.py (549 passed), tests/robotic/storage/lco/ (57 passed), and ruff check/pyrefly check clean.

Verdict: approve

Sound design, thorough plan, good regression coverage. Confirmed the core changes behave as claimed (extra="forbid", declared LCO fields, context-routing in create_object, TaskPolymorphicBaseModel).

Findings, none blocking:

  1. by_alias dropped on polymorphic dispatch (pre-existing, dormant). retrieve_class_on_deserialization calls klass.model_validate(modified_value, context=info.context) without by_alias, even though Constraint.create/Merit.create/create_object pass by_alias=True. Not triggered today: create_object deletes class before validating (so no dispatch), and no constraint/merit uses aliased fields. If one ever does, it'll fail validation. One-line fix in serialization.py if you want to close it now.

  2. resolve_polymorphic_type_shorthand mutates the caller's dict (deletes type, adds class). Original also set class in place, but now it removes a key too. Low risk since configs are loaded fresh.

  3. LCO portal strictness is the real operational risk, by design. Any undeclared field in a live portal response now raises at load. The self-hosted/fail-loud reclassification is reasonable and fields were checked against the portal source, but the plan still carries "confirm against a live portal response before finalizing" for the LcoConfiguration additions (inferred from fixtures). First portal upgrade that adds a field breaks pyobs at load.

  4. Task.model_dump() now emits class and static_target (field name, not target alias). Not a regression (pydantic already dumps field names; populate_by_name=True makes both loadable; class is popped on deserialize). Just confirm no cross-repo backend expects a class-less task payload.

Nice side effect worth noting: Task created via Object.get_object now actually receives comm/vfs/observer/timezone through context, where extra="ignore" previously dropped them.

retrieve_class_on_deserialization was the one model_validate call left
without by_alias=True, unlike Constraint.create/Merit.create/
create_object. Dormant today (no constraint/merit uses aliased
fields), but a polymorphic model with an aliased field would silently
fail validation on load without this.
@thusser
thusser merged commit e398117 into develop Aug 16, 2026
3 checks passed
@thusser
thusser deleted the feature/pydantic-extra-forbid branch August 16, 2026 20:25
thusser added a commit that referenced this pull request Aug 16, 2026
Records the merge commit and the final round of review fixes
(create_object kwarg guards, deduped type-shorthand helper,
by_alias on polymorphic dispatch). Closes #755.
thusser added a commit that referenced this pull request Aug 17, 2026
…plan and add anchor/alias tests

pydantic-extra-validation was merged (e398117, #762, closes #755) but still
filed as draft/not-finished in the index. Also revise object-kwarg-validation's
Decision: fix the comm_cfg anchor-holder leak at its source in pre_process_yaml
instead of allowlisting it in Object.__init__, since reload_anchors() already
identifies the leaking key by name. Add regression coverage for the
include/anchor mechanism in tests/utils/test_config.py, including an
xfail(strict=True) test documenting the comm_cfg leak itself.
thusser added a commit that referenced this pull request Aug 19, 2026
Backend's ProjectSerializer includes a users field (project visibility
per user) that Project never modeled. With extra="forbid" enabled
(#762), this now raises a validation error instead of being silently
dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
thusser added a commit that referenced this pull request Sep 2, 2026
* Make pydantic config models reject unknown keys (extra="forbid")

A task YAML misplaced guiding_config/acquisition_config inside
instrument_configs; pydantic silently dropped both keys instead of
erroring, and the task then failed can_run forever with no error
pointing at the config. Every pyobs BaseModel/PolymorphicBaseModel
now rejects unrecognized keys at load time instead of dropping them.

Also declares the LCO portal models' previously-undeclared fields
(state/submitter on LcoSchedulableRequest, instrument_name/
guide_camera_name/summary on LcoConfiguration, and eight fields on
LcoObservation) rather than opting them out with extra="ignore" -
the portal is self-hosted, so a schema mismatch should fail loudly
at upgrade time, not get silently absorbed forever.

Fixes surfaced along the way:
- create_object/get_object now route comm/timezone/vfs/observer
  through pydantic's validation context for pydantic models instead
  of passing them as constructor kwargs, which extra="forbid" would
  otherwise reject.
- Task is now a PolymorphicBaseModel so it pops its own `class` key.
- Merit.create() left a stale `type` key in the config dict after
  deriving `class` from it; only surfaced once the LCO fixture-setup
  error that had been masking it was fixed.

* Address review feedback on pydantic extra=forbid PR

- Constraint.create() had the same stale-type bug as Merit.create():
  it derived config["class"] from config["type"] but never removed
  type, so extra="forbid" now rejects it (Constraint is a
  PolymorphicBaseModel). Fixed the same way, plus a regression test
  reproducing the type-shorthand config path used by e.g.
  OnDemandScheduler(constraints=[{"type": "Airmass", ...}]), which no
  existing test covered (they all pass Constraint instances).
- Removed Merit.create()'s dead "dotted type" branch: it never set
  class in the first place, so it was already broken before this PR;
  half-fixing it by conditionally deleting type left it half-broken
  in a different way. Added the equivalent regression test for
  Merit.create()'s type-shorthand path.
- create_object()'s pydantic branch now passes by_alias=True to
  model_validate (matching Merit.create/Constraint.create's existing
  convention) and asserts against positional args, which model_validate
  can't accept.

* Fix LcoObservation required fields: two endpoints, two shapes

Checked the review's "verify required fields against a live portal"
concern against the actual portal source (LCO's Django app, our
self-hosted deployment) instead of guessing from fixtures.

Portal.observations() and Portal.download_schedule() hit different
endpoints with different response shapes:
- download_schedule() -> GET /api/observations/, routed through
  ListAsDictMixin.list() -> Observation.as_dict() with no args ->
  no_request=False -> observation_as_dict() sets all 8 fields
  unconditionally. This is the shape the test fixtures modeled.
- observations() -> GET /api/requests/{id}/observations/, a custom
  action that explicitly calls o.as_dict(no_request=True) -> those
  8 fields are omitted entirely, and `request` stays a bare FK id
  instead of an expanded object.

Made created/modified/ipp_value/name/observation_type/proposal/
request_group_id/submitter optional on LcoObservation. Added a
regression test against the actual no_request=True shape; verified
it fails with exactly the 8 missing-field errors against the
pre-fix code before restoring the fix.

* Harden create_object kwarg guards, dedupe merit/constraint type-shorthand logic

create_object's pydantic branch now raises instead of silently letting
kwargs clobber colliding cfg keys, and the positional-args guard is a
real raise instead of an assert (stripped under python -O). Also
extracts the duplicated type->class shorthand resolution out of
Merit.create/Constraint.create into a shared helper.

* Close sibling-repo question on Task.model_dump() class-key leak into pyobs-robotic-backend

Verified against pyobs-robotic-backend source: task.id is never None on
the reachable BackendObservationArchive+BackendTaskArchive path, and the
hypothetical mixed-backend case was already broken pre-PR (PK-only
ForeignKey field) independent of the class key, which the backend
strips defensively anyway.

* Pass by_alias=True on polymorphic-dispatch deserialization

retrieve_class_on_deserialization was the one model_validate call left
without by_alias=True, unlike Constraint.create/Merit.create/
create_object. Dormant today (no constraint/merit uses aliased
fields), but a polymorphic model with an aliased field would silently
fail validation on load without this.
thusser added a commit that referenced this pull request Sep 2, 2026
Records the merge commit and the final round of review fixes
(create_object kwarg guards, deduped type-shorthand helper,
by_alias on polymorphic dispatch). Closes #755.
thusser added a commit that referenced this pull request Sep 2, 2026
…plan and add anchor/alias tests

pydantic-extra-validation was merged (e398117, #762, closes #755) but still
filed as draft/not-finished in the index. Also revise object-kwarg-validation's
Decision: fix the comm_cfg anchor-holder leak at its source in pre_process_yaml
instead of allowlisting it in Object.__init__, since reload_anchors() already
identifies the leaking key by name. Add regression coverage for the
include/anchor mechanism in tests/utils/test_config.py, including an
xfail(strict=True) test documenting the comm_cfg leak itself.
thusser added a commit that referenced this pull request Sep 2, 2026
Backend's ProjectSerializer includes a users field (project visibility
per user) that Project never modeled. With extra="forbid" enabled
(#762), this now raises a validation error instead of being silently
dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant