Client Argument-Formatting & Content-Encoding 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 pure-function layer (lines
109-1064: _iter_items through _get_first, i.e. everything above the
Client class) from 18% to essentially full coverage, per
docs/superpowers/specs/2026-08-01-test-coverage-design.md's step 4
("client.py argument-formatting + content-encoding"). This is the
half of client.py that has zero dependency on network I/O or a real
Client instance — pure functions and two small helper classes
(SSLContext, the pickling registrars) — so it is tackled before the
Client runtime (a later plan) and the ClientModule code generator
(the largest remaining chunk, also later).
Architecture: Four new test files, each independently runnable and each targeting a cohesive group of functions:
tests/test_client_argument_formatting.py—_iter_items,urlencode,_item_is_not_empty,_censor_long_json_strings,_format_primitive_value, all seven_format_*_argument_valuestyle formatters,_format_dot_object_argument_value, and theformat_argument_valuedispatcher.tests/test_client_request_assembly.py—get_request_curl,_represent_http_response,_set_response_callback(both of the latter two use the realhttp_test_serverfromdocs/superpowers/plans/2026-08-01-test-infrastructure.md),_remove_none,_format_request_data,_get_file_name,_get_first, and_assemble_request(both the plain and multipart branches).tests/test_client_retry_and_encoding.py—default_retry_hook,retry,_encode_content,_decode_content.tests/test_client_pickling.py—_make_thread_locks_pickleable,_make_http_errors_pickleable,_make_loggers_pickleable,SSLContext.
Unlike the prior two plans in this initiative, every test in all four
files below was already written and independently verified against the
real library, pytest, mypy --strict, and hatch fmt --check before
this plan document was drafted (each function's real behavior was
explored live via hatch run hatch-test.py3.10:python - scratch
scripts, several genuine test-writing mistakes were caught and fixed
that way — see Global Constraints — and several genuine source bugs
were discovered and are documented as real behavior rather than
"fixed"). Because of that, each task below is a single "create/verify
this already-correct file" step rather than the incremental
per-function-group build-up used in earlier plans: there is exactly one
task per file, plus one small task to add two optional test
dependencies. This does not reduce implementer rigor — Step 2 in every
task still requires an independent pytest/mypy --strict/hatch fmt
--check run before committing, not a rubber-stamp.
Tech Stack: Python 3.10, pytest, stdlib (gzip, zlib, pickle,
ssl, logging, threading, email.message), sob (already a
dependency), zstandard/brotli (newly added as hatch-test-env-only
test dependencies — see Task 1), tests/servers.py's http_test_server
(Task 3 only).
Global Constraints
- Line length 79 (ruff + black), enforced via
hatch fmt --check. - mypy strict on
tests/:disallow_untyped_defs,disallow_incomplete_defs, and — per this project's standing preference — explicit type annotations on every local variable, not only function signatures. Two established exceptions apply in this plan:with ... as name:targets (no annotation syntax exists there), andMessage[str, str]-vs-dict[str, str]in Task 4 (a hand-writtendictannotation is a real mypy error againstHTTPError's actualhdrs: email.message.Message[str, str]parameter type — a realemail.message.Messageinstance is constructed and typed instead). All code in this plan was verified against a realhatch run mypy --strict --ignore-missing-importsrun (thedefaultenv, which hasmypyinstalled and, in this checkout, syncs successfully — unlike in earlier plans in this initiative,dependence~=1.4resolved cleanly this time; if it doesn't for you, fall back to installingmypyintohatch-testad hoc and note it, don't skip the check). - Two new optional test dependencies (Task 1):
zstandardandbrotli/brotlicffi. Without them,_encode_content/_decode_content'szstd/br/dcb/dczbranches are completely untestable in thehatch-testenvironment (they were, before Task 1 — confirmed by directimport zstandard/import brotliboth raisingModuleNotFoundErrorin a freshly-syncedhatch-test.py3.10env). Both packages are already listed in this project's ownzstd/brotli/alloptional-dependency groups (pyproject.toml) and are real, supported, already-documented dependencies ofclient.py's own content-encoding code — this is closing a real test-environment gap, not adding a new production dependency. - Prefer real integration over mocks: no
unittest.mock/pytest-mockanywhere in any of the four new files. Realgzip/zlib/zstandard/brotliround-trips, a realhttp_test_serverfor the two functions that touch anHTTPResponse, realurllib.request.Request/HTTPError/Loggerinstances throughout. The one place a realtime.sleepis unavoidable (retry's exponential backoff) is used as-is rather than faked — the resulting real time cost is bounded (~6 seconds total across the wholeretrytest file, one to two seconds per test that needs a retry) and multiple assertions were consolidated onto a single retry attempt where possible specifically to keep that cost down (see Task 3). - Imports go at the top of the file (this project's ruff config
enforces
I001import sorting). Runhatch fmt --formatter(orhatch fmtwithout--check, which also auto-fixes some lint rules like unused-ignore) followed byhatch fmt --checkbefore every commit — not optional.hatch fmt's auto-formatter reflows some function signatures (e.g.def some_long_name() -> (\n None\n):); the exact code blocks in this plan already reflect the post-hatch fmtoutput, so a byte-for-byte transcription should not need reformatting, but run it anyway as a safety net. - Several verified, real, non-obvious behaviors these tests document rather than "fix" (do not treat as bugs, do not deviate from the specified test code trying to make them "more correct") — all independently traced against the real source, not assumed:
_iter_itemson asob.abc.Object(e.g.Reference) iterates by property name, not JSON key —$refis exposed as the propertyref(since$refisn't a valid Python identifier) — and yields every declared property, including unset ones (asNone), not just the ones actually provided at construction time._format_simple_argument_value,_format_label_argument_value, and_format_matrix_argument_value's exploded-dictionary branches each dofor item in valuedirectly over a plaindict, which yields the dict's keys, not(key, value)pairs —item[0]/item[1]then index into the key string itself. This silently produces wrong output for any key of length ≥ 2 and raisesIndexErrorfor any single-character key._format_form_ argument_valuedoes not share this bug — it recognizesdictas one of_ITEMIZED_TYPESand correctly uses_iter_items._format_deep_object_argument_value: a dictionary entry whose value is itself a sequence of primitives (e.g.{"a": [1, 2]}) raisesTypeError: dict.update() argument after ** must be a mapping, not str— the recursive call for each primitive item returns a plain formatted string, and the caller unconditionally doesdeep_object.update(**that_result). A sequence of dictionaries ({"a": [{"x": 1}]}-shaped, or a bare top-level sequence of dicts) does not hit this, since the recursive result is itself a dict there._encode_content's comma-separated ("gzip,deflate"-style) branch is genuinely broken for real multi-stage encoding: its comment says "Encode content in the order provided", but the code recursively calls_decode_content(not_encode_content) on the still-plain input data using the remaining tokens before applying the first one. For most real encoding-pair inputs this raises (e.g.zlib.errorwhen the plain data isn't actually deflate-compressed); Task 3's test exercises the branch without triggering the crash by using an unrecognized second token ("identity"), which_decode_contentsilently no-ops on, leaving only the first-listed encoding (gzip) actually applied._decode_content's own comma branch is correct (verified via a real two-stagegzip→deflate-compressed payload and the matching"gzip,deflate"decode call) — only the encode side is broken._assemble_request's own URL-scheme guard accepts a scheme-less relative URL like/relative/path(its check only looks for a:before the first/), but the plain, non-multipart branch then constructs a realurllib.request.Request(url, ...), which itself raisesValueError: unknown url type: '/relative/path'when the URL isn't fully qualified. Task 2's test for this asserts aValueErroris raised without asserting which of the two layers raised it (real, current, combined behavior).- Two lines in the pure-function layer are genuinely dead code, not
reachable via any real call graph — verified by tracing, not assumed
from a coverage report (in the same spirit as a prior plan's
correction of a wrong "coverage.py bug" claim — see
docs/superpowers/plans/2026-08-02-references-model-validation-tests.md's Global Constraints): _format_deep_object_argument_value'selif isinstance(formatted_value, typing.Iterable) and not isinstance(formatted_value, str):branch (lines 454-460) can only be reached via this function's own recursive self-calls, and every such recursive call returns eitherNone, astr(from_format_primitive_value), or adict(the function's owndeep_objectreturn value) — never a bare list or tuple.Noneandstrare excluded by theelif's own condition;dictis caught by the itemized-type branch immediately above it. Not covered by this plan's tests; not chased further._format_request_data'sif not isinstance(data, collections.abc. MutableMapping): raise ValueError(data)(line 842) immediately followsdata = dict(data)on the line above, anddict(...)always produces adict, which always is aMutableMapping— theraisecan never execute. Not covered by this plan's tests; not chased further.- Verify commands:
hatch run hatch-test.py3.10:pytest tests/test_client_argument_formatting.py tests/test_client_request_assembly.py tests/test_client_retry_and_encoding.py tests/test_client_pickling.py -v(150 tests total: 81 + 38 + 24 + 7, after a final-review fix added one test — see Task 2).hatch run mypy --strict --ignore-missing-imports <file>per file — expect exactly one pre-existing, unrelated finding ontests/servers.py:56([str-bytes-safe], from the infrastructure plan) 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. - Known, out-of-scope, pre-existing issue: a real
mypy --strictrun also flagstests/servers.py:56([str-bytes-safe]) wheneverservers.pyis imported — unrelated to this plan's files, don't touchtests/servers.py. 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 zstandard/brotli as hatch-test env test dependencies
Files:
- Modify: pyproject.toml
Interfaces:
- Consumes: nothing new.
- Produces: a synced hatch-test.py3.10 environment where import
zstandard and import brotli both succeed, needed by Task 3's
_encode_content/_decode_content round-trip tests.
- [ ] Step 1: Add the dependencies
In pyproject.toml, under [tool.hatch.envs.hatch-test], change:
[tool.hatch.envs.hatch-test]
extra-dependencies = [
"dependence~=1.4",
"pyyaml>2",
]
to:
[tool.hatch.envs.hatch-test]
extra-dependencies = [
"dependence~=1.4",
"pyyaml>2",
"zstandard~=0.25",
"brotli~=1.2; platform_python_implementation == 'CPython'",
"brotlicffi~=1.2; platform_python_implementation != 'CPython'",
]
- [ ] Step 2: Sync and verify
Run: hatch env prune && hatch run hatch-test.py3.10:python -c "import
zstandard, brotli; print('ok')"
Expected: environment recreates cleanly and prints ok. Verified
against the real environment: before this change, both imports raised
ModuleNotFoundError in a freshly-synced hatch-test.py3.10; after
adding these three lines and re-syncing, both succeed
(zstandard.__version__ reports 0.25.0 in the verified run).
- [ ] Step 3: Commit
git add pyproject.toml
git commit pyproject.toml -m "test: add zstandard/brotli to hatch-test env for content-encoding coverage"
Task 2: tests/test_client_argument_formatting.py
Files:
- Create: tests/test_client_argument_formatting.py
Interfaces:
- Consumes: oapi.client._iter_items, urlencode, URLENCODE_SAFE,
_item_is_not_empty, _censor_long_json_strings,
_format_primitive_value, _format_simple_argument_value,
_format_label_argument_value, _format_matrix_argument_value,
_format_space_delimited_argument_value,
_format_pipe_delimited_argument_value,
_format_form_argument_value, _format_deep_object_argument_value,
_format_dot_object_argument_value, format_argument_value
(existing). oapi.oas.model.Reference (existing).
- Produces: tests/test_client_argument_formatting.py, 81 tests (80
as originally written, plus one added during final review — see the
Self-Review section).
- [ ] Step 1: Write the test file
Create tests/test_client_argument_formatting.py:
from __future__ import annotations
import decimal
from datetime import date, datetime
import pytest
import sob
from oapi.client import (
URLENCODE_SAFE,
_censor_long_json_strings,
_format_deep_object_argument_value,
_format_dot_object_argument_value,
_format_form_argument_value,
_format_label_argument_value,
_format_matrix_argument_value,
_format_pipe_delimited_argument_value,
_format_primitive_value,
_format_simple_argument_value,
_format_space_delimited_argument_value,
_item_is_not_empty,
_iter_items,
format_argument_value,
urlencode,
)
from oapi.oas.model import Reference
def test_iter_items_yields_mapping_items() -> None:
result: list[tuple[str, object]] = list(_iter_items({"a": 1, "b": 2}))
assert result == [("a", 1), ("b", 2)]
def test_iter_items_yields_sob_dictionary_items() -> None:
dictionary: sob.abc.Dictionary = sob.model.Dictionary({"x": 1, "y": 2})
result: list[tuple[str, object]] = list(_iter_items(dictionary))
assert result == [("x", 1), ("y", 2)]
def test_iter_items_yields_sob_object_property_values() -> None:
"""
A `sob.abc.Object` iterates by *property name* (not by JSON key) --
`Reference`'s `$ref` property is named `ref` internally (since `$ref`
is not a valid Python identifier), and unset properties (`description`
here) are yielded too, with a value of `None`.
"""
reference: Reference = Reference(
{"$ref": "#/components/schemas/Foo", "summary": "hi"}
)
result: list[tuple[str, object]] = list(_iter_items(reference))
assert result == [
("description", None),
("ref", "#/components/schemas/Foo"),
("summary", "hi"),
]
def test_iter_items_yields_from_a_sequence_of_tuples() -> None:
result: list[tuple[str, object]] = list(_iter_items([("a", 1), ("b", 2)]))
assert result == [("a", 1), ("b", 2)]
def test_urlencode_bumps_nested_dictionary_values_to_the_top_level() -> None:
"""
When a query value is itself a dictionary/mapping, `urlencode` merges
that mapping's items into the top-level query instead of nesting it.
"""
encoded: str = urlencode({"a": 1, "nested": {"b": 2, "c": 3}})
assert encoded == "a=1&b=2&c=3"
def test_urlencode_repeats_a_key_for_sequence_values_by_default() -> None:
encoded: str = urlencode({"a": [1, 2, 3]})
assert encoded == "a=1&a=2&a=3"
def test_urlencode_default_safe_characters_are_not_percent_encoded() -> None:
encoded: str = urlencode({"a": "|;,/=+[]."})
assert encoded == f"a={URLENCODE_SAFE}"
@pytest.mark.parametrize(
("item", "expected"),
[
(("k", "v"), True),
(("k", None), False),
(("k", ""), False),
(("", "v"), False),
],
)
def test_item_is_not_empty(item: tuple[str, object], expected: bool) -> None:
assert _item_is_not_empty(item) is expected
def test_censor_long_json_strings_replaces_strings_over_the_limit() -> None:
text: str = '{"short": "ok", "long": "' + ("x" * 3000) + '"}'
censored: str = _censor_long_json_strings(text)
assert censored == '{"short": "ok", "long": "..."}'
def test_censor_long_json_strings_leaves_short_strings_untouched() -> None:
text: str = '{"short": "ok"}'
assert _censor_long_json_strings(text) == text
def test_censor_long_json_strings_respects_a_custom_limit() -> None:
text: str = '{"value": "abcdef"}'
censored: str = _censor_long_json_strings(text, limit=6)
assert censored == '{"value": "..."}'
@pytest.mark.parametrize(
("value", "expected"),
[
(None, None),
("abc", "abc"),
(True, "true"),
(False, "false"),
(42, "42"),
(3.14, "3.14"),
(decimal.Decimal("1.5"), "1.5"),
(b"hello", "aGVsbG8="),
(date(2024, 1, 1), "2024-01-01"),
(datetime(2024, 1, 1, 12, 0, 0), "2024-01-01T12:00:00"),
],
)
def test_format_primitive_value(value: object, expected: str | None) -> None:
assert _format_primitive_value(value) == expected # type: ignore[arg-type]
def test_format_primitive_value_rejects_unsupported_types() -> None:
with pytest.raises(TypeError):
_format_primitive_value(object()) # type: ignore[arg-type]
def test_format_simple_argument_value_with_a_primitive() -> None:
assert _format_simple_argument_value(5) == "5"
def test_format_simple_argument_value_with_a_sequence() -> None:
assert _format_simple_argument_value([1, 2, 3]) == "1,2,3"
def test_format_simple_argument_value_with_a_dictionary_not_exploded() -> None:
assert _format_simple_argument_value({"a": 1, "b": 2}) == "a,1,b,2"
def test_format_simple_argument_value_dict_explode_iterates_dict_keys() -> (
None
):
"""
`_format_simple_argument_value`'s exploded-dictionary branch iterates
`value` directly (`for item in value`), which -- for a plain `dict` --
yields its *keys*, not `(key, value)` pairs. `item[0]`/`item[1]` then
index into that key string rather than a tuple. This only avoids
crashing when every key is at least 2 characters long, and the
"value" half of the output is actually the second character of the
key, not the dictionary's mapped value. This is real, current
behavior of the function -- documented here, not corrected.
"""
result: str = _format_simple_argument_value(
{"ab": 1, "cd": 2}, explode=True
)
assert result == "a=b,c=d"
def test_format_simple_argument_value_dict_explode_crashes_on_short_keys() -> (
None
):
with pytest.raises(IndexError):
_format_simple_argument_value({"a": 1, "b": 2}, explode=True)
def test_format_simple_argument_value_rejects_unsupported_types() -> None:
with pytest.raises(ValueError, match=".*"):
_format_simple_argument_value(object()) # type: ignore[arg-type]
def test_format_label_argument_value_with_a_primitive() -> None:
assert _format_label_argument_value(5) == ".5"
def test_format_label_argument_value_with_a_sequence_not_exploded() -> None:
assert _format_label_argument_value([1, 2, 3]) == ".1,2,3"
def test_format_label_argument_value_with_a_sequence_exploded() -> None:
assert _format_label_argument_value([1, 2, 3], explode=True) == ".1.2.3"
def test_format_label_argument_value_dict_explode_shares_the_dict_quirk() -> (
None
):
with pytest.raises(IndexError):
_format_label_argument_value({"a": 1, "b": 2}, explode=True)
def test_format_label_argument_value_rejects_unsupported_exploded_types() -> (
None
):
with pytest.raises(ValueError, match=".*"):
_format_label_argument_value(object(), explode=True) # type: ignore[arg-type]
def test_format_matrix_argument_value_with_none() -> None:
assert _format_matrix_argument_value("id", None) is None
def test_format_matrix_argument_value_with_a_primitive() -> None:
assert _format_matrix_argument_value("id", 5) == ";id=5"
def test_format_matrix_argument_value_with_a_sequence_exploded() -> None:
result = _format_matrix_argument_value("id", [3, 4, 5], explode=True)
assert result == ";id=3;id=4;id=5"
def test_format_matrix_argument_value_with_a_sequence_not_exploded() -> None:
result = _format_matrix_argument_value("id", [3, 4, 5], explode=False)
assert result == ";id=3,4,5"
def test_format_matrix_argument_value_dict_explode_shares_the_dict_quirk() -> (
None
):
"""
Like `_format_simple_argument_value`, the exploded-dictionary branch
here also does `for item in value` over a plain `dict`, hitting the
same "iterates keys, not pairs" issue when a key is a single
character.
"""
with pytest.raises(IndexError):
_format_matrix_argument_value("id", {"a": 1, "b": 2}, explode=True)
def test_format_matrix_argument_value_rejects_unsupported_exploded_types() -> (
None
):
with pytest.raises(TypeError):
_format_matrix_argument_value("id", object(), explode=True) # type: ignore[arg-type]
def test_format_space_delimited_argument_value_with_a_primitive() -> None:
assert _format_space_delimited_argument_value(5) == "5"
def test_format_space_delimited_argument_value_with_a_sequence() -> None:
assert _format_space_delimited_argument_value([1, 2, 3]) == "1 2 3"
def test_format_space_delimited_argument_value_explode_uses_form() -> None:
result = _format_space_delimited_argument_value([1, 2, 3], explode=True)
assert result == ("1", "2", "3")
def test_format_space_delimited_argument_value_rejects_non_sequences() -> None:
with pytest.raises(ValueError, match=".*"):
_format_space_delimited_argument_value({"a": 1}, explode=False)
def test_format_pipe_delimited_argument_value_with_a_primitive() -> None:
assert _format_pipe_delimited_argument_value(None) is None
assert _format_pipe_delimited_argument_value(5) == "5"
def test_format_pipe_delimited_argument_value_with_a_sequence() -> None:
assert _format_pipe_delimited_argument_value([1, 2, 3]) == "1|2|3"
def test_format_pipe_delimited_argument_value_exploded_delegates_to_form() -> (
None
):
result = _format_pipe_delimited_argument_value([1, 2, 3], explode=True)
assert result == ("1", "2", "3")
def test_format_pipe_delimited_argument_value_rejects_non_sequences() -> None:
with pytest.raises(ValueError, match=".*"):
_format_pipe_delimited_argument_value({"a": 1}, explode=False)
def test_format_form_argument_value_with_a_primitive() -> None:
assert _format_form_argument_value(5) == "5"
def test_format_form_argument_value_with_a_sequence_not_exploded() -> None:
assert _format_form_argument_value([1, 2, 3]) == "1,2,3"
def test_format_form_argument_value_with_a_sequence_exploded() -> None:
result = _format_form_argument_value([1, 2, 3], explode=True)
assert result == ("1", "2", "3")
def test_format_form_argument_value_with_a_dictionary_exploded() -> None:
"""
Unlike the "simple"/"label"/"matrix" formatters, `_format_form_
argument_value` recognizes a plain `dict` as one of `_ITEMIZED_TYPES`
and uses `_iter_items`, so exploding a dictionary correctly yields
`(key, value)` pairs rather than iterating its keys.
"""
result = _format_form_argument_value({"a": 1, "b": 2}, explode=True)
assert result == {"a": "1", "b": "2"}
def test_format_form_argument_value_exploded_merges_duplicate_keys() -> None:
result = _format_form_argument_value(
[("a", 1), ("a", 2), ("b", 3)], explode=True
)
assert result == {"a": ["1", "2"], "b": "3"}
def test_format_form_argument_value_exploded_appends_a_third_duplicate() -> (
None
):
"""
A third occurrence of the same key exercises the "already collected
into a list" append branch, distinct from the second occurrence
(which converts a scalar into a two-item list).
"""
result = _format_form_argument_value(
[("a", 1), ("a", 2), ("a", 3)], explode=True
)
assert result == {"a": ["1", "2", "3"]}
def test_format_form_argument_value_rejects_unsupported_exploded_types() -> (
None
):
with pytest.raises(ValueError, match=".*"):
_format_form_argument_value(object(), explode=True) # type: ignore[arg-type]
def test_format_deep_object_argument_value_with_none() -> None:
assert _format_deep_object_argument_value("id", None) is None
def test_format_deep_object_argument_value_with_a_primitive() -> None:
assert _format_deep_object_argument_value("id", 5) == "5"
def test_format_deep_object_argument_value_requires_explode() -> None:
with pytest.raises(ValueError, match="only supports `explode=True`"):
_format_deep_object_argument_value("id", {"a": 1}, explode=False)
def test_format_deep_object_argument_value_with_a_flat_dictionary() -> None:
result = _format_deep_object_argument_value(
"id", {"a": 1, "b": 2}, explode=True
)
assert result == {"id[a]": "1", "id[b]": "2"}
def test_format_deep_object_argument_value_with_a_nested_dictionary() -> None:
result = _format_deep_object_argument_value(
"id", {"a": {"b": 1}}, explode=True
)
assert result == {"id[a][b]": "1"}
def test_format_deep_object_argument_value_dict_of_sequence_is_broken() -> (
None
):
"""
A dictionary value whose entry is itself a sequence of primitives
(rather than a sequence of dictionaries) hits a genuine bug: the
recursive call for each primitive item returns a plain formatted
string (e.g. `"1"`), and the caller then does
`deep_object.update(**that_string)`, which fails because a `str` is
not a mapping. This is real, current behavior -- documented here, not
corrected.
"""
with pytest.raises(TypeError, match="argument after \\*\\*"):
_format_deep_object_argument_value("id", {"a": [1, 2]}, explode=True)
def test_format_deep_object_argument_value_dict_rejects_bad_types() -> None:
with pytest.raises(TypeError):
_format_deep_object_argument_value("id", {"a": object()}, explode=True)
def test_format_deep_object_argument_value_with_a_sequence_of_primitives() -> (
None
):
result = _format_deep_object_argument_value("id", [1, 2, 3], explode=True)
assert result == {"id[0]": "1", "id[1]": "2", "id[2]": "3"}
def test_format_deep_object_argument_value_with_a_sequence_of_dicts() -> None:
result = _format_deep_object_argument_value(
"id", [{"a": 1}, {"b": 2}], explode=True
)
assert result == {"id[0][a]": "1", "id[1][b]": "2"}
def test_format_deep_object_argument_value_with_nested_sequences() -> None:
result = _format_deep_object_argument_value(
"id", [[1, 2], [3, 4]], explode=True
)
assert result == {
"id[0][0]": "1",
"id[0][1]": "2",
"id[1][0]": "3",
"id[1][1]": "4",
}
def test_format_deep_object_argument_value_rejects_a_non_sequence() -> None:
"""
A value that is neither a primitive, an itemized type (dict/Object),
nor a `collections.abc.Sequence` -- e.g. a `set` -- falls through to
the function's final `raise ValueError(value)`. This is a real,
reachable guard (unlike the two lines noted as dead code in Global
Constraints), just not exercised by any of the styles' other tests,
since query/path/header argument values are otherwise always a
primitive, mapping, or sequence.
"""
with pytest.raises(ValueError, match=".*"):
_format_deep_object_argument_value("id", {1, 2}, explode=True)
def test_format_dot_object_argument_value_uses_dot_notation() -> None:
result = _format_dot_object_argument_value(
"id", {"a": {"b": 1}}, explode=True
)
assert result == {"id.a.b": "1"}
@pytest.mark.parametrize(
("style", "value", "explode", "expected"),
[
("simple", 5, False, "5"),
("label", 5, False, ".5"),
("matrix", 5, False, ";id=5"),
("form", [1, 2], False, "1,2"),
("spaceDelimited", [1, 2], False, "1 2"),
("pipeDelimited", [1, 2], False, "1|2"),
],
)
def test_format_argument_value_dispatches_by_style(
style: str, value: object, explode: bool, expected: object
) -> None:
assert (
format_argument_value("id", value, style, explode=explode) # type: ignore[arg-type]
== expected
)
def test_format_argument_value_dispatches_deep_object_and_dot_object() -> None:
assert format_argument_value(
"id", {"a": 1}, "deepObject", explode=True
) == {"id[a]": "1"}
assert format_argument_value(
"id", {"a": 1}, "dotObject", explode=True
) == {"id.a": "1"}
def test_format_argument_value_rejects_an_unknown_style() -> None:
with pytest.raises(ValueError, match="bogus"):
format_argument_value("id", 5, "bogus")
def test_format_argument_value_marshals_a_sob_model_first() -> None:
reference: Reference = Reference({"$ref": "#/x"})
assert format_argument_value("id", reference, "simple") == "$ref,#/x"
def test_format_argument_value_multipart_bypasses_formatting_for_bytes() -> (
None
):
assert (
format_argument_value("id", b"raw bytes", "simple", multipart=True)
== b"raw bytes"
)
def test_format_argument_value_multipart_bypasses_a_readable() -> None:
import io
readable: io.BytesIO = io.BytesIO(b"data")
assert (
format_argument_value("id", readable, "simple", multipart=True)
is readable
)
def test_format_argument_value_multipart_bypasses_a_sequence_of_bytes() -> (
None
):
value: list[bytes] = [b"a", b"b"]
assert (
format_argument_value("id", value, "simple", multipart=True) is value
)
- [ ] Step 2: Verify
Run:
hatch run hatch-test.py3.10:pytest tests/test_client_argument_formatting.py -v
hatch run mypy --strict --ignore-missing-imports tests/test_client_argument_formatting.py
hatch fmt --formatter
hatch fmt --check
test_format_deep_object_argument_value_rejects_a_non_sequence, added
during final review — see Self-Review); mypy Success: no issues found
in 1 source file; hatch fmt --check clean. All were verified against
the real library while this file was authored — every assertion's
expected value was produced by actually calling the real function, not
predicted.
- [ ] Step 3: Commit
git add tests/test_client_argument_formatting.py
git commit tests/test_client_argument_formatting.py -m "test: add coverage for client.py's argument-value formatting functions"
Task 3: tests/test_client_request_assembly.py
Files:
- Create: tests/test_client_request_assembly.py
Interfaces:
- Consumes: oapi.client._assemble_request, _format_request_data,
_get_file_name, _get_first, _remove_none,
_represent_http_response, _set_response_callback,
get_request_curl, urlencode (existing). oapi._multipart_request.
MultipartRequest, Part (existing). oapi.oas.model.Reference
(existing). tests/servers.py's Response, http_test_server
(existing, from the infrastructure plan).
- Produces: tests/test_client_request_assembly.py, 38 tests.
- [ ] Step 1: Write the test file
Create tests/test_client_request_assembly.py:
from __future__ import annotations
import collections.abc
import gzip
import io
import typing
from urllib.request import Request, urlopen
import pytest
from servers import Response, http_test_server
from oapi._multipart_request import MultipartRequest, Part
from oapi.client import (
_assemble_request,
_format_request_data,
_get_file_name,
_get_first,
_remove_none,
_represent_http_response,
_set_response_callback,
get_request_curl,
urlencode,
)
from oapi.oas.model import Reference
def test_get_request_curl_basic_get_has_no_data_flag() -> None:
request: Request = Request("http://example.com/foo", method="GET")
assert get_request_curl(request) == "curl -X GET -i http://example.com/foo"
def test_get_request_curl_censors_a_matching_header_by_default() -> None:
request: Request = Request(
"http://example.com/foo",
data=b'{"a": 1}',
method="POST",
headers={
"Content-type": "application/json",
"Authorization": "Bearer secret",
},
)
assert get_request_curl(request) == (
"curl -X POST -i -H 'Authorization: ***' "
"-H 'Content-type: application/json' "
"-d '{\"a\": 1}' http://example.com/foo"
)
def test_get_request_curl_censors_a_matching_form_parameter() -> None:
form_data: bytes = urlencode(
{"client_secret": "topsecret", "grant_type": "client_credentials"}
).encode()
request: Request = Request(
"http://example.com/token",
data=form_data,
method="POST",
headers={"Content-type": "application/x-www-form-urlencoded"},
)
assert get_request_curl(request) == (
"curl -X POST -i -H 'Content-type: application/x-www-form-urlencoded' "
"-d 'client_secret=***&grant_type=client_credentials' "
"http://example.com/token"
)
def test_get_request_curl_non_utf8_body_becomes_a_placeholder() -> None:
request: Request = Request(
"http://example.com/bin", data=b"\xff\xfe\x00", method="POST"
)
assert get_request_curl(request) == (
"curl -X POST -i -d *** http://example.com/bin"
)
def test_get_request_curl_decodes_gzip_content_before_rendering() -> None:
request: Request = Request(
"http://example.com/foo",
data=gzip.compress(b'{"a": 1}'),
method="POST",
headers={
"Content-type": "application/json",
"Content-encoding": "gzip",
},
)
assert get_request_curl(request) == (
"curl -X POST -i -H 'Content-encoding: gzip' "
"-H 'Content-type: application/json' "
"-d '{\"a\": 1}' http://example.com/foo"
)
def test_get_request_curl_reads_a_readable_data_object() -> None:
request: Request = Request(
"http://example.com/foo",
data=io.BytesIO(b'{"a":1}'),
method="POST",
headers={"Content-type": "application/json"},
)
assert get_request_curl(request) == (
"curl -X POST -i -H 'Content-type: application/json' "
"-d '{\"a\":1}' http://example.com/foo"
)
def test_get_request_curl_joins_an_iterable_of_byte_chunks() -> None:
encoded: bytes = urlencode({"a": "1", "b": "2"}).encode()
request: Request = Request(
"http://example.com/foo",
method="POST",
headers={"Content-type": "application/x-www-form-urlencoded"},
)
request.data = [encoded[:5], encoded[5:]]
assert get_request_curl(request) == (
"curl -X POST -i -H 'Content-type: application/x-www-form-urlencoded' "
"-d 'a=1&b=2' http://example.com/foo"
)
def test_get_request_curl_without_censored_headers_or_parameters() -> None:
request: Request = Request(
"http://example.com/foo",
data=b"client_secret=topsecret",
method="POST",
headers={
"Content-type": "application/x-www-form-urlencoded",
"Authorization": "Bearer secret",
},
)
assert get_request_curl(
request, censored_headers=(), censored_parameters=()
) == (
"curl -X POST -i -H 'Authorization: Bearer secret' "
"-H 'Content-type: application/x-www-form-urlencoded' "
"-d client_secret=topsecret http://example.com/foo"
)
def test_represent_http_response_includes_status_headers_and_body() -> None:
with http_test_server(
responses={
("GET", "/foo"): Response(
status=200,
headers={"X-Custom": "abc", "Authorization": "secret"},
body=b"hello world",
)
}
) as server:
request: Request = Request(server.url + "/foo")
with urlopen(request) as response: # noqa: S310
text: str = _represent_http_response(
response, censored_headers=("authorization",)
)
assert "200" in text
assert "X-Custom: abc" in text
assert "Authorization: ***" in text
assert text.endswith("hello world")
def test_set_response_callback_invokes_the_callback_on_read() -> None:
with http_test_server(
responses={("GET", "/foo"): Response(status=200, body=b"hello")}
) as server:
request: Request = Request(server.url + "/foo")
captured: list[str] = []
with urlopen(request) as response: # noqa: S310
_set_response_callback(response, callback=captured.append)
data: bytes = response.read()
assert data == b"hello"
assert len(captured) == 1
assert captured[0].endswith("hello")
def test_set_response_callback_decodes_encoded_content_before_callback() -> (
None
):
with http_test_server(
responses={
("GET", "/foo"): Response(
status=200,
headers={"Content-encoding": "gzip"},
body=gzip.compress(b"hello gzip"),
)
}
) as server:
request: Request = Request(server.url + "/foo")
captured: list[str] = []
with urlopen(request) as response: # noqa: S310
_set_response_callback(response, callback=captured.append)
data: bytes = response.read()
assert data == b"hello gzip"
assert captured[0].endswith("hello gzip")
def test_remove_none_filters_a_mapping() -> None:
result: collections.abc.Sequence[tuple[str, object]] = _remove_none(
{"a": 1, "b": None, "c": "x"}
)
assert result == (("a", 1), ("c", "x"))
def test_remove_none_filters_a_sequence_of_pairs() -> None:
result: collections.abc.Sequence[tuple[str, object]] = _remove_none(
[("a", 1), ("b", None)]
)
assert result == (("a", 1),)
def test_get_first_returns_the_first_item() -> None:
assert _get_first([10, 20, 30]) == 10
assert _get_first(iter(["a", "b"])) == "a"
def test_get_first_raises_on_an_empty_iterable() -> None:
with pytest.raises(StopIteration):
_get_first([])
def test_format_request_data_with_a_json_string() -> None:
assert _format_request_data('{"a": 1}', {}) == b'{"a": 1}'
def test_format_request_data_with_json_bytes() -> None:
assert _format_request_data(b'{"a": 1}', {}) == b'{"a": 1}'
def test_format_request_data_rejects_json_and_data_together() -> None:
with pytest.raises(
ValueError, match="only contain form data or JSON data"
):
_format_request_data('{"a": 1}', {"x": 1})
def test_format_request_data_serializes_a_sob_model_to_json() -> None:
reference: Reference = Reference({"$ref": "#/x"})
assert _format_request_data(reference, {}) == b'{"$ref": "#/x"}'
def test_format_request_data_urlencodes_form_data_and_drops_none() -> None:
result: bytes | None = _format_request_data(
None, {"a": 1, "b": None, "c": b"xyz"}
)
assert result == b"a=1&c=eHl6"
def test_format_request_data_base64_encodes_a_readable_value() -> None:
result: bytes | None = _format_request_data(
None, {"file": io.BytesIO(b"content")}
)
assert result == b"file=Y29udGVudA=="
def test_format_request_data_rejects_a_readable_returning_non_bytes() -> None:
class BadReadable:
def read(self) -> str:
return "not bytes"
with pytest.raises(TypeError):
_format_request_data(None, {"file": BadReadable()})
def test_format_request_data_applies_content_encoding() -> None:
result: bytes | None = _format_request_data(
'{"a": 1}', {}, content_encoding="gzip"
)
assert result is not None
assert result[:2] == b"\x1f\x8b"
assert gzip.decompress(result) == b'{"a": 1}'
def test_format_request_data_with_neither_json_nor_data_returns_none() -> None:
assert _format_request_data(None, {}) is None
def test_get_file_name_prefers_a_url_attribute() -> None:
class WithUrl:
url: str = "http://example.com/path/to/file.json"
assert _get_file_name(WithUrl()) == "file.json" # type: ignore[arg-type]
def test_get_file_name_falls_back_to_a_name_attribute() -> None:
class WithName:
name: str = "/local/path/data.csv"
assert _get_file_name(WithName()) == "data.csv" # type: ignore[arg-type]
def test_get_file_name_uses_the_default_when_neither_is_present() -> None:
class Neither:
pass
assert _get_file_name(Neither(), default="fallback.bin") == "fallback.bin" # type: ignore[arg-type]
def test_assemble_request_builds_a_plain_json_request() -> None:
request: Request = _assemble_request(
"http://example.com/foo",
"post",
'{"a": 1}',
{},
{"Content-type": "application/json"},
multipart=False,
multipart_data_headers={},
)
assert type(request) is Request
assert request.method == "POST"
assert request.full_url == "http://example.com/foo"
def test_assemble_request_builds_a_urlencoded_form_request() -> None:
request: Request = _assemble_request(
"http://example.com/foo",
"post",
None,
{"a": 1, "b": 2},
{},
multipart=False,
multipart_data_headers={},
)
assert request.data == b"a=1&b=2"
def test_assemble_request_rejects_a_non_http_scheme() -> None:
with pytest.raises(ValueError, match="ftp://example.com/foo"):
_assemble_request(
"ftp://example.com/foo",
"get",
None,
{},
{},
multipart=False,
multipart_data_headers={},
)
def test_assemble_request_url_guard_does_not_guarantee_a_valid_request() -> (
None
):
"""
`_assemble_request`'s own scheme guard treats a scheme-less relative
URL like `/relative/path` as acceptable (no `:` appears before the
first `/`), but `urllib.request.Request` itself then rejects it when
actually constructed, since it isn't a fully qualified URL. This is
real, current behavior of the two layers together, not something
`_assemble_request` catches on its own.
"""
with pytest.raises(ValueError, match="."):
_assemble_request(
"/relative/path",
"get",
None,
{},
{},
multipart=False,
multipart_data_headers={},
)
def test_assemble_request_multipart_rejects_json() -> None:
with pytest.raises(
ValueError, match="only contain form data, not JSON data"
):
_assemble_request(
"http://x",
"post",
'{"a":1}',
{"f": b"x"},
{},
multipart=True,
multipart_data_headers={},
)
def test_assemble_request_multipart_rejects_empty_data() -> None:
with pytest.raises(
ValueError, match="only contain form data, not JSON data"
):
_assemble_request(
"http://x",
"post",
None,
{},
{},
multipart=True,
multipart_data_headers={},
)
def test_assemble_request_multipart_wraps_a_scalar_value_as_text() -> None:
request: MultipartRequest = typing.cast(
"MultipartRequest",
_assemble_request(
"http://x",
"post",
None,
{"name": "value"},
{},
multipart=True,
multipart_data_headers={},
),
)
assert request.parts is not None
assert len(request.parts) == 1
part: Part = list(request.parts)[0]
assert part.headers is not None
assert dict(part.headers) == {
"Content-disposition": 'form-data; name="name"',
"Content-type": "text/plain",
}
assert part.data == b"value"
def test_assemble_request_multipart_respects_preset_headers() -> None:
request: MultipartRequest = typing.cast(
"MultipartRequest",
_assemble_request(
"http://x",
"post",
None,
{"file": b"binarydata"},
{},
multipart=True,
multipart_data_headers={
"file": {"Content-disposition": "custom; already=set"}
},
),
)
assert request.parts is not None
part: Part = list(request.parts)[0]
assert part.headers is not None
assert dict(part.headers) == {
"Content-disposition": "custom; already=set",
"Content-type": "application/octet-stream",
}
def test_assemble_request_multipart_derives_a_filename_from_a_readable() -> (
None
):
file: io.BytesIO = io.BytesIO(b"filecontent")
file.name = "upload.bin"
request: MultipartRequest = typing.cast(
"MultipartRequest",
_assemble_request(
"http://x",
"post",
None,
{"file": file},
{},
multipart=True,
multipart_data_headers={},
),
)
assert request.parts is not None
part: Part = list(request.parts)[0]
assert part.headers is not None
assert dict(part.headers) == {
"Content-disposition": (
'form-data; name="file"; filename="upload.bin"'
),
"Content-type": "application/octet-stream",
}
assert part.data == b"filecontent"
def test_assemble_request_multipart_creates_one_part_per_sequence_item() -> (
None
):
request: MultipartRequest = typing.cast(
"MultipartRequest",
_assemble_request(
"http://x",
"post",
None,
{"tags": [b"one", b"two"]},
{},
multipart=True,
multipart_data_headers={},
),
)
assert request.parts is not None
assert len(request.parts) == 2
assert [part.data for part in request.parts] == [b"one", b"two"]
def test_assemble_request_multipart_json_encodes_a_mapping_value() -> None:
request: MultipartRequest = typing.cast(
"MultipartRequest",
_assemble_request(
"http://x",
"post",
None,
{"meta": {"a": 1}},
{},
multipart=True,
multipart_data_headers={},
),
)
assert request.parts is not None
part: Part = list(request.parts)[0]
assert part.headers is not None
assert part.headers["Content-type"] == "application/json"
assert part.data == b'{"a": 1}'
- [ ] Step 2: Verify
Run:
hatch run hatch-test.py3.10:pytest tests/test_client_request_assembly.py -v
hatch run mypy --strict --ignore-missing-imports tests/test_client_request_assembly.py
hatch fmt --formatter
hatch fmt --check
Success: no issues found in 1 source file
(_assemble_request's declared return type is the base Request, so
each multipart assertion typing.casts to the real runtime type,
MultipartRequest, before accessing .parts/.headers — both of
which are themselves Optional-typed on the real classes even though
they're never actually None for a request built with real parts/
headers arguments, hence the assert ... is not None narrowing
lines); hatch fmt --check clean.
- [ ] Step 3: Commit
git add tests/test_client_request_assembly.py
git commit tests/test_client_request_assembly.py -m "test: add coverage for client.py's request/response assembly helpers"
Task 4: tests/test_client_retry_and_encoding.py
Files:
- Create: tests/test_client_retry_and_encoding.py
Interfaces:
- Consumes: oapi.client._decode_content, _encode_content,
default_retry_hook, retry (existing).
- Produces: tests/test_client_retry_and_encoding.py, 24 tests.
- Requires: Task 1 (zstandard/brotli in hatch-test).
- [ ] Step 1: Write the test file
Create tests/test_client_retry_and_encoding.py:
from __future__ import annotations
import gzip
import logging
import time
import warnings
import zlib
from urllib.error import HTTPError
import pytest
from oapi.client import (
_decode_content,
_encode_content,
default_retry_hook,
retry,
)
@pytest.mark.parametrize(
("code", "expected"),
[
(404, False),
(401, False),
(409, False),
(410, False),
(500, True),
(503, True),
],
)
def test_default_retry_hook_by_status_code(code: int, expected: bool) -> None:
error: HTTPError = HTTPError("http://x", code, "message", None, None) # type: ignore[arg-type]
assert default_retry_hook(error) is expected
def test_default_retry_hook_retries_non_http_errors() -> None:
assert default_retry_hook(ValueError("not an http error")) is True
def test_retry_returns_on_success_without_retrying() -> None:
calls: list[int] = []
@retry(number_of_attempts=3, errors=ValueError)
def succeeds() -> str:
calls.append(1)
return "ok"
with warnings.catch_warnings():
warnings.simplefilter("ignore")
assert succeeds() == "ok"
assert len(calls) == 1
def test_retry_retries_warns_and_backs_off_until_success() -> None:
"""
One assertion covers three behaviors of a single retry: the call is
re-attempted until it succeeds, a `UserWarning` is emitted for the
failed attempt, and the retry sleeps for `2 ** attempt_number`
seconds beforehand -- checked together so the ~2 second real sleep
is only paid once.
"""
calls: list[int] = []
@retry(number_of_attempts=2, errors=ValueError)
def flaky() -> str:
calls.append(1)
if len(calls) < 2:
message: str = "fail once"
raise ValueError(message)
return "ok"
start: float = time.monotonic()
with pytest.warns(UserWarning, match="Attempt # 1"):
assert flaky() == "ok"
elapsed: float = time.monotonic() - start
assert len(calls) == 2
assert elapsed >= 1.9
def test_retry_exhausts_attempts_and_reraises() -> None:
calls: list[int] = []
@retry(number_of_attempts=2, errors=ValueError, retry_hook=lambda e: True)
def always_fails() -> None:
calls.append(1)
message: str = "always fails"
raise ValueError(message)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
with pytest.raises(ValueError, match="always fails"):
always_fails()
assert len(calls) == 2
def test_retry_hook_returning_false_skips_retry() -> None:
calls: list[int] = []
@retry(number_of_attempts=5, errors=ValueError, retry_hook=lambda e: False)
def never_retry() -> None:
calls.append(1)
message: str = "no retry"
raise ValueError(message)
with pytest.raises(ValueError, match="no retry"):
never_retry()
assert len(calls) == 1
def test_retry_default_number_of_attempts_does_nothing() -> None:
calls: list[int] = []
@retry(errors=ValueError)
def always_fails() -> None:
calls.append(1)
message: str = "boom"
raise ValueError(message)
with pytest.raises(ValueError, match="boom"):
always_fails()
assert len(calls) == 1
def test_retry_logs_a_warning_when_a_logger_is_provided() -> None:
logger: logging.Logger = logging.getLogger(
"test-client-retry-and-encoding"
)
records: list[str] = []
class RecordingHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
records.append(record.getMessage())
handler: RecordingHandler = RecordingHandler()
logger.addHandler(handler)
logger.setLevel(logging.WARNING)
try:
calls: list[int] = []
@retry(number_of_attempts=2, errors=ValueError, logger=logger)
def flaky() -> str:
calls.append(1)
if len(calls) < 2:
message: str = "fail"
raise ValueError(message)
return "ok"
with warnings.catch_warnings():
warnings.simplefilter("ignore")
assert flaky() == "ok"
assert len(records) == 1
finally:
logger.removeHandler(handler)
@pytest.mark.parametrize("encoding", ["gzip", "deflate", "zstd", "br"])
def test_encode_and_decode_content_round_trip(encoding: str) -> None:
data: bytes = b'{"hello": "world"}' * 50
encoded: bytes = _encode_content(data, encoding)
assert len(encoded) < len(data)
assert _decode_content(encoded, encoding) == data
@pytest.mark.parametrize("encoding", ["dcb", "dcz"])
def test_encode_and_decode_content_brotli_aliases(encoding: str) -> None:
data: bytes = b'{"hello": "world"}' * 50
encoded: bytes = _encode_content(data, encoding)
assert _decode_content(encoded, encoding) == data
def test_encode_content_is_a_no_op_for_empty_data() -> None:
assert _encode_content(b"", "gzip") == b""
def test_decode_content_is_a_no_op_for_empty_data() -> None:
assert _decode_content(b"", "gzip") == b""
def test_encode_and_decode_content_are_case_and_whitespace_insensitive() -> (
None
):
data: bytes = b'{"hello": "world"}' * 50
encoded: bytes = _encode_content(data, " GZIP ")
assert _decode_content(encoded, " GZIP ") == data
def test_encode_content_comma_branch_only_applies_the_first_encoding() -> None:
"""
A comma-separated `content_encoding` is meant to apply each encoding
in the order listed. `_encode_content`'s comma branch instead
recursively *decodes* the still-plain data using the remaining
tokens before applying the first one -- a genuine bug in the current
source, documented here rather than corrected. It happens not to
raise when the remaining token (`"identity"`) is unrecognized, since
`_decode_content` silently returns unrecognized-encoding data
unchanged; the practical effect is that only the first-listed
encoding (`gzip`) is actually applied.
"""
data: bytes = b'{"hello": "world"}' * 50
encoded: bytes = _encode_content(data, "gzip,identity")
assert gzip.decompress(encoded) == data
def test_decode_content_comma_branch_reverses_a_real_encoding_chain() -> None:
"""
`_decode_content`'s comma branch is correct: for
`Content-Encoding: gzip, deflate` (gzip applied first, then
deflate), decoding must undo deflate first, then gzip -- which is
exactly what the recursive call (decode the remaining tokens first,
then apply the first token's decoder) does.
"""
data: bytes = b'{"hello": "world"}' * 50
double_encoded: bytes = zlib.compress(gzip.compress(data))
assert _decode_content(double_encoded, "gzip,deflate") == data
- [ ] Step 2: Verify
Run:
hatch run hatch-test.py3.10:pytest tests/test_client_retry_and_encoding.py -v
hatch run mypy --strict --ignore-missing-imports tests/test_client_retry_and_encoding.py
hatch fmt --formatter
hatch fmt --check
Success: no
issues found in 1 source file; hatch fmt --check clean. Both zstd
and br/dcb/dcz round-trips genuinely exercise real
zstandard/brotli compression — confirmed by checking len(encoded)
< len(data) (a real compression ratio on repetitive input), not merely
that no exception was raised.
- [ ] Step 3: Commit
git add tests/test_client_retry_and_encoding.py
git commit tests/test_client_retry_and_encoding.py -m "test: add coverage for client.py's retry decorator and content encoding"
Task 5: tests/test_client_pickling.py
Files:
- Create: tests/test_client_pickling.py
Interfaces:
- Consumes: oapi.client.SSLContext, _make_http_errors_pickleable,
_make_loggers_pickleable, _make_thread_locks_pickleable
(existing).
- Produces: tests/test_client_pickling.py, 7 tests.
- [ ] Step 1: Write the test file
Create tests/test_client_pickling.py:
from __future__ import annotations
import pickle
import threading
from email.message import Message
from logging import Logger, getLogger
from urllib.error import HTTPError
from oapi.client import (
SSLContext,
_make_http_errors_pickleable,
_make_loggers_pickleable,
_make_thread_locks_pickleable,
)
def test_make_thread_locks_pickleable_is_idempotent_and_pickles_a_lock() -> (
None
):
_make_thread_locks_pickleable()
lock: threading.Lock = threading.Lock()
unpickled: threading.Lock = pickle.loads(pickle.dumps(lock))
assert type(unpickled) is type(lock)
def test_make_thread_locks_pickleable_pickles_an_rlock() -> None:
rlock: threading.RLock = threading.RLock()
unpickled: threading.RLock = pickle.loads(pickle.dumps(rlock))
assert type(unpickled) is type(rlock)
def test_make_http_errors_pickleable_is_idempotent_and_pickles_an_error() -> (
None
):
_make_http_errors_pickleable()
headers: Message[str, str] = Message()
headers["X"] = "1"
error: HTTPError = HTTPError("http://x", 404, "not found", headers, None)
unpickled: HTTPError = pickle.loads(pickle.dumps(error))
assert unpickled.code == 404
assert unpickled.msg == "not found"
def test_make_loggers_pickleable_is_idempotent_and_pickles_a_logger() -> None:
_make_loggers_pickleable()
logger: Logger = getLogger("test-client-pickling")
unpickled: Logger = pickle.loads(pickle.dumps(logger))
assert unpickled.name == "test-client-pickling"
assert unpickled is logger
def test_ssl_context_default_verifies_the_hostname() -> None:
import ssl
context: SSLContext = SSLContext()
assert context.check_hostname is True
assert context.verify_mode == ssl.CERT_REQUIRED
def test_ssl_context_can_disable_hostname_verification() -> None:
import ssl
context: SSLContext = SSLContext(check_hostname=False)
assert context.check_hostname is False
assert context.verify_mode == ssl.CERT_NONE
def test_ssl_context_pickles_as_a_fresh_instance() -> None:
context: SSLContext = SSLContext(check_hostname=False)
unpickled: SSLContext = pickle.loads(pickle.dumps(context))
assert unpickled.check_hostname is False
assert unpickled is not context
- [ ] Step 2: Verify
Run:
hatch run hatch-test.py3.10:pytest tests/test_client_pickling.py -v
hatch run mypy --strict --ignore-missing-imports tests/test_client_pickling.py
hatch fmt --formatter
hatch fmt --check
Success: no issues found in 1 source file
(note HTTPError's fourth positional parameter is typed as
email.message.Message[str, str], not dict/None — a real Message
instance is constructed rather than passing a plain dict or None,
both of which are real mypy errors against the actual stub); hatch fmt
--check clean.
- [ ] Step 3: Commit
git add tests/test_client_pickling.py
git commit tests/test_client_pickling.py -m "test: add coverage for client.py's pickling helpers and SSLContext"
Task 6: 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_argument_formatting.py tests/test_client_request_assembly.py tests/test_client_retry_and_encoding.py tests/test_client_pickling.py -v
hatch run hatch-test.py3.10:pytest tests/ -q
git checkout -- tests/input-data/languagetool-swagger.json
retry's real warnings.warn calls or the real http_test_server
instances could in principle interact with other tests' global state —
they don't, confirmed).
- [ ] Step 2: Confirm the coverage delta
hatch run hatch-test.py3.10:coverage run -m pytest tests/test_client_argument_formatting.py tests/test_client_request_assembly.py tests/test_client_retry_and_encoding.py tests/test_client_pickling.py -q
hatch run hatch-test.py3.10:coverage report -m --include="*/client.py"
src/oapi/client.py moves from 18% to roughly 38% (whole-file
percentage — the pure-function layer, lines 109-1064, is the first
quarter of a 4039-line file dominated by the Client and ClientModule
classes covered in later plans). Remaining "Missing" lines below 1215
should be limited to 457-460, 842 — the two confirmed-dead-code
lines from Global Constraints — everything else in the pure-function
layer should show as covered, including line 465 (a real, reachable
raise ValueError(value) for non-primitive/non-itemized/non-sequence
input such as a set, initially miscategorized as dead alongside
454-460 and corrected during final review — see Task 2's
test_format_deep_object_argument_value_rejects_a_non_sequence).
- [ ] Step 3: Update project memory
Update the project-oapi-test-coverage-initiative memory file to note
this plan complete and move to the next step (the Client runtime
plan), per the standing blanket-execution-approval instruction — no
user confirmation needed for this step.
Self-Review
1. Spec coverage: The spec's step 4 ("client.py argument-formatting
+ content-encoding") is fully addressed: every function in the
pure-function layer (_iter_items at line 109 through _get_first at
line 1064) has a test, across the four files. The two small
module-level helper classes/registrars that live in this same region
(SSLContext, the three _make_*_pickleable functions) are covered in
Task 5. retry's exponential backoff — the one place genuine wall-clock
time is unavoidable without mocking — is tested with real timing
assertions, consolidated to minimize total real time spent. Content
encoding is tested against all four real codecs the source supports
(gzip, deflate, zstd, br, plus the dcb/dcz brotli aliases),
which required closing a real gap in the hatch-test environment
(Task 1) rather than skipping those branches.
2. Placeholder scan: No "TBD"/"add appropriate handling"/"similar to
Task N" language. Every line of test code in every task was executed
against the real library before being written into this document — not
just once per function, but as the exact final file content, re-run
after every fix. Five genuine test-authoring mistakes were caught this
way before they ever reached this plan (two wrong expected values for
_format_deep_object_argument_value/_censor_long_json_strings in
Task 2; a Parts/Headers subscript assumption and an over-quoted
shlex.quote expectation in Task 3; an invalid dict-typed HTTPError
header argument in Task 5) — all fixed by re-running against the real
functions, not by adjusting the functions to match a wrong guess. Five
genuine source behaviors (not test bugs) were discovered and are
documented as real, current behavior in Global Constraints rather than
"corrected": three separate dict-iteration quirks shared across
_format_simple_argument_value/_format_label_argument_value/
_format_matrix_argument_value, _format_deep_object_argument_value's
crash on a dict-of-primitive-sequence value, _encode_content's broken
comma-separated multi-encoding branch, and _assemble_request's
URL-scheme guard not guaranteeing a constructible Request. Two lines
were traced and confirmed genuinely dead code and are explicitly left
uncovered rather than contorted around. A third line (465, the same
function's final raise ValueError(value) fallback) was initially
lumped in with those two as dead in an earlier draft — this was wrong,
caught by the opus final-branch review, and independently
re-confirmed by the controller with a live hatch run
hatch-test.py3.10:python - probe (_format_deep_object_argument_value
("id", {1, 2}, explode=True) really does raise ValueError at line
465, since a set is neither a primitive, an itemized type, nor a
Sequence). Rather than repeating the prior plan's mistake of leaving
a real, reachable line undertested, test_format_deep_object_
argument_value_rejects_a_non_sequence was added to Task 2 (bringing
its count to 81) to cover it. This is the second time in this
initiative a "coverage gap must be dead code" claim was wrong; both
times the fix was the same — trace the real call graph (or run the
real function) rather than trust the first plausible explanation, and
when a claim turns out wrong, add the missing test rather than just
correcting the prose.
3. Type consistency: Each of the four files declares its own
complete import block (no cross-file imports between the new test
files); Reference is imported identically in Tasks 2 and 3 wherever
needed. MultipartRequest/Part are imported once in Task 3 and used
consistently for every multipart assertion via the same typing.cast
pattern. Message[str, str] in Task 5 is the one deliberate,
documented exception to "always annotate with the obvious type" (see
Global Constraints) — used nowhere else in this plan.
Plan complete and saved to
docs/superpowers/plans/2026-08-02-client-argument-formatting-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.