mirror of
https://github.com/getpelican/pelican.git
synced 2026-10-15 08:56:54 +02:00
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:
parent
3c69dc68d2
commit
094cb04f35
3 changed files with 52 additions and 7 deletions
3
RELEASE.md
Normal file
3
RELEASE.md
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue