Skip to content

Commit 5dfdf17

Browse files
authored
Merge pull request #188 from OpenSemanticLab/fix/172-package-page-order
fix(package): write packages.json pages in a deterministic order
2 parents f247a08 + 04494fa commit 5dfdf17

2 files changed

Lines changed: 159 additions & 3 deletions

File tree

‎src/osw/wtsite.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,6 +1060,13 @@ def create_page_package(self, param: CreatePagePackageParam):
10601060
WtSite.GetPageParam(titles=titles_to_fetch, parallel=param.parallel)
10611061
).pages
10621062
)
1063+
1064+
# get_page appends in completion order when fetching in parallel, so the
1065+
# page that claims a file referenced by several pages would vary between
1066+
# runs. Restore the configured order.
1067+
title_rank = {title: rank for rank, title in enumerate(added_titles)}
1068+
pages.sort(key=lambda p: (title_rank.get(p.title, len(title_rank)), p.title))
1069+
10631070
added_file_titles = []
10641071
page_dumps = {}
10651072
page_files = {}
@@ -1084,8 +1091,10 @@ def create_page_package(self, param: CreatePagePackageParam):
10841091
if debug and len(ignored_files_pages) > 0:
10851092
_logger.debug(f"Ignored: {ignored_files_pages}")
10861093
referenced_file_pages = included_file_pages
1087-
# find those files that are not already in the package
1088-
page_files[page.title] = list(
1094+
# find those files that are not already in the package. sorted()
1095+
# because the set difference and find_file_page_refs_in_slots
1096+
# both return an order that varies between runs
1097+
page_files[page.title] = sorted(
10891098
set(referenced_file_pages) - set(added_file_titles)
10901099
)
10911100
added_file_titles = list(set(added_file_titles + referenced_file_pages))

‎tests/test_wtsite_create_page_package.py‎

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1-
"""Unit tests for the PagePackageConfig.clear_content_dir purge flag.
1+
"""Unit tests for the PagePackageConfig.clear_content_dir purge flag, and for
2+
the deterministic ordering of packages.json (#172).
23
34
Regression guard for #42: WtSite.create_page_package used to unconditionally
45
shutil.rmtree() the content directory. clear_content_dir defaults to True (no
56
behaviour change), but setting it to False must keep any existing content.
7+
8+
Regression guard for #172: WtSite.create_page_package wrote the "pages" array
9+
of packages.json in an order that varied between runs whenever
10+
config.include_files was True, causing spurious diffs.
611
"""
712

13+
import json
814
import threading
915

1016
import pytest
@@ -27,6 +33,51 @@ def _make_fake_wtsite():
2733
return ws
2834

2935

36+
def _make_file_page(ws, title):
37+
"""A File: page whose dump() does not attempt a real file download."""
38+
page = WtPage(wtSite=ws, title=title, do_init=False)
39+
page.exists = True
40+
page.dump = lambda config, _title=title: package.PagePackagePage(
41+
name=_title.split(":")[-1], namespace="NS_FILE", slots={}
42+
)
43+
return page
44+
45+
46+
def _find_order_sensitive_file_titles():
47+
"""Two File: titles for which list(set([a, b])) != list(set([b, a])) under
48+
the current process's (randomized) string hash seed. Used to reproduce the
49+
hash-order dependent bug without relying on a specific PYTHONHASHSEED."""
50+
for i in range(1000):
51+
for j in range(i + 1, 1000):
52+
a, b = f"File:F{i}.png", f"File:F{j}.png"
53+
if list({a, b}) != list({b, a}):
54+
return a, b
55+
raise RuntimeError("could not find an order-sensitive pair of file titles")
56+
57+
58+
def _make_bundle_and_config(tmp_path, subdir, titles, include_files=True):
59+
target = tmp_path / subdir
60+
target.mkdir(parents=True, exist_ok=True)
61+
bundle = package.PagePackageBundle(
62+
packages={
63+
"TestPkg": package.PagePackage(
64+
globalID="org.test.TestPkg",
65+
description="test package",
66+
version="0.0.1",
67+
baseURL="https://example.org/",
68+
)
69+
}
70+
)
71+
return package.PagePackageConfig(
72+
name="TestPkg",
73+
config_path=target / "packages.json",
74+
content_path=target / "content",
75+
bundle=bundle,
76+
titles=titles,
77+
include_files=include_files,
78+
)
79+
80+
3081
def _make_config(tmp_path, clear_content_dir):
3182
bundle = package.PagePackageBundle(
3283
packages={
@@ -84,3 +135,99 @@ def test_create_page_package_honours_clear_content_dir(tmp_path, clear_content_d
84135
assert not marker.exists()
85136
else:
86137
assert marker.exists()
138+
139+
140+
def test_create_page_package_per_page_file_order_is_deterministic(tmp_path):
141+
"""Regression test for #172: the order in which a page's file references
142+
are discovered must not change where they end up in packages.json."""
143+
file_a, file_b = _find_order_sensitive_file_titles()
144+
extra_file = "File:Extra.png"
145+
146+
def _run(subdir, reversed_refs):
147+
ws = _make_fake_wtsite()
148+
item1 = WtPage(wtSite=ws, title="Item:OSW1", do_init=False)
149+
item2 = WtPage(wtSite=ws, title="Item:OSW2", do_init=False)
150+
order = [file_b, file_a] if reversed_refs else [file_a, file_b]
151+
item1.find_file_page_refs_in_slots = lambda slots=None: list(order)
152+
item2.find_file_page_refs_in_slots = lambda slots=None: [extra_file]
153+
154+
offline_pages = {
155+
"Item:OSW1": item1,
156+
"Item:OSW2": item2,
157+
file_a: _make_file_page(ws, file_a),
158+
file_b: _make_file_page(ws, file_b),
159+
extra_file: _make_file_page(ws, extra_file),
160+
}
161+
162+
config = _make_bundle_and_config(
163+
tmp_path, subdir, titles=["Item:OSW1", "Item:OSW2"]
164+
)
165+
ws.create_page_package(
166+
WtSite.CreatePagePackageParam(
167+
config=config,
168+
offline_pages=offline_pages,
169+
debug=False,
170+
)
171+
)
172+
return json.loads(config.config_path.read_text(encoding="utf-8"))
173+
174+
data_1 = _run("run1", reversed_refs=False)
175+
data_2 = _run("run2", reversed_refs=True)
176+
177+
names_1 = [p["name"] for p in data_1["packages"]["TestPkg"]["pages"]]
178+
names_2 = [p["name"] for p in data_2["packages"]["TestPkg"]["pages"]]
179+
180+
assert names_1 == names_2
181+
182+
# Item:OSW1's own files sit between its dump and Item:OSW2's dump, and are
183+
# in sorted order.
184+
expected_file_names = sorted([file_a.split(":")[-1], file_b.split(":")[-1]])
185+
assert names_1[1:3] == expected_file_names
186+
187+
188+
def test_create_page_package_shared_file_always_attaches_to_first_page(tmp_path):
189+
"""Regression test for #172: a file referenced by several pages must
190+
always be attached to the same (first-configured) page, regardless of the
191+
order in which get_page's parallel fetch happens to complete."""
192+
shared_file = "File:Shared.png"
193+
194+
def _run(subdir, reverse_fetch_order):
195+
ws = _make_fake_wtsite()
196+
item1 = WtPage(wtSite=ws, title="Item:OSW1", do_init=False)
197+
item2 = WtPage(wtSite=ws, title="Item:OSW2", do_init=False)
198+
item1.find_file_page_refs_in_slots = lambda slots=None: [shared_file]
199+
item2.find_file_page_refs_in_slots = lambda slots=None: [shared_file]
200+
201+
pages_by_title = {
202+
"Item:OSW1": item1,
203+
"Item:OSW2": item2,
204+
shared_file: _make_file_page(ws, shared_file),
205+
}
206+
207+
def fake_get_page(param):
208+
titles = list(param.titles)
209+
if reverse_fetch_order:
210+
titles = list(reversed(titles))
211+
selected = [pages_by_title[t] for t in titles]
212+
return WtSite.GetPageResult(pages=selected, errors=[])
213+
214+
ws.get_page = fake_get_page
215+
216+
config = _make_bundle_and_config(
217+
tmp_path, subdir, titles=["Item:OSW1", "Item:OSW2"]
218+
)
219+
ws.create_page_package(
220+
WtSite.CreatePagePackageParam(config=config, debug=False)
221+
)
222+
return json.loads(config.config_path.read_text(encoding="utf-8"))
223+
224+
data_normal = _run("normal", reverse_fetch_order=False)
225+
data_reversed = _run("reversed", reverse_fetch_order=True)
226+
227+
names_normal = [p["name"] for p in data_normal["packages"]["TestPkg"]["pages"]]
228+
names_reversed = [p["name"] for p in data_reversed["packages"]["TestPkg"]["pages"]]
229+
230+
assert names_normal == names_reversed
231+
# Shared.png must be attached to Item:OSW1 (first in config.titles), even
232+
# when Item:OSW2 is fetched first.
233+
assert names_normal == ["OSW1", "Shared.png", "OSW2"]

0 commit comments

Comments
 (0)