Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,7 @@ Bugs fixed
* LaTeX: Fix accidental removal at ``3.5.0`` (#8854) of the documentation of
``literalblockcappos`` key of :ref:`'sphinxsetup' <latexsphinxsetup>`.
Patch by Jean-François B.
* #2835: HTML builder now detects changes in theme files (templates and
static files) and triggers a full rebuild automatically, without
requiring ``make clean``.
Patch by kishorhange111.
6 changes: 3 additions & 3 deletions sphinx/builders/html/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,15 +160,15 @@ def __init__(self, app: Sphinx, env: BuildEnvironment) -> None:
)

def init(self) -> None:
self.build_info = self.create_build_info()

# basename of images directory
self.imagedir = '_images'
# section numbers for headings in the currently visited document
self.secnumbers: dict[str, tuple[int, ...]] = {}
# currently written docname
self.current_docname: str = ''

self.init_templates()
self.build_info = self.create_build_info()
self.init_highlighter()
self.init_css_files()
self.init_js_files()
Expand All @@ -186,7 +186,7 @@ def init(self) -> None:
self.use_index = self.get_builder_config('use_index', 'html')

def create_build_info(self) -> BuildInfo:
return BuildInfo(self.config, self.tags, frozenset({'html'}))
return BuildInfo(self.config, self.tags, frozenset({'html'}), self.theme)

def _get_translations_js(self) -> Path | None:
for dir_ in self.config.locale_dirs:
Expand Down
23 changes: 21 additions & 2 deletions sphinx/builders/html/_build_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from pathlib import Path

from sphinx.config import Config, _ConfigRebuild
from sphinx.theming import Theme
from sphinx.util.tags import Tags


Expand Down Expand Up @@ -42,16 +43,22 @@ def load(cls: type[BuildInfo], filename: Path, /) -> BuildInfo:
build_info = BuildInfo()
build_info.config_hash = lines[2].removeprefix('config: ').strip()
build_info.tags_hash = lines[3].removeprefix('tags: ').strip()
# theme_hash is optional for backward compatibility
# with old .buildinfo files that don't have it
if len(lines) > 4 and lines[4].startswith('theme: '):
build_info.theme_hash = lines[4].removeprefix('theme: ').strip()
return build_info

def __init__(
self,
config: Config | None = None,
tags: Tags | None = None,
config_categories: Set[_ConfigRebuild] = frozenset(),
theme: Theme | None = None,
) -> None:
self.config_hash = ''
self.tags_hash = ''
self.theme_hash = ''

if config:
values = {c.name: c.value for c in config.filter(config_categories)}
Expand All @@ -60,13 +67,24 @@ def __init__(
if tags:
self.tags_hash = stable_hash(sorted(tags))

if theme:
# Hash all files in all theme dirs to detect any changes
theme_files = {}
for theme_dir in theme._dirs:
for path in sorted(theme_dir.rglob('*')):
if path.is_file():
theme_files[str(path)] = path.read_bytes()
self.theme_hash = stable_hash(theme_files)

def __eq__(self, other: BuildInfo) -> bool: # type: ignore[override]
return (
self.config_hash == other.config_hash and self.tags_hash == other.tags_hash
self.config_hash == other.config_hash
and self.tags_hash == other.tags_hash
and self.theme_hash == other.theme_hash
)

def __hash__(self) -> int:
return hash((self.config_hash, self.tags_hash))
return hash((self.config_hash, self.tags_hash, self.theme_hash))

def dump(self, filename: Path, /) -> None:
build_info = (
Expand All @@ -75,5 +93,6 @@ def dump(self, filename: Path, /) -> None:
'When it is not found, a full rebuild will be done.\n'
f'config: {self.config_hash}\n'
f'tags: {self.tags_hash}\n'
f'theme: {self.theme_hash}\n'
)
filename.write_text(build_info, encoding='utf-8')
114 changes: 114 additions & 0 deletions tests/test_builders/test_build_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Test the BuildInfo class and theme rebuild detection."""

from __future__ import annotations

from typing import TYPE_CHECKING

from sphinx.builders.html import StandaloneHTMLBuilder
from sphinx.builders.html._build_info import BuildInfo

if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path

from sphinx.testing.util import SphinxTestApp


def test_build_info_theme_hash_is_set(
make_app: Callable[..., SphinxTestApp], tmp_path: Path
) -> None:
"""BuildInfo should include a theme_hash when a theme is provided."""
(tmp_path / 'conf.py').touch()
(tmp_path / 'index.rst').write_text('Test\n====\n', encoding='utf-8')

app = make_app('html', srcdir=tmp_path)
builder = app.builder
assert isinstance(builder, StandaloneHTMLBuilder)

build_info = builder.create_build_info()
assert build_info.theme_hash != ''


def test_build_info_theme_hash_changes_when_theme_file_changes(
make_app: Callable[..., SphinxTestApp], tmp_path: Path
) -> None:
"""BuildInfo theme_hash should change when a theme file is modified."""
(tmp_path / 'conf.py').touch()
(tmp_path / 'index.rst').write_text('Test\n====\n', encoding='utf-8')

app = make_app('html', srcdir=tmp_path)
builder = app.builder
assert isinstance(builder, StandaloneHTMLBuilder)

# Get initial theme hash
build_info_before = builder.create_build_info()

# Modify a theme template file
theme_dir = builder.theme._dirs[0]
theme_files = list(theme_dir.rglob('*.html'))
assert theme_files, 'No HTML files found in theme'

theme_files[0].write_bytes(theme_files[0].read_bytes() + b'\n<!-- test change -->')

# Get new theme hash
build_info_after = builder.create_build_info()

assert build_info_before.theme_hash != build_info_after.theme_hash


def test_build_info_equality_with_same_theme(
make_app: Callable[..., SphinxTestApp], tmp_path: Path
) -> None:
"""Two BuildInfo objects with same theme should be equal."""
(tmp_path / 'conf.py').touch()
(tmp_path / 'index.rst').write_text('Test\n====\n', encoding='utf-8')

app = make_app('html', srcdir=tmp_path)
builder = app.builder
assert isinstance(builder, StandaloneHTMLBuilder)

build_info_1 = builder.create_build_info()
build_info_2 = builder.create_build_info()

assert build_info_1 == build_info_2


def test_build_info_dump_and_load_preserves_theme_hash(
make_app: Callable[..., SphinxTestApp], tmp_path: Path
) -> None:
"""theme_hash should survive a dump/load round trip."""
(tmp_path / 'conf.py').touch()
(tmp_path / 'index.rst').write_text('Test\n====\n', encoding='utf-8')

app = make_app('html', srcdir=tmp_path)
builder = app.builder
assert isinstance(builder, StandaloneHTMLBuilder)

build_info = builder.create_build_info()
build_info_path = tmp_path / '.buildinfo'

# Dump and reload
build_info.dump(build_info_path)
loaded = BuildInfo.load(build_info_path)

assert build_info.theme_hash == loaded.theme_hash


def test_build_info_load_without_theme_hash(tmp_path: Path) -> None:
"""Old .buildinfo files without theme_hash should still load correctly."""
build_info_path = tmp_path / '.buildinfo'

build_info_path.write_text(
'# 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'
'config: abc123\n'
'tags: def456\n',
encoding='utf-8',
)

loaded = BuildInfo.load(build_info_path)

assert loaded.config_hash == 'abc123'
assert loaded.tags_hash == 'def456'
assert loaded.theme_hash == ''
Loading