From 5890cfff8f744a08b5fa2a75a83959bd16629efc Mon Sep 17 00:00:00 2001 From: Douglas Strodtman Date: Thu, 13 Aug 2026 10:14:00 -0400 Subject: [PATCH 1/3] Fix #165 Five of the httpdomain renderer's options map to 'None' in 'option_spec'. docutils treats 'None' as "option explicitly disabled" and raises KeyError for it, which the directive machinery reports as 'unknown option'. So 'response-examples-for', 'request-parameters-order' and the three example preference options could not be used at all. Give them the same whitespace splitting converter that 'http-methods-order' already had, which is what all five consumers of these values expect. The converter also tolerates an option passed with no value, where docutils hands over 'None' rather than a string. Signed-off-by: Douglas Strodtman --- .../openapi/renderers/_httpdomain.py | 20 +++- tests/conftest.py | 13 ++- tests/renderers/httpdomain/test_options.py | 103 ++++++++++++++++++ 3 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 tests/renderers/httpdomain/test_options.py diff --git a/sphinxcontrib/openapi/renderers/_httpdomain.py b/sphinxcontrib/openapi/renderers/_httpdomain.py index 5118e15..3de334d 100644 --- a/sphinxcontrib/openapi/renderers/_httpdomain.py +++ b/sphinxcontrib/openapi/renderers/_httpdomain.py @@ -30,6 +30,14 @@ def indented(generator, indent=3): yield item +def _split_option(value): + """Parse a whitespace delimited option value into a list of tokens.""" + + # An option may be passed with no value at all, in which case docutils + # hands over 'None' instead of a string. + return (value or "").split() + + def _iterinorder(iterable, order_by, key=lambda x: x, case_sensitive=False): """Iterate over iterable in a given order.""" @@ -195,12 +203,12 @@ class HttpdomainRenderer(abc.RestructuredTextRenderer): option_spec = { "markup": functools.partial(directives.choice, values=_markup_converters), - "http-methods-order": lambda s: s.split(), - "response-examples-for": None, - "request-parameters-order": None, - "example-preference": None, - "request-example-preference": None, - "response-example-preference": None, + "http-methods-order": _split_option, + "response-examples-for": _split_option, + "request-parameters-order": _split_option, + "example-preference": _split_option, + "request-example-preference": _split_option, + "response-example-preference": _split_option, "generate-examples-from-schemas": directives.flag, "no-json-schema-description": directives.flag, } diff --git a/tests/conftest.py b/tests/conftest.py index d102a4a..7b69601 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import io import os import pathlib import textwrap @@ -45,7 +46,7 @@ def run_sphinx(tmpdir): src = tmpdir.ensure('src', dir=True) out = tmpdir.ensure('out', dir=True) - def run(spec, options={}): + def run(spec, options={}, directive='openapi'): options_raw = '\n'.join([ ' %s' % _format_option_raw(key, val) for key, val in options.items()]) @@ -64,17 +65,23 @@ def run(spec, options={}): encoding='utf-8') src.join('index.rst').write_text( - '.. openapi:: %s\n%s' % (spec, options_raw), + '.. %s:: %s\n%s' % (directive, spec, options_raw), encoding='utf-8') + # Warnings are captured and returned so tests can assert on them. + warning = io.StringIO() + Sphinx( srcdir=src.strpath, confdir=src.strpath, outdir=out.strpath, doctreedir=out.join('.doctrees').strpath, - buildername='html' + buildername='html', + warning=warning ).build() + return warning.getvalue() + yield run diff --git a/tests/renderers/httpdomain/test_options.py b/tests/renderers/httpdomain/test_options.py new file mode 100644 index 0000000..44c512f --- /dev/null +++ b/tests/renderers/httpdomain/test_options.py @@ -0,0 +1,103 @@ +"""Test the directive options the 'httpdomain' renderer accepts.""" + +import textwrap + +import pytest + +from sphinxcontrib.openapi import renderers + +_SPEC = """\ +openapi: 3.0.0 +info: + title: An example spec. + version: 1.0.0 +paths: + /evidences: + get: + responses: + '200': + description: An evidence. + content: + application/json: + schema: + type: object + properties: + id: + type: string +""" + + +_WHITESPACE_DELIMITED_OPTIONS = [ + pytest.param("http-methods-order", "head get"), + pytest.param("response-examples-for", "200 201 2XX 404"), + pytest.param("request-parameters-order", "query path header cookie"), + pytest.param("example-preference", "application/json text/plain"), + pytest.param("request-example-preference", "application/json text/plain"), + pytest.param("response-example-preference", "application/json text/plain"), +] + + +@pytest.mark.parametrize(["option", "value"], _WHITESPACE_DELIMITED_OPTIONS) +def test_option_is_accepted(tmpdir, run_sphinx, option, value): + """A whitespace delimited option is not rejected as an unknown one.""" + + tmpdir.join("src", "test-spec.yml").write_text(_SPEC, encoding="utf-8") + warnings = run_sphinx( + "test-spec.yml", + options={option: value}, + directive="openapi:httpdomain", + ) + + assert "unknown option" not in warnings + + +@pytest.mark.parametrize(["option", "value"], _WHITESPACE_DELIMITED_OPTIONS) +def test_option_is_parsed(option, value): + """A whitespace delimited option is parsed into a list of tokens.""" + + convertor = renderers.HttpdomainRenderer.option_spec[option] + assert convertor(value) == value.split() + + +@pytest.mark.parametrize(["option", "value"], _WHITESPACE_DELIMITED_OPTIONS) +def test_option_without_value_is_parsed(option, value): + """A whitespace delimited option may be passed with no value at all.""" + + convertor = renderers.HttpdomainRenderer.option_spec[option] + assert convertor(None) == [] + + +def test_response_examples_for_is_effective(tmpdir, run_sphinx): + """The 'response-examples-for' option reaches the renderer.""" + + tmpdir.join("src", "test-spec.yml").write_text( + textwrap.dedent("""\ + openapi: 3.0.0 + info: + title: An example spec. + version: 1.0.0 + paths: + /evidences: + get: + responses: + '404': + description: An evidence is not found. + content: + text/plain: + example: no-such-evidence + """), + encoding="utf-8", + ) + warnings = run_sphinx( + "test-spec.yml", + options={"response-examples-for": "404"}, + directive="openapi:httpdomain", + ) + + assert "unknown option" not in warnings + + # By default examples are rendered for successful status codes only, so + # this example is rendered if and only if the option took effect. + assert "no-such-evidence" in tmpdir.join("out", "index.html").read_text( + encoding="utf-8" + ) From c47105915c7222cef20028b6fb16d2efbb446421 Mon Sep 17 00:00:00 2001 From: Douglas Strodtman Date: Thu, 13 Aug 2026 10:20:39 -0400 Subject: [PATCH 2/3] Select the renderer by config in the option tests The 'httpdomain' renderer's own directive name contains a colon, which Sphinx first reads as a domain prefix, so building a document that uses '.. openapi:httpdomain::' reports 'unknown directive name' before falling back to the registered name and rendering correctly anyway. Reaching the renderer through 'openapi_default_renderer' instead keeps the captured warning stream clean, so it says only what the tests are asserting on. Signed-off-by: Douglas Strodtman --- tests/conftest.py | 10 +++++++--- tests/renderers/httpdomain/test_options.py | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 7b69601..03a4d0a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -46,11 +46,15 @@ def run_sphinx(tmpdir): src = tmpdir.ensure('src', dir=True) out = tmpdir.ensure('out', dir=True) - def run(spec, options={}, directive='openapi'): + def run(spec, options={}, renderer=None): options_raw = '\n'.join([ ' %s' % _format_option_raw(key, val) for key, val in options.items()]) + conf_raw = '' + if renderer: + conf_raw = "openapi_default_renderer = '%s'" % renderer + src.join('conf.py').write_text( textwrap.dedent(''' import os @@ -61,11 +65,11 @@ def run(spec, options={}, directive='openapi'): extensions = ['sphinxcontrib.openapi'] source_suffix = '.rst' master_doc = 'index' - '''), + ''') + conf_raw, encoding='utf-8') src.join('index.rst').write_text( - '.. %s:: %s\n%s' % (directive, spec, options_raw), + '.. openapi:: %s\n%s' % (spec, options_raw), encoding='utf-8') # Warnings are captured and returned so tests can assert on them. diff --git a/tests/renderers/httpdomain/test_options.py b/tests/renderers/httpdomain/test_options.py index 44c512f..a1b2c02 100644 --- a/tests/renderers/httpdomain/test_options.py +++ b/tests/renderers/httpdomain/test_options.py @@ -45,7 +45,7 @@ def test_option_is_accepted(tmpdir, run_sphinx, option, value): warnings = run_sphinx( "test-spec.yml", options={option: value}, - directive="openapi:httpdomain", + renderer="httpdomain", ) assert "unknown option" not in warnings @@ -91,7 +91,7 @@ def test_response_examples_for_is_effective(tmpdir, run_sphinx): warnings = run_sphinx( "test-spec.yml", options={"response-examples-for": "404"}, - directive="openapi:httpdomain", + renderer="httpdomain", ) assert "unknown option" not in warnings From 4ffebcf987ab8201c7cd94edc4f75fe88a08089a Mon Sep 17 00:00:00 2001 From: Douglas Strodtman Date: Thu, 13 Aug 2026 12:02:19 -0400 Subject: [PATCH 3/3] Reject an empty option value, and prove each option reaches the renderer An empty value parsed to '[]', which for 'response-examples-for' is a present key that overrides the default and quietly disables every response example. Raise 'ValueError' instead, which docutils reports as a directive error. The options were previously accepted-looking but inert, so assert that each one changes rendered output rather than only that its converter parses. The test asserting the converter matched 'value.split()' was tautological, since that is the converter, and is now written against literal expectations. Also tee the captured warning stream to stderr, so that build warnings still reach pytest's capture for every other caller of the fixture. Signed-off-by: Douglas Strodtman --- .../openapi/renderers/_httpdomain.py | 10 +- tests/conftest.py | 19 +- tests/renderers/httpdomain/test_options.py | 234 +++++++++++++----- 3 files changed, 200 insertions(+), 63 deletions(-) diff --git a/sphinxcontrib/openapi/renderers/_httpdomain.py b/sphinxcontrib/openapi/renderers/_httpdomain.py index 3de334d..07f23d6 100644 --- a/sphinxcontrib/openapi/renderers/_httpdomain.py +++ b/sphinxcontrib/openapi/renderers/_httpdomain.py @@ -34,8 +34,14 @@ def _split_option(value): """Parse a whitespace delimited option value into a list of tokens.""" # An option may be passed with no value at all, in which case docutils - # hands over 'None' instead of a string. - return (value or "").split() + # hands over 'None' instead of a string. Since every option parsed here + # takes one or more values, and since an empty one would silently turn + # 'response-examples-for' into "no examples at all", let's reject it. The + # 'ValueError' is turned into a directive error by docutils. + tokens = (value or "").split() + if not tokens: + raise ValueError("expected one or more whitespace delimited values") + return tokens def _iterinorder(iterable, order_by, key=lambda x: x, case_sensitive=False): diff --git a/tests/conftest.py b/tests/conftest.py index 03a4d0a..4059326 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import io import os import pathlib +import sys import textwrap import pytest @@ -35,6 +36,18 @@ def pytest_collection_modifyitems(items): items[:] = items_new +class _Tee(io.StringIO): + """Accumulate everything written, and pass it through to a stream.""" + + def __init__(self, stream): + super().__init__() + self._stream = stream + + def write(self, text): + self._stream.write(text) + return super().write(text) + + def _format_option_raw(key, val): if isinstance(val, bool) and val: return ':%s:' % key @@ -72,8 +85,10 @@ def run(spec, options={}, renderer=None): '.. openapi:: %s\n%s' % (spec, options_raw), encoding='utf-8') - # Warnings are captured and returned so tests can assert on them. - warning = io.StringIO() + # Warnings are captured and returned so tests can assert on them. They + # keep going to stderr as well, so that a test that doesn't care about + # them still shows them when it fails. + warning = _Tee(sys.stderr) Sphinx( srcdir=src.strpath, diff --git a/tests/renderers/httpdomain/test_options.py b/tests/renderers/httpdomain/test_options.py index a1b2c02..04329e5 100644 --- a/tests/renderers/httpdomain/test_options.py +++ b/tests/renderers/httpdomain/test_options.py @@ -1,11 +1,16 @@ """Test the directive options the 'httpdomain' renderer accepts.""" -import textwrap +import re import pytest from sphinxcontrib.openapi import renderers +# Exercises every option below at once. Two methods on one endpoint for +# 'http-methods-order', two kinds of parameter for 'request-parameters-order', +# and two media types carrying distinguishable examples, for both a request and +# a response, for the preference options. The '404' response is what +# 'response-examples-for' has to be widened to reach. _SPEC = """\ openapi: 3.0.0 info: @@ -14,90 +19,201 @@ paths: /evidences: get: + parameters: + - name: q + in: query + schema: + type: string + - name: X-Evidence-Token + in: header + schema: + type: string responses: '200': description: An evidence. content: application/json: - schema: - type: object - properties: - id: - type: string + example: {'id': 'json-response'} + text/plain: + example: plain-response + '404': + description: An evidence is not found. + content: + text/plain: + example: no-such-evidence + post: + requestBody: + content: + application/json: + example: {'id': 'json-request'} + text/plain: + example: plain-request + responses: + '201': + description: An evidence created. """ _WHITESPACE_DELIMITED_OPTIONS = [ - pytest.param("http-methods-order", "head get"), - pytest.param("response-examples-for", "200 201 2XX 404"), - pytest.param("request-parameters-order", "query path header cookie"), - pytest.param("example-preference", "application/json text/plain"), - pytest.param("request-example-preference", "application/json text/plain"), - pytest.param("response-example-preference", "application/json text/plain"), + pytest.param("http-methods-order", "head get", ["head", "get"]), + pytest.param( + "response-examples-for", "200 201 2XX 404", ["200", "201", "2XX", "404"] + ), + pytest.param( + "request-parameters-order", + "query path header cookie", + ["query", "path", "header", "cookie"], + ), + pytest.param( + "example-preference", + "text/plain application/json", + ["text/plain", "application/json"], + ), + pytest.param( + "request-example-preference", + "text/plain application/json", + ["text/plain", "application/json"], + ), + pytest.param( + "response-example-preference", + "text/plain application/json", + ["text/plain", "application/json"], + ), ] -@pytest.mark.parametrize(["option", "value"], _WHITESPACE_DELIMITED_OPTIONS) -def test_option_is_accepted(tmpdir, run_sphinx, option, value): - """A whitespace delimited option is not rejected as an unknown one.""" +@pytest.fixture(scope="function") +def build_warnings(tmpdir, run_sphinx): + """Build '_SPEC' with the given options, and return reported warnings.""" - tmpdir.join("src", "test-spec.yml").write_text(_SPEC, encoding="utf-8") - warnings = run_sphinx( - "test-spec.yml", - options={option: value}, - renderer="httpdomain", - ) + def build_warnings(options): + tmpdir.join("src", "test-spec.yml").write_text(_SPEC, encoding="utf-8") + return run_sphinx("test-spec.yml", options=options, renderer="httpdomain") + + return build_warnings + + +@pytest.fixture(scope="function") +def build(tmpdir, build_warnings): + """Build '_SPEC' with the given options, and return the rendered text. - assert "unknown option" not in warnings + Tags are stripped since httpdomain splits a method and its path into + separate elements, so the rendered markup can't be searched as is. + """ + def build(options): + assert "unknown option" not in build_warnings(options) + html = tmpdir.join("out", "index.html").read_text(encoding="utf-8") + return re.sub(r"<[^>]+>", "", html) -@pytest.mark.parametrize(["option", "value"], _WHITESPACE_DELIMITED_OPTIONS) -def test_option_is_parsed(option, value): + return build + + +@pytest.mark.parametrize(["option", "value", "expected"], _WHITESPACE_DELIMITED_OPTIONS) +def test_option_is_accepted(build, option, value, expected): + """A whitespace delimited option is not rejected as an unknown one.""" + + build({option: value}) + + +@pytest.mark.parametrize(["option", "value", "expected"], _WHITESPACE_DELIMITED_OPTIONS) +def test_option_is_parsed(option, value, expected): """A whitespace delimited option is parsed into a list of tokens.""" convertor = renderers.HttpdomainRenderer.option_spec[option] - assert convertor(value) == value.split() + assert convertor(value) == expected -@pytest.mark.parametrize(["option", "value"], _WHITESPACE_DELIMITED_OPTIONS) -def test_option_without_value_is_parsed(option, value): - """A whitespace delimited option may be passed with no value at all.""" +@pytest.mark.parametrize(["option", "value", "expected"], _WHITESPACE_DELIMITED_OPTIONS) +def test_option_without_value_is_rejected(option, value, expected): + """A whitespace delimited option passed with no value is an error.""" convertor = renderers.HttpdomainRenderer.option_spec[option] - assert convertor(None) == [] + with pytest.raises(ValueError): + convertor(None) -def test_response_examples_for_is_effective(tmpdir, run_sphinx): - """The 'response-examples-for' option reaches the renderer.""" - tmpdir.join("src", "test-spec.yml").write_text( - textwrap.dedent("""\ - openapi: 3.0.0 - info: - title: An example spec. - version: 1.0.0 - paths: - /evidences: - get: - responses: - '404': - description: An evidence is not found. - content: - text/plain: - example: no-such-evidence - """), - encoding="utf-8", - ) - warnings = run_sphinx( - "test-spec.yml", - options={"response-examples-for": "404"}, - renderer="httpdomain", - ) +@pytest.mark.parametrize( + ["option", "value"], + [ + pytest.param("response-examples-for", ""), + pytest.param("response-examples-for", " "), + ], +) +def test_option_with_blank_value_is_reported(build_warnings, option, value): + """A blank option value is reported instead of silently taking effect.""" + + # Left to itself, an empty 'response-examples-for' would override the + # default and quietly disable every response example. + assert "invalid option value" in build_warnings({option: value}) + + +def test_http_methods_order_is_effective(build): + """The 'http-methods-order' option reaches the renderer.""" + + text = build({"http-methods-order": "post get"}) + + # Natural order puts 'get' first, since that's how the spec declares them. + assert text.index("POST /evidences") < text.index("GET /evidences") - assert "unknown option" not in warnings - # By default examples are rendered for successful status codes only, so - # this example is rendered if and only if the option took effect. - assert "no-such-evidence" in tmpdir.join("out", "index.html").read_text( - encoding="utf-8" +def test_request_parameters_order_is_effective(build): + """The 'request-parameters-order' option reaches the renderer.""" + + text = build({"request-parameters-order": "query header"}) + + # The renderer's own default order puts header parameters first. + assert text.index("Query Parameters") < text.index("Request Headers") + + +def test_response_examples_for_is_effective(build): + """The 'response-examples-for' option reaches the renderer.""" + + # By default examples are rendered for successful status codes only, so the + # '404' example is rendered if and only if the option took effect. + assert "no-such-evidence" not in build({"http-methods-order": "get"}) + assert "no-such-evidence" in build({"response-examples-for": "404"}) + + +@pytest.mark.parametrize( + ["option", "expected"], + [ + pytest.param("example-preference", "plain-response"), + pytest.param("response-example-preference", "plain-response"), + ], +) +def test_response_example_preference_is_effective(build, option, expected): + """The response example preference options reach the renderer.""" + + # 'application/json' is declared first, so it wins without a preference. + assert expected not in build({"http-methods-order": "get"}) + assert expected in build({option: "text/plain application/json"}) + + +@pytest.mark.parametrize( + ["option", "expected"], + [ + pytest.param("example-preference", "plain-request"), + pytest.param("request-example-preference", "plain-request"), + ], +) +def test_request_example_preference_is_effective(build, option, expected): + """The request example preference options reach the renderer.""" + + assert expected not in build({"http-methods-order": "post"}) + assert expected in build({option: "text/plain application/json"}) + + +def test_request_example_preference_takes_precedence(build): + """A request specific preference wins over the shared one.""" + + text = build( + { + "example-preference": "application/json text/plain", + "request-example-preference": "text/plain application/json", + } ) + + assert "plain-request" in text + assert "json-response" in text