refactor(BA-7722): mark unsent request fields with pydantic MISSING - #14312
Merged
Conversation
Update-request DTOs marked an unsent field with a hand-rolled `Sentinel` enum.
It serialized as the integer `1`, which reached the wire whenever a caller set it
explicitly and was published in the OpenAPI spec as `{"type":"integer","enum":[1]}`.
`UNSET` is `pydantic_core.MISSING`; pydantic omits a field holding it from both
serialization and the JSON schema. `Unset` aliases `typing_extensions.Sentinel`
rather than the exact `MISSING` type because mypy has merged PEP 661 support but
not released it. `MISSING` is a real instance of that class, so the annotation is
wider than needed but never false, and mypy accepts it with no suppressions.
`TriState.from_unset` and `OptionalState.from_unset` map the three states onto the
target column, and `OptionalState.and_optional` / `and_tri` read a nested request
model. Nested access is on `OptionalState` only: a nested model has no column
behind it, so a null parent must not spread a nullify into its children.
This covers the foundation and one reference entity. Migrating the remaining DTO
modules and dropping `exclude_none` from the v2 client transport follow separately;
the latter must wait until no field is a `Sentinel` any more.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHHR2fFGQNB3NssxHeRKBz
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHHR2fFGQNB3NssxHeRKBz
Co-authored-by: octodog <mu001@lablup.com>
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
The new serialization and tri-state conversion contracts lack automated regression coverage.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Replaces the legacy update-field sentinel with Pydantic MISSING, using Prometheus query presets as the reference migration.
Changes:
- Adds reusable unset and tri-state conversion helpers.
- Migrates Prometheus update DTOs and adapter logic.
- Upgrades Pydantic and regenerates lockfiles.
File summaries
| File | Description |
|---|---|
tools/mypy.lock.metadata |
Updates Pydantic requirement metadata. |
tools/mypy.lock |
Refreshes mypy tool dependencies. |
tools/mypy-requirements.txt |
Requires Pydantic 2.13.5. |
src/ai/backend/manager/types.py |
Adds unset and nested-state conversions. |
src/ai/backend/manager/api/rest/prometheus_query_preset/adapter.py |
Uses the new conversion helpers. |
src/ai/backend/common/tristate/unset.py |
Defines UNSET and Unset. |
src/ai/backend/common/tristate/KNOWLEDGE.md |
Documents sentinel rationale and migration. |
src/ai/backend/common/tristate/AGENTS.md |
Adds tri-state usage guardrails. |
src/ai/backend/common/tristate/__init__.py |
Initializes the package. |
src/ai/backend/common/dto/manager/prometheus_query_preset/request.py |
Migrates update fields to UNSET. |
src/ai/backend/common/dto/AGENTS.md |
Documents create/update schema rules. |
src/ai/backend/common/BUILD |
Adds tristate dependency boundaries. |
requirements.txt |
Upgrades runtime Pydantic. |
python.lock.metadata |
Updates lock requirement metadata. |
python.lock |
Regenerates runtime dependencies. |
changes/14312.enhance.md |
Adds the changelog entry. |
Review details
- Files reviewed: 13/17 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+75
to
+80
| name: str | None | Unset = Field(default=UNSET, description="Human-readable name") | ||
| metric_name: str | None | Unset = Field(default=UNSET, description="Prometheus metric name") | ||
| query_template: str | None | Unset = Field( | ||
| default=UNSET, description="PromQL template with placeholders" | ||
| ) | ||
| time_window: str | Sentinel | None = Field(default=SENTINEL, description="Default time window") | ||
| options: ModifyQueryDefinitionOptionsRequest | None = Field( | ||
| default=None, description="Query definition options" | ||
| time_window: str | None | Unset = Field(default=UNSET, description="Default time window") |
Comment on lines
+284
to
+299
| def and_optional[TNew](self, fn: Callable[[TVal], TNew | None | Unset]) -> OptionalState[TNew]: | ||
| """Read a field of the held value onto a non-nullable column. | ||
|
|
||
| For nested request models. An absent parent yields nop. | ||
| """ | ||
| if self._state == _TriStateEnum.UPDATE: | ||
| return OptionalState.from_unset(fn(self.value())) | ||
| return OptionalState.nop() | ||
|
|
||
| def and_tri[TNew](self, fn: Callable[[TVal], TNew | None | Unset]) -> TriState[TNew]: | ||
| """Read a field of the held value onto a nullable column. | ||
|
|
||
| An absent parent yields nop; only the field's own null nullifies. | ||
| """ | ||
| if self._state == _TriStateEnum.UPDATE: | ||
| return TriState.from_unset(fn(self.value())) |
fregataa
approved these changes
Sep 7, 2026
seedspirit
pushed a commit
that referenced
this pull request
Sep 7, 2026
seedspirit
added a commit
that referenced
this pull request
Sep 7, 2026
BA-7722 (#14312) replaced the SENTINEL marker for an unsent update field with pydantic MISSING, wrapped as Unset/UNSET, and made it the rule for update schemas in common/dto/AGENTS.md. Move UpdateRuntimeVariantPresetInput onto it rather than land two new fields on the mechanism that was just retired. Reading every field through TriState.from_unset / OptionalState.from_unset also drops the three-way ternary each one needed, taking the adapter's update from 47 lines to 15. Dropping default=None from the GQL input fixes a data loss on the GraphQL path: strawberry filled an omitted field with null, and null on a nullable column means clear, so updating only the rank wiped the preset's description and default_value. An omitted field is now strawberry UNSET, which to_pydantic skips. The fields stay optional in the schema and an explicit null still clears them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
seedspirit
added a commit
that referenced
this pull request
Sep 7, 2026
BA-7722 (#14312) replaced the SENTINEL marker for an unsent update field with pydantic MISSING, wrapped as Unset/UNSET, and made it the rule for update schemas in common/dto/AGENTS.md. Move UpdateRuntimeVariantPresetInput onto it rather than land two new fields on the mechanism that was just retired. Reading every field through TriState.from_unset / OptionalState.from_unset also drops the three-way ternary each one needed, taking the adapter's update from 47 lines to 15. Dropping default=None from the GQL input fixes a data loss on the GraphQL path: strawberry filled an omitted field with null, and null on a nullable column means clear, so updating only the rank wiped the preset's description and default_value. An omitted field is now strawberry UNSET, which to_pydantic skips. The fields stay optional in the schema and an explicit null still clears them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
seedspirit
added a commit
that referenced
this pull request
Sep 7, 2026
BA-7722 (#14312) retired the SENTINEL marker for an unsent update field in favour of pydantic MISSING, wrapped as Unset/UNSET, and common/dto/AGENTS.md now requires it of update schemas. Declare the two fields this branch adds that way rather than land them on the mechanism that was just replaced. Only the new fields move. The eleven that were already on SENTINEL keep it, and none of the model validators read the new fields, so no existing default or code path changes. Converting the rest is a follow-up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 8, 2026
This was referenced Sep 8, 2026
seedspirit
added a commit
that referenced
this pull request
Sep 8, 2026
BA-7722 (#14312) made Unset/UNSET the marker for a field an update request did not send, and common/dto/AGENTS.md now requires it of update schemas. The patch inputs this branch adds are update schemas, so they carry it rather than a None default. Behaviour is unchanged: the merge already keyed off model_fields_set through model_dump(exclude_unset=True), and the GQL inputs already defaulted to strawberry UNSET. What changes is the published contract -- an omitted field no longer reads as "defaults to null" in the OpenAPI, which is what "omit to keep the current value" meant all along. A union holding Unset drops a field-level constraint from the generated schema, so the six constraints ride on their annotated member instead. Verified that exclusiveMinimum, minimum, minLength and maxItems all survive, the last of which would otherwise have been emitted as maxLength on an array. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
seedspirit
added a commit
that referenced
this pull request
Sep 8, 2026
BA-7722 (#14312) retired the SENTINEL marker for an unsent update field in favour of pydantic MISSING, wrapped as Unset/UNSET, and common/dto/AGENTS.md now requires it of update schemas. Declare the two fields this branch adds that way rather than land them on the mechanism that was just replaced. Only the new fields move. The eleven that were already on SENTINEL keep it, and none of the model validators read the new fields, so no existing default or code path changes. Converting the rest is a follow-up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
seedspirit
added a commit
that referenced
this pull request
Sep 9, 2026
BA-7722 (#14312) retired the SENTINEL marker for an unsent update field in favour of pydantic MISSING, wrapped as Unset/UNSET, and common/dto/AGENTS.md now requires it of update schemas. Declare the two fields this branch adds that way rather than land them on the mechanism that was just replaced. Only the new fields move. The eleven that were already on SENTINEL keep it, and none of the model validators read the new fields, so no existing default or code path changes. Converting the rest is a follow-up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Problem
Update-request DTOs mark an unsent field with a hand-rolled
Sentinelenum(
common/api_handlers.py). It serializes as the integer1, so:docs/manager/rest-reference/openapi.jsoncarries
Sentinelas{"type":"integer","enum":[1]}, referenced by 78 fields across 25Update*Inputschemas, each with"default": 1.does (
client/cli/v2/object_storage/commands.py). The server happens to decode1backinto the enum, so this has gone unnoticed.
Change
UNSETispydantic_core.MISSING. pydantic omits a field holding it from serialization andfrom the JSON schema, so an absent field puts nothing on the wire.
Unsetaliasestyping_extensions.Sentinelrather than the exactMISSINGtype: mypy mergedPEP 661 support (python/mypy#21647, 2026-08-07) but has not released it.
MISSINGis a realinstance of that class, so the annotation is wider than needed but never false, and mypy accepts
it with no suppressions. When mypy ships PEP 661,
unset.pyand the twofrom_unsetbodieschange and nothing else does —
common/tristate/KNOWLEDGE.mdrecords the steps.Conversion
TriState.from_unset/OptionalState.from_unsetmap the three states onto the target column.What
nullmeans is decided by the column, and the constructor the adapter picks states it.TriState.from_unsetOptionalState.from_unsetOptionalState.and_optional/and_triread a nested request model. Nested access is onOptionalStateonly: a nested model is a wire-side grouping with no column behind it, so a nullparent must not spread a nullify into its children — a non-nullable child would violate its
constraint and a nullable one would be erased unasked.
The adapter loses its hand-rolled three-way ternaries:
Scope
Foundation plus one reference entity (
prometheus_query_preset).common/dto/AGENTS.mdgainsthe create-vs-update schema rules this follows.
Follow-ups, not in this PR:
Sentinel.exclude_none=Truefromclient/v2/base_client.py, which currently discards an explicitnull and so leaves a client unable to express NULLIFY at all. It must land after every field is
migrated — removing it earlier would put the integer
1on the wire more often, not less.Verification
pants check --changed-since=origin/main --changed-dependents=transitive— 7152 source files,no issues. This covers the pydantic 2.11.10 → 2.13.5 bump across the repo.
pants lintclean, including the new/tristate/**visibility rule.reads resolve,
CreateQueryDefinitionRequestis untouched, and thetime_windowfieldvalidator still rejects a bad duration.
pants testleft to CI per the submit workflow.🤖 Generated with Claude Code
https://claude.ai/code/session_01UHHR2fFGQNB3NssxHeRKBz