From 04b5cd818ebced99d66240073d8e8cf7fc4768fa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 07:34:03 -0500 Subject: [PATCH 01/11] py(deps[dev]): Replace sphinx/furo stack with gp-sphinx packages why: Consolidate docs dependencies into the gp-sphinx shared platform, replacing individual sphinx extensions with curated package bundles. what: - Remove sphinx<9, furo, sphinx-autodoc-typehints, sphinx-inline-tabs, sphinxext-opengraph, sphinx-copybutton, sphinxext-rediraffe, sphinx-design, myst-parser, linkify-it-py from dev/docs groups - Add gp-sphinx and sphinx-pytest-fixtures (editable from local workspace) - Move types-docutils into dev group (needed for sphinx_pytest_fixtures types) - Add [tool.uv.sources] pointing to local gp-sphinx packages - Update uv.lock --- pyproject.toml | 40 +++++--------- uv.lock | 140 +++++++++++++++++++++++++++++++------------------ 2 files changed, 104 insertions(+), 76 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 164191fe5..f626186fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,19 +51,11 @@ Changes = "https://github.com/tmux-python/libtmux/blob/master/CHANGES" [dependency-groups] dev = [ - # Docs - "sphinx<9", # https://www.sphinx-doc.org/ - "furo", # https://pradyunsg.me/furo/ - "gp-libs", # https://gp-libs.git-pull.com/ - "sphinx-autobuild", # https://sphinx-extensions.readthedocs.io/en/latest/sphinx-autobuild.html - "sphinx-autodoc-typehints", # https://sphinx-autodoc-typehints.readthedocs.io/ - "sphinx-inline-tabs", # https://sphinx-inline-tabs.readthedocs.io/ - "sphinxext-opengraph", # https://sphinxext-opengraph.readthedocs.io/ - "sphinx-copybutton", # https://sphinx-copybutton.readthedocs.io/ - "sphinxext-rediraffe", # https://sphinxext-rediraffe.readthedocs.io/ - "sphinx-design", # https://sphinx-design.readthedocs.io/ - "myst-parser", # https://myst-parser.readthedocs.io/ - "linkify-it-py", # https://github.com/tsutsu3/linkify-it-py + # Docs (via gp-sphinx) + "gp-sphinx", + "sphinx-pytest-fixtures", + "sphinx-autobuild", + "types-docutils", # Testing "typing-extensions; python_version < '3.11'", "gp-libs", @@ -79,22 +71,12 @@ dev = [ # Lint "ruff", "mypy", - "types-docutils", # stubs for docutils used by sphinx_pytest_fixtures ] docs = [ - "sphinx<9", # https://www.sphinx-doc.org/ - "furo", # https://pradyunsg.me/furo/ - "gp-libs", # https://gp-libs.git-pull.com/ - "sphinx-autobuild", # https://sphinx-extensions.readthedocs.io/en/latest/sphinx-autobuild.html - "sphinx-autodoc-typehints", # https://sphinx-autodoc-typehints.readthedocs.io/ - "sphinx-inline-tabs", # https://sphinx-inline-tabs.readthedocs.io/ - "sphinxext-opengraph", # https://sphinxext-opengraph.readthedocs.io/ - "sphinx-copybutton", # https://sphinx-copybutton.readthedocs.io/ - "sphinxext-rediraffe", # https://sphinxext-rediraffe.readthedocs.io/ - "sphinx-design", # https://sphinx-design.readthedocs.io/ - "myst-parser", # https://myst-parser.readthedocs.io/ - "linkify-it-py", # https://github.com/tsutsu3/linkify-it-py + "gp-sphinx", + "sphinx-pytest-fixtures", + "sphinx-autobuild", ] testing = [ "typing-extensions; python_version < '3.11'", @@ -122,6 +104,12 @@ libtmux = "libtmux.pytest_plugin" requires = ["hatchling"] build-backend = "hatchling.build" +[tool.uv.sources] +gp-sphinx = { path = "../gp-sphinx/packages/gp-sphinx", editable = true } +sphinx-fonts = { path = "../gp-sphinx/packages/sphinx-fonts", editable = true } +sphinx-gptheme = { path = "../gp-sphinx/packages/sphinx-gptheme", editable = true } +sphinx-pytest-fixtures = { path = "../gp-sphinx/packages/sphinx-pytest-fixtures", editable = true } + [tool.mypy] strict = true python_version = "3.10" diff --git a/uv.lock b/uv.lock index e268f2fdd..1e647fc92 100644 --- a/uv.lock +++ b/uv.lock @@ -391,6 +391,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/f9/5d78d1dda9cb0f27d6f2305e95a58edbff935a62d53ec3227a3518cb4f72/gp_libs-0.0.17-py3-none-any.whl", hash = "sha256:7ce96d5e09980c0dc82062ab3e3b911600bd44da97a64fb78379f1af9a79d4d3", size = 16157, upload-time = "2025-12-07T22:44:48.036Z" }, ] +[[package]] +name = "gp-sphinx" +version = "0.0.1a0" +source = { editable = "../gp-sphinx/packages/gp-sphinx" } +dependencies = [ + { name = "docutils" }, + { name = "gp-libs" }, + { name = "linkify-it-py" }, + { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-autodoc-typehints", version = "3.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-fonts" }, + { name = "sphinx-gptheme" }, + { name = "sphinx-inline-tabs" }, + { name = "sphinxext-opengraph" }, + { name = "sphinxext-rediraffe" }, +] + +[package.metadata] +requires-dist = [ + { name = "docutils" }, + { name = "gp-libs" }, + { name = "linkify-it-py" }, + { name = "myst-parser" }, + { name = "sphinx", specifier = "<9" }, + { name = "sphinx-argparse-neo", marker = "extra == 'argparse'", editable = "../gp-sphinx/packages/sphinx-argparse-neo" }, + { name = "sphinx-autodoc-typehints" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-design" }, + { name = "sphinx-fonts", editable = "../gp-sphinx/packages/sphinx-fonts" }, + { name = "sphinx-gptheme", editable = "../gp-sphinx/packages/sphinx-gptheme" }, + { name = "sphinx-inline-tabs" }, + { name = "sphinxext-opengraph" }, + { name = "sphinxext-rediraffe" }, +] +provides-extras = ["argparse"] + [[package]] name = "h11" version = "0.16.0" @@ -538,12 +581,9 @@ coverage = [ dev = [ { name = "codecov" }, { name = "coverage" }, - { name = "furo" }, { name = "gp-libs" }, - { name = "linkify-it-py" }, + { name = "gp-sphinx" }, { name = "mypy" }, - { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-mock" }, @@ -551,39 +591,17 @@ dev = [ { name = "pytest-watcher" }, { name = "pytest-xdist" }, { name = "ruff" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx-autodoc-typehints", version = "3.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-copybutton" }, - { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-inline-tabs" }, - { name = "sphinxext-opengraph" }, - { name = "sphinxext-rediraffe" }, + { name = "sphinx-pytest-fixtures" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] docs = [ - { name = "furo" }, - { name = "gp-libs" }, - { name = "linkify-it-py" }, - { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "gp-sphinx" }, { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx-autodoc-typehints", version = "3.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-copybutton" }, - { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-inline-tabs" }, - { name = "sphinxext-opengraph" }, - { name = "sphinxext-rediraffe" }, + { name = "sphinx-pytest-fixtures" }, ] lint = [ { name = "mypy" }, @@ -610,11 +628,9 @@ coverage = [ dev = [ { name = "codecov" }, { name = "coverage" }, - { name = "furo" }, { name = "gp-libs" }, - { name = "linkify-it-py" }, + { name = "gp-sphinx", editable = "../gp-sphinx/packages/gp-sphinx" }, { name = "mypy" }, - { name = "myst-parser" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-mock" }, @@ -622,30 +638,15 @@ dev = [ { name = "pytest-watcher" }, { name = "pytest-xdist" }, { name = "ruff" }, - { name = "sphinx", specifier = "<9" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-typehints" }, - { name = "sphinx-copybutton" }, - { name = "sphinx-design" }, - { name = "sphinx-inline-tabs" }, - { name = "sphinxext-opengraph" }, - { name = "sphinxext-rediraffe" }, + { name = "sphinx-pytest-fixtures", editable = "../gp-sphinx/packages/sphinx-pytest-fixtures" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] docs = [ - { name = "furo" }, - { name = "gp-libs" }, - { name = "linkify-it-py" }, - { name = "myst-parser" }, - { name = "sphinx", specifier = "<9" }, + { name = "gp-sphinx", editable = "../gp-sphinx/packages/gp-sphinx" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-typehints" }, - { name = "sphinx-copybutton" }, - { name = "sphinx-design" }, - { name = "sphinx-inline-tabs" }, - { name = "sphinxext-opengraph" }, - { name = "sphinxext-rediraffe" }, + { name = "sphinx-pytest-fixtures", editable = "../gp-sphinx/packages/sphinx-pytest-fixtures" }, ] lint = [ { name = "mypy" }, @@ -1373,6 +1374,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl", hash = "sha256:f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282", size = 2220350, upload-time = "2026-01-19T13:12:51.077Z" }, ] +[[package]] +name = "sphinx-fonts" +version = "0.0.1a0" +source = { editable = "../gp-sphinx/packages/sphinx-fonts" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.metadata] +requires-dist = [{ name = "sphinx" }] + +[[package]] +name = "sphinx-gptheme" +version = "0.0.1a0" +source = { editable = "../gp-sphinx/packages/sphinx-gptheme" } +dependencies = [ + { name = "furo" }, +] + +[package.metadata] +requires-dist = [{ name = "furo" }] + [[package]] name = "sphinx-inline-tabs" version = "2025.12.21.14" @@ -1386,6 +1410,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/2b/e64e7de34663cff1df029ba4f05a86124315bd9eba3d3b78e64904bea7e0/sphinx_inline_tabs-2025.12.21.14-py3-none-any.whl", hash = "sha256:e685c782b58d4e01490bcc4e2367cf7135ec28e7283a05e89095394e4ca6e81a", size = 7082, upload-time = "2025-12-21T13:30:50.142Z" }, ] +[[package]] +name = "sphinx-pytest-fixtures" +version = "0.0.1a0" +source = { editable = "../gp-sphinx/packages/sphinx-pytest-fixtures" } +dependencies = [ + { name = "pytest" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest" }, + { name = "sphinx" }, +] + [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0" From b04fdc98b2b58be2bb1694f5c0993cca4c627b75 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 07:34:10 -0500 Subject: [PATCH 02/11] docs(feat[gp-sphinx]): Migrate conf.py to merge_sphinx_config() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: libtmux was maintaining ~290 lines of Sphinx configuration that duplicates what gp-sphinx now provides as shared defaults — fonts, theme, extensions, copybutton, autodoc, and linkcode resolution. what: - Replace manual conf.py with merge_sphinx_config() and make_linkcode_resolve() - Retain project-specific overrides: announcement banner, favicon, custom CSS, todo extension --- docs/conf.py | 295 ++++++--------------------------------------------- 1 file changed, 30 insertions(+), 265 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index a83d89ce2..446a2cd62 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,19 +1,13 @@ -# flake8: NOQA: E501 """Sphinx configuration for libtmux.""" from __future__ import annotations -import contextlib -import inspect import pathlib import sys -import typing as t -from os.path import relpath import libtmux -if t.TYPE_CHECKING: - from sphinx.application import Sphinx +from gp_sphinx.config import make_linkcode_resolve, merge_sphinx_config # Get the project root dir, which is the parent dir of this cwd = pathlib.Path(__file__).parent @@ -21,269 +15,40 @@ project_src = project_root / "src" sys.path.insert(0, str(project_src)) -sys.path.insert(0, str(cwd / "_ext")) # package data about: dict[str, str] = {} with (project_src / "libtmux" / "__about__.py").open() as fp: exec(fp.read(), about) -extensions = [ - "sphinx.ext.autodoc", - "sphinx_pytest_fixtures", - "sphinx_fonts", - "sphinx.ext.intersphinx", - "sphinx_autodoc_typehints", - "sphinx.ext.todo", - "sphinx.ext.linkcode", - "sphinx.ext.napoleon", - "sphinx_inline_tabs", - "sphinx_copybutton", - "sphinxext.opengraph", - "sphinxext.rediraffe", - "myst_parser", - "linkify_issues", - "sphinx_design", -] - -myst_enable_extensions = [ - "colon_fence", - "substitution", - "replacements", - "strikethrough", - "linkify", -] - -myst_heading_anchors = 4 - -templates_path = ["_templates"] - -source_suffix = {".rst": "restructuredtext", ".md": "markdown"} - -master_doc = "index" - -project = about["__title__"] -project_copyright = about["__copyright__"] - -version = "{}".format(".".join(about["__version__"].split("."))[:2]) -release = "{}".format(about["__version__"]) - -exclude_patterns = ["_build"] - -pygments_style = "monokai" -pygments_dark_style = "monokai" - -html_favicon = "_static/favicon.ico" -html_static_path = ["_static"] -html_css_files = ["css/custom.css"] -html_extra_path = ["manifest.json"] -html_theme = "furo" -html_theme_path: list[str] = [] -html_theme_options: dict[str, str | list[dict[str, str]]] = { - "light_logo": "img/libtmux.svg", - "dark_logo": "img/libtmux.svg", - "footer_icons": [ - { - "name": "GitHub", - "url": about["__github__"], - "html": """ - - - - """, - "class": "", - }, - ], - "source_repository": f"{about['__github__']}/", - "source_branch": "master", - "source_directory": "docs/", - "announcement": "Friendly reminder: 📌 Pin the package, libtmux is pre-1.0 and APIs will be changing throughout 2026.", -} -html_sidebars = { - "**": [ - "sidebar/scroll-start.html", - "sidebar/brand.html", - "sidebar/search.html", - "sidebar/navigation.html", - "sidebar/projects.html", - "sidebar/scroll-end.html", - ], -} - -# linkify_issues -issue_url_tpl = f"{about['__github__']}/issues/{{issue_id}}" - -# sphinx.ext.autodoc -autoclass_content = "both" -autodoc_member_order = "bysource" -# Automatically extract typehints when specified and place them in -# descriptions of the relevant function/method. -autodoc_typehints = "description" -# Don't show class signature with the class' name. -autodoc_class_signature = "separated" -toc_object_entries_show_parents = "hide" - -# sphinx-autodoc-typehints -# Suppress warnings for forward references that can't be resolved -# (types in TYPE_CHECKING blocks used for circular import avoidance) -suppress_warnings = [ - "sphinx_autodoc_typehints.forward_reference", -] - -# sphinx-copybutton -copybutton_prompt_text = ( - r">>> |\.\.\. |> |\$ |\# | In \[\d*\]: | {2,5}\.\.\.: | {5,8}: " -) -copybutton_prompt_is_regexp = True -copybutton_remove_prompts = True -copybutton_line_continuation_character = "\\" - -# sphinxext-rediraffe -rediraffe_redirects = "redirects.txt" -rediraffe_branch = "master~1" - -# sphinxext.opengraph -ogp_site_url = about["__docs__"] -ogp_image = "_static/img/icons/icon-192x192.png" -ogp_site_name = about["__title__"] - -# sphinx_fonts — self-hosted IBM Plex via Fontsource CDN -sphinx_fonts = [ - { - "family": "IBM Plex Sans", - "package": "@fontsource/ibm-plex-sans", - "version": "5.2.8", - "weights": [400, 500, 600, 700], - "styles": ["normal", "italic"], - "subsets": ["latin", "latin-ext"], - }, - { - "family": "IBM Plex Mono", - "package": "@fontsource/ibm-plex-mono", - "version": "5.2.7", - "weights": [400, 500, 600, 700], - "styles": ["normal", "italic"], - "subsets": ["latin", "latin-ext"], +conf = merge_sphinx_config( + project=about["__title__"], + version=about["__version__"], + copyright=about["__copyright__"], + source_repository=f"{about['__github__']}/", + docs_url=about["__docs__"], + source_branch="master", + light_logo="img/libtmux.svg", + dark_logo="img/libtmux.svg", + extra_extensions=["sphinx_pytest_fixtures", "sphinx.ext.todo"], + intersphinx_mapping={ + "python": ("https://docs.python.org/", None), + "pytest": ("https://docs.pytest.org/en/stable/", None), }, -] - -sphinx_font_preload = [ - ("IBM Plex Sans", 400, "normal"), # body text - ("IBM Plex Sans", 700, "normal"), # headings - ("IBM Plex Mono", 400, "normal"), # code blocks -] - -sphinx_font_fallbacks = [ - { - "family": "IBM Plex Sans Fallback", - "src": 'local("Arial"), local("Helvetica Neue"), local("Helvetica")', - "size_adjust": "110.6%", - "ascent_override": "92.7%", - "descent_override": "24.9%", - "line_gap_override": "0%", + linkcode_resolve=make_linkcode_resolve( + libtmux, about["__github__"], src_dir="src" + ), + # Project-specific overrides + theme_options={ + "announcement": ( + "Friendly reminder: 📌 Pin the package, libtmux is" + " pre-1.0 and APIs will be changing" + " throughout 2026." + ), }, - { - "family": "IBM Plex Mono Fallback", - "src": 'local("Courier New"), local("Courier")', - "size_adjust": "100%", - "ascent_override": "102.5%", - "descent_override": "27.5%", - "line_gap_override": "0%", - }, -] - -sphinx_font_css_variables = { - "--font-stack": '"IBM Plex Sans", "IBM Plex Sans Fallback", -apple-system, BlinkMacSystemFont, sans-serif', - "--font-stack--monospace": '"IBM Plex Mono", "IBM Plex Mono Fallback", SFMono-Regular, Menlo, Consolas, monospace', - "--font-stack--headings": "var(--font-stack)", -} - -intersphinx_mapping = { - "python": ("https://docs.python.org/", None), - "pytest": ("https://docs.pytest.org/en/stable/", None), -} - - -def linkcode_resolve(domain: str, info: dict[str, str]) -> None | str: - """ - Determine the URL corresponding to Python object. - - Notes - ----- - From https://github.com/numpy/numpy/blob/v1.15.1/doc/source/conf.py, 7c49cfa - on Jul 31. License BSD-3. https://github.com/numpy/numpy/blob/v1.15.1/LICENSE.txt - """ - if domain != "py": - return None - - modname = info["module"] - fullname = info["fullname"] - - submod = sys.modules.get(modname) - if submod is None: - return None - - obj = submod - for part in fullname.split("."): - try: - obj = getattr(obj, part) - except Exception: # noqa: PERF203 - return None - - # strip decorators, which would resolve to the source of the decorator - # possibly an upstream bug in getsourcefile, bpo-1764286 - try: - unwrap = inspect.unwrap - except AttributeError: - pass - else: - if callable(obj): - obj = unwrap(obj) - - try: - fn = inspect.getsourcefile(obj) - except Exception: - fn = None - if not fn: - return None - - try: - source, lineno = inspect.getsourcelines(obj) - except Exception: - lineno = None - - linespec = f"#L{lineno}-L{lineno + len(source) - 1}" if lineno else "" - - fn = relpath(fn, start=pathlib.Path(libtmux.__file__).parent) - - if "dev" in about["__version__"]: - return "{}/blob/master/{}/{}/{}{}".format( - about["__github__"], - "src", - about["__package_name__"], - fn, - linespec, - ) - return "{}/blob/v{}/{}/{}/{}{}".format( - about["__github__"], - about["__version__"], - "src", - about["__package_name__"], - fn, - linespec, - ) - - -def remove_tabs_js(app: Sphinx, exc: Exception) -> None: - """Remove tabs.js from _static after build.""" - # Fix for sphinx-inline-tabs#18 - if app.builder.format == "html" and not exc: - tabs_js = pathlib.Path(app.builder.outdir) / "_static" / "tabs.js" - with contextlib.suppress(FileNotFoundError): - tabs_js.unlink() # When python 3.7 deprecated, use missing_ok=True - - -def setup(app: Sphinx) -> None: - """Configure Sphinx app hooks.""" - app.add_js_file("js/spa-nav.js", loading_method="defer") - app.connect("build-finished", remove_tabs_js) + html_favicon="_static/favicon.ico", + html_css_files=["css/custom.css"], + html_extra_path=["manifest.json"], + rediraffe_redirects="redirects.txt", +) +globals().update(conf) From ed2e19d7096a4ab23aff5ef67d856cfc1d353df7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 3 Apr 2026 11:47:26 -0500 Subject: [PATCH 03/11] refactor(deps): Rename sphinx-pytest-fixtures to sphinx-autodoc-pytest-fixtures why: Follow sphinx-autodoc-* naming convention (like sphinx-autodoc-typehints). what: - Update dependency name in dev and docs groups - Update [tool.uv.sources] path - Update mypy module override - Update docs/conf.py extra_extensions --- docs/conf.py | 2 +- pyproject.toml | 8 ++++---- uv.lock | 40 ++++++++++++++++++++-------------------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 446a2cd62..dac64e2d7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,7 +30,7 @@ source_branch="master", light_logo="img/libtmux.svg", dark_logo="img/libtmux.svg", - extra_extensions=["sphinx_pytest_fixtures", "sphinx.ext.todo"], + extra_extensions=["sphinx_autodoc_pytest_fixtures", "sphinx.ext.todo"], intersphinx_mapping={ "python": ("https://docs.python.org/", None), "pytest": ("https://docs.pytest.org/en/stable/", None), diff --git a/pyproject.toml b/pyproject.toml index f626186fb..30b9b634e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ Changes = "https://github.com/tmux-python/libtmux/blob/master/CHANGES" dev = [ # Docs (via gp-sphinx) "gp-sphinx", - "sphinx-pytest-fixtures", + "sphinx-autodoc-pytest-fixtures", "sphinx-autobuild", "types-docutils", # Testing @@ -75,7 +75,7 @@ dev = [ docs = [ "gp-sphinx", - "sphinx-pytest-fixtures", + "sphinx-autodoc-pytest-fixtures", "sphinx-autobuild", ] testing = [ @@ -108,7 +108,7 @@ build-backend = "hatchling.build" gp-sphinx = { path = "../gp-sphinx/packages/gp-sphinx", editable = true } sphinx-fonts = { path = "../gp-sphinx/packages/sphinx-fonts", editable = true } sphinx-gptheme = { path = "../gp-sphinx/packages/sphinx-gptheme", editable = true } -sphinx-pytest-fixtures = { path = "../gp-sphinx/packages/sphinx-pytest-fixtures", editable = true } +sphinx-autodoc-pytest-fixtures = { path = "../gp-sphinx/packages/sphinx-autodoc-pytest-fixtures", editable = true } [tool.mypy] strict = true @@ -123,7 +123,7 @@ module = ["sphinx_fonts"] ignore_missing_imports = true [[tool.mypy.overrides]] -module = ["sphinx_pytest_fixtures", "sphinx_pytest_fixtures.*"] +module = ["sphinx_autodoc_pytest_fixtures", "sphinx_autodoc_pytest_fixtures.*"] ignore_missing_imports = true [tool.coverage.run] diff --git a/uv.lock b/uv.lock index 1e647fc92..0a93aca09 100644 --- a/uv.lock +++ b/uv.lock @@ -593,7 +593,7 @@ dev = [ { name = "ruff" }, { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-pytest-fixtures" }, + { name = "sphinx-autodoc-pytest-fixtures" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -601,7 +601,7 @@ docs = [ { name = "gp-sphinx" }, { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx-pytest-fixtures" }, + { name = "sphinx-autodoc-pytest-fixtures" }, ] lint = [ { name = "mypy" }, @@ -639,14 +639,14 @@ dev = [ { name = "pytest-xdist" }, { name = "ruff" }, { name = "sphinx-autobuild" }, - { name = "sphinx-pytest-fixtures", editable = "../gp-sphinx/packages/sphinx-pytest-fixtures" }, + { name = "sphinx-autodoc-pytest-fixtures", editable = "../gp-sphinx/packages/sphinx-autodoc-pytest-fixtures" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] docs = [ { name = "gp-sphinx", editable = "../gp-sphinx/packages/gp-sphinx" }, { name = "sphinx-autobuild" }, - { name = "sphinx-pytest-fixtures", editable = "../gp-sphinx/packages/sphinx-pytest-fixtures" }, + { name = "sphinx-autodoc-pytest-fixtures", editable = "../gp-sphinx/packages/sphinx-autodoc-pytest-fixtures" }, ] lint = [ { name = "mypy" }, @@ -1286,6 +1286,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/20/56411b52f917696995f5ad27d2ea7e9492c84a043c5b49a3a3173573cd93/sphinx_autobuild-2025.8.25-py3-none-any.whl", hash = "sha256:b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a", size = 12535, upload-time = "2025-08-25T18:44:54.164Z" }, ] +[[package]] +name = "sphinx-autodoc-pytest-fixtures" +version = "0.0.1a0" +source = { editable = "../gp-sphinx/packages/sphinx-autodoc-pytest-fixtures" } +dependencies = [ + { name = "pytest" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest" }, + { name = "sphinx" }, +] + [[package]] name = "sphinx-autodoc-typehints" version = "3.0.1" @@ -1410,22 +1426,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/2b/e64e7de34663cff1df029ba4f05a86124315bd9eba3d3b78e64904bea7e0/sphinx_inline_tabs-2025.12.21.14-py3-none-any.whl", hash = "sha256:e685c782b58d4e01490bcc4e2367cf7135ec28e7283a05e89095394e4ca6e81a", size = 7082, upload-time = "2025-12-21T13:30:50.142Z" }, ] -[[package]] -name = "sphinx-pytest-fixtures" -version = "0.0.1a0" -source = { editable = "../gp-sphinx/packages/sphinx-pytest-fixtures" } -dependencies = [ - { name = "pytest" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, -] - -[package.metadata] -requires-dist = [ - { name = "pytest" }, - { name = "sphinx" }, -] - [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0" From 19f802b8619f7786393dd61212db57a274bcbec8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 3 Apr 2026 11:56:30 -0500 Subject: [PATCH 04/11] docs(chore): Switch to git sources, remove bundled extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Match tmuxp's gp-sphinx integration pattern — use git sources pointing to git-pull/gp-sphinx#init-3 instead of local editable paths. sphinx_fonts and sphinx_pytest_fixtures are now gp-sphinx packages. what: - Switch [tool.uv.sources] from local paths to git sources (init-3) - Remove docs/_ext/sphinx_fonts.py (now sphinx-fonts package) - Remove docs/_ext/sphinx_pytest_fixtures/ (now sphinx-autodoc-pytest-fixtures) - Keep docs/_ext/spf_demo_fixtures.py (libtmux-specific badge demo) - Re-add docs/_ext to sys.path for spf_demo_fixtures only --- docs/_ext/sphinx_fonts.py | 209 ------- docs/_ext/sphinx_pytest_fixtures/__init__.py | 182 ------ docs/_ext/sphinx_pytest_fixtures/_badges.py | 140 ----- .../_ext/sphinx_pytest_fixtures/_constants.py | 169 ------ docs/_ext/sphinx_pytest_fixtures/_css.py | 28 - .../_ext/sphinx_pytest_fixtures/_detection.py | 348 ----------- .../sphinx_pytest_fixtures/_directives.py | 574 ------------------ .../sphinx_pytest_fixtures/_documenter.py | 280 --------- docs/_ext/sphinx_pytest_fixtures/_index.py | 282 --------- docs/_ext/sphinx_pytest_fixtures/_metadata.py | 413 ------------- docs/_ext/sphinx_pytest_fixtures/_models.py | 150 ----- .../_static/css/sphinx_pytest_fixtures.css | 394 ------------ docs/_ext/sphinx_pytest_fixtures/_store.py | 256 -------- .../sphinx_pytest_fixtures/_transforms.py | 325 ---------- .../sphinx_pytest_fixtures/_validation.py | 96 --- docs/conf.py | 1 + pyproject.toml | 8 +- uv.lock | 47 +- 18 files changed, 13 insertions(+), 3889 deletions(-) delete mode 100644 docs/_ext/sphinx_fonts.py delete mode 100644 docs/_ext/sphinx_pytest_fixtures/__init__.py delete mode 100644 docs/_ext/sphinx_pytest_fixtures/_badges.py delete mode 100644 docs/_ext/sphinx_pytest_fixtures/_constants.py delete mode 100644 docs/_ext/sphinx_pytest_fixtures/_css.py delete mode 100644 docs/_ext/sphinx_pytest_fixtures/_detection.py delete mode 100644 docs/_ext/sphinx_pytest_fixtures/_directives.py delete mode 100644 docs/_ext/sphinx_pytest_fixtures/_documenter.py delete mode 100644 docs/_ext/sphinx_pytest_fixtures/_index.py delete mode 100644 docs/_ext/sphinx_pytest_fixtures/_metadata.py delete mode 100644 docs/_ext/sphinx_pytest_fixtures/_models.py delete mode 100644 docs/_ext/sphinx_pytest_fixtures/_static/css/sphinx_pytest_fixtures.css delete mode 100644 docs/_ext/sphinx_pytest_fixtures/_store.py delete mode 100644 docs/_ext/sphinx_pytest_fixtures/_transforms.py delete mode 100644 docs/_ext/sphinx_pytest_fixtures/_validation.py diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py deleted file mode 100644 index 7a7e1a278..000000000 --- a/docs/_ext/sphinx_fonts.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Sphinx extension for self-hosted fonts via Fontsource CDN. - -Downloads font files at build time, caches them locally, and passes -structured font data to the template context for inline @font-face CSS. -""" - -from __future__ import annotations - -import logging -import pathlib -import shutil -import typing as t -import urllib.error -import urllib.request - -if t.TYPE_CHECKING: - from sphinx.application import Sphinx - -logger = logging.getLogger(__name__) - -CDN_TEMPLATE = ( - "https://cdn.jsdelivr.net/npm/{package}@{version}" - "/files/{font_id}-{subset}-{weight}-{style}.woff2" -) - - -class SetupDict(t.TypedDict): - """Return type for Sphinx extension setup().""" - - version: str - parallel_read_safe: bool - parallel_write_safe: bool - - -def _cache_dir() -> pathlib.Path: - return pathlib.Path.home() / ".cache" / "sphinx-fonts" - - -def _cdn_url( - package: str, - version: str, - font_id: str, - subset: str, - weight: int, - style: str, -) -> str: - return CDN_TEMPLATE.format( - package=package, - version=version, - font_id=font_id, - subset=subset, - weight=weight, - style=style, - ) - - -# Unicode range descriptors per subset — tells the browser to only download -# the file when characters from this range appear on the page. Ranges are -# from Fontsource / Google Fonts CSS (CSS unicode-range values). -_UNICODE_RANGES: dict[str, str] = { - "latin": ( - "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6," - " U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F," - " U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215," - " U+FEFF, U+FFFD" - ), - "latin-ext": ( - "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7," - " U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF," - " U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB," - " U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF" - ), - "cyrillic": ("U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116"), - "cyrillic-ext": ( - "U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F" - ), - "greek": ( - "U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF" - ), - "vietnamese": ( - "U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169," - " U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304," - " U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB" - ), -} - - -def _unicode_range(subset: str) -> str: - """Return the CSS ``unicode-range`` descriptor for *subset*. - - Falls back to an empty string for unknown subsets (omitting the - descriptor causes the browser to treat the face as covering all - codepoints, which is the correct fallback). - - Parameters - ---------- - subset : str - Fontsource subset name (e.g. ``"latin"``, ``"latin-ext"``). - - Returns - ------- - str - CSS ``unicode-range`` value, or ``""`` if unknown. - """ - return _UNICODE_RANGES.get(subset, "") - - -def _download_font(url: str, dest: pathlib.Path) -> bool: - if dest.exists(): - logger.debug("font cached: %s", dest.name) - return True - dest.parent.mkdir(parents=True, exist_ok=True) - try: - urllib.request.urlretrieve(url, dest) - logger.info("downloaded font: %s", dest.name) - except (urllib.error.URLError, OSError): - if dest.exists(): - dest.unlink() - logger.warning("failed to download font: %s", url) - return False - return True - - -def _on_builder_inited(app: Sphinx) -> None: - if app.builder.format != "html": - return - - fonts: list[dict[str, t.Any]] = app.config.sphinx_fonts - variables: dict[str, str] = app.config.sphinx_font_css_variables - if not fonts: - return - - cache = _cache_dir() - static_dir = pathlib.Path(app.outdir) / "_static" - fonts_dir = static_dir / "fonts" - fonts_dir.mkdir(parents=True, exist_ok=True) - - font_faces: list[dict[str, str]] = [] - for font in fonts: - font_id = font["package"].split("/")[-1] - version = font["version"] - package = font["package"] - # Accept "subsets" (list) or legacy "subset" (str). - subsets: list[str] = font.get("subsets", [font.get("subset", "latin")]) - for subset in subsets: - for weight in font["weights"]: - for style in font["styles"]: - filename = f"{font_id}-{subset}-{weight}-{style}.woff2" - cached = cache / filename - url = _cdn_url(package, version, font_id, subset, weight, style) - if _download_font(url, cached): - shutil.copy2(cached, fonts_dir / filename) - font_faces.append( - { - "family": font["family"], - "style": style, - "weight": str(weight), - "filename": filename, - "unicode_range": _unicode_range(subset), - } - ) - - preload_hrefs: list[str] = [] - preload_specs: list[tuple[str, int, str]] = app.config.sphinx_font_preload - for family_name, weight, style in preload_specs: - for font in fonts: - if font["family"] == family_name: - font_id = font["package"].split("/")[-1] - # Preload the first (primary) subset only — typically "latin". - subsets = font.get("subsets", [font.get("subset", "latin")]) - primary = subsets[0] if subsets else "latin" - filename = f"{font_id}-{primary}-{weight}-{style}.woff2" - preload_hrefs.append(filename) - break - - fallbacks: list[dict[str, str]] = app.config.sphinx_font_fallbacks - - app._font_preload_hrefs = preload_hrefs # type: ignore[attr-defined] - app._font_faces = font_faces # type: ignore[attr-defined] - app._font_fallbacks = fallbacks # type: ignore[attr-defined] - app._font_css_variables = variables # type: ignore[attr-defined] - - -def _on_html_page_context( - app: Sphinx, - pagename: str, - templatename: str, - context: dict[str, t.Any], - doctree: t.Any, -) -> None: - context["font_preload_hrefs"] = getattr(app, "_font_preload_hrefs", []) - context["font_faces"] = getattr(app, "_font_faces", []) - context["font_fallbacks"] = getattr(app, "_font_fallbacks", []) - context["font_css_variables"] = getattr(app, "_font_css_variables", {}) - - -def setup(app: Sphinx) -> SetupDict: - """Register config values, events, and return extension metadata.""" - app.add_config_value("sphinx_fonts", [], "html") - app.add_config_value("sphinx_font_fallbacks", [], "html") - app.add_config_value("sphinx_font_css_variables", {}, "html") - app.add_config_value("sphinx_font_preload", [], "html") - app.connect("builder-inited", _on_builder_inited) - app.connect("html-page-context", _on_html_page_context) - return { - "version": "1.0", - "parallel_read_safe": True, - "parallel_write_safe": True, - } diff --git a/docs/_ext/sphinx_pytest_fixtures/__init__.py b/docs/_ext/sphinx_pytest_fixtures/__init__.py deleted file mode 100644 index 8ca0091aa..000000000 --- a/docs/_ext/sphinx_pytest_fixtures/__init__.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Sphinx extension for documenting pytest fixtures as first-class objects. - -Registers ``py:fixture`` as a domain directive and ``autofixture::`` as an -autodoc documenter. Fixtures are rendered with their scope, user-visible -dependencies, and an auto-generated usage snippet rather than as plain -callable signatures. - -.. note:: - - This extension self-registers its CSS via ``add_css_file()``. The rules - live in ``_static/css/sphinx_pytest_fixtures.css`` inside this package. -""" - -from __future__ import annotations - -import typing as t - -from docutils import nodes -from sphinx.domains import ObjType -from sphinx.domains.python import PythonDomain, PyXRefRole - -# --------------------------------------------------------------------------- -# Re-exports for backward compatibility (tests access these via the package) -# --------------------------------------------------------------------------- -from sphinx_pytest_fixtures._badges import ( - _BADGE_TOOLTIPS, - _build_badge_group_node, -) -from sphinx_pytest_fixtures._constants import ( - _CONFIG_BUILTIN_LINKS, - _CONFIG_EXTERNAL_LINKS, - _CONFIG_HIDDEN_DEPS, - _CONFIG_LINT_LEVEL, - _EXTENSION_KEY, - _EXTENSION_VERSION, - _STORE_VERSION, - PYTEST_BUILTIN_LINKS, - PYTEST_HIDDEN, - SetupDict, -) -from sphinx_pytest_fixtures._css import _CSS -from sphinx_pytest_fixtures._detection import ( - _classify_deps, - _get_fixture_fn, - _get_fixture_marker, - _get_return_annotation, - _get_user_deps, - _is_factory, - _is_pytest_fixture, - _iter_injectable_params, -) -from sphinx_pytest_fixtures._directives import ( - AutofixtureIndexDirective, - AutofixturesDirective, - PyFixtureDirective, -) -from sphinx_pytest_fixtures._documenter import FixtureDocumenter -from sphinx_pytest_fixtures._metadata import ( - _build_usage_snippet, - _has_authored_example, - _register_fixture_meta, -) -from sphinx_pytest_fixtures._models import ( - FixtureDep, - FixtureMeta, - autofixture_index_node, -) -from sphinx_pytest_fixtures._store import ( - _finalize_store, - _get_spf_store, - _on_env_merge_info, - _on_env_purge_doc, - _on_env_updated, -) -from sphinx_pytest_fixtures._transforms import ( - _depart_abbreviation_html, - _on_doctree_resolved, - _on_missing_reference, - _visit_abbreviation_html, -) - -if t.TYPE_CHECKING: - from sphinx.application import Sphinx - - -def setup(app: Sphinx) -> SetupDict: - """Register the ``sphinx_pytest_fixtures`` extension. - - Parameters - ---------- - app : Sphinx - The Sphinx application instance. - - Returns - ------- - SetupDict - Extension metadata dict. - """ - app.setup_extension("sphinx.ext.autodoc") - - # Register extension CSS so projects adopting this extension get styled - # output without manually copying spf-* rules into their custom.css. - import pathlib - - _static_dir = str(pathlib.Path(__file__).parent / "_static") - - def _add_static_path(app: Sphinx) -> None: - if _static_dir not in app.config.html_static_path: - app.config.html_static_path.append(_static_dir) - - app.connect("builder-inited", _add_static_path) - app.add_css_file("css/sphinx_pytest_fixtures.css") - - # Override the built-in abbreviation visitor to emit tabindex when set. - # Sphinx's default visit_abbreviation only passes explanation → title, - # silently dropping all other attributes. This override is a strict - # superset — non-badge abbreviation nodes produce identical output. - app.add_node( - nodes.abbreviation, - override=True, - html=(_visit_abbreviation_html, _depart_abbreviation_html), - ) - - # --- New config values (v1.1) --- - app.add_config_value( - _CONFIG_HIDDEN_DEPS, - default=PYTEST_HIDDEN, - rebuild="env", - types=[frozenset], - ) - app.add_config_value( - _CONFIG_BUILTIN_LINKS, - default=PYTEST_BUILTIN_LINKS, - rebuild="env", - types=[dict], - ) - app.add_config_value( - _CONFIG_EXTERNAL_LINKS, - default={}, - rebuild="env", - types=[dict], - ) - app.add_config_value( - _CONFIG_LINT_LEVEL, - default="warning", - rebuild="env", - types=[str], - ) - - # Register std:fixture so :external+pytest:std:fixture: intersphinx - # references resolve. Pytest registers this in their own conf.py; - # we mirror it so the role is known locally. - app.add_crossref_type("fixture", "fixture") - - # Guard against re-registration when setup() is called multiple times. - if "fixture" not in PythonDomain.object_types: - PythonDomain.object_types["fixture"] = ObjType( - "fixture", - "fixture", - "func", - "obj", - ) - app.add_directive_to_domain("py", "fixture", PyFixtureDirective) - app.add_role_to_domain("py", "fixture", PyXRefRole()) - - app.add_autodocumenter(FixtureDocumenter) - app.add_directive("autofixtures", AutofixturesDirective) - app.add_node(autofixture_index_node) - app.add_directive("autofixture-index", AutofixtureIndexDirective) - - app.connect("missing-reference", _on_missing_reference) - app.connect("doctree-resolved", _on_doctree_resolved) - app.connect("env-purge-doc", _on_env_purge_doc) - app.connect("env-merge-info", _on_env_merge_info) - app.connect("env-updated", _on_env_updated) - - return { - "version": _EXTENSION_VERSION, - "env_version": _STORE_VERSION, - "parallel_read_safe": True, - "parallel_write_safe": True, - } diff --git a/docs/_ext/sphinx_pytest_fixtures/_badges.py b/docs/_ext/sphinx_pytest_fixtures/_badges.py deleted file mode 100644 index b56754883..000000000 --- a/docs/_ext/sphinx_pytest_fixtures/_badges.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Badge group rendering helpers for sphinx_pytest_fixtures.""" - -from __future__ import annotations - -from docutils import nodes - -from sphinx_pytest_fixtures._constants import _SUPPRESSED_SCOPES -from sphinx_pytest_fixtures._css import _CSS - -_BADGE_TOOLTIPS: dict[str, str] = { - "session": "Scope: session \u2014 created once per test session", - "module": "Scope: module \u2014 created once per test module", - "class": "Scope: class \u2014 created once per test class", - "factory": "Factory \u2014 returns a callable that creates instances", - "override_hook": "Override hook \u2014 customize in conftest.py", - "fixture": "pytest fixture \u2014 injected by name into test functions", - "autouse": "Runs automatically for every test (autouse=True)", - "deprecated": "Deprecated \u2014 see docs for replacement", -} - - -def _build_badge_group_node( - scope: str, - kind: str, - autouse: bool, - *, - deprecated: bool = False, - show_fixture_badge: bool = True, -) -> nodes.inline: - """Return a badge group as portable ``nodes.abbreviation`` nodes. - - Each badge renders as ```` in HTML, providing hover - tooltips. Non-HTML builders fall back to plain text. - - Badge slots (left-to-right in visual order): - - * Slot 0 (deprecated): shown when fixture is deprecated - * Slot 1 (scope): shown when ``scope != "function"`` - * Slot 2 (kind): shown for ``"factory"`` / ``"override_hook"``; or - state badge (``"autouse"``) when ``autouse=True`` - * Slot 3 (FIXTURE): shown when ``show_fixture_badge=True`` (default) - - Parameters - ---------- - scope : str - Fixture scope string. - kind : str - Fixture kind string. - autouse : bool - When True, renders AUTO state badge instead of a kind badge. - deprecated : bool - When True, renders a deprecated badge at slot 0 (leftmost). - show_fixture_badge : bool - When False, suppresses the FIXTURE badge at slot 3. Use in contexts - where the fixture type is already implied (e.g. an index table). - - Returns - ------- - nodes.inline - Badge group container with abbreviation badge children. - """ - group = nodes.inline(classes=[_CSS.BADGE_GROUP]) - badges: list[nodes.abbreviation] = [] - - # Slot 0 — deprecated badge (leftmost when present) - if deprecated: - badges.append( - nodes.abbreviation( - "deprecated", - "deprecated", - explanation=_BADGE_TOOLTIPS["deprecated"], - classes=[_CSS.BADGE, _CSS.BADGE_STATE, _CSS.DEPRECATED], - ) - ) - - # Slot 1 — scope badge (only non-function scope) - if scope and scope not in _SUPPRESSED_SCOPES: - badges.append( - nodes.abbreviation( - scope, - scope, - explanation=_BADGE_TOOLTIPS.get(scope, f"Scope: {scope}"), - classes=[_CSS.BADGE, _CSS.BADGE_SCOPE, _CSS.scope(scope)], - ) - ) - - # Slot 2 — kind or autouse badge - if autouse: - badges.append( - nodes.abbreviation( - "auto", - "auto", - explanation=_BADGE_TOOLTIPS["autouse"], - classes=[_CSS.BADGE, _CSS.BADGE_STATE, _CSS.AUTOUSE], - ) - ) - elif kind == "factory": - badges.append( - nodes.abbreviation( - "factory", - "factory", - explanation=_BADGE_TOOLTIPS["factory"], - classes=[_CSS.BADGE, _CSS.BADGE_KIND, _CSS.FACTORY], - ) - ) - elif kind == "override_hook": - badges.append( - nodes.abbreviation( - "override", - "override", - explanation=_BADGE_TOOLTIPS["override_hook"], - classes=[_CSS.BADGE, _CSS.BADGE_KIND, _CSS.OVERRIDE], - ) - ) - - # Slot 3 — fixture badge (rightmost, suppressed in index table context) - if show_fixture_badge: - badges.append( - nodes.abbreviation( - "fixture", - "fixture", - explanation=_BADGE_TOOLTIPS["fixture"], - classes=[_CSS.BADGE, _CSS.BADGE_FIXTURE], - ) - ) - - # Make badges focusable for touch/keyboard tooltip accessibility. - # Sphinx's built-in visit_abbreviation does NOT emit tabindex — our - # custom visitor override (_visit_abbreviation_html) handles it. - for badge in badges: - badge["tabindex"] = "0" - - # Interleave with text separators for non-HTML builders (CSS gap - # handles spacing in HTML; text/LaTeX/man builders need explicit spaces). - for i, badge in enumerate(badges): - group += badge - if i < len(badges) - 1: - group += nodes.Text(" ") - - return group diff --git a/docs/_ext/sphinx_pytest_fixtures/_constants.py b/docs/_ext/sphinx_pytest_fixtures/_constants.py deleted file mode 100644 index f944a0b8d..000000000 --- a/docs/_ext/sphinx_pytest_fixtures/_constants.py +++ /dev/null @@ -1,169 +0,0 @@ -from __future__ import annotations - -import re -import typing as t - - -class SetupDict(t.TypedDict): - """Return type for Sphinx extension setup().""" - - version: str - env_version: int - parallel_read_safe: bool - parallel_write_safe: bool - - -# --------------------------------------------------------------------------- -# Extension identity and version -# --------------------------------------------------------------------------- - -_EXTENSION_KEY = "sphinx_pytest_fixtures" -"""Domaindata namespace key used in ``env.domaindata``.""" - -_EXTENSION_VERSION = "1.0" -"""Reported in ``setup()`` return dict.""" - -# --------------------------------------------------------------------------- -# Default values -# --------------------------------------------------------------------------- - -_DEFAULTS: dict[str, str] = { - "scope": "function", - "kind": "resource", - "usage": "auto", -} - -# --------------------------------------------------------------------------- -# Field labels for rendered metadata -# --------------------------------------------------------------------------- - -_FIELD_LABELS: dict[str, str] = { - "scope": "Scope", - "depends": "Depends on", - "autouse": "Autouse", - "kind": "Kind", - "used_by": "Used by", - "parametrized": "Parametrized", -} - -# --------------------------------------------------------------------------- -# Callout messages for fixture cards -# --------------------------------------------------------------------------- - -_CALLOUT_MESSAGES: dict[str, str] = { - "autouse": ( - "No request needed \u2014 this fixture runs automatically for every test." - ), - "session_scope": ( - "Created once per test session and shared across all tests. " - "Requesting this fixture does not create a new instance per test." - ), - "override_hook": ( - "This is an override hook. Override it in your project\u2019s " - "conftest.py to customise behaviour for your test suite." - ), - "yield_fixture": ( - "This is a yield fixture \u2014 it runs setup code before yielding " - "the value to the test, then teardown code after the test completes." - ), - "async_fixture": "This is an async fixture. Use it in async test functions.", -} - -# --------------------------------------------------------------------------- -# Fixture index table structure -# --------------------------------------------------------------------------- - -_INDEX_TABLE_COLUMNS: tuple[tuple[str, int], ...] = ( - ("Fixture", 20), - ("Flags", 22), - ("Returns", 12), - ("Description", 46), -) - -# --------------------------------------------------------------------------- -# Config attribute names (registered via app.add_config_value) -# --------------------------------------------------------------------------- - -_CONFIG_HIDDEN_DEPS = "pytest_fixture_hidden_dependencies" -_CONFIG_BUILTIN_LINKS = "pytest_fixture_builtin_links" -_CONFIG_EXTERNAL_LINKS = "pytest_external_fixture_links" -_CONFIG_LINT_LEVEL = "pytest_fixture_lint_level" - -# --------------------------------------------------------------------------- -# Intersphinx resolution keys -# --------------------------------------------------------------------------- - -_INTERSPHINX_PROJECT = "pytest" -_INTERSPHINX_FIXTURE_ROLE = "std:fixture" - -# --------------------------------------------------------------------------- -# Scopes that suppress the scope badge (function scope = no badge) -# --------------------------------------------------------------------------- - -_SUPPRESSED_SCOPES: frozenset[str] = frozenset({"function"}) - -# --------------------------------------------------------------------------- -# Compiled regex patterns -# --------------------------------------------------------------------------- - -_RST_INLINE_PATTERN = re.compile( - r":(\w+):`([^`]+)`" # :role:`content` - r"|``([^`]+)``" # ``literal`` - r"|`([^`]+)`" # `interpreted text` -) -_IDENTIFIER_PATTERN = re.compile(r"(\b[a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*\b)") - -# --------------------------------------------------------------------------- -# Fixture metadata models — env-safe (all fields are pickle-safe primitives) -# --------------------------------------------------------------------------- - -FixtureKind = t.Literal["resource", "factory", "override_hook"] -_KNOWN_KINDS: frozenset[str] = frozenset(t.get_args(FixtureKind)) - -_STORE_VERSION = 5 -"""Bump whenever ``FixtureMeta`` or the store schema changes. - -Used both as the Sphinx ``env_version`` (triggers full cache invalidation) and -as a runtime sentinel inside the store dict (guards against stale pickles on -incremental builds when ``env_version`` was not bumped). -""" - - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - -# Fixtures hidden from "Depends on" entirely (low-value noise for readers). -# Does NOT include fixtures that have entries in PYTEST_BUILTIN_LINKS — -# those are shown with external hyperlinks instead of being hidden. -PYTEST_HIDDEN: frozenset[str] = frozenset( - { - "pytestconfig", - "capfd", - "capsysbinary", - "capfdbinary", - "recwarn", - "tmpdir", - "pytester", - "testdir", - "record_property", - "record_xml_attribute", - "record_testsuite_property", - "cache", - }, -) - -# External links for pytest built-in fixtures shown in "Depends on" blocks. -# Used as offline fallback when intersphinx inventory is unavailable. -PYTEST_BUILTIN_LINKS: dict[str, str] = { - "tmp_path_factory": ( - "https://docs.pytest.org/en/stable/reference/fixtures.html#tmp_path_factory" - ), - "tmp_path": "https://docs.pytest.org/en/stable/reference/fixtures.html#tmp_path", - "monkeypatch": ( - "https://docs.pytest.org/en/stable/reference/fixtures.html#monkeypatch" - ), - "request": "https://docs.pytest.org/en/stable/reference/fixtures.html#request", - "capsys": "https://docs.pytest.org/en/stable/reference/fixtures.html#capsys", - "caplog": "https://docs.pytest.org/en/stable/reference/fixtures.html#caplog", -} diff --git a/docs/_ext/sphinx_pytest_fixtures/_css.py b/docs/_ext/sphinx_pytest_fixtures/_css.py deleted file mode 100644 index b24f781fe..000000000 --- a/docs/_ext/sphinx_pytest_fixtures/_css.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import annotations - - -class _CSS: - """CSS class name constants used in generated HTML. - - Centralises every ``spf-*`` class name so the extension and stylesheet - stay in sync. Tests import this class to assert on rendered output. - """ - - PREFIX = "spf" - BADGE_GROUP = f"{PREFIX}-badge-group" - BADGE = f"{PREFIX}-badge" - BADGE_SCOPE = f"{PREFIX}-badge--scope" - BADGE_KIND = f"{PREFIX}-badge--kind" - BADGE_STATE = f"{PREFIX}-badge--state" - BADGE_FIXTURE = f"{PREFIX}-badge--fixture" - FACTORY = f"{PREFIX}-factory" - OVERRIDE = f"{PREFIX}-override" - AUTOUSE = f"{PREFIX}-autouse" - DEPRECATED = f"{PREFIX}-deprecated" - FIXTURE_INDEX = f"{PREFIX}-fixture-index" - TABLE_SCROLL = f"{PREFIX}-table-scroll" - - @staticmethod - def scope(name: str) -> str: - """Return the scope-specific CSS class, e.g. ``spf-scope-session``.""" - return f"{_CSS.PREFIX}-scope-{name}" diff --git a/docs/_ext/sphinx_pytest_fixtures/_detection.py b/docs/_ext/sphinx_pytest_fixtures/_detection.py deleted file mode 100644 index 6be1c8372..000000000 --- a/docs/_ext/sphinx_pytest_fixtures/_detection.py +++ /dev/null @@ -1,348 +0,0 @@ -"""Fixture detection and classification helpers for sphinx_pytest_fixtures.""" - -from __future__ import annotations - -import collections.abc -import inspect -import typing as t - -from sphinx.util import logging as sphinx_logging -from sphinx.util.typing import stringify_annotation - -from sphinx_pytest_fixtures._constants import ( - _CONFIG_BUILTIN_LINKS, - _CONFIG_EXTERNAL_LINKS, - _CONFIG_HIDDEN_DEPS, - _DEFAULTS, - PYTEST_BUILTIN_LINKS, - PYTEST_HIDDEN, -) -from sphinx_pytest_fixtures._models import ( - _FixtureFunctionDefinitionAdapter, - _FixtureMarker, -) - -if t.TYPE_CHECKING: - pass - -logger = sphinx_logging.getLogger(__name__) - - -def _is_pytest_fixture(obj: t.Any) -> bool: - """Return True if *obj* is a pytest fixture callable. - - Parameters - ---------- - obj : Any - The object to inspect. - - Returns - ------- - bool - True for pytest 9+ ``FixtureFunctionDefinition`` instances and older - pytest fixtures marked with ``_fixture_function_marker``. - """ - try: - from _pytest.fixtures import FixtureFunctionDefinition - - if isinstance(obj, FixtureFunctionDefinition): - return True - except ImportError: - pass - return hasattr(obj, "_fixture_function_marker") - - -def _get_fixture_fn(obj: t.Any) -> t.Callable[..., t.Any]: - """Return the raw underlying function from a fixture wrapper. - - Parameters - ---------- - obj : Any - A pytest fixture wrapper object. - - Returns - ------- - Callable - The unwrapped fixture function with original annotations and docstring. - """ - if hasattr(obj, "_get_wrapped_function"): - return obj._get_wrapped_function() # type: ignore[no-any-return] - if hasattr(obj, "_fixture_function"): - return obj._fixture_function # type: ignore[no-any-return] - if hasattr(obj, "__wrapped__"): - return obj.__wrapped__ # type: ignore[no-any-return] - return t.cast("t.Callable[..., t.Any]", obj) - - -def _get_fixture_marker(obj: t.Any) -> _FixtureMarker: - """Return normalised fixture metadata for *obj*. - - Handles pytest 9+ FixtureFunctionDefinition (scope is Scope enum) and - older pytest fixtures (_fixture_function_marker attribute). - - Parameters - ---------- - obj : Any - A pytest fixture wrapper object. - - Returns - ------- - _FixtureMarker - Normalised marker object exposing ``scope`` (always a str), - ``autouse``, ``params``, and ``name``. - """ - try: - from _pytest.fixtures import FixtureFunctionDefinition - - if isinstance(obj, FixtureFunctionDefinition): - # FixtureFunctionDefinition wraps a FixtureFunctionMarker; - # access the marker to get scope/autouse/params/name. - marker = obj._fixture_function_marker - return _FixtureFunctionDefinitionAdapter(marker) - except ImportError: - pass - old_marker = getattr(obj, "_fixture_function_marker", None) - if old_marker is not None: - return _FixtureFunctionDefinitionAdapter(old_marker) - msg = f"pytest fixture marker metadata not found on {type(obj).__name__!r}" - raise AttributeError(msg) - - -def _iter_injectable_params( - obj: t.Any, -) -> t.Iterator[tuple[str, inspect.Parameter]]: - """Yield (name, param) for injectable (non-variadic) fixture parameters. - - Pytest injects all POSITIONAL_OR_KEYWORD and KEYWORD_ONLY params by name. - POSITIONAL_ONLY parameters (before ``/``) cannot be injected by name — skip. - VAR_POSITIONAL (*args) and VAR_KEYWORD (**kwargs) are also skipped. - - Parameters - ---------- - obj : Any - A pytest fixture wrapper object. - - Yields - ------ - tuple[str, inspect.Parameter] - ``(name, param)`` pairs for injectable fixture parameters only. - """ - sig = inspect.signature(_get_fixture_fn(obj)) - for name, param in sig.parameters.items(): - if param.kind in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.VAR_POSITIONAL, - inspect.Parameter.VAR_KEYWORD, - ): - continue - yield name, param - - -def _get_user_deps( - obj: t.Any, - hidden: frozenset[str] | None = None, -) -> list[tuple[str, t.Any]]: - """Return ``(name, annotation)`` pairs for user-visible fixture dependencies. - - Parameters - ---------- - obj : Any - A pytest fixture wrapper object. - hidden : frozenset[str] | None - Names to exclude from the dependency list. When ``None``, falls back - to the module-level :data:`PYTEST_HIDDEN` constant. - - Returns - ------- - list[tuple[str, Any]] - Parameters of the wrapped function that are not pytest built-in fixtures. - These are the fixtures users need to provide (or that are auto-provided - by other project fixtures). - """ - if hidden is None: - hidden = PYTEST_HIDDEN - return [ - (name, param.annotation) - for name, param in _iter_injectable_params(obj) - if name not in hidden - ] - - -def _classify_deps( - obj: t.Any, - app: t.Any, -) -> tuple[list[str], dict[str, str], list[str]]: - """Classify fixture dependencies into three buckets. - - Parameters - ---------- - obj : Any - A pytest fixture wrapper object. - app : Any - The Sphinx application (may be ``None`` in unit-test contexts). - - Returns - ------- - tuple[list[str], dict[str, str], list[str]] - ``(project_deps, builtin_deps, hidden_deps)`` where: - - * *project_deps* — dep names to render as ``:fixture:`` cross-refs - * *builtin_deps* — dict mapping dep name → external URL - * *hidden_deps* — dep names suppressed entirely - """ - if app is not None: - hidden: frozenset[str] = getattr( - app.config, - _CONFIG_HIDDEN_DEPS, - PYTEST_HIDDEN, - ) - builtin_links: dict[str, str] = getattr( - app.config, - _CONFIG_BUILTIN_LINKS, - PYTEST_BUILTIN_LINKS, - ) - external_links: dict[str, str] = getattr( - app.config, - _CONFIG_EXTERNAL_LINKS, - {}, - ) - all_links = {**builtin_links, **external_links} - else: - hidden = PYTEST_HIDDEN - all_links = PYTEST_BUILTIN_LINKS - - project: list[str] = [] - builtin: dict[str, str] = {} - hidden_list: list[str] = [] - - for name, _param in _iter_injectable_params(obj): - if name in hidden: - hidden_list.append(name) - elif name in all_links: - builtin[name] = all_links[name] - else: - project.append(name) - - return project, builtin, hidden_list - - -def _get_return_annotation(obj: t.Any) -> t.Any: - """Return the injected type of the fixture's underlying function. - - For ``yield`` fixtures annotated as ``Generator[T, None, None]`` or - ``Iterator[T]``, returns the yield type ``T`` — the value the test function - actually receives. This matches how pytest users think about the fixture's - return contract. - - Parameters - ---------- - obj : Any - A pytest fixture wrapper object. - - Returns - ------- - Any - The resolved return/yield type annotation, or ``inspect.Parameter.empty`` - when the annotation cannot be resolved (e.g. forward references under - ``TYPE_CHECKING`` guards not importable at doc-build time). - """ - fn = _get_fixture_fn(obj) - try: - hints = t.get_type_hints(fn) - except (NameError, AttributeError, TypeError, RecursionError): - # Forward references (TYPE_CHECKING guards), parameterized generics - # (TypeError in some Python versions), circular imports (RecursionError), - # or other resolution failures. Fall back to the raw annotation string. - ann = inspect.signature(fn).return_annotation - return inspect.Parameter.empty if ann is inspect.Parameter.empty else ann - ret = hints.get("return", inspect.Parameter.empty) - if ret is inspect.Parameter.empty: - return ret - # Unwrap Generator/Iterator and their async counterparts so that - # yield-based fixtures show the injected type, not the generator type. - origin = t.get_origin(ret) - if origin in ( - collections.abc.Generator, - collections.abc.Iterator, - collections.abc.AsyncGenerator, - collections.abc.AsyncIterator, - ): - args = t.get_args(ret) - return args[0] if args else inspect.Parameter.empty - return ret - - -def _format_type_short(annotation: t.Any) -> str: - """Format *annotation* to a short display string for docs. - - Parameters - ---------- - annotation : Any - A type annotation, possibly ``inspect.Parameter.empty``. - - Returns - ------- - str - A human-readable type string, or ``"..."`` when annotation is absent. - """ - if annotation is inspect.Parameter.empty: - return "..." - try: - return stringify_annotation(annotation) - except Exception: - return str(annotation) - - -def _is_factory(obj: t.Any) -> bool: - """Return True if *obj* is a factory fixture. - - Parameters - ---------- - obj : Any - A pytest fixture wrapper object. - - Returns - ------- - bool - True when the return annotation is ``type[X]`` or ``Callable[..., X]``. - Returns False when no annotation (or ``t.Any``) is present — use - the explicit ``:kind: factory`` option to override. - """ - ret = _get_return_annotation(obj) - # t.Any / unannotated: no type information — default to resource. - if ret is inspect.Parameter.empty or ret is t.Any: - return False - origin = t.get_origin(ret) - if origin is type or origin is collections.abc.Callable: - return True - ret_str = str(ret) - return ret_str.startswith("type[") or "Callable" in ret_str - - -def _infer_kind(obj: t.Any, explicit_kind: str | None = None) -> str: - """Return the fixture kind, honouring an explicit override. - - Priority chain: - - 1. *explicit_kind* — set via ``:kind:`` directive option by the author. - 2. Type annotation — ``type[X]`` / ``Callable`` → ``"factory"``. - 3. Default → ``"resource"``. - - Parameters - ---------- - obj : Any - A pytest fixture wrapper object. - explicit_kind : str | None - Value from the ``:kind:`` directive option, if provided. - - Returns - ------- - str - One of ``"resource"``, ``"factory"``, or ``"override_hook"`` (or any - custom string passed via ``:kind:``). - """ - if explicit_kind: - return explicit_kind - if _is_factory(obj): - return "factory" - return str(_DEFAULTS["kind"]) diff --git a/docs/_ext/sphinx_pytest_fixtures/_directives.py b/docs/_ext/sphinx_pytest_fixtures/_directives.py deleted file mode 100644 index c37a82bd4..000000000 --- a/docs/_ext/sphinx_pytest_fixtures/_directives.py +++ /dev/null @@ -1,574 +0,0 @@ -"""Sphinx directive classes for sphinx_pytest_fixtures.""" - -from __future__ import annotations - -import typing as t - -from docutils import nodes -from docutils.parsers.rst import Directive, directives -from docutils.statemachine import ViewList -from sphinx import addnodes -from sphinx.domains.python import PyFunction -from sphinx.util import logging as sphinx_logging -from sphinx.util.docfields import Field, GroupedField -from sphinx.util.docutils import SphinxDirective - -from sphinx_pytest_fixtures._constants import ( - _CALLOUT_MESSAGES, - _CONFIG_BUILTIN_LINKS, - _CONFIG_EXTERNAL_LINKS, - _DEFAULTS, - _FIELD_LABELS, - _KNOWN_KINDS, - PYTEST_BUILTIN_LINKS, -) -from sphinx_pytest_fixtures._css import _CSS -from sphinx_pytest_fixtures._detection import ( - _get_fixture_fn, - _get_fixture_marker, - _is_pytest_fixture, -) -from sphinx_pytest_fixtures._metadata import ( - _build_usage_snippet, - _has_authored_example, - _summary_insert_index, -) -from sphinx_pytest_fixtures._models import ( - FixtureDep, - FixtureMeta, - autofixture_index_node, -) -from sphinx_pytest_fixtures._store import _get_spf_store, _resolve_builtin_url - -if t.TYPE_CHECKING: - pass - -logger = sphinx_logging.getLogger(__name__) - - -class PyFixtureDirective(PyFunction): - """Sphinx directive for documenting pytest fixtures: ``.. py:fixture::``. - - Registered as ``fixture`` in the Python domain. Renders as:: - - fixture server -> Server - - instead of:: - - server(request, monkeypatch, config_file) -> Server - """ - - option_spec = PyFunction.option_spec.copy() - option_spec.update( - { - "scope": directives.unchanged, - "autouse": directives.flag, - "depends": directives.unchanged, - "factory": directives.flag, - "overridable": directives.flag, - "kind": directives.unchanged, # explicit kind override - "return-type": directives.unchanged, - "usage": directives.unchanged, # "auto" (default) or "none" - "params": directives.unchanged, # e.g. ":params: val1, val2" - "teardown": directives.flag, # ":teardown:" flag for yield fixtures - "async": directives.flag, # ":async:" flag for async fixtures - "deprecated": directives.unchanged, # version string - "replacement": directives.unchanged, # canonical replacement fixture - "teardown-summary": directives.unchanged, # teardown description - }, - ) - - doc_field_types = [ # noqa: RUF012 - Field( - "scope", - label=_FIELD_LABELS["scope"], - has_arg=False, - names=("scope",), - ), - GroupedField( - "depends", - label=_FIELD_LABELS["depends"], - rolename="fixture", - names=("depends", "depend"), - can_collapse=True, - ), - Field( - "factory", - label="Factory", - has_arg=False, - names=("factory",), - ), - Field( - "overridable", - label="Override hook", - has_arg=False, - names=("overridable",), - ), - ] - - def needs_arglist(self) -> bool: - """Suppress ``()`` — fixtures are not called with arguments.""" - return False - - def get_signature_prefix( - self, - sig: str, - ) -> t.Sequence[addnodes.desc_sig_element]: - """Render the ``fixture`` keyword before the fixture name. - - Parameters - ---------- - sig : str - The raw signature string from the directive. - - Returns - ------- - Sequence[addnodes.desc_sig_element] - Prefix nodes rendering as ``fixture `` before the fixture name. - """ - return [ - addnodes.desc_sig_keyword("", "fixture"), - addnodes.desc_sig_space(), - ] - - def handle_signature( - self, - sig: str, - signode: addnodes.desc_signature, - ) -> tuple[str, str]: - """Store fixture metadata on signode for badge injection. - - Parameters - ---------- - sig : str - The raw signature string from the directive. - signode : addnodes.desc_signature - The signature node to annotate. - - Returns - ------- - tuple[str, str] - ``(fullname, prefix)`` from the parent implementation. - """ - result = super().handle_signature(sig, signode) - signode["spf_scope"] = self.options.get("scope", _DEFAULTS["scope"]) - signode["spf_kind"] = self.options.get("kind", _DEFAULTS["kind"]) - signode["spf_autouse"] = "autouse" in self.options - signode["spf_deprecated"] = "deprecated" in self.options - signode["spf_ret_type"] = self.options.get("return-type", "") - return result - - def get_index_text(self, modname: str, name_cls: tuple[str, str]) -> str: - """Return index entry text for the fixture. - - Parameters - ---------- - modname : str - The module name containing the fixture. - name_cls : tuple[str, str] - ``(fullname, classname_prefix)`` from ``handle_signature``. - - Returns - ------- - str - Index entry in the form ``name (pytest fixture in modname)``. - """ - name, _cls = name_cls - return f"{name} (pytest fixture in {modname})" - - def transform_content( - self, - content_node: addnodes.desc_content, - ) -> None: - """Inject fixture metadata as doctree nodes before DocFieldTransformer. - - ``transform_content`` runs at line 108 of ``ObjectDescription.run()``; - ``DocFieldTransformer.transform_all()`` runs at line 112 — so - ``nodes.field_list`` entries inserted here ARE processed by - ``DocFieldTransformer`` and receive full field styling. - - Parameters - ---------- - content_node : addnodes.desc_content - The content node to prepend metadata into. - """ - scope = self.options.get("scope", _DEFAULTS["scope"]) - depends_str = self.options.get("depends", "") - ret_type = self.options.get("return-type", "") - show_usage = self.options.get("usage", _DEFAULTS["usage"]) != "none" - kind = self.options.get("kind", "") - autouse = "autouse" in self.options - has_teardown = "teardown" in self.options - is_async = "async" in self.options - - field_list = nodes.field_list() - - # Scope field removed — badges communicate scope at a glance, - # the index table provides comparison. See P2-2 in the enhancement spec. - - # --- Autouse field --- - if autouse: - field_list += nodes.field( - "", - nodes.field_name("", _FIELD_LABELS["autouse"]), - nodes.field_body( - "", - nodes.paragraph("", "yes \u2014 runs automatically for every test"), - ), - ) - - # --- Kind field (only for custom/nonstandard kinds not covered by badges) --- - if kind and kind not in _KNOWN_KINDS: - field_list += nodes.field( - "", - nodes.field_name("", _FIELD_LABELS["kind"]), - nodes.field_body("", nodes.paragraph("", kind)), - ) - - # --- Depends-on fields — project deps as :fixture: xrefs, - # builtin/external deps as external hyperlinks --- - if depends_str: - # Resolve builtin/external link mapping from config - app_obj = getattr(getattr(self, "env", None), "app", None) - builtin_links: dict[str, str] = ( - getattr( - app_obj.config, - _CONFIG_BUILTIN_LINKS, - PYTEST_BUILTIN_LINKS, - ) - if app_obj is not None - else PYTEST_BUILTIN_LINKS - ) - external_links: dict[str, str] = ( - getattr(app_obj.config, _CONFIG_EXTERNAL_LINKS, {}) - if app_obj is not None - else {} - ) - all_links = {**builtin_links, **external_links} - - # Collect all dep nodes, then emit one comma-separated row - # (matches the "Used by" pattern in _on_doctree_resolved). - dep_ref_nodes: list[nodes.Node] = [] - for dep in (d.strip() for d in depends_str.split(",") if d.strip()): - # Resolve URL: intersphinx → config → hardcoded fallback - url: str | None = None - if dep in all_links: - url = _resolve_builtin_url(dep, app_obj) or all_links[dep] - if url: - dep_ref_nodes.append( - nodes.reference(dep, "", nodes.literal(dep, dep), refuri=url) - ) - else: - ref_ns, _ = self.state.inline_text( - f":fixture:`{dep}`", - self.lineno, - ) - dep_ref_nodes.extend(ref_ns) - - if dep_ref_nodes: - body_para = nodes.paragraph() - for i, dn in enumerate(dep_ref_nodes): - body_para += dn - if i < len(dep_ref_nodes) - 1: - body_para += nodes.Text(", ") - field_list += nodes.field( - "", - nodes.field_name("", _FIELD_LABELS["depends"]), - nodes.field_body("", body_para), - ) - - # --- Deprecation warning (before lifecycle callouts) --- - deprecated_version = self.options.get("deprecated") - replacement_name = self.options.get("replacement") - - if deprecated_version is not None: - warning = nodes.warning() - dep_para = nodes.paragraph() - dep_para += nodes.Text(f"Deprecated since version {deprecated_version}.") - if replacement_name: - dep_para += nodes.Text(" Use ") - ref_ns, _ = self.state.inline_text( - f":fixture:`{replacement_name}`", - self.lineno, - ) - dep_para.extend(ref_ns) - dep_para += nodes.Text(" instead.") - warning += dep_para - # Add spf-deprecated class to the parent desc node for CSS muting - for parent in self.state.document.findall(addnodes.desc): - for sig in parent.findall(addnodes.desc_signature): - if sig.get("spf_deprecated"): - parent["classes"].append(_CSS.DEPRECATED) - break - - # --- Lifecycle callouts (session note + override hook tip) --- - callout_nodes: list[nodes.Node] = [] - - if deprecated_version is not None: - callout_nodes.append(warning) - - if scope == "session": - note = nodes.note() - note += nodes.paragraph("", _CALLOUT_MESSAGES["session_scope"]) - callout_nodes.append(note) - - if kind == "override_hook": - tip = nodes.tip() - tip += nodes.paragraph("", _CALLOUT_MESSAGES["override_hook"]) - callout_nodes.append(tip) - - if has_teardown: - note = nodes.note() - note += nodes.paragraph("", _CALLOUT_MESSAGES["yield_fixture"]) - teardown_text = self.options.get("teardown-summary", "") - if teardown_text: - note += nodes.paragraph( - "", - "", - nodes.strong("", "Teardown: "), - nodes.Text(teardown_text), - ) - callout_nodes.append(note) - - if is_async: - note = nodes.note() - note += nodes.paragraph("", _CALLOUT_MESSAGES["async_fixture"]) - callout_nodes.append(note) - - # --- Usage snippet (five-zone insertion after first paragraph) --- - raw_arg = self.arguments[0] if self.arguments else "" - fixture_name = raw_arg.split("(")[0].strip() - - snippet: nodes.Node | None = None - if show_usage and fixture_name and not _has_authored_example(content_node): - snippet = _build_usage_snippet( - fixture_name, - ret_type or None, - kind or _DEFAULTS["kind"], - scope, - autouse, - ) - - # Collect generated nodes and insert in five-zone order after summary. - # Insertion uses reversed() so nodes end up in forward order. - generated: list[nodes.Node] = [*callout_nodes] - if field_list.children: - generated.append(field_list) - if snippet is not None: - generated.append(snippet) - - if generated: - insert_idx = _summary_insert_index(content_node) - for node in reversed(generated): - content_node.insert(insert_idx, node) - - def add_target_and_index( - self, - name_cls: tuple[str, str], - sig: str, - signode: addnodes.desc_signature, - ) -> None: - """Register the fixture target and index entry. - - Notes - ----- - Bypasses ``PyFunction.add_target_and_index``, which always appends a - ``name() (in module X)`` index entry — wrong for fixtures. Calls - ``PyObject.add_target_and_index`` directly so only the fixture-style - ``get_index_text`` entry is produced. - - Stores ``spf_canonical_name`` on *signode* for metadata-driven - rendering in :func:`_on_doctree_resolved`. - """ - modname = self.options.get("module", self.env.ref_context.get("py:module", "")) - name = name_cls[0] - canonical = f"{modname}.{name}" if modname else name - signode["spf_canonical_name"] = canonical - super(PyFunction, self).add_target_and_index(name_cls, sig, signode) - - # Scope/kind-qualified pair index entries for the general index. - node_id = signode.get("ids", [""])[0] if signode.get("ids") else "" - scope = self.options.get("scope", _DEFAULTS["scope"]) - kind = self.options.get("kind", _DEFAULTS["kind"]) - if scope != "function" and node_id: - self.indexnode["entries"].append( - ("pair", f"{scope}-scoped fixtures; {name}", node_id, "", None) - ) - if kind not in ("resource",) and node_id: - kind_label = { - "factory": "factory fixtures", - "override_hook": "override hooks", - }.get(kind, f"{kind} fixtures") - self.indexnode["entries"].append( - ("pair", f"{kind_label}; {name}", node_id, "", None) - ) - - # Register minimal FixtureMeta for manual directives so they - # participate in short-name xrefs, "Used by", and reverse_deps. - # Guard: don't overwrite richer autodoc-generated metadata. - store = _get_spf_store(self.env) - if canonical not in store["fixtures"]: - public = canonical.rsplit(".", 1)[-1] - deps: list[FixtureDep] = [] - if depends_str := self.options.get("depends"): - deps.extend( - FixtureDep(display_name=d.strip(), kind="fixture") - for d in depends_str.split(",") - if d.strip() - ) - store["fixtures"][canonical] = FixtureMeta( - docname=self.env.docname, - canonical_name=canonical, - public_name=public, - source_name=public, - scope=self.options.get("scope", _DEFAULTS["scope"]), - autouse="autouse" in self.options, - kind=self.options.get("kind", _DEFAULTS["kind"]), - return_display=self.options.get("return-type", ""), - return_xref_target=None, - deps=tuple(deps), - param_reprs=tuple( - p.strip() - for p in self.options.get("params", "").split(",") - if p.strip() - ), - has_teardown="teardown" in self.options, - is_async="async" in self.options, - summary="", - deprecated=self.options.get("deprecated"), - replacement=self.options.get("replacement"), - teardown_summary=self.options.get("teardown-summary"), - ) - - -class AutofixturesDirective(Directive): - """Bulk fixture autodoc directive: ``.. autofixtures:: module.name``. - - Scans *module.name* for all pytest fixtures and emits one - ``.. autofixture::`` directive per fixture found. This eliminates - the need to list every fixture manually in docs. - - Usage:: - - .. autofixtures:: libtmux.pytest_plugin - :order: source - :exclude: clear_env - - Options - ------- - order : str, optional - ``"source"`` (default) preserves module attribute order. - ``"alpha"`` sorts fixtures alphabetically by public name. - exclude : str, optional - Comma-separated list of fixture public names to skip. - """ - - required_arguments = 1 - optional_arguments = 0 - has_content = False - option_spec: t.ClassVar[dict[str, t.Any]] = { - "order": directives.unchanged, - "exclude": directives.unchanged, - } - - def run(self) -> list[nodes.Node]: - """Scan the module and emit autofixture directives.""" - import importlib - - modname = self.arguments[0].strip() - order = self.options.get("order", "source") - exclude_str = self.options.get("exclude", "") - excluded: set[str] = { - name.strip() for name in exclude_str.split(",") if name.strip() - } - - try: - module = importlib.import_module(modname) - except ImportError: - logger.warning( - "autofixtures: cannot import module %r — skipping.", - modname, - ) - return [] - - # Register the module file as a dependency so incremental rebuilds - # re-process this page when the scanned module changes. - env = self.state.document.settings.env - if hasattr(module, "__file__") and module.__file__: - env.note_dependency(module.__file__) - - # Collect all (attr_name, public_name, fixture_obj) triples. - entries: list[tuple[str, str, t.Any]] = [] - seen_public: set[str] = set() - for attr_name, value in vars(module).items(): - if not _is_pytest_fixture(value): - continue - try: - marker = _get_fixture_marker(value) - except AttributeError: - continue - public_name = marker.name or _get_fixture_fn(value).__name__ - if public_name in excluded: - continue - if public_name in seen_public: - logger.warning( - "autofixtures: duplicate public name %r in %s; skipping duplicate.", - public_name, - modname, - ) - continue - seen_public.add(public_name) - entries.append((attr_name, public_name, value)) - - if order == "alpha": - entries.sort(key=lambda e: e[1]) - - # Build RST content: one ``autofixture::`` directive per fixture. - source = f"" - lines: list[str] = [] - for _attr_name, public_name, _value in entries: - lines.append(f".. autofixture:: {modname}.{public_name}") - lines.append("") - rst_lines = ViewList(lines, source=source) - - # Parse the generated RST into a container node. - # ViewList is compatible with nested_parse at runtime even though - # docutils stubs declare StringList — suppress the type mismatch. - container = nodes.section() - container.document = self.state.document - self.state.nested_parse( - rst_lines, # type: ignore[arg-type] - self.content_offset, - container, - ) - return container.children - - -class AutofixtureIndexDirective(SphinxDirective): - """Generate a fixture index table from the :class:`FixtureStoreDict`. - - Emits a :class:`autofixture_index_node` placeholder at parse time. - The placeholder is resolved into a ``nodes.table`` during - ``doctree-resolved``, when the store has been finalized by ``env-updated``. - - Usage:: - - .. autofixture-index:: libtmux.pytest_plugin - :exclude: _internal_helper - """ - - required_arguments = 1 - optional_arguments = 0 - has_content = False - option_spec: t.ClassVar[dict[str, t.Any]] = { - "exclude": directives.unchanged, - } - - def run(self) -> list[nodes.Node]: - """Return a placeholder node with module and exclude metadata.""" - node = autofixture_index_node() - node["module"] = self.arguments[0].strip() - node["exclude"] = { - s.strip() for s in self.options.get("exclude", "").split(",") if s.strip() - } - return [node] diff --git a/docs/_ext/sphinx_pytest_fixtures/_documenter.py b/docs/_ext/sphinx_pytest_fixtures/_documenter.py deleted file mode 100644 index 2497dba71..000000000 --- a/docs/_ext/sphinx_pytest_fixtures/_documenter.py +++ /dev/null @@ -1,280 +0,0 @@ -"""Autodoc documenter for pytest fixtures.""" - -from __future__ import annotations - -import inspect -import typing as t - -from docutils.parsers.rst import directives -from sphinx.ext.autodoc import FunctionDocumenter -from sphinx.util import logging as sphinx_logging - -from sphinx_pytest_fixtures._constants import ( - _CONFIG_HIDDEN_DEPS, - PYTEST_HIDDEN, -) -from sphinx_pytest_fixtures._detection import ( - _format_type_short, - _get_fixture_fn, - _get_fixture_marker, - _get_return_annotation, - _get_user_deps, - _infer_kind, - _is_pytest_fixture, -) -from sphinx_pytest_fixtures._metadata import ( - _extract_teardown_summary, - _register_fixture_meta, -) - -if t.TYPE_CHECKING: - pass - -logger = sphinx_logging.getLogger(__name__) - - -class FixtureDocumenter(FunctionDocumenter): - """Autodoc documenter for pytest fixtures. - - Registered via ``app.add_autodocumenter()``. Enables:: - - .. autofixture:: libtmux.pytest_plugin.server - :kind: override_hook - """ - - objtype = "fixture" - directivetype = "fixture" - priority = FunctionDocumenter.priority + 10 - - option_spec: t.ClassVar[dict[str, t.Any]] = { - **FunctionDocumenter.option_spec, - "kind": directives.unchanged, - } - - # Resolved during import_object(); None until then. - _fixture_public_name: str | None = None - - @classmethod - def can_document_member( - cls, - member: t.Any, - membername: str, - isattr: bool, - parent: t.Any, - ) -> bool: - """Return True if *member* is a pytest fixture.""" - return bool(_is_pytest_fixture(member)) - - def import_object(self, raiseerror: bool = False) -> bool: - """Import the fixture object, with alias-aware fallback. - - When ``@pytest.fixture(name='alias')`` is used, the module attribute - name differs from the public fixture name. ``autofixture::`` directives - may be written with either the attribute name or the public alias. The - standard ``super().import_object()`` path finds the attribute name; if - that fails we scan the module members looking for a fixture whose public - name matches the requested name. - - Parameters - ---------- - raiseerror : bool - When True, raise ``ImportError`` on failure instead of returning - False. - - Returns - ------- - bool - True when the fixture object was resolved successfully. - """ - import importlib - - # --- Standard path: resolve by module attribute name --- - if super().import_object(raiseerror=False): - try: - marker = _get_fixture_marker(self.object) - self._fixture_public_name = ( - marker.name or _get_fixture_fn(self.object).__name__ - ) - except AttributeError: - pass - return True - - # --- Alias fallback: scan module members --- - modname, _, wanted_public = self.fullname.rpartition(".") - if not modname: - if raiseerror: - msg = f"fixture {self.fullname!r} not found" - raise ImportError(msg) - return False - - try: - module = importlib.import_module(modname) - except ImportError: - if raiseerror: - raise - return False - - found: list[tuple[str, t.Any, str]] = [] - for attr_name, value in vars(module).items(): - if not _is_pytest_fixture(value): - continue - try: - marker = _get_fixture_marker(value) - except AttributeError: - continue - public = marker.name or _get_fixture_fn(value).__name__ - if public == wanted_public: - found.append((attr_name, value, public)) - - if len(found) > 1: - logger.warning( - "autofixture: multiple fixtures with public name %r in %s; " - "using first match. Use the attribute name to disambiguate.", - wanted_public, - modname, - ) - - if found: - attr_name, value, public_name = found[0] - self.object = value - self.modname = modname - self.objpath = [attr_name] # real attr path for source lookup - self.fullname = f"{modname}.{public_name}" - self._fixture_public_name = public_name - self.parent = module - return True - - if raiseerror: - msg = f"fixture alias {self.fullname!r} not found" - raise ImportError(msg) - return False - - def format_name(self) -> str: - """Return the effective fixture name, honouring ``@pytest.fixture(name=...)``. - - Returns - ------- - str - The fixture's name as pytest will inject it into test functions. - When ``@pytest.fixture(name='alias')`` is used, returns ``'alias'`` - rather than the underlying function name. - """ - if self._fixture_public_name: - return self._fixture_public_name - return str( - getattr(self.object, "name", None) or _get_fixture_fn(self.object).__name__ - ) - - def format_signature(self, **kwargs: t.Any) -> str: - """Return ``() -> ReturnType`` so Sphinx can parse the directive argument. - - The ``()`` is required for ``py_sig_re`` to match a ``->`` return - annotation. ``needs_arglist()`` returns ``False``, so the ``()`` is - suppressed in the rendered output — the reader sees only - ``fixture name -> ReturnType``. - - Returns - ------- - str - Signature string of the form ``() -> ReturnType``, or empty string - when no return annotation is present. - """ - ret = _get_return_annotation(self.object) - if ret is inspect.Parameter.empty: - return "()" - return f"() -> {_format_type_short(ret)}" - - def format_args(self, **kwargs: t.Any) -> str: - """Return empty string — no argument list is shown to users. - - Returns - ------- - str - Always ``""``. - """ - return "" - - def get_doc(self) -> list[list[str]] | None: - """Extract the docstring from the wrapped function, not the fixture wrapper. - - Returns - ------- - list[list[str]] or None - Docstring lines or empty list if no docstring. - """ - fn = _get_fixture_fn(self.object) - docstring = inspect.getdoc(fn) - if docstring: - return [docstring.splitlines()] - return [] - - def add_directive_header(self, sig: str) -> None: - """Emit the directive header with fixture-specific options. - - Also registers ``FixtureMeta`` in the env store for reverse dep - tracking and incremental-build correctness. - - Parameters - ---------- - sig : str - The formatted signature string. - """ - super().add_directive_header(sig) - sourcename = self.get_sourcename() - marker = _get_fixture_marker(self.object) - - scope = marker.scope - self.add_line(f" :scope: {scope}", sourcename) - - if marker.autouse: - self.add_line(" :autouse:", sourcename) - - # Use the config-driven hidden set so pytest_fixture_hidden_dependencies - # in conf.py suppresses deps from the directive header too. - hidden_cfg: frozenset[str] = getattr( - self.env.app.config, - _CONFIG_HIDDEN_DEPS, - PYTEST_HIDDEN, - ) - user_deps = _get_user_deps(self.object, hidden=hidden_cfg) - if user_deps: - dep_names = ", ".join(name for name, _ in user_deps) - self.add_line(f" :depends: {dep_names}", sourcename) - - ret = _get_return_annotation(self.object) - if ret is not inspect.Parameter.empty: - self.add_line(f" :return-type: {_format_type_short(ret)}", sourcename) - - explicit_kind = self.options.get("kind") - kind = _infer_kind(self.object, explicit_kind=explicit_kind) - self.add_line(f" :kind: {kind}", sourcename) - - # Register fixture metadata in the env store for reverse-dep tracking. - # Pass already-resolved kind to avoid a second _infer_kind call. - public_name = self.format_name() - source_name = self.objpath[-1] if self.objpath else public_name - - # Extract teardown summary before registering so the store gets the - # correct value; PyFixtureDirective will overwrite it again later but - # this ensures _validate_store sees the summary. - teardown_text = _extract_teardown_summary(self.object) - - meta = _register_fixture_meta( - env=self.env, - docname=self.env.docname, - obj=self.object, - public_name=public_name, - source_name=source_name, - modname=self.modname, - kind=kind, - app=self.env.app, - teardown_summary=teardown_text, - ) - - # Emit teardown/async flags derived from the fixture function. - if meta.has_teardown: - self.add_line(" :teardown:", sourcename) - if teardown_text: - self.add_line(f" :teardown-summary: {teardown_text}", sourcename) - if meta.is_async: - self.add_line(" :async:", sourcename) diff --git a/docs/_ext/sphinx_pytest_fixtures/_index.py b/docs/_ext/sphinx_pytest_fixtures/_index.py deleted file mode 100644 index 59319f704..000000000 --- a/docs/_ext/sphinx_pytest_fixtures/_index.py +++ /dev/null @@ -1,282 +0,0 @@ -"""Index table generation helpers for sphinx_pytest_fixtures.""" - -from __future__ import annotations - -import typing as t - -from docutils import nodes -from sphinx import addnodes -from sphinx.domains.python import PythonDomain -from sphinx.util import logging as sphinx_logging -from sphinx.util.nodes import make_refnode - -from sphinx_pytest_fixtures._badges import _build_badge_group_node -from sphinx_pytest_fixtures._constants import ( - _IDENTIFIER_PATTERN, - _INDEX_TABLE_COLUMNS, - _RST_INLINE_PATTERN, -) -from sphinx_pytest_fixtures._css import _CSS -from sphinx_pytest_fixtures._models import FixtureMeta, autofixture_index_node -from sphinx_pytest_fixtures._store import FixtureStoreDict - -if t.TYPE_CHECKING: - from sphinx.application import Sphinx - -logger = sphinx_logging.getLogger(__name__) - - -def _parse_rst_inline( - text: str, - app: Sphinx, - docname: str, -) -> list[nodes.Node]: - """Parse RST inline markup into doctree nodes with resolved cross-refs. - - Handles ``:class:`Target```, ``:fixture:`name```, ````literal````, - and plain text. Cross-references are created as ``pending_xref`` nodes - and resolved via ``env.resolve_references()``. - - Parameters - ---------- - text : str - RST inline text, e.g. ``"Return new :class:`libtmux.Server`."``. - app : Sphinx - The Sphinx application (for builder and env access). - docname : str - Current document name (for relative URI resolution). - - Returns - ------- - list[nodes.Node] - Sequence of text, literal, and reference nodes ready for insertion. - """ - result_nodes: list[nodes.Node] = [] - - # Tokenise: :role:`content`, ``literal``, or plain text - pattern = _RST_INLINE_PATTERN - pos = 0 - for m in pattern.finditer(text): - # Plain text before match - if m.start() > pos: - result_nodes.append(nodes.Text(text[pos : m.start()])) - - if m.group(1): - # :role:`content` — build a pending_xref - role = m.group(1) - content = m.group(2) - - # Handle ~ shortening prefix - if content.startswith("~"): - target = content[1:] - display = target.rsplit(".", 1)[-1] - elif "<" in content and ">" in content: - display = content.split("<")[0].strip() - target = content.split("<")[1].rstrip(">").strip() - else: - target = content - display = content.rsplit(".", 1)[-1] - - xref = addnodes.pending_xref( - "", - nodes.literal(display, display, classes=["xref", "py", f"py-{role}"]), - refdomain="py", - reftype=role, - reftarget=target, - refexplicit=True, - refwarn=True, - ) - xref["refdoc"] = docname - result_nodes.append(xref) - - elif m.group(3): - # ``literal`` — inline code - result_nodes.append(nodes.literal(m.group(3), m.group(3), classes=["code"])) - elif m.group(4): - # `interpreted text` — render as inline code (Sphinx default role - # in the Python domain is :obj:, which renders as code) - result_nodes.append(nodes.literal(m.group(4), m.group(4))) - - pos = m.end() - - # Trailing plain text - if pos < len(text): - result_nodes.append(nodes.Text(text[pos:])) - - # Resolve pending_xref nodes via env.resolve_references - if any(isinstance(n, addnodes.pending_xref) for n in result_nodes): - from sphinx.util.docutils import new_document - - temp_doc = new_document("") - temp_para = nodes.paragraph() - for n in result_nodes: - temp_para += n - temp_doc += temp_para - app.env.resolve_references(temp_doc, docname, app.builder) - # Extract resolved nodes from the temp paragraph - result_nodes = list(temp_para.children) - - return result_nodes - - -def _build_return_type_nodes( - meta: FixtureMeta, - py_domain: PythonDomain, - app: Sphinx, - docname: str, -) -> list[nodes.Node]: - """Build doctree nodes for the return type, with linked class/builtin names. - - Tokenises the ``return_display`` string and wraps every identifier in a - ``:class:`` cross-reference. ``env.resolve_references()`` then resolves - identifiers it knows (``str`` \u2192 Python docs via intersphinx, ``Server`` \u2192 - local API page) and leaves unknown ones as plain code literals. - - Parameters - ---------- - meta : FixtureMeta - Fixture metadata containing ``return_display``. - py_domain : PythonDomain - Python domain for object lookup. - app : Sphinx - Sphinx application. - docname : str - Current document name. - - Returns - ------- - list[nodes.Node] - Nodes for the return type cell with cross-referenced identifiers. - """ - display = meta.return_display - if not display: - return [nodes.Text("")] - - # Tokenise: identifiers (including dotted) vs punctuation/whitespace. - # Every identifier gets wrapped in :class:`~name` so intersphinx and - # the Python domain can resolve it. Punctuation passes through as text. - rst_parts: list[str] = [] - for token in _IDENTIFIER_PATTERN.split(display): - if not token: - continue - if _IDENTIFIER_PATTERN.fullmatch(token): - rst_parts.append(f":class:`~{token}`") - else: - rst_parts.append(token) - - rst_text = "".join(rst_parts) - return _parse_rst_inline(rst_text, app, docname) - - -def _resolve_fixture_index( - node: autofixture_index_node, - store: FixtureStoreDict, - py_domain: PythonDomain, - app: Sphinx, - docname: str, -) -> None: - """Replace a :class:`autofixture_index_node` with a docutils table. - - Builds a 4-column table (Fixture, Flags, Returns, Description). - Scope, kind, autouse, and deprecated appear as badges in the Flags column. - Fixture names and return types are cross-referenced; description text - has RST inline markup parsed and rendered. - - Parameters - ---------- - node : autofixture_index_node - The placeholder node to replace. - store : FixtureStoreDict - The finalized fixture store. - py_domain : PythonDomain - Python domain for cross-reference resolution. - app : Sphinx - The Sphinx application. - docname : str - Current document name. - """ - modname = node["module"] - exclude: set[str] = node.get("exclude", set()) - - fixtures = [ - meta - for canon, meta in sorted(store["fixtures"].items()) - if canon.startswith(f"{modname}.") and meta.public_name not in exclude - ] - - if not fixtures: - node.replace_self([]) - return - - table = nodes.table(classes=[_CSS.FIXTURE_INDEX]) - tgroup = nodes.tgroup(cols=len(_INDEX_TABLE_COLUMNS)) - table += tgroup - for _header, width in _INDEX_TABLE_COLUMNS: - tgroup += nodes.colspec(colwidth=width) - - thead = nodes.thead() - tgroup += thead - header_row = nodes.row() - thead += header_row - for header, _width in _INDEX_TABLE_COLUMNS: - entry = nodes.entry() - entry += nodes.paragraph("", header) - header_row += entry - - tbody = nodes.tbody() - tgroup += tbody - for meta in fixtures: - row = nodes.row() - tbody += row - - # --- Fixture name: cross-ref link --- - name_entry = nodes.entry() - obj_entry = py_domain.objects.get(meta.canonical_name) - if obj_entry is not None: - ref_node: nodes.Node = make_refnode( - app.builder, - docname, - obj_entry.docname, - obj_entry.node_id, - nodes.literal(meta.public_name, meta.public_name), - ) - else: - ref_node = nodes.literal(meta.public_name, meta.public_name) - name_para = nodes.paragraph() - name_para += ref_node - name_entry += name_para - row += name_entry - - # --- Flags: scope/kind/autouse/deprecated badges --- - flags_entry = nodes.entry() - flags_para = nodes.paragraph() - flags_para += _build_badge_group_node( - scope=meta.scope, - kind=meta.kind, - autouse=meta.autouse, - deprecated=bool(meta.deprecated), - show_fixture_badge=True, - ) - flags_entry += flags_para - row += flags_entry - - # --- Returns: linked type name --- - ret_entry = nodes.entry() - ret_para = nodes.paragraph() - for ret_node in _build_return_type_nodes(meta, py_domain, app, docname): - ret_para += ret_node - ret_entry += ret_para - row += ret_entry - - # --- Description: parsed RST inline markup --- - desc_entry = nodes.entry() - desc_para = nodes.paragraph() - if meta.summary: - for desc_node in _parse_rst_inline(meta.summary, app, docname): - desc_para += desc_node - desc_entry += desc_para - row += desc_entry - - scroll_wrapper = nodes.container(classes=[_CSS.TABLE_SCROLL]) - scroll_wrapper += table - node.replace_self([scroll_wrapper]) diff --git a/docs/_ext/sphinx_pytest_fixtures/_metadata.py b/docs/_ext/sphinx_pytest_fixtures/_metadata.py deleted file mode 100644 index 77eb357a2..000000000 --- a/docs/_ext/sphinx_pytest_fixtures/_metadata.py +++ /dev/null @@ -1,413 +0,0 @@ -"""Metadata extraction/registration and usage snippet helpers.""" - -from __future__ import annotations - -import ast -import inspect -import re -import typing as t - -from docutils import nodes -from sphinx import addnodes -from sphinx.util import logging as sphinx_logging - -from sphinx_pytest_fixtures._constants import ( - _CALLOUT_MESSAGES, - _KNOWN_KINDS, -) -from sphinx_pytest_fixtures._detection import ( - _classify_deps, - _format_type_short, - _get_fixture_fn, - _get_fixture_marker, - _get_return_annotation, - _infer_kind, -) -from sphinx_pytest_fixtures._models import FixtureDep, FixtureMeta -from sphinx_pytest_fixtures._store import _get_spf_store - -if t.TYPE_CHECKING: - pass - -logger = sphinx_logging.getLogger(__name__) - - -def _is_type_checking_guard(node: ast.If) -> bool: - """Return True if *node* is an ``if TYPE_CHECKING:`` guard.""" - test = node.test - # Handles: TYPE_CHECKING, typing.TYPE_CHECKING, t.TYPE_CHECKING, etc. - return (isinstance(test, ast.Name) and test.id == "TYPE_CHECKING") or ( - isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING" - ) - - -def _qualify_forward_ref(name: str, fn: t.Any) -> str | None: - """Try to resolve a bare forward-reference string to a qualified name. - - When a fixture's return type is behind ``if TYPE_CHECKING:``, the - annotation is a bare string like ``"Session"`` instead of the class. - This helper inspects the fixture module's source AST to find the - ``from X import Y`` that provides the name, even when inside a - ``TYPE_CHECKING`` guard. - - Parameters - ---------- - name : str - The bare class name (e.g. ``"Session"``). - fn : Any - The fixture's underlying function, used to find its module. - - Returns - ------- - str or None - The fully-qualified name (e.g. ``"libtmux.session.Session"``), - or ``None`` if resolution fails. - """ - import sys - - module = getattr(fn, "__module__", None) - if not module: - return None - mod = sys.modules.get(module) - if mod is None: - return None - - # Fast path: name is available at runtime (not behind TYPE_CHECKING). - obj = getattr(mod, name, None) - if obj is not None and hasattr(obj, "__module__") and hasattr(obj, "__qualname__"): - return f"{obj.__module__}.{obj.__qualname__}" - - # Slow path: parse the module source to find TYPE_CHECKING imports. - try: - source = inspect.getsource(mod) - except (OSError, TypeError): - return None - - try: - tree = ast.parse(source) - except SyntaxError: - return None - - # Restrict to TYPE_CHECKING blocks only so a runtime import of the same - # name from a different module does not steal the cross-reference. - for node in ast.walk(tree): - if isinstance(node, ast.If) and _is_type_checking_guard(node): - for child in ast.walk(node): - if isinstance(child, ast.ImportFrom) and child.module: - for alias in child.names: - imported_name = alias.asname if alias.asname else alias.name - if imported_name == name: - return f"{child.module}.{alias.name}" - - return None - - -def _extract_summary(obj: t.Any) -> str: - """Return the first sentence of the fixture docstring, preserving RST markup. - - Parameters - ---------- - obj : Any - A pytest fixture wrapper object. - - Returns - ------- - str - First sentence with RST markup intact (e.g. ``:class:`Server```), - or empty string if no docstring. - """ - fn = _get_fixture_fn(obj) - doc = inspect.getdoc(fn) or "" - first_para = doc.split("\n\n")[0].replace("\n", " ").strip() - match = re.match(r"^(.*?[.!?])(?:\s|$)", first_para) - return match.group(1) if match else first_para - - -_TEARDOWN_HEADINGS: frozenset[str] = frozenset({"teardown", "cleanup", "finalizer"}) - - -def _extract_teardown_summary(obj: t.Any) -> str | None: - """Return the first line of the Teardown section from the fixture docstring. - - Parameters - ---------- - obj : Any - A pytest fixture wrapper object. - - Returns - ------- - str or None - The first non-blank line(s) of the ``Teardown`` / ``Cleanup`` / - ``Finalizer`` section (NumPy-style heading), or ``None`` when absent. - """ - fn = _get_fixture_fn(obj) - doc = inspect.getdoc(fn) or "" - lines = doc.splitlines() - for i, line in enumerate(lines): - if ( - line.strip().lower() in _TEARDOWN_HEADINGS - and i + 1 < len(lines) - and set(lines[i + 1].strip()) <= {"-"} - ): - body: list[str] = [] - for j in range(i + 2, len(lines)): - stripped = lines[j].strip() - if stripped: - body.append(stripped) - elif body: - break - if body: - return " ".join(body) - return None - - -def _register_fixture_meta( - env: t.Any, - docname: str, - obj: t.Any, - public_name: str, - source_name: str, - modname: str, - kind: str, - app: t.Any, - *, - deprecated: str | None = None, - replacement: str | None = None, - teardown_summary: str | None = None, -) -> FixtureMeta: - """Build and register a FixtureMeta for *obj* in the env store. - - Parameters - ---------- - env : Any - The Sphinx build environment. - docname : str - The current document name. - obj : Any - The pytest fixture wrapper object. - public_name : str - The injection name (alias or function name). - source_name : str - The real module attribute name. - modname : str - The module name. - kind : str - Explicit kind override, or empty to auto-infer. - app : Any - The Sphinx application. - - Returns - ------- - FixtureMeta - The newly created and registered fixture metadata. - """ - canonical_name = f"{modname}.{public_name}" - marker = _get_fixture_marker(obj) - scope = marker.scope - autouse = marker.autouse - params_seq = marker.params or () - param_reprs = tuple(repr(p) for p in params_seq) - - fn = _get_fixture_fn(obj) - has_teardown = inspect.isgeneratorfunction(fn) or inspect.isasyncgenfunction(fn) - is_async = inspect.iscoroutinefunction(fn) or inspect.isasyncgenfunction(fn) - - ret_ann = _get_return_annotation(obj) - return_display = ( - _format_type_short(ret_ann) if ret_ann is not inspect.Parameter.empty else "" - ) - # Simple class name for xref: only for bare names without special chars. - # When the annotation is a forward-reference string (from TYPE_CHECKING), - # try to qualify it via the module's TYPE_CHECKING imports so Sphinx can - # resolve cross-references (e.g. "Session" → "libtmux.session.Session"). - return_xref_target: str | None = None - if return_display and return_display.isidentifier(): - return_xref_target = return_display - if isinstance(ret_ann, str): - qualified = _qualify_forward_ref(return_display, fn) - if qualified: - return_xref_target = qualified - return_display = qualified - - inferred_kind = _infer_kind(obj, kind or None) - if inferred_kind not in _KNOWN_KINDS: - logger.warning( - "unknown fixture kind %r for %r; expected one of %r", - inferred_kind, - canonical_name, - sorted(_KNOWN_KINDS), - ) - - # Build classified deps. - project_deps, builtin_deps, _hidden = _classify_deps(obj, app) - dep_list: list[FixtureDep] = [] - for dep_name in project_deps: - dep_store = _get_spf_store(env) - target_canon = dep_store["public_to_canon"].get(dep_name) - dep_list.append( - FixtureDep( - display_name=dep_name, - kind="fixture", - target=target_canon, - ) - ) - for dep_name, url in builtin_deps.items(): - dep_list.append(FixtureDep(display_name=dep_name, kind="builtin", url=url)) - - meta = FixtureMeta( - docname=docname, - canonical_name=canonical_name, - public_name=public_name, - source_name=source_name, - scope=scope, - autouse=autouse, - kind=inferred_kind, - return_display=return_display, - return_xref_target=return_xref_target, - deps=tuple(dep_list), - param_reprs=param_reprs, - has_teardown=has_teardown, - is_async=is_async, - summary=_extract_summary(obj), - deprecated=deprecated, - replacement=replacement, - teardown_summary=teardown_summary, - ) - - store = _get_spf_store(env) - store["fixtures"][canonical_name] = meta - - # Update public_to_canon mapping (fast-path; _finalize_store is backstop). - if public_name not in store["public_to_canon"]: - store["public_to_canon"][public_name] = canonical_name - elif store["public_to_canon"][public_name] != canonical_name: - store["public_to_canon"][public_name] = None # ambiguous - - # Build reverse_deps for each project dep. - for dep in dep_list: - if dep.kind == "fixture" and dep.target: - store["reverse_deps"].setdefault(dep.target, []) - if canonical_name not in store["reverse_deps"][dep.target]: - store["reverse_deps"][dep.target].append(canonical_name) - - return meta - - -# --------------------------------------------------------------------------- -# Usage snippet and layout helpers -# --------------------------------------------------------------------------- - - -def _has_authored_example(content_node: nodes.Element) -> bool: - """Return True if *content_node* already contains authored examples. - - Only inspects direct children — does not recurse into nested containers. - This keeps the detection narrow and predictable: a ``rubric`` titled - "Example" buried inside an unrelated admonition will not suppress the - auto-generated usage snippet. - """ - for child in content_node.children: - if isinstance(child, nodes.doctest_block): - return True - if isinstance(child, nodes.rubric) and child.astext() in { - "Example", - "Examples", - }: - return True - return False - - -def _build_usage_snippet( - fixture_name: str, - ret_type: str | None, - kind: str, - scope: str, - autouse: bool, -) -> nodes.Node | None: - """Return a doctree node for the kind-appropriate usage example. - - Parameters - ---------- - fixture_name : str - The fixture's injection name. - ret_type : str | None - The fixture's return type string, or empty/None when absent. - kind : str - One of ``"resource"``, ``"factory"``, or ``"override_hook"``. - scope : str - The fixture scope (used in the conftest decorator for override hooks). - autouse : bool - When True, returns a note admonition instead of a test snippet. - - Returns - ------- - nodes.Node | None - A ``literal_block`` or ``note`` node, or ``None`` for autouse fixtures. - - Notes - ----- - * ``resource`` → ``None`` (trivially obvious to pytest users) - * ``factory`` → ``def test_example(Name) -> None: obj = Name(); ...`` - * ``override_hook`` → ``conftest.py`` snippet with ``@pytest.fixture`` override - * ``autouse`` → ``nodes.note`` (no test snippet needed) - """ - if autouse: - note = nodes.note() - note += nodes.paragraph( - "", - _CALLOUT_MESSAGES["autouse"], - ) - return note - - if kind == "override_hook": - scope_decorator = ( - f'@pytest.fixture(scope="{scope}")\n' - if scope != "function" - else "@pytest.fixture\n" - ) - ret_ann = f" -> {ret_type}" if ret_type else "" - code = ( - "# conftest.py\n" - "import pytest\n\n\n" - f"{scope_decorator}" - f"def {fixture_name}(){ret_ann}:\n" - " return ... # your value here\n" - ) - elif kind == "factory": - type_ann = f": {ret_type}" if ret_type else "" - code = ( - f"def test_example({fixture_name}{type_ann}) -> None:\n" - f" obj = {fixture_name}()\n" - " assert obj is not None\n" - ) - else: - # Resource fixtures — generic snippet like - # ``def test_example(server: Server): ...`` is trivially obvious - # to any pytest user and adds nothing beyond the signature. - return None - - return nodes.literal_block(code, code, language="python") - - -def _summary_insert_index(content_node: addnodes.desc_content) -> int: - """Return insertion index just after the first paragraph in content_node. - - The first paragraph is the docstring summary sentence. Metadata and - snippets should follow it (five-zone layout: sig → summary → metadata - → usage → body). - - Parameters - ---------- - content_node : addnodes.desc_content - The directive's content node. - - Returns - ------- - int - Index of the node slot immediately after the first paragraph child, - or ``0`` when no paragraph is found. - """ - for i, child in enumerate(content_node.children): - if isinstance(child, nodes.paragraph): - return i + 1 - return 0 diff --git a/docs/_ext/sphinx_pytest_fixtures/_models.py b/docs/_ext/sphinx_pytest_fixtures/_models.py deleted file mode 100644 index cf4b4187e..000000000 --- a/docs/_ext/sphinx_pytest_fixtures/_models.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Data models, dataclasses, protocols, and node types for sphinx_pytest_fixtures.""" - -from __future__ import annotations - -import typing as t -from dataclasses import dataclass - -from docutils import nodes - -from sphinx_pytest_fixtures._constants import _DEFAULTS - - -@dataclass(frozen=True) -class FixtureDep: - """A classified fixture dependency. - - All fields are primitive types so that ``FixtureDep`` can be pickled - safely when stored in the Sphinx build environment. - """ - - display_name: str - """Short display name, e.g. ``"config_file"``.""" - - kind: t.Literal["fixture", "builtin", "external", "unresolved"] - """Classification of the dependency.""" - - target: str | None = None - """Canonical name for project fixtures (used for reverse deps).""" - - url: str | None = None - """External URL for builtin/external deps.""" - - -@dataclass(frozen=True) -class FixtureMeta: - """Env-safe fixture metadata stored per fixture in the build environment. - - All fields must be pickle-safe primitives — never store raw annotation - objects (``type``, generics) here as they are not reliably picklable. - - Stored at ``env.domaindata["sphinx_pytest_fixtures"]["fixtures"][canonical_name]``. - """ - - docname: str - """Sphinx docname of the page where this fixture is documented.""" - - canonical_name: str - """Fully-qualified name, e.g. ``"libtmux.pytest_plugin.server"``.""" - - public_name: str - """Pytest injection name, e.g. ``"server"`` (alias or function name).""" - - source_name: str - """Real module attribute name, e.g. ``"_server"``.""" - - scope: str - """Fixture scope: ``"function"``, ``"session"``, ``"module"``, or ``"class"``.""" - - autouse: bool - """Whether the fixture runs automatically for every test.""" - - kind: str - """Fixture kind: one of :data:`FixtureKind` values, or a custom string.""" - - return_display: str - """Short type label, e.g. ``"Server"``.""" - - return_xref_target: str | None - """Simple class name for cross-referencing, or ``None`` for complex types.""" - - deps: tuple[FixtureDep, ...] - """Classified fixture dependencies.""" - - param_reprs: tuple[str, ...] - """``repr()`` of each parametrize value from the fixture marker.""" - - has_teardown: bool - """True when the fixture is a generator (yield-based) fixture.""" - - is_async: bool - """True when the fixture is an async function or async generator.""" - - summary: str - """First sentence of the fixture docstring (raw RST markup preserved).""" - - deprecated: str | None = None - """Version string when the fixture is deprecated, or ``None``.""" - - replacement: str | None = None - """Canonical name of the replacement fixture, or ``None``.""" - - teardown_summary: str | None = None - """Short description of teardown/cleanup behavior, or ``None``.""" - - -class autofixture_index_node(nodes.General, nodes.Element): - """Placeholder replaced during ``doctree-resolved`` with a fixture index table.""" - - -# --------------------------------------------------------------------------- -# Protocol for fixture marker (structural type for mypy safety) -# --------------------------------------------------------------------------- - - -class _FixtureMarker(t.Protocol): - """Normalised fixture metadata — scope is ALWAYS a plain str.""" - - @property - def scope(self) -> str: ... # never None, never Scope enum - - @property - def autouse(self) -> bool: ... - - @property - def params(self) -> t.Sequence[t.Any] | None: ... - - @property - def name(self) -> str | None: ... - - -class _FixtureFunctionDefinitionAdapter: - """Adapter: normalises pytest 9+ FixtureFunctionDefinition to _FixtureMarker. - - pytest 9+: .scope is a _pytest.scope.Scope enum — .value is the lowercase str. - pytest <9: .scope may be str or None (None means function-scope). - """ - - __slots__ = ("_obj",) - - def __init__(self, obj: t.Any) -> None: - self._obj = obj - - @property - def scope(self) -> str: - raw = self._obj.scope - if hasattr(raw, "value"): # pytest 9+: _pytest.scope.Scope enum - return str(raw.value) - return str(raw) if raw else _DEFAULTS["scope"] - - @property - def autouse(self) -> bool: - return bool(self._obj.autouse) - - @property - def params(self) -> t.Sequence[t.Any] | None: - return self._obj.params # type: ignore[no-any-return] - - @property - def name(self) -> str | None: - return self._obj.name # type: ignore[no-any-return] diff --git a/docs/_ext/sphinx_pytest_fixtures/_static/css/sphinx_pytest_fixtures.css b/docs/_ext/sphinx_pytest_fixtures/_static/css/sphinx_pytest_fixtures.css deleted file mode 100644 index 959f90866..000000000 --- a/docs/_ext/sphinx_pytest_fixtures/_static/css/sphinx_pytest_fixtures.css +++ /dev/null @@ -1,394 +0,0 @@ -/* ── sphinx_pytest_fixtures ──────────────────────────────── - * Multi-badge group: scope + kind/state + FIXTURE - * Three slots, hard ceiling: at most 3 badges per fixture. - * - * Slot 1 (scope): session=amber, module=teal, class=slate - * function → suppressed (absence = function-scope) - * Slot 2 (kind): factory=amber/brown, override_hook=violet - * resource → suppressed (default, no badge needed) - * OR state: autouse=rose (replaces kind when autouse=True) - * Slot 3 (base): FIXTURE — always shown, green - * - * Uses nodes.inline (portable across all Sphinx builders). - * Class-based selectors replace data-* attribute selectors. - * Flex layout replaces float with mobile-responsive wrapping. - * ────────────────────────────────────────────────────────── */ - -/* Token system */ -:root { - /* Base FIXTURE badge — outlined green. 7.2:1 fg-on-bg (WCAG AA+AAA) */ - --spf-fixture-bg: #e6f7ed; - --spf-fixture-fg: #1a5c2e; - --spf-fixture-border: #3aad65; - - /* Scope: session — amber/gold. 6.2:1 fg-on-bg (WCAG AA) */ - --spf-scope-session-bg: #fff3cd; - --spf-scope-session-fg: #7a5200; - --spf-scope-session-border: #d4a017; - - /* Scope: module — teal. 6.7:1 fg-on-bg (WCAG AA) */ - --spf-scope-module-bg: #e0f4f4; - --spf-scope-module-fg: #1a5c5c; - --spf-scope-module-border: #3aabab; - - /* Scope: class — slate. 9.3:1 fg-on-bg (WCAG AA+AAA) */ - --spf-scope-class-bg: #eeedf6; - --spf-scope-class-fg: #3c3670; - --spf-scope-class-border: #7b76c0; - - /* Kind: factory — amber/brown (outlined). 8.1:1 fg-on-white (WCAG AA+AAA) */ - --spf-kind-factory-bg: transparent; - --spf-kind-factory-fg: #7a4200; - --spf-kind-factory-border: #c87f35; - - /* Kind: override_hook — violet (outlined). 11.3:1 fg-on-white (WCAG AA+AAA) */ - --spf-kind-override-bg: transparent; - --spf-kind-override-fg: #5a1a7a; - --spf-kind-override-border: #9b59c8; - - /* State: autouse — rose (outlined). 10.5:1 fg-on-white (WCAG AA+AAA) */ - --spf-state-autouse-bg: transparent; - --spf-state-autouse-fg: #7a1a2a; - --spf-state-autouse-border: #c85070; - - /* State: deprecated — muted red/grey (outlined). 8.5:1 fg-on-white */ - --spf-deprecated-bg: transparent; - --spf-deprecated-fg: #8a4040; - --spf-deprecated-border: #c07070; - - /* Shared badge metrics */ - --spf-badge-font-size: 0.67rem; - --spf-badge-padding-v: 0.16rem; - --spf-badge-border-w: 1px; -} - -/* Dark mode — OS-level */ -@media (prefers-color-scheme: dark) { - body:not([data-theme="light"]) { - --spf-fixture-bg: #0d2e1a; - --spf-fixture-fg: #70dd90; - --spf-fixture-border: #309050; - - --spf-scope-session-bg: #3a2800; - --spf-scope-session-fg: #f5d580; - --spf-scope-session-border: #c89030; - - --spf-scope-module-bg: #0d2a2a; - --spf-scope-module-fg: #70dddd; - --spf-scope-module-border: #309090; - - --spf-scope-class-bg: #1a1838; - --spf-scope-class-fg: #b0acee; - --spf-scope-class-border: #6060b0; - - --spf-kind-factory-fg: #f0b060; - --spf-kind-factory-border: #d08040; - - --spf-kind-override-fg: #d090f0; - --spf-kind-override-border: #a060d0; - - --spf-state-autouse-fg: #f080a0; - --spf-state-autouse-border: #d05070; - - --spf-deprecated-fg: #e08080; - --spf-deprecated-border: #c06060; - } -} - -/* Furo explicit dark toggle — must match OS-dark block above */ -body[data-theme="dark"] { - --spf-fixture-bg: #0d2e1a; /* 8.7:1 with fg #70dd90 */ - --spf-fixture-fg: #70dd90; - --spf-fixture-border: #309050; - - --spf-scope-session-bg: #3a2800; - --spf-scope-session-fg: #f5d580; - --spf-scope-session-border: #c89030; - - --spf-scope-module-bg: #0d2a2a; - --spf-scope-module-fg: #70dddd; - --spf-scope-module-border: #309090; - - --spf-scope-class-bg: #1a1838; - --spf-scope-class-fg: #b0acee; - --spf-scope-class-border: #6060b0; - - --spf-kind-factory-fg: #f0b060; - --spf-kind-factory-border: #d08040; - - --spf-kind-override-fg: #d090f0; - --spf-kind-override-border: #a060d0; - - --spf-state-autouse-fg: #f080a0; - --spf-state-autouse-border: #d05070; - - --spf-deprecated-fg: #e08080; - --spf-deprecated-border: #c06060; -} - -/* "fixture" keyword prefix — keep Furo's default keyword colour */ -dl.py.fixture > dt em.property { - color: var(--color-api-keyword); - font-style: normal; -} - -/* Badge group container — flex layout for reliable multi-badge alignment. - * Flexbox replaces the float: right approach; the dt becomes a flex row - * (signature text + badge group side by side). - * On narrow viewports the badge group wraps below the signature. */ -dl.py.fixture > dt { - display: flex; - align-items: center; - gap: 0.35rem; - flex-wrap: wrap; - background: var(--color-background-secondary); - border-bottom: 1px solid var(--color-background-border); - padding: 0.5rem 0.75rem; -} - -/* Visual reorder: sig elements (0) → ¶ (1) → badges (2) → [source] (3). - * The ¶ headerlink is injected by Sphinx's HTML translator AFTER our - * doctree code runs, so CSS order is the only way to position it. */ -dl.py.fixture > dt > .headerlink { order: 1; } -dl.py.fixture > dt > .spf-badge-group { order: 2; } -dl.py.fixture > dt > a.reference.external { order: 3; } - -dl.py.fixture > dt .spf-badge-group { - display: inline-flex; - align-items: center; - gap: 0.3rem; - flex-shrink: 0; - margin-left: auto; - white-space: nowrap; - text-indent: 0; -} - -/* Mobile: badge group wraps below signature text, shares row with actions */ -@media (max-width: 52rem) { - dl.py.fixture > dt .spf-badge-group { - margin-left: 0; - white-space: normal; - flex-wrap: wrap; - } - dl.py.fixture > dt .spf-badge { - --spf-badge-font-size: 0.75rem; - } -} - -/* Shared badge base — component-scoped. - * Applies wherever .spf-badge appears: fixture cards, index tables, or any - * future context. Context-specific layout rules (flex container, tooltip - * positioning anchor, margin-left: auto in dt) remain on their containers. */ -.spf-badge { - position: relative; /* positioning context for the CSS-only tooltip */ - display: inline-block; - font-size: var(--spf-badge-font-size, 0.67rem); - font-weight: 650; - line-height: normal; - letter-spacing: 0.01em; - padding: var(--spf-badge-padding-v, 0.16rem) 0.5rem; - border-radius: 0.22rem; - border: var(--spf-badge-border-w, 1px) solid; - vertical-align: middle; -} - -/* Touch/keyboard tooltip — shows on focus (touch tap focuses the element). - * Sphinx's tooltips are invisible on touch devices; this CSS-only - * solution renders the title text as a positioned pseudo-element on :focus. - * Works in both fixture cards and index table cells since .spf-badge carries - * position: relative as the tooltip's containing block. */ -.spf-badge[tabindex]:focus::after { - content: attr(title); - position: absolute; - bottom: calc(100% + 4px); - left: 50%; - transform: translateX(-50%); - background: var(--color-background-primary); - border: 1px solid var(--color-background-border); - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - font-weight: 400; - white-space: nowrap; - border-radius: 0.2rem; - z-index: 10; - pointer-events: none; -} - -/* Restore visible focus outline for keyboard users. The tooltip still appears - * on any :focus, but the ring only appears for :focus-visible (keyboard nav). */ -.spf-badge[tabindex]:focus-visible { - outline: 2px solid var(--color-link); - outline-offset: 2px; -} - -/* FIXTURE badge (always shown, outlined green — same visual treatment - * as scope badges: light bg, dark text, colored border) */ -.spf-badge--fixture { - background-color: var(--spf-fixture-bg); - color: var(--spf-fixture-fg); - border-color: var(--spf-fixture-border); -} - -/* Scope badges — component-scoped (replaces data-* attribute selectors) */ -.spf-scope-session { - background-color: var(--spf-scope-session-bg); - color: var(--spf-scope-session-fg); - border-color: var(--spf-scope-session-border); - letter-spacing: 0.03em; -} -.spf-scope-module { - background-color: var(--spf-scope-module-bg); - color: var(--spf-scope-module-fg); - border-color: var(--spf-scope-module-border); -} -.spf-scope-class { - background-color: var(--spf-scope-class-bg); - color: var(--spf-scope-class-fg); - border-color: var(--spf-scope-class-border); -} - -/* Kind badges (outlined — behaviour, not lifecycle) */ -.spf-factory { - background-color: var(--spf-kind-factory-bg); - color: var(--spf-kind-factory-fg); - border-color: var(--spf-kind-factory-border); -} -.spf-override { - background-color: var(--spf-kind-override-bg); - color: var(--spf-kind-override-fg); - border-color: var(--spf-kind-override-border); -} - -/* State badge (autouse) */ -.spf-autouse { - background-color: var(--spf-state-autouse-bg); - color: var(--spf-state-autouse-fg); - border-color: var(--spf-state-autouse-border); -} - -/* Deprecated badge — component-scoped; card muting stays dt-scoped */ -.spf-deprecated { - background-color: var(--spf-deprecated-bg); - color: var(--spf-deprecated-fg); - border-color: var(--spf-deprecated-border); -} - -/* ── abbr[title] specificity fix ─────────────────────────────────────── - * Normalize.css (bundled with Furo) sets on abbr[title]: - * border-bottom: none — removes bottom border entirely - * text-decoration: underline dotted — adds unwanted dotted underline - * Specificity of abbr[title] is (0,1,1) which beats .spf-badge (0,1,0), - * so the bottom border is trimmed and the underline bleeds through. - * - * Fix: use abbr.spf-badge (0,1,1) to win on source order. - * The border-color reset by abbr[title] also needs per-variant overrides - * at the same specificity, otherwise the bottom border colour falls back - * to currentColor (the text fg colour) instead of the border variable. - * ─────────────────────────────────────────────────────────────────────── */ -abbr.spf-badge { - border-bottom-style: solid; - border-bottom-width: var(--spf-badge-border-w, 1px); - text-decoration: none; -} -abbr.spf-badge--fixture { border-color: var(--spf-fixture-border); } -abbr.spf-scope-session { border-color: var(--spf-scope-session-border); } -abbr.spf-scope-module { border-color: var(--spf-scope-module-border); } -abbr.spf-scope-class { border-color: var(--spf-scope-class-border); } -abbr.spf-factory { border-color: var(--spf-kind-factory-border); } -abbr.spf-override { border-color: var(--spf-kind-override-border); } -abbr.spf-autouse { border-color: var(--spf-state-autouse-border); } -abbr.spf-deprecated { border-color: var(--spf-deprecated-border); } - -dl.py.fixture.spf-deprecated > dt { - opacity: 0.7; -} - -/* Badge group inside fixture index table cell — keep badges inline. - * The card context has its own dl.py.fixture > dt .spf-badge-group rule - * (with margin-left: auto and flex layout tied to the dt flex row). - * Table cells need a simpler inline-flex without the card-specific overrides. */ -.spf-fixture-index .spf-badge-group { - display: inline-flex; - gap: 0.3rem; -} - -/* Suppress module prefix (libtmux.pytest_plugin.) */ -dl.py.fixture > dt .sig-prename.descclassname { - display: none; -} - -/* ── Fixture card treatment ──────────────────────────────── */ -dl.py.fixture { - border: 1px solid var(--color-background-border); - border-radius: 0.5rem; - padding: 0; - margin-bottom: 1.5rem; - overflow: visible; - box-shadow: 0 1px 3px rgba(0,0,0,0.04); -} - -/* Reset Furo's hanging-indent and negative-margin on dt so content - * stays within the card border. Furo sets text-indent: -35px and - * margin: 0 -4px for wrapped signatures — both break our flex card. - * !important on padding overrides Furo's .sig:not(.sig-inline) rule which - * sets padding-top/bottom: 0.25rem and wins on specificity without it. */ -dl.py.fixture > dt { - text-indent: 0; - margin: 0; - padding-left: 1rem; - padding-top: 0.5rem !important; - padding-bottom: 0.5rem !important; - min-height: 2rem; -} - -dl.py.fixture > dd { - padding: 0.75rem 1rem; - margin-left: 0 !important; /* override Furo's dd { margin-left: 32px } */ -} - -/* Metadata fields: compact grid that keeps dt/dd pairs together */ -dl.py.fixture > dd > dl.field-list { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - gap: 0.25rem 1rem; - border-top: 1px solid var(--color-background-border); - padding-top: 0.5rem; - margin-top: 0.5rem; -} -dl.py.fixture > dd > dl.field-list > dt { - grid-column: 1; - font-weight: normal; - text-transform: uppercase; - font-size: 0.85em; - letter-spacing: 0.025em; -} -dl.py.fixture > dd > dl.field-list > dt .colon { display: none; } -dl.py.fixture > dd > dl.field-list > dd { grid-column: 2; margin-left: 0; } - -/* Mobile: metadata fields stack to single column */ -@media (max-width: 52rem) { - dl.py.fixture > dd > dl.field-list { - grid-template-columns: 1fr; - } - dl.py.fixture > dd > dl.field-list > dt, - dl.py.fixture > dd > dl.field-list > dd { - grid-column: 1; - } -} - -/* Suppress Rtype field-list on fixtures — return type is already in the - * signature (→ Type). sphinx_autodoc_typehints emits a separate field-list - * with only "Rtype:" when autodoc_typehints = "description". Hide it. */ -dl.py.fixture > dd > dl.field-list + dl.field-list { - display: none; -} - -/* Horizontal scroll wrapper for the fixture index table on narrow viewports */ -.spf-table-scroll { - overflow-x: auto; - -webkit-overflow-scrolling: touch; -} -.spf-table-scroll table { - min-width: 40rem; - width: 100%; -} diff --git a/docs/_ext/sphinx_pytest_fixtures/_store.py b/docs/_ext/sphinx_pytest_fixtures/_store.py deleted file mode 100644 index 873d622c5..000000000 --- a/docs/_ext/sphinx_pytest_fixtures/_store.py +++ /dev/null @@ -1,256 +0,0 @@ -from __future__ import annotations - -import dataclasses -import typing as t - -from sphinx.util import logging as sphinx_logging - -from sphinx_pytest_fixtures._constants import ( - _EXTENSION_KEY, - _INTERSPHINX_FIXTURE_ROLE, - _INTERSPHINX_PROJECT, - _STORE_VERSION, - PYTEST_BUILTIN_LINKS, -) -from sphinx_pytest_fixtures._models import FixtureDep, FixtureMeta - -if t.TYPE_CHECKING: - from sphinx.application import Sphinx - -logger = sphinx_logging.getLogger(__name__) - - -def _resolve_builtin_url(name: str, app: t.Any) -> str | None: - """Resolve a pytest builtin fixture URL from intersphinx inventory. - - Falls back to the hardcoded ``PYTEST_BUILTIN_LINKS`` dict when - the intersphinx inventory is unavailable (offline builds, missing - extension, or inventory not yet loaded). - - Parameters - ---------- - name : str - The fixture name to look up (e.g. ``"tmp_path_factory"``). - app : Any - The Sphinx application instance (or None). - - Returns - ------- - str or None - The resolved URL, or None if the fixture is not a known builtin. - """ - try: - inv = getattr(getattr(app, "env", None), "intersphinx_named_inventory", {}) - fixture_inv = inv.get(_INTERSPHINX_PROJECT, {}).get( - _INTERSPHINX_FIXTURE_ROLE, {} - ) - if name in fixture_inv: - _proj, _ver, uri, _dispname = fixture_inv[name] - return str(uri) - except Exception: - pass - result: str | None = PYTEST_BUILTIN_LINKS.get(name) - return result - - -class FixtureStoreDict(t.TypedDict): - """Typed shape of the extension-owned env domaindata namespace.""" - - fixtures: dict[str, FixtureMeta] - public_to_canon: dict[str, str | None] - reverse_deps: dict[str, list[str]] - _store_version: int - - -def _make_empty_store() -> FixtureStoreDict: - """Return a fresh, empty store dict.""" - return FixtureStoreDict( - fixtures={}, - public_to_canon={}, - reverse_deps={}, - _store_version=_STORE_VERSION, - ) - - -def _get_spf_store(env: t.Any) -> FixtureStoreDict: - """Return the extension-owned env domaindata namespace. - - Creates the namespace with empty collections on first access. - - Parameters - ---------- - env : Any - The Sphinx build environment. - - Returns - ------- - FixtureStoreDict - The mutable store dict. - """ - store: FixtureStoreDict = env.domaindata.setdefault( - _EXTENSION_KEY, - _make_empty_store(), - ) - if store.get("_store_version") != _STORE_VERSION: - # Stale pickle — mutate in-place to preserve existing references. - t.cast(dict[str, t.Any], store).clear() - t.cast(dict[str, t.Any], store).update(_make_empty_store()) - return store - - -# --------------------------------------------------------------------------- -# Store finalization — one-shot index rebuild after all docs are read -# --------------------------------------------------------------------------- - - -def _rebuild_public_to_canon(store: FixtureStoreDict) -> None: - """Rebuild ``public_to_canon`` from the ``fixtures`` registry. - - Marks public names that map to multiple canonical names as ``None`` - (ambiguous). - """ - pub_map: dict[str, str | None] = {} - for canon, meta in store["fixtures"].items(): - pub = meta.public_name - if pub in pub_map and pub_map[pub] != canon: - pub_map[pub] = None # ambiguous - else: - pub_map[pub] = canon - store["public_to_canon"] = pub_map - - -def _rebind_dep_targets(store: FixtureStoreDict) -> None: - """Rebind ALL ``FixtureDep.target`` values from the current ``public_to_canon``. - - Handles forward references (``None`` → resolved), stale references - (old canonical → updated/``None``), and purged providers (resolved → - ``None``). Uses ``dataclasses.replace`` on the frozen dataclasses. - """ - p2c = store["public_to_canon"] - updated: dict[str, FixtureMeta] = {} - for canon, meta in store["fixtures"].items(): - new_deps: list[FixtureDep] = [] - changed = False - for dep in meta.deps: - if dep.kind == "fixture": - correct_target = p2c.get(dep.display_name) - if correct_target != dep.target: - new_deps.append(dataclasses.replace(dep, target=correct_target)) - changed = True - continue - new_deps.append(dep) - if changed: - updated[canon] = dataclasses.replace(meta, deps=tuple(new_deps)) - store["fixtures"].update(updated) - - -def _rebuild_reverse_deps(store: FixtureStoreDict) -> None: - """Rebuild ``reverse_deps`` from the finalized ``fixtures`` registry. - - Skips self-edges (a fixture depending on itself). - """ - rev: dict[str, list[str]] = {} - for canon, meta in store["fixtures"].items(): - for dep in meta.deps: - if dep.kind == "fixture" and dep.target and dep.target != canon: - rev.setdefault(dep.target, []) - if canon not in rev[dep.target]: - rev[dep.target].append(canon) - store["reverse_deps"] = {k: sorted(v) for k, v in rev.items()} - - -def _finalize_store(store: FixtureStoreDict) -> None: - """One-shot finalization: rebuild all derived indices from ``fixtures``. - - Called via the ``env-updated`` event, which fires once after all - parallel merges are complete and before ``doctree-resolved``. - """ - _rebuild_public_to_canon(store) - _rebind_dep_targets(store) - _rebuild_reverse_deps(store) - - -def _on_env_updated(app: Sphinx, env: t.Any) -> None: - """Finalize the fixture store after all documents are read and merged. - - Parameters - ---------- - app : Sphinx - The Sphinx application. - env : Any - The Sphinx build environment. - """ - store = _get_spf_store(env) - _finalize_store(store) - - from sphinx_pytest_fixtures._validation import _validate_store - - _validate_store(store, app) - - -# --------------------------------------------------------------------------- -# Incremental / parallel build env hooks -# --------------------------------------------------------------------------- - - -def _on_env_purge_doc(app: Sphinx, env: t.Any, docname: str) -> None: - """Remove fixture records for a doc being re-processed. - - Index rebuilds are deferred to :func:`_finalize_store` via ``env-updated``. - - Parameters - ---------- - app : Sphinx - The Sphinx application (unused; required by the hook signature). - env : Any - The Sphinx build environment. - docname : str - The document being purged. - """ - store = _get_spf_store(env) - to_remove = [k for k, v in store["fixtures"].items() if v.docname == docname] - for k in to_remove: - del store["fixtures"][k] - - -def _on_env_merge_info( - app: Sphinx, - env: t.Any, - docnames: list[str], - other: t.Any, -) -> None: - """Merge fixture metadata from parallel-build sub-environments. - - Index rebuilds are deferred to :func:`_finalize_store` via ``env-updated``. - - Parameters - ---------- - app : Sphinx - The Sphinx application (unused; required by the hook signature). - env : Any - The primary (receiving) build environment. - docnames : list[str] - Docnames processed by the sub-environment (unused). - other : Any - The sub-environment whose store is merged into *env*. - """ - store = _get_spf_store(env) - other_store = _get_spf_store(other) - # Explicit loop so we can emit SPF009 when the same fixture canonical name - # appears in both environments with different docnames (parallel build - # collision). Last writer wins — this is warning-only and does not - # participate in lint_level=error because parallel builds are rare and - # treating collisions as errors would break incremental builds. - for canon, meta in other_store["fixtures"].items(): - if ( - canon in store["fixtures"] - and store["fixtures"][canon].docname != meta.docname - ): - logger.warning( - "fixture %r documented from multiple pages (%r and %r); using last", - canon, - store["fixtures"][canon].docname, - meta.docname, - extra={"spf_code": "SPF009"}, - ) - store["fixtures"][canon] = meta diff --git a/docs/_ext/sphinx_pytest_fixtures/_transforms.py b/docs/_ext/sphinx_pytest_fixtures/_transforms.py deleted file mode 100644 index 40f4148a7..000000000 --- a/docs/_ext/sphinx_pytest_fixtures/_transforms.py +++ /dev/null @@ -1,325 +0,0 @@ -"""Doctree-resolved transforms, missing-reference handler, and HTML visitors.""" - -from __future__ import annotations - -import typing as t - -from docutils import nodes -from sphinx import addnodes -from sphinx.domains.python import PythonDomain -from sphinx.util import logging as sphinx_logging -from sphinx.util.nodes import make_refnode -from sphinx.writers.html5 import HTML5Translator - -from sphinx_pytest_fixtures._badges import _build_badge_group_node -from sphinx_pytest_fixtures._constants import _FIELD_LABELS -from sphinx_pytest_fixtures._index import _resolve_fixture_index -from sphinx_pytest_fixtures._models import autofixture_index_node -from sphinx_pytest_fixtures._store import FixtureStoreDict, _get_spf_store - -if t.TYPE_CHECKING: - from sphinx.application import Sphinx - -logger = sphinx_logging.getLogger(__name__) - - -def _on_missing_reference( - app: t.Any, - env: t.Any, - node: t.Any, - contnode: t.Any, -) -> t.Any | None: - r"""Resolve ``:func:\`name\``` cross-references to ``py:fixture`` entries. - - Parameters - ---------- - app : Any - The Sphinx application. - env : Any - The Sphinx build environment. - node : Any - The pending cross-reference node. - contnode : Any - The content node to wrap. - - Returns - ------- - Any or None - A resolved reference node, or ``None`` to let Sphinx continue. - - Notes - ----- - Handles MyST ``{func}\\`name\\``` references in ``usage.md`` that predate - the ``py:fixture`` registration. The ``ObjType`` fallback roles cover most - cases; this handler covers the ``any`` and implicit-domain paths. - """ - if node.get("refdomain") != "py": - return None - - reftype = node.get("reftype") - target = node.get("reftarget", "") - py_domain: PythonDomain = env.get_domain("py") - - # Short-name :fixture: lookup via public_to_canon. - if reftype == "fixture": - store = _get_spf_store(env) - canon = store["public_to_canon"].get(target) - if canon: # None means ambiguous — let Sphinx emit standard warning - return py_domain.resolve_xref( - env, - node.get("refdoc", ""), - app.builder, - "fixture", - canon, - node, - contnode, - ) - return None - - # Existing func/obj/any fallback for legacy :func: references. - if reftype not in ("func", "obj", "any"): - return None - - matches = py_domain.find_obj( - env, - node.get("py:module", ""), - node.get("py:class", ""), - target, - "fixture", - 1, - ) - if not matches: - return None - - match_name, _obj_entry = matches[0] - return py_domain.resolve_xref( - env, - node.get("refdoc", ""), - app.builder, - "fixture", - match_name, - node, - contnode, - ) - - -def _inject_badges_and_reorder(sig_node: addnodes.desc_signature) -> None: - """Inject scope/kind/fixture badges and reorder signature children. - - Appends a badge group to *sig_node* and reorders the \u00b6 headerlink and - [source] viewcode link so the visual layout is: - ``name \u2192 return \u2192 \u00b6 \u2192 badges (right-aligned) \u2192 [source]``. - - Guarded by the ``spf_badges_injected`` flag \u2014 safe to call multiple times. - """ - if sig_node.get("spf_badges_injected"): - return - sig_node["spf_badges_injected"] = True - - scope = sig_node.get("spf_scope", "function") - kind = sig_node.get("spf_kind", "resource") - autouse = sig_node.get("spf_autouse", False) - deprecated = sig_node.get("spf_deprecated", False) - - badge_group = _build_badge_group_node(scope, kind, autouse, deprecated=deprecated) - - # Detach [source] and \u00b6 links, then re-append in desired order. - viewcode_ref = None - headerlink_ref = None - for child in list(sig_node.children): - if isinstance(child, nodes.reference): - if child.get("internal") is not True and any( - "viewcode-link" in getattr(gc, "get", lambda *_: "")("classes", []) - for gc in child.children - if isinstance(gc, nodes.inline) - ): - viewcode_ref = child - sig_node.remove(child) - elif "headerlink" in child.get("classes", []): - headerlink_ref = child - sig_node.remove(child) - - if headerlink_ref is not None: - sig_node += headerlink_ref - sig_node += badge_group - if viewcode_ref is not None: - sig_node += viewcode_ref - - -def _strip_rtype_fields(desc_node: addnodes.desc) -> None: - """Remove redundant "Rtype" fields from fixture descriptions. - - ``sphinx_autodoc_typehints`` emits these for all autodoc objects; for - fixtures the return type is already in the signature line (``\u2192 Type``). - """ - for content_child in desc_node.findall(addnodes.desc_content): - for fl in list(content_child.findall(nodes.field_list)): - for field in list(fl.children): - if not isinstance(field, nodes.field): - continue - field_name = field.children[0] if field.children else None - if ( - isinstance(field_name, nodes.field_name) - and field_name.astext().lower() == "rtype" - ): - fl.remove(field) - if not fl.children: - content_child.remove(fl) - - -def _inject_metadata_fields( - desc_node: addnodes.desc, - store: FixtureStoreDict, - py_domain: PythonDomain, - app: Sphinx, - docname: str, -) -> None: - """Inject "Used by" and "Parametrized" fields into fixture descriptions. - - Uses :func:`make_refnode` for "Used by" links because ``pending_xref`` - nodes added during ``doctree-resolved`` are too late for normal reference - resolution. - - Guarded by ``spf_metadata_injected`` \u2014 safe to call multiple times. - """ - if desc_node.get("spf_metadata_injected"): - return - desc_node["spf_metadata_injected"] = True - - first_sig = next(desc_node.findall(addnodes.desc_signature), None) - if first_sig is None: - return - canon = first_sig.get("spf_canonical_name", "") - if not canon: - return - - meta = store["fixtures"].get(canon) - content_node = None - for child in desc_node.children: - if isinstance(child, addnodes.desc_content): - content_node = child - break - if content_node is None: - return - - extra_fields = nodes.field_list() - - # "Used by" field \u2014 resolved links via make_refnode - consumers = store.get("reverse_deps", {}).get(canon, []) - if consumers: - body_para = nodes.paragraph() - for i, consumer_canon in enumerate(sorted(consumers)): - short = consumer_canon.rsplit(".", 1)[-1] - obj_entry = py_domain.objects.get(consumer_canon) - if obj_entry is not None: - ref_node: nodes.Node = make_refnode( - app.builder, - docname, - obj_entry.docname, - obj_entry.node_id, - nodes.literal(short, short), - ) - else: - ref_node = nodes.literal(short, short) - body_para += ref_node - if i < len(consumers) - 1: - body_para += nodes.Text(", ") - extra_fields += nodes.field( - "", - nodes.field_name("", _FIELD_LABELS["used_by"]), - nodes.field_body("", body_para), - ) - - # "Parametrized" field — render from FixtureMeta.param_reprs tuple - if meta and meta.param_reprs: - if len(meta.param_reprs) <= 3: - # Inline for short lists - body_node: nodes.Element = nodes.paragraph() - for i, param_repr in enumerate(meta.param_reprs): - body_node += nodes.literal(param_repr, param_repr) - if i < len(meta.param_reprs) - 1: - body_node += nodes.Text(", ") - else: - # Enumerated list for longer param lists - body_node = nodes.enumerated_list(enumtype="arabic") - for param_repr in meta.param_reprs: - item = nodes.list_item() - item += nodes.paragraph("", "", nodes.literal(param_repr, param_repr)) - body_node += item - extra_fields += nodes.field( - "", - nodes.field_name("", _FIELD_LABELS["parametrized"]), - nodes.field_body("", body_node), - ) - - if extra_fields.children: - existing_list = next(content_node.findall(nodes.field_list), None) - if existing_list is not None: - for child in list(extra_fields.children): - existing_list += child - else: - content_node += extra_fields - - -def _on_doctree_resolved( - app: Sphinx, - doctree: nodes.document, - docname: str, -) -> None: - """Inject badges and metadata fields into ``py:fixture`` descriptions. - - Orchestrates three focused helpers in the correct order: - badges first, rtype stripping second, metadata injection third. - - Parameters - ---------- - app : Sphinx - The Sphinx application instance. - doctree : nodes.document - The resolved document tree. - docname : str - The name of the document being resolved. - """ - store = _get_spf_store(app.env) - py_domain: PythonDomain = app.env.get_domain("py") # type: ignore[assignment] - - for desc_node in doctree.findall(addnodes.desc): - if desc_node.get("objtype") != "fixture": - continue - - for sig_node in desc_node.findall(addnodes.desc_signature): - _inject_badges_and_reorder(sig_node) - _strip_rtype_fields(desc_node) - _inject_metadata_fields(desc_node, store, py_domain, app, docname) - - # Resolve autofixture-index placeholders - for idx_node in list(doctree.findall(autofixture_index_node)): - _resolve_fixture_index(idx_node, store, py_domain, app, docname) - - -def _visit_abbreviation_html( - self: HTML5Translator, - node: nodes.abbreviation, -) -> None: - """Emit ```` with ``tabindex`` when present. - - Sphinx's built-in ``visit_abbreviation`` only passes ``explanation`` \u2192 - ``title``. It silently drops all other node attributes (including - ``tabindex``). This override is a strict superset: non-badge abbreviation - nodes produce byte-identical output because the ``tabindex`` guard only - fires when the attribute is explicitly set. - """ - attrs: dict[str, t.Any] = {} - if node.get("explanation"): - attrs["title"] = node["explanation"] - if node.get("tabindex"): - attrs["tabindex"] = node["tabindex"] - self.body.append(self.starttag(node, "abbr", "", **attrs)) - - -def _depart_abbreviation_html( - self: HTML5Translator, - node: nodes.abbreviation, -) -> None: - """Close the ```` tag.""" - self.body.append("") diff --git a/docs/_ext/sphinx_pytest_fixtures/_validation.py b/docs/_ext/sphinx_pytest_fixtures/_validation.py deleted file mode 100644 index 0a6bd3413..000000000 --- a/docs/_ext/sphinx_pytest_fixtures/_validation.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Build-time fixture documentation validation with stable warning codes.""" - -from __future__ import annotations - -import typing as t - -from sphinx.util import logging as sphinx_logging - -from sphinx_pytest_fixtures._store import FixtureStoreDict - -if t.TYPE_CHECKING: - pass - -logger = sphinx_logging.getLogger(__name__) - - -def _validate_store(store: FixtureStoreDict, app: t.Any) -> None: - """Emit structured warnings for fixture documentation issues. - - Each warning uses a stable ``spf_code`` in its ``extra`` dict so - downstream tools can filter or suppress specific checks. - - When ``pytest_fixture_lint_level`` is ``"error"``, diagnostics are - emitted at ERROR level and ``app.statuscode`` is set to ``1`` so the - Sphinx build reports failure. - - Parameters - ---------- - store : FixtureStoreDict - The finalized fixture store. - app : Any - The Sphinx application instance, or ``None`` (skips validation). - """ - if app is None: - return - - lint_level = getattr( - getattr(app, "config", None), - "pytest_fixture_lint_level", - "warning", - ) - if lint_level == "none": - return - - _emit = logger.error if lint_level == "error" else logger.warning - emitted = False - - for canon, meta in store["fixtures"].items(): - # SPF001: Missing summary/docstring - if not meta.summary: - _emit( - "fixture %r has no docstring", - meta.public_name, - extra={"fixture_canonical": canon, "spf_code": "SPF001"}, - ) - emitted = True - - # SPF002: Missing return/yield annotation - if meta.return_display in ("", "..."): - _emit( - "fixture %r has no return annotation", - meta.public_name, - extra={"fixture_canonical": canon, "spf_code": "SPF002"}, - ) - emitted = True - - # SPF003: Yield fixture missing teardown documentation - if meta.has_teardown and not meta.teardown_summary: - _emit( - "yield fixture %r has no teardown documentation", - meta.public_name, - extra={"fixture_canonical": canon, "spf_code": "SPF003"}, - ) - emitted = True - - # SPF005: Deprecated fixture missing replacement - if meta.deprecated and not meta.replacement: - _emit( - "deprecated fixture %r has no replacement specified", - meta.public_name, - extra={"fixture_canonical": canon, "spf_code": "SPF005"}, - ) - emitted = True - - # Ambiguous public names (two canonical names map to the same public name) - for pub, canon in store["public_to_canon"].items(): - if canon is None: - _emit( - "fixture public name %r is ambiguous (maps to multiple canonicals)", - pub, - extra={"spf_code": "SPF006"}, - ) - emitted = True - - if lint_level == "error" and emitted: - app.statuscode = 1 diff --git a/docs/conf.py b/docs/conf.py index dac64e2d7..8000acf34 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,6 +15,7 @@ project_src = project_root / "src" sys.path.insert(0, str(project_src)) +sys.path.insert(0, str(cwd / "_ext")) # spf_demo_fixtures for badge demo page # package data about: dict[str, str] = {} diff --git a/pyproject.toml b/pyproject.toml index 30b9b634e..1c215aaee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,10 +105,10 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.uv.sources] -gp-sphinx = { path = "../gp-sphinx/packages/gp-sphinx", editable = true } -sphinx-fonts = { path = "../gp-sphinx/packages/sphinx-fonts", editable = true } -sphinx-gptheme = { path = "../gp-sphinx/packages/sphinx-gptheme", editable = true } -sphinx-autodoc-pytest-fixtures = { path = "../gp-sphinx/packages/sphinx-autodoc-pytest-fixtures", editable = true } +gp-sphinx = { git = "https://github.com/git-pull/gp-sphinx", branch = "init-3", subdirectory = "packages/gp-sphinx" } +sphinx-fonts = { git = "https://github.com/git-pull/gp-sphinx", branch = "init-3", subdirectory = "packages/sphinx-fonts" } +sphinx-gptheme = { git = "https://github.com/git-pull/gp-sphinx", branch = "init-3", subdirectory = "packages/sphinx-gptheme" } +sphinx-autodoc-pytest-fixtures = { git = "https://github.com/git-pull/gp-sphinx", branch = "init-3", subdirectory = "packages/sphinx-autodoc-pytest-fixtures" } [tool.mypy] strict = true diff --git a/uv.lock b/uv.lock index 0a93aca09..a9bb1be28 100644 --- a/uv.lock +++ b/uv.lock @@ -394,7 +394,7 @@ wheels = [ [[package]] name = "gp-sphinx" version = "0.0.1a0" -source = { editable = "../gp-sphinx/packages/gp-sphinx" } +source = { git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fgp-sphinx&branch=init-3#070ded624fdc16236c02d0d4aa8ebbdbd6f002d5" } dependencies = [ { name = "docutils" }, { name = "gp-libs" }, @@ -415,25 +415,6 @@ dependencies = [ { name = "sphinxext-rediraffe" }, ] -[package.metadata] -requires-dist = [ - { name = "docutils" }, - { name = "gp-libs" }, - { name = "linkify-it-py" }, - { name = "myst-parser" }, - { name = "sphinx", specifier = "<9" }, - { name = "sphinx-argparse-neo", marker = "extra == 'argparse'", editable = "../gp-sphinx/packages/sphinx-argparse-neo" }, - { name = "sphinx-autodoc-typehints" }, - { name = "sphinx-copybutton" }, - { name = "sphinx-design" }, - { name = "sphinx-fonts", editable = "../gp-sphinx/packages/sphinx-fonts" }, - { name = "sphinx-gptheme", editable = "../gp-sphinx/packages/sphinx-gptheme" }, - { name = "sphinx-inline-tabs" }, - { name = "sphinxext-opengraph" }, - { name = "sphinxext-rediraffe" }, -] -provides-extras = ["argparse"] - [[package]] name = "h11" version = "0.16.0" @@ -629,7 +610,7 @@ dev = [ { name = "codecov" }, { name = "coverage" }, { name = "gp-libs" }, - { name = "gp-sphinx", editable = "../gp-sphinx/packages/gp-sphinx" }, + { name = "gp-sphinx", git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fgp-sphinx&branch=init-3" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -639,14 +620,14 @@ dev = [ { name = "pytest-xdist" }, { name = "ruff" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-pytest-fixtures", editable = "../gp-sphinx/packages/sphinx-autodoc-pytest-fixtures" }, + { name = "sphinx-autodoc-pytest-fixtures", git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fsphinx-autodoc-pytest-fixtures&branch=init-3" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] docs = [ - { name = "gp-sphinx", editable = "../gp-sphinx/packages/gp-sphinx" }, + { name = "gp-sphinx", git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fgp-sphinx&branch=init-3" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-pytest-fixtures", editable = "../gp-sphinx/packages/sphinx-autodoc-pytest-fixtures" }, + { name = "sphinx-autodoc-pytest-fixtures", git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fsphinx-autodoc-pytest-fixtures&branch=init-3" }, ] lint = [ { name = "mypy" }, @@ -1289,19 +1270,13 @@ wheels = [ [[package]] name = "sphinx-autodoc-pytest-fixtures" version = "0.0.1a0" -source = { editable = "../gp-sphinx/packages/sphinx-autodoc-pytest-fixtures" } +source = { git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fsphinx-autodoc-pytest-fixtures&branch=init-3#070ded624fdc16236c02d0d4aa8ebbdbd6f002d5" } dependencies = [ { name = "pytest" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -[package.metadata] -requires-dist = [ - { name = "pytest" }, - { name = "sphinx" }, -] - [[package]] name = "sphinx-autodoc-typehints" version = "3.0.1" @@ -1393,26 +1368,20 @@ wheels = [ [[package]] name = "sphinx-fonts" version = "0.0.1a0" -source = { editable = "../gp-sphinx/packages/sphinx-fonts" } +source = { git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fsphinx-fonts&branch=init-3#070ded624fdc16236c02d0d4aa8ebbdbd6f002d5" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -[package.metadata] -requires-dist = [{ name = "sphinx" }] - [[package]] name = "sphinx-gptheme" version = "0.0.1a0" -source = { editable = "../gp-sphinx/packages/sphinx-gptheme" } +source = { git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fsphinx-gptheme&branch=init-3#070ded624fdc16236c02d0d4aa8ebbdbd6f002d5" } dependencies = [ { name = "furo" }, ] -[package.metadata] -requires-dist = [{ name = "furo" }] - [[package]] name = "sphinx-inline-tabs" version = "2025.12.21.14" From 0e7f9b2f74ebfc2002904e68be959d844bb6a522 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 3 Apr 2026 12:01:54 -0500 Subject: [PATCH 05/11] docs(chore): Remove bundled extension files and tests why: sphinx_fonts, sphinx_pytest_fixtures, and their tests now live in the gp-sphinx workspace as independent packages. The badge demo page depended on spf_demo_fixtures which was libtmux-specific scaffolding. what: - Remove docs/_ext/spf_demo_fixtures.py and remaining __pycache__ - Remove tests/docs/_ext/ entirely (tests migrated to gp-sphinx) - Remove docs/demo/ badge showcase pages - Remove demo/index from docs toctree - Remove dead mypy overrides for sphinx_fonts and sphinx_autodoc_pytest_fixtures - Remove _ext sys.path from docs/conf.py --- docs/_ext/spf_demo_fixtures.py | 79 - docs/conf.py | 1 - docs/demo/index.md | 24 - docs/demo/sphinx-pytest-fixtures-badges.md | 108 - docs/index.md | 1 - pyproject.toml | 7 - tests/docs/_ext/__init__.py | 3 - tests/docs/_ext/conftest.py | 10 - tests/docs/_ext/test_sphinx_fonts.py | 674 ------ .../docs/_ext/test_sphinx_pytest_fixtures.py | 1385 ------------ ...test_sphinx_pytest_fixtures_integration.py | 1853 ----------------- 11 files changed, 4145 deletions(-) delete mode 100644 docs/_ext/spf_demo_fixtures.py delete mode 100644 docs/demo/index.md delete mode 100644 docs/demo/sphinx-pytest-fixtures-badges.md delete mode 100644 tests/docs/_ext/__init__.py delete mode 100644 tests/docs/_ext/conftest.py delete mode 100644 tests/docs/_ext/test_sphinx_fonts.py delete mode 100644 tests/docs/_ext/test_sphinx_pytest_fixtures.py delete mode 100644 tests/docs/_ext/test_sphinx_pytest_fixtures_integration.py diff --git a/docs/_ext/spf_demo_fixtures.py b/docs/_ext/spf_demo_fixtures.py deleted file mode 100644 index 36c818125..000000000 --- a/docs/_ext/spf_demo_fixtures.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Synthetic fixtures for the sphinx_pytest_fixtures badge demo page. - -Each fixture exercises one badge-slot combination so the demo page can show -every permutation side-by-side: - - Scope: session | module | class | function (suppressed — no badge) - Kind: resource (suppressed) | factory | override_hook - State: autouse | deprecated (set via RST :deprecated: option) - Combos: session+factory, session+autouse - -These fixtures are purely for documentation; they are never collected by -pytest during a real test run (the module is not in the test tree). -""" - -from __future__ import annotations - -import pytest - - -@pytest.fixture -def demo_plain() -> str: - """Plain function-scope resource. Shows FIXTURE badge only.""" - return "plain" - - -@pytest.fixture(scope="session") -def demo_session() -> str: - """Session-scoped resource. Shows SESSION + FIXTURE badges.""" - return "session" - - -@pytest.fixture(scope="module") -def demo_module() -> str: - """Module-scoped resource. Shows MODULE + FIXTURE badges.""" - return "module" - - -@pytest.fixture(scope="class") -def demo_class() -> str: - """Class-scoped resource. Shows CLASS + FIXTURE badges.""" - return "class" - - -@pytest.fixture -def demo_factory() -> type[str]: - """Return a callable (factory kind). Shows FACTORY + FIXTURE badges.""" - return str - - -@pytest.fixture -def demo_override_hook() -> str: - """Override hook — customise in conftest.py. Shows OVERRIDE + FIXTURE badges.""" - return "override" - - -@pytest.fixture(autouse=True) -def demo_autouse() -> None: - """Autouse fixture. Shows AUTO + FIXTURE badges.""" - - -@pytest.fixture -def demo_deprecated() -> str: - """Return a value (deprecated since 1.0, replaced by :func:`demo_plain`). - - This fixture is documented with the ``deprecated`` RST option so the - demo page can show the DEPRECATED + FIXTURE badge combination. - """ - return "deprecated" - - -@pytest.fixture(scope="session") -def demo_session_factory() -> type[str]: - """Session-scoped factory. Shows SESSION + FACTORY + FIXTURE badges.""" - return str - - -@pytest.fixture(scope="session", autouse=True) -def demo_session_autouse() -> None: - """Session-scoped autouse. Shows SESSION + AUTO + FIXTURE badges.""" diff --git a/docs/conf.py b/docs/conf.py index 8000acf34..dac64e2d7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,7 +15,6 @@ project_src = project_root / "src" sys.path.insert(0, str(project_src)) -sys.path.insert(0, str(cwd / "_ext")) # spf_demo_fixtures for badge demo page # package data about: dict[str, str] = {} diff --git a/docs/demo/index.md b/docs/demo/index.md deleted file mode 100644 index e9eb0d0d4..000000000 --- a/docs/demo/index.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -myst: - html_meta: - "description": "Visual demo and test pages for libtmux documentation components" ---- - -(demo-index)= - -# Demo - -Visual showcases and test pages for documentation components. Useful for -verifying badge rendering, theme compatibility, and extension behaviour. - -## Sphinx extensions - -- [sphinx\_pytest\_fixtures badges](sphinx-pytest-fixtures-badges.md) — - All badge permutations (scope, kind, autouse, deprecated) in both the - fixture index table and individual fixture cards. - -```{toctree} -:hidden: - -sphinx-pytest-fixtures-badges -``` diff --git a/docs/demo/sphinx-pytest-fixtures-badges.md b/docs/demo/sphinx-pytest-fixtures-badges.md deleted file mode 100644 index ed7d671ae..000000000 --- a/docs/demo/sphinx-pytest-fixtures-badges.md +++ /dev/null @@ -1,108 +0,0 @@ -# sphinx\_pytest\_fixtures — Badge Demo - -Visual reference for all badge permutations. Use this page to verify badge -rendering across themes, zoom levels, and light/dark modes. - -Each section documents one badge combination. The index table at the top -gives a compact overview of all permutations at once. - -```{py:module} spf_demo_fixtures -``` - -## Fixture Summary - -```{autofixture-index} spf_demo_fixtures -``` - ---- - -## Plain (FIXTURE badge only) - -Function scope, resource kind, not autouse. Shows only the green FIXTURE badge. - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_plain -``` - ---- - -## Scope badges - -### Session scope - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_session -``` - -### Module scope - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_module -``` - -### Class scope - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_class -``` - ---- - -## Kind badges - -### Factory kind - -Return type `type[str]` is auto-detected as factory — no explicit `:kind:` needed. - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_factory -``` - -### Override hook - -Requires explicit `:kind: override_hook` since it cannot be inferred from type. - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_override_hook - :kind: override_hook -``` - ---- - -## State badges - -### Autouse - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_autouse -``` - -### Deprecated - -The `deprecated` badge is set via the `:deprecated:` RST option on `py:fixture`. -`autofixture` does not support `:deprecated:`; use `py:fixture` instead. - -```{eval-rst} -.. py:fixture:: demo_deprecated - :deprecated: 1.0 - :replacement: demo_plain - :return-type: str - - Return a deprecated value. Use :fixture:`demo_plain` instead. -``` - ---- - -## Combinations - -### Session + Factory - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_session_factory -``` - -### Session + Autouse - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_session_autouse -``` diff --git a/docs/index.md b/docs/index.md index f9bdf23ec..845f45e23 100644 --- a/docs/index.md +++ b/docs/index.md @@ -105,5 +105,4 @@ project/index history migration glossary -demo/index ``` diff --git a/pyproject.toml b/pyproject.toml index 1c215aaee..b294ff2b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,13 +118,6 @@ files = [ "tests", ] -[[tool.mypy.overrides]] -module = ["sphinx_fonts"] -ignore_missing_imports = true - -[[tool.mypy.overrides]] -module = ["sphinx_autodoc_pytest_fixtures", "sphinx_autodoc_pytest_fixtures.*"] -ignore_missing_imports = true [tool.coverage.run] branch = true diff --git a/tests/docs/_ext/__init__.py b/tests/docs/_ext/__init__.py deleted file mode 100644 index 56548488e..000000000 --- a/tests/docs/_ext/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Tests for docs/_ext Sphinx extensions.""" - -from __future__ import annotations diff --git a/tests/docs/_ext/conftest.py b/tests/docs/_ext/conftest.py deleted file mode 100644 index e7547fa8a..000000000 --- a/tests/docs/_ext/conftest.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Fixtures and configuration for docs extension tests.""" - -from __future__ import annotations - -import pathlib -import sys - -docs_ext_path = pathlib.Path(__file__).parent.parent.parent.parent / "docs" / "_ext" -if str(docs_ext_path) not in sys.path: - sys.path.insert(0, str(docs_ext_path)) diff --git a/tests/docs/_ext/test_sphinx_fonts.py b/tests/docs/_ext/test_sphinx_fonts.py deleted file mode 100644 index e96a879be..000000000 --- a/tests/docs/_ext/test_sphinx_fonts.py +++ /dev/null @@ -1,674 +0,0 @@ -"""Tests for sphinx_fonts Sphinx extension.""" - -from __future__ import annotations - -import logging -import pathlib -import types -import typing as t -import urllib.error - -import pytest -import sphinx_fonts - -# --- _cache_dir tests --- - - -def test_cache_dir_returns_home_cache_path() -> None: - """_cache_dir returns ~/.cache/sphinx-fonts.""" - result = sphinx_fonts._cache_dir() - assert result == pathlib.Path.home() / ".cache" / "sphinx-fonts" - - -# --- _cdn_url tests --- - - -class CdnUrlFixture(t.NamedTuple): - """Test fixture for CDN URL generation.""" - - test_id: str - package: str - version: str - font_id: str - subset: str - weight: int - style: str - expected_url: str - - -CDN_URL_FIXTURES: list[CdnUrlFixture] = [ - CdnUrlFixture( - test_id="normal_weight", - package="@fontsource/open-sans", - version="5.2.5", - font_id="open-sans", - subset="latin", - weight=400, - style="normal", - expected_url=( - "https://cdn.jsdelivr.net/npm/@fontsource/open-sans@5.2.5" - "/files/open-sans-latin-400-normal.woff2" - ), - ), - CdnUrlFixture( - test_id="bold_italic", - package="@fontsource/roboto", - version="5.0.0", - font_id="roboto", - subset="latin-ext", - weight=700, - style="italic", - expected_url=( - "https://cdn.jsdelivr.net/npm/@fontsource/roboto@5.0.0" - "/files/roboto-latin-ext-700-italic.woff2" - ), - ), -] - - -@pytest.mark.parametrize( - list(CdnUrlFixture._fields), - CDN_URL_FIXTURES, - ids=[f.test_id for f in CDN_URL_FIXTURES], -) -def test_cdn_url( - test_id: str, - package: str, - version: str, - font_id: str, - subset: str, - weight: int, - style: str, - expected_url: str, -) -> None: - """_cdn_url formats the CDN URL template correctly.""" - result = sphinx_fonts._cdn_url(package, version, font_id, subset, weight, style) - assert result == expected_url - - -def test_cdn_url_matches_template() -> None: - """_cdn_url produces URLs matching CDN_TEMPLATE structure.""" - url = sphinx_fonts._cdn_url( - "@fontsource/inter", "5.1.0", "inter", "latin", 400, "normal" - ) - assert url.startswith("https://cdn.jsdelivr.net/npm/") - assert "@fontsource/inter@5.1.0" in url - assert url.endswith(".woff2") - - -# --- _unicode_range tests --- - - -def test_unicode_range_latin() -> None: - """_unicode_range returns a non-empty range for 'latin'.""" - result = sphinx_fonts._unicode_range("latin") - assert result.startswith("U+") - assert "U+0000" in result - - -def test_unicode_range_latin_ext() -> None: - """_unicode_range returns a non-empty range for 'latin-ext'.""" - result = sphinx_fonts._unicode_range("latin-ext") - assert result.startswith("U+") - assert result != sphinx_fonts._unicode_range("latin") - - -def test_unicode_range_unknown_subset() -> None: - """_unicode_range returns empty string for unknown subsets.""" - result = sphinx_fonts._unicode_range("klingon") - assert result == "" - - -def test_unicode_range_all_known_subsets_non_empty() -> None: - """Every subset in _UNICODE_RANGES produces a non-empty range.""" - for subset, urange in sphinx_fonts._UNICODE_RANGES.items(): - assert urange.startswith("U+"), f"subset {subset!r} has invalid range" - assert sphinx_fonts._unicode_range(subset) == urange - - -# --- _download_font tests --- - - -def test_download_font_cached( - tmp_path: pathlib.Path, - caplog: pytest.LogCaptureFixture, -) -> None: - """_download_font returns True and logs debug when file exists.""" - dest = tmp_path / "font.woff2" - dest.write_bytes(b"cached-data") - - with caplog.at_level(logging.DEBUG, logger="sphinx_fonts"): - result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) - - assert result is True - debug_records = [r for r in caplog.records if r.levelno == logging.DEBUG] - assert any("cached" in r.message for r in debug_records) - - -def test_download_font_success( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, -) -> None: - """_download_font downloads and returns True on success.""" - dest = tmp_path / "subdir" / "font.woff2" - - def fake_urlretrieve(url: str, filename: t.Any) -> tuple[str, t.Any]: - pathlib.Path(filename).write_bytes(b"font-data") - return (str(filename), None) - - monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) - - with caplog.at_level(logging.INFO, logger="sphinx_fonts"): - result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) - - assert result is True - info_records = [r for r in caplog.records if r.levelno == logging.INFO] - assert any("downloaded" in r.message for r in info_records) - - -def test_download_font_url_error( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, -) -> None: - """_download_font returns False and warns on URLError.""" - dest = tmp_path / "font.woff2" - - msg = "network error" - - def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: - raise urllib.error.URLError(msg) - - monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) - - with caplog.at_level(logging.WARNING, logger="sphinx_fonts"): - result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) - - assert result is False - warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] - assert any("failed" in r.message for r in warning_records) - - -def test_download_font_os_error( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, -) -> None: - """_download_font returns False and warns on OSError.""" - dest = tmp_path / "font.woff2" - - msg = "disk full" - - def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: - raise OSError(msg) - - monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) - - with caplog.at_level(logging.WARNING, logger="sphinx_fonts"): - result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) - - assert result is False - warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] - assert any("failed" in r.message for r in warning_records) - - -def test_download_font_partial_file_cleanup( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """_download_font removes partial file on failure.""" - dest = tmp_path / "cache" / "partial.woff2" - - msg = "disk full" - - def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: - pathlib.Path(filename).write_bytes(b"partial") - raise OSError(msg) - - monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) - - result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) - - assert result is False - assert not dest.exists() - - -# --- _on_builder_inited tests --- - - -def _make_app( - tmp_path: pathlib.Path, - *, - builder_format: str = "html", - fonts: list[dict[str, t.Any]] | None = None, - preload: list[tuple[str, int, str]] | None = None, - fallbacks: list[dict[str, str]] | None = None, - variables: dict[str, str] | None = None, -) -> types.SimpleNamespace: - """Create a fake Sphinx app namespace for testing.""" - config = types.SimpleNamespace( - sphinx_fonts=fonts if fonts is not None else [], - sphinx_font_preload=preload if preload is not None else [], - sphinx_font_fallbacks=fallbacks if fallbacks is not None else [], - sphinx_font_css_variables=variables if variables is not None else {}, - ) - builder = types.SimpleNamespace(format=builder_format) - return types.SimpleNamespace( - builder=builder, - config=config, - outdir=str(tmp_path / "output"), - ) - - -def test_on_builder_inited_non_html(tmp_path: pathlib.Path) -> None: - """_on_builder_inited returns early for non-HTML builders.""" - app = _make_app(tmp_path, builder_format="latex") - sphinx_fonts._on_builder_inited(app) - assert not hasattr(app, "_font_faces") - - -def test_on_builder_inited_empty_fonts(tmp_path: pathlib.Path) -> None: - """_on_builder_inited returns early when no fonts configured.""" - app = _make_app(tmp_path, fonts=[]) - sphinx_fonts._on_builder_inited(app) - assert not hasattr(app, "_font_faces") - - -def test_on_builder_inited_with_fonts( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """_on_builder_inited processes fonts and stores results on app.""" - monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") - - fonts = [ - { - "package": "@fontsource/open-sans", - "version": "5.2.5", - "family": "Open Sans", - "weights": [400, 700], - "styles": ["normal"], - }, - ] - app = _make_app(tmp_path, fonts=fonts) - - cache = tmp_path / "cache" - cache.mkdir(parents=True) - for weight in [400, 700]: - (cache / f"open-sans-latin-{weight}-normal.woff2").write_bytes(b"data") - - sphinx_fonts._on_builder_inited(app) - - assert len(app._font_faces) == 2 - assert app._font_faces[0]["family"] == "Open Sans" - assert app._font_faces[0]["weight"] == "400" - assert app._font_faces[1]["weight"] == "700" - assert app._font_preload_hrefs == [] - assert app._font_fallbacks == [] - assert app._font_css_variables == {} - - -def test_on_builder_inited_download_failure( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """_on_builder_inited skips font_faces entry on download failure.""" - monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") - - msg = "offline" - - def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: - raise urllib.error.URLError(msg) - - monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) - - fonts = [ - { - "package": "@fontsource/inter", - "version": "5.0.0", - "family": "Inter", - "weights": [400], - "styles": ["normal"], - }, - ] - app = _make_app(tmp_path, fonts=fonts) - - sphinx_fonts._on_builder_inited(app) - - assert len(app._font_faces) == 0 - - -def test_on_builder_inited_explicit_subset( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """_on_builder_inited respects explicit subset in font config.""" - monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") - - fonts = [ - { - "package": "@fontsource/noto-sans", - "version": "5.0.0", - "family": "Noto Sans", - "subset": "latin-ext", - "weights": [400], - "styles": ["normal"], - }, - ] - app = _make_app(tmp_path, fonts=fonts) - - cache = tmp_path / "cache" - cache.mkdir(parents=True) - (cache / "noto-sans-latin-ext-400-normal.woff2").write_bytes(b"data") - - sphinx_fonts._on_builder_inited(app) - - assert app._font_faces[0]["filename"] == "noto-sans-latin-ext-400-normal.woff2" - - -def test_on_builder_inited_multiple_subsets( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """_on_builder_inited downloads files for each subset and includes unicode_range.""" - monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") - - fonts = [ - { - "package": "@fontsource/ibm-plex-sans", - "version": "5.2.8", - "family": "IBM Plex Sans", - "subsets": ["latin", "latin-ext"], - "weights": [400], - "styles": ["normal"], - }, - ] - app = _make_app(tmp_path, fonts=fonts) - - cache = tmp_path / "cache" - cache.mkdir(parents=True) - (cache / "ibm-plex-sans-latin-400-normal.woff2").write_bytes(b"data") - (cache / "ibm-plex-sans-latin-ext-400-normal.woff2").write_bytes(b"data") - - sphinx_fonts._on_builder_inited(app) - - assert len(app._font_faces) == 2 - filenames = [f["filename"] for f in app._font_faces] - assert "ibm-plex-sans-latin-400-normal.woff2" in filenames - assert "ibm-plex-sans-latin-ext-400-normal.woff2" in filenames - - # unicode_range should be populated for known subsets - latin_face = next(f for f in app._font_faces if "latin-400" in f["filename"]) - assert latin_face["unicode_range"].startswith("U+") - latin_ext_face = next(f for f in app._font_faces if "latin-ext" in f["filename"]) - assert latin_ext_face["unicode_range"].startswith("U+") - assert latin_face["unicode_range"] != latin_ext_face["unicode_range"] - - -def test_on_builder_inited_legacy_subset_gets_unicode_range( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Legacy single 'subset' config still produces unicode_range in font_faces.""" - monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") - - fonts = [ - { - "package": "@fontsource/noto-sans", - "version": "5.0.0", - "family": "Noto Sans", - "subset": "latin", - "weights": [400], - "styles": ["normal"], - }, - ] - app = _make_app(tmp_path, fonts=fonts) - - cache = tmp_path / "cache" - cache.mkdir(parents=True) - (cache / "noto-sans-latin-400-normal.woff2").write_bytes(b"data") - - sphinx_fonts._on_builder_inited(app) - - assert len(app._font_faces) == 1 - assert app._font_faces[0]["unicode_range"].startswith("U+") - - -def test_on_builder_inited_preload_uses_primary_subset( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Preload uses the first (primary) subset when multiple are configured.""" - monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") - - fonts = [ - { - "package": "@fontsource/ibm-plex-sans", - "version": "5.2.8", - "family": "IBM Plex Sans", - "subsets": ["latin", "latin-ext"], - "weights": [400], - "styles": ["normal"], - }, - ] - preload = [("IBM Plex Sans", 400, "normal")] - app = _make_app(tmp_path, fonts=fonts, preload=preload) - - cache = tmp_path / "cache" - cache.mkdir(parents=True) - (cache / "ibm-plex-sans-latin-400-normal.woff2").write_bytes(b"data") - (cache / "ibm-plex-sans-latin-ext-400-normal.woff2").write_bytes(b"data") - - sphinx_fonts._on_builder_inited(app) - - # Preload should only include the primary (first) subset - assert app._font_preload_hrefs == ["ibm-plex-sans-latin-400-normal.woff2"] - - -def test_on_builder_inited_preload_match( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """_on_builder_inited builds preload_hrefs for matching preload specs.""" - monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") - - fonts = [ - { - "package": "@fontsource/open-sans", - "version": "5.2.5", - "family": "Open Sans", - "weights": [400], - "styles": ["normal"], - }, - ] - preload = [("Open Sans", 400, "normal")] - app = _make_app(tmp_path, fonts=fonts, preload=preload) - - cache = tmp_path / "cache" - cache.mkdir(parents=True) - (cache / "open-sans-latin-400-normal.woff2").write_bytes(b"data") - - sphinx_fonts._on_builder_inited(app) - - assert app._font_preload_hrefs == ["open-sans-latin-400-normal.woff2"] - - -def test_on_builder_inited_preload_no_match( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """_on_builder_inited produces empty preload when family doesn't match.""" - monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") - - fonts = [ - { - "package": "@fontsource/open-sans", - "version": "5.2.5", - "family": "Open Sans", - "weights": [400], - "styles": ["normal"], - }, - ] - preload = [("Nonexistent Font", 400, "normal")] - app = _make_app(tmp_path, fonts=fonts, preload=preload) - - cache = tmp_path / "cache" - cache.mkdir(parents=True) - (cache / "open-sans-latin-400-normal.woff2").write_bytes(b"data") - - sphinx_fonts._on_builder_inited(app) - - assert app._font_preload_hrefs == [] - - -def test_on_builder_inited_fallbacks_and_variables( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """_on_builder_inited stores fallbacks and CSS variables on app.""" - monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") - - fonts = [ - { - "package": "@fontsource/inter", - "version": "5.0.0", - "family": "Inter", - "weights": [400], - "styles": ["normal"], - }, - ] - fallbacks = [{"family": "system-ui", "style": "normal", "weight": "400"}] - variables = {"--font-body": "Inter, system-ui"} - app = _make_app(tmp_path, fonts=fonts, fallbacks=fallbacks, variables=variables) - - cache = tmp_path / "cache" - cache.mkdir(parents=True) - (cache / "inter-latin-400-normal.woff2").write_bytes(b"data") - - sphinx_fonts._on_builder_inited(app) - - assert app._font_fallbacks == fallbacks - assert app._font_css_variables == variables - - -# --- _on_html_page_context tests --- - - -def test_on_html_page_context_with_attrs() -> None: - """_on_html_page_context injects font data from app attributes.""" - app = types.SimpleNamespace( - _font_preload_hrefs=["font-400.woff2"], - _font_faces=[ - { - "family": "Inter", - "weight": "400", - "style": "normal", - "filename": "font-400.woff2", - }, - ], - _font_fallbacks=[{"family": "system-ui"}], - _font_css_variables={"--font-body": "Inter"}, - ) - context: dict[str, t.Any] = {} - - sphinx_fonts._on_html_page_context( - app, - "index", - "page.html", - context, - None, - ) - - assert context["font_preload_hrefs"] == ["font-400.woff2"] - assert context["font_faces"] == app._font_faces - assert context["font_fallbacks"] == [{"family": "system-ui"}] - assert context["font_css_variables"] == {"--font-body": "Inter"} - - -def test_on_html_page_context_without_attrs() -> None: - """_on_html_page_context uses defaults when app attrs are missing.""" - app = types.SimpleNamespace() - context: dict[str, t.Any] = {} - - sphinx_fonts._on_html_page_context( - app, - "index", - "page.html", - context, - None, - ) - - assert context["font_preload_hrefs"] == [] - assert context["font_faces"] == [] - assert context["font_fallbacks"] == [] - assert context["font_css_variables"] == {} - - -# --- setup tests --- - - -def test_setup_return_value() -> None: - """Verify setup() returns correct metadata dict.""" - config_values: list[tuple[str, t.Any, str]] = [] - connections: list[tuple[str, t.Any]] = [] - - app = types.SimpleNamespace( - add_config_value=lambda name, default, rebuild: config_values.append( - (name, default, rebuild) - ), - connect=lambda event, handler: connections.append((event, handler)), - ) - - result = sphinx_fonts.setup(app) - - assert result == { - "version": "1.0", - "parallel_read_safe": True, - "parallel_write_safe": True, - } - - -def test_setup_config_values() -> None: - """Verify setup() registers all expected config values.""" - config_values: list[tuple[str, t.Any, str]] = [] - connections: list[tuple[str, t.Any]] = [] - - app = types.SimpleNamespace( - add_config_value=lambda name, default, rebuild: config_values.append( - (name, default, rebuild) - ), - connect=lambda event, handler: connections.append((event, handler)), - ) - - sphinx_fonts.setup(app) - - config_names = [c[0] for c in config_values] - assert "sphinx_fonts" in config_names - assert "sphinx_font_fallbacks" in config_names - assert "sphinx_font_css_variables" in config_names - assert "sphinx_font_preload" in config_names - assert all(c[2] == "html" for c in config_values) - - -def test_setup_event_connections() -> None: - """Verify setup() connects to builder-inited and html-page-context events.""" - config_values: list[tuple[str, t.Any, str]] = [] - connections: list[tuple[str, t.Any]] = [] - - app = types.SimpleNamespace( - add_config_value=lambda name, default, rebuild: config_values.append( - (name, default, rebuild) - ), - connect=lambda event, handler: connections.append((event, handler)), - ) - - sphinx_fonts.setup(app) - - event_names = [c[0] for c in connections] - assert "builder-inited" in event_names - assert "html-page-context" in event_names - - handlers = {c[0]: c[1] for c in connections} - assert handlers["builder-inited"] is sphinx_fonts._on_builder_inited - assert handlers["html-page-context"] is sphinx_fonts._on_html_page_context diff --git a/tests/docs/_ext/test_sphinx_pytest_fixtures.py b/tests/docs/_ext/test_sphinx_pytest_fixtures.py deleted file mode 100644 index f339b2836..000000000 --- a/tests/docs/_ext/test_sphinx_pytest_fixtures.py +++ /dev/null @@ -1,1385 +0,0 @@ -"""Tests for sphinx_pytest_fixtures Sphinx extension.""" - -from __future__ import annotations - -import collections.abc -import types -import typing as t - -import pytest -import sphinx_pytest_fixtures -import sphinx_pytest_fixtures._store -from docutils import nodes - -from libtmux.server import Server - -# --------------------------------------------------------------------------- -# _is_pytest_fixture -# --------------------------------------------------------------------------- - - -def test_is_pytest_fixture_positive() -> None: - """_is_pytest_fixture returns True for decorated fixtures.""" - - @pytest.fixture(scope="session") - def my_fixture(tmp_path_factory: pytest.TempPathFactory) -> str: - return "hello" - - assert sphinx_pytest_fixtures._is_pytest_fixture(my_fixture) - - -def test_is_pytest_fixture_negative() -> None: - """_is_pytest_fixture returns False for plain functions.""" - - def not_a_fixture() -> str: - return "hello" - - assert not sphinx_pytest_fixtures._is_pytest_fixture(not_a_fixture) - - -# --------------------------------------------------------------------------- -# _get_user_deps -# --------------------------------------------------------------------------- - - -def test_user_deps_filters_pytest_hidden() -> None: - """_get_user_deps excludes fixtures in PYTEST_HIDDEN (low-value noise). - - Fixtures in PYTEST_BUILTIN_LINKS (request, monkeypatch, etc.) are NOT - filtered by _get_user_deps — they are rendered with external hyperlinks - by transform_content instead. - """ - - @pytest.fixture - def my_fixture( - pytestconfig: pytest.Config, - monkeypatch: pytest.MonkeyPatch, - server: t.Any, - ) -> str: - return "hello" - - deps = sphinx_pytest_fixtures._get_user_deps(my_fixture) - names = [name for name, _ in deps] - # pytestconfig is in PYTEST_HIDDEN → filtered - assert "pytestconfig" not in names - # monkeypatch is in PYTEST_BUILTIN_LINKS (not PYTEST_HIDDEN) → appears - assert "monkeypatch" in names - # project fixture → appears - assert "server" in names - - -def test_user_deps_empty_for_only_hidden_params() -> None: - """_get_user_deps returns empty list when all params are in PYTEST_HIDDEN.""" - - @pytest.fixture - def my_fixture(pytestconfig: pytest.Config) -> str: - return "hello" - - assert sphinx_pytest_fixtures._get_user_deps(my_fixture) == [] - - -# --------------------------------------------------------------------------- -# _get_return_annotation — including Generator/yield unwrapping -# --------------------------------------------------------------------------- - - -def test_get_return_annotation_resolved() -> None: - """_get_return_annotation returns the resolved return type.""" - - @pytest.fixture - def my_fixture() -> str: - return "hello" - - ann = sphinx_pytest_fixtures._get_return_annotation(my_fixture) - assert ann is str - - -def test_get_return_annotation_forward_ref_fallback() -> None: - """_get_return_annotation falls back gracefully on unresolvable forward refs.""" - - @pytest.fixture - def my_fixture() -> UnresolvableForwardRef: # type: ignore[name-defined] # noqa: F821 - return None - - # Should not raise; returns the annotation string or Parameter.empty - ann = sphinx_pytest_fixtures._get_return_annotation(my_fixture) - assert ann is not None - - -def test_get_return_annotation_unwraps_generator() -> None: - """_get_return_annotation extracts yield type from Generator[T, None, None].""" - - @pytest.fixture - def server_fixture() -> collections.abc.Generator[Server, None, None]: - srv = Server() - yield srv - srv.kill() - - ann = sphinx_pytest_fixtures._get_return_annotation(server_fixture) - assert ann is Server - - -def test_get_return_annotation_unwraps_iterator() -> None: - """_get_return_annotation extracts yield type from Iterator[T].""" - - @pytest.fixture - def server_fixture() -> collections.abc.Iterator[Server]: - yield Server() - - ann = sphinx_pytest_fixtures._get_return_annotation(server_fixture) - assert ann is Server - - -# --------------------------------------------------------------------------- -# _is_factory -# --------------------------------------------------------------------------- - - -def test_factory_detection_from_type_annotation() -> None: - """_is_factory returns True for type[X] return annotation.""" - - @pytest.fixture - def test_factory(request: pytest.FixtureRequest) -> type[Server]: - return Server - - assert sphinx_pytest_fixtures._is_factory(test_factory) - - -def test_factory_detection_from_callable_annotation() -> None: - """_is_factory returns True for Callable return annotation.""" - - @pytest.fixture - def make_thing() -> collections.abc.Callable[[], str]: - return lambda: "x" - - assert sphinx_pytest_fixtures._is_factory(make_thing) - - -def test_factory_detection_from_name_convention() -> None: - """_is_factory returns False for unannotated (t.Any) fixtures; no name heuristic.""" - - @pytest.fixture - def CapitalFactory() -> t.Any: - return lambda: None - - assert not sphinx_pytest_fixtures._is_factory(CapitalFactory) - - -def test_is_factory_camelcase_unannotated_defaults_to_resource() -> None: - """Unannotated CamelCase fixture must NOT be silently classified as factory.""" - - @pytest.fixture - def Session() -> t.Any: - return "string value" - - assert not sphinx_pytest_fixtures._is_factory(Session) - - -def test_factory_detection_negative() -> None: - """_is_factory returns False for plain resource fixtures.""" - - @pytest.fixture - def plain_fixture() -> str: - return "hello" - - assert not sphinx_pytest_fixtures._is_factory(plain_fixture) - - -# --------------------------------------------------------------------------- -# format_name (via getattr pattern used in FixtureDocumenter.format_name) -# --------------------------------------------------------------------------- - - -def test_format_name_uses_function_name_when_not_renamed() -> None: - """format_name returns the function name when no name alias is set.""" - - @pytest.fixture - def server_fixture() -> str: - return "hello" - - fixture_name = ( - getattr( - server_fixture, - "name", - None, - ) - or sphinx_pytest_fixtures._get_fixture_fn(server_fixture).__name__ - ) - assert fixture_name == "server_fixture" - - -def test_format_name_honours_fixture_name_alias() -> None: - """format_name returns the alias when @pytest.fixture(name=...) is used.""" - - @pytest.fixture(name="server") - def _server_fixture() -> str: - return "hello" - - fixture_name = ( - getattr( - _server_fixture, - "name", - None, - ) - or sphinx_pytest_fixtures._get_fixture_fn(_server_fixture).__name__ - ) - assert fixture_name == "server" - - -# --------------------------------------------------------------------------- -# setup() -# --------------------------------------------------------------------------- - - -def test_setup_return_value() -> None: - """setup() returns correct extension metadata.""" - connections: list[tuple[str, t.Any]] = [] - - app = types.SimpleNamespace( - setup_extension=lambda ext: None, - add_config_value=lambda name, default, rebuild, **kw: None, - add_crossref_type=lambda *a, **kw: None, - add_directive_to_domain=lambda d, n, cls: None, - add_role_to_domain=lambda d, n, role: None, - add_autodocumenter=lambda cls: None, - add_directive=lambda name, cls: None, - add_node=lambda *a, **kw: None, - add_css_file=lambda *a, **kw: None, - connect=lambda event, handler: connections.append((event, handler)), - ) - - result = sphinx_pytest_fixtures.setup(app) - assert result["version"] == "1.0" - assert result["parallel_read_safe"] is True - assert result["parallel_write_safe"] is True - - -def test_setup_event_connections() -> None: - """setup() connects required event handlers.""" - connections: list[tuple[str, t.Any]] = [] - - app = types.SimpleNamespace( - setup_extension=lambda ext: None, - add_config_value=lambda name, default, rebuild, **kw: None, - add_crossref_type=lambda *a, **kw: None, - add_directive_to_domain=lambda d, n, cls: None, - add_role_to_domain=lambda d, n, role: None, - add_autodocumenter=lambda cls: None, - add_directive=lambda name, cls: None, - add_node=lambda *a, **kw: None, - add_css_file=lambda *a, **kw: None, - connect=lambda event, handler: connections.append((event, handler)), - ) - - sphinx_pytest_fixtures.setup(app) - event_names = [e for e, _ in connections] - assert "missing-reference" in event_names - assert "doctree-resolved" in event_names - assert "env-purge-doc" in event_names - assert "env-merge-info" in event_names - assert "env-updated" in event_names - - handlers = dict(connections) - assert handlers["missing-reference"] is sphinx_pytest_fixtures._on_missing_reference - - -def test_setup_registers_autodocumenter() -> None: - """setup() registers FixtureDocumenter.""" - registered: list[t.Any] = [] - - app = types.SimpleNamespace( - setup_extension=lambda ext: None, - add_config_value=lambda name, default, rebuild, **kw: None, - add_crossref_type=lambda *a, **kw: None, - add_directive_to_domain=lambda d, n, cls: None, - add_role_to_domain=lambda d, n, role: None, - add_autodocumenter=lambda cls: registered.append(cls), - add_directive=lambda name, cls: None, - add_node=lambda *a, **kw: None, - add_css_file=lambda *a, **kw: None, - connect=lambda event, handler: None, - ) - - sphinx_pytest_fixtures.setup(app) - assert sphinx_pytest_fixtures.FixtureDocumenter in registered - - -# --------------------------------------------------------------------------- -# _get_fixture_marker — scope normalisation (Commit 1) -# --------------------------------------------------------------------------- - - -def test_get_fixture_marker_scope_is_str_for_session() -> None: - """_get_fixture_marker always returns str scope, never enum or None.""" - - @pytest.fixture(scope="session") - def my_fixture() -> str: - return "hello" - - marker = sphinx_pytest_fixtures._get_fixture_marker(my_fixture) - assert isinstance(marker.scope, str) - assert marker.scope == "session" - - -def test_get_fixture_marker_function_scope_is_str() -> None: - """Function-scope (default) fixture returns 'function', not None.""" - - @pytest.fixture - def fn_fixture() -> str: - return "x" - - marker = sphinx_pytest_fixtures._get_fixture_marker(fn_fixture) - assert isinstance(marker.scope, str) - assert marker.scope == "function" - - -# --------------------------------------------------------------------------- -# _iter_injectable_params — variadic filter (Commit 1) -# --------------------------------------------------------------------------- - - -def test_iter_injectable_params_skips_kwargs() -> None: - """_iter_injectable_params skips *args and **kwargs.""" - - @pytest.fixture - def fx(server: t.Any, *args: t.Any, **kwargs: t.Any) -> None: - pass - - names = [n for n, _ in sphinx_pytest_fixtures._iter_injectable_params(fx)] - assert names == ["server"] - assert "args" not in names - assert "kwargs" not in names - - -def test_iter_injectable_params_keeps_keyword_only() -> None: - """_iter_injectable_params includes KEYWORD_ONLY params — pytest can inject them.""" - - @pytest.fixture - def fx(*, server: t.Any) -> None: - pass - - names = [n for n, _ in sphinx_pytest_fixtures._iter_injectable_params(fx)] - assert "server" in names - - -def test_iter_injectable_params_skips_positional_only() -> None: - """_iter_injectable_params skips POSITIONAL_ONLY params (before /). - - Positional-only parameters cannot be injected by name, so they are - correctly excluded from the fixture dependency list. - """ - import textwrap - - code = textwrap.dedent(""" - import pytest - import typing as t - - @pytest.fixture - def fx(server: t.Any, /, *, session: t.Any) -> None: - pass - """) - ns: dict[str, t.Any] = {} - exec(compile(code, "", "exec"), ns) - names = [n for n, _ in sphinx_pytest_fixtures._iter_injectable_params(ns["fx"])] - assert names == ["session"] - assert "server" not in names - - -# --------------------------------------------------------------------------- -# _build_badge_group_node — portable inline badge nodes (Commit 4) -# --------------------------------------------------------------------------- - - -def test_build_badge_group_node_fixture_always_present() -> None: - """_build_badge_group_node always includes a FIXTURE badge child.""" - node = sphinx_pytest_fixtures._build_badge_group_node("function", "resource", False) - texts = [child.astext() for child in node.children] - assert "fixture" in texts - - -def test_build_badge_group_node_no_scope_for_function() -> None: - """Function-scope produces no scope badge (absence = function-scope).""" - node = sphinx_pytest_fixtures._build_badge_group_node("function", "resource", False) - classes_all = [ - c - for child in node.children - if hasattr(child, "get") - for c in child.get("classes", []) - ] - assert sphinx_pytest_fixtures._CSS.BADGE_SCOPE not in classes_all - - -def test_build_badge_group_node_session_scope_badge() -> None: - """Session-scope produces a scope badge with class spf-scope-session.""" - node = sphinx_pytest_fixtures._build_badge_group_node("session", "resource", False) - classes_all = [ - c - for child in node.children - if hasattr(child, "get") - for c in child.get("classes", []) - ] - assert sphinx_pytest_fixtures._CSS.scope("session") in classes_all - - -def test_build_badge_group_node_override_kind() -> None: - """override_hook produces a badge with class spf-override.""" - node = sphinx_pytest_fixtures._build_badge_group_node( - "function", "override_hook", False - ) - texts = [child.astext() for child in node.children] - classes_all = [ - c - for child in node.children - if hasattr(child, "get") - for c in child.get("classes", []) - ] - assert "override" in texts - assert sphinx_pytest_fixtures._CSS.OVERRIDE in classes_all - - -def test_build_badge_group_node_autouse_replaces_kind() -> None: - """autouse=True shows AUTO badge with spf-autouse class, no kind badge.""" - node = sphinx_pytest_fixtures._build_badge_group_node("function", "resource", True) - texts = [child.astext() for child in node.children] - classes_all = [ - c - for child in node.children - if hasattr(child, "get") - for c in child.get("classes", []) - ] - assert "auto" in texts - assert sphinx_pytest_fixtures._CSS.AUTOUSE in classes_all - assert sphinx_pytest_fixtures._CSS.BADGE_KIND not in classes_all - - -def test_build_badge_group_node_factory_session() -> None: - """Factory + session scope produces both scope and factory badges.""" - node = sphinx_pytest_fixtures._build_badge_group_node("session", "factory", False) - texts = [child.astext() for child in node.children] - classes_all = [ - c - for child in node.children - if hasattr(child, "get") - for c in child.get("classes", []) - ] - assert "factory" in texts - assert sphinx_pytest_fixtures._CSS.FACTORY in classes_all - assert sphinx_pytest_fixtures._CSS.scope("session") in classes_all - - -def test_build_badge_group_node_has_tabindex() -> None: - """All badge abbreviation nodes have tabindex='0' for touch accessibility.""" - from docutils import nodes - - node = sphinx_pytest_fixtures._build_badge_group_node("session", "factory", True) - abbreviations = [ - child for child in node.children if isinstance(child, nodes.abbreviation) - ] - assert len(abbreviations) > 0 - for abbr in abbreviations: - assert abbr.get("tabindex") == "0", ( - f"Badge {abbr.astext()!r} missing tabindex='0'" - ) - - -# --------------------------------------------------------------------------- -# _get_spf_store — store version guard -# --------------------------------------------------------------------------- - - -def test_store_version_guard_resets_stale() -> None: - """_get_spf_store resets a store with an outdated _store_version.""" - env = types.SimpleNamespace( - domaindata={ - "sphinx_pytest_fixtures": { - "fixtures": {"old.fixture": "stale"}, - "public_to_canon": {"old": "old.fixture"}, - "reverse_deps": {}, - "_store_version": 1, - } - } - ) - store = sphinx_pytest_fixtures._get_spf_store(env) - assert store["fixtures"] == {} - assert store["public_to_canon"] == {} - assert store["_store_version"] == sphinx_pytest_fixtures._STORE_VERSION - - -def test_store_version_guard_preserves_current() -> None: - """_get_spf_store preserves a store with the current _store_version.""" - sentinel_meta = types.SimpleNamespace(docname="api", public_name="srv") - env = types.SimpleNamespace( - domaindata={ - "sphinx_pytest_fixtures": { - "fixtures": {"mod.srv": sentinel_meta}, - "public_to_canon": {"srv": "mod.srv"}, - "reverse_deps": {}, - "_store_version": sphinx_pytest_fixtures._STORE_VERSION, - } - } - ) - store = sphinx_pytest_fixtures._get_spf_store(env) - assert store["fixtures"]["mod.srv"] is sentinel_meta - - -# --------------------------------------------------------------------------- -# public_to_canon registration logic -# --------------------------------------------------------------------------- - - -def test_public_to_canon_first_registration() -> None: - """First registration stores canonical name for a public name.""" - env = types.SimpleNamespace(domaindata={}) - store = sphinx_pytest_fixtures._get_spf_store(env) - - store["public_to_canon"]["server"] = "mod_a.server" - assert store["public_to_canon"]["server"] == "mod_a.server" - - -def test_public_to_canon_ambiguous() -> None: - """Two fixtures with the same public name mark the mapping as None.""" - env = types.SimpleNamespace(domaindata={}) - store = sphinx_pytest_fixtures._get_spf_store(env) - - # Simulate what _register_fixture_meta does (corrected logic): - public_name = "server" - - # First registration - if public_name not in store["public_to_canon"]: - store["public_to_canon"][public_name] = "mod_a.server" - elif store["public_to_canon"][public_name] != "mod_a.server": - store["public_to_canon"][public_name] = None - - # Second registration with different canonical name - if public_name not in store["public_to_canon"]: - store["public_to_canon"][public_name] = "mod_b.server" - elif store["public_to_canon"][public_name] != "mod_b.server": - store["public_to_canon"][public_name] = None - - assert store["public_to_canon"]["server"] is None - - -def test_public_to_canon_idempotent() -> None: - """Registering the same fixture twice preserves the canonical name.""" - env = types.SimpleNamespace(domaindata={}) - store = sphinx_pytest_fixtures._get_spf_store(env) - - public_name = "server" - - # First registration - if public_name not in store["public_to_canon"]: - store["public_to_canon"][public_name] = "mod.server" - elif store["public_to_canon"][public_name] != "mod.server": - store["public_to_canon"][public_name] = None - - # Same fixture registered again - if public_name not in store["public_to_canon"]: - store["public_to_canon"][public_name] = "mod.server" - elif store["public_to_canon"][public_name] != "mod.server": - store["public_to_canon"][public_name] = None - - assert store["public_to_canon"]["server"] == "mod.server" - - -# --------------------------------------------------------------------------- -# _finalize_store — store finalization -# --------------------------------------------------------------------------- - - -def _make_meta( - canonical: str, - public: str, - deps: tuple[sphinx_pytest_fixtures.FixtureDep, ...] = (), - docname: str = "api", -) -> sphinx_pytest_fixtures.FixtureMeta: - """Build a minimal FixtureMeta for unit tests.""" - return sphinx_pytest_fixtures.FixtureMeta( - docname=docname, - canonical_name=canonical, - public_name=public, - source_name=public, - scope="function", - autouse=False, - kind="resource", - return_display="str", - return_xref_target=None, - deps=deps, - param_reprs=(), - has_teardown=False, - is_async=False, - summary="Test fixture.", - ) - - -def test_finalize_store_forward_reference() -> None: - """_finalize_store resolves forward-reference dep targets.""" - env = types.SimpleNamespace(domaindata={}) - store = sphinx_pytest_fixtures._get_spf_store(env) - - # consumer registered before provider → dep.target is None - consumer_dep = sphinx_pytest_fixtures.FixtureDep( - display_name="provider", kind="fixture", target=None - ) - store["fixtures"]["mod.consumer"] = _make_meta( - "mod.consumer", "consumer", deps=(consumer_dep,) - ) - store["fixtures"]["mod.provider"] = _make_meta("mod.provider", "provider") - - sphinx_pytest_fixtures._finalize_store(store) - - resolved_dep = store["fixtures"]["mod.consumer"].deps[0] - assert resolved_dep.target == "mod.provider" - - -def test_finalize_store_empty_store() -> None: - """_finalize_store on an empty store completes without error.""" - env = types.SimpleNamespace(domaindata={}) - store = sphinx_pytest_fixtures._get_spf_store(env) - sphinx_pytest_fixtures._finalize_store(store) - assert store["fixtures"] == {} - assert store["public_to_canon"] == {} - assert store["reverse_deps"] == {} - - -def test_finalize_store_self_dependency() -> None: - """_finalize_store skips self-edges in reverse_deps.""" - env = types.SimpleNamespace(domaindata={}) - store = sphinx_pytest_fixtures._get_spf_store(env) - - self_dep = sphinx_pytest_fixtures.FixtureDep( - display_name="self_ref", kind="fixture", target=None - ) - store["fixtures"]["mod.self_ref"] = _make_meta( - "mod.self_ref", "self_ref", deps=(self_dep,) - ) - - sphinx_pytest_fixtures._finalize_store(store) - - # dep.target resolves to itself, but reverse_deps should not contain self-edge - assert "mod.self_ref" not in store["reverse_deps"].get("mod.self_ref", []) - - -def test_finalize_store_ambiguous_public_name() -> None: - """_finalize_store marks ambiguous public names as None in public_to_canon.""" - env = types.SimpleNamespace(domaindata={}) - store = sphinx_pytest_fixtures._get_spf_store(env) - - store["fixtures"]["mod_a.server"] = _make_meta("mod_a.server", "server") - store["fixtures"]["mod_b.server"] = _make_meta("mod_b.server", "server") - - sphinx_pytest_fixtures._finalize_store(store) - - assert store["public_to_canon"]["server"] is None - - -def test_finalize_store_reverse_deps() -> None: - """_finalize_store populates reverse_deps from fixture deps.""" - env = types.SimpleNamespace(domaindata={}) - store = sphinx_pytest_fixtures._get_spf_store(env) - - dep_on_server = sphinx_pytest_fixtures.FixtureDep( - display_name="server", kind="fixture", target="mod.server" - ) - store["fixtures"]["mod.server"] = _make_meta("mod.server", "server") - store["fixtures"]["mod.client"] = _make_meta( - "mod.client", "client", deps=(dep_on_server,) - ) - - sphinx_pytest_fixtures._finalize_store(store) - - assert "mod.client" in store["reverse_deps"]["mod.server"] - - -def test_finalize_store_parallel_merge() -> None: - """_finalize_store resolves deps after parallel worker merge.""" - # Simulate primary env with consumer, sub-env with provider - primary_env = types.SimpleNamespace(domaindata={}) - primary_store = sphinx_pytest_fixtures._get_spf_store(primary_env) - - consumer_dep = sphinx_pytest_fixtures.FixtureDep( - display_name="provider", kind="fixture", target=None - ) - primary_store["fixtures"]["mod.consumer"] = _make_meta( - "mod.consumer", "consumer", deps=(consumer_dep,) - ) - - # Simulate sub-env merge - sub_env = types.SimpleNamespace(domaindata={}) - sub_store = sphinx_pytest_fixtures._get_spf_store(sub_env) - sub_store["fixtures"]["mod.provider"] = _make_meta( - "mod.provider", "provider", docname="other" - ) - - # Merge (what _on_env_merge_info does) - primary_store["fixtures"].update(sub_store["fixtures"]) - - # Finalize - sphinx_pytest_fixtures._finalize_store(primary_store) - - resolved_dep = primary_store["fixtures"]["mod.consumer"].deps[0] - assert resolved_dep.target == "mod.provider" - assert "mod.consumer" in primary_store["reverse_deps"]["mod.provider"] - - -def test_finalize_store_stale_target_after_purge() -> None: - """_finalize_store clears stale dep targets after provider is purged.""" - env = types.SimpleNamespace(domaindata={}) - store = sphinx_pytest_fixtures._get_spf_store(env) - - dep_on_provider = sphinx_pytest_fixtures.FixtureDep( - display_name="provider", kind="fixture", target="mod.provider" - ) - store["fixtures"]["mod.consumer"] = _make_meta( - "mod.consumer", "consumer", deps=(dep_on_provider,) - ) - store["fixtures"]["mod.provider"] = _make_meta("mod.provider", "provider") - - # Simulate purge of provider - del store["fixtures"]["mod.provider"] - - sphinx_pytest_fixtures._finalize_store(store) - - resolved_dep = store["fixtures"]["mod.consumer"].deps[0] - assert resolved_dep.target is None - assert "mod.provider" not in store["reverse_deps"] - - -# --------------------------------------------------------------------------- -# Badge group text separators (Commit 4) -# --------------------------------------------------------------------------- - - -def test_badge_group_node_has_text_separators() -> None: - """Badge group nodes have Text(' ') separators between badge children.""" - from docutils import nodes as docnodes - - node = sphinx_pytest_fixtures._build_badge_group_node("session", "factory", False) - # Should have: scope badge, Text(" "), factory badge, Text(" "), FIXTURE badge - text_nodes = [child for child in node.children if isinstance(child, docnodes.Text)] - assert len(text_nodes) >= 2, f"Expected >=2 Text separators, got {len(text_nodes)}" - for t_node in text_nodes: - assert t_node.astext() == " " - - -# --------------------------------------------------------------------------- -# FixtureKind validation (Commit 4) -# --------------------------------------------------------------------------- - - -def test_infer_kind_custom_warning(caplog: pytest.LogCaptureFixture) -> None: - """Unknown :kind: values produce a warning during registration.""" - import logging - - env = types.SimpleNamespace( - domaindata={}, - app=types.SimpleNamespace( - config=types.SimpleNamespace( - pytest_fixture_hidden_dependencies=frozenset(), - pytest_fixture_builtin_links={}, - pytest_external_fixture_links={}, - ), - ), - ) - - @pytest.fixture - def my_fixture() -> str: - """Return a test value.""" - return "hello" - - with caplog.at_level(logging.WARNING, logger="sphinx_pytest_fixtures"): - sphinx_pytest_fixtures._register_fixture_meta( - env=env, - docname="api", - obj=my_fixture, - public_name="my_fixture", - source_name="my_fixture", - modname="mod", - kind="custom_weird_kind", - app=env.app, - ) - - assert any("custom_weird_kind" in r.message for r in caplog.records) - - -# --------------------------------------------------------------------------- -# _classify_deps -# --------------------------------------------------------------------------- - - -def test_classify_deps_project_fixture() -> None: - """Non-builtin, non-hidden dep is classified as a project fixture.""" - - @pytest.fixture - def my_fixture(server: t.Any) -> str: - return "hello" - - project, builtin, hidden = sphinx_pytest_fixtures._classify_deps(my_fixture, None) - assert "server" in project - assert "server" not in builtin - assert "server" not in hidden - - -def test_classify_deps_hidden_fixture() -> None: - """Fixture depending on pytestconfig has it classified as hidden.""" - - @pytest.fixture - def my_fixture(pytestconfig: t.Any) -> str: - return "hello" - - project, _builtin, hidden = sphinx_pytest_fixtures._classify_deps(my_fixture, None) - assert "pytestconfig" in hidden - assert "pytestconfig" not in project - - -# --------------------------------------------------------------------------- -# _has_authored_example -# --------------------------------------------------------------------------- - - -def test_has_authored_example_with_rubric() -> None: - """Authored Example rubric suppresses auto-generated snippets.""" - from docutils import nodes - - content = nodes.container() - content += nodes.paragraph("", "Some intro text.") - content += nodes.rubric("", "Example") - content += nodes.literal_block("", "def test(): pass") - assert sphinx_pytest_fixtures._has_authored_example(content) - - -def test_has_authored_example_with_doctest() -> None: - """Doctest blocks count as authored examples.""" - from docutils import nodes - - content = nodes.container() - content += nodes.doctest_block("", ">>> 1 + 1\n2") - assert sphinx_pytest_fixtures._has_authored_example(content) - - -def test_has_authored_example_without() -> None: - """No authored examples — auto-snippet should still be generated.""" - from docutils import nodes - - content = nodes.container() - content += nodes.paragraph("", "Just a description.") - assert not sphinx_pytest_fixtures._has_authored_example(content) - - -def test_has_authored_example_nested_not_detected() -> None: - """Nested rubrics inside admonitions are not detected (non-recursive).""" - from docutils import nodes - - content = nodes.container() - admonition = nodes.note() - admonition += nodes.rubric("", "Example") - content += admonition - assert not sphinx_pytest_fixtures._has_authored_example(content) - - -# --------------------------------------------------------------------------- -# _build_usage_snippet -# --------------------------------------------------------------------------- - - -def test_build_usage_snippet_resource_returns_none() -> None: - """Resource fixtures return None (generic snippet suppressed).""" - result = sphinx_pytest_fixtures._build_usage_snippet( - "server", "Server", "resource", "function", autouse=False - ) - assert result is None - - -def test_build_usage_snippet_autouse_returns_note() -> None: - """Autouse fixtures return a nodes.note admonition.""" - from docutils import nodes - - result = sphinx_pytest_fixtures._build_usage_snippet( - "auto_cleanup", None, "resource", "function", autouse=True - ) - assert isinstance(result, nodes.note) - assert "No request needed" in result.astext() - - -def test_build_usage_snippet_factory_returns_literal_block() -> None: - """Factory fixtures produce a literal_block with instantiation pattern.""" - from docutils import nodes - - result = sphinx_pytest_fixtures._build_usage_snippet( - "TestServer", "Server", "factory", "function", autouse=False - ) - assert isinstance(result, nodes.literal_block) - text = result.astext() - assert "test_example" in text - assert "TestServer()" in text - assert ": Server" in text - - -def test_build_usage_snippet_override_hook_returns_conftest() -> None: - """Override hook fixtures produce a conftest.py snippet.""" - from docutils import nodes - - result = sphinx_pytest_fixtures._build_usage_snippet( - "home_user", "str", "override_hook", "function", autouse=False - ) - assert isinstance(result, nodes.literal_block) - text = result.astext() - assert "conftest.py" in text - assert "@pytest.fixture\n" in text - - -def test_build_usage_snippet_override_hook_session_scope() -> None: - """Override hook with session scope includes scope in decorator.""" - result = sphinx_pytest_fixtures._build_usage_snippet( - "home_user", "str", "override_hook", "session", autouse=False - ) - assert result is not None - text = result.astext() - assert 'scope="session"' in text - - -def test_build_usage_snippet_override_hook_no_return_type() -> None: - """Override hook without return type omits the arrow annotation.""" - result = sphinx_pytest_fixtures._build_usage_snippet( - "home_user", None, "override_hook", "function", autouse=False - ) - assert result is not None - text = result.astext() - assert " -> " not in text - - -# --------------------------------------------------------------------------- -# _on_env_purge_doc -# --------------------------------------------------------------------------- - - -def test_env_purge_doc_removes_only_target() -> None: - """Purging a doc removes only that doc's fixtures from the store.""" - env = types.SimpleNamespace( - domaindata={ - "sphinx_pytest_fixtures": { - "fixtures": { - "mod.fixture_a": sphinx_pytest_fixtures.FixtureMeta( - docname="page_a", - canonical_name="mod.fixture_a", - public_name="fixture_a", - source_name="fixture_a", - scope="function", - autouse=False, - kind="resource", - return_display="str", - return_xref_target=None, - deps=(), - param_reprs=(), - has_teardown=False, - is_async=False, - summary="", - ), - "mod.fixture_b": sphinx_pytest_fixtures.FixtureMeta( - docname="page_b", - canonical_name="mod.fixture_b", - public_name="fixture_b", - source_name="fixture_b", - scope="function", - autouse=False, - kind="resource", - return_display="str", - return_xref_target=None, - deps=(), - param_reprs=(), - has_teardown=False, - is_async=False, - summary="", - ), - }, - "public_to_canon": {}, - "reverse_deps": {}, - "_store_version": sphinx_pytest_fixtures._STORE_VERSION, - }, - }, - ) - app = types.SimpleNamespace() - sphinx_pytest_fixtures._on_env_purge_doc(app, env, "page_a") - store = env.domaindata["sphinx_pytest_fixtures"] - assert "mod.fixture_a" not in store["fixtures"] - assert "mod.fixture_b" in store["fixtures"] - - -# --------------------------------------------------------------------------- -# FixtureMeta schema evolution — deprecated/replacement/teardown_summary -# --------------------------------------------------------------------------- - - -def test_fixture_meta_new_fields_default_to_none() -> None: - """New optional fields default to None when not provided.""" - meta = _make_meta("mod.server", "server") - assert meta.deprecated is None - assert meta.replacement is None - assert meta.teardown_summary is None - - -def test_fixture_meta_new_fields_accept_values() -> None: - """New optional fields accept explicit values.""" - meta = sphinx_pytest_fixtures.FixtureMeta( - docname="api", - canonical_name="mod.old_server", - public_name="old_server", - source_name="old_server", - scope="function", - autouse=False, - kind="resource", - return_display="Server", - return_xref_target=None, - deps=(), - param_reprs=(), - has_teardown=True, - is_async=False, - summary="Deprecated server fixture.", - deprecated="2.0", - replacement="mod.new_server", - teardown_summary="Kills the tmux server process.", - ) - assert meta.deprecated == "2.0" - assert meta.replacement == "mod.new_server" - assert meta.teardown_summary == "Kills the tmux server process." - - -# --------------------------------------------------------------------------- -# Deprecation badge rendering -# --------------------------------------------------------------------------- - - -def test_deprecated_badge_renders_at_slot_zero() -> None: - """Deprecated badge appears as leftmost badge (slot 0).""" - node = sphinx_pytest_fixtures._build_badge_group_node( - "session", "resource", False, deprecated=True - ) - badges = [c for c in node.children if isinstance(c, nodes.abbreviation)] - assert len(badges) >= 2 - # First badge should be "deprecated" - assert badges[0].astext() == "deprecated" - classes_first: list[str] = badges[0].get("classes", []) - assert sphinx_pytest_fixtures._CSS.DEPRECATED in classes_first - - -def test_deprecated_badge_absent_when_not_deprecated() -> None: - """No deprecated badge when deprecated=False (default).""" - node = sphinx_pytest_fixtures._build_badge_group_node("session", "resource", False) - badges = [c for c in node.children if isinstance(c, nodes.abbreviation)] - texts = [b.astext() for b in badges] - assert "deprecated" not in texts - - -# --------------------------------------------------------------------------- -# Build-time validation (SPF001-SPF006) -# --------------------------------------------------------------------------- - - -def test_spf001_missing_docstring(caplog: pytest.LogCaptureFixture) -> None: - """SPF001 fires for fixtures with empty summary.""" - import logging - - from sphinx_pytest_fixtures._validation import _validate_store - - store: sphinx_pytest_fixtures._store.FixtureStoreDict = { - "fixtures": { - "mod.bare": _make_meta("mod.bare", "bare"), - }, - "public_to_canon": {"bare": "mod.bare"}, - "reverse_deps": {}, - "_store_version": sphinx_pytest_fixtures._STORE_VERSION, - } - # Override summary to empty - import dataclasses - - store["fixtures"]["mod.bare"] = dataclasses.replace( - store["fixtures"]["mod.bare"], summary="" - ) - - app = types.SimpleNamespace( - config=types.SimpleNamespace(pytest_fixture_lint_level="warning") - ) - with caplog.at_level(logging.WARNING, logger="sphinx_pytest_fixtures._validation"): - _validate_store(store, app) - - spf001 = [r for r in caplog.records if getattr(r, "spf_code", None) == "SPF001"] - assert len(spf001) == 1 - - -def test_spf005_deprecated_without_replacement( - caplog: pytest.LogCaptureFixture, -) -> None: - """SPF005 fires for deprecated fixtures without replacement.""" - import dataclasses - import logging - - from sphinx_pytest_fixtures._validation import _validate_store - - meta = dataclasses.replace( - _make_meta("mod.old", "old"), deprecated="2.0", replacement=None - ) - store: sphinx_pytest_fixtures._store.FixtureStoreDict = { - "fixtures": {"mod.old": meta}, - "public_to_canon": {"old": "mod.old"}, - "reverse_deps": {}, - "_store_version": sphinx_pytest_fixtures._STORE_VERSION, - } - app = types.SimpleNamespace( - config=types.SimpleNamespace(pytest_fixture_lint_level="warning") - ) - with caplog.at_level(logging.WARNING, logger="sphinx_pytest_fixtures._validation"): - _validate_store(store, app) - - spf005 = [r for r in caplog.records if getattr(r, "spf_code", None) == "SPF005"] - assert len(spf005) == 1 - - -def test_validation_silent_when_lint_level_none( - caplog: pytest.LogCaptureFixture, -) -> None: - """lint_level='none' suppresses all validation warnings.""" - import dataclasses - import logging - - from sphinx_pytest_fixtures._validation import _validate_store - - meta = dataclasses.replace(_make_meta("mod.bare", "bare"), summary="") - store: sphinx_pytest_fixtures._store.FixtureStoreDict = { - "fixtures": {"mod.bare": meta}, - "public_to_canon": {"bare": "mod.bare"}, - "reverse_deps": {}, - "_store_version": sphinx_pytest_fixtures._STORE_VERSION, - } - app = types.SimpleNamespace( - config=types.SimpleNamespace(pytest_fixture_lint_level="none") - ) - with caplog.at_level(logging.WARNING, logger="sphinx_pytest_fixtures._validation"): - _validate_store(store, app) - - assert len(caplog.records) == 0 - - -def test_lint_level_error_uses_logger_error( - caplog: pytest.LogCaptureFixture, -) -> None: - """lint_level='error' emits ERROR-level records and sets statuscode=1.""" - import dataclasses - import logging - - from sphinx_pytest_fixtures._validation import _validate_store - - meta = dataclasses.replace(_make_meta("mod.bare", "bare"), summary="") - store: sphinx_pytest_fixtures._store.FixtureStoreDict = { - "fixtures": {"mod.bare": meta}, - "public_to_canon": {"bare": "mod.bare"}, - "reverse_deps": {}, - "_store_version": sphinx_pytest_fixtures._STORE_VERSION, - } - app = types.SimpleNamespace( - config=types.SimpleNamespace(pytest_fixture_lint_level="error"), - statuscode=0, - ) - with caplog.at_level(logging.DEBUG, logger="sphinx_pytest_fixtures._validation"): - _validate_store(store, app) - - spf001 = [r for r in caplog.records if getattr(r, "spf_code", None) == "SPF001"] - assert len(spf001) == 1 - assert spf001[0].levelno == logging.ERROR - assert app.statuscode == 1 - - -def test_spf002_missing_return_annotation(caplog: pytest.LogCaptureFixture) -> None: - """SPF002 fires for fixtures with empty return annotation.""" - import dataclasses - import logging - - from sphinx_pytest_fixtures._validation import _validate_store - - meta = dataclasses.replace(_make_meta("mod.bare", "bare"), return_display="...") - store: sphinx_pytest_fixtures._store.FixtureStoreDict = { - "fixtures": {"mod.bare": meta}, - "public_to_canon": {"bare": "mod.bare"}, - "reverse_deps": {}, - "_store_version": sphinx_pytest_fixtures._STORE_VERSION, - } - app = types.SimpleNamespace( - config=types.SimpleNamespace(pytest_fixture_lint_level="warning") - ) - with caplog.at_level(logging.WARNING, logger="sphinx_pytest_fixtures._validation"): - _validate_store(store, app) - - spf002 = [r for r in caplog.records if getattr(r, "spf_code", None) == "SPF002"] - assert len(spf002) == 1 - - -def test_spf003_yield_missing_teardown(caplog: pytest.LogCaptureFixture) -> None: - """SPF003 fires for yield fixtures without teardown documentation.""" - import dataclasses - import logging - - from sphinx_pytest_fixtures._validation import _validate_store - - meta = dataclasses.replace( - _make_meta("mod.gen", "gen"), - has_teardown=True, - teardown_summary=None, - ) - store: sphinx_pytest_fixtures._store.FixtureStoreDict = { - "fixtures": {"mod.gen": meta}, - "public_to_canon": {"gen": "mod.gen"}, - "reverse_deps": {}, - "_store_version": sphinx_pytest_fixtures._STORE_VERSION, - } - app = types.SimpleNamespace( - config=types.SimpleNamespace(pytest_fixture_lint_level="warning") - ) - with caplog.at_level(logging.WARNING, logger="sphinx_pytest_fixtures._validation"): - _validate_store(store, app) - - spf003 = [r for r in caplog.records if getattr(r, "spf_code", None) == "SPF003"] - assert len(spf003) == 1 - - -def test_spf006_ambiguous_public_name(caplog: pytest.LogCaptureFixture) -> None: - """SPF006 fires when a public name maps to multiple canonical names.""" - import logging - - from sphinx_pytest_fixtures._validation import _validate_store - - store: sphinx_pytest_fixtures._store.FixtureStoreDict = { - "fixtures": { - "mod_a.server": _make_meta("mod_a.server", "server"), - "mod_b.server": _make_meta("mod_b.server", "server"), - }, - "public_to_canon": {"server": None}, # ambiguous - "reverse_deps": {}, - "_store_version": sphinx_pytest_fixtures._STORE_VERSION, - } - app = types.SimpleNamespace( - config=types.SimpleNamespace(pytest_fixture_lint_level="warning") - ) - with caplog.at_level(logging.WARNING, logger="sphinx_pytest_fixtures._validation"): - _validate_store(store, app) - - spf006 = [r for r in caplog.records if getattr(r, "spf_code", None) == "SPF006"] - assert len(spf006) == 1 - - -# --------------------------------------------------------------------------- -# _qualify_forward_ref — TYPE_CHECKING forward-reference resolution -# --------------------------------------------------------------------------- - - -def test_qualify_forward_ref_resolves_type_checking_import() -> None: - """_qualify_forward_ref resolves TYPE_CHECKING imports via AST parsing.""" - from sphinx_pytest_fixtures._metadata import _qualify_forward_ref - - from libtmux.pytest_plugin import session - - fn = sphinx_pytest_fixtures._get_fixture_fn(session) - result = _qualify_forward_ref("Session", fn) - assert result == "libtmux.session.Session" - - -def test_qualify_forward_ref_returns_none_for_unknown() -> None: - """_qualify_forward_ref returns None for names not found in module imports.""" - from sphinx_pytest_fixtures._metadata import _qualify_forward_ref - - from libtmux.pytest_plugin import server - - fn = sphinx_pytest_fixtures._get_fixture_fn(server) - result = _qualify_forward_ref("NonexistentClass", fn) - assert result is None - - -def test_qualify_forward_ref_prefers_type_checking_block_over_runtime_import( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """TYPE_CHECKING-guarded import wins over a same-name runtime import.""" - import sys - - from sphinx_pytest_fixtures import _metadata as spf_meta - from sphinx_pytest_fixtures._metadata import _qualify_forward_ref - - synthetic_source = """\ -from __future__ import annotations -import typing as t -from mod_a import Foo # runtime import -if t.TYPE_CHECKING: - from mod_b import Foo # TYPE_CHECKING import — should win -""" - fake_mod = types.ModuleType("fake_qual_mod") - sys.modules["fake_qual_mod"] = fake_mod - - def fake_fn() -> Foo: # type: ignore[name-defined] # noqa: F821 - pass - - fake_fn.__module__ = "fake_qual_mod" - monkeypatch.setattr(spf_meta.inspect, "getsource", lambda _: synthetic_source) - - result = _qualify_forward_ref("Foo", fake_fn) - # Pre-fix: "mod_a.Foo" (runtime import wins via first-match AST walk) - # Post-fix: "mod_b.Foo" (TYPE_CHECKING block wins) - assert result == "mod_b.Foo" - - del sys.modules["fake_qual_mod"] - - -def test_qualify_forward_ref_no_source_returns_none( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """_qualify_forward_ref returns None when inspect.getsource raises OSError.""" - from sphinx_pytest_fixtures import _metadata as spf_meta - from sphinx_pytest_fixtures._metadata import _qualify_forward_ref - - from libtmux.pytest_plugin import session - - fn = sphinx_pytest_fixtures._get_fixture_fn(session) - monkeypatch.setattr( - spf_meta.inspect, - "getsource", - lambda _: (_ for _ in ()).throw(OSError("no source")), - ) - result = _qualify_forward_ref("Session", fn) - assert result is None - - -# --------------------------------------------------------------------------- -# _extract_summary -# --------------------------------------------------------------------------- - - -def test_extract_summary_single_sentence() -> None: - """_extract_summary returns the first sentence when it ends with a period.""" - from sphinx_pytest_fixtures._metadata import _extract_summary - - @pytest.fixture - def my_fix() -> str: - """Return a string. This second sentence should be excluded.""" - return "x" - - assert _extract_summary(my_fix) == "Return a string." - - -def test_extract_summary_sentence_at_eof() -> None: - """_extract_summary handles first paragraph whose last sentence ends at EOF.""" - from sphinx_pytest_fixtures._metadata import _extract_summary - - @pytest.fixture - def my_fix() -> str: - """First sentence. Second sentence ends here.""" - return "x" - - # The last sentence ends at EOF with no trailing whitespace — regex must match. - assert _extract_summary(my_fix) == "First sentence." - - -def test_extract_summary_no_sentence_terminator() -> None: - """_extract_summary falls back to first_para when no sentence terminator exists.""" - from sphinx_pytest_fixtures._metadata import _extract_summary - - @pytest.fixture - def my_fix() -> str: - """A fixture with no sentence terminator""" # noqa: D400, D401 - return "x" - - assert _extract_summary(my_fix) == "A fixture with no sentence terminator" diff --git a/tests/docs/_ext/test_sphinx_pytest_fixtures_integration.py b/tests/docs/_ext/test_sphinx_pytest_fixtures_integration.py deleted file mode 100644 index cfef365af..000000000 --- a/tests/docs/_ext/test_sphinx_pytest_fixtures_integration.py +++ /dev/null @@ -1,1853 +0,0 @@ -"""Integration tests for sphinx_pytest_fixtures using a full Sphinx build. - -These tests build a minimal Sphinx project with a synthetic fixture module so -results are independent of the libtmux fixture signatures. They gate the B1, -B2, and B4/B5 fixes in subsequent commits. - -Run integration tests specifically: - - uv run pytest tests/docs/_ext/test_sphinx_pytest_fixtures_integration.py -v - -""" - -from __future__ import annotations - -import io -import pathlib -import sys -import textwrap -import typing as t - -import pytest -import sphinx_pytest_fixtures -from sphinx_pytest_fixtures import _CSS - -# --------------------------------------------------------------------------- -# Synthetic fixture module written to tmp_path for each test run -# --------------------------------------------------------------------------- - -FIXTURE_MOD_SOURCE = textwrap.dedent( - """\ - from __future__ import annotations - import typing as t - import pytest - - class Server: - \"\"\"A fake server.\"\"\" - - @pytest.fixture(scope="session") - def my_server() -> Server: - \"\"\"Return a fake server for testing. - - Use this when you need a long-lived server across the session. - \"\"\" - return Server() - - @pytest.fixture - def my_client(my_server: Server) -> str: - \"\"\"Return a fake client connected to *my_server*.\"\"\" - return f"client@{my_server}" - - @pytest.fixture - def home_user() -> str: - \"\"\"Override to customise the home directory username.\"\"\" - return "testuser" - - @pytest.fixture - def yield_server(my_server: Server) -> t.Generator[Server, None, None]: - \"\"\"Yield the server and tear down after the test.\"\"\" - yield my_server - - @pytest.fixture(autouse=True) - def auto_cleanup() -> None: - \"\"\"Runs automatically before every test — no request needed.\"\"\" - - @pytest.fixture - def TestServer() -> type[Server]: - \"\"\"Return the Server class for direct instantiation (factory fixture).\"\"\" - return Server - - @pytest.fixture(name="renamed_fixture") - def _internal_name() -> str: - \"\"\"Fixture with a name alias — injected as 'renamed_fixture'.\"\"\" - return "renamed" - """, -) - -CONF_PY_TEMPLATE = """\ -import sys -sys.path.insert(0, "{srcdir}") - -extensions = [ - "sphinx.ext.autodoc", - "sphinx_pytest_fixtures", -] - -master_doc = "index" -exclude_patterns = ["_build"] -html_theme = "alabaster" -""" - -INDEX_RST = textwrap.dedent( - """\ - Test fixtures - ============= - - .. py:module:: fixture_mod - - .. autofixture:: fixture_mod.my_server - - .. autofixture:: fixture_mod.my_client - - .. autofixture:: fixture_mod.home_user - :kind: override_hook - - .. autofixture:: fixture_mod.yield_server - - .. autofixture:: fixture_mod.auto_cleanup - - .. autofixture:: fixture_mod.TestServer - - .. autofixture:: fixture_mod._internal_name - """, -) - - -# --------------------------------------------------------------------------- -# Module isolation helper -# --------------------------------------------------------------------------- - - -def _purge_fixture_module(name: str = "fixture_mod") -> None: - """Remove *name* and its sub-modules from sys.modules. - - Multiple Sphinx builds in the same process cache imported modules. - Without this cleanup, the second test to use ``fixture_mod`` gets the - first test's cached version — new attributes written to a fresh - ``fixture_mod.py`` in a different ``tmp_path`` are never visible. - """ - for key in list(sys.modules): - if key == name or key.startswith(f"{name}."): - del sys.modules[key] - - -# --------------------------------------------------------------------------- -# Shared fixture: build the Sphinx app once per test (no caching — each test -# gets an isolated tmp_path) -# --------------------------------------------------------------------------- - - -class _SphinxResult(t.NamedTuple): - """Lightweight wrapper around a completed Sphinx build.""" - - app: t.Any # sphinx.application.Sphinx - srcdir: pathlib.Path - outdir: pathlib.Path - status: str - warnings: str - - -def _build_sphinx_app( - tmp_path: pathlib.Path, - *, - confoverrides: dict[str, t.Any] | None = None, - fixture_source: str | None = None, - index_rst: str | None = None, -) -> _SphinxResult: - """Write project files and run a full Sphinx HTML build; return results. - - Parameters - ---------- - tmp_path : - Per-test temporary directory provided by pytest. - confoverrides : - Optional Sphinx confoverrides dict (passed to Sphinx constructor). - fixture_source : - Override the fixture module source written to ``fixture_mod.py``. - Defaults to :data:`FIXTURE_MOD_SOURCE`. - index_rst : - Override the RST index written to ``index.rst``. - Defaults to :data:`INDEX_RST`. - """ - from sphinx.application import Sphinx - - srcdir = tmp_path / "src" - outdir = tmp_path / "out" - doctreedir = tmp_path / ".doctrees" - - srcdir.mkdir() - outdir.mkdir() - doctreedir.mkdir() - - (srcdir / "fixture_mod.py").write_text( - fixture_source if fixture_source is not None else FIXTURE_MOD_SOURCE, - encoding="utf-8", - ) - (srcdir / "conf.py").write_text( - CONF_PY_TEMPLATE.format(srcdir=str(srcdir)), - encoding="utf-8", - ) - (srcdir / "index.rst").write_text( - index_rst if index_rst is not None else INDEX_RST, - encoding="utf-8", - ) - - status_buf = io.StringIO() - warning_buf = io.StringIO() - - _purge_fixture_module() - app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="html", - confoverrides=confoverrides, - status=status_buf, - warning=warning_buf, - freshenv=True, - ) - app.build() - - return _SphinxResult( - app=app, - srcdir=srcdir, - outdir=outdir, - status=status_buf.getvalue(), - warnings=warning_buf.getvalue(), - ) - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -def test_fixture_target_id(tmp_path: pathlib.Path) -> None: - """Registered fixtures have non-empty IDs in their signature nodes.""" - from sphinx.domains.python import PythonDomain - - result = _build_sphinx_app(tmp_path) - domain = t.cast("PythonDomain", result.app.env.get_domain("py")) - objects = domain.data["objects"] - # ObjectEntry = (docname, node_id, objtype, aliased) - fixture_keys = [k for k, v in objects.items() if v.objtype == "fixture"] - assert fixture_keys, f"No py:fixture objects found in domain. Objects: {objects}" - - -@pytest.mark.integration -def test_fixture_in_domain_objects(tmp_path: pathlib.Path) -> None: - """Domain objects registry has qualified fixture names with objtype='fixture'.""" - from sphinx.domains.python import PythonDomain - - result = _build_sphinx_app(tmp_path) - domain = t.cast("PythonDomain", result.app.env.get_domain("py")) - objects = domain.data["objects"] - - assert "fixture_mod.my_server" in objects, ( - f"fixture_mod.my_server not in domain objects. Keys: {list(objects)}" - ) - # ObjectEntry = (docname, node_id, objtype, aliased) - assert objects["fixture_mod.my_server"].objtype == "fixture", ( - f"Expected objtype='fixture', got {objects['fixture_mod.my_server'].objtype!r}" - ) - - -@pytest.mark.integration -def test_fixture_in_inventory(tmp_path: pathlib.Path) -> None: - """objects.inv contains a 'py:fixture' section with the registered fixtures.""" - from sphinx.util.inventory import InventoryFile - - result = _build_sphinx_app(tmp_path) - inv_path = result.outdir / "objects.inv" - assert inv_path.exists(), "objects.inv was not generated" - - inv = InventoryFile.loads(inv_path.read_bytes(), uri="") - # inv.data is dict[obj_type_str, dict[name_str, _InventoryItem]] - assert "py:fixture" in inv.data, ( - f"'py:fixture' not in inventory. Types: {sorted(inv.data)}" - ) - fixture_names = list(inv.data["py:fixture"]) - assert any("my_server" in name for name in fixture_names), ( - f"my_server not in py:fixture inventory entries: {fixture_names}" - ) - - -@pytest.mark.integration -def test_manual_directive_without_module(tmp_path: pathlib.Path) -> None: - """Manual py:fixture without currentmodule uses bare name as target.""" - from sphinx.application import Sphinx - from sphinx.domains.python import PythonDomain - - srcdir = tmp_path / "src" - outdir = tmp_path / "out" - doctreedir = tmp_path / ".doctrees" - srcdir.mkdir() - outdir.mkdir() - doctreedir.mkdir() - - (srcdir / "fixture_mod.py").write_text(FIXTURE_MOD_SOURCE, encoding="utf-8") - (srcdir / "conf.py").write_text( - CONF_PY_TEMPLATE.format(srcdir=str(srcdir)), - encoding="utf-8", - ) - # Bare directive with no currentmodule - (srcdir / "index.rst").write_text( - "Manual\n======\n\n.. py:fixture:: bare_server\n\n Bare server docs.\n", - encoding="utf-8", - ) - - _purge_fixture_module() - app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="html", - status=io.StringIO(), - warning=io.StringIO(), - freshenv=True, - ) - app.build() - - domain = t.cast("PythonDomain", app.env.get_domain("py")) - objects = domain.data["objects"] - # Without currentmodule the target is the bare name — document this known behaviour - assert "bare_server" in objects, ( - "Known limitation: bare py:fixture registers under unqualified name. " - f"Objects: {list(objects)}" - ) - - -@pytest.mark.integration -def test_xref_resolves(tmp_path: pathlib.Path) -> None: - """Cross-file :fixture: role resolves to a hyperlink in the output HTML.""" - from sphinx.application import Sphinx - - srcdir = tmp_path / "src" - outdir = tmp_path / "out" - doctreedir = tmp_path / ".doctrees" - srcdir.mkdir() - outdir.mkdir() - doctreedir.mkdir() - - (srcdir / "fixture_mod.py").write_text(FIXTURE_MOD_SOURCE, encoding="utf-8") - (srcdir / "conf.py").write_text( - CONF_PY_TEMPLATE.format(srcdir=str(srcdir)), - encoding="utf-8", - ) - (srcdir / "index.rst").write_text( - textwrap.dedent( - """\ - Fixtures - ======== - - .. toctree:: - - api - usage - - """, - ), - encoding="utf-8", - ) - (srcdir / "api.rst").write_text( - textwrap.dedent( - """\ - API - === - - .. py:module:: fixture_mod - - .. autofixture:: fixture_mod.my_server - """, - ), - encoding="utf-8", - ) - (srcdir / "usage.rst").write_text( - textwrap.dedent( - """\ - Usage - ===== - - Use :fixture:`fixture_mod.my_server` to get a server. - """, - ), - encoding="utf-8", - ) - - warning_buf = io.StringIO() - _purge_fixture_module() - app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="html", - status=io.StringIO(), - warning=warning_buf, - freshenv=True, - ) - app.build() - - usage_html = (outdir / "usage.html").read_text(encoding="utf-8") - # The xref should produce a hyperlink element pointing to the fixture - assert " None: - """Scope value appears in the rendered HTML for autofixture directives.""" - result = _build_sphinx_app(tmp_path) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - # my_server has scope="session"; the rendered page should mention it - assert "session" in index_html, ( - "Expected scope 'session' to appear in rendered HTML for my_server" - ) - - -@pytest.mark.integration -def test_config_hidden_deps(tmp_path: pathlib.Path) -> None: - """pytest_fixture_hidden_dependencies suppresses named deps from output HTML.""" - # my_client depends on my_server; hiding my_server should suppress it - result = _build_sphinx_app( - tmp_path, - confoverrides={ - "pytest_fixture_hidden_dependencies": frozenset( - {*sphinx_pytest_fixtures.PYTEST_HIDDEN, "my_server"}, - ), - }, - ) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - # With my_server hidden, it must not appear in "Depends on" for my_client - # (it may still appear as its own fixture entry — check the Depends section) - assert ( - "Depends on" not in index_html - or "my_server" - not in index_html.split( - "Depends on", - )[-1].split(" None: - """Pytest builtin deps in PYTEST_BUILTIN_LINKS render with an external URL.""" - # Add a synthetic fixture that depends on tmp_path (a builtin with external link) - src = FIXTURE_MOD_SOURCE + textwrap.dedent( - """\ - - @pytest.fixture - def needs_tmp(tmp_path: "pathlib.Path") -> str: - \"\"\"Uses tmp_path internally.\"\"\" - return str(tmp_path) - """, - ) - srcdir = tmp_path / "src" - outdir = tmp_path / "out" - doctreedir = tmp_path / ".doctrees" - srcdir.mkdir() - outdir.mkdir() - doctreedir.mkdir() - - (srcdir / "fixture_mod.py").write_text(src, encoding="utf-8") - (srcdir / "conf.py").write_text( - CONF_PY_TEMPLATE.format(srcdir=str(srcdir)), - encoding="utf-8", - ) - (srcdir / "index.rst").write_text( - "Fixtures\n========\n\n.. py:module:: fixture_mod\n\n" - ".. autofixture:: fixture_mod.needs_tmp\n", - encoding="utf-8", - ) - - assert "tmp_path" in sphinx_pytest_fixtures.PYTEST_BUILTIN_LINKS, ( - "tmp_path must be in PYTEST_BUILTIN_LINKS for this test to be meaningful" - ) - - from sphinx.application import Sphinx - - _purge_fixture_module() - sphinx_app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="html", - status=io.StringIO(), - warning=io.StringIO(), - freshenv=True, - ) - sphinx_app.build() - - index_html = (outdir / "index.html").read_text(encoding="utf-8") - # tmp_path dependency should be rendered with an external href - assert "tmp_path" in index_html, ( - "tmp_path dependency should appear in rendered HTML" - ) - - -@pytest.mark.integration -def test_kind_override_hook_option(tmp_path: pathlib.Path) -> None: - """Manual :kind: override_hook option appears in rendered HTML.""" - srcdir = tmp_path / "src" - outdir = tmp_path / "out" - doctreedir = tmp_path / ".doctrees" - srcdir.mkdir() - outdir.mkdir() - doctreedir.mkdir() - - (srcdir / "fixture_mod.py").write_text(FIXTURE_MOD_SOURCE, encoding="utf-8") - (srcdir / "conf.py").write_text( - CONF_PY_TEMPLATE.format(srcdir=str(srcdir)), - encoding="utf-8", - ) - (srcdir / "index.rst").write_text( - textwrap.dedent( - """\ - Fixtures - ======== - - .. py:module:: fixture_mod - - .. py:fixture:: home_user - :kind: override_hook - - Override the home username. - """, - ), - encoding="utf-8", - ) - - from sphinx.application import Sphinx - - _purge_fixture_module() - app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="html", - status=io.StringIO(), - warning=io.StringIO(), - freshenv=True, - ) - app.build() - - index_html = (outdir / "index.html").read_text(encoding="utf-8") - # Standard kinds (override_hook) are communicated via badge, not Kind field. - assert _CSS.OVERRIDE in index_html, ( - "Expected spf-override badge class when :kind: override_hook is set" - ) - - -@pytest.mark.integration -def test_usage_none_suppresses_snippet(tmp_path: pathlib.Path) -> None: - """:usage: none suppresses the auto-generated usage snippet.""" - index_rst = textwrap.dedent( - """\ - Test - ==== - - .. py:module:: fixture_mod - - .. py:fixture:: my_server - :usage: none - """, - ) - result = _build_sphinx_app( - tmp_path, - index_rst=index_rst, - confoverrides={"pytest_fixture_lint_level": "none"}, - ) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - # No usage snippet should be generated — no code-block with def test_example - assert "def test_example" not in index_html - assert "conftest.py" not in index_html - - -@pytest.mark.integration -def test_async_fixture_callout_renders(tmp_path: pathlib.Path) -> None: - """Async fixtures show the 'async fixture' callout note via autofixture::.""" - async_fixture_source = textwrap.dedent( - """\ - from __future__ import annotations - import pytest - - @pytest.fixture - async def async_resource() -> str: - \"\"\"An async fixture.\"\"\" - return "async_value" - """, - ) - index_rst = textwrap.dedent( - """\ - Test - ==== - - .. py:module:: fixture_mod - - .. autofixture:: fixture_mod.async_resource - """, - ) - result = _build_sphinx_app( - tmp_path, - fixture_source=async_fixture_source, - index_rst=index_rst, - confoverrides={"pytest_fixture_lint_level": "none"}, - ) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - # The async callout message should appear in the rendered HTML - assert "async fixture" in index_html.lower() - - -@pytest.mark.integration -def test_spf003_fires_for_yield_fixture_via_autodoc(tmp_path: pathlib.Path) -> None: - """SPF003 fires for yield fixtures with no Teardown docstring section via autodoc. - - The fixture intentionally has no "Teardown" section in its docstring so this - test remains stable after the teardown-extraction feature (commit C4) is added. - """ - yield_fixture_source = textwrap.dedent( - """\ - from __future__ import annotations - import typing as t - import pytest - - @pytest.fixture - def simple_yield() -> t.Generator[str, None, None]: - \"\"\"A yield fixture with no teardown documentation.\"\"\" - yield "value" - # teardown happens here but is not documented - """, - ) - index_rst = textwrap.dedent( - """\ - Test - ==== - - .. py:module:: fixture_mod - - .. autofixture:: fixture_mod.simple_yield - """, - ) - result = _build_sphinx_app( - tmp_path, - fixture_source=yield_fixture_source, - index_rst=index_rst, - confoverrides={"pytest_fixture_lint_level": "warning"}, - ) - # SPF003 fires: yield fixture with no teardown documentation - assert "no teardown documentation" in result.warnings - - -@pytest.mark.integration -def test_teardown_section_suppresses_spf003(tmp_path: pathlib.Path) -> None: - """A 'Teardown' docstring section suppresses SPF003 and shows teardown-summary.""" - teardown_fixture_source = textwrap.dedent( - """\ - from __future__ import annotations - import typing as t - import pytest - - @pytest.fixture - def documented_yield() -> t.Generator[str, None, None]: - \"\"\"A yield fixture with documented teardown. - - Teardown - -------- - Releases the resource after the test completes. - \"\"\" - yield "value" - """, - ) - index_rst = textwrap.dedent( - """\ - Test - ==== - - .. py:module:: fixture_mod - - .. autofixture:: fixture_mod.documented_yield - """, - ) - result = _build_sphinx_app( - tmp_path, - fixture_source=teardown_fixture_source, - index_rst=index_rst, - confoverrides={"pytest_fixture_lint_level": "warning"}, - ) - # SPF003 must NOT fire when there is a Teardown section - assert "no teardown documentation" not in result.warnings - # The teardown summary text must appear in the rendered HTML - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - assert "Releases the resource" in index_html - - -@pytest.mark.integration -def test_override_hook_snippet_shows_conftest(tmp_path: pathlib.Path) -> None: - """override_hook fixtures show a conftest.py snippet, not def test_example.""" - result = _build_sphinx_app(tmp_path) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - # home_user is classified as override_hook via explicit :kind: option — - # its usage snippet must show conftest.py, not test. - assert "conftest.py" in index_html, ( - "Expected conftest.py in override_hook fixture usage snippet (home_user)" - ) - - -@pytest.mark.integration -def test_function_scope_field_suppressed(tmp_path: pathlib.Path) -> None: - """Function-scope fixtures do not render a 'Scope:' metadata field.""" - result = _build_sphinx_app(tmp_path) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - # my_client is function-scope; "Scope" field should be absent from its entry. - # my_server is session-scope and WILL have "Scope" — check that function-scope - # entries between my_client headings do not contain "Scope: function". - # Simple check: "function" should not appear as a scope value anywhere. - assert "Scope: function" not in index_html, ( - "Function-scope fixture should not render 'Scope: function' field" - ) - - -@pytest.mark.integration -def test_badge_group_present_in_html(tmp_path: pathlib.Path) -> None: - """Every fixture signature contains a spf-badge-group span.""" - result = _build_sphinx_app(tmp_path) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - assert _CSS.BADGE_GROUP in index_html, ( - "Expected spf-badge-group to be present in rendered HTML" - ) - assert _CSS.BADGE_FIXTURE in index_html, ( - "Expected FIXTURE badge (spf-badge--fixture) to be present in rendered HTML" - ) - - -@pytest.mark.integration -def test_scope_badge_session_present(tmp_path: pathlib.Path) -> None: - """Session-scope fixtures have a scope badge with class spf-scope-session.""" - result = _build_sphinx_app(tmp_path) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - assert _CSS.scope("session") in index_html, ( - "Expected spf-scope-session class badge for my_server (scope=session)" - ) - - -@pytest.mark.integration -def test_no_scope_badge_for_function_scope(tmp_path: pathlib.Path) -> None: - """Function-scope fixtures do not have a scope badge in the HTML.""" - result = _build_sphinx_app(tmp_path) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - assert _CSS.scope("function") not in index_html, ( - "Function-scope fixtures should not render a scope badge" - ) - - -@pytest.mark.integration -def test_session_scope_lifecycle_note_present(tmp_path: pathlib.Path) -> None: - """Session-scope fixtures have a lifecycle callout note in HTML.""" - result = _build_sphinx_app(tmp_path) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - assert "once per test session" in index_html, ( - "Expected session-scope lifecycle note for my_server (scope=session)" - ) - - -@pytest.mark.integration -def test_no_build_warnings(tmp_path: pathlib.Path) -> None: - """A full build of the synthetic fixture module produces zero WARNING lines.""" - result = _build_sphinx_app( - tmp_path, - confoverrides={"pytest_fixture_lint_level": "none"}, - ) - warnings = result.warnings - # Strip ANSI escape codes before filtering - import re - - ansi_escape = re.compile(r"\x1b\[[0-9;]*m") - warning_lines = [ - line - for line in warnings.splitlines() - if "WARNING" in line - # Sphinx emits "already registered" warnings when multiple Sphinx apps - # run in the same process — these are internal Sphinx artefacts, not - # problems with our extension. - and "already registered" not in line - # Filter Sphinx theme warnings unrelated to fixture processing - and "alabaster" not in line - ] - # Strip ANSI codes for readability in failure output - warning_lines = [ansi_escape.sub("", line) for line in warning_lines] - assert not warning_lines, "Unexpected WARNING lines in build output:\n" + "\n".join( - warning_lines - ) - - -@pytest.mark.integration -def test_lint_level_error_fails_build(tmp_path: pathlib.Path) -> None: - """lint_level='error' causes the build to report failure on validation issues.""" - # auto_cleanup in FIXTURE_MOD_SOURCE has no return annotation → SPF002 fires. - result = _build_sphinx_app( - tmp_path, - confoverrides={"pytest_fixture_lint_level": "error"}, - ) - assert result.app.statuscode != 0, ( - "Expected non-zero statuscode with lint_level='error' " - "and fixtures that trigger validation warnings" - ) - - -@pytest.mark.integration -def test_factory_snippet_shows_instantiation(tmp_path: pathlib.Path) -> None: - """Factory fixtures are classified as factory and render a FACTORY badge.""" - result = _build_sphinx_app(tmp_path) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - # TestServer is a factory fixture — it must have the FACTORY badge. - assert _CSS.FACTORY in index_html, ( - "Expected spf-factory class badge for TestServer factory fixture" - ) - # Standard kinds (resource, factory, override_hook) are communicated via - # badges only — the Kind field is suppressed for badge-covered kinds. - assert "

factory

" not in index_html, ( - "Standard Kind field should be suppressed when badge covers it" - ) - - -@pytest.mark.integration -def test_autouse_note_present(tmp_path: pathlib.Path) -> None: - """Autouse fixtures show a 'No request needed' note instead of a test snippet.""" - result = _build_sphinx_app(tmp_path) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - assert "No request needed" in index_html, ( - "Expected 'No request needed' note for auto_cleanup (autouse=True)" - ) - - -@pytest.mark.integration -def test_name_alias_registered_in_domain(tmp_path: pathlib.Path) -> None: - """Fixtures with name= alias are registered under the alias, not internal name.""" - from sphinx.domains.python import PythonDomain - - result = _build_sphinx_app(tmp_path) - domain = t.cast("PythonDomain", result.app.env.get_domain("py")) - objects = domain.data["objects"] - fixture_keys = {k for k, v in objects.items() if v.objtype == "fixture"} - assert "fixture_mod.renamed_fixture" in fixture_keys, ( - f"Expected 'fixture_mod.renamed_fixture' in domain objects. " - f"Fixture keys: {fixture_keys}" - ) - assert "fixture_mod._internal_name" not in fixture_keys, ( - "Internal function name '_internal_name' should not appear in domain — " - "only the 'renamed_fixture' alias should be registered" - ) - - -# --------------------------------------------------------------------------- -# "Used by" and "Parametrized" metadata rendering (Commit 3) -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -def test_used_by_links_rendered(tmp_path: pathlib.Path) -> None: - """Fixtures with consumers show a "Used by" field with anchor links.""" - result = _build_sphinx_app(tmp_path) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - # my_server is used by my_client and yield_server — assert anchor markup, - # not just text presence (plain text would pass even if xref resolution failed) - assert "Used by" in index_html - assert '
None: - """Fixtures with no consumers do not show "Used by".""" - result = _build_sphinx_app(tmp_path) - store = result.app.env.domaindata.get("sphinx_pytest_fixtures", {}) - reverse_deps = store.get("reverse_deps", {}) - assert "fixture_mod.auto_cleanup" not in reverse_deps - - -@pytest.mark.integration -def test_parametrized_values_rendered(tmp_path: pathlib.Path) -> None: - """Parametrized fixtures show their parameter values.""" - extra_fixture = textwrap.dedent( - """\ - - @pytest.fixture(params=["bash", "zsh"]) - def shell(request) -> str: - \"\"\"Fixture parametrized over shell interpreters.\"\"\" - return request.param - """, - ) - result = _build_sphinx_app( - tmp_path, - fixture_source=FIXTURE_MOD_SOURCE + extra_fixture, - index_rst=INDEX_RST + "\n.. autofixture:: fixture_mod.shell\n", - ) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - assert "Parametrized" in index_html - assert "'bash'" in index_html - assert "'zsh'" in index_html - - -@pytest.mark.integration -def test_manual_fixture_params_renders_parametrized_field( - tmp_path: pathlib.Path, -) -> None: - """py:fixture:: :params: option renders a Parametrized field.""" - index_rst = textwrap.dedent( - """\ - Test - ==== - - .. py:module:: fixture_mod - - .. py:fixture:: shell - :params: 'bash', 'zsh' - """, - ) - result = _build_sphinx_app( - tmp_path, - index_rst=index_rst, - confoverrides={"pytest_fixture_lint_level": "none"}, - ) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - assert "Parametrized" in index_html - assert "'bash'" in index_html - assert "'zsh'" in index_html - - -# --------------------------------------------------------------------------- -# Short-name :fixture: xref resolution (Commit 3) -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -def test_short_name_fixture_xref_resolves(tmp_path: pathlib.Path) -> None: - """:fixture:`my_server` short-name reference resolves to a hyperlink.""" - index_with_xref = INDEX_RST + textwrap.dedent( - """\ - - Usage - ----- - - See :fixture:`my_server` for the server fixture. - """, - ) - result = _build_sphinx_app(tmp_path, index_rst=index_with_xref) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - # Must resolve to a real link — plain text or pending_xref would still - # contain the fixture name but would NOT produce an element. - assert ' None: - """Manual .. py:fixture:: directives register in the env store.""" - manual_rst = textwrap.dedent( - """\ - Test fixtures - ============= - - .. py:module:: fixture_mod - - .. autofixture:: fixture_mod.my_server - - .. py:fixture:: manual_helper - :scope: session - :depends: my_server - - A manually documented fixture. - """, - ) - result = _build_sphinx_app(tmp_path, index_rst=manual_rst) - store = result.app.env.domaindata.get("sphinx_pytest_fixtures", {}) - fixtures = store.get("fixtures", {}) - assert "fixture_mod.manual_helper" in fixtures - meta = fixtures["fixture_mod.manual_helper"] - assert meta.scope == "session" - assert len(meta.deps) == 1 - assert meta.deps[0].display_name == "my_server" - - -# --------------------------------------------------------------------------- -# CSS contract tests — verify extension HTML matches custom.css selectors -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -def test_css_contract_badge_classes(tmp_path: pathlib.Path) -> None: - """CSS class names used in custom.css are present in rendered HTML.""" - result = _build_sphinx_app(tmp_path) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - - # These classes are targeted by selectors in docs/_static/css/custom.css. - # If the extension changes its class names, CSS silently breaks. - css_classes = [ - _CSS.BADGE_GROUP, - _CSS.BADGE, - _CSS.BADGE_FIXTURE, - _CSS.BADGE_SCOPE, - _CSS.scope("session"), - _CSS.BADGE_KIND, - _CSS.FACTORY, - ] - for cls in css_classes: - assert cls in index_html, f"CSS class {cls!r} missing from rendered HTML" - - -@pytest.mark.integration -def test_badge_tabindex_in_html(tmp_path: pathlib.Path) -> None: - """Badges render with tabindex='0' for touch/keyboard accessibility.""" - result = _build_sphinx_app(tmp_path) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - assert 'tabindex="0"' in index_html, ( - "Expected tabindex='0' on badge elements for touch accessibility" - ) - - -# --------------------------------------------------------------------------- -# autofixture-index directive -# --------------------------------------------------------------------------- - - -def _extract_index_table(html: str) -> str: - """Extract the spf-fixture-index table fragment from rendered HTML. - - The built page contains both the index table and fixture cards. Both emit - badge HTML, so whole-page assertions get false positives from card badges. - Always extract the table fragment before asserting on Flags badge content. - - Parameters - ---------- - html : str - Full rendered HTML of the built index page. - - Returns - ------- - str - Substring from the opening ```` tag. - """ - marker = _CSS.FIXTURE_INDEX - pos = 0 - while True: - start = html.find("", start) - if marker in html[start : end_tag + 1]: - break - pos = start + 1 - close = html.find("", start) - assert close != -1, "Unclosed in HTML" - return html[start : close + len("
")] - - -@pytest.mark.integration -def test_autofixture_index_renders_table(tmp_path: pathlib.Path) -> None: - """autofixture-index directive produces a table with linked fixture names.""" - index_rst = textwrap.dedent( - """\ - Test fixtures - ============= - - .. py:module:: fixture_mod - - .. autofixture-index:: fixture_mod - - .. autofixture:: fixture_mod.my_server - - .. autofixture:: fixture_mod.my_client - - .. autofixture:: fixture_mod.TestServer - """, - ) - result = _build_sphinx_app(tmp_path, index_rst=index_rst) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - - # Table should have the spf-fixture-index class - assert _CSS.FIXTURE_INDEX in index_html - - # Fixture names should be cross-ref links (not plain text) - assert 'href="#fixture_mod.my_server"' in index_html or "my_server" in index_html - assert "TestServer" in index_html - - # Column headers: 4-column Flags layout (Scope/Kind columns removed) - assert ">Flags<" in index_html - assert ">Scope<" not in index_html - assert ">Kind<" not in index_html - # Scope and kind appear as badge CSS classes in the Flags column table fragment - table_html = _extract_index_table(index_html) - assert _CSS.scope("session") in table_html # my_server: session-scope badge - assert _CSS.FACTORY in table_html # TestServer: factory-kind badge - assert _CSS.BADGE_FIXTURE in table_html # FIXTURE badge shown in every table row - - -@pytest.mark.integration -def test_autofixture_index_description_renders_markup( - tmp_path: pathlib.Path, -) -> None: - """autofixture-index description column renders RST markup, not raw text.""" - index_rst = textwrap.dedent( - """\ - Test fixtures - ============= - - .. py:module:: fixture_mod - - .. autofixture-index:: fixture_mod - - .. autofixture:: fixture_mod.my_server - """, - ) - result = _build_sphinx_app(tmp_path, index_rst=index_rst) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - - # Description should NOT contain raw RST markup like :class: or `` - # (the my_server docstring is plain text so just verify no RST leaks) - assert _CSS.FIXTURE_INDEX in index_html - # The description "Return a fake server for testing." should appear - assert "fake server" in index_html - - -# --------------------------------------------------------------------------- -# Flags column badge regression tests -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -def test_autofixture_index_flags_session_scope(tmp_path: pathlib.Path) -> None: - """Flags column shows session-scope badge and FIXTURE badge.""" - index_rst = textwrap.dedent( - """\ - Test - ==== - - .. py:module:: fixture_mod - - .. autofixture-index:: fixture_mod - - .. autofixture:: fixture_mod.my_server - """, - ) - result = _build_sphinx_app(tmp_path, index_rst=index_rst) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - table_html = _extract_index_table(index_html) - assert _CSS.scope("session") in table_html - assert _CSS.BADGE_FIXTURE in table_html - - -@pytest.mark.integration -def test_autofixture_index_flags_factory_kind(tmp_path: pathlib.Path) -> None: - """Flags column shows factory-kind badge for a factory fixture.""" - index_rst = textwrap.dedent( - """\ - Test - ==== - - .. py:module:: fixture_mod - - .. autofixture-index:: fixture_mod - - .. autofixture:: fixture_mod.TestServer - """, - ) - result = _build_sphinx_app(tmp_path, index_rst=index_rst) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - table_html = _extract_index_table(index_html) - assert _CSS.FACTORY in table_html - - -@pytest.mark.integration -def test_autofixture_index_flags_autouse(tmp_path: pathlib.Path) -> None: - """Flags column shows autouse badge for autouse=True fixtures.""" - index_rst = textwrap.dedent( - """\ - Test - ==== - - .. py:module:: fixture_mod - - .. autofixture-index:: fixture_mod - - .. autofixture:: fixture_mod.auto_cleanup - """, - ) - result = _build_sphinx_app(tmp_path, index_rst=index_rst) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - table_html = _extract_index_table(index_html) - assert _CSS.AUTOUSE in table_html - - -@pytest.mark.integration -def test_autofixture_index_flags_empty_for_defaults(tmp_path: pathlib.Path) -> None: - """Flags cell shows only FIXTURE badge for a plain function-scope fixture. - - A default fixture (function scope, resource kind, not autouse) shows FIXTURE - and nothing else — no scope, kind, autouse, deprecated, or override badges. - """ - fixture_source = textwrap.dedent( - """\ - from __future__ import annotations - import pytest - - @pytest.fixture - def plain_fixture() -> str: - \"\"\"A plain function-scope resource fixture.\"\"\" - return "plain" - """, - ) - index_rst = textwrap.dedent( - """\ - Test - ==== - - .. py:module:: fixture_mod - - .. autofixture-index:: fixture_mod - - .. autofixture:: fixture_mod.plain_fixture - """, - ) - result = _build_sphinx_app( - tmp_path, - fixture_source=fixture_source, - index_rst=index_rst, - ) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - table_html = _extract_index_table(index_html) - # FIXTURE badge always present; no scope/kind/autouse badges for default fixture - assert _CSS.BADGE_FIXTURE in table_html - assert _CSS.BADGE_SCOPE not in table_html - assert _CSS.AUTOUSE not in table_html - assert _CSS.FACTORY not in table_html - assert _CSS.OVERRIDE not in table_html - - -@pytest.mark.integration -def test_autofixture_index_flags_deprecated(tmp_path: pathlib.Path) -> None: - """Flags column shows deprecated badge when :deprecated: RST option is set. - - FixtureMeta.deprecated is sourced from the RST directive :deprecated: option. - autofixture (autodoc) does not support :deprecated:; use py:fixture instead. - """ - index_rst = textwrap.dedent( - """\ - Test - ==== - - .. py:module:: fixture_mod - - .. autofixture-index:: fixture_mod - - .. py:fixture:: my_client - :deprecated: 1.5 - """, - ) - result = _build_sphinx_app(tmp_path, index_rst=index_rst) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - table_html = _extract_index_table(index_html) - assert _CSS.DEPRECATED in table_html - - -# --------------------------------------------------------------------------- -# autofixture-index :exclude: HTML rendering -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -def test_autofixture_index_exclude_hides_row(tmp_path: pathlib.Path) -> None: - """autofixture-index :exclude: removes the named fixture from the table HTML.""" - index_rst = textwrap.dedent( - """\ - Test - ==== - - .. py:module:: fixture_mod - - .. autofixture-index:: fixture_mod - :exclude: my_client, auto_cleanup - - .. autofixture:: fixture_mod.my_server - - .. autofixture:: fixture_mod.my_client - - .. autofixture:: fixture_mod.auto_cleanup - """, - ) - result = _build_sphinx_app( - tmp_path, - index_rst=index_rst, - confoverrides={"pytest_fixture_lint_level": "none"}, - ) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - table_html = _extract_index_table(index_html) - # Excluded fixtures must not appear in the rendered table - assert "my_client" not in table_html - assert "auto_cleanup" not in table_html - # Non-excluded fixtures must remain - assert "my_server" in table_html - - -# --------------------------------------------------------------------------- -# TYPE_CHECKING forward-reference regression test -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -def test_type_checking_return_type_resolves_in_store( - tmp_path: pathlib.Path, -) -> None: - """TYPE_CHECKING return type is qualified via AST so Sphinx can cross-ref. - - Regression test for commit bcd9e2fe: fixtures with return types behind - ``if TYPE_CHECKING:`` rendered as unlinked text because the bare string - annotation couldn't be resolved to a fully-qualified name. - """ - fixture_source = textwrap.dedent( - """\ - from __future__ import annotations - import typing as t - import pytest - - class Widget: - \"\"\"A widget.\"\"\" - - if t.TYPE_CHECKING: - from fixture_mod import Widget as WidgetType - - @pytest.fixture - def my_widget() -> "WidgetType": - \"\"\"Return a widget.\"\"\" - return Widget() - """, - ) - - index_rst = textwrap.dedent( - """\ - Test - ==== - - .. py:module:: fixture_mod - - .. autofixture-index:: fixture_mod - - .. autofixture:: fixture_mod.my_widget - """, - ) - - result = _build_sphinx_app( - tmp_path, - fixture_source=fixture_source, - index_rst=index_rst, - confoverrides={"pytest_fixture_lint_level": "none"}, - ) - - # The return type should be fully qualified via AST parsing, - # not left as bare "WidgetType". - store = result.app.env.domaindata.get("sphinx_pytest_fixtures", {}) - meta = store["fixtures"]["fixture_mod.my_widget"] - assert meta.return_display == "fixture_mod.Widget", ( - f"Expected qualified name but got {meta.return_display!r}" - ) - assert meta.return_xref_target == "fixture_mod.Widget" - - -# --------------------------------------------------------------------------- -# Teardown summary rendering -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -def test_teardown_summary_rendered_in_callout(tmp_path: pathlib.Path) -> None: - """teardown-summary option appends text to the yield-fixture callout note.""" - index_rst = textwrap.dedent( - """\ - Test - ==== - - .. py:module:: fixture_mod - - .. py:fixture:: yield_server - :module: fixture_mod - :teardown: - :teardown-summary: Shuts down the server and cleans socket files. - """, - ) - result = _build_sphinx_app( - tmp_path, - index_rst=index_rst, - confoverrides={"pytest_fixture_lint_level": "none"}, - ) - html = (result.outdir / "index.html").read_text(encoding="utf-8") - assert "Shuts down the server and cleans socket files" in html - - -# --------------------------------------------------------------------------- -# Deprecated replacement cross-reference rendering -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -def test_deprecated_replacement_renders_hyperlink(tmp_path: pathlib.Path) -> None: - """Deprecated replacement :fixture: role renders as a hyperlink, not raw text.""" - index_rst = textwrap.dedent( - """\ - Test - ==== - - .. py:module:: fixture_mod - - .. autofixture:: fixture_mod.my_server - - .. py:fixture:: old_server - :module: fixture_mod - :deprecated: 2.0 - :replacement: my_server - - An old server fixture. - """, - ) - result = _build_sphinx_app( - tmp_path, - index_rst=index_rst, - confoverrides={"pytest_fixture_lint_level": "none"}, - ) - html = (result.outdir / "index.html").read_text(encoding="utf-8") - # The replacement must render as a hyperlink, not literal :fixture: text - assert "Deprecated since version 2.0" in html - assert ":fixture:" not in html, ( - "Replacement fixture rendered as literal :fixture: text instead of a link" - ) - - -# --------------------------------------------------------------------------- -# AutofixturesDirective regression tests -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -def test_autofixtures_directive_default_order(tmp_path: pathlib.Path) -> None: - """autofixtures:: without options renders all fixtures from the module.""" - index_rst = textwrap.dedent( - """\ - Test fixtures - ============= - - .. py:module:: fixture_mod - - .. autofixtures:: fixture_mod - """, - ) - result = _build_sphinx_app( - tmp_path, - index_rst=index_rst, - confoverrides={"pytest_fixture_lint_level": "none"}, - ) - html = (result.outdir / "index.html").read_text(encoding="utf-8") - # All public fixtures should appear - assert "my_server" in html - assert "my_client" in html - assert "TestServer" in html - - -@pytest.mark.integration -def test_autofixtures_directive_alpha_order(tmp_path: pathlib.Path) -> None: - """autofixtures:: with :order: alpha builds successfully.""" - index_rst = textwrap.dedent( - """\ - Test fixtures - ============= - - .. py:module:: fixture_mod - - .. autofixtures:: fixture_mod - :order: alpha - """, - ) - result = _build_sphinx_app( - tmp_path, - index_rst=index_rst, - confoverrides={"pytest_fixture_lint_level": "none"}, - ) - html = (result.outdir / "index.html").read_text(encoding="utf-8") - assert "my_server" in html - assert "my_client" in html - - -@pytest.mark.integration -def test_autofixtures_directive_exclude(tmp_path: pathlib.Path) -> None: - """autofixtures:: with :exclude: omits named fixtures.""" - index_rst = textwrap.dedent( - """\ - Test fixtures - ============= - - .. py:module:: fixture_mod - - .. autofixtures:: fixture_mod - :exclude: my_client, auto_cleanup - """, - ) - result = _build_sphinx_app( - tmp_path, - index_rst=index_rst, - confoverrides={"pytest_fixture_lint_level": "none"}, - ) - store = result.app.env.domaindata.get("sphinx_pytest_fixtures", {}) - fixtures = store.get("fixtures", {}) - # Excluded fixtures should NOT be in the store - assert "fixture_mod.my_client" not in fixtures - assert "fixture_mod.auto_cleanup" not in fixtures - # Non-excluded fixtures should be present - assert "fixture_mod.my_server" in fixtures - - -@pytest.mark.integration -def test_autofixtures_directive_import_error(tmp_path: pathlib.Path) -> None: - """autofixtures:: with a nonexistent module completes without crash.""" - index_rst = textwrap.dedent( - """\ - Test fixtures - ============= - - .. autofixtures:: nonexistent_module_xyz_12345 - """, - ) - result = _build_sphinx_app( - tmp_path, - index_rst=index_rst, - confoverrides={"pytest_fixture_lint_level": "none"}, - ) - # Build should complete, and warnings should mention the module - assert "nonexistent_module_xyz_12345" in result.warnings - - -# --------------------------------------------------------------------------- -# Search pair index entries -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -def test_scope_pair_index_entry(tmp_path: pathlib.Path) -> None: - """Session-scoped fixtures get scope-qualified pair index entries.""" - result = _build_sphinx_app( - tmp_path, - confoverrides={"pytest_fixture_lint_level": "none"}, - ) - # Check that the Sphinx domain has index entries for session-scoped fixtures. - # my_server is session-scoped in FIXTURE_MOD_SOURCE. - store = result.app.env.domaindata.get("sphinx_pytest_fixtures", {}) - meta = store["fixtures"].get("fixture_mod.my_server") - assert meta is not None - assert meta.scope == "session" - # Verify the pair index entry structure in genindex.html: - # add_target_and_index emits ("pair", "session-scoped fixtures; my_server", ...) - genindex_html = (result.outdir / "genindex.html").read_text(encoding="utf-8") - assert "session-scoped fixtures" in genindex_html - assert "my_server" in genindex_html - # The pair entry links back to the fixture anchor in index.html - assert 'href="index.html#fixture_mod.my_server"' in genindex_html - - -# --------------------------------------------------------------------------- -# Parametrized values enumerated list rendering -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -def test_parametrized_more_than_three_renders_enumerated_list( - tmp_path: pathlib.Path, -) -> None: - """Parametrized fixtures with >3 values render as an enumerated list.""" - extra_fixture = textwrap.dedent( - """\ - - @pytest.fixture(params=["bash", "zsh", "fish", "nushell"]) - def shell(request) -> str: - \"\"\"Fixture parametrized over shell interpreters.\"\"\" - return request.param - """, - ) - result = _build_sphinx_app( - tmp_path, - fixture_source=FIXTURE_MOD_SOURCE + extra_fixture, - index_rst=INDEX_RST + "\n.. autofixture:: fixture_mod.shell\n", - confoverrides={"pytest_fixture_lint_level": "none"}, - ) - html = (result.outdir / "index.html").read_text(encoding="utf-8") - assert "Parametrized" in html - # >3 items should render as an enumerated (ordered) list - assert " enumerated list for >3 parametrized values" - assert "'bash'" in html - assert "'nushell'" in html - - -# --------------------------------------------------------------------------- -# Manual py:fixture directive option tests -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -def test_manual_fixture_deprecated_option(tmp_path: pathlib.Path) -> None: - """Manual py:fixture with :deprecated: renders callout and badge.""" - index_rst = textwrap.dedent( - """\ - Test - ==== - - .. py:module:: fixture_mod - - .. py:fixture:: old_thing - :deprecated: 1.5 - - An old fixture. - """, - ) - result = _build_sphinx_app( - tmp_path, - index_rst=index_rst, - confoverrides={"pytest_fixture_lint_level": "none"}, - ) - html = (result.outdir / "index.html").read_text(encoding="utf-8") - assert "Deprecated since version 1.5" in html - from sphinx_pytest_fixtures import _CSS - - assert _CSS.DEPRECATED in html - - -@pytest.mark.integration -def test_manual_fixture_async_option(tmp_path: pathlib.Path) -> None: - """Manual py:fixture with :async: renders async fixture callout.""" - index_rst = textwrap.dedent( - """\ - Test - ==== - - .. py:module:: fixture_mod - - .. py:fixture:: async_thing - :async: - - An async fixture. - """, - ) - result = _build_sphinx_app( - tmp_path, - index_rst=index_rst, - confoverrides={"pytest_fixture_lint_level": "none"}, - ) - html = (result.outdir / "index.html").read_text(encoding="utf-8") - assert "async fixture" in html.lower() - - -# --------------------------------------------------------------------------- -# Multi-document "Used by" cross-doc links -# --------------------------------------------------------------------------- - -CROSS_DOC_FIXTURE_SOURCE = textwrap.dedent( - """\ - from __future__ import annotations - import typing as t - import pytest - - class Server: - \"\"\"A fake server.\"\"\" - - @pytest.fixture(scope="session") - def cross_server() -> Server: - \"\"\"A session-scoped server fixture.\"\"\" - return Server() - - @pytest.fixture - def cross_client(cross_server: Server) -> str: - \"\"\"A client that depends on cross_server.\"\"\" - return f"client@{cross_server}" - """, -) - -CROSS_DOC_API_RST = textwrap.dedent( - """\ - API - === - - .. py:module:: fixture_mod - - .. autofixture:: fixture_mod.cross_server - """, -) - -CROSS_DOC_USAGE_RST = textwrap.dedent( - """\ - Usage - ===== - - .. autofixture:: fixture_mod.cross_client - """, -) - -CROSS_DOC_INDEX_RST = textwrap.dedent( - """\ - Test - ==== - - .. toctree:: - - api - usage - """, -) - - -@pytest.mark.integration -def test_cross_doc_used_by_link(tmp_path: pathlib.Path) -> None: - """Used-by links work across documents. - - Fixture defined on api.rst, consumer on usage.rst — the "Used by" field - must render a cross-document anchor link. - """ - from sphinx.application import Sphinx - - srcdir = tmp_path / "src" - srcdir.mkdir() - outdir = tmp_path / "out" - outdir.mkdir() - doctreedir = tmp_path / ".doctrees" - doctreedir.mkdir() - - (srcdir / "fixture_mod.py").write_text(CROSS_DOC_FIXTURE_SOURCE, encoding="utf-8") - conf = CONF_PY_TEMPLATE.format(srcdir=str(srcdir)) - (srcdir / "conf.py").write_text(conf, encoding="utf-8") - (srcdir / "index.rst").write_text(CROSS_DOC_INDEX_RST, encoding="utf-8") - (srcdir / "api.rst").write_text(CROSS_DOC_API_RST, encoding="utf-8") - (srcdir / "usage.rst").write_text(CROSS_DOC_USAGE_RST, encoding="utf-8") - - status_buf = io.StringIO() - warning_buf = io.StringIO() - - _purge_fixture_module() - app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="html", - confoverrides={"pytest_fixture_lint_level": "none"}, - status=status_buf, - warning=warning_buf, - freshenv=True, - ) - app.build() - - # cross_server is on api.html; its "Used by" should link to usage.html#cross_client - api_html = (outdir / "api.html").read_text(encoding="utf-8") - assert "Used by" in api_html - cross_client_href = 'href="usage.html#fixture_mod.cross_client"' - assert cross_client_href in api_html - - -# --------------------------------------------------------------------------- -# pytest_external_fixture_links config -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -def test_external_fixture_links_renders_url(tmp_path: pathlib.Path) -> None: - """pytest_external_fixture_links maps fixture dep names to external URLs.""" - index_rst = textwrap.dedent( - """\ - Test - ==== - - .. py:module:: fixture_mod - - .. py:fixture:: my_widget - :depends: special_dep - """, - ) - result = _build_sphinx_app( - tmp_path, - index_rst=index_rst, - confoverrides={ - "pytest_fixture_lint_level": "none", - "pytest_external_fixture_links": { - "special_dep": "https://example.com/fixtures/special_dep", - }, - }, - ) - index_html = (result.outdir / "index.html").read_text(encoding="utf-8") - # The external URL should appear in the rendered anchor tag - assert "https://example.com/fixtures/special_dep" in index_html - assert "special_dep" in index_html - - -# --------------------------------------------------------------------------- -# Text builder smoke test -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -def test_text_builder_does_not_crash(tmp_path: pathlib.Path) -> None: - """Extension does not crash when building with the text builder. - - badge nodes use nodes.abbreviation (portable), but the text builder path - was never tested. - """ - from sphinx.application import Sphinx - - srcdir = tmp_path / "src" - srcdir.mkdir() - outdir = tmp_path / "out" - outdir.mkdir() - doctreedir = tmp_path / "doctrees" - doctreedir.mkdir() - - (srcdir / "fixture_mod.py").write_text(FIXTURE_MOD_SOURCE, encoding="utf-8") - conf_py = CONF_PY_TEMPLATE.format(srcdir=str(srcdir)) - (srcdir / "conf.py").write_text(conf_py, encoding="utf-8") - (srcdir / "index.rst").write_text(INDEX_RST, encoding="utf-8") - - status_buf = io.StringIO() - warning_buf = io.StringIO() - - _purge_fixture_module() - app = Sphinx( - srcdir=str(srcdir), - confdir=str(srcdir), - outdir=str(outdir), - doctreedir=str(doctreedir), - buildername="text", - confoverrides={"pytest_fixture_lint_level": "none"}, - status=status_buf, - warning=warning_buf, - freshenv=True, - ) - # Should not raise — the text builder must handle badge nodes gracefully - app.build() - output_text = (outdir / "index.txt").read_text(encoding="utf-8") - # Basic sanity: fixture names appear in the text output - assert "my_server" in output_text From 7bc0f80ba94d0ce930a66231891d4af7bf4d7bd4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 3 Apr 2026 16:12:35 -0500 Subject: [PATCH 06/11] py(deps[dev]): Pin gp-sphinx packages to PyPI 0.0.1a0 why: Squash incremental git-source tracking into final pinned release. what: - Remove [tool.uv.sources] git overrides for gp-sphinx packages - Pin gp-sphinx==0.0.1a0 and sphinx-autodoc-pytest-fixtures==0.0.1a0 - Update uv.lock to resolve from PyPI --- pyproject.toml | 14 ++++---------- uv.lock | 32 ++++++++++++++++++++++++-------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b294ff2b5..6bebf09a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,8 +52,8 @@ Changes = "https://github.com/tmux-python/libtmux/blob/master/CHANGES" [dependency-groups] dev = [ # Docs (via gp-sphinx) - "gp-sphinx", - "sphinx-autodoc-pytest-fixtures", + "gp-sphinx==0.0.1a0", + "sphinx-autodoc-pytest-fixtures==0.0.1a0", "sphinx-autobuild", "types-docutils", # Testing @@ -74,8 +74,8 @@ dev = [ ] docs = [ - "gp-sphinx", - "sphinx-autodoc-pytest-fixtures", + "gp-sphinx==0.0.1a0", + "sphinx-autodoc-pytest-fixtures==0.0.1a0", "sphinx-autobuild", ] testing = [ @@ -104,12 +104,6 @@ libtmux = "libtmux.pytest_plugin" requires = ["hatchling"] build-backend = "hatchling.build" -[tool.uv.sources] -gp-sphinx = { git = "https://github.com/git-pull/gp-sphinx", branch = "init-3", subdirectory = "packages/gp-sphinx" } -sphinx-fonts = { git = "https://github.com/git-pull/gp-sphinx", branch = "init-3", subdirectory = "packages/sphinx-fonts" } -sphinx-gptheme = { git = "https://github.com/git-pull/gp-sphinx", branch = "init-3", subdirectory = "packages/sphinx-gptheme" } -sphinx-autodoc-pytest-fixtures = { git = "https://github.com/git-pull/gp-sphinx", branch = "init-3", subdirectory = "packages/sphinx-autodoc-pytest-fixtures" } - [tool.mypy] strict = true python_version = "3.10" diff --git a/uv.lock b/uv.lock index a9bb1be28..db6d474c7 100644 --- a/uv.lock +++ b/uv.lock @@ -394,7 +394,7 @@ wheels = [ [[package]] name = "gp-sphinx" version = "0.0.1a0" -source = { git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fgp-sphinx&branch=init-3#070ded624fdc16236c02d0d4aa8ebbdbd6f002d5" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, { name = "gp-libs" }, @@ -414,6 +414,10 @@ dependencies = [ { name = "sphinxext-opengraph" }, { name = "sphinxext-rediraffe" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/ea/57/7a8ea21c53c83e7c54b17610ed0c48e8db6254c2ff017c1e44ae4f7132ca/gp_sphinx-0.0.1a0.tar.gz", hash = "sha256:5cf583c06dffe6697b05a9a5f0593aa41cfe35fed8a1577324ccc87e0c0c92f7", size = 13989, upload-time = "2026-04-05T10:10:23.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/8e/5e0a0364be9c80e18bd07ec2bf43fd760c5938629035a356c172f1234daa/gp_sphinx-0.0.1a0-py3-none-any.whl", hash = "sha256:fb8310dd73ffb52827ed834f49d2e769ed3136359b54879aadd9d55ff7c6048d", size = 14399, upload-time = "2026-04-05T10:04:29.578Z" }, +] [[package]] name = "h11" @@ -610,7 +614,7 @@ dev = [ { name = "codecov" }, { name = "coverage" }, { name = "gp-libs" }, - { name = "gp-sphinx", git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fgp-sphinx&branch=init-3" }, + { name = "gp-sphinx", specifier = "==0.0.1a0" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -620,14 +624,14 @@ dev = [ { name = "pytest-xdist" }, { name = "ruff" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-pytest-fixtures", git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fsphinx-autodoc-pytest-fixtures&branch=init-3" }, + { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a0" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] docs = [ - { name = "gp-sphinx", git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fgp-sphinx&branch=init-3" }, + { name = "gp-sphinx", specifier = "==0.0.1a0" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-pytest-fixtures", git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fsphinx-autodoc-pytest-fixtures&branch=init-3" }, + { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a0" }, ] lint = [ { name = "mypy" }, @@ -1270,12 +1274,16 @@ wheels = [ [[package]] name = "sphinx-autodoc-pytest-fixtures" version = "0.0.1a0" -source = { git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fsphinx-autodoc-pytest-fixtures&branch=init-3#070ded624fdc16236c02d0d4aa8ebbdbd6f002d5" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/6b/fd/b03bcf916b27372a01a656600aa2c39558e0133b49b2633e39ae96013dc2/sphinx_autodoc_pytest_fixtures-0.0.1a0.tar.gz", hash = "sha256:ec9f54f15a1ca3306a29d315ede309692771fcd8e6f8b545d00c084f40ab6efb", size = 35119, upload-time = "2026-04-05T10:10:26.507Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/eb/38914b8887daf5947c89ceb6d0edfe48bbe1d85fa44101965caf850f67b2/sphinx_autodoc_pytest_fixtures-0.0.1a0-py3-none-any.whl", hash = "sha256:e33a60c8efbec0d10c0789a381539c39c8e59d2e0c6d6aaf9c257d7f5f48e8f9", size = 42956, upload-time = "2026-04-05T10:06:55.85Z" }, +] [[package]] name = "sphinx-autodoc-typehints" @@ -1368,19 +1376,27 @@ wheels = [ [[package]] name = "sphinx-fonts" version = "0.0.1a0" -source = { git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fsphinx-fonts&branch=init-3#070ded624fdc16236c02d0d4aa8ebbdbd6f002d5" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/c3/4f/be4fe35f90d0bc5090a8bd1367c53d063d5808e367e22274f16cc6978796/sphinx_fonts-0.0.1a0.tar.gz", hash = "sha256:9ca77ba151fa27963e90f899d92b1e43680e223efa3acdd3c532d5e4f0b29eed", size = 5628, upload-time = "2026-04-05T10:10:28.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/68/c8793bc5a08aee5644aed1ac0eb1ef2368cc61e31d4c1d6fd6cc52192a15/sphinx_fonts-0.0.1a0-py3-none-any.whl", hash = "sha256:aae888b35cc901ad2947c3d171a0bf02b724bc78d2677827673113c8c73e11fd", size = 4345, upload-time = "2026-04-05T10:09:11.134Z" }, +] [[package]] name = "sphinx-gptheme" version = "0.0.1a0" -source = { git = "https://github.com/git-pull/gp-sphinx?subdirectory=packages%2Fsphinx-gptheme&branch=init-3#070ded624fdc16236c02d0d4aa8ebbdbd6f002d5" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "furo" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/0c/7c/71908e74939fd4d33d83bc39d31398deae895218dd319f626f6a3e4a1068/sphinx_gptheme-0.0.1a0.tar.gz", hash = "sha256:06f222f557dbd0e3256494f145cdbc1bc971d665e9203db19bc9c105283132ac", size = 13697, upload-time = "2026-04-05T10:10:29.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/ec/7fe7909d31da9007232a77ac5750da9b9329921e938c3d73d409e4caa4ec/sphinx_gptheme-0.0.1a0-py3-none-any.whl", hash = "sha256:da0e6bb047b01c93a7df2f81be693e46b0709a1960b250991597648f7b320dfa", size = 14690, upload-time = "2026-04-05T10:10:21.577Z" }, +] [[package]] name = "sphinx-inline-tabs" From fe58d6db3be19f9681fe5e4b6456a44c9db1b64c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 05:39:10 -0500 Subject: [PATCH 07/11] =?UTF-8?q?fix(docs[conf]):=20Fix=20isort=20order=20?= =?UTF-8?q?=E2=80=94=20gp=5Fsphinx=20(third-party)=20before=20libtmux=20(f?= =?UTF-8?q?irst-party)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index dac64e2d7..46c8a8646 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -5,10 +5,10 @@ import pathlib import sys -import libtmux - from gp_sphinx.config import make_linkcode_resolve, merge_sphinx_config +import libtmux + # Get the project root dir, which is the parent dir of this cwd = pathlib.Path(__file__).parent project_root = cwd.parent From 51c0565d1a1c0f9ed69e9c0d19d93bd211bd59a4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 05:40:37 -0500 Subject: [PATCH 08/11] style(docs[conf]): Apply ruff format --- docs/conf.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 46c8a8646..09e1c28de 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,9 +35,7 @@ "python": ("https://docs.python.org/", None), "pytest": ("https://docs.pytest.org/en/stable/", None), }, - linkcode_resolve=make_linkcode_resolve( - libtmux, about["__github__"], src_dir="src" - ), + linkcode_resolve=make_linkcode_resolve(libtmux, about["__github__"], src_dir="src"), # Project-specific overrides theme_options={ "announcement": ( From 063c447eb438d57bad2371c1eacb27e23e3d24e0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 06:01:50 -0500 Subject: [PATCH 09/11] test(docs): Remove stale tests/docs/__init__.py why: tests/docs/ was a placeholder for upstream extension tests that were never committed. With sphinx-fonts and sphinx-autodoc-pytest-fixtures as standalone PyPI packages, there is nothing to test here. what: - Remove tests/docs/__init__.py --- tests/docs/__init__.py | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 tests/docs/__init__.py diff --git a/tests/docs/__init__.py b/tests/docs/__init__.py deleted file mode 100644 index b6723bfd0..000000000 --- a/tests/docs/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Tests for documentation extensions.""" - -from __future__ import annotations From 9b79065967e7cb1d1d1748250be48d51461dfaeb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 11:49:58 -0500 Subject: [PATCH 10/11] py(deps[dev]) Bump dev packages --- uv.lock | 61 +++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/uv.lock b/uv.lock index db6d474c7..28dc480e4 100644 --- a/uv.lock +++ b/uv.lock @@ -344,7 +344,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -394,7 +394,7 @@ wheels = [ [[package]] name = "gp-sphinx" version = "0.0.1a0" -source = { registry = "https://pypi.org/simple" } +source = { editable = "../gp-sphinx/packages/gp-sphinx" } dependencies = [ { name = "docutils" }, { name = "gp-libs" }, @@ -414,10 +414,25 @@ dependencies = [ { name = "sphinxext-opengraph" }, { name = "sphinxext-rediraffe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/57/7a8ea21c53c83e7c54b17610ed0c48e8db6254c2ff017c1e44ae4f7132ca/gp_sphinx-0.0.1a0.tar.gz", hash = "sha256:5cf583c06dffe6697b05a9a5f0593aa41cfe35fed8a1577324ccc87e0c0c92f7", size = 13989, upload-time = "2026-04-05T10:10:23.038Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/8e/5e0a0364be9c80e18bd07ec2bf43fd760c5938629035a356c172f1234daa/gp_sphinx-0.0.1a0-py3-none-any.whl", hash = "sha256:fb8310dd73ffb52827ed834f49d2e769ed3136359b54879aadd9d55ff7c6048d", size = 14399, upload-time = "2026-04-05T10:04:29.578Z" }, + +[package.metadata] +requires-dist = [ + { name = "docutils" }, + { name = "gp-libs" }, + { name = "linkify-it-py" }, + { name = "myst-parser" }, + { name = "sphinx", specifier = "<9" }, + { name = "sphinx-argparse-neo", marker = "extra == 'argparse'", editable = "../gp-sphinx/packages/sphinx-argparse-neo" }, + { name = "sphinx-autodoc-typehints" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-design" }, + { name = "sphinx-fonts", editable = "../gp-sphinx/packages/sphinx-fonts" }, + { name = "sphinx-gptheme", editable = "../gp-sphinx/packages/sphinx-gptheme" }, + { name = "sphinx-inline-tabs" }, + { name = "sphinxext-opengraph" }, + { name = "sphinxext-rediraffe" }, ] +provides-extras = ["argparse"] [[package]] name = "h11" @@ -614,7 +629,7 @@ dev = [ { name = "codecov" }, { name = "coverage" }, { name = "gp-libs" }, - { name = "gp-sphinx", specifier = "==0.0.1a0" }, + { name = "gp-sphinx", editable = "../gp-sphinx/packages/gp-sphinx" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -624,14 +639,14 @@ dev = [ { name = "pytest-xdist" }, { name = "ruff" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a0" }, + { name = "sphinx-autodoc-pytest-fixtures", editable = "../gp-sphinx/packages/sphinx-autodoc-pytest-fixtures" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] docs = [ - { name = "gp-sphinx", specifier = "==0.0.1a0" }, + { name = "gp-sphinx", editable = "../gp-sphinx/packages/gp-sphinx" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a0" }, + { name = "sphinx-autodoc-pytest-fixtures", editable = "../gp-sphinx/packages/sphinx-autodoc-pytest-fixtures" }, ] lint = [ { name = "mypy" }, @@ -1274,15 +1289,17 @@ wheels = [ [[package]] name = "sphinx-autodoc-pytest-fixtures" version = "0.0.1a0" -source = { registry = "https://pypi.org/simple" } +source = { editable = "../gp-sphinx/packages/sphinx-autodoc-pytest-fixtures" } dependencies = [ { name = "pytest" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6b/fd/b03bcf916b27372a01a656600aa2c39558e0133b49b2633e39ae96013dc2/sphinx_autodoc_pytest_fixtures-0.0.1a0.tar.gz", hash = "sha256:ec9f54f15a1ca3306a29d315ede309692771fcd8e6f8b545d00c084f40ab6efb", size = 35119, upload-time = "2026-04-05T10:10:26.507Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/eb/38914b8887daf5947c89ceb6d0edfe48bbe1d85fa44101965caf850f67b2/sphinx_autodoc_pytest_fixtures-0.0.1a0-py3-none-any.whl", hash = "sha256:e33a60c8efbec0d10c0789a381539c39c8e59d2e0c6d6aaf9c257d7f5f48e8f9", size = 42956, upload-time = "2026-04-05T10:06:55.85Z" }, + +[package.metadata] +requires-dist = [ + { name = "pytest" }, + { name = "sphinx" }, ] [[package]] @@ -1376,27 +1393,25 @@ wheels = [ [[package]] name = "sphinx-fonts" version = "0.0.1a0" -source = { registry = "https://pypi.org/simple" } +source = { editable = "../gp-sphinx/packages/sphinx-fonts" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/4f/be4fe35f90d0bc5090a8bd1367c53d063d5808e367e22274f16cc6978796/sphinx_fonts-0.0.1a0.tar.gz", hash = "sha256:9ca77ba151fa27963e90f899d92b1e43680e223efa3acdd3c532d5e4f0b29eed", size = 5628, upload-time = "2026-04-05T10:10:28.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/68/c8793bc5a08aee5644aed1ac0eb1ef2368cc61e31d4c1d6fd6cc52192a15/sphinx_fonts-0.0.1a0-py3-none-any.whl", hash = "sha256:aae888b35cc901ad2947c3d171a0bf02b724bc78d2677827673113c8c73e11fd", size = 4345, upload-time = "2026-04-05T10:09:11.134Z" }, -] + +[package.metadata] +requires-dist = [{ name = "sphinx" }] [[package]] name = "sphinx-gptheme" version = "0.0.1a0" -source = { registry = "https://pypi.org/simple" } +source = { editable = "../gp-sphinx/packages/sphinx-gptheme" } dependencies = [ { name = "furo" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/7c/71908e74939fd4d33d83bc39d31398deae895218dd319f626f6a3e4a1068/sphinx_gptheme-0.0.1a0.tar.gz", hash = "sha256:06f222f557dbd0e3256494f145cdbc1bc971d665e9203db19bc9c105283132ac", size = 13697, upload-time = "2026-04-05T10:10:29.63Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/ec/7fe7909d31da9007232a77ac5750da9b9329921e938c3d73d409e4caa4ec/sphinx_gptheme-0.0.1a0-py3-none-any.whl", hash = "sha256:da0e6bb047b01c93a7df2f81be693e46b0709a1960b250991597648f7b320dfa", size = 14690, upload-time = "2026-04-05T10:10:21.577Z" }, -] + +[package.metadata] +requires-dist = [{ name = "furo" }] [[package]] name = "sphinx-inline-tabs" From 974f7618f91e38d754ef0b8b7b464a61692f196a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 12:46:19 -0500 Subject: [PATCH 11/11] py(deps[dev]): Bump gp-sphinx packages to 0.0.1a1 why: The gp-sphinx docs stack moved to v0.0.1a1 and downstream branches need matching pins and locks. what: - bump gp-sphinx docs dependency pins from 0.0.1a0 to 0.0.1a1 - refresh uv.lock to resolve the published v0.0.1a1 packages --- pyproject.toml | 8 +++--- uv.lock | 69 ++++++++++++++++++++------------------------------ 2 files changed, 31 insertions(+), 46 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6bebf09a4..f320d2d47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,8 +52,8 @@ Changes = "https://github.com/tmux-python/libtmux/blob/master/CHANGES" [dependency-groups] dev = [ # Docs (via gp-sphinx) - "gp-sphinx==0.0.1a0", - "sphinx-autodoc-pytest-fixtures==0.0.1a0", + "gp-sphinx==0.0.1a1", + "sphinx-autodoc-pytest-fixtures==0.0.1a1", "sphinx-autobuild", "types-docutils", # Testing @@ -74,8 +74,8 @@ dev = [ ] docs = [ - "gp-sphinx==0.0.1a0", - "sphinx-autodoc-pytest-fixtures==0.0.1a0", + "gp-sphinx==0.0.1a1", + "sphinx-autodoc-pytest-fixtures==0.0.1a1", "sphinx-autobuild", ] testing = [ diff --git a/uv.lock b/uv.lock index 28dc480e4..fb765647e 100644 --- a/uv.lock +++ b/uv.lock @@ -344,7 +344,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -393,8 +393,8 @@ wheels = [ [[package]] name = "gp-sphinx" -version = "0.0.1a0" -source = { editable = "../gp-sphinx/packages/gp-sphinx" } +version = "0.0.1a1" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, { name = "gp-libs" }, @@ -414,25 +414,10 @@ dependencies = [ { name = "sphinxext-opengraph" }, { name = "sphinxext-rediraffe" }, ] - -[package.metadata] -requires-dist = [ - { name = "docutils" }, - { name = "gp-libs" }, - { name = "linkify-it-py" }, - { name = "myst-parser" }, - { name = "sphinx", specifier = "<9" }, - { name = "sphinx-argparse-neo", marker = "extra == 'argparse'", editable = "../gp-sphinx/packages/sphinx-argparse-neo" }, - { name = "sphinx-autodoc-typehints" }, - { name = "sphinx-copybutton" }, - { name = "sphinx-design" }, - { name = "sphinx-fonts", editable = "../gp-sphinx/packages/sphinx-fonts" }, - { name = "sphinx-gptheme", editable = "../gp-sphinx/packages/sphinx-gptheme" }, - { name = "sphinx-inline-tabs" }, - { name = "sphinxext-opengraph" }, - { name = "sphinxext-rediraffe" }, +sdist = { url = "https://files.pythonhosted.org/packages/23/89/aa7d03025bbcd036806a67299f04c1de302eda265b35046a1355240503da/gp_sphinx-0.0.1a1.tar.gz", hash = "sha256:70f99cdd2ef5f24aa160da4eb47f80933c8d69bce00383dc0eb60e8bd51663f5", size = 13991, upload-time = "2026-04-05T17:32:41.295Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/2a/21836581ec988b8c58cacac2bfb091bbb000b8fe682f62a2fa584674aa6b/gp_sphinx-0.0.1a1-py3-none-any.whl", hash = "sha256:6f0c73a1a13ba94bef7fb1c5368fe6e47dc4128ec948c27f08e834cdf41a2111", size = 14398, upload-time = "2026-04-05T17:32:31.292Z" }, ] -provides-extras = ["argparse"] [[package]] name = "h11" @@ -629,7 +614,7 @@ dev = [ { name = "codecov" }, { name = "coverage" }, { name = "gp-libs" }, - { name = "gp-sphinx", editable = "../gp-sphinx/packages/gp-sphinx" }, + { name = "gp-sphinx", specifier = "==0.0.1a1" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -639,14 +624,14 @@ dev = [ { name = "pytest-xdist" }, { name = "ruff" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-pytest-fixtures", editable = "../gp-sphinx/packages/sphinx-autodoc-pytest-fixtures" }, + { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a1" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] docs = [ - { name = "gp-sphinx", editable = "../gp-sphinx/packages/gp-sphinx" }, + { name = "gp-sphinx", specifier = "==0.0.1a1" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-pytest-fixtures", editable = "../gp-sphinx/packages/sphinx-autodoc-pytest-fixtures" }, + { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a1" }, ] lint = [ { name = "mypy" }, @@ -1288,18 +1273,16 @@ wheels = [ [[package]] name = "sphinx-autodoc-pytest-fixtures" -version = "0.0.1a0" -source = { editable = "../gp-sphinx/packages/sphinx-autodoc-pytest-fixtures" } +version = "0.0.1a1" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] - -[package.metadata] -requires-dist = [ - { name = "pytest" }, - { name = "sphinx" }, +sdist = { url = "https://files.pythonhosted.org/packages/ed/1f/d70118d2b81863a934a204ea889bf22bfe08a95ef57016fa8035a9e0a9ee/sphinx_autodoc_pytest_fixtures-0.0.1a1.tar.gz", hash = "sha256:a6424d1a56d243886b0d78eb12b743b2449d3b1af0d0687c20e70aae010a6f26", size = 35118, upload-time = "2026-04-05T17:32:44.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/36/7aa6d743db7f4dfa606c87637a4b521a64d833d9d6394ee72692db9ca03e/sphinx_autodoc_pytest_fixtures-0.0.1a1-py3-none-any.whl", hash = "sha256:02759731fe70ccbb5b357b4e2afd1ce0c63fb99be28237f9d125b484c1a37cc7", size = 42957, upload-time = "2026-04-05T17:32:35.88Z" }, ] [[package]] @@ -1392,26 +1375,28 @@ wheels = [ [[package]] name = "sphinx-fonts" -version = "0.0.1a0" -source = { editable = "../gp-sphinx/packages/sphinx-fonts" } +version = "0.0.1a1" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] - -[package.metadata] -requires-dist = [{ name = "sphinx" }] +sdist = { url = "https://files.pythonhosted.org/packages/27/17/c7bdfd74248812b5d7df452d65474817ba96d41ebd67862022938c914465/sphinx_fonts-0.0.1a1.tar.gz", hash = "sha256:2c4ae152636649d88151a1421293b7b147bab36d97ef7aa3e85ce52ce7984dad", size = 5628, upload-time = "2026-04-05T17:32:46.905Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/dd/595ac1e9f72c7bc9b19bc9cc2e5c3d429c4d20b9a344674d23b75269906f/sphinx_fonts-0.0.1a1-py3-none-any.whl", hash = "sha256:6b45590254b912fb1b19e08c1ab6c3ce42eb1e1d07333183005d1fd54bb92b6f", size = 4348, upload-time = "2026-04-05T17:32:38.579Z" }, +] [[package]] name = "sphinx-gptheme" -version = "0.0.1a0" -source = { editable = "../gp-sphinx/packages/sphinx-gptheme" } +version = "0.0.1a1" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "furo" }, ] - -[package.metadata] -requires-dist = [{ name = "furo" }] +sdist = { url = "https://files.pythonhosted.org/packages/b9/8d/2bbde808fcc5aadb2e9cdb4c5ae0713ad88f3f57bfbdcfc6f0a4eae82bb2/sphinx_gptheme-0.0.1a1.tar.gz", hash = "sha256:d4b64b6dd6f8c213300820e1300ba075c56428946f4a903d1258440c0a9094d5", size = 14566, upload-time = "2026-04-05T17:32:47.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/18/85b1d4550501d7f4a91d75a2ad39e6883e988e4217272e216e5a86b80a49/sphinx_gptheme-0.0.1a1-py3-none-any.whl", hash = "sha256:52a752136bda4641d001d8f32f59f3b492a631fe19cec116ba14c316351ba00d", size = 15624, upload-time = "2026-04-05T17:32:39.9Z" }, +] [[package]] name = "sphinx-inline-tabs"