From a9c1ec7c2392ffaba4a58692437a5de5a86b8f02 Mon Sep 17 00:00:00 2001 From: djungelorm Date: Fri, 16 Oct 2026 01:25:29 +0100 Subject: [PATCH] Support Sphinx 7.2 and later Lower the Sphinx floor from 9.0 to 7.2, the minimum for Breathe 5. CI tests each Sphinx minor version from 7.2 to 8.2 on Python 3.11. Import ExtensionMetadata for type checking only, since Sphinx 7.2 lacks it. Check both noindexentry and no-index-entry, because Sphinx 7 and 8 only alias them in the desc node. The tests avoid the testing APIs added in Sphinx 7.3 and 9. --- .github/workflows/test.yml | 17 ++++++++++++++++- CHANGES.txt | 2 +- pyproject.toml | 11 +++++++++-- sphinx_csharp/__init__.py | 8 ++++++-- sphinx_csharp/csharp.py | 3 ++- tests/conftest.py | 19 +++++++++++++++---- tests/test_extrefs.py | 14 +++++++------- tests/test_projects.py | 3 ++- tests/test_render.py | 13 +++++++------ 9 files changed, 65 insertions(+), 25 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cb75ffa..971cc2d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,4 +40,19 @@ jobs: python-version: ${{ matrix.python }} - run: sudo apt-get install -y doxygen - run: pip install hatch - - run: hatch run +py=${{ matrix.python }} test:all + - run: hatch run test.py${{ matrix.python }}:all + + test-sphinx: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sphinx: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2'] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.11' + - run: sudo apt-get install -y doxygen + - run: pip install hatch + - run: hatch run test.py3.11-${{ matrix.sphinx }}:all diff --git a/CHANGES.txt b/CHANGES.txt index 176051a..6c1dc92 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,5 @@ v1.0.0 - * Require Python 3.11+ and Sphinx 9.0+ + * Require Python 3.11+ and Sphinx 7.2+ * Rename the domain from csharp to cs * Rename the method directive to function, and value to enumerator * Support documenting C# APIs generated by Doxygen with Breathe diff --git a/pyproject.toml b/pyproject.toml index 54b406c..67f73b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ license = "MIT" license-files = ["LICENSE"] authors = [{ name = "djungelorm", email = "djungelorm@users.noreply.github.com" }] requires-python = ">=3.11" -dependencies = ["Sphinx>=9.0", "requests"] +dependencies = ["Sphinx>=7.2", "requests"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", @@ -46,11 +46,18 @@ dependencies = ["mypy", "types-docutils", "types-requests"] check = "mypy sphinx_csharp" [tool.hatch.envs.test] -dependencies = ["breathe", "pytest", "pytest-cov"] +dependencies = ["breathe", "defusedxml", "pytest", "pytest-cov"] [[tool.hatch.envs.test.matrix]] python = ["3.11", "3.12", "3.13", "3.14", "3.15"] +[[tool.hatch.envs.test.matrix]] +python = ["3.11"] +sphinx = ["7.2", "7.3", "7.4", "8.0", "8.1", "8.2"] + +[tool.hatch.envs.test.overrides] +matrix.sphinx.dependencies = ["sphinx~={matrix:sphinx}.0"] + [tool.hatch.envs.test.scripts] all = "pytest --cov=sphinx_csharp --cov-report=term-missing {args}" diff --git a/sphinx_csharp/__init__.py b/sphinx_csharp/__init__.py index 0b70721..4b9d46a 100644 --- a/sphinx_csharp/__init__.py +++ b/sphinx_csharp/__init__.py @@ -1,13 +1,17 @@ from importlib.metadata import version +from typing import TYPE_CHECKING from sphinx.application import Sphinx -from sphinx.util.typing import ExtensionMetadata from .csharp import CSharpDomain from .debug import CSDebug +# Note: ExtensionMetadata was added in Sphinx 7.3. +if TYPE_CHECKING: + from sphinx.util.typing import ExtensionMetadata -def setup(app: Sphinx) -> ExtensionMetadata: + +def setup(app: Sphinx) -> 'ExtensionMetadata': """ Setup called by sphinx when the module is added to the extensions list in conf.py """ # Register domain diff --git a/sphinx_csharp/csharp.py b/sphinx_csharp/csharp.py index 1d38576..e620749 100644 --- a/sphinx_csharp/csharp.py +++ b/sphinx_csharp/csharp.py @@ -315,7 +315,8 @@ def add_target_and_index(self, name: str, _: str, signode: addnodes.desc_signatu location=(self.env.docname, self.lineno)) objects[key] = self.env.docname - if 'no-index-entry' not in self.options: + # Sphinx 7 and 8 keep noindexentry as a separate option + if 'no-index-entry' not in self.options and 'noindexentry' not in self.options: indextext = self.get_index_text(name) if indextext: self.indexnode['entries'].append(('single', indextext, targetname, '', None)) diff --git a/tests/conftest.py b/tests/conftest.py index 8693dbb..883eb3b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ import pytest from docutils import nodes -from sphinx import addnodes +from sphinx import addnodes, version_info pytest_plugins = ['sphinx.testing.fixtures'] @@ -27,13 +27,24 @@ def _build(rst, pages=None, parallel=0, **confoverrides): return _build +def resolved_doctree(app): + # Sphinx 9 deprecates calls without tags, and earlier versions reject the argument + kwargs = {'tags': app.tags} if version_info >= (9,) else {} + return app.env.get_and_resolve_doctree('index', app.builder, **kwargs) + + +# Note: SphinxTestApp.status and SphinxTestApp.warning were added in Sphinx 7.3. +def status(app): + return app._status.getvalue() + + def warnings(app): - return [line for line in app.warning.getvalue().splitlines() if line.strip()] + return [line for line in app._warning.getvalue().splitlines() if line.strip()] def signatures(app): """ Map each object id on the index page to its rendered signature text """ - doctree = app.env.get_and_resolve_doctree('index', app.builder, tags=app.tags) + doctree = resolved_doctree(app) return {node_id: node.astext().replace('\xa0', ' ') for node in doctree.findall(addnodes.desc_signature) for node_id in node['ids']} @@ -41,7 +52,7 @@ def signatures(app): def references(app, node_id=None): """ Map each linked name to its target, in one signature or in the whole index page """ - doctree = app.env.get_and_resolve_doctree('index', app.builder, tags=app.tags) + doctree = resolved_doctree(app) if node_id is not None: doctree = next(node for node in doctree.findall(addnodes.desc_signature) if node_id in node['ids']) return {ref.astext(): ref.get('refid') or ref.get('refuri') diff --git a/tests/test_extrefs.py b/tests/test_extrefs.py index 802cc50..ea38829 100644 --- a/tests/test_extrefs.py +++ b/tests/test_extrefs.py @@ -1,6 +1,6 @@ import pytest import requests -from conftest import references, warnings +from conftest import references, status, warnings from sphinx_csharp.debug import CSDebug from sphinx_csharp.extrefs import ExternalRefs @@ -48,9 +48,9 @@ def test_package_without_search_page(build): def test_debug_logging(build): app = build('.. cs:function:: int Foo (int a)\n', sphinx_csharp_debug_ext_links=True) - status = app.status.getvalue() - assert 'found external link' in status - assert 'found in extlink_cache for int' in status + log = status(app) + assert 'found external link' in log + assert 'found in extlink_cache for int' in log class Response: @@ -67,8 +67,8 @@ def test_link_testing(build, monkeypatch, status_code, url): monkeypatch.setattr(requests, 'get', lambda *args, **kwargs: Response(status_code)) refs, app = links(build, 'int', sphinx_csharp_test_links=True) assert refs == {'int': url} - assert 'external link testing is enabled' in app.status.getvalue() - assert ('invalid API link' in app.warning.getvalue()) == (status_code == 404) + assert 'external link testing is enabled' in status(app) + assert any('invalid API link' in line for line in warnings(app)) == (status_code == 404) def test_link_testing_without_connection(build, monkeypatch): @@ -78,6 +78,6 @@ def get(*args, **kwargs): monkeypatch.setattr(requests, 'get', get) refs, app = links(build, 'int', sphinx_csharp_test_links=True) assert warnings(app) == [] - assert 'external link testing is enabled' not in app.status.getvalue() + assert 'external link testing is enabled' not in status(app) assert refs == {'int': MICROSOFT_DOCS + 'System.Int32'} assert not ExternalRefs.test_links diff --git a/tests/test_projects.py b/tests/test_projects.py index 18b6737..148a2ff 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -12,7 +12,8 @@ def test_basic(app): @pytest.mark.skipif(shutil.which('doxygen') is None, reason='doxygen is not installed') -@pytest.mark.sphinx('html', testroot='breathe', copy_test_root=True, confoverrides={'nitpicky': True}) +# Note: Passing srcdir copies the test root in every supported Sphinx version. +@pytest.mark.sphinx('html', testroot='breathe', srcdir='breathe', confoverrides={'nitpicky': True}) def test_breathe(app): subprocess.run(['doxygen'], cwd=app.srcdir, check=True) app.build() diff --git a/tests/test_render.py b/tests/test_render.py index 49e99a2..7f0d571 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -1,7 +1,7 @@ from io import StringIO import pytest -from conftest import references, signatures, warnings +from conftest import references, resolved_doctree, signatures, status, warnings from docutils import nodes from sphinx import addnodes from sphinx.errors import ExtensionError @@ -118,7 +118,7 @@ def test_invalid_signature_is_shown_as_written(build, directive, sig): app = build(f'.. cs:{directive}:: {sig}\n') assert len(warnings(app)) == 1 assert 'signature invalid' in warnings(app)[0] - doctree = app.env.get_and_resolve_doctree('index', app.builder, tags=app.tags) + doctree = resolved_doctree(app) assert next(doctree.findall(addnodes.desc_signature)).astext() == sig @@ -255,8 +255,9 @@ def test_index_entries(app): assert len(entries) == 10 -def test_no_index_entry(build): - app = build('.. cs:class:: Shown\n\n.. cs:class:: Hidden\n :no-index-entry:\n\n' +@pytest.mark.parametrize('option', ['no-index-entry', 'noindexentry']) +def test_no_index_entry(build, option): + app = build(f'.. cs:class:: Shown\n\n.. cs:class:: Hidden\n :{option}:\n\n' '.. cs:class:: Derived\n\n .. cs:inherits:: Shown\n') assert warnings(app) == [] assert index_entries(app) == [('Shown (C# class)', 'class-Shown'), ('Derived (C# class)', 'class-Derived')] @@ -345,10 +346,10 @@ def test_reference_without_target(build): def test_debug_logging(build): app = build(DEBUG_SOURCE, nitpicky=False, sphinx_csharp_debug=True) assert warnings(app) == [] - status = app.status.getvalue() + log = status(app) for text in ['parsed func:', 'parsed var:', 'parsed prop:', 'falling back to variable: int Plain', 'parsed idxr:', 'parsed type:', 'parsed attr:', 'no C# object found for Missing']: - assert text in status + assert text in log def test_invalid_config(make_app, tmp_path):