Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,12 +445,15 @@ What to do with `allOf: [{$ref: Base}, ...]`.
- Constructors become keyword-only **for the models in a hierarchy** — a subclass may
pin an inherited field to a default while adding required fields of its own, which
positional ordering cannot express. Models outside every hierarchy are untouched.
- A subtype that restates an inherited property just to attach prose, or to relax it
to nullable, simply **inherits** it: re-declaring `v: str | None` over the base's
`v: str` is rejected by `mypy --strict`. Genuine narrowings are kept — including a
`Literal` tag over a `str` base, but not over a base of a different scalar type
(`Literal['one', 'two']` does not narrow an `int`). So is a restatement that changes
the `default`, tightens the `constraints`, or makes the field `required`.
- A property explicitly declared by a subtype stays on that subtype, even when it is
identical to the inherited property. Compatible overrides render normally — that
includes narrowing a `$ref` to a schema that inherits from the base's
(`companion: Pet` over `companion: Creature`) and `integer` over `number`. An
override the base cannot admit, such as `v: str | None` over `v: str`, means the
subtype is not substitutable for its base: that is a defect in the spec and worth
fixing there, so it is reported as a warning. The generated model stays faithful to
the schema and carries a local `# type: ignore[assignment, unused-ignore]`, which
keeps it clean under `mypy --strict` either way.
- Naming an inherited property in the subtype's `required` **without** restating the
property still tightens it: the subtype re-declares it with the base's annotation
and no default, so the constructor demands it.
Expand Down
153 changes: 79 additions & 74 deletions src/unihttp_openapi_generator/ir/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import logging
from collections.abc import Mapping
from dataclasses import replace
from typing import Any
from urllib.parse import urlsplit
Expand Down Expand Up @@ -60,6 +61,11 @@

_HTTP_METHODS = ("get", "put", "post", "delete", "patch", "head", "options", "trace")

# Python's numeric tower as mypy applies it: an ``int`` is accepted wherever a ``float``
# is expected, and a ``bool`` wherever an ``int`` is. A subtype re-typing ``number`` as
# ``integer`` is an ordinary OpenAPI narrowing, so it must not read as incompatible.
_PROMOTIONS = frozenset({("int", "float"), ("bool", "int"), ("bool", "float")})

