Process files in a stable, sorted order

Generator.get_files() returns a set, and the article, page, static, and
disabled-reader loops iterated it directly (the static generator iterates a
set union). Set iteration order depends on the interpreter's hash seed, so
the order of generator.staticfiles, and the order files are processed in,
changed from build to build.

Default output isn't affected, but anything that depends on that order is:
a plugin that lists static files gets a different file each build, and a
plugin that reads a static file's url during generation freezes its output
location, so which shared attachment gets frozen also varied.

Iterate in sorted order at the call sites. get_files() still returns a set,
so plugins using set operations on it are unaffected. Plugin-free output is
byte-identical before and after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
25rushilj 2026-10-07 20:39:34 -07:00
commit 094cb04f35
3 changed files with 52 additions and 7 deletions

3
RELEASE.md Normal file
View file

@ -0,0 +1,3 @@
Release type: patch
Process content and static files in a stable, sorted order so that build output no longer depends on the interpreter's hash seed

View file

@ -255,8 +255,10 @@ class Generator:
def _check_disabled_readers(self, paths, exclude: list[str] | None) -> None:
"""Log warnings for files that would have been processed by disabled readers."""
for fil in self.get_files(
paths, exclude=exclude, extensions=self.readers.disabled_extensions
for fil in sorted(
self.get_files(
paths, exclude=exclude, extensions=self.readers.disabled_extensions
)
):
self.readers.check_file(fil)
@ -701,8 +703,11 @@ class ArticlesGenerator(CachingGenerator):
all_articles = []
all_drafts = []
hidden_articles = []
for f in self.get_files(
self.settings["ARTICLE_PATHS"], exclude=self.settings["ARTICLE_EXCLUDES"]
for f in sorted(
self.get_files(
self.settings["ARTICLE_PATHS"],
exclude=self.settings["ARTICLE_EXCLUDES"],
)
):
article = self.get_cached_data(f, None)
if article is None:
@ -916,8 +921,10 @@ class PagesGenerator(CachingGenerator):
all_pages = []
hidden_pages = []
draft_pages = []
for f in self.get_files(
self.settings["PAGE_PATHS"], exclude=self.settings["PAGE_EXCLUDES"]
for f in sorted(
self.get_files(
self.settings["PAGE_PATHS"], exclude=self.settings["PAGE_EXCLUDES"]
)
):
page = self.get_cached_data(f, None)
if page is None:
@ -1035,7 +1042,7 @@ class StaticGenerator(Generator):
exclude=self.settings["STATIC_EXCLUDES"],
extensions=False,
)
for f in linked_files | found_files:
for f in sorted(linked_files | found_files):
# skip content source files unless the user explicitly wants them
if self.settings["STATIC_EXCLUDE_SOURCES"]:
if self._is_potential_source_path(f):

View file

@ -1211,6 +1211,41 @@ class TestStaticGenerator(unittest.TestCase):
def set_ancient_mtime(self, path, timestamp=1):
os.utime(path, (timestamp, timestamp))
def test_static_files_processed_in_stable_order(self):
"""Static files are processed in sorted order, not in the iteration
order of the sets returned by get_files(), which depends on the
interpreter's hash seed."""
class ReversedSet(set):
# Iterates in reverse sorted order, standing in for an unlucky
# PYTHONHASHSEED; set unions keep the behavior.
def __iter__(self):
return iter(sorted(super().__iter__(), reverse=True))
def __or__(self, other):
return ReversedSet(set(self) | set(other))
__ror__ = __or__
settings = get_settings(PATH=self.content_path, STATIC_PATHS=[""])
context = get_context(settings)
generator = StaticGenerator(
context=context,
settings=settings,
path=settings["PATH"],
output_path=self.temp_output,
theme=settings["THEME"],
)
get_files = generator.get_files
generator.get_files = lambda *args, **kwargs: ReversedSet(
get_files(*args, **kwargs)
)
generator.generate_context()
source_paths = [sf.source_path for sf in context["staticfiles"]]
self.assertGreater(len(source_paths), 1)
self.assertEqual(source_paths, sorted(source_paths))
def test_theme_static_paths_dirs(self):
"""Test that StaticGenerator properly copies also files mentioned in
TEMPLATE_STATIC_PATHS, not just directories."""