Use more precise last-modified times for files (#12661)

This commit is contained in:
Adam Turner
2024-07-23 14:52:31 +01:00
committed by GitHub
parent de15d61a46
commit 57a63ea603
14 changed files with 94 additions and 75 deletions

View File

@@ -9,7 +9,6 @@ import os
import posixpath import posixpath
import re import re
import sys import sys
import time
import types import types
import warnings import warnings
from os import path from os import path
@@ -40,13 +39,21 @@ from sphinx.locale import _, __
from sphinx.search import js_index from sphinx.search import js_index
from sphinx.theming import HTMLThemeFactory from sphinx.theming import HTMLThemeFactory
from sphinx.util import isurl, logging from sphinx.util import isurl, logging
from sphinx.util._timestamps import _format_rfc3339_microseconds
from sphinx.util.display import progress_message, status_iterator from sphinx.util.display import progress_message, status_iterator
from sphinx.util.docutils import new_document from sphinx.util.docutils import new_document
from sphinx.util.fileutil import copy_asset from sphinx.util.fileutil import copy_asset
from sphinx.util.i18n import format_date from sphinx.util.i18n import format_date
from sphinx.util.inventory import InventoryFile from sphinx.util.inventory import InventoryFile
from sphinx.util.matching import DOTFILES, Matcher, patmatch from sphinx.util.matching import DOTFILES, Matcher, patmatch
from sphinx.util.osutil import SEP, copyfile, ensuredir, os_path, relative_uri from sphinx.util.osutil import (
SEP,
_last_modified_time,
copyfile,
ensuredir,
os_path,
relative_uri,
)
from sphinx.writers.html import HTMLWriter from sphinx.writers.html import HTMLWriter
from sphinx.writers.html5 import HTML5Translator from sphinx.writers.html5 import HTML5Translator
@@ -397,7 +404,7 @@ class StandaloneHTMLBuilder(Builder):
pass pass
if self.templates: if self.templates:
template_mtime = self.templates.newest_template_mtime() template_mtime = int(self.templates.newest_template_mtime() * 10**6)
else: else:
template_mtime = 0 template_mtime = 0
for docname in self.env.found_docs: for docname in self.env.found_docs:
@@ -407,19 +414,19 @@ class StandaloneHTMLBuilder(Builder):
continue continue
targetname = self.get_outfilename(docname) targetname = self.get_outfilename(docname)
try: try:
targetmtime = path.getmtime(targetname) targetmtime = _last_modified_time(targetname)
except Exception: except Exception:
targetmtime = 0 targetmtime = 0
try: try:
srcmtime = max(path.getmtime(self.env.doc2path(docname)), template_mtime) srcmtime = max(_last_modified_time(self.env.doc2path(docname)), template_mtime)
if srcmtime > targetmtime: if srcmtime > targetmtime:
logger.debug( logger.debug(
'[build target] targetname %r(%s), template(%s), docname %r(%s)', '[build target] targetname %r(%s), template(%s), docname %r(%s)',
targetname, targetname,
_format_modified_time(targetmtime), _format_rfc3339_microseconds(targetmtime),
_format_modified_time(template_mtime), _format_rfc3339_microseconds(template_mtime),
docname, docname,
_format_modified_time(path.getmtime(self.env.doc2path(docname))), _format_rfc3339_microseconds(_last_modified_time(self.env.doc2path(docname))),
) )
yield docname yield docname
except OSError: except OSError:
@@ -1224,12 +1231,6 @@ def convert_html_css_files(app: Sphinx, config: Config) -> None:
config.html_css_files = html_css_files config.html_css_files = html_css_files
def _format_modified_time(timestamp: float) -> str:
"""Return an RFC 3339 formatted string representing the given timestamp."""
seconds, fraction = divmod(timestamp, 1)
return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(seconds)) + f'.{fraction:.3f}'
def convert_html_js_files(app: Sphinx, config: Config) -> None: def convert_html_js_files(app: Sphinx, config: Config) -> None:
"""Convert string styled html_js_files to tuple styled one.""" """Convert string styled html_js_files to tuple styled one."""
html_js_files: list[tuple[str, dict[str, str]]] = [] html_js_files: list[tuple[str, dict[str, str]]] = []

