diff --git a/sphinx/builders/__init__.py b/sphinx/builders/__init__.py index a5d5a1ef6..b6471d3d5 100644 --- a/sphinx/builders/__init__.py +++ b/sphinx/builders/__init__.py @@ -496,11 +496,7 @@ class Builder: doctree = publisher.document # store time of reading, for outdated files detection - # (Some filesystems have coarse timestamp resolution; - # therefore time.time() can be older than filesystem's timestamp. - # For example, FAT32 has 2sec timestamp resolution.) - self.env.all_docs[docname] = max(time.time(), - path.getmtime(self.env.doc2path(docname))) + self.env.all_docs[docname] = time.time_ns() // 1_000 # cleanup self.env.temp_data.clear() diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py index b1d0dc4b4..d2beffdd9 100644 --- a/sphinx/environment/__init__.py +++ b/sphinx/environment/__init__.py @@ -7,7 +7,7 @@ import os import pickle from collections import defaultdict from copy import copy -from datetime import datetime +from datetime import datetime, timezone from os import path from typing import TYPE_CHECKING, Any, Callable, Generator, Iterator @@ -55,7 +55,7 @@ default_settings: dict[str, Any] = { # This is increased every time an environment attribute is added # or changed to properly invalidate pickle files. -ENV_VERSION = 57 +ENV_VERSION = 58 # config status CONFIG_OK = 1 @@ -166,9 +166,9 @@ class BuildEnvironment: # All "docnames" here are /-separated and relative and exclude # the source suffix. - # docname -> mtime at the time of reading + # docname -> time of reading (in integer microseconds) # contains all read docnames - self.all_docs: dict[str, float] = {} + self.all_docs: dict[str, int] = {} # docname -> set of dependent file # names, relative to documentation root self.dependencies: dict[str, set[str]] = defaultdict(set) @@ -481,12 +481,14 @@ class BuildEnvironment: continue # check the mtime of the document mtime = self.all_docs[docname] - newmtime = path.getmtime(self.doc2path(docname)) + newmtime = _last_modified_time(self.doc2path(docname)) if newmtime > mtime: + # convert integer microseconds to floating-point seconds, + # and then to timezone-aware datetime objects. + mtime_dt = datetime.fromtimestamp(mtime / 1_000_000, tz=timezone.utc) + newmtime_dt = datetime.fromtimestamp(mtime / 1_000_000, tz=timezone.utc) logger.debug('[build target] outdated %r: %s -> %s', - docname, - datetime.utcfromtimestamp(mtime), - datetime.utcfromtimestamp(newmtime)) + docname, mtime_dt, newmtime_dt) changed.add(docname) continue # finally, check the mtime of dependencies @@ -497,7 +499,7 @@ class BuildEnvironment: if not path.isfile(deppath): changed.add(docname) break - depmtime = path.getmtime(deppath) + depmtime = _last_modified_time(deppath) if depmtime > mtime: changed.add(docname) break @@ -728,3 +730,18 @@ class BuildEnvironment: for domain in self.domains.values(): domain.check_consistency() 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)