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."""