View File

@@ -10,7 +10,11 @@ from docutils.io import StringOutput
from sphinx.builders import Builder from sphinx.builders import Builder
from sphinx.locale import __ from sphinx.locale import __
from sphinx.util import logging from sphinx.util import logging
from sphinx.util.osutil import ensuredir, os_path from sphinx.util.osutil import (
_last_modified_time,
ensuredir,
os_path,
)
from sphinx.writers.text import TextTranslator, TextWriter from sphinx.writers.text import TextTranslator, TextWriter
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -46,11 +50,11 @@ class TextBuilder(Builder):
continue continue
targetname = path.join(self.outdir, docname + self.out_suffix) targetname = path.join(self.outdir, docname + self.out_suffix)
try: try:
targetmtime = path.getmtime(targetname) targetmtime = _last_modified_time(targetname)
except Exception: except Exception:
targetmtime = 0 targetmtime = 0
try: try:
srcmtime = path.getmtime(self.env.doc2path(docname)) srcmtime = _last_modified_time(self.env.doc2path(docname))
if srcmtime > targetmtime: if srcmtime > targetmtime:
yield docname yield docname
except OSError: except OSError:

View File

@@ -12,7 +12,11 @@ from docutils.writers.docutils_xml import XMLTranslator
from sphinx.builders import Builder from sphinx.builders import Builder
from sphinx.locale import __ from sphinx.locale import __
from sphinx.util import logging from sphinx.util import logging
from sphinx.util.osutil import ensuredir, os_path from sphinx.util.osutil import (
_last_modified_time,
ensuredir,
os_path,
)
from sphinx.writers.xml import PseudoXMLWriter, XMLWriter from sphinx.writers.xml import PseudoXMLWriter, XMLWriter
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -52,11 +56,11 @@ class XMLBuilder(Builder):
continue continue
targetname = path.join(self.outdir, docname + self.out_suffix) targetname = path.join(self.outdir, docname + self.out_suffix)
try: try:
targetmtime = path.getmtime(targetname) targetmtime = _last_modified_time(targetname)
except Exception: except Exception:
targetmtime = 0 targetmtime = 0
try: try:
srcmtime = path.getmtime(self.env.doc2path(docname)) srcmtime = _last_modified_time(self.env.doc2path(docname))
if srcmtime > targetmtime: if srcmtime > targetmtime:
yield docname yield docname
except OSError: except OSError:

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
import functools import functools
import os import os
import pickle import pickle
import time
from collections import defaultdict from collections import defaultdict
from copy import copy from copy import copy
from os import path from os import path
@@ -17,10 +16,11 @@ from sphinx.errors import BuildEnvironmentError, DocumentError, ExtensionError,
from sphinx.locale import __ from sphinx.locale import __
from sphinx.transforms import SphinxTransformer from sphinx.transforms import SphinxTransformer
from sphinx.util import DownloadFiles, FilenameUniqDict, logging from sphinx.util import DownloadFiles, FilenameUniqDict, logging
from sphinx.util._timestamps import _format_rfc3339_microseconds
from sphinx.util.docutils import LoggingReporter from sphinx.util.docutils import LoggingReporter
from sphinx.util.i18n import CatalogRepository, docname_to_domain from sphinx.util.i18n import CatalogRepository, docname_to_domain
from sphinx.util.nodes import is_translatable from sphinx.util.nodes import is_translatable
from sphinx.util.osutil import canon_path, os_path from sphinx.util.osutil import _last_modified_time, canon_path, os_path
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
@@ -508,7 +508,8 @@ class BuildEnvironment:
if newmtime > mtime: if newmtime > mtime:
logger.debug('[build target] outdated %r: %s -> %s', logger.debug('[build target] outdated %r: %s -> %s',
docname, docname,
_format_modified_time(mtime), _format_modified_time(newmtime)) _format_rfc3339_microseconds(mtime),
_format_rfc3339_microseconds(newmtime))
changed.add(docname) changed.add(docname)
continue continue
# finally, check the mtime of dependencies # finally, check the mtime of dependencies
@@ -528,7 +529,8 @@ class BuildEnvironment:
logger.debug( logger.debug(
'[build target] outdated %r from dependency %r: %s -> %s', '[build target] outdated %r from dependency %r: %s -> %s',
docname, deppath, docname, deppath,
_format_modified_time(mtime), _format_modified_time(depmtime), _format_rfc3339_microseconds(mtime),
_format_rfc3339_microseconds(depmtime),
) )
changed.add(docname) changed.add(docname)
break break
@@ -756,26 +758,6 @@ class BuildEnvironment:
self.events.emit('env-check-consistency', self) self.events.emit('env-check-consistency', self)
def _last_modified_time(filename: str | os.PathLike[str]) -> int:
"""Return the last modified time of ``filename``.
The time is returned as integer microseconds.
The lowest common denominator of modern file-systems seems to be
microsecond-level precision.
We prefer to err on the side of re-rendering a file,
so we round up to the nearest microsecond.
"""
# upside-down floor division to get the ceiling
return -(os.stat(filename).st_mtime_ns // -1_000)
def _format_modified_time(timestamp: int) -> str:
"""Return an RFC 3339 formatted string representing the given timestamp."""
seconds, fraction = divmod(timestamp, 10**6)
return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(seconds)) + f'.{fraction // 1_000}'
def _traverse_toctree( def _traverse_toctree(
traversed: set[str], traversed: set[str],
parent: str | None, parent: str | None,

View File

@@ -21,6 +21,7 @@ from sphinx.transforms.post_transforms import SphinxPostTransform
from sphinx.util import logging from sphinx.util import logging
from sphinx.util.display import status_iterator from sphinx.util.display import status_iterator
from sphinx.util.nodes import make_refnode from sphinx.util.nodes import make_refnode
from sphinx.util.osutil import _last_modified_time
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable, Iterator from collections.abc import Iterable, Iterator
@@ -231,7 +232,7 @@ def should_generate_module_page(app: Sphinx, modname: str) -> bool:
page_filename = path.join(app.outdir, '_modules/', basename) page_filename = path.join(app.outdir, '_modules/', basename)
try: try:
if path.getmtime(module_filename) <= path.getmtime(page_filename): if _last_modified_time(module_filename) <= _last_modified_time(page_filename):
# generation is not needed if the HTML page is newer than module file. # generation is not needed if the HTML page is newer than module file.
return False return False
except OSError: except OSError:

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import os
from os import path from os import path
from pprint import pformat from pprint import pformat
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -12,7 +13,7 @@ from jinja2.utils import open_if_exists, pass_context
from sphinx.application import TemplateBridge from sphinx.application import TemplateBridge
from sphinx.util import logging from sphinx.util import logging
from sphinx.util.osutil import mtimes_of_files from sphinx.util.osutil import _last_modified_time
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
@@ -127,11 +128,11 @@ class SphinxFileSystemLoader(FileSystemLoader):
with f: with f:
contents = f.read().decode(self.encoding) contents = f.read().decode(self.encoding)
mtime = path.getmtime(filename) mtime = _last_modified_time(filename)
def uptodate() -> bool: def uptodate() -> bool:
try: try:
return path.getmtime(filename) == mtime return _last_modified_time(filename) == mtime
except OSError: except OSError:
return False return False
@@ -203,7 +204,13 @@ class BuiltinTemplateLoader(TemplateBridge, BaseLoader):
return self.environment.from_string(source).render(context) return self.environment.from_string(source).render(context)
def newest_template_mtime(self) -> float: def newest_template_mtime(self) -> float:
return max(mtimes_of_files(self.pathchain, '.html')) return max(
os.stat(os.path.join(root, sfile)).st_mtime_ns / 10**9
for dirname in self.pathchain
for root, _dirs, files in os.walk(dirname)
for sfile in files
if sfile.endswith('.html')
)
# Loader interface # Loader interface

View File

@@ -29,10 +29,8 @@ from sphinx.util.nodes import ( # NoQA: F401
from sphinx.util.osutil import ( # NoQA: F401 from sphinx.util.osutil import ( # NoQA: F401
SEP, SEP,
copyfile, copyfile,
copytimes,
ensuredir, ensuredir,
make_filename, make_filename,
mtimes_of_files,
os_path, os_path,
relative_uri, relative_uri,
) )

View File

@@ -0,0 +1,12 @@
from __future__ import annotations
import time
def _format_rfc3339_microseconds(timestamp: int, /) -> str:
"""Return an RFC 3339 formatted string representing the given timestamp.
:param timestamp: The timestamp to format, in microseconds.
"""
seconds, fraction = divmod(timestamp, 10**6)
return time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(seconds)) + f'.{fraction // 1_000}'

View File

@@ -15,7 +15,12 @@ from babel.messages.pofile import read_po
from sphinx.errors import SphinxError from sphinx.errors import SphinxError
from sphinx.locale import __ from sphinx.locale import __
from sphinx.util import logging from sphinx.util import logging
from sphinx.util.osutil import SEP, canon_path, relpath from sphinx.util.osutil import (
SEP,
_last_modified_time,
canon_path,
relpath,
)
if TYPE_CHECKING: if TYPE_CHECKING:
import datetime as dt import datetime as dt
@@ -84,7 +89,7 @@ class CatalogInfo(LocaleFileInfoBase):
def is_outdated(self) -> bool: def is_outdated(self) -> bool:
return ( return (
not path.exists(self.mo_path) or not path.exists(self.mo_path) or
path.getmtime(self.mo_path) < path.getmtime(self.po_path)) _last_modified_time(self.mo_path) < _last_modified_time(self.po_path))
def write_mo(self, locale: str, use_fuzzy: bool = False) -> None: def write_mo(self, locale: str, use_fuzzy: bool = False) -> None:
with open(self.po_path, encoding=self.charset) as file_po: with open(self.po_path, encoding=self.charset) as file_po:

View File

@@ -16,7 +16,6 @@ from typing import TYPE_CHECKING
from sphinx.locale import __ from sphinx.locale import __
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path from pathlib import Path
from types import TracebackType from types import TracebackType
from typing import Any from typing import Any
@@ -72,20 +71,25 @@ def ensuredir(file: str | os.PathLike[str]) -> None:
os.makedirs(file, exist_ok=True) os.makedirs(file, exist_ok=True)
def mtimes_of_files(dirnames: list[str], suffix: str) -> Iterator[float]: def _last_modified_time(source: str | os.PathLike[str], /) -> int:
for dirname in dirnames: """Return the last modified time of ``filename``.
for root, _dirs, files in os.walk(dirname):
for sfile in files: The time is returned as integer microseconds.
if sfile.endswith(suffix): The lowest common denominator of modern file-systems seems to be
with contextlib.suppress(OSError): microsecond-level precision.
yield path.getmtime(path.join(root, sfile))
We prefer to err on the side of re-rendering a file,
so we round up to the nearest microsecond.
"""
st = source.stat() if isinstance(source, os.DirEntry) else os.stat(source)
# upside-down floor division to get the ceiling
return -(st.st_mtime_ns // -1_000)
def copytimes(source: str | os.PathLike[str], dest: str | os.PathLike[str]) -> None: def _copy_times(source: str | os.PathLike[str], dest: str | os.PathLike[str]) -> None:
"""Copy a file's modification times.""" """Copy a file's modification times."""
st = os.stat(source) st = source.stat() if isinstance(source, os.DirEntry) else os.stat(source)
if hasattr(os, 'utime'): os.utime(dest, ns=(st.st_atime_ns, st.st_mtime_ns))
os.utime(dest, (st.st_atime, st.st_mtime))
def copyfile( def copyfile(
@@ -128,7 +132,7 @@ def copyfile(
shutil.copyfile(source, dest) shutil.copyfile(source, dest)
with contextlib.suppress(OSError): with contextlib.suppress(OSError):
# don't do full copystat because the source may be read-only # don't do full copystat because the source may be read-only
copytimes(source, dest) _copy_times(source, dest)
_no_fn_re = re.compile(r'[^a-zA-Z0-9_-]') _no_fn_re = re.compile(r'[^a-zA-Z0-9_-]')

View File

@@ -678,7 +678,7 @@ def test_remove_old_files(tmp_path: Path):
(gen_dir / 'other.rst').write_text('', encoding='utf8') (gen_dir / 'other.rst').write_text('', encoding='utf8')
apidoc_main(['-o', str(gen_dir), str(module_dir)]) apidoc_main(['-o', str(gen_dir), str(module_dir)])
assert set(gen_dir.iterdir()) == {gen_dir / 'modules.rst', gen_dir / 'example.rst', gen_dir / 'other.rst'} assert set(gen_dir.iterdir()) == {gen_dir / 'modules.rst', gen_dir / 'example.rst', gen_dir / 'other.rst'}
example_mtime = (gen_dir / 'example.rst').stat().st_mtime example_mtime = (gen_dir / 'example.rst').stat().st_mtime_ns
apidoc_main(['--remove-old', '-o', str(gen_dir), str(module_dir)]) apidoc_main(['--remove-old', '-o', str(gen_dir), str(module_dir)])
assert set(gen_dir.iterdir()) == {gen_dir / 'modules.rst', gen_dir / 'example.rst'} assert set(gen_dir.iterdir()) == {gen_dir / 'modules.rst', gen_dir / 'example.rst'}
assert (gen_dir / 'example.rst').stat().st_mtime == example_mtime assert (gen_dir / 'example.rst').stat().st_mtime_ns == example_mtime

View File

@@ -40,7 +40,7 @@ def write_mo(pathname, po):
return mofile.write_mo(f, po) return mofile.write_mo(f, po)
def _set_mtime_ns(target, value): def _set_mtime_ns(target: str | os.PathLike[str], value: int) -> int:
os.utime(target, ns=(value, value)) os.utime(target, ns=(value, value))
return os.stat(target).st_mtime_ns return os.stat(target).st_mtime_ns

View File

@@ -46,25 +46,25 @@ def test_SphinxFileOutput(tmpdir):
filename = str(tmpdir / 'test.txt') filename = str(tmpdir / 'test.txt')
output = SphinxFileOutput(destination_path=filename) output = SphinxFileOutput(destination_path=filename)
output.write(content) output.write(content)
os.utime(filename, (0, 0)) os.utime(filename, ns=(0, 0))
# overwrite it again # overwrite it again
output.write(content) output.write(content)
assert os.stat(filename).st_mtime != 0 # updated assert os.stat(filename).st_mtime_ns != 0 # updated
# write test2.txt at first # write test2.txt at first
filename = str(tmpdir / 'test2.txt') filename = str(tmpdir / 'test2.txt')
output = SphinxFileOutput(destination_path=filename, overwrite_if_changed=True) output = SphinxFileOutput(destination_path=filename, overwrite_if_changed=True)
output.write(content) output.write(content)
os.utime(filename, (0, 0)) os.utime(filename, ns=(0, 0))
# overwrite it again # overwrite it again
output.write(content) output.write(content)
assert os.stat(filename).st_mtime == 0 # not updated assert os.stat(filename).st_mtime_ns == 0 # not updated
# overwrite it again (content changed) # overwrite it again (content changed)
output.write(content + "; content change") output.write(content + "; content change")
assert os.stat(filename).st_mtime != 0 # updated assert os.stat(filename).st_mtime_ns != 0 # updated
def test_SphinxTranslator(app): def test_SphinxTranslator(app):

View File

@@ -38,7 +38,8 @@ def test_catalog_outdated(tmp_path):
mo_file.write_text('#', encoding='utf8') mo_file.write_text('#', encoding='utf8')
assert not cat.is_outdated() # if mo is exist and newer than po assert not cat.is_outdated() # if mo is exist and newer than po
os.utime(mo_file, (os.stat(mo_file).st_mtime - 10,) * 2) # to be outdate new_mtime = os.stat(mo_file).st_mtime_ns - 10_000_000_000
os.utime(mo_file, ns=(new_mtime, new_mtime)) # to be outdated
assert cat.is_outdated() # if mo is exist and older than po assert cat.is_outdated() # if mo is exist and older than po