diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index 6955caf16..06917232f 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -9,7 +9,6 @@ import os import posixpath import re import sys -import time import types import warnings from os import path @@ -40,13 +39,21 @@ from sphinx.locale import _, __ from sphinx.search import js_index from sphinx.theming import HTMLThemeFactory 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.docutils import new_document from sphinx.util.fileutil import copy_asset from sphinx.util.i18n import format_date from sphinx.util.inventory import InventoryFile 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.html5 import HTML5Translator @@ -397,7 +404,7 @@ class StandaloneHTMLBuilder(Builder): pass if self.templates: - template_mtime = self.templates.newest_template_mtime() + template_mtime = int(self.templates.newest_template_mtime() * 10**6) else: template_mtime = 0 for docname in self.env.found_docs: @@ -407,19 +414,19 @@ class StandaloneHTMLBuilder(Builder): continue targetname = self.get_outfilename(docname) try: - targetmtime = path.getmtime(targetname) + targetmtime = _last_modified_time(targetname) except Exception: targetmtime = 0 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: logger.debug( '[build target] targetname %r(%s), template(%s), docname %r(%s)', targetname, - _format_modified_time(targetmtime), - _format_modified_time(template_mtime), + _format_rfc3339_microseconds(targetmtime), + _format_rfc3339_microseconds(template_mtime), docname, - _format_modified_time(path.getmtime(self.env.doc2path(docname))), + _format_rfc3339_microseconds(_last_modified_time(self.env.doc2path(docname))), ) yield docname except OSError: @@ -1224,12 +1231,6 @@ def convert_html_css_files(app: Sphinx, config: Config) -> None: 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: """Convert string styled html_js_files to tuple styled one.""" html_js_files: list[tuple[str, dict[str, str]]] = [] diff --git a/sphinx/builders/text.py b/sphinx/builders/text.py index 483e7fade..ec0eecfb5 100644 --- a/sphinx/builders/text.py +++ b/sphinx/builders/text.py @@ -10,7 +10,11 @@ from docutils.io import StringOutput from sphinx.builders import Builder from sphinx.locale import __ 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 if TYPE_CHECKING: @@ -46,11 +50,11 @@ class TextBuilder(Builder): continue targetname = path.join(self.outdir, docname + self.out_suffix) try: - targetmtime = path.getmtime(targetname) + targetmtime = _last_modified_time(targetname) except Exception: targetmtime = 0 try: - srcmtime = path.getmtime(self.env.doc2path(docname)) + srcmtime = _last_modified_time(self.env.doc2path(docname)) if srcmtime > targetmtime: yield docname except OSError: diff --git a/sphinx/builders/xml.py b/sphinx/builders/xml.py index 1f2c10571..947c7d279 100644 --- a/sphinx/builders/xml.py +++ b/sphinx/builders/xml.py @@ -12,7 +12,11 @@ from docutils.writers.docutils_xml import XMLTranslator from sphinx.builders import Builder from sphinx.locale import __ 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 if TYPE_CHECKING: @@ -52,11 +56,11 @@ class XMLBuilder(Builder): continue targetname = path.join(self.outdir, docname + self.out_suffix) try: - targetmtime = path.getmtime(targetname) + targetmtime = _last_modified_time(targetname) except Exception: targetmtime = 0 try: - srcmtime = path.getmtime(self.env.doc2path(docname)) + srcmtime = _last_modified_time(self.env.doc2path(docname)) if srcmtime > targetmtime: yield docname except OSError: diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py index 239db1f8f..4ef23cd8d 100644 --- a/sphinx/environment/__init__.py +++ b/sphinx/environment/__init__.py @@ -5,7 +5,6 @@ from __future__ import annotations import functools import os import pickle -import time from collections import defaultdict from copy import copy from os import path @@ -17,10 +16,11 @@ from sphinx.errors import BuildEnvironmentError, DocumentError, ExtensionError, from sphinx.locale import __ from sphinx.transforms import SphinxTransformer from sphinx.util import DownloadFiles, FilenameUniqDict, logging +from sphinx.util._timestamps import _format_rfc3339_microseconds from sphinx.util.docutils import LoggingReporter from sphinx.util.i18n import CatalogRepository, docname_to_domain 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: from collections.abc import Callable, Iterator @@ -508,7 +508,8 @@ class BuildEnvironment: if newmtime > mtime: logger.debug('[build target] outdated %r: %s -> %s', docname, - _format_modified_time(mtime), _format_modified_time(newmtime)) + _format_rfc3339_microseconds(mtime), + _format_rfc3339_microseconds(newmtime)) changed.add(docname) continue # finally, check the mtime of dependencies @@ -528,7 +529,8 @@ class BuildEnvironment: logger.debug( '[build target] outdated %r from dependency %r: %s -> %s', docname, deppath, - _format_modified_time(mtime), _format_modified_time(depmtime), + _format_rfc3339_microseconds(mtime), + _format_rfc3339_microseconds(depmtime), ) changed.add(docname) break @@ -756,26 +758,6 @@ class BuildEnvironment: 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( traversed: set[str], parent: str | None, diff --git a/sphinx/ext/viewcode.py b/sphinx/ext/viewcode.py index 39a08b66b..9991cf5d4 100644 --- a/sphinx/ext/viewcode.py +++ b/sphinx/ext/viewcode.py @@ -21,6 +21,7 @@ from sphinx.transforms.post_transforms import SphinxPostTransform from sphinx.util import logging from sphinx.util.display import status_iterator from sphinx.util.nodes import make_refnode +from sphinx.util.osutil import _last_modified_time if TYPE_CHECKING: 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) 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. return False except OSError: diff --git a/sphinx/jinja2glue.py b/sphinx/jinja2glue.py index 9746d3eab..ec75c6d96 100644 --- a/sphinx/jinja2glue.py +++ b/sphinx/jinja2glue.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from os import path from pprint import pformat 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.util import logging -from sphinx.util.osutil import mtimes_of_files +from sphinx.util.osutil import _last_modified_time if TYPE_CHECKING: from collections.abc import Callable, Iterator @@ -127,11 +128,11 @@ class SphinxFileSystemLoader(FileSystemLoader): with f: contents = f.read().decode(self.encoding) - mtime = path.getmtime(filename) + mtime = _last_modified_time(filename) def uptodate() -> bool: try: - return path.getmtime(filename) == mtime + return _last_modified_time(filename) == mtime except OSError: return False @@ -203,7 +204,13 @@ class BuiltinTemplateLoader(TemplateBridge, BaseLoader): return self.environment.from_string(source).render(context) 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 diff --git a/sphinx/util/__init__.py b/sphinx/util/__init__.py index ccc37ae60..4271f9089 100644 --- a/sphinx/util/__init__.py +++ b/sphinx/util/__init__.py @@ -29,10 +29,8 @@ from sphinx.util.nodes import ( # NoQA: F401 from sphinx.util.osutil import ( # NoQA: F401 SEP, copyfile, - copytimes, ensuredir, make_filename, - mtimes_of_files, os_path, relative_uri, ) diff --git a/sphinx/util/_timestamps.py b/sphinx/util/_timestamps.py new file mode 100644 index 000000000..32aca5232 --- /dev/null +++ b/sphinx/util/_timestamps.py @@ -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}' diff --git a/sphinx/util/i18n.py b/sphinx/util/i18n.py index b08ab29ff..2f2d500d9 100644 --- a/sphinx/util/i18n.py +++ b/sphinx/util/i18n.py @@ -15,7 +15,12 @@ from babel.messages.pofile import read_po from sphinx.errors import SphinxError from sphinx.locale import __ 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: import datetime as dt @@ -84,7 +89,7 @@ class CatalogInfo(LocaleFileInfoBase): def is_outdated(self) -> bool: return ( 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: with open(self.po_path, encoding=self.charset) as file_po: diff --git a/sphinx/util/osutil.py b/sphinx/util/osutil.py index 8552773f8..6c61390f1 100644 --- a/sphinx/util/osutil.py +++ b/sphinx/util/osutil.py @@ -16,7 +16,6 @@ from typing import TYPE_CHECKING from sphinx.locale import __ if TYPE_CHECKING: - from collections.abc import Iterator from pathlib import Path from types import TracebackType from typing import Any @@ -72,20 +71,25 @@ def ensuredir(file: str | os.PathLike[str]) -> None: os.makedirs(file, exist_ok=True) -def mtimes_of_files(dirnames: list[str], suffix: str) -> Iterator[float]: - for dirname in dirnames: - for root, _dirs, files in os.walk(dirname): - for sfile in files: - if sfile.endswith(suffix): - with contextlib.suppress(OSError): - yield path.getmtime(path.join(root, sfile)) +def _last_modified_time(source: 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. + """ + 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.""" - st = os.stat(source) - if hasattr(os, 'utime'): - os.utime(dest, (st.st_atime, st.st_mtime)) + st = source.stat() if isinstance(source, os.DirEntry) else os.stat(source) + os.utime(dest, ns=(st.st_atime_ns, st.st_mtime_ns)) def copyfile( @@ -128,7 +132,7 @@ def copyfile( shutil.copyfile(source, dest) with contextlib.suppress(OSError): # 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_-]') diff --git a/tests/test_extensions/test_ext_apidoc.py b/tests/test_extensions/test_ext_apidoc.py index 13c43dfdf..0d1b519ce 100644 --- a/tests/test_extensions/test_ext_apidoc.py +++ b/tests/test_extensions/test_ext_apidoc.py @@ -678,7 +678,7 @@ def test_remove_old_files(tmp_path: Path): (gen_dir / 'other.rst').write_text('', encoding='utf8') 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'} - 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)]) 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 diff --git a/tests/test_intl/test_intl.py b/tests/test_intl/test_intl.py index 3a6c517f9..c7653065a 100644 --- a/tests/test_intl/test_intl.py +++ b/tests/test_intl/test_intl.py @@ -40,7 +40,7 @@ def write_mo(pathname, 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)) return os.stat(target).st_mtime_ns diff --git a/tests/test_util/test_util_docutils.py b/tests/test_util/test_util_docutils.py index cefee412f..a5423a216 100644 --- a/tests/test_util/test_util_docutils.py +++ b/tests/test_util/test_util_docutils.py @@ -46,25 +46,25 @@ def test_SphinxFileOutput(tmpdir): filename = str(tmpdir / 'test.txt') output = SphinxFileOutput(destination_path=filename) output.write(content) - os.utime(filename, (0, 0)) + os.utime(filename, ns=(0, 0)) # overwrite it again 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 filename = str(tmpdir / 'test2.txt') output = SphinxFileOutput(destination_path=filename, overwrite_if_changed=True) output.write(content) - os.utime(filename, (0, 0)) + os.utime(filename, ns=(0, 0)) # overwrite it again 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) 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): diff --git a/tests/test_util/test_util_i18n.py b/tests/test_util/test_util_i18n.py index f2f324970..01d343d5f 100644 --- a/tests/test_util/test_util_i18n.py +++ b/tests/test_util/test_util_i18n.py @@ -38,7 +38,8 @@ def test_catalog_outdated(tmp_path): mo_file.write_text('#', encoding='utf8') 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