ClientModule Code Generation 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/client.py's ClientModule class (lines
2284-3963, ~1680 lines -- the OpenAPI-to-Python-client code generator,
the largest remaining chunk of this module) and the free functions
immediately above it (lines 1929-2281 -- naming/import/parameter-
representation helpers ClientModule itself calls) from 56% to a
substantially higher whole-file coverage on client.py, per
docs/superpowers/specs/2026-08-01-test-coverage-design.md's step 6
("client.py ClientModule code generation").
Architecture: This plan uses a fundamentally different testing
strategy than the three prior client.py plans. ClientModule has
~50 private methods, almost all of which assemble fragments of
generated Python source text -- unit-testing each one in isolation
would mean asserting exact generated-source-string output, which is
brittle and largely tautological (the same "testing that generated
code says what the generator says" problem already excluded for
ModelModule-generated constructor boilerplate in an earlier plan).
Instead, this plan tests ClientModule black-box, through its real
output: for each of the four purpose-built OpenAPI fixtures already
sitting unused in tests/input-data/ since the infrastructure plan
(parameter-styles.json, security-schemes.json,
multipart-request-body.json, polymorphic-schemas.json -- each
fixture's own description field already states it exists "for
testing oapi.client... code-generation and runtime behavior
end-to-end"), this plan generates a real model module and a real
client module, loads them as a real importable Python package,
instantiates the generated Client subclass, and calls its generated
methods against the real http_test_server -- verifying the actual
HTTP request that reaches the server (path, query, headers, cookies,
body) and the actual deserialized response. This single black-box
pass, across the four fixtures, transitively exercises the overwhelming
majority of ClientModule's ~50 private methods (parameter-style
representation, security-scheme-derived __init__ defaults, OAuth2
flow handling, multipart form-data assembly, polymorphic response type
resolution, method naming) at once -- confirmed by the coverage delta
in Task 8 below (56% → 83%, file-wide). A small number of the
free functions above ClientModule (Task 3) are additionally isolable
enough to unit-test directly.
Five new test files:
tests/test_client_module_helpers.py-- direct unit tests for the isolable free functions:_get_relative_module_path,_get_relative_ module_import,_schema_defines_model,_iter_path_item_operations,get_default_method_name_from_path_method_operation,_strip_def_ decorators.tests/test_client_module_parameter_styles.py-- generates a real client fromparameter-styles.jsonand executes every one of its 9 operations (pathsimple/label/matrixstyles, queryform/spaceDelimited/pipeDelimited/deepObjectstyles, headersimplestyle, cookieformstyle) againsthttp_test_server, asserting the real path/query/header/cookie that reaches the server.tests/test_client_module_security_schemes.py-- generates a real client fromsecurity-schemes.json, asserts the generated__init__bakes in the right defaults for API-key location/name and OAuth2/OIDC URLs, and executes the API-key (header/query/cookie) and Bearer operations end-to-end.tests/test_client_module_multipart.py-- generates a real client frommultipart-request-body.jsonand documents a real bug (see Global Constraints).tests/test_client_module_polymorphic_responses.py-- generates a real client frompolymorphic-schemas.jsonand executes its four operations, assertingallOf-merged,oneOf-resolved,anyOf-resolved, andadditionalProperties-dictionary responses all deserialize to the correct real model type.
Plus two small supporting changes:
tests/conftest.pygains a newgenerated_client_packagefixture (Task 2) --generated_module_loader(from the infrastructure plan) loads a single generated file, but a generated client module always contains a package-relativefrom . import modelimport, so it must be loaded as part of a real package. This fixture generates a real model + client module pair into a real temporary package directory, imports both, and cleans upsys.modules/sys.pathafterward.tests/input-data/security-schemes.jsongains one real schema (Task 1) -- as originally authored (infrastructure plan), every response in this fixture was a bare{"type": "object"}with no namedcomponents/schemasentry, which triggers a real, separateModelModulebug (an empty-schema OpenAPI document generates amodel.pymissing a neededsobimport, raisingNameErroron import -- out ofclient.py's scope, noted for a futuremodel.pyplan). Adding one minimal named schema (ProtectedResource) and referencing it from one response unblocks generating a genuinely loadable/runnable package from this fixture without touchingmodel.py.
Tech Stack: Python 3.10, pytest, stdlib (importlib, inspect),
oapi.model.ModelModule, oapi.client.ClientModule (the systems under
test, used as real code generators, not mocked), tests/servers.py's
http_test_server.
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. Generated-module attribute access (client_module.Client,model_module.Pet, etc.) is necessarily untyped -- the modules are loaded dynamically viaimportlib, so mypy seesModuleTypeand treats attribute access asAny-- this is not a new exception to the annotation rule, it's the same dynamic-loading pattern already used (and already accepted bymypy --strict) forgenerated_module_ loaderin prior plans. - Prefer real integration over mocks: no
unittest.mock/pytest-mockanywhere in any of the five new files. Every test in this plan generates real source code with the realModelModule/ClientModulegenerators, executes it as a real imported Python package, and (for four of the five files) makes real HTTP calls against the realhttp_test_server. This is the most "real integration" plan in the initiative so far -- no OpenAPI construct is faked; every fixture is a real, validatedOpenAPIdocument. - Four verified, real, currently-unfixed bugs found while validating this plan's tests (do not deviate from the specified test code trying to "fix" them -- document, don't correct):
matrix-style path parameters are completely broken in every generated client._represent_dictionary_parameter(client.py) prepends the matrix delimiter to the dictionary key used for string formatting (";id", since_format_matrix_argument_value's own output already includes the full;id=valuefragment), but the generated path template'sstr.format(**{...})placeholder is still the bare{id}from the OpenAPI path --"{id}".format(**{";id": ...})cannot find an"id"key in the kwargs dict it was given and raisesKeyError. Verified live:client.get_path_matrix_id(id_=[1, 2, 3])against a real generated client raisesKeyError: \'id\'every time. Documented intests/test_client_module_parameter_styles.py'stest_path_matrix_style_raises_key_error.- A generated client cannot be instantiated with its own defaults
whenever its OpenAPI document names a multi-word OAuth2 flow
(
authorizationCode/clientCredentials-- i.e. most real-world OAuth2 specs)._iter_oauth2_flowsreads flow-type names viasob.utilities.iter_properties_values(security_scheme.flows), which yields the Python-side snake_case property names of the generatedOAuthFlowsmodel ("authorization_code") rather than the OpenAPI spec's own camelCase flow-type identifiers ("authorizationCode") thatClient.__init__'s own validation requires. The baked-in defaultoauth2_flowstuple therefore fails that validation immediately on construction. Verified live:Client(url="http://example.com")(no override) against a real generated client raisesValueErrorevery time; the identical construction with an explicit, validoauth2_flows=(...)succeeds. Documented intests/test_client_module_security_schemes.py'stest_default_oauth2_flows_value_is_invalid; every other test in that file works around it with an explicit override, exactly as a real caller currently must. - Every generated multipart operation is unusable, for the same
reason documented in the prior plan
(
docs/superpowers/plans/2026-08-02-client-runtime-tests.md's Global Constraints):_request_callbackcallsrequest.headers. get("Content-encoding")expecting ordinarydict.getsemantics, but aMultipartRequest's customHeadersobject re-raisesKeyErrorwhen the key is missing (as it always is for an ordinary multipart request) and nodefaultis passed. Verified live against a real generated multipart method (not justClient.request()directly, as the prior plan tested) --client.post_upload(file=b"...", description="...", tags=[...])raisesKeyError: \'Content-encoding\'every time. Documented intests/test_client_module_multipart.py. - A narrower, separate
ModelModulebug (notClientModule's; noted here because it was discovered while building this plan's test fixtures, and blocks generating a runnable package from an OpenAPI document with no namedcomponents/schemas): the generatedmodel.py's trailing_POINTERS_CLASSES: dict[str, type[sob.abc.Model]] = {}line referencessob.abc.Modelin a variable annotation, but when a document defines no real model classes,ModelModuleomits bothfrom __future__ import annotationsandimport sobfrom the generated file (since nothing else in the file needs them) -- so that one remaining reference raisesNameError: name \'sob\' is not definedon import. Worked around here (Task 1) by givingsecurity- schemes.jsonone real named schema rather than by fixingModelModule(out ofclient.py's scope) -- noted for whoever picks up themodel.py/ModelModuleplan next. All four are real, reproducible defects inoapi, flagged directly to the user in conversation, not just recorded here. - Codegen naming default worth knowing, not a bug:
ClientModule'suse_operation_idparameter defaults toFalse, so even when an OpenAPI operation has an explicitoperationId, generated method names default to being derived from the path and HTTP method instead (get_protected_api_key_header, notget_api_key_header) --get_default_method_name_from_path_method_operation(tested directly in Task 3) is the function responsible, and it's exercised in its default (use_operation_id=False) configuration by every integration test in this plan, matching howClientModule/write_client_moduleactually behave without an explicit override. - Deliberately out of scope for this plan (diminishing returns,
chased and found not worth it):
ClientModule's many optional customization parameters not exercised by a defaultClientModule(open_api, model_path=...)call --imports,init_decorator,include_init_parameters,add_init_parameters,add_init_parameter_ docs,init_parameter_defaults/init_parameter_defaults_source,use_operation_id,module_docstring,class_docstring-- and the deprecatedModule/write_client_modulealiases. Each is a real, independent feature, but covering all of them would mean many more generate-and-load round trips for configuration options with no fixture built to specifically exercise them; the coverage delta already achieved (Task 8) demonstrates the four existing fixtures already exercise the overwhelming majority ofClientModule's real logic. Not chased further in this plan. - Verify commands:
hatch run hatch-test.py3.10:pytest tests/test_client_module_helpers.py tests/test_client_module_parameter_styles.py tests/test_client_module_security_schemes.py tests/test_client_module_multipart.py tests/test_client_module_polymorphic_responses.py -v(34 tests total: 13 + 9 + 7 + 1 + 4).hatch run mypy --strict --ignore-missing-imports <file>per file -- expect exactly one pre-existing, unrelated finding ontests/servers.py:56([str-bytes-safe]) wheneverservers.pyis imported transitively; nothing else. - 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 touchtests/servers.py,src/oapi/client.py, orsrc/oapi/model.py-- this is a test-only (plus one fixture-data) initiative. - 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: Add a real schema to security-schemes.json
Files:
- Modify: tests/input-data/security-schemes.json
Interfaces:
- Consumes: nothing new.
- Produces: a security-schemes.json that ModelModule can turn into
a genuinely importable model.py (see Global Constraints, bug 4).
- [ ] Step 1: Replace the file
Replace the full contents of tests/input-data/security-schemes.json
with:
{
"openapi": "3.0.3",
"info": {
"title": "Security Schemes",
"description": "Minimal fixture covering apiKey, http, oauth2 (all four flows), and openIdConnect security schemes, for testing `oapi.client` auth code-generation and runtime behavior end-to-end.",
"version": "1.0.0"
},
"paths": {
"/protected/api-key-header": {
"get": {
"operationId": "getApiKeyHeader",
"security": [{"apiKeyHeader": []}],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProtectedResource"
}
}
}
}
}
}
},
"/protected/api-key-query": {
"get": {
"operationId": "getApiKeyQuery",
"security": [{"apiKeyQuery": []}],
"responses": {
"200": {
"description": "OK",
"content": {"application/json": {"schema": {"type": "object"}}}
}
}
}
},
"/protected/api-key-cookie": {
"get": {
"operationId": "getApiKeyCookie",
"security": [{"apiKeyCookie": []}],
"responses": {
"200": {
"description": "OK",
"content": {"application/json": {"schema": {"type": "object"}}}
}
}
}
},
"/protected/bearer": {
"get": {
"operationId": "getBearer",
"security": [{"httpBearer": []}],
"responses": {
"200": {
"description": "OK",
"content": {"application/json": {"schema": {"type": "object"}}}
}
}
}
},
"/protected/oauth2-password": {
"get": {
"operationId": "getOauth2Password",
"security": [{"oauth2Password": ["read"]}],
"responses": {
"200": {
"description": "OK",
"content": {"application/json": {"schema": {"type": "object"}}}
}
}
}
},
"/protected/oauth2-client-credentials": {
"get": {
"operationId": "getOauth2ClientCredentials",
"security": [{"oauth2ClientCredentials": ["read"]}],
"responses": {
"200": {
"description": "OK",
"content": {"application/json": {"schema": {"type": "object"}}}
}
}
}
},
"/protected/oauth2-authorization-code": {
"get": {
"operationId": "getOauth2AuthorizationCode",
"security": [{"oauth2AuthorizationCode": ["read"]}],
"responses": {
"200": {
"description": "OK",
"content": {"application/json": {"schema": {"type": "object"}}}
}
}
}
},
"/protected/oauth2-implicit": {
"get": {
"operationId": "getOauth2Implicit",
"security": [{"oauth2Implicit": ["read"]}],
"responses": {
"200": {
"description": "OK",
"content": {"application/json": {"schema": {"type": "object"}}}
}
}
}
},
"/protected/open-id-connect": {
"get": {
"operationId": "getOpenIdConnect",
"security": [{"openIdConnect": []}],
"responses": {
"200": {
"description": "OK",
"content": {"application/json": {"schema": {"type": "object"}}}
}
}
}
}
},
"components": {
"schemas": {
"ProtectedResource": {
"type": "object",
"properties": {
"name": {"type": "string"}
}
}
},
"securitySchemes": {
"apiKeyHeader": {
"type": "apiKey",
"in": "header",
"name": "X-Api-Key"
},
"apiKeyQuery": {
"type": "apiKey",
"in": "query",
"name": "api_key"
},
"apiKeyCookie": {
"type": "apiKey",
"in": "cookie",
"name": "api_key"
},
"httpBearer": {
"type": "http",
"scheme": "bearer"
},
"oauth2Password": {
"type": "oauth2",
"flows": {
"password": {
"tokenUrl": "https://example.com/oauth2/token",
"scopes": {"read": "Read access"}
}
}
},
"oauth2ClientCredentials": {
"type": "oauth2",
"flows": {
"clientCredentials": {
"tokenUrl": "https://example.com/oauth2/token",
"scopes": {"read": "Read access"}
}
}
},
"oauth2AuthorizationCode": {
"type": "oauth2",
"flows": {
"authorizationCode": {
"authorizationUrl": "https://example.com/oauth2/authorize",
"tokenUrl": "https://example.com/oauth2/token",
"scopes": {"read": "Read access"}
}
}
},
"oauth2Implicit": {
"type": "oauth2",
"flows": {
"implicit": {
"authorizationUrl": "https://example.com/oauth2/authorize",
"scopes": {"read": "Read access"}
}
}
},
"openIdConnect": {
"type": "openIdConnect",
"openIdConnectUrl": "https://example.com/.well-known/openid-configuration"
}
}
}
}
(The only changes from the infrastructure-plan version: a new
components.schemas.ProtectedResource object schema, and the
/protected/api-key-header operation's 200 response now references
it via $ref instead of an inline bare {"type": "object"}.)
- [ ] Step 2: Verify
Run:
python3 -m json.tool tests/input-data/security-schemes.json > /dev/null && echo "valid JSON"
valid JSON. Full end-to-end verification (that this
actually produces a loadable package) happens in Task 5.
- [ ] Step 3: Commit
git add tests/input-data/security-schemes.json
git commit tests/input-data/security-schemes.json -m "test: add a named schema to the security-schemes fixture so it generates a loadable model module"
Task 2: Add the generated_client_package fixture
Files:
- Modify: tests/conftest.py
Interfaces:
- Consumes: oapi.client.ClientModule, oapi.model.ModelModule,
oapi.oas.model.OpenAPI (existing).
- Produces: a new generated_client_package pytest fixture, consumed
by every other task in this plan.
- [ ] Step 1: Replace the file
Replace the full contents of tests/conftest.py with:
from __future__ import annotations
import importlib
import importlib.util
import sys
from collections.abc import Callable, Iterator
from pathlib import Path
from types import ModuleType
import pytest
from oapi.client import ClientModule
from oapi.model import ModelModule
from oapi.oas.model import OpenAPI
@pytest.fixture
def generated_module_loader(
tmp_path: Path,
) -> Iterator[Callable[..., ModuleType]]:
loaded_module_names: list[str] = []
def load(source: str, module_name: str = "generated_module") -> ModuleType:
module_path = tmp_path / f"{module_name}.py"
module_path.write_text(source)
spec = importlib.util.spec_from_file_location(module_name, module_path)
if spec is None or spec.loader is None:
message = f"Unable to load a module from {module_path}"
raise ImportError(message)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
loaded_module_names.append(module_name)
spec.loader.exec_module(module)
return module
yield load
for module_name in loaded_module_names:
sys.modules.pop(module_name, None)
@pytest.fixture
def generated_client_package(
tmp_path: Path,
) -> Iterator[Callable[[OpenAPI], tuple[ModuleType, ModuleType]]]:
"""
Generates a real `oapi.model`/`oapi.client` module pair from a real
`OpenAPI` document, writes them into a real, importable package (the
generated client module uses a package-relative `from . import
model` import, so it cannot be loaded as a standalone file the way
`generated_module_loader` loads single files), and returns
`(model_module, client_module)`.
"""
loaded_module_names: list[str] = []
inserted_sys_path: str | None = None
def load(
open_api: OpenAPI, package_name: str = "generated_client_pkg"
) -> tuple[ModuleType, ModuleType]:
nonlocal inserted_sys_path
package_dir: Path = tmp_path / package_name
package_dir.mkdir()
(package_dir / "__init__.py").write_text("")
model_path: Path = package_dir / "model.py"
model_path.write_text(str(ModelModule(open_api)))
client_path: Path = package_dir / "client.py"
ClientModule(open_api, model_path=str(model_path)).save(
str(client_path)
)
if inserted_sys_path is None:
sys.path.insert(0, str(tmp_path))
inserted_sys_path = str(tmp_path)
model_module: ModuleType = importlib.import_module(
f"{package_name}.model"
)
client_module: ModuleType = importlib.import_module(
f"{package_name}.client"
)
loaded_module_names.extend(
(package_name, f"{package_name}.model", f"{package_name}.client")
)
return model_module, client_module
yield load
for module_name in loaded_module_names:
sys.modules.pop(module_name, None)
if inserted_sys_path is not None and inserted_sys_path in sys.path:
sys.path.remove(inserted_sys_path)
- [ ] Step 2: Verify
Run:
hatch run hatch-test.py3.10:pytest tests/test_conftest.py -v
hatch run mypy --strict --ignore-missing-imports tests/conftest.py
hatch fmt --formatter
hatch fmt --check
test_conftest.py (from the infrastructure
plan, exercising generated_module_loader) still passes unmodified --
confirming this change is additive; mypy Success: no issues found in
1 source file; hatch fmt --check clean.
- [ ] Step 3: Commit
git add tests/conftest.py
git commit tests/conftest.py -m "test: add generated_client_package fixture for loading real generated model+client packages"
Task 3: tests/test_client_module_helpers.py
Files:
- Create: tests/test_client_module_helpers.py
Interfaces:
- Consumes: oapi.client._get_relative_module_path, _get_relative_
module_import, _schema_defines_model, _iter_path_item_operations,
get_default_method_name_from_path_method_operation, _strip_def_
decorators (existing). oapi.oas.model.Operation, Parameter,
PathItem, Schema (existing).
- Produces: tests/test_client_module_helpers.py, 13 tests.
- [ ] Step 1: Write the test file
Create tests/test_client_module_helpers.py:
from __future__ import annotations
from oapi.client import (
_get_relative_module_import,
_get_relative_module_path,
_iter_path_item_operations,
_schema_defines_model,
_strip_def_decorators,
get_default_method_name_from_path_method_operation,
)
from oapi.oas.model import Operation, Parameter, PathItem, Schema
def test_get_relative_module_path_across_directories() -> None:
assert _get_relative_module_path("a/b/c.py", "d/e/f.py") == "...a.b.c"
def test_get_relative_module_path_within_the_same_directory() -> None:
assert _get_relative_module_path("a/b/c.py", "a/b/f.py") == ".c"
def test_get_relative_module_import_across_directories() -> None:
result: str = _get_relative_module_import("a/b/c.py", "d/e/f.py")
assert result == "from ...a.b import c"
def test_get_relative_module_import_within_the_same_directory() -> None:
result: str = _get_relative_module_import("a/b/c.py", "a/b/f.py")
assert result == "from . import c"
def test_schema_defines_model_for_object_and_array_types() -> None:
assert _schema_defines_model(Schema({"type": "object"})) is True
assert _schema_defines_model(Schema({"type": "array"})) is True
def test_schema_defines_model_for_primitive_types() -> None:
assert _schema_defines_model(Schema({"type": "string"})) is False
def test_schema_defines_model_accepts_a_parameter() -> None:
parameter: Parameter = Parameter(
{"name": "x", "in": "query", "type": "object"}
)
assert _schema_defines_model(parameter) is True
def test_iter_path_item_operations_yields_name_and_operation_pairs() -> None:
path_item: PathItem = PathItem(
{
"get": {"operationId": "getX"},
"post": {"operationId": "postX"},
}
)
name: str
operation: Operation
result: list[tuple[str, str | None]] = [
(name, operation.operation_id)
for name, operation in _iter_path_item_operations(path_item)
]
assert result == [("get", "getX"), ("post", "postX")]
def test_iter_path_item_operations_skips_unset_methods() -> None:
path_item: PathItem = PathItem({"get": {"operationId": "getX"}})
result: list[str] = [
name for name, _operation in _iter_path_item_operations(path_item)
]
assert result == ["get"]
def test_get_default_method_name_derives_from_path_when_no_operation_id() -> (
None
):
result: str = get_default_method_name_from_path_method_operation(
"/foo/{id}/bar", "get", None
)
assert result == "get_foo_id_bar"
def test_get_default_method_name_prefers_the_operation_id() -> None:
result: str = get_default_method_name_from_path_method_operation(
"/foo/{id}/bar", "get", "myOperationId"
)
assert result == "my_operation_id"
def test_strip_def_decorators_removes_a_leading_decorator() -> None:
source: str = "@decorator\ndef foo():\n pass\n"
assert _strip_def_decorators(source) == "def foo():\n pass\n"
def test_strip_def_decorators_is_a_no_op_without_a_decorator() -> None:
source: str = "def foo():\n pass\n"
assert _strip_def_decorators(source) == source
- [ ] Step 2: Verify
Run:
hatch run hatch-test.py3.10:pytest tests/test_client_module_helpers.py -v
hatch run mypy --strict --ignore-missing-imports tests/test_client_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 (including the two
docstring examples already present on _get_relative_module_path/
_get_relative_module_import, re-verified live rather than trusted).
- [ ] Step 3: Commit
git add tests/test_client_module_helpers.py
git commit tests/test_client_module_helpers.py -m "test: add coverage for ClientModule's isolable naming/import helper functions"
Task 4: tests/test_client_module_parameter_styles.py
Files:
- Create: tests/test_client_module_parameter_styles.py
Interfaces:
- Consumes: the generated_client_package fixture (Task 2).
tests/input-data/parameter-styles.json (existing, from the
infrastructure plan). tests/servers.py's Response,
http_test_server (existing).
- Produces: tests/test_client_module_parameter_styles.py, 9 tests.
- Requires: Task 2.
- [ ] Step 1: Write the test file
Create tests/test_client_module_parameter_styles.py:
from __future__ import annotations
from collections.abc import Callable
from types import ModuleType
import pytest
from servers import Response, http_test_server
from oapi.oas.model import OpenAPI
@pytest.fixture
def parameter_styles_client(
generated_client_package: Callable[
[OpenAPI], tuple[ModuleType, ModuleType]
],
) -> ModuleType:
with open("tests/input-data/parameter-styles.json") as f:
open_api: OpenAPI = OpenAPI(f)
_model_module, client_module = generated_client_package(open_api)
return client_module
def test_path_simple_style(parameter_styles_client: ModuleType) -> None:
with http_test_server(
responses={("GET", "/path/simple/1,2,3"): Response(body=b"{}")}
) as server:
client = parameter_styles_client.Client(url=server.url)
client.get_path_simple_id(id_=[1, 2, 3])
assert server.requests[0].path == "/path/simple/1,2,3"
def test_path_label_style(parameter_styles_client: ModuleType) -> None:
with http_test_server(
responses={("GET", "/path/label/.1.2.3"): Response(body=b"{}")}
) as server:
client = parameter_styles_client.Client(url=server.url)
client.get_path_label_id(id_=[1, 2, 3])
assert server.requests[0].path == "/path/label/.1.2.3"
def test_path_matrix_style_raises_key_error(
parameter_styles_client: ModuleType,
) -> None:
"""
Documents a real, verified, currently-unfixed codegen bug: every
generated method for a `matrix`-style path parameter crashes with
`KeyError: 'id'`. `_represent_dictionary_parameter` (client.py)
prepends the matrix delimiter to the *dictionary key* used for
string formatting (`";id"`, since `_format_matrix_argument_value`'s
own output already includes the full `;id=value` fragment), but the
generated path template's `str.format(**{...})` placeholder is
still the bare `{id}` from the OpenAPI path -- `"{id}".format(
**{";id": ...})` cannot find an `"id"` key in the kwargs dict it was
given (only `";id"` is present) and raises `KeyError`. This means
matrix-style path parameters are completely unusable in any
generated client. Not fixed here (out of this test-only
initiative's scope) -- flagged to the user directly as well as
documented here.
"""
with http_test_server(responses={}) as server:
client = parameter_styles_client.Client(url=server.url)
with pytest.raises(KeyError, match="id"):
client.get_path_matrix_id(id_=[1, 2, 3])
def test_query_form_style(parameter_styles_client: ModuleType) -> None:
with http_test_server(
responses={("GET", "/query/form"): Response(body=b"{}")}
) as server:
client = parameter_styles_client.Client(url=server.url)
client.get_query_form(ids=[1, 2, 3])
assert server.requests[0].query == "ids=1,2,3"
def test_query_space_delimited_style(
parameter_styles_client: ModuleType,
) -> None:
with http_test_server(
responses={("GET", "/query/space-delimited"): Response(body=b"{}")}
) as server:
client = parameter_styles_client.Client(url=server.url)
client.get_query_space_delimited(ids=[1, 2, 3])
assert server.requests[0].query == "ids=1%202%203"
def test_query_pipe_delimited_style(
parameter_styles_client: ModuleType,
) -> None:
with http_test_server(
responses={("GET", "/query/pipe-delimited"): Response(body=b"{}")}
) as server:
client = parameter_styles_client.Client(url=server.url)
client.get_query_pipe_delimited(ids=[1, 2, 3])
assert server.requests[0].query == "ids=1|2|3"
def test_query_deep_object_style(parameter_styles_client: ModuleType) -> None:
with http_test_server(
responses={("GET", "/query/deep-object"): Response(body=b"{}")}
) as server:
client = parameter_styles_client.Client(url=server.url)
client.get_query_deep_object(filter_={"a": "1", "b": "2"})
assert server.requests[0].query == "filter[a]=1&filter[b]=2"
def test_header_simple_style(parameter_styles_client: ModuleType) -> None:
with http_test_server(
responses={("GET", "/header/simple"): Response(body=b"{}")}
) as server:
client = parameter_styles_client.Client(url=server.url)
client.get_header_simple(x_ids=[1, 2])
assert server.requests[0].headers.get("X-Ids") == "1,2"
def test_cookie_form_style(parameter_styles_client: ModuleType) -> None:
with http_test_server(
responses={("GET", "/cookie/form"): Response(body=b"{}")}
) as server:
client = parameter_styles_client.Client(url=server.url)
client.get_cookie_form(ids=[1, 2])
assert server.requests[0].headers.get("Cookie") == "ids=1,2"
- [ ] Step 2: Verify
Run:
hatch run hatch-test.py3.10:pytest tests/test_client_module_parameter_styles.py -v
hatch run mypy --strict --ignore-missing-imports tests/test_client_module_parameter_styles.py
hatch fmt --formatter
hatch fmt --check
Success: no issues found in 1 source file; hatch fmt --check
clean. Every expected path/query/header/cookie value was produced by
actually running the generated method against the real
http_test_server and reading RecordedRequest's real fields.
- [ ] Step 3: Commit
git add tests/test_client_module_parameter_styles.py
git commit tests/test_client_module_parameter_styles.py -m "test: add end-to-end coverage for ClientModule's argument-style code generation"
Task 5: tests/test_client_module_security_schemes.py
Files:
- Create: tests/test_client_module_security_schemes.py
Interfaces:
- Consumes: the generated_client_package fixture (Task 2).
tests/input-data/security-schemes.json (Task 1). tests/servers.py's
Response, http_test_server (existing).
- Produces: tests/test_client_module_security_schemes.py, 7 tests.
- Requires: Tasks 1, 2.
- [ ] Step 1: Write the test file
Create tests/test_client_module_security_schemes.py:
from __future__ import annotations
import inspect
from collections.abc import Callable
from types import ModuleType
import pytest
from servers import Response, http_test_server
from oapi.oas.model import OpenAPI
@pytest.fixture
def security_schemes_client(
generated_client_package: Callable[
[OpenAPI], tuple[ModuleType, ModuleType]
],
) -> ModuleType:
with open("tests/input-data/security-schemes.json") as f:
open_api: OpenAPI = OpenAPI(f)
_model_module, client_module = generated_client_package(open_api)
return client_module
def test_init_bakes_in_the_first_api_key_schemes_location_and_name(
security_schemes_client: ModuleType,
) -> None:
"""
The OpenAPI document declares three `apiKey` security schemes
(header/query/cookie) across different operations, but a generated
`Client` has one shared `api_key_in`/`api_key_name` default pair --
codegen picks the first `apiKey` scheme it encounters. Other
locations remain reachable by passing `api_key_in`/`api_key_name`
explicitly when constructing the client (see the query/cookie tests
below), which isn't a bug -- a single client instance can only default
to one location.
"""
signature: inspect.Signature = inspect.signature(
security_schemes_client.Client.__init__
)
assert signature.parameters["api_key_in"].default == "header"
assert signature.parameters["api_key_name"].default == "X-Api-Key"
def test_init_bakes_in_oauth2_and_oidc_urls_from_the_security_schemes(
security_schemes_client: ModuleType,
) -> None:
signature: inspect.Signature = inspect.signature(
security_schemes_client.Client.__init__
)
assert (
signature.parameters["oauth2_token_url"].default
== "https://example.com/oauth2/token"
)
assert (
signature.parameters["oauth2_authorization_url"].default
== "https://example.com/oauth2/authorize"
)
assert (
signature.parameters["open_id_connect_url"].default
== "https://example.com/.well-known/openid-configuration"
)
def test_default_oauth2_flows_value_is_invalid(
security_schemes_client: ModuleType,
) -> None:
"""
Documents a real, verified, currently-unfixed codegen bug: a
generated `Client` for an OpenAPI document with named OAuth2 flows
cannot be instantiated with its own defaults. `_iter_oauth2_flows`
(client.py) reads flow-type names via `sob.utilities.
iter_properties_values(security_scheme.flows)`, which yields the
Python-side (snake_case) *property* names of the generated
`OAuthFlows` model -- e.g. `"authorization_code"` -- rather than the
OpenAPI spec's own camelCase flow-type identifiers (`"authorization
Code"`) that `Client.__init__`'s own validation (and its `Literal`
parameter type) require. The baked-in default `oauth2_flows` tuple
therefore fails that same validation immediately on construction,
for *any* generated client whose OpenAPI document has a
multi-word-named OAuth2 flow (`authorizationCode`/
`clientCredentials` -- i.e. most real-world OAuth2 specs). The
workaround (used by every other test in this file) is passing an
explicit, valid `oauth2_flows` override. Not fixed here (out of
this test-only initiative's scope) -- flagged to the user directly
as well as documented here.
"""
with pytest.raises(ValueError, match="oauth2_flows"):
security_schemes_client.Client(url="http://example.com")
def test_api_key_header_authentication(
security_schemes_client: ModuleType,
) -> None:
with http_test_server(
responses={
("GET", "/protected/api-key-header"): Response(
body=b'{"name": "x"}'
)
}
) as server:
client = security_schemes_client.Client(
url=server.url, api_key="secret123", oauth2_flows=()
)
client.get_protected_api_key_header()
assert server.requests[0].headers.get("X-Api-Key") == "secret123"
def test_api_key_query_authentication_with_an_explicit_location(
security_schemes_client: ModuleType,
) -> None:
with http_test_server(
responses={
("GET", "/protected/api-key-query"): Response(
body=b'{"name": "x"}'
)
}
) as server:
client = security_schemes_client.Client(
url=server.url,
api_key="qkey",
api_key_in="query",
api_key_name="api_key",
oauth2_flows=(),
)
client.get_protected_api_key_query()
assert server.requests[0].query == "api_key=qkey"
def test_api_key_cookie_authentication_with_an_explicit_location(
security_schemes_client: ModuleType,
) -> None:
with http_test_server(
responses={
("GET", "/protected/api-key-cookie"): Response(
body=b'{"name": "x"}'
)
}
) as server:
client = security_schemes_client.Client(
url=server.url,
api_key="ckey",
api_key_in="cookie",
api_key_name="api_key",
oauth2_flows=(),
)
client.get_protected_api_key_cookie()
assert server.requests[0].headers.get("Cookie") == "api_key=ckey"
def test_bearer_authentication(security_schemes_client: ModuleType) -> None:
with http_test_server(
responses={
("GET", "/protected/bearer"): Response(body=b'{"name": "x"}')
}
) as server:
client = security_schemes_client.Client(
url=server.url, bearer_token="tok", oauth2_flows=()
)
client.get_protected_bearer()
assert server.requests[0].headers.get("Authorization") == "Bearer tok"
- [ ] Step 2: Verify
Run:
hatch run hatch-test.py3.10:pytest tests/test_client_module_security_schemes.py -v
hatch run mypy --strict --ignore-missing-imports tests/test_client_module_security_schemes.py
hatch fmt --formatter
hatch fmt --check
Success: no issues found in 1 source file;
hatch fmt --check clean. This is the task that both confirms Task 1's
fixture fix (the package must load at all) and documents the real
oauth2_flows codegen bug from Global Constraints.
- [ ] Step 3: Commit
git add tests/test_client_module_security_schemes.py
git commit tests/test_client_module_security_schemes.py -m "test: add end-to-end coverage for ClientModule's security-scheme code generation"
Task 6: tests/test_client_module_multipart.py
Files:
- Create: tests/test_client_module_multipart.py
Interfaces:
- Consumes: the generated_client_package fixture (Task 2).
tests/input-data/multipart-request-body.json (existing, from the
infrastructure plan). tests/servers.py's Response,
http_test_server (existing).
- Produces: tests/test_client_module_multipart.py, 1 test.
- Requires: Task 2.
- [ ] Step 1: Write the test file
Create tests/test_client_module_multipart.py:
from __future__ import annotations
from collections.abc import Callable
from types import ModuleType
import pytest
from servers import Response, http_test_server
from oapi.oas.model import OpenAPI
@pytest.fixture
def multipart_client(
generated_client_package: Callable[
[OpenAPI], tuple[ModuleType, ModuleType]
],
) -> ModuleType:
with open("tests/input-data/multipart-request-body.json") as f:
open_api: OpenAPI = OpenAPI(f)
_model_module, client_module = generated_client_package(open_api)
return client_module
def test_generated_multipart_method_raises_key_error(
multipart_client: ModuleType,
) -> None:
"""
Documents the same real, verified, currently-unfixed bug already
covered directly against `Client.request()` in
`tests/test_client_request_runtime.py`'s
`test_request_multipart_crashes_missing_content_encoding_header`,
confirmed here to also break *generated* multipart operations (the
exact scenario `tests/input-data/multipart-request-body.json` was
built to exercise, per the infrastructure plan): `_request_callback`
calls `request.headers.get("Content-encoding")` expecting ordinary
`dict.get` semantics, but a `MultipartRequest`'s custom `Headers`
object re-raises `KeyError` when no `default` is passed and the key
is missing -- which it always is for an ordinary multipart request.
Every generated multipart operation is therefore unusable as-is.
Not fixed here (out of this test-only initiative's scope) --
flagged to the user directly as well as documented here.
"""
with http_test_server(
responses={("POST", "/upload"): Response(status=200, body=b"{}")}
) as server:
client = multipart_client.Client(url=server.url)
with pytest.raises(KeyError, match="Content-encoding"):
client.post_upload(
file=b"filedata", description="a file", tags=["a", "b"]
)
- [ ] Step 2: Verify
Run:
hatch run hatch-test.py3.10:pytest tests/test_client_module_multipart.py -v
hatch run mypy --strict --ignore-missing-imports tests/test_client_module_multipart.py
hatch fmt --formatter
hatch fmt --check
Success: no issues found in 1 source file;
hatch fmt --check clean.
- [ ] Step 3: Commit
git add tests/test_client_module_multipart.py
git commit tests/test_client_module_multipart.py -m "test: document that generated multipart operations crash (Content-encoding KeyError)"
Task 7: tests/test_client_module_polymorphic_responses.py
Files:
- Create: tests/test_client_module_polymorphic_responses.py
Interfaces:
- Consumes: the generated_client_package fixture (Task 2).
tests/input-data/polymorphic-schemas.json (existing, from the
infrastructure plan). tests/servers.py's Response,
http_test_server (existing).
- Produces: tests/test_client_module_polymorphic_responses.py, 4
tests.
- Requires: Task 2.
- [ ] Step 1: Write the test file
Create tests/test_client_module_polymorphic_responses.py:
from __future__ import annotations
from collections.abc import Callable
from types import ModuleType
import pytest
from servers import Response, http_test_server
from oapi.oas.model import OpenAPI
@pytest.fixture
def polymorphic_client(
generated_client_package: Callable[
[OpenAPI], tuple[ModuleType, ModuleType]
],
) -> tuple[ModuleType, ModuleType]:
with open("tests/input-data/polymorphic-schemas.json") as f:
open_api: OpenAPI = OpenAPI(f)
return generated_client_package(open_api)
def test_array_of_allof_merged_schema_response(
polymorphic_client: tuple[ModuleType, ModuleType],
) -> None:
model_module, client_module = polymorphic_client
with http_test_server(
responses={
("GET", "/pets"): Response(
status=200,
body=b'[{"name": "Rex", "species": "dog", "status": "sold"}]',
)
}
) as server:
client = client_module.Client(url=server.url)
pets = client.get_pets()
assert len(pets) == 1
pet = pets[0]
assert isinstance(pet, model_module.Pet)
assert pet.name == "Rex"
assert pet.species == "dog"
assert pet.status == "sold"
def test_oneof_response_infers_the_matching_variant(
polymorphic_client: tuple[ModuleType, ModuleType],
) -> None:
model_module, client_module = polymorphic_client
with http_test_server(
responses={
("GET", "/shapes"): Response(status=200, body=b'{"radius": 5}')
}
) as server:
client = client_module.Client(url=server.url)
shape = client.get_shapes()
assert isinstance(shape, model_module.Circle)
assert shape.radius == 5
def test_anyof_response_infers_the_matching_variant(
polymorphic_client: tuple[ModuleType, ModuleType],
) -> None:
model_module, client_module = polymorphic_client
with http_test_server(
responses={
("GET", "/contacts"): Response(
status=200, body=b'{"email": "[email protected]"}'
)
}
) as server:
client = client_module.Client(url=server.url)
contact = client.get_contacts()
assert isinstance(contact, model_module.EmailContact)
assert contact.email == "[email protected]"
def test_additional_properties_dictionary_response(
polymorphic_client: tuple[ModuleType, ModuleType],
) -> None:
_model_module, client_module = polymorphic_client
with http_test_server(
responses={
("GET", "/tags"): Response(
status=200, body=b'{"a": "1", "b": "2"}'
)
}
) as server:
client = client_module.Client(url=server.url)
tags = client.get_tags()
assert dict(tags) == {"a": "1", "b": "2"}
- [ ] Step 2: Verify
Run:
hatch run hatch-test.py3.10:pytest tests/test_client_module_polymorphic_responses.py -v
hatch run mypy --strict --ignore-missing-imports tests/test_client_module_polymorphic_responses.py
hatch fmt --formatter
hatch fmt --check
Success: no issues found in 1 source file;
hatch fmt --check clean. Confirms real, correct polymorphic-type
resolution end-to-end: allOf merging (Pet), oneOf (Shape ->
Circle), anyOf (Contact -> EmailContact), and a plain
additionalProperties dictionary response.
- [ ] Step 3: Commit
git add tests/test_client_module_polymorphic_responses.py
git commit tests/test_client_module_polymorphic_responses.py -m "test: add end-to-end coverage for ClientModule's polymorphic response-type resolution"
Task 8: 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_client_module_helpers.py tests/test_client_module_parameter_styles.py tests/test_client_module_security_schemes.py tests/test_client_module_multipart.py tests/test_client_module_polymorphic_responses.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="*/client.py"
git checkout -- tests/input-data/languagetool-swagger.json
src/oapi/client.py moves from 56% (this plan's starting
point) to roughly 83%, file-wide -- confirming the black-box,
generate-and-execute strategy transitively covers the large majority
of ClientModule despite no test directly calling any of its ~50
private methods.
- [ ] Step 3: Update project memory
Update the project-oapi-test-coverage-initiative memory file to note
this plan complete and move to the final remaining step (the model.py
ModelModule plan), per the standing blanket-execution-approval
instruction -- no user confirmation needed for this step. Make sure the
memory update prominently notes all four real bugs from Global
Constraints (matrix-style path parameters, the oauth2_flows codegen
mismatch, the multipart KeyError recurrence, and the ModelModule
empty-schema NameError), since three of them are genuine defects
worth surfacing beyond just this plan document, and the fourth is a
concrete pointer for the next plan.
Self-Review
1. Spec coverage: The spec's step 6 ("client.py ClientModule
code generation... paired with executing generated clients against
http_test_server") is followed precisely -- every one of the four
purpose-built fixtures from the infrastructure plan is finally used for
its stated purpose, each generating a real client, loading it as a real
package, and executing it against the real http_test_server. The
free-function helpers immediately above ClientModule are covered
directly (Task 3) since they're genuinely isolable pure functions,
matching this initiative's established distinction between "isolable
pure logic gets a direct unit test" and "generated-source-text assembly
gets tested through its real output."
2. Placeholder scan: No "TBD"/"add appropriate handling"/"similar to
Task N" language. Every line of test code, and the new conftest.py
fixture itself, was executed against the real generators before being
written into this document. Four genuine bugs were discovered this way
(three in ClientModule/client.py, one in ModelModule) and are
documented as real, current behavior rather than "fixed," each verified
by tracing the source and then confirming live against a real generated
package -- not assumed. One genuine test-authoring mistake was caught
during validation (an isinstance(pets, list) assertion that was wrong
because a generated array response deserializes to a real sob.Array
subclass, not a plain list -- fixed to check length/indexing instead)
before it ever reached this document.
3. Type consistency: generated_client_package (Task 2) is the one
new fixture every other task's file depends on; its return type
(tuple[ModuleType, ModuleType]) is used consistently everywhere it's
consumed. Response/http_test_server continue to be imported the
same way established in every prior plan's test files touching
tests/servers.py.
Plan complete and saved to
docs/superpowers/plans/2026-08-02-client-module-codegen-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.