# Identifiers imported into generated modules; a model/method class must never reuse one
# or it would shadow the import (markers, unihttp core, serializer bases, common types).
_RESERVED_NAMES = frozenset(
Expand Down Expand Up @@ -263,7 +269,7 @@ def _reconcile_inheritance(self, declarations: list[Declaration]) -> None:
inherited = self._inherited_fields(by_name, decl.base_model)
self._restore_required_narrowing(decl, inherited)
self._retype_discriminator_tag(decl, inherited, by_name)
self._drop_unsafe_overrides(decl, inherited)
self._mark_unsafe_overrides(decl, inherited, by_name)
self._rename_shadowed_fields(decl, inherited)
self._align_override_names(decl, inherited)

Expand All @@ -280,7 +286,7 @@ def _restore_required_narrowing(self, model: IRModel, inherited: dict[str, IRFie
an optional field is rendered ``T | None`` whether it is optional or genuinely
nullable, and the IR no longer distinguishes the two, so narrowing to ``T``
could reject a null the spec allows. Same annotation is always a legal
override, and ``_drop_unsafe_overrides`` keeps it because requiredness differs.
override, so ``_mark_unsafe_overrides`` leaves the re-declaration unflagged.
"""
own = {f.wire_name for f in model.fields}
for wire in sorted(self._own_required.get(model.name, set())):
Expand All @@ -295,6 +301,9 @@ def _restore_required_narrowing(self, model: IRModel, inherited: dict[str, IRFie
has_default=False,
omittable=False,
constraints=dict(base_field.constraints),
# Judged against *this* model's base chain, not the one the copied
# field was flagged for; ``_mark_unsafe_overrides`` runs next.
incompatible_override=False,
)
)

Expand Down Expand Up @@ -938,79 +947,43 @@ def _build_object(self, name: str, schema: dict[str, Any], base_uri: str) -> IRM
model.additional_properties = ANY
return model

def _drop_unsafe_overrides(self, model: IRModel, inherited: dict[str, IRField]) -> None:
"""Remove re-declared inherited fields that would not type-check as overrides.
def _mark_unsafe_overrides(
self,
model: IRModel,
inherited: dict[str, IRField],
by_name: dict[str, Declaration],
) -> None:
"""Flag re-declared inherited fields whose type the base does not admit.

Specs routinely restate a base property in a subtype just to attach prose, or to
relax it to nullable. Re-emitting those produces ``class Sub(Base)`` with an
attribute whose type is not a subtype of the base's, which ``mypy --strict``
rejects outright (``Incompatible types in assignment``). The base's declaration
already covers the field, so anything that is not a genuine narrowing is
dropped and simply inherited.
Every property a subtype declares stays on the subtype, so the flag records
only that the spec asked for something ``class Sub(Base)`` cannot express
soundly; the serializers decide what to emit for it.

This also covers the pinned discriminator tag: ``_apply_discriminator_tag``
adds it after the model is built, and a base that types the tag property as an
enum (or as its own narrower ``Literal``) does not admit a subtype's
``Literal`` over it.
A spec that does this is inconsistent -- the subtype is not substitutable for
its base -- and that is worth fixing in the spec rather than in its clients, so
every occurrence is logged with the schema and property it comes from.
"""
kept: list[IRField] = []
for f in model.fields:
base_field = inherited.get(f.wire_name)
if base_field is None or self._is_narrowing(f.type, base_field.type):
kept.append(f)
# Always assigned, never only set: ``_restore_required_narrowing`` copies
# fields down from the base chain, flag and all, and the copy is judged
# against a different base than the original was.
f.incompatible_override = False
if base_field is None:
continue
# Same annotation is always a legal override, so a restatement survives
# whenever it carries something the base does not.
if f.type.annotation() == base_field.type.annotation() and self._refines(f, base_field):
kept.append(f)
if f.type.annotation() == base_field.type.annotation() or self._is_narrowing(
f.type, base_field.type, by_name
):
continue
widened = isinstance(f.type, OptionalType) and not isinstance(
base_field.type, OptionalType
f.incompatible_override = True
logger.warning(
"%s re-declares %r as %s, which the inherited %s does not admit; "
"the subtype is not substitutable for its base -- fix the schema",
model.name,
f.wire_name,
f.type.annotation(),
base_field.type.annotation(),
)
if f.type.annotation() == base_field.type.annotation() or widened:
# A verbatim restatement (spec prose, usually): the base's declaration
# already says everything this one does, so nothing is lost by
# inheriting it and there is nothing for the user to act on.
# Restating a property to attach prose, or to relax it to nullable, is
# something specs do routinely and the README documents as inherited --
# so it is not something the user can or should act on.
logger.debug(
"%s.%s restates inherited %s (%s); inheriting instead",
model.name,
f.name,
f.wire_name,
f.type.annotation(),
)
else:
# This one *does* lose information -- the subtype asked for a type the
# base does not admit, and the generated client will use the base's.
logger.warning(
"%s.%s re-declares %s incompatibly (%s vs %s); inheriting instead",
model.name,
f.name,
f.wire_name,
f.type.annotation(),
base_field.type.annotation(),
)
model.fields = kept

@staticmethod
def _refines(sub: IRField, base: IRField) -> bool:
"""Whether a same-annotation restatement carries something the base lacks.

An identical annotation is always a legal override, so the only question is
whether re-emitting it changes anything:

- a different ``default`` -- dropping it hands the subtype the base's value;
- different ``constraints`` -- ``maxLength``/``pattern``/``minimum`` are
enforced by every serializer, so losing them makes the generated client
accept payloads the API will reject;
- stricter requiredness -- see ``_restore_required_narrowing``.
"""
changes_default = sub.has_default and (not base.has_default or sub.default != base.default)
return (
changes_default or sub.constraints != base.constraints or sub.required != base.required
)

@staticmethod
def _rename_shadowed_fields(model: IRModel, inherited: dict[str, IRField]) -> None:
Expand Down Expand Up @@ -1054,23 +1027,36 @@ def _align_override_names(model: IRModel, inherited: dict[str, IRField]) -> None
f.name = base_field.name

@classmethod
def _is_narrowing(cls, sub: IRType, base: IRType) -> bool:
def _is_narrowing(
cls, sub: IRType, base: IRType, by_name: Mapping[str, Declaration] | None = None
) -> bool:
"""Whether ``sub`` is safe to re-declare over an inherited ``base`` annotation.

Deliberately conservative: it only says yes for the shapes that are provably
assignable, because a false yes emits code that fails ``mypy --strict`` while a
false no merely inherits a slightly less precise type.
Only says yes for shapes that are provably assignable, and a no is answered by
emitting the declaration under a suppression comment rather than by dropping
it -- so a false no costs a line of noise in the output, not a lost property.
The suppression carries ``unused-ignore`` for exactly that reason.

``by_name`` resolves ``$ref`` narrowings: ``Dog`` over ``Animal`` is assignable
only if the generated ``Dog`` really does subclass ``Animal``, which nothing in
either annotation says. Omitting it just makes those answer no.
"""
if sub.annotation() == base.annotation():
return False # a pure restatement: nothing to gain, just inherit it
if isinstance(base, PrimitiveType) and base.py == "Any":
return True
if isinstance(sub, PrimitiveType) and isinstance(base, PrimitiveType):
return (sub.py, base.py) in _PROMOTIONS
if isinstance(sub, RefType) and isinstance(base, RefType):
return cls._extends(sub.name, base.name, by_name)
if isinstance(sub, OptionalType) and isinstance(base, OptionalType):
# ``T | None`` narrows ``U | None`` exactly when ``T`` narrows ``U``.
return cls._is_narrowing(sub.inner, base.inner)
return cls._is_narrowing(sub.inner, base.inner, by_name)
if isinstance(base, OptionalType):
# ``T | None`` admits ``T`` and anything that narrows ``T``.
return sub.annotation() == base.inner.annotation() or cls._is_narrowing(sub, base.inner)
return sub.annotation() == base.inner.annotation() or cls._is_narrowing(
sub, base.inner, by_name
)
if isinstance(sub, OptionalType):
return False # adding None to a non-optional base widens it
if isinstance(sub, LiteralType):
Expand All @@ -1084,9 +1070,28 @@ def _is_narrowing(cls, sub: IRType, base: IRType) -> bool:
return isinstance(base, PrimitiveType) and cls._literals_fit(sub.values, base.py)
if isinstance(base, UnionType):
return any(
cls._is_narrowing(sub, m) or sub.annotation() == m.annotation()
cls._is_narrowing(sub, m, by_name) or sub.annotation() == m.annotation()
for m in base.members
)
# Containers land here: ``list``/``dict`` are invariant, so a narrowed element
# type is not a narrowed container.
return False

@staticmethod
def _extends(sub: str, base: str, by_name: Mapping[str, Declaration] | None) -> bool:
"""Whether the model named ``sub`` inherits from ``base``, however deep."""
if by_name is None:
return False
seen: set[str] = set()
current: str | None = sub
while current is not None and current not in seen:
seen.add(current)
decl = by_name.get(current)
if not isinstance(decl, IRModel):
return False
if decl.base_model == base:
return True
current = decl.base_model
return False

@staticmethod
Expand Down
7 changes: 7 additions & 0 deletions src/unihttp_openapi_generator/ir/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ class is reported by ``runtime_refs`` so the module imports it at runtime.
read_only: bool = False
write_only: bool = False
constraints: dict[str, Any] = field(default_factory=dict)
incompatible_override: bool = False
"""Whether this field re-declares an inherited one with a type the base does not admit.

A fact about the spec, not a rendering decision: the subtype asked for something
``class Sub(Base)`` cannot express soundly. What to do about it belongs to the
serializer strategies (see ``override_suppression``).
"""

@property
def needs_alias(self) -> bool:
Expand Down
3 changes: 2 additions & 1 deletion src/unihttp_openapi_generator/render/serializers/adaptix.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ def render_model(self, model: IRModel) -> str:
lines.append(" pass")
inherited = self.inherited_field_names(model)
for f in fields:
lines.append(" " + self._field_line(f.name, f.type.annotation(), f, inherited))
line = self._field_line(f.name, f.type.annotation(), f, inherited)
lines.append(" " + line + self.override_suppression(f))
return "\n".join(lines)

@staticmethod
Expand Down
17 changes: 17 additions & 0 deletions src/unihttp_openapi_generator/render/serializers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,23 @@ def inherited_field_names(self, model: IRModel) -> set[str]:
current = parent.base_model
return names

@staticmethod
def override_suppression(f: IRField) -> str:
"""Trailing comment for a field the subtype re-declares incompatibly.

``unused-ignore`` is listed alongside ``assignment`` on purpose: the builder
decides whether an override is compatible from the IR alone, and the IR is a
coarser view of the type than mypy's. It has no notion of the numeric tower
beyond ``int``/``float``, and a ``$ref`` narrowed to a schema that is itself a
subtype resolves to a plain ``RefType`` whose relation to the base's is not
visible in the annotation. Whenever the builder is stricter than mypy the bare
``[assignment]`` ignore would be unused, which ``--strict`` reports as an error
of its own -- so the comment silences its own redundancy.
"""
if not f.incompatible_override:
return ""
return " # type: ignore[assignment, unused-ignore]"

# -- imports ---------------------------------------------------------------

@abstractmethod
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def render_model(self, model: IRModel) -> str:
lines.append(doc.rstrip("\n"))
fields = sorted(model.fields, key=self._sort_key)
for f in fields:
lines.append(" " + self._field_line(f))
lines.append(" " + self._field_line(f) + self.override_suppression(f))
if not fields and not doc:
lines.append(" pass")
return "\n".join(lines)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ def render_model(self, model: IRModel) -> str:
# ``model_config`` used to keep the body non-empty for free.
lines.append(" pass")
for f in model.fields:
lines.append(" " + self._field_line(f))
lines.append(" " + self._field_line(f) + self.override_suppression(f))
return "\n".join(lines)

def _field_line(self, f: IRField) -> str:
Expand Down
41 changes: 39 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,9 @@ def hierarchy_spec() -> dict[str, Any]:
real defect at some point: a discriminated base that keeps its own properties, a
tag the base types as an enum (so the subtype's ``Literal`` is not assignable),
restatements that only add prose / relax to nullable / change a default, wire names
that snake-case onto an inherited identifier, a three-level chain, and a base whose
own body refers back to its subtype.
that snake-case onto an inherited identifier, a three-level chain, a base whose own
body refers back to its subtype, and narrowings only the base chain or the numeric
tower can justify.
"""
return {
"openapi": "3.1.0",
Expand Down Expand Up @@ -339,6 +340,42 @@ def hierarchy_spec() -> dict[str, Any]:
{"properties": {"value": {"type": "string"}}},
]
},
# A subtype narrowing a property to a schema that subclasses the base's:
# nothing in either annotation says ``Pet`` is a ``Creature``, so the
# override reads as incompatible unless the base chain is consulted.
"Creature": {
"type": "object",
"required": ["name"],
"properties": {"name": {"type": "string"}},
},
"Pet": {
"allOf": [
{"$ref": "#/components/schemas/Creature"},
{"properties": {"tame": {"type": "boolean"}}},
]
},
"Owner": {
"type": "object",
"required": ["companion", "score"],
"properties": {
"companion": {"$ref": "#/components/schemas/Creature"},
"score": {"type": "number"},
},
},
"PetOwner": {
"allOf": [
{"$ref": "#/components/schemas/Owner"},
{
"type": "object",
"required": ["companion", "score"],
"properties": {
"companion": {"$ref": "#/components/schemas/Pet"},
# the numeric tower: ``integer`` narrows ``number``
"score": {"type": "integer"},
},
},
]
},
}
},
}
Loading
Loading