Skip to content

feat: inheritance mode, body-field descriptions, keep soft keywords - #5

Draft
K1rL3s wants to merge 2 commits into
goduni:mainfrom
K1rL3s:feat/inheritance-and-body-descriptions
Draft

feat: inheritance mode, body-field descriptions, keep soft keywords#5
K1rL3s wants to merge 2 commits into
goduni:mainfrom
K1rL3s:feat/inheritance-and-body-descriptions

Conversation

@K1rL3s

@K1rL3s K1rL3s commented Jul 23, 2026

Copy link
Copy Markdown

Three additions, driven by generating a hand-written client's shape:

  • --inheritance renders allOf: [{$ref: Base}, ...] as a real base class instead of merging the parent's fields into every subtype. A discriminated base stays a model (its own properties survive) and its mapped subtypes inherit from it, re-declaring only the discriminator tag. Declarations are emitted parent-first so a class Sub(Base) statement resolves, and model constructors become keyword-only because a subclass may pin an inherited field to a default while adding required fields of its own.

  • IRBodyField.description carries the schema description of a spread request body field, which was silently dropped. IRParameter already had it.

  • sanitize_identifier no longer suffixes soft keywords: type is a legal attribute name and an extremely common spec field, so type_ was noise. _ stays reserved.

Verified on a real 130-schema spec: both file layouts x all three serializers generate code that passes ruff --isolated and mypy --strict.

Description

Please include a summary of the change and specify which issue is being addressed. Additionally, provide relevant motivation and context.

Fixes # (issue number)

Type of change

Please delete options that are not relevant.

  • Documentation (typos, code examples, or any documentation updates)
  • Bug fix (a non-breaking change that resolves an issue)
  • New feature (a non-breaking change that adds functionality)
  • Breaking change (a fix or feature that would disrupt existing functionality)
  • This change requires a documentation update

Checklist

  • My code adheres to the style guidelines of this project (uv run ruff check and uv run ruff format --check show no errors)
  • I have conducted a self-review of my own code
  • I have made the necessary changes to the documentation
  • My changes do not generate any new warnings
  • I have added tests to validate the effectiveness of my fix or the functionality of my new feature
  • I have ensured that type checking passes by running uv run mypy
  • I have included code examples to illustrate the modifications

K1rL3s added 2 commits July 23, 2026 17:37
Three additions, driven by generating a hand-written client's shape:

- `--inheritance` renders `allOf: [{$ref: Base}, ...]` as a real base class
  instead of merging the parent's fields into every subtype. A discriminated
  base stays a model (its own properties survive) and its mapped subtypes
  inherit from it, re-declaring only the discriminator tag. Declarations are
  emitted parent-first so a `class Sub(Base)` statement resolves, and model
  constructors become keyword-only because a subclass may pin an inherited
  field to a default while adding required fields of its own.

- `IRBodyField.description` carries the schema description of a spread request
  body field, which was silently dropped. `IRParameter` already had it.

- `sanitize_identifier` no longer suffixes soft keywords: `type` is a legal
  attribute name and an extremely common spec field, so `type_` was noise.
  `_` stays reserved.

Verified on a real 130-schema spec: both file layouts x all three serializers
generate code that passes `ruff --isolated` and `mypy --strict`.
Review of 566dc7c found the inheritance path silently changing decoded data
in several shapes. Fixes, most severe first:

- A `oneOf` + `discriminator` union holder is no longer force-built as a class.
  It declares no properties, so `--inheritance` produced `class Button: pass`
  and every `list[Button]` payload decoded into it, dropping each variant's
  fields. It stays a union alias; only bases with their own properties become
  classes. Verified: pydantic again decodes `CallbackButton(payload=...)` and
  `isinstance(x, ButtonBase)` still holds.

- The pinned discriminator tag is reserved against the subtype's existing field
  names. A sibling property whose wire name only differs in case (`Type` vs
  `type`) snake-cases to the same identifier, and the two class attributes
  collapsed into one -- destroying the tag, with ruff reporting nothing.

- A subtype that restates an inherited property only to attach prose, or to
  relax it to nullable, now inherits it instead of emitting an override.
  `v: str | None` over the base's `v: str` is an `[assignment]` error under
  `mypy --strict`, so `--inheritance --check` failed on ordinary specs. Genuine
  narrowings (a `Literal` tag over a `str`) are kept.

- The base class is resolved from the schema, not from the half-built
  `_declarations` registry. A base whose own body refers back to its subtype
  (a recursive hierarchy) has no entry yet, so inheritance silently degraded to
  a field merge based on nothing but graph traversal order.

- Keyword-only constructors are limited to the models in a hierarchy. One
  `allOf` subtype used to flip every model in the package, breaking positional
  construction for models with no relation to it.

- A discriminated base kept as a class emits its mapping as a comment. No
  serializer resolves a subtype from a base-class annotation on its own, and
  `IRModel.discriminator` was read by nobody, so that was dropped on the floor.

- `IRBodyField.description` (and `IRParameter.description`) now reach the
  generated code as PEP 258 attribute docstrings. Both were carried through the
  IR and rendered nowhere.

