Skip to content

ModelModule Gap Tests Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Bring src/oapi/model.py's ModelModule/_Modeler (the OpenAPI-to-Python-model code generator) from 69% to a moderately higher whole-file coverage, per docs/superpowers/specs/2026-08-01-test-coverage-design.md's final step ("model.py ModelModule gaps"). This is the last plan in the test-coverage initiative.

Architecture: model.py already started this initiative at a much higher baseline (69%, vs. client.py's 16%) thanks to tests/ test_model.py's existing real-world-document exercise (test_openapi_ examples, test_languagetool). This plan targets the remaining gap with two complementary, appropriately-scoped approaches rather than chasing every one of _Modeler's ~1300 lines (a deliberate choice -- see Global Constraints for what's excluded and why):

  • tests/test_model_module_helpers.py -- direct unit tests for the isolable free functions above _Modeler: _get_schema_type, _types_from_enum_values, _append_property_type, get_default_class_name_from_pointer (cases beyond its own docstring examples, which already run via --doctest-modules).
  • tests/test_model_module_empty_schema.py -- documents a real, verified, currently-unfixed bug (see Global Constraints) found while building the prior (ClientModule) plan.
  • tests/test_model_module_polymorphic_schemas.py -- black-box: generates a real model module from tests/input-data/polymorphic- schemas.json (built in the infrastructure plan specifically "for testing oapi.model's _Modeler merge/polymorph code paths", per its own description field, and only partially exercised so far -- the ClientModule plan used it for response-type resolution, not for directly inspecting the generated model classes) via the generated_client_package fixture (from the ClientModule plan), and inspects/exercises the real generated classes: allOf merging (properties and required combined from every member schema, with real validation enforcement), enum-to-EnumeratedProperty generation (with real, eager unmarshal-time rejection of invalid values -- confirmed live, not assumed, to raise at construction rather than only at sob.validate() time), oneOf/anyOf producing genuinely independent (non-merged) classes, and additionalProperties generating a real sob.Dictionary subclass.

