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 fromtests/input-data/polymorphic- schemas.json(built in the infrastructure plan specifically "for testingoapi.model's_Modelermerge/polymorph code paths", per its owndescriptionfield, and only partially exercised so far -- theClientModuleplan used it for response-type resolution, not for directly inspecting the generated model classes) via thegenerated_client_packagefixture (from theClientModuleplan), and inspects/exercises the real generated classes:allOfmerging (properties andrequiredcombined from every member schema, with real validation enforcement), enum-to-EnumeratedPropertygeneration (with real, eager unmarshal-time rejection of invalid values -- confirmed live, not assumed, to raise at construction rather than only atsob.validate()time),oneOf/anyOfproducing genuinely independent (non-merged) classes, andadditionalPropertiesgenerating a realsob.Dictionarysubclass.
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 realhatch run mypy --strict --ignore-missing-importsrun.sob.read_object_meta(...)returnsObjectMeta | None, andObjectMeta.propertiesisProperties | None-- both are narrowed with explicitassert ... is not Nonelines in every test that reads them (not a new exception to the annotation rule, just realOptionaltypes that need narrowing, same pattern used forClient.parts/.headersin an earlier plan). - Prefer real integration over mocks: no
unittest.mock/pytest-mockanywhere in any of the three new files. Every generated-class test intests/test_model_module_polymorphic_schemas.pyinspects a real class generated by a realModelModulerun and loaded via realimportlib; every validation assertion calls the realsob.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 amodel.pythat cannot be imported.ModelModule.get_module_source(model.py) builds itsimportsset purely from each generated class's own source text; when zero classes are generated, that set stays empty, so neitherfrom __future__ import annotationsnorimport sobget emitted -- but the module's own trailing_POINTERS_CLASSES: dict[str, type[sob.abc.Model]] = {...}line always referencessob.abc.Modelin a variable annotation regardless, and without the lazy-annotation future import,sobis genuinely undefined at execution. Verified live:exec()-ing the generated source for a real minimal empty-schema document raisesNameError: name 'sob' is not defined. This is the same bug already discovered (and worked around, via one added fixture schema) while building the priorClientModuleplan -- this plan adds the first direct regression test forModelModuleitself. A genuine testing subtlety was hit and corrected while validating this test:compile(source, "<generated>", "exec")called from inside a test file that itself hasfrom __future__ import annotationssilently inherits that future-import flag (compile'sdont_inheritparameter defaults toFalse), which makes the generated_POINTERS_CLASSESline'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 isdont_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,$refresolution edge cases, format-specific type mapping for every OpenAPIformatvalue,_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 forClientModule. This plan's coverage delta (Task 4) reflects a real, if more modest, improvement -- the initiative overall has already takenclient.pyfrom 16% to 83% andmodel.pyfrom 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 importtests/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 baregit commit -m "..."-- alwaysgit commit <exact-path(s)> -m "..."(paths before-m). Nevergit add -A/./-u, nevergit resetin any form to self-fix a mistake -- if a commit's scope looks wrong, stop and report it. Never touchsrc/oapi/model.pyortests/conftest.py-- this plan only adds new test files, reusing the existinggenerated_ client_packagefixture from theClientModuleplan unmodified. - Known, out-of-scope, pre-existing issue: running the full suite
(
hatch test -corhatch run hatch-test.py3.10:pytest tests/) has the unrelated, pre-existing side effect oftest_languagetoolre-downloading and overwritingtests/input-data/languagetool- swagger.json; rungit checkout -- tests/input-data/languagetool- swagger.jsonafterward if you ran the full suite, before checkinggit 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
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
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
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
- [ ] 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
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.