Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Append CRC32 checksum to asset URIs #11415

Merged
merged 4 commits into from May 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES
Expand Up @@ -22,6 +22,9 @@ Deprecated
Features added
--------------

* #11415: Add a checksum to JavaScript and CSS asset URIs included within
generated HTML, using the CRC32 algorithm.

Bugs fixed
----------

Expand Down
7 changes: 7 additions & 0 deletions sphinx/builders/__init__.py
Expand Up @@ -560,6 +560,9 @@ def write(
with progress_message(__('preparing documents')):
self.prepare_writing(docnames)

with progress_message(__('copying assets')):
self.copy_assets()

if self.parallel_ok:
# number of subprocesses is parallel-1 because the main process
# is busy loading doctrees and doing write_doc_serialized()
Expand Down Expand Up @@ -620,6 +623,10 @@ def prepare_writing(self, docnames: set[str]) -> None:
"""A place where you can add logic before :meth:`write_doc` is run"""
raise NotImplementedError

def copy_assets(self) -> None:
"""Where assets (images, static files, etc) are copied before writing"""
pass

def write_doc(self, docname: str, doctree: nodes.document) -> None:
"""Where you actually write something to the filesystem."""
raise NotImplementedError
Expand Down
41 changes: 33 additions & 8 deletions sphinx/builders/html/__init__.py
Expand Up @@ -8,6 +8,7 @@
import re
import sys
import warnings
import zlib
from datetime import datetime
from os import path
from typing import IO, Any, Iterable, Iterator, List, Tuple, Type
Expand Down Expand Up @@ -649,6 +650,12 @@ def get_doc_context(self, docname: str, body: str, metatags: str) -> dict[str, A
'page_source_suffix': source_suffix,
}

def copy_assets(self) -> None:
self.finish_tasks.add_task(self.copy_download_files)
self.finish_tasks.add_task(self.copy_static_files)
self.finish_tasks.add_task(self.copy_extra_files)
self.finish_tasks.join()

def write_doc(self, docname: str, doctree: nodes.document) -> None:
destination = StringOutput(encoding='utf-8')
doctree.settings = self.docsettings
Expand Down Expand Up @@ -678,9 +685,6 @@ def finish(self) -> None:
self.finish_tasks.add_task(self.gen_pages_from_extensions)
self.finish_tasks.add_task(self.gen_additional_pages)
self.finish_tasks.add_task(self.copy_image_files)
self.finish_tasks.add_task(self.copy_download_files)
self.finish_tasks.add_task(self.copy_static_files)
self.finish_tasks.add_task(self.copy_extra_files)
self.finish_tasks.add_task(self.write_buildinfo)

# dump the search index
Expand Down Expand Up @@ -1193,8 +1197,11 @@ def css_tag(css: Stylesheet) -> str:
value = css.attributes[key]
if value is not None:
attrs.append(f'{key}="{html.escape(value, True)}"')
attrs.append('href="%s"' % pathto(css.filename, resource=True))
return '<link %s />' % ' '.join(attrs)
uri = pathto(css.filename, resource=True)
if checksum := _file_checksum(app.outdir, css.filename):
uri += f'?v={checksum}'
attrs.append(f'href="{uri}"')
return f'<link {" ".join(attrs)} />'

context['css_tag'] = css_tag

Expand All @@ -1217,14 +1224,17 @@ def js_tag(js: JavaScript) -> str:
if key == 'body':
body = value
elif key == 'data_url_root':
attrs.append('data-url_root="%s"' % pathto('', resource=True))
attrs.append(f'data-url_root="{pathto("", resource=True)}"')
else:
attrs.append(f'{key}="{html.escape(value, True)}"')
if js.filename:
attrs.append('src="%s"' % pathto(js.filename, resource=True))
uri = pathto(js.filename, resource=True)
if checksum := _file_checksum(app.outdir, js.filename):
uri += f'?v={checksum}'
attrs.append(f'src="{uri}"')
else:
# str value (old styled)
attrs.append('src="%s"' % pathto(js, resource=True))
attrs.append(f'src="{pathto(js, resource=True)}"')

if attrs:
return f'<script {" ".join(attrs)}>{body}</script>'
Expand All @@ -1234,6 +1244,21 @@ def js_tag(js: JavaScript) -> str:
context['js_tag'] = js_tag


def _file_checksum(outdir: str, filename: str) -> str:
# Don't generate checksums for HTTP URIs
if '://' in filename:
return ''
try:
# Ensure universal newline mode is used to avoid checksum differences
with open(path.join(outdir, filename), encoding='utf-8') as f:
content = f.read().encode(encoding='utf-8')
except FileNotFoundError:
return ''
if not content:
return ''
return f'{zlib.crc32(content):08x}'


def setup_resource_paths(app: Sphinx, pagename: str, templatename: str,
context: dict, doctree: Node) -> None:
"""Set up relative resource paths."""
Expand Down
11 changes: 7 additions & 4 deletions sphinx/builders/latex/__init__.py
Expand Up @@ -254,6 +254,12 @@ def write_stylesheet(self) -> None:
f.write('% Its contents depend on pygments_style configuration variable.\n\n')
f.write(highlighter.get_stylesheet())

def copy_assets(self) -> None:
self.copy_support_files()

if self.config.latex_additional_files:
self.copy_latex_additional_files()

def write(self, *ignored: Any) -> None:
docwriter = LaTeXWriter(self)
with warnings.catch_warnings():
Expand All @@ -267,6 +273,7 @@ def write(self, *ignored: Any) -> None:

self.init_document_data()
self.write_stylesheet()
self.copy_assets()

for entry in self.document_data:
docname, targetname, title, author, themename = entry[:5]
Expand Down Expand Up @@ -371,10 +378,6 @@ def assemble_doctree(
def finish(self) -> None:
self.copy_image_files()
self.write_message_catalog()
self.copy_support_files()

if self.config.latex_additional_files:
self.copy_latex_additional_files()

@progress_message(__('copying TeX support files'))
def copy_support_files(self) -> None:
Expand Down
3 changes: 2 additions & 1 deletion sphinx/builders/texinfo.py
Expand Up @@ -85,6 +85,7 @@ def init_document_data(self) -> None:

def write(self, *ignored: Any) -> None:
self.init_document_data()
self.copy_assets()
for entry in self.document_data:
docname, targetname, title, author = entry[:4]
targetname += '.texi'
Expand Down Expand Up @@ -168,7 +169,7 @@ def assemble_doctree(
pendingnode.replace_self(newnodes)
return largetree

def finish(self) -> None:
def copy_assets(self) -> None:
self.copy_support_files()

def copy_image_files(self, targetname: str) -> None:
Expand Down
16 changes: 8 additions & 8 deletions sphinx/ext/graphviz.py
Expand Up @@ -8,7 +8,7 @@
import subprocess
from os import path
from subprocess import CalledProcessError
from typing import Any
from typing import TYPE_CHECKING, Any

from docutils import nodes
from docutils.nodes import Node
Expand All @@ -20,7 +20,6 @@
from sphinx.locale import _, __
from sphinx.util import logging, sha1
from sphinx.util.docutils import SphinxDirective, SphinxTranslator
from sphinx.util.fileutil import copy_asset
from sphinx.util.i18n import search_image_for_language
from sphinx.util.nodes import set_source_info
from sphinx.util.osutil import ensuredir
Expand All @@ -31,6 +30,9 @@
from sphinx.writers.texinfo import TexinfoTranslator
from sphinx.writers.text import TextTranslator

if TYPE_CHECKING:
from sphinx.config import Config

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -391,11 +393,9 @@ def man_visit_graphviz(self: ManualPageTranslator, node: graphviz) -> None:
raise nodes.SkipNode


def on_build_finished(app: Sphinx, exc: Exception) -> None:
if exc is None and app.builder.format == 'html':
src = path.join(sphinx.package_dir, 'templates', 'graphviz', 'graphviz.css')
dst = path.join(app.outdir, '_static')
copy_asset(src, dst)
def on_config_inited(_app: Sphinx, config: Config) -> None:
css_path = path.join(sphinx.package_dir, 'templates', 'graphviz', 'graphviz.css')
config.html_static_path.append(css_path)


def setup(app: Sphinx) -> dict[str, Any]:
Expand All @@ -412,5 +412,5 @@ def setup(app: Sphinx) -> dict[str, Any]:
app.add_config_value('graphviz_dot_args', [], 'html')
app.add_config_value('graphviz_output_format', 'png', 'html')
app.add_css_file('graphviz.css')
app.connect('build-finished', on_build_finished)
app.connect('config-inited', on_config_inited)
return {'version': sphinx.__display_version__, 'parallel_read_safe': True}
35 changes: 24 additions & 11 deletions tests/test_build_html.py
Expand Up @@ -1186,19 +1186,32 @@ def test_assets_order(app):
content = (app.outdir / 'index.html').read_text(encoding='utf8')

# css_files
expected = ['_static/early.css', '_static/pygments.css', '_static/alabaster.css',
'https://example.com/custom.css', '_static/normal.css', '_static/late.css',
'_static/css/style.css', '_static/lazy.css']
pattern = '.*'.join('href="%s"' % f for f in expected)
assert re.search(pattern, content, re.S)
expected = [
'_static/early.css',
'_static/pygments.css?v=b3523f8e',
'_static/alabaster.css?v=039e1c02',
'https://example.com/custom.css',
'_static/normal.css',
'_static/late.css',
'_static/css/style.css',
'_static/lazy.css',
]
pattern = '.*'.join(f'href="{re.escape(f)}"' for f in expected)
assert re.search(pattern, content, re.DOTALL), content

# js_files
expected = ['_static/early.js',
'_static/doctools.js', '_static/sphinx_highlight.js',
'https://example.com/script.js', '_static/normal.js',
'_static/late.js', '_static/js/custom.js', '_static/lazy.js']
pattern = '.*'.join('src="%s"' % f for f in expected)
assert re.search(pattern, content, re.S)
expected = [
'_static/early.js',
'_static/doctools.js?v=888ff710',
'_static/sphinx_highlight.js?v=4825356b',
'https://example.com/script.js',
'_static/normal.js',
'_static/late.js',
'_static/js/custom.js',
'_static/lazy.js',
]
pattern = '.*'.join(f'src="{re.escape(f)}"' for f in expected)
assert re.search(pattern, content, re.DOTALL), content


@pytest.mark.sphinx('html', testroot='html_assets')
Expand Down
4 changes: 2 additions & 2 deletions tests/test_theming.py
Expand Up @@ -99,10 +99,10 @@ def test_dark_style(app, status, warning):
assert (app.outdir / '_static' / 'pygments_dark.css').exists()

result = (app.outdir / 'index.html').read_text(encoding='utf8')
assert '<link rel="stylesheet" type="text/css" href="_static/pygments.css" />' in result
assert '<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=b76e3c8a" />' in result
assert ('<link id="pygments_dark_css" media="(prefers-color-scheme: dark)" '
'rel="stylesheet" type="text/css" '
'href="_static/pygments_dark.css" />') in result
'href="_static/pygments_dark.css?v=e15ddae3" />') in result


@pytest.mark.sphinx(testroot='theming')
Expand Down