Tech Stack: Python 3.10, pytest, stdlib, sob, the generated_client_package fixture (tests/conftest.py, added in the ClientModule plan -- reused here since it already handles the model-module half of code generation, even though this plan doesn't need the paired client module).

Global Constraints

  • Line length 79 (ruff + black), enforced via hatch fmt --check.
  • mypy strict on tests/: explicit type annotations on every local variable, per this project's standing preference. All code verified against a real hatch run mypy --strict --ignore-missing-imports run. sob.read_object_meta(...) returns ObjectMeta | None, and ObjectMeta.properties is Properties | None -- both are narrowed with explicit assert ... is not None lines in every test that reads them (not a new exception to the annotation rule, just real Optional types that need narrowing, same pattern used for Client.parts/ .headers in an earlier plan).
  • Prefer real integration over mocks: no unittest.mock/pytest-mock anywhere in any of the three new files. Every generated-class test in tests/test_model_module_polymorphic_schemas.py inspects a real class generated by a real ModelModule run and loaded via real importlib; every validation assertion calls the real sob.validate/ real class constructor, not a stand-in.
  • A real, verified, currently-unfixed bug is documented, not "fixed": an OpenAPI document with no components/schemas (every response schema inline and primitive-shaped) generates a model.py that cannot be imported. ModelModule.get_module_source (model.py) builds its imports set purely from each generated class's own source text; when zero classes are generated, that set stays empty, so neither from __future__ import annotations nor import sob get emitted -- but the module's own trailing _POINTERS_CLASSES: dict[str, type[sob.abc.Model]] = {...} line always references sob.abc.Model in a variable annotation regardless, and without the lazy-annotation future import, sob is genuinely undefined at execution. Verified live: exec()-ing the generated source for a real minimal empty-schema document raises NameError: name 'sob' is not defined. This is the same bug already discovered (and worked around, via one added fixture schema) while building the prior ClientModule plan -- this plan adds the first direct regression test for ModelModule itself. A genuine testing subtlety was hit and corrected while validating this test: compile(source, "<generated>", "exec") called from inside a test file that itself has from __future__ import annotations silently inherits that future-import flag (compile's dont_inherit parameter defaults to False), which makes the generated _POINTERS_CLASSES line's annotation lazy and masks the real bug -- caught because the test failed to reproduce the error the first time it was run, not assumed. The fix is dont_inherit=True, documented in the test's own comment.
  • Deliberately out of scope for this plan (diminishing returns, a reasonable stopping point for the initiative's final piece): the remaining missing lines in _Modeler (roughly lines 300-1650) represent deep internal state-tracking across a ~1300-line class (pointer/class-name bookkeeping, $ref resolution edge cases, format-specific type mapping for every OpenAPI format value, _ModuleParser's re-generation-preserving-custom-code logic). Real code, but reaching significantly further would mean either building several more purpose-specific fixtures (this plan's black-box strategy scales with fixture variety, and only one -- polymorphic- schemas.json -- was available and unused for this purpose) or unit-testing _Modeler's internals directly against hand-built partial state, which reintroduces the brittle/tautological problem already avoided for ClientModule. This plan's coverage delta (Task 4) reflects a real, if more modest, improvement -- the initiative overall has already taken client.py from 16% to 83% and model.py from a 69% starting point; this is a reasonable point to close out the initiative rather than keep expanding scope.
  • Verify commands: hatch run hatch-test.py3.10:pytest tests/test_model_module_helpers.py tests/test_model_module_empty_schema.py tests/test_model_module_polymorphic_schemas.py -v (26 tests total: 16 + 1 + 9). hatch run mypy --strict --ignore-missing-imports <file> per file -- expect a fully clean result (no pre-existing findings; these three files don't import tests/servers.py).
  • Commit scope: this working tree has unrelated pre-existing staged files (.claude/settings.json, .claude/skills/fableplan/SKILL.md, AGENTS.md, .gitignore) belonging to other work-in-progress -- never include them in a commit. Never a bare git commit -m "..." -- always git commit <exact-path(s)> -m "..." (paths before -m). Never git add -A/./-u, never git reset in any form to self-fix a mistake -- if a commit's scope looks wrong, stop and report it. Never touch src/oapi/model.py or tests/conftest.py -- this plan only adds new test files, reusing the existing generated_ client_package fixture from the ClientModule plan unmodified.
  • Known, out-of-scope, pre-existing issue: running the full suite (hatch test -c or hatch run hatch-test.py3.10:pytest tests/) has the unrelated, pre-existing side effect of test_languagetool re-downloading and overwriting tests/input-data/languagetool- swagger.json; run git checkout -- tests/input-data/languagetool- swagger.json afterward if you ran the full suite, before checking git status --porcelain.

Task 1: tests/test_model_module_helpers.py

Files: - Create: tests/test_model_module_helpers.py

Interfaces: - Consumes: oapi.model._get_schema_type, _types_from_enum_values, _append_property_type, get_default_class_name_from_pointer (existing). oapi.oas.model.Items, Schema (existing). - Produces: tests/test_model_module_helpers.py, 16 tests.

  • [ ] Step 1: Write the test file

Create tests/test_model_module_helpers.py:

from __future__ import annotations

from datetime import date, datetime

import sob

from oapi.model import (
    _append_property_type,
    _get_schema_type,
    _types_from_enum_values,
    get_default_class_name_from_pointer,
)
from oapi.oas.model import Items, Schema


def test_get_schema_type_returns_the_explicit_type() -> None:
    assert _get_schema_type(Schema({"type": "string"})) == "string"


def test_get_schema_type_infers_object_from_properties() -> None:
    schema: Schema = Schema({"properties": {"a": {"type": "string"}}})
    assert _get_schema_type(schema) == "object"


def test_get_schema_type_infers_object_from_additional_properties() -> None:
    schema: Schema = Schema({"additionalProperties": {"type": "string"}})
    assert _get_schema_type(schema) == "object"


def test_get_schema_type_infers_array_from_items() -> None:
    schema: Schema = Schema({"items": {"type": "string"}})
    assert _get_schema_type(schema) == "array"


def test_get_schema_type_returns_none_when_nothing_is_inferable() -> None:
    assert _get_schema_type(Schema({})) is None


def test_get_schema_type_accepts_an_items_instance() -> None:
    items: Items = Items({"items": {"type": "string"}})
    assert _get_schema_type(items) == "array"


def test_types_from_enum_values_maps_python_types() -> None:
    types: sob.abc.Types = _types_from_enum_values(["a", "b", 1])
    assert list(types) == [str, int]


def test_types_from_enum_values_maps_date_and_datetime_specially() -> None:
    types: sob.abc.Types = _types_from_enum_values(
        [date(2024, 1, 1), datetime(2024, 1, 1)]
    )
    represented: list[str] = [
        sob.utilities.represent(type_) for type_ in types
    ]
    assert represented == [
        sob.utilities.represent(sob.DateProperty()),
        sob.utilities.represent(sob.DateTimeProperty()),
    ]


def test_types_from_enum_values_deduplicates_by_type() -> None:
    types: sob.abc.Types = _types_from_enum_values(["a", "b", "c"])
    assert list(types) == [str]


def test_append_property_type_adds_a_new_type() -> None:
    property_: sob.abc.Property = sob.Property()
    result: sob.abc.Property = _append_property_type(property_, str)
    assert list(result.types or ()) == [str]


def test_append_property_type_accumulates_distinct_types() -> None:
    property_: sob.abc.Property = _append_property_type(sob.Property(), str)
    property_ = _append_property_type(property_, int)
    assert list(property_.types or ()) == [str, int]


def test_append_property_type_is_idempotent_for_the_same_type() -> None:
    property_: sob.abc.Property = _append_property_type(sob.Property(), str)
    property_ = _append_property_type(property_, str)
    assert list(property_.types or ()) == [str]


def test_append_property_type_maps_date_and_datetime_specially() -> None:
    property_: sob.abc.Property = _append_property_type(
        sob.Property(), datetime
    )
    types: list[str] = [
        sob.utilities.represent(type_) for type_ in (property_.types or ())
    ]
    assert types == [sob.utilities.represent(sob.DateTimeProperty())]


def test_get_default_class_name_from_pointer_for_a_named_schema() -> None:
    result: str = get_default_class_name_from_pointer(
        "#/components/schemas/Foo"
    )
    assert result == "Foo"


def test_get_default_class_name_from_pointer_for_a_200_response() -> None:
    result: str = get_default_class_name_from_pointer(
        "#/paths/~1foo/get/responses/200/content/application~1json/schema"
    )
    assert result == "FooGetResponse"


def test_get_default_class_name_from_pointer_for_a_non_200_response() -> None:
    result: str = get_default_class_name_from_pointer(
        "#/paths/~1foo/get/responses/404/schema"
    )
    assert result == "FooGetResponse404"
  • [ ] Step 2: Verify

Run:

hatch run hatch-test.py3.10:pytest tests/test_model_module_helpers.py -v
hatch run mypy --strict --ignore-missing-imports tests/test_model_module_helpers.py
hatch fmt --formatter
hatch fmt --check
Expected: 16 passed; mypy Success: no issues found in 1 source file; hatch fmt --check clean. Every assertion's expected value was produced by actually calling the real function -- note that a 17th planned test for _get_model_import_class_source was dropped during validation: calling it on an already-imported, already-defined class (rather than mid-generation, inside _Modeler's own live process) produces a different split than expected (the whole class landed in the "imports" half, the "class source" half was empty) -- the function is already fully, transitively exercised by every generated-module test in this plan and the prior ClientModule plan, so it wasn't worth reconstructing _Modeler's internal generation-time state just to unit -test this one helper in isolation.

  • [ ] Step 3: Commit
git add tests/test_model_module_helpers.py
git commit tests/test_model_module_helpers.py -m "test: add coverage for ModelModule's isolable schema/type/naming helper functions"

Task 2: tests/test_model_module_empty_schema.py

Files: - Create: tests/test_model_module_empty_schema.py

Interfaces: - Consumes: oapi.model.ModelModule, oapi.oas.model.OpenAPI (existing). - Produces: tests/test_model_module_empty_schema.py, 1 test.

  • [ ] Step 1: Write the test file

Create tests/test_model_module_empty_schema.py:

from __future__ import annotations

import typing

import pytest

from oapi.model import ModelModule
from oapi.oas.model import OpenAPI


def test_a_document_with_no_named_schemas_generates_unimportable_source() -> (
    None
):
    """
    Documents a real, verified, currently-unfixed bug: an OpenAPI
    document with no `components/schemas` (every response schema
    inline and primitive-shaped, e.g. bare `{"type": "object"}`)
    generates a `model.py` that cannot actually be imported.
    `ModelModule.get_module_source` builds its `imports` set purely
    from each *generated class's own* source text -- when there are
    zero generated classes, that set stays empty, so neither `from
    __future__ import annotations` nor `import sob` gets emitted. But
    the module's own trailing `_POINTERS_CLASSES: dict[str,
    type[sob.abc.Model]] = {...}` line always references `sob.abc.
    Model` in a variable annotation regardless -- and without `from
    __future__ import annotations` to make that annotation lazy, `sob`
    is genuinely undefined when the module is executed. Not fixed here
    (out of this test-only initiative's scope) -- flagged to the user
    directly as well as documented here.
    """
    open_api_data: dict[str, typing.Any] = {
        "openapi": "3.0.3",
        "info": {"title": "t", "version": "1"},
        "paths": {
            "/foo": {
                "get": {
                    "responses": {
                        "200": {
                            "description": "OK",
                            "content": {
                                "application/json": {
                                    "schema": {"type": "object"}
                                }
                            },
                        }
                    }
                }
            }
        },
    }
    open_api: OpenAPI = OpenAPI(open_api_data)
    model: ModelModule = ModelModule(open_api)
    source: str = str(model)
    assert "import sob" not in source
    assert "from __future__ import annotations" not in source
    assert "sob.abc.Model" in source
    # `dont_inherit=True` is required here: without it, `compile()`
    # inherits the *calling* module's `__future__` flags -- since this
    # test file itself has `from __future__ import annotations`, that
    # would silently make `_POINTERS_CLASSES`'s annotation lazy (PEP
    # 563) even though the generated source string has no such import
    # of its own, masking the real bug. A real `import`/`importlib`
    # load of this generated file (as every other test in this
    # initiative does) never has this problem, since each file's
    # `__future__` behavior is self-contained -- `dont_inherit=True`
    # makes `exec()` behave the same way, for a faithful reproduction.
    with pytest.raises(NameError, match="sob"):
        exec(  # noqa: S102
            compile(source, "<generated>", "exec", dont_inherit=True), {}
        )
  • [ ] Step 2: Verify

Run:

hatch run hatch-test.py3.10:pytest tests/test_model_module_empty_schema.py -v
hatch run mypy --strict --ignore-missing-imports tests/test_model_module_empty_schema.py
hatch fmt --formatter
hatch fmt --check
Expected: 1 passed; mypy Success: no issues found in 1 source file; hatch fmt --check clean. Pay close attention to the dont_inherit= True argument in the compile() call and its explanatory comment -- transcribe it exactly; omitting it makes the test pass for the wrong reason (silently not reproducing the real bug at all) rather than fail.

  • [ ] Step 3: Commit
git add tests/test_model_module_empty_schema.py
git commit tests/test_model_module_empty_schema.py -m "test: document that an empty-schema OpenAPI document generates an unimportable model module"

Task 3: tests/test_model_module_polymorphic_schemas.py

Files: - Create: tests/test_model_module_polymorphic_schemas.py

Interfaces: - Consumes: the generated_client_package fixture (existing, from the ClientModule plan). tests/input-data/polymorphic-schemas.json (existing, from the infrastructure plan). - Produces: tests/test_model_module_polymorphic_schemas.py, 9 tests.

  • [ ] Step 1: Write the test file

Create tests/test_model_module_polymorphic_schemas.py:

from __future__ import annotations

from collections.abc import Callable
from types import ModuleType

import pytest
import sob

from oapi.oas.model import OpenAPI


@pytest.fixture
def polymorphic_model(
    generated_client_package: Callable[
        [OpenAPI], tuple[ModuleType, ModuleType]
    ],
) -> ModuleType:
    with open("tests/input-data/polymorphic-schemas.json") as f:
        open_api: OpenAPI = OpenAPI(f)
    model_module, _client_module = generated_client_package(open_api)
    return model_module


def test_allof_merges_properties_from_every_member_schema(
    polymorphic_model: ModuleType,
) -> None:
    """
    `Pet` is `allOf: [NamedEntity, {species, status}]` -- the generated
    class should have properties from both member schemas merged into
    one, including `NamedEntity`'s `required: [name]`.
    """
    meta: sob.abc.ObjectMeta | None = sob.read_object_meta(
        polymorphic_model.Pet
    )
    assert meta is not None
    assert meta.properties is not None
    assert set(meta.properties.keys()) == {"name", "species", "status"}
    assert meta.properties["name"].required is True


def test_allof_merged_class_enforces_the_merged_required_property(
    polymorphic_model: ModuleType,
) -> None:
    pet: sob.abc.Object = polymorphic_model.Pet({"species": "dog"})
    with pytest.raises(sob.errors.ValidationError, match="name"):
        sob.validate(pet)


def test_allof_merged_class_accepts_a_fully_populated_instance(
    polymorphic_model: ModuleType,
) -> None:
    pet: sob.abc.Object = polymorphic_model.Pet(
        {"name": "Rex", "species": "dog", "status": "sold"}
    )
    sob.validate(pet)


def test_enum_schema_generates_an_enumerated_property_with_real_values(
    polymorphic_model: ModuleType,
) -> None:
    meta: sob.abc.ObjectMeta | None = sob.read_object_meta(
        polymorphic_model.Pet
    )
    assert meta is not None
    assert meta.properties is not None
    status_property: sob.abc.Property = meta.properties["status"]
    assert isinstance(status_property, sob.EnumeratedProperty)
    assert status_property.values == {"available", "pending", "sold"}


def test_enum_schema_rejects_an_unlisted_value_at_construction(
    polymorphic_model: ModuleType,
) -> None:
    with pytest.raises(sob.errors.UnmarshalValueError, match="unknown"):
        polymorphic_model.Pet({"name": "x", "status": "unknown"})


def test_oneof_generates_independent_non_merged_classes(
    polymorphic_model: ModuleType,
) -> None:
    """
    `Shape` is `oneOf: [Circle, Square]` -- unlike `allOf`, this should
    *not* merge `Circle` and `Square` into one class; each keeps only
    its own properties.
    """
    circle_meta: sob.abc.ObjectMeta | None = sob.read_object_meta(
        polymorphic_model.Circle
    )
    square_meta: sob.abc.ObjectMeta | None = sob.read_object_meta(
        polymorphic_model.Square
    )
    assert circle_meta is not None
    assert square_meta is not None
    assert circle_meta.properties is not None
    assert square_meta.properties is not None
    assert set(circle_meta.properties.keys()) == {"radius"}
    assert set(square_meta.properties.keys()) == {"side"}


def test_oneof_member_schemas_validate_independently(
    polymorphic_model: ModuleType,
) -> None:
    circle: sob.abc.Object = polymorphic_model.Circle({"radius": 5})
    sob.validate(circle)
    square: sob.abc.Object = polymorphic_model.Square({"side": 3})
    sob.validate(square)


def test_anyof_generates_independent_non_merged_classes(
    polymorphic_model: ModuleType,
) -> None:
    email_meta: sob.abc.ObjectMeta | None = sob.read_object_meta(
        polymorphic_model.EmailContact
    )
    phone_meta: sob.abc.ObjectMeta | None = sob.read_object_meta(
        polymorphic_model.PhoneContact
    )
    assert email_meta is not None
    assert phone_meta is not None
    assert email_meta.properties is not None
    assert phone_meta.properties is not None
    assert set(email_meta.properties.keys()) == {"email"}
    assert set(phone_meta.properties.keys()) == {"phone"}


def test_additional_properties_schema_generates_a_dictionary_subclass(
    polymorphic_model: ModuleType,
) -> None:
    assert issubclass(polymorphic_model.TagsGetResponse, sob.Dictionary)
    tags: sob.abc.Dictionary = polymorphic_model.TagsGetResponse(
        {"a": "1", "b": "2"}
    )
    sob.validate(tags)
    assert dict(tags) == {"a": "1", "b": "2"}
  • [ ] Step 2: Verify

Run:

hatch run hatch-test.py3.10:pytest tests/test_model_module_polymorphic_schemas.py -v
hatch run mypy --strict --ignore-missing-imports tests/test_model_module_polymorphic_schemas.py
hatch fmt --formatter
hatch fmt --check
Expected: 9 passed; mypy Success: no issues found in 1 source file; hatch fmt --check clean. Confirms real, correct schema-composition behavior end-to-end: allOf merges properties and required from every member schema (and a real sob.validate() call enforces the merged requirement); enum schemas produce a real sob.EnumeratedProperty whose invalid-value rejection happens eagerly, at construction/unmarshal time (a real sob.errors.UnmarshalValueError, not the more commonly expected sob.errors.ValidationError raised by sob.validate() -- confirmed live, this was a genuine surprise during validation); oneOf/ anyOf do not merge their member schemas into one class, unlike allOf; additionalProperties schemas generate a real sob.Dictionary subclass.

  • [ ] Step 3: Commit
git add tests/test_model_module_polymorphic_schemas.py
git commit tests/test_model_module_polymorphic_schemas.py -m "test: add coverage for ModelModule's allOf/oneOf/anyOf/enum/dictionary schema generation"

Task 4: Whole-plan verification

Files: - None modified (verification only).

  • [ ] Step 1: Run every new file together, plus the full existing suite

hatch run hatch-test.py3.10:pytest tests/test_model_module_helpers.py tests/test_model_module_empty_schema.py tests/test_model_module_polymorphic_schemas.py -v
hatch run hatch-test.py3.10:pytest tests/ -q
git checkout -- tests/input-data/languagetool-swagger.json
Expected: 26 passed for the three new files together; 399 passed for the full suite (373 before this plan's tests existed, plus 26).

  • [ ] Step 2: Confirm the coverage delta

hatch run hatch-test.py3.10:coverage run -m pytest tests/ -q
hatch run hatch-test.py3.10:coverage report -m --include="*/model.py"
git checkout -- tests/input-data/languagetool-swagger.json
Expected: src/oapi/model.py moves from 69% (this plan's starting point) to roughly 71%, file-wide -- a real, if modest, improvement given _Modeler's remaining internals are deliberately out of scope (see Global Constraints).

  • [ ] Step 3: Update project memory

Update the project-oapi-test-coverage-initiative memory file to note this plan complete -- this is the last plan in the initiative, per the spec's sequencing. Summarize the initiative's overall result (client.py 16%→83%, model.py 69%→71%, seven real bugs found and documented across the whole initiative) and mark the initiative complete, per the standing blanket-execution-approval instruction -- no user confirmation needed for this step.


Self-Review

1. Spec coverage: The spec's final step ("model.py ModelModule gaps") is addressed within a deliberately bounded scope, explained in Global Constraints rather than silently stopping short -- the isolable free functions get direct unit tests (Task 1), the one concrete, already-identified bug gets its own regression test (Task 2), and the one fixture genuinely built for exercising _Modeler's merge/polymorph paths (polymorphic-schemas.json, per its own description field) gets used for that stated purpose via real generated-class inspection (Task 3) rather than left further unused. The initiative's overall goal (real, integration-first coverage, client.py's runtime and codegen both substantially covered) is achieved; this final plan closes out the remaining, most defensible slice of model.py rather than over-extending into diminishing-returns internal-method unit testing.

2. Placeholder scan: No "TBD"/"add appropriate handling"/"similar to Task N" language. Every line of test code was executed against the real library before being written into this document. One genuine test-authoring surprise was caught and fixed during validation (not buried): a dropped test (_get_model_import_class_source, noted in Task 1's Step 2) whose live behavior didn't match the assumption drawn from its calling convention in get_module_source, and a subtle compile()/dont_inherit testing pitfall in Task 2 that initially made the empty-schema regression test pass for the wrong reason (the test's own from __future__ import annotations leaking into the exec()'d generated code) until caught by the test unexpectedly not reproducing the bug on a first run.

3. Type consistency: All three files declare complete import blocks. sob.read_object_meta(...)'s Optional return type is narrowed identically (assert ... is not None) everywhere it's used in Task 3, matching the established pattern from the ClientModule-plan's Client.parts/.headers narrowing.


Plan complete and saved to docs/superpowers/plans/2026-08-02-model-module-gap-tests.md. Per the standing blanket authorization for this initiative ([[feedback-autonomous-plan-execution]]), proceeding directly to subagent-driven execution on branch test-coverage without further confirmation. This is the final plan in the initiative.