From 094cb04f35cff3b5e237d715c5fa599d37428ff1 Mon Sep 17 00:00:00 2001 From: 25rushilj <255478046+rushjais@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:39:34 -0700 Subject: [PATCH] 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 --- RELEASE.md | 3 +++ pelican/generators.py | 21 ++++++++++++------- pelican/tests/test_generators.py | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 RELEASE.md diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 00000000..f91d093b --- /dev/null +++ b/RELEASE.md @@ -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 diff --git a/pelican/generators.py b/pelican/generators.py index 515257c4..bad68c2f 100644 --- a/pelican/generators.py +++ b/pelican/generators.py @@ -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): diff --git a/pelican/tests/test_generators.py b/pelican/tests/test_generators.py index f566bc9c..6cf3b1ba 100644 --- a/pelican/tests/test_generators.py +++ b/pelican/tests/test_generators.py @@ -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."""