diff --git a/.github/workflows/builddoc.yml b/.github/workflows/builddoc.yml index b5f82592360..7fa7b96f3f9 100644 --- a/.github/workflows/builddoc.yml +++ b/.github/workflows/builddoc.yml @@ -14,6 +14,7 @@ concurrency: env: FORCE_COLOR: "1" + UV_SYSTEM_PYTHON: "1" # make uv do global installs jobs: build: @@ -27,10 +28,14 @@ jobs: python-version: "3" - name: Install graphviz run: sudo apt-get install graphviz + - name: Install uv + run: > + curl --no-progress-meter --location --fail + --proto '=https' --tlsv1.2 + "https://astral.sh/uv/install.sh" + | sh - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install .[docs] + run: uv pip install .[docs] - name: Render the documentation run: > sphinx-build diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e09f17145b0..fd59f7ff119 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,6 +14,7 @@ concurrency: env: FORCE_COLOR: "1" + UV_SYSTEM_PYTHON: "1" # make uv do global installs jobs: # If you update any of these commands, don't forget to update the equivalent @@ -23,17 +24,13 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3" - - name: Install pip - run: python -m pip install --upgrade pip - - name: Install Ruff - run: | - ruff_version=$(awk -F'[="]' '/\[project\.optional-dependencies\]/ {p=1} /ruff/ {if (p) print $4}' pyproject.toml) - python -m pip install "ruff==${ruff_version}" + run: > + RUFF_VERSION=$(awk -F'[="]' '/\[project\.optional-dependencies\]/ {p=1} /ruff/ {if (p) print $4}' pyproject.toml) + curl --no-progress-meter --location --fail + --proto '=https' --tlsv1.2 + "https://astral.sh/ruff/${RUFF_VERSION}/install.sh" + | sh - name: Lint with Ruff run: ruff check . --output-format github @@ -50,10 +47,14 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3" + - name: Install uv + run: > + curl --no-progress-meter --location --fail + --proto '=https' --tlsv1.2 + "https://astral.sh/uv/install.sh" + | sh - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install --upgrade "flake8>=6.0" + run: uv pip install --upgrade "flake8>=6.0" - name: Lint with flake8 run: flake8 . @@ -66,10 +67,14 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3" + - name: Install uv + run: > + curl --no-progress-meter --location --fail + --proto '=https' --tlsv1.2 + "https://astral.sh/uv/install.sh" + | sh - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install ".[lint,test]" + run: uv pip install ".[lint,test]" - name: Type check with mypy run: mypy @@ -82,10 +87,14 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3" + - name: Install uv + run: > + curl --no-progress-meter --location --fail + --proto '=https' --tlsv1.2 + "https://astral.sh/uv/install.sh" + | sh - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install --upgrade sphinx-lint + run: uv pip install --upgrade sphinx-lint - name: Lint documentation with sphinx-lint run: make doclinter @@ -98,10 +107,14 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3" + - name: Install uv + run: > + curl --no-progress-meter --location --fail + --proto '=https' --tlsv1.2 + "https://astral.sh/uv/install.sh" + | sh - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install --upgrade twine build + run: uv pip install --upgrade twine build - name: Lint with twine run: | python -m build . diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b4804d01ad0..7d3f5608551 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -36,7 +36,6 @@ jobs: - "3.10" - "3.11" - "3.12" - - "3.13-dev" docutils: - "0.20" - "0.21" @@ -49,12 +48,43 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v5 - if: "!endsWith(matrix.python, '-dev')" with: python-version: ${{ matrix.python }} + - name: Check Python version + run: python --version --version + - name: Install graphviz + run: sudo apt-get install graphviz + - name: Install uv + run: > + curl --no-progress-meter --location --fail + --proto '=https' --tlsv1.2 + "https://astral.sh/uv/install.sh" + | sh + - name: Install dependencies + run: uv pip install .[test] + - name: Install Docutils ${{ matrix.docutils }} + run: uv pip install --upgrade "docutils~=${{ matrix.docutils }}.0" + - name: Test with pytest + run: python -m pytest -vv --durations 25 + env: + PYTHONWARNINGS: "error" # treat all warnings as errors + + deadsnakes: + runs-on: ubuntu-latest + name: Python ${{ matrix.python }} (Docutils ${{ matrix.docutils }}) + strategy: + fail-fast: false + matrix: + python: + - "3.13-dev" + docutils: + - "0.20" + - "0.21" + + steps: + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} (deadsnakes) uses: deadsnakes/action@v3.1.0 - if: "endsWith(matrix.python, '-dev')" with: python-version: ${{ matrix.python }} - name: Check Python version @@ -84,10 +114,14 @@ jobs: python-version: "3" - name: Check Python version run: python --version --version + - name: Install graphviz + run: choco install --no-progress graphviz + - name: Install uv + run: > + Invoke-WebRequest -Uri "https://astral.sh/uv/install.ps1" + | Invoke-Expression - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install .[test] + run: uv pip install .[test] - name: Test with pytest run: python -m pytest -vv --durations 25 env: @@ -100,9 +134,10 @@ jobs: steps: - name: Install epubcheck run: | + EPUBCHECK_VERSION="5.1.0" mkdir /tmp/epubcheck && cd /tmp/epubcheck - wget https://github.com/w3c/epubcheck/releases/download/v5.1.0/epubcheck-5.1.0.zip - unzip epubcheck-5.1.0.zip + wget --no-verbose https://github.com/w3c/epubcheck/releases/download/v${EPUBCHECK_VERSION}/epubcheck-${EPUBCHECK_VERSION}.zip + unzip epubcheck-${EPUBCHECK_VERSION}.zip - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 @@ -112,12 +147,16 @@ jobs: run: python --version --version - name: Install graphviz run: sudo apt-get install graphviz + - name: Install uv + run: > + curl --no-progress-meter --location --fail + --proto '=https' --tlsv1.2 + "https://astral.sh/uv/install.sh" + | sh - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install .[test] + run: uv pip install .[test] - name: Install Docutils' HEAD - run: python -m pip install git+https://repo.or.cz/docutils.git\#subdirectory=docutils + run: uv pip install "docutils @ git+https://repo.or.cz/docutils.git#subdirectory=docutils" - name: Test with pytest run: python -m pytest -vv env: @@ -140,13 +179,10 @@ jobs: - name: Install graphviz run: sudo apt-get install graphviz - name: Install uv - run: > - curl - --location - --fail - --proto '=https' --tlsv1.2 - --silent --show-error - https://astral.sh/uv/install.sh + run: > + curl --no-progress-meter --location --fail + --proto '=https' --tlsv1.2 + "https://astral.sh/uv/install.sh" | sh - name: Install dependencies run: | @@ -171,10 +207,14 @@ jobs: python-version: "3" - name: Check Python version run: python --version --version + - name: Install uv + run: > + curl --no-progress-meter --location --fail + --proto '=https' --tlsv1.2 + "https://astral.sh/uv/install.sh" + | sh - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install .[test] + run: uv pip install .[test] - name: Test with pytest run: python -m pytest -vv --durations 25 env: @@ -196,10 +236,14 @@ jobs: run: python --version --version - name: Install graphviz run: sudo apt-get install graphviz + - name: Install uv + run: > + curl --no-progress-meter --location --fail + --proto '=https' --tlsv1.2 + "https://astral.sh/uv/install.sh" + | sh - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install .[test] pytest-cov + run: uv pip install .[test] pytest-cov - name: Test with pytest run: python -m pytest -vv --cov . --cov-append --cov-config pyproject.toml env: diff --git a/CHANGES.rst b/CHANGES.rst index b7d1a0b6e09..08e2656826e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -57,6 +57,11 @@ Bugs fixed * #12744: Classes injected by a custom interpreted text role now give rise to nested ``\DUrole``'s, rather than a single one with comma separated classes. Patch by Jean-François B. +* #11970, #12551: singlehtml builder: make target URIs to be same-document + references in the sense of :rfc:`RFC 3986, §4.4 <3986#section-4.4>`, + e.g., ``index.html#foo`` becomes ``#foo``. + (note: continuation of a partial fix added in Sphinx 7.3.0) + Patch by James Addison (with reference to prior work by Eric Norige) Testing ------- diff --git a/sphinx/builders/_epub_base.py b/sphinx/builders/_epub_base.py index 444e9812ddd..15c4bd80b52 100644 --- a/sphinx/builders/_epub_base.py +++ b/sphinx/builders/_epub_base.py @@ -15,7 +15,8 @@ from docutils.utils import smartquotes from sphinx import addnodes -from sphinx.builders.html import BuildInfo, StandaloneHTMLBuilder +from sphinx.builders.html import StandaloneHTMLBuilder +from sphinx.builders.html._build_info import BuildInfo from sphinx.locale import __ from sphinx.util import logging from sphinx.util.display import status_iterator diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index 2fa5f360a95..e6453bea7c3 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -3,17 +3,16 @@ from __future__ import annotations import contextlib -import hashlib import html import os import posixpath import re +import shutil import sys -import types import warnings from os import path from pathlib import Path -from typing import IO, TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any from urllib.parse import quote import docutils.readers.doctree @@ -31,6 +30,7 @@ _file_checksum, _JavaScript, ) +from sphinx.builders.html._build_info import BuildInfo from sphinx.config import ENUM, Config from sphinx.deprecation import _deprecation_warning from sphinx.domains import Domain, Index, IndexEntry @@ -63,16 +63,14 @@ from sphinx.writers.html5 import HTML5Translator if TYPE_CHECKING: - from collections.abc import Iterable, Iterator, Set + from collections.abc import Iterable, Iterator from typing import TypeAlias from docutils.nodes import Node from docutils.readers import Reader from sphinx.application import Sphinx - from sphinx.config import _ConfigRebuild from sphinx.environment import BuildEnvironment - from sphinx.util.tags import Tags from sphinx.util.typing import ExtensionMetadata #: the filename for the inventory of objects @@ -93,23 +91,6 @@ ] -def _stable_hash(obj: Any) -> str: - """Return a stable hash for a Python data structure. - - We can't just use the md5 of str(obj) as the order of collections - may be random. - """ - if isinstance(obj, dict): - obj = sorted(map(_stable_hash, obj.items())) - if isinstance(obj, list | tuple | set | frozenset): - obj = sorted(map(_stable_hash, obj)) - elif isinstance(obj, type | types.FunctionType): - # The default repr() of functions includes the ID, which is not ideal. - # We use the fully qualified name instead. - obj = f'{obj.__module__}.{obj.__qualname__}' - return hashlib.md5(str(obj).encode(), usedforsecurity=False).hexdigest() - - def convert_locale_to_language_tag(locale: str | None) -> str | None: """Convert a locale string to a language tag (ex. en_US -> en-US). @@ -121,57 +102,6 @@ def convert_locale_to_language_tag(locale: str | None) -> str | None: return None -class BuildInfo: - """buildinfo file manipulator. - - HTMLBuilder and its family are storing their own envdata to ``.buildinfo``. - This class is a manipulator for the file. - """ - - @classmethod - def load(cls: type[BuildInfo], f: IO[str]) -> BuildInfo: - try: - lines = f.readlines() - assert lines[0].rstrip() == '# Sphinx build info version 1' - assert lines[2].startswith('config: ') - assert lines[3].startswith('tags: ') - - build_info = BuildInfo() - build_info.config_hash = lines[2].split()[1].strip() - build_info.tags_hash = lines[3].split()[1].strip() - return build_info - except Exception as exc: - raise ValueError(__('build info file is broken: %r') % exc) from exc - - def __init__( - self, - config: Config | None = None, - tags: Tags | None = None, - config_categories: Set[_ConfigRebuild] = frozenset(), - ) -> None: - self.config_hash = '' - self.tags_hash = '' - - if config: - values = {c.name: c.value for c in config.filter(config_categories)} - self.config_hash = _stable_hash(values) - - if tags: - self.tags_hash = _stable_hash(sorted(tags)) - - def __eq__(self, other: BuildInfo) -> bool: # type: ignore[override] - return (self.config_hash == other.config_hash and - self.tags_hash == other.tags_hash) - - def dump(self, f: IO[str]) -> None: - f.write('# Sphinx build info version 1\n' - '# This file hashes the configuration used when building these files.' - ' When it is not found, a full rebuild will be done.\n' - 'config: %s\n' - 'tags: %s\n' % - (self.config_hash, self.tags_hash)) - - class StandaloneHTMLBuilder(Builder): """ Builds standalone HTML docs. @@ -396,18 +326,28 @@ def math_renderer_name(self) -> str | None: def get_outdated_docs(self) -> Iterator[str]: build_info_fname = self.outdir / '.buildinfo' try: - with open(build_info_fname, encoding="utf-8") as fp: - buildinfo = BuildInfo.load(fp) - - if self.build_info != buildinfo: - logger.debug('[build target] did not match: build_info ') - yield from self.env.found_docs - return + build_info = BuildInfo.load(build_info_fname) except ValueError as exc: logger.warning(__('Failed to read build info file: %r'), exc) except OSError: # ignore errors on reading pass + else: + if self.build_info != build_info: + # log the mismatch and backup the old build info + build_info_backup = build_info_fname.with_name('.buildinfo.bak') + try: + shutil.move(build_info_fname, build_info_backup) + self.build_info.dump(build_info_fname) + except OSError: + pass # ignore errors + else: + # only log on success + msg = __('build_info mismatch, copying .buildinfo to .buildinfo.bak') + logger.info(bold(__('building [html]: ')) + msg) + + yield from self.env.found_docs + return if self.templates: template_mtime = int(self.templates.newest_template_mtime() * 10**6) @@ -943,8 +883,7 @@ def copy_extra_files(self) -> None: def write_buildinfo(self) -> None: try: - with open(path.join(self.outdir, '.buildinfo'), 'w', encoding="utf-8") as fp: - self.build_info.dump(fp) + self.build_info.dump(self.outdir / '.buildinfo') except OSError as exc: logger.warning(__('Failed to write build info file: %r'), exc) diff --git a/sphinx/builders/html/_build_info.py b/sphinx/builders/html/_build_info.py new file mode 100644 index 00000000000..5b364c0d9fc --- /dev/null +++ b/sphinx/builders/html/_build_info.py @@ -0,0 +1,94 @@ +"""Record metadata for the build process.""" + +from __future__ import annotations + +import hashlib +import types +from typing import TYPE_CHECKING + +from sphinx.locale import __ + +if TYPE_CHECKING: + from collections.abc import Set + from pathlib import Path + from typing import Any + + from sphinx.config import Config, _ConfigRebuild + from sphinx.util.tags import Tags + + +class BuildInfo: + """buildinfo file manipulator. + + HTMLBuilder and its family are storing their own envdata to ``.buildinfo``. + This class is a manipulator for the file. + """ + + @classmethod + def load(cls: type[BuildInfo], filename: Path, /) -> BuildInfo: + content = filename.read_text(encoding="utf-8") + lines = content.splitlines() + + version = lines[0].rstrip() + if version != '# Sphinx build info version 1': + msg = __('failed to read broken build info file (unknown version)') + raise ValueError(msg) + + if not lines[2].startswith('config: '): + msg = __('failed to read broken build info file (missing config entry)') + raise ValueError(msg) + if not lines[3].startswith('tags: '): + msg = __('failed to read broken build info file (missing tags entry)') + raise ValueError(msg) + + build_info = BuildInfo() + build_info.config_hash = lines[2].removeprefix('config: ').strip() + build_info.tags_hash = lines[3].removeprefix('tags: ').strip() + return build_info + + def __init__( + self, + config: Config | None = None, + tags: Tags | None = None, + config_categories: Set[_ConfigRebuild] = frozenset(), + ) -> None: + self.config_hash = '' + self.tags_hash = '' + + if config: + values = {c.name: c.value for c in config.filter(config_categories)} + self.config_hash = _stable_hash(values) + + if tags: + self.tags_hash = _stable_hash(sorted(tags)) + + def __eq__(self, other: BuildInfo) -> bool: # type: ignore[override] + return (self.config_hash == other.config_hash and + self.tags_hash == other.tags_hash) + + def dump(self, filename: Path, /) -> None: + build_info = ( + '# Sphinx build info version 1\n' + '# This file records the configuration used when building these files. ' + 'When it is not found, a full rebuild will be done.\n' + f'config: {self.config_hash}\n' + f'tags: {self.tags_hash}\n' + ) + filename.write_text(build_info, encoding="utf-8") + + +def _stable_hash(obj: Any) -> str: + """Return a stable hash for a Python data structure. + + We can't just use the md5 of str(obj) as the order of collections + may be random. + """ + if isinstance(obj, dict): + obj = sorted(map(_stable_hash, obj.items())) + if isinstance(obj, list | tuple | set | frozenset): + obj = sorted(map(_stable_hash, obj)) + elif isinstance(obj, type | types.FunctionType): + # The default repr() of functions includes the ID, which is not ideal. + # We use the fully qualified name instead. + obj = f'{obj.__module__}.{obj.__qualname__}' + return hashlib.md5(str(obj).encode(), usedforsecurity=False).hexdigest() diff --git a/sphinx/builders/singlehtml.py b/sphinx/builders/singlehtml.py index d222184b52f..4d58d554cf6 100644 --- a/sphinx/builders/singlehtml.py +++ b/sphinx/builders/singlehtml.py @@ -52,7 +52,6 @@ def get_relative_uri(self, from_: str, to: str, typ: str | None = None) -> str: def fix_refuris(self, tree: Node) -> None: # fix refuris with double anchor - fname = self.config.root_doc + self.out_suffix for refnode in tree.findall(nodes.reference): if 'refuri' not in refnode: continue @@ -62,7 +61,8 @@ def fix_refuris(self, tree: Node) -> None: continue hashindex = refuri.find('#', hashindex + 1) if hashindex >= 0: - refnode['refuri'] = fname + refuri[hashindex:] + # all references are on the same page... + refnode['refuri'] = refuri[hashindex:] def _get_local_toctree(self, docname: str, collapse: bool = True, **kwargs: Any) -> str: if isinstance(includehidden := kwargs.get('includehidden'), str): diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py index 637fe185f7f..67087e61829 100644 --- a/sphinx/environment/__init__.py +++ b/sphinx/environment/__init__.py @@ -248,7 +248,7 @@ def __init__(self, app: Sphinx) -> None: self.dlfiles: DownloadFiles = DownloadFiles() # the original URI for images - self.original_image_uri: dict[str, str] = {} + self.original_image_uri: dict[_StrPath, str] = {} # temporary data storage while reading a document self.temp_data: dict[str, Any] = {} diff --git a/sphinx/environment/adapters/asset.py b/sphinx/environment/adapters/asset.py index 57fdc91f14d..dc0cf766957 100644 --- a/sphinx/environment/adapters/asset.py +++ b/sphinx/environment/adapters/asset.py @@ -1,6 +1,7 @@ """Assets adapter for sphinx.environment.""" from sphinx.environment import BuildEnvironment +from sphinx.util._pathlib import _StrPath class ImageAdapter: @@ -9,7 +10,7 @@ def __init__(self, env: BuildEnvironment) -> None: def get_original_image_uri(self, name: str) -> str: """Get the original image URI.""" - while name in self.env.original_image_uri: - name = self.env.original_image_uri[name] + while _StrPath(name) in self.env.original_image_uri: + name = self.env.original_image_uri[_StrPath(name)] return name diff --git a/sphinx/transforms/post_transforms/images.py b/sphinx/transforms/post_transforms/images.py index b679481e83d..05b07dd2232 100644 --- a/sphinx/transforms/post_transforms/images.py +++ b/sphinx/transforms/post_transforms/images.py @@ -6,6 +6,7 @@ import re from hashlib import sha1 from math import ceil +from pathlib import Path from typing import TYPE_CHECKING, Any from docutils import nodes @@ -13,6 +14,7 @@ from sphinx.locale import __ from sphinx.transforms import SphinxTransform from sphinx.util import logging, requests +from sphinx.util._pathlib import _StrPath from sphinx.util.http_date import epoch_to_rfc1123, rfc1123_to_epoch from sphinx.util.images import get_image_extension, guess_mimetype, parse_data_uri from sphinx.util.osutil import ensuredir @@ -65,50 +67,58 @@ def handle(self, node: nodes.image) -> None: basename = CRITICAL_PATH_CHAR_RE.sub("_", basename) uri_hash = sha1(node['uri'].encode(), usedforsecurity=False).hexdigest() - ensuredir(os.path.join(self.imagedir, uri_hash)) - path = os.path.join(self.imagedir, uri_hash, basename) - - headers = {} - if os.path.exists(path): - timestamp: float = ceil(os.stat(path).st_mtime) - headers['If-Modified-Since'] = epoch_to_rfc1123(timestamp) - - config = self.app.config - r = requests.get( - node['uri'], headers=headers, - _user_agent=config.user_agent, - _tls_info=(config.tls_verify, config.tls_cacerts), - ) - if r.status_code >= 400: - logger.warning(__('Could not fetch remote image: %s [%d]'), - node['uri'], r.status_code) - else: - self.app.env.original_image_uri[path] = node['uri'] - - if r.status_code == 200: - with open(path, 'wb') as f: - f.write(r.content) - - last_modified = r.headers.get('last-modified') - if last_modified: - timestamp = rfc1123_to_epoch(last_modified) - os.utime(path, (timestamp, timestamp)) - - mimetype = guess_mimetype(path, default='*') - if mimetype != '*' and os.path.splitext(basename)[1] == '': - # append a suffix if URI does not contain suffix - ext = get_image_extension(mimetype) - newpath = os.path.join(self.imagedir, uri_hash, basename + ext) - os.replace(path, newpath) - self.app.env.original_image_uri.pop(path) - self.app.env.original_image_uri[newpath] = node['uri'] - path = newpath - node['candidates'].pop('?') - node['candidates'][mimetype] = path - node['uri'] = path - self.app.env.images.add_file(self.env.docname, path) + path = Path(self.imagedir, uri_hash, basename) + path.parent.mkdir(parents=True, exist_ok=True) + self._download_image(node, path) + except Exception as exc: - logger.warning(__('Could not fetch remote image: %s [%s]'), node['uri'], exc) + msg = __('Could not fetch remote image: %s [%s]') + logger.warning(msg, node['uri'], exc) + + def _download_image(self, node: nodes.image, path: Path) -> None: + headers = {} + if path.exists(): + timestamp: float = ceil(path.stat().st_mtime) + headers['If-Modified-Since'] = epoch_to_rfc1123(timestamp) + + config = self.app.config + r = requests.get( + node['uri'], headers=headers, + _user_agent=config.user_agent, + _tls_info=(config.tls_verify, config.tls_cacerts), + ) + if r.status_code >= 400: + msg = __('Could not fetch remote image: %s [%d]') + logger.warning(msg, node['uri'], r.status_code) + else: + self.app.env.original_image_uri[_StrPath(path)] = node['uri'] + + if r.status_code == 200: + path.write_bytes(r.content) + if last_modified := r.headers.get('Last-Modified'): + timestamp = rfc1123_to_epoch(last_modified) + os.utime(path, (timestamp, timestamp)) + + self._process_image(node, path) + + def _process_image(self, node: nodes.image, path: Path) -> None: + str_path = _StrPath(path) + self.app.env.original_image_uri[str_path] = node['uri'] + + mimetype = guess_mimetype(path, default='*') + if mimetype != '*' and path.suffix == '': + # append a suffix if URI does not contain suffix + ext = get_image_extension(mimetype) or '' + with_ext = path.with_name(path.name + ext) + os.replace(path, with_ext) + self.app.env.original_image_uri.pop(str_path) + self.app.env.original_image_uri[_StrPath(with_ext)] = node['uri'] + path = with_ext + path_str = str(path) + node['candidates'].pop('?') + node['candidates'][mimetype] = path_str + node['uri'] = path_str + self.app.env.images.add_file(self.env.docname, path_str) class DataURIExtractor(BaseImageConverter): @@ -130,16 +140,17 @@ def handle(self, node: nodes.image) -> None: ensuredir(os.path.join(self.imagedir, 'embeded')) digest = sha1(image.data, usedforsecurity=False).hexdigest() - path = os.path.join(self.imagedir, 'embeded', digest + ext) + path = _StrPath(self.imagedir, 'embeded', digest + ext) self.app.env.original_image_uri[path] = node['uri'] with open(path, 'wb') as f: f.write(image.data) + path_str = str(path) node['candidates'].pop('?') - node['candidates'][image.mimetype] = path - node['uri'] = path - self.app.env.images.add_file(self.env.docname, path) + node['candidates'][image.mimetype] = path_str + node['uri'] = path_str + self.app.env.images.add_file(self.env.docname, path_str) def get_filename_for(filename: str, mimetype: str) -> str: @@ -258,7 +269,7 @@ def handle(self, node: nodes.image) -> None: node['candidates'][_to] = destpath node['uri'] = destpath - self.env.original_image_uri[destpath] = srcpath + self.env.original_image_uri[_StrPath(destpath)] = srcpath self.env.images.add_file(self.env.docname, destpath) def convert(self, _from: str, _to: str) -> bool: diff --git a/tests/conftest.py b/tests/conftest.py index 6e3b83b1397..a9ef8e7c92c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ import os import sys from pathlib import Path +from types import SimpleNamespace from typing import TYPE_CHECKING import docutils @@ -60,3 +61,20 @@ def _cleanup_docutils() -> Iterator[None]: sys.path[:] = saved_path _clean_up_global_state() + + +@pytest.fixture +def _http_teapot(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + """Short-circuit HTTP requests. + + Windows takes too long to fail on connections, hence this fixture. + """ + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/418 + response = SimpleNamespace(status_code=418) + + def _request(*args, **kwargs): + return response + + with monkeypatch.context() as m: + m.setattr('sphinx.util.requests._Session.request', _request) + yield diff --git a/tests/test_builders/test_build.py b/tests/test_builders/test_build.py index 5ccef02c943..6a5ed0b6d26 100644 --- a/tests/test_builders/test_build.py +++ b/tests/test_builders/test_build.py @@ -100,6 +100,7 @@ def test_numbered_circular_toctree(app): ) in warnings +@pytest.mark.usefixtures('_http_teapot') @pytest.mark.sphinx('dummy', testroot='images') def test_image_glob(app): app.build(force_all=True) diff --git a/tests/test_builders/test_build_epub.py b/tests/test_builders/test_build_epub.py index 263fefe8090..178b46ff0a2 100644 --- a/tests/test_builders/test_build_epub.py +++ b/tests/test_builders/test_build_epub.py @@ -473,6 +473,7 @@ def test_xml_name_pattern_check(): assert not _XML_NAME_PATTERN.match('1bfda21') +@pytest.mark.usefixtures('_http_teapot') @pytest.mark.sphinx('epub', testroot='images') def test_copy_images(app): app.build() diff --git a/tests/test_builders/test_build_html.py b/tests/test_builders/test_build_html.py index 93e770cca9c..ecaab1651a1 100644 --- a/tests/test_builders/test_build_html.py +++ b/tests/test_builders/test_build_html.py @@ -266,6 +266,7 @@ def test_html_inventory(app): ) +@pytest.mark.usefixtures('_http_teapot') @pytest.mark.sphinx( 'html', testroot='images', diff --git a/tests/test_builders/test_build_html_image.py b/tests/test_builders/test_build_html_image.py index 93777c76329..15b0be7aaea 100644 --- a/tests/test_builders/test_build_html_image.py +++ b/tests/test_builders/test_build_html_image.py @@ -5,6 +5,7 @@ import pytest +@pytest.mark.usefixtures('_http_teapot') @pytest.mark.sphinx('html', testroot='images') def test_html_remote_images(app): app.build(force_all=True) @@ -77,6 +78,7 @@ def test_html_scaled_image_link(app): ) +@pytest.mark.usefixtures('_http_teapot') @pytest.mark.sphinx('html', testroot='images') def test_copy_images(app): app.build() diff --git a/tests/test_builders/test_build_html_tocdepth.py b/tests/test_builders/test_build_html_tocdepth.py index dda049c47ff..7d6afc4c1c3 100644 --- a/tests/test_builders/test_build_html_tocdepth.py +++ b/tests/test_builders/test_build_html_tocdepth.py @@ -2,6 +2,7 @@ import pytest +from tests.test_builders.xpath_html_util import _intradocument_hyperlink_check from tests.test_builders.xpath_util import check_xpath @@ -78,6 +79,7 @@ def test_tocdepth(app, cached_etree_parse, fname, path, check, be_found): (".//li[@class='toctree-l3']/a", '1.2.1. Foo B1', True), (".//li[@class='toctree-l3']/a", '2.1.1. Bar A1', False), (".//li[@class='toctree-l3']/a", '2.2.1. Bar B1', False), + (".//ul/li[@class='toctree-l1']/..//a", _intradocument_hyperlink_check), # index.rst ('.//h1', 'test-tocdepth', True), # foo.rst diff --git a/tests/test_toctree.py b/tests/test_builders/test_build_html_toctree.py similarity index 74% rename from tests/test_toctree.py rename to tests/test_builders/test_build_html_toctree.py index 39aac47a679..4195ac2e41b 100644 --- a/tests/test_toctree.py +++ b/tests/test_builders/test_build_html_toctree.py @@ -4,6 +4,9 @@ import pytest +from tests.test_builders.xpath_html_util import _intradocument_hyperlink_check +from tests.test_builders.xpath_util import check_xpath + @pytest.mark.sphinx(testroot='toctree-glob') def test_relations(app): @@ -45,3 +48,17 @@ def test_numbered_toctree(app): index = re.sub(':numbered:.*', ':numbered: 1', index) (app.srcdir / 'index.rst').write_text(index, encoding='utf8') app.build(force_all=True) + + +@pytest.mark.parametrize( + 'expect', + [ + # internal references should be same-document; external should not + (".//a[@class='reference internal']", _intradocument_hyperlink_check), + (".//a[@class='reference external']", r'https?://'), + ], +) +@pytest.mark.sphinx('singlehtml', testroot='toctree') +def test_singlehtml_hyperlinks(app, cached_etree_parse, expect): + app.build() + check_xpath(cached_etree_parse(app.outdir / 'index.html'), 'index.html', *expect) diff --git a/tests/test_builders/test_build_latex.py b/tests/test_builders/test_build_latex.py index b1eb2dfa4ea..a558abf5e1e 100644 --- a/tests/test_builders/test_build_latex.py +++ b/tests/test_builders/test_build_latex.py @@ -2157,6 +2157,7 @@ def test_latex_code_role(app): ) in content +@pytest.mark.usefixtures('_http_teapot') @pytest.mark.sphinx('latex', testroot='images') def test_copy_images(app): app.build() diff --git a/tests/test_builders/test_build_texinfo.py b/tests/test_builders/test_build_texinfo.py index 07934ad1825..7f229db83dd 100644 --- a/tests/test_builders/test_build_texinfo.py +++ b/tests/test_builders/test_build_texinfo.py @@ -131,6 +131,7 @@ def test_texinfo_samp_with_variable(app): assert '@code{Show @var{variable} in the middle}' in output +@pytest.mark.usefixtures('_http_teapot') @pytest.mark.sphinx('texinfo', testroot='images') def test_copy_images(app): app.build() diff --git a/tests/test_builders/xpath_html_util.py b/tests/test_builders/xpath_html_util.py new file mode 100644 index 00000000000..b0949c80429 --- /dev/null +++ b/tests/test_builders/xpath_html_util.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + from xml.etree.ElementTree import Element + + +def _intradocument_hyperlink_check(nodes: Sequence[Element]) -> None: + """Confirm that a series of nodes are all HTML hyperlinks to the current page""" + assert nodes, 'Expected at least one node to check' + for node in nodes: + assert node.tag == 'a', 'Attempted to check hyperlink on a non-anchor element' + href = node.attrib.get('href') + # Allow Sphinx index and table hyperlinks to be non-same-document, as exceptions. + if href in {'genindex.html', 'py-modindex.html', 'search.html'}: + continue + assert not href or href.startswith('#'), 'Hyperlink failed same-document check' diff --git a/tests/test_extensions/test_ext_math.py b/tests/test_extensions/test_ext_math.py index a18a410c42c..5a866520afb 100644 --- a/tests/test_extensions/test_ext_math.py +++ b/tests/test_extensions/test_ext_math.py @@ -288,6 +288,7 @@ def test_mathjax_numsep_html(app): assert html in content +@pytest.mark.usefixtures('_http_teapot') @pytest.mark.sphinx( 'html', testroot='ext-math', diff --git a/tests/test_intl/test_intl.py b/tests/test_intl/test_intl.py index e664d479999..74e9cf57e60 100644 --- a/tests/test_intl/test_intl.py +++ b/tests/test_intl/test_intl.py @@ -1705,6 +1705,7 @@ def test_text_prolog_epilog_substitution(app): ) +@pytest.mark.usefixtures('_http_teapot') @pytest.mark.sphinx( 'dummy', testroot='images', @@ -1782,6 +1783,7 @@ def test_image_glob_intl(app): ) +@pytest.mark.usefixtures('_http_teapot') @pytest.mark.sphinx( 'dummy', testroot='images',