From fe802e698a3f491d09a15717d3bdb4c159665ae2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 10:00:18 -0500 Subject: [PATCH 1/4] py(deps[dev]): Replace sphinx/furo stack with gp-sphinx==0.0.1a0 why: Consolidate docs dependencies into the gp-sphinx shared platform. what: - Remove sphinx, furo, sphinx-autodoc-typehints, sphinx-inline-tabs, sphinxext-opengraph, sphinx-copybutton, sphinxext-rediraffe, sphinx-design, myst-parser, linkify-it-py from dev and docs groups - Add gp-sphinx==0.0.1a0 - Update uv.lock --- pyproject.toml | 22 +---------- uv.lock | 105 +++++++++++++++++++++++++++---------------------- 2 files changed, 59 insertions(+), 68 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7a88197..2f8ad09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,18 +48,9 @@ Changes = "https://github.com/git-pull/gp-libs/blob/master/CHANGES" [dependency-groups] dev = [ # Docs - "sphinx<9", - "furo", + "gp-sphinx==0.0.1a0", "gp-libs", "sphinx-autobuild", - "sphinx-autodoc-typehints", - "sphinx-inline-tabs", - "sphinxext-opengraph", - "sphinx-copybutton", - "sphinxext-rediraffe", - "sphinx-design", - "myst-parser", - "linkify-it-py", # Testing "gp-libs", "pytest", @@ -78,18 +69,9 @@ dev = [ ] docs = [ - "sphinx<9", - "furo", + "gp-sphinx==0.0.1a0", "gp-libs", "sphinx-autobuild", - "sphinx-autodoc-typehints", - "sphinx-inline-tabs", - "sphinxext-opengraph", - "sphinx-copybutton", - "sphinxext-rediraffe", - "sphinx-design", - "myst-parser", - "linkify-it-py", ] testing = [ "gp-libs", diff --git a/uv.lock b/uv.lock index 0765cf4..dd93616 100644 --- a/uv.lock +++ b/uv.lock @@ -387,51 +387,25 @@ 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" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, { 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 = "types-docutils" }, { name = "typing-extensions" }, ] 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" }, ] lint = [ { name = "mypy" }, @@ -462,41 +436,23 @@ coverage = [ dev = [ { name = "codecov" }, { name = "coverage" }, - { name = "furo" }, { name = "gp-libs" }, - { name = "linkify-it-py" }, + { name = "gp-sphinx", specifier = "==0.0.1a0" }, { name = "mypy" }, - { name = "myst-parser" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, { 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 = "types-docutils" }, { name = "typing-extensions" }, ] docs = [ - { name = "furo" }, { name = "gp-libs" }, - { name = "linkify-it-py" }, - { name = "myst-parser" }, - { name = "sphinx", specifier = "<9" }, + { name = "gp-sphinx", specifier = "==0.0.1a0" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-typehints" }, - { name = "sphinx-copybutton" }, - { name = "sphinx-design" }, - { name = "sphinx-inline-tabs" }, - { name = "sphinxext-opengraph" }, - { name = "sphinxext-rediraffe" }, ] lint = [ { name = "mypy" }, @@ -512,6 +468,34 @@ testing = [ { name = "pytest-watcher" }, ] +[[package]] +name = "gp-sphinx" +version = "0.0.1a0" +source = { registry = "https://pypi.org/simple" } +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" }, +] +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" version = "0.16.0" @@ -1344,6 +1328,31 @@ 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 = { 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 = { 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" version = "2025.12.21.14" From d0bfb02bb24cc4d3c4b5f752f8c1ae397d03d9fb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 10:01:28 -0500 Subject: [PATCH 2/4] docs(chore): Remove bundled extensions and migrate conf.py to gp-sphinx why: sphinx_fonts, templates, static assets, and spa-nav.js are now provided by gp-sphinx packages. Upstream extension tests are covered by the PyPI packages. what: - Delete docs/_ext/sphinx_fonts.py - Delete docs/_static/css/custom.css, js/spa-nav.js - Delete docs/_templates/page.html, sidebar/brand.html, sidebar/projects.html - Delete tests/docs/ entirely - Rewrite docs/conf.py to use merge_sphinx_config() and make_linkcode_resolve() --- docs/_ext/sphinx_fonts.py | 153 -------- docs/_static/css/custom.css | 234 ----------- docs/_static/js/spa-nav.js | 254 ------------ docs/_templates/page.html | 76 ---- docs/_templates/sidebar/brand.html | 18 - docs/_templates/sidebar/projects.html | 84 ---- docs/conf.py | 277 +------------ tests/docs/__init__.py | 3 - tests/docs/_ext/__init__.py | 3 - tests/docs/_ext/conftest.py | 10 - tests/docs/_ext/test_sphinx_fonts.py | 545 -------------------------- 11 files changed, 21 insertions(+), 1636 deletions(-) delete mode 100644 docs/_ext/sphinx_fonts.py delete mode 100644 docs/_static/css/custom.css delete mode 100644 docs/_static/js/spa-nav.js delete mode 100644 docs/_templates/page.html delete mode 100644 docs/_templates/sidebar/brand.html delete mode 100644 docs/_templates/sidebar/projects.html delete mode 100644 tests/docs/__init__.py 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 diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py deleted file mode 100644 index e8d2a69..0000000 --- a/docs/_ext/sphinx_fonts.py +++ /dev/null @@ -1,153 +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, - ) - - -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"] - subset = font.get("subset", "latin") - 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, - } - ) - - 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] - subset = font.get("subset", "latin") - filename = f"{font_id}-{subset}-{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/_static/css/custom.css b/docs/_static/css/custom.css deleted file mode 100644 index 9032773..0000000 --- a/docs/_static/css/custom.css +++ /dev/null @@ -1,234 +0,0 @@ -.sidebar-tree p.indented-block { - padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0 - var(--sidebar-item-spacing-horizontal); - margin-bottom: 0; -} - -.sidebar-tree p.indented-block span.indent { - margin-left: var(--sidebar-item-spacing-horizontal); - display: block; -} - -.sidebar-tree p.indented-block .project-name { - font-size: var(--sidebar-item-font-size); - font-weight: bold; - margin-right: calc(var(--sidebar-item-spacing-horizontal) / 2.5); -} - -#sidebar-projects:not(.ready) { - visibility: hidden; -} - -.sidebar-tree .active { - font-weight: bold; -} - - -/* ── Global heading refinements ───────────────────────────── - * Biome-inspired scale: medium weight (500) throughout — size - * and spacing carry hierarchy, not boldness. H4-H6 add eyebrow - * treatment (uppercase, muted). `article` prefix overrides - * Furo's bare h1-h6 selectors. - * ────────────────────────────────────────────────────────── */ -article h1 { - font-size: 1.8em; - font-weight: 500; - margin-top: 1.5rem; - margin-bottom: 0.75rem; -} - -article h2 { - font-size: 1.6em; - font-weight: 500; - margin-top: 2.5rem; - margin-bottom: 0.5rem; -} - -article h3 { - font-size: 1.15em; - font-weight: 500; - margin-top: 1.5rem; - margin-bottom: 0.375rem; -} - -article h4 { - font-size: 0.85em; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-foreground-secondary); - margin-top: 1rem; - margin-bottom: 0.25rem; -} - -article h5 { - font-size: 0.8em; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-foreground-secondary); -} - -article h6 { - font-size: 0.75em; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-foreground-secondary); -} - -/* ── Changelog heading extras ─────────────────────────────── - * Vertical spacing separates consecutive version entries. - * Category headings (h3) are muted. Item headings (h4) are - * subtle. Targets #history section from CHANGES markdown. - * ────────────────────────────────────────────────────────── */ - -/* Spacing between consecutive version entries */ -#history > section + section { - margin-top: 2.5rem; -} - -/* Category headings — muted secondary color */ -#history h3 { - color: var(--color-foreground-secondary); - margin-top: 1.25rem; -} - -/* Item headings — subtle, same size as body */ -#history h4 { - font-size: 1em; - margin-top: 1rem; - text-transform: none; - letter-spacing: normal; - color: inherit; -} - -/* ── Right-panel TOC refinements ──────────────────────────── - * Adjust Furo's table-of-contents proportions for better - * readability. Inspired by Starlight defaults (Biome docs). - * Uses Furo CSS variable overrides where possible. - * ────────────────────────────────────────────────────────── */ - -/* TOC font sizes: override Furo defaults (75% → 87.5%) */ -:root { - --toc-font-size: var(--font-size--small); /* 87.5% = 14px */ - --toc-title-font-size: var(--font-size--small); /* 87.5% = 14px */ -} - -/* More generous line-height for wrapped TOC entries */ -.toc-tree { - line-height: 1.4; -} - -/* ── Flexible right-panel TOC (inner-panel padding) ───────── - * Furo hardcodes .toc-drawer to width: 15em (SASS, compiled). - * min-width: 18em overrides it; long TOC entries wrap inside - * the box instead of blowing past the viewport. - * - * Padding lives on .toc-sticky (the inner panel), not on - * .toc-drawer (the outer aside). This matches Biome/Starlight - * where the aside defines dimensions and an inner wrapper - * (.right-sidebar-panel) controls content insets. The - * scrollbar sits naturally between content and viewport edge. - * - * Content area gets flex: 1 to absorb extra space on wide - * screens. At ≤82em Furo collapses the TOC to position: fixed; - * override right offset so the drawer fully hides off-screen. - * ────────────────────────────────────────────────────────── */ -.toc-drawer { - min-width: 18em; - flex-shrink: 0; - padding-right: 0; -} - -.toc-sticky { - padding-right: 1.5em; -} - -.content { - width: auto; - max-width: 46em; - flex: 1 1 46em; - padding: 0 2em; -} - -@media (max-width: 82em) { - .toc-drawer { - right: -18em; - } -} - -/* ── Body typography refinements ──────────────────────────── - * Improve paragraph readability with wider line-height and - * sharper text rendering. Furo already sets font-smoothing. - * - * IBM Plex tracks slightly wide at default spacing; -0.01em - * tightens it to feel more natural (matches tony.sh/tony.nl). - * Kerning + ligatures polish AV/To pairs and fi/fl combos. - * ────────────────────────────────────────────────────────── */ -body { - text-rendering: optimizeLegibility; - font-kerning: normal; - font-variant-ligatures: common-ligatures; - letter-spacing: -0.01em; -} - -/* ── Code block text rendering ──────────────────────────── - * Monospace needs fixed-width columns: disable kerning, - * ligatures, and letter-spacing that body sets for prose. - * optimizeSpeed skips heuristics that can shift the grid. - * ────────────────────────────────────────────────────────── */ -pre, -code, -kbd, -samp { - text-rendering: optimizeSpeed; - font-kerning: none; - font-variant-ligatures: none; - letter-spacing: normal; -} - -article { - line-height: 1.6; -} - -/* ── Image layout shift prevention ──────────────────────── - * Reserve space for images before they load. Furo already - * sets max-width: 100%; height: auto on img. We add - * content-visibility and badge-specific height to prevent CLS. - * ────────────────────────────────────────────────────────── */ - - -img { - content-visibility: auto; -} - -/* Docutils emits :width:/:height: as inline CSS (style="width: Xpx; - * height: Ypx;") rather than HTML attributes. When Furo's - * max-width: 100% constrains width below the declared value, - * the fixed height causes distortion. height: auto + aspect-ratio - * lets the browser compute the correct height from the intrinsic - * ratio once loaded; before load, aspect-ratio reserves space - * at the intended proportion — preventing both CLS and distortion. */ -article img[loading="lazy"] { - height: auto !important; -} - -img[src*="shields.io"], -img[src*="badge.svg"], -img[src*="codecov.io"] { - height: 20px; - width: auto; - min-width: 60px; - border-radius: 3px; - background: var(--color-background-secondary); -} - -/* ── View Transitions (SPA navigation) ──────────────────── - * Crossfade between pages during SPA navigation. - * Browsers without View Transitions API get instant swap. - * ────────────────────────────────────────────────────────── */ -::view-transition-old(root), -::view-transition-new(root) { - animation-duration: 150ms; -} diff --git a/docs/_static/js/spa-nav.js b/docs/_static/js/spa-nav.js deleted file mode 100644 index cd99233..0000000 --- a/docs/_static/js/spa-nav.js +++ /dev/null @@ -1,254 +0,0 @@ -/** - * SPA-like navigation for Sphinx/Furo docs. - * - * Intercepts internal link clicks and swaps only the content that changes - * (article, sidebar nav tree, TOC drawer), preserving sidebar scroll - * position, theme state, and avoiding full-page reloads. - * - * Progressive enhancement: no-op when fetch/DOMParser/pushState unavailable. - */ -(function () { - "use strict"; - - if (!window.fetch || !window.DOMParser || !window.history?.pushState) return; - - // --- Theme toggle (replicates Furo's cycleThemeOnce) --- - - function cycleTheme() { - var current = localStorage.getItem("theme") || "auto"; - var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - var next; - if (current === "auto") next = prefersDark ? "light" : "dark"; - else if (current === "dark") next = prefersDark ? "auto" : "light"; - else next = prefersDark ? "dark" : "auto"; - document.body.dataset.theme = next; - localStorage.setItem("theme", next); - } - - // --- Copy button injection --- - - var copyBtnTemplate = null; - - function captureCopyIcon() { - var btn = document.querySelector(".copybtn"); - if (btn) copyBtnTemplate = btn.cloneNode(true); - } - - function addCopyButtons() { - if (!copyBtnTemplate) captureCopyIcon(); - if (!copyBtnTemplate) return; - var cells = document.querySelectorAll("div.highlight pre"); - cells.forEach(function (cell, i) { - cell.id = "codecell" + i; - var next = cell.nextElementSibling; - if (next && next.classList.contains("copybtn")) { - next.setAttribute("data-clipboard-target", "#codecell" + i); - } else { - var btn = copyBtnTemplate.cloneNode(true); - btn.setAttribute("data-clipboard-target", "#codecell" + i); - cell.insertAdjacentElement("afterend", btn); - } - }); - } - - // --- Minimal scrollspy --- - - var scrollCleanup = null; - - function initScrollSpy() { - if (scrollCleanup) scrollCleanup(); - scrollCleanup = null; - - var links = document.querySelectorAll(".toc-tree a"); - if (!links.length) return; - - var entries = []; - links.forEach(function (a) { - var id = (a.getAttribute("href") || "").split("#")[1]; - var el = id && document.getElementById(id); - var li = a.closest("li"); - if (el && li) entries.push({ el: el, li: li }); - }); - if (!entries.length) return; - - function update() { - var offset = - parseFloat(getComputedStyle(document.documentElement).fontSize) * 4; - var active = null; - for (var i = entries.length - 1; i >= 0; i--) { - if (entries[i].el.getBoundingClientRect().top <= offset) { - active = entries[i]; - break; - } - } - entries.forEach(function (e) { - e.li.classList.remove("scroll-current"); - }); - if (active) active.li.classList.add("scroll-current"); - } - - window.addEventListener("scroll", update, { passive: true }); - update(); - scrollCleanup = function () { - window.removeEventListener("scroll", update); - }; - } - - // --- Link interception --- - - function shouldIntercept(link, e) { - if (e.defaultPrevented || e.button !== 0) return false; - if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false; - if (link.origin !== location.origin) return false; - if (link.target && link.target !== "_self") return false; - if (link.hasAttribute("download")) return false; - - var path = link.pathname; - if (!path.endsWith(".html") && !path.endsWith("/")) return false; - - var base = path.split("/").pop() || ""; - if ( - base === "search.html" || - base === "genindex.html" || - base === "py-modindex.html" - ) - return false; - - if (link.closest("#sidebar-projects")) return false; - if (link.pathname === location.pathname && link.hash) return false; - - return true; - } - - // --- DOM swap --- - - function swap(doc) { - [".article-container", ".sidebar-tree", ".toc-drawer"].forEach( - function (sel) { - var fresh = doc.querySelector(sel); - var stale = document.querySelector(sel); - if (fresh && stale) stale.replaceWith(fresh); - }, - ); - var title = doc.querySelector("title"); - if (title) document.title = title.textContent || ""; - - // Brand links and logo images live outside swapped regions. - // Their relative hrefs/srcs go stale after cross-depth navigation. - // Copy the correct values from the fetched document. - [".sidebar-brand", ".header-center a"].forEach(function (sel) { - var fresh = doc.querySelector(sel); - if (!fresh) return; - document.querySelectorAll(sel).forEach(function (el) { - el.setAttribute("href", fresh.getAttribute("href")); - }); - }); - var freshLogos = doc.querySelectorAll(".sidebar-logo"); - var staleLogos = document.querySelectorAll(".sidebar-logo"); - freshLogos.forEach(function (fresh, i) { - if (staleLogos[i]) { - staleLogos[i].setAttribute("src", fresh.getAttribute("src")); - } - }); - } - - function reinit() { - addCopyButtons(); - initScrollSpy(); - var btn = document.querySelector(".content-icon-container .theme-toggle"); - if (btn) btn.addEventListener("click", cycleTheme); - } - - // --- Navigation --- - - var currentCtrl = null; - - async function navigate(url, isPop) { - if (currentCtrl) currentCtrl.abort(); - var ctrl = new AbortController(); - currentCtrl = ctrl; - - try { - var resp = await fetch(url, { signal: ctrl.signal }); - if (!resp.ok) throw new Error(resp.status); - - var html = await resp.text(); - var doc = new DOMParser().parseFromString(html, "text/html"); - - if (!doc.querySelector(".article-container")) - throw new Error("no article"); - - var applySwap = function () { - swap(doc); - - if (!isPop) history.pushState({ spa: true }, "", url); - - if (!isPop) { - var hash = new URL(url, location.href).hash; - if (hash) { - var el = document.querySelector(hash); - if (el) el.scrollIntoView(); - } else { - window.scrollTo(0, 0); - } - } - - reinit(); - }; - - if (document.startViewTransition) { - document.startViewTransition(applySwap); - } else { - applySwap(); - } - } catch (err) { - if (err.name === "AbortError") return; - window.location.href = url; - } finally { - if (currentCtrl === ctrl) currentCtrl = null; - } - } - - // --- Events --- - - document.addEventListener("click", function (e) { - var link = e.target.closest("a[href]"); - if (link && shouldIntercept(link, e)) { - e.preventDefault(); - navigate(link.href, false); - } - }); - - history.replaceState({ spa: true }, ""); - - window.addEventListener("popstate", function () { - navigate(location.href, true); - }); - - // --- Hover prefetch --- - - var prefetchTimer = null; - - document.addEventListener("mouseover", function (e) { - var link = e.target.closest("a[href]"); - if (!link || link.origin !== location.origin) return; - if (!link.pathname.endsWith(".html") && !link.pathname.endsWith("/")) - return; - - clearTimeout(prefetchTimer); - prefetchTimer = setTimeout(function () { - fetch(link.href, { priority: "low" }).catch(function () {}); - }, 65); - }); - - document.addEventListener("mouseout", function (e) { - if (e.target.closest("a[href]")) clearTimeout(prefetchTimer); - }); - - // --- Init --- - - // Copy buttons are injected by copybutton.js on DOMContentLoaded. - // This defer script runs before DOMContentLoaded, so our handler - // fires after copybutton's handler (registration order preserved). - document.addEventListener("DOMContentLoaded", captureCopyIcon); -})(); diff --git a/docs/_templates/page.html b/docs/_templates/page.html deleted file mode 100644 index 4d2bf9f..0000000 --- a/docs/_templates/page.html +++ /dev/null @@ -1,76 +0,0 @@ -{% extends "!page.html" %} -{%- block extrahead %} - {{ super() }} - {%- for href in font_preload_hrefs|default([]) %} - - {%- endfor %} - {%- if font_faces is defined and font_faces %} - - {%- endif %} - {%- if theme_show_meta_manifest_tag == true %} - - {% endif -%} - {%- if theme_show_meta_og_tags == true %} - - - - - - - - - - - - - - - - {% endif -%} - {%- if theme_show_meta_app_icon_tags == true %} - - - - - - - - - - - - - - - - - - - - {% endif -%} -{% endblock %} diff --git a/docs/_templates/sidebar/brand.html b/docs/_templates/sidebar/brand.html deleted file mode 100644 index 7fe241c..0000000 --- a/docs/_templates/sidebar/brand.html +++ /dev/null @@ -1,18 +0,0 @@ - diff --git a/docs/_templates/sidebar/projects.html b/docs/_templates/sidebar/projects.html deleted file mode 100644 index f70e6fe..0000000 --- a/docs/_templates/sidebar/projects.html +++ /dev/null @@ -1,84 +0,0 @@ - - diff --git a/docs/conf.py b/docs/conf.py index 06254ed..744fd53 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,277 +1,42 @@ -# flake8: noqa: E501 """Sphinx configuration for gp_libs.""" from __future__ import annotations -import contextlib -import inspect import pathlib import sys -import typing as t -from os.path import relpath -import gp_libs +from gp_sphinx.config import make_linkcode_resolve, merge_sphinx_config -if t.TYPE_CHECKING: - from sphinx.application import Sphinx +import gp_libs # Get the project root dir, which is the parent dir of this cwd = pathlib.Path(__file__).parent project_root = cwd.parent sys.path.insert(0, str(project_root)) -sys.path.insert(0, str(cwd / "_ext")) # package data about: dict[str, str] = {} with (project_root / "src" / "gp_libs.py").open() as fp: exec(fp.read(), about) -extensions = [ - "sphinx.ext.autodoc", - "sphinx_fonts", - "sphinx.ext.intersphinx", - "sphinx_autodoc_typehints", - "sphinx.ext.todo", - "sphinx.ext.napoleon", - "sphinx.ext.linkcode", - "sphinx_inline_tabs", - "sphinx_copybutton", - "sphinxext.opengraph", - "sphinxext.rediraffe", - "sphinx_design", - "myst_parser", - "linkify_issues", -] -myst_heading_anchors = 4 -myst_enable_extensions = [ - "colon_fence", - "substitution", - "replacements", - "strikethrough", - "linkify", -] - -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/img/icons/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/icons/logo.svg", - "dark_logo": "img/icons/logo-dark.svg", - "footer_icons": [ - { - "name": "GitHub", - "url": about["__github__"], - "html": """ - - - - """, - "class": "", - }, - ], - "source_repository": f"{about['__github__']}/", - "source_branch": "master", - "source_directory": "docs/", -} -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" -toc_object_entries_show_parents = "hide" -autodoc_default_options = { - "undoc-members": True, - "members": True, - "private-members": True, - "show-inheritance": True, - "member-order": "bysource", -} - -# sphinxext.opengraph -ogp_site_url = about["__docs__"] -ogp_image = "_static/img/icons/icon-192x192.png" -ogp_site_name = about["__title__"] - -# sphinx-copybutton -copybutton_prompt_text = ( - r">>> |\.\.\. |> |\$ |\# | In \[\d*\]: | {2,5}\.\.\.: | {5,8}: " -) -copybutton_prompt_is_regexp = True -copybutton_remove_prompts = True - -# sphinxext-rediraffe -rediraffe_redirects = "redirects.txt" -rediraffe_branch = "master~1" - -# 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"], - "subset": "latin", - }, - { - "family": "IBM Plex Mono", - "package": "@fontsource/ibm-plex-mono", - "version": "5.2.7", - "weights": [400], - "styles": ["normal", "italic"], - "subset": "latin", - }, -] - -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%", +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/icons/logo.svg", + dark_logo="img/icons/logo-dark.svg", + intersphinx_mapping={ + "py": ("https://docs.python.org/", None), + "pytest": ("https://docs.pytest.org/en/stable/", None), + "sphinx": ("https://www.sphinx-doc.org/en/master/", None), }, - { - "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 = { - "py": ("https://docs.python.org/", None), - "pytest": ("https://docs.pytest.org/en/stable/", None), - "sphinx": ("https://www.sphinx-doc.org/en/master/", 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(gp_libs.__file__).parent) - - if "dev" in about["__version__"]: - return "{}/blob/master/{}/{}{}".format( - about["__github__"], - "src", - fn, - linespec, - ) - return "{}/blob/v{}/{}/{}{}".format( - about["__github__"], - about["__version__"], - "src", - 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) + linkcode_resolve=make_linkcode_resolve(gp_libs, about["__github__"], src_dir=""), + html_favicon="_static/img/icons/favicon.ico", + html_extra_path=["manifest.json"], + rediraffe_redirects="redirects.txt", +) +globals().update(conf) diff --git a/tests/docs/__init__.py b/tests/docs/__init__.py deleted file mode 100644 index b6723bf..0000000 --- a/tests/docs/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Tests for documentation extensions.""" - -from __future__ import annotations diff --git a/tests/docs/_ext/__init__.py b/tests/docs/_ext/__init__.py deleted file mode 100644 index 5654848..0000000 --- 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 e7547fa..0000000 --- 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 22f546a..0000000 --- a/tests/docs/_ext/test_sphinx_fonts.py +++ /dev/null @@ -1,545 +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") - - -# --- _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_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 From 95866d39ea19cf8a5d654fa312473268e0a4bb7e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 10:01:52 -0500 Subject: [PATCH 3/4] chore(mypy): Remove unused ignore_missing_imports overrides why: sphinx_fonts and test_sphinx_fonts are no longer local modules. what: - Remove sphinx_fonts ignore_missing_imports override - Remove tests.docs._ext.test_sphinx_fonts disable_error_code override --- pyproject.toml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2f8ad09..820f279 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,13 +119,6 @@ files = [ "tests/", ] -[[tool.mypy.overrides]] -module = ["sphinx_fonts"] -ignore_missing_imports = true - -[[tool.mypy.overrides]] -module = ["tests.docs._ext.test_sphinx_fonts"] -disable_error_code = ["arg-type"] [tool.ruff] target-version = "py310" From 30113c09663fdead3758aead1fa9fc91b9a4d52a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 12:46:19 -0500 Subject: [PATCH 4/4] 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 | 4 ++-- uv.lock | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 820f279..a515b4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ Changes = "https://github.com/git-pull/gp-libs/blob/master/CHANGES" [dependency-groups] dev = [ # Docs - "gp-sphinx==0.0.1a0", + "gp-sphinx==0.0.1a1", "gp-libs", "sphinx-autobuild", # Testing @@ -69,7 +69,7 @@ dev = [ ] docs = [ - "gp-sphinx==0.0.1a0", + "gp-sphinx==0.0.1a1", "gp-libs", "sphinx-autobuild", ] diff --git a/uv.lock b/uv.lock index dd93616..97e7283 100644 --- a/uv.lock +++ b/uv.lock @@ -437,7 +437,7 @@ dev = [ { name = "codecov" }, { name = "coverage" }, { name = "gp-libs" }, - { name = "gp-sphinx", specifier = "==0.0.1a0" }, + { name = "gp-sphinx", specifier = "==0.0.1a1" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -451,7 +451,7 @@ dev = [ ] docs = [ { name = "gp-libs" }, - { name = "gp-sphinx", specifier = "==0.0.1a0" }, + { name = "gp-sphinx", specifier = "==0.0.1a1" }, { name = "sphinx-autobuild" }, ] lint = [ @@ -470,7 +470,7 @@ testing = [ [[package]] name = "gp-sphinx" -version = "0.0.1a0" +version = "0.0.1a1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, @@ -491,9 +491,9 @@ 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" } +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/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" }, + { 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" }, ] [[package]] @@ -1330,27 +1330,27 @@ wheels = [ [[package]] name = "sphinx-fonts" -version = "0.0.1a0" +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'" }, ] -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" } +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/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" }, + { 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" +version = "0.0.1a1" 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" } +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/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" }, + { 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]]