Also: `IRModel.base` -> `base_model`, so it can't be confused with
`IREnum.base` (the enum's "str"/"int" value type); soft keywords use an
allow-list over `keyword.issoftkeyword` so a future Python's new soft keyword
stays guarded; `_ordered_declarations` breaks an inheritance cycle instead of
emitting a class before its base; the two discriminator-mapping loops share one
helper; `_inherited_ref` is computed once and passed down.

Verified: both file layouts x all three serializers generate code that passes
`ruff check --isolated` and `mypy --strict` (only the pre-existing
`BaseMethod.__init_subclass__` no-untyped-call remains).
@K1rL3s
K1rL3s force-pushed the feat/inheritance-and-body-descriptions branch from 32d314d to 53f0765 Compare July 23, 2026 14:39
@K1rL3s

K1rL3s commented Jul 23, 2026

Copy link
Copy Markdown
Author

Review of 566dc7c → fixes in 53f0765

Went through the diff for recall. Found 12 issues, all fixed in a follow-up commit. What was breaking, and what it does now.

Critical: silently corrupted decoded data

1. oneOf + discriminator was turned into an empty class

The most common polymorphism shape in OpenAPI is a union holder with no properties of its own:

Button:
  oneOf: [CallbackButton, LinkButton]
  discriminator: {propertyName: type, mapping: {...}}

The _build_named branch went into _build_object unconditionally, so --inheritance produced:

@dataclass(kw_only=True)
class Button:
    pass

@dataclass(kw_only=True)
class Keyboard:
    buttons: list[Button]     # ← everything decoded into this, and everything was lost

There is nothing to inherit from such a schema. Only a base that declares its own properties (_is_object) becomes a class now; the rest stays a union alias. Verified: pydantic again returns CallbackButton(payload='P'), while isinstance(x, ButtonBase) still holds — both goals of the feature coexist.

2. The discriminator tag was overwritten by a sibling field

_apply_discriminator_tag inserted the field bypassing the model's field_names registry. If a subtype declared Type while propertyName was type, both snake-cased to the same identifier:

class A(B):
    type: Literal['a'] = 'a'
    type: int | None = None      # ← the second one wins

ruff --isolated lets this through (F811 does not fire on annotated class attributes) and dataclasses.fields(A) shows only int | None. The tag was destroyed, and name_mapping mapped type -> 'Type' — the wrong wire name. The name is now reserved against the fields already on the model.

3. Re-declaring an inherited field broke mypy --strict

Specs routinely restate a base property to attach prose or to allow null. That produced an unsound override:

error: Incompatible types in assignment (expression has type "str | None",
base class "P" defined the type as "str")  [assignment]

So --inheritance --check failed on ordinary specs. Pure restatements and widenings are now inherited; genuine narrowings (a Literal over a str) are kept. _is_narrowing is deliberately conservative: a false yes emits code that does not type-check, a false no merely inherits a slightly less precise type.

4. Inheritance depended on graph traversal order

The base was looked up in self._declarations, which has no entry yet while the base itself is being built. If the base's own body refers back to its subtype (a recursive hierarchy), the entry was missing:

before: LeafNode base=None  fields=['id', 'child', 'value']   ← merged, --inheritance did nothing
after:  LeafNode base=Node  fields=['value']

The decision is now made from the schema (_declares_model), not from a half-built registry.

The rest

5. kw_only is now per-model. A single allOf subtype used to flip every model in the package to keyword-only, breaking positional construction for models unrelated to any hierarchy. Only hierarchy members are marked now; Keyboard from the example stays a plain @dataclass.

6. IRModel.discriminator was read by nobody. For a base kept as a class, no serializer resolves the concrete subtype from a base-class annotation on its own. The mapping is now emitted as a comment — exactly what you need to wire tagged decoding in _serialization.py. The limitation is documented in the README.

7. IRBodyField.description was not rendered. The commit message says it "was silently dropped" — but the drop point had only moved from the builder to the renderer: no template reads field descriptions. Both IRBodyField.description and IRParameter.description now reach the generated code as PEP 258 attribute docstrings, without touching the constructor signature:

title: Body[str]
"""Doc title."""
type: Query[Omittable[str]] = Omitted()
"""Kind of send."""

8. IRModel.basebase_model. IREnum.base is the enum's value type ("str"/"int"). Both new call sites already guarded with isinstance(...), but any future getattr(decl, "base", None) loop would read an enum's "str" as a superclass name.

9. Soft keywords use an allow-list. frozenset({"_"}) in place of keyword.issoftkeyword() would stop catching whatever word the next Python release promotes (that is how type arrived in 3.12). It is now issoftkeyword(c) and c not in {"type", "match", "case"} — the reviewed names are hardcoded, not the forbidden ones.

10-12. _ordered_declarations breaks an inheritance cycle explicitly (marking visited before recursing prevented infinite recursion but not the wrong emit order); the two discriminator.mapping traversals share one _convert_mapped_subtypes helper; _inherited_ref is computed once and passed into _flatten_object instead of a keep_base flag, so the caller's decision and the merge cannot disagree.

Verification

  • 208 tests green (+6 regression tests, one per bug 1-4, +1 for per-model kw_only, +1 for mapping visibility, +1 for attribute docstrings).
  • ruff format --check, ruff check, mypy --strict clean across the repo.
  • End-to-end generation: both file layouts × all three serializers → the output passes ruff check --isolated and mypy --strict. The only remaining error is BaseMethod.__init_subclass__ [no-untyped-call], which comes from unihttp and predates this PR.

One call worth a second opinion

For the widening case (item 3) I chose to inherit the base's declaration, which means a subtype's nullable: true is dropped. The alternative is to keep the override and accept the mypy failure. I went with code that type-checks, but if annotation fidelity matters more for your specs, that is a one-line flip in _is_narrowing.

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