chore: merge installer fixes, fourth pass

This commit is contained in:
Abdessamad Derraz committed 2026-11-06 10:28:14 +01:00
commit a29b836730
27 files changed
+888 -158

No files matched your search

+277 -6
View File
@@ -451,6 +451,64 @@ class AMissingDestIsSaid(unittest.TestCase):
self.assertIn("does not exist", out)
class ADestThatIsAFileIsNamed(unittest.TestCase):
"""--dest naming a file, or a path under one, failed at mkdir with
EEXIST or ENOTDIR, and the run said this user cannot write there and
offered a sudo command that fails the same way."""
def _run(self, dest: Path) -> subprocess.CompletedProcess:
env = dict(os.environ, RETROBIOS_OS="linux")
return subprocess.run(
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "retroarch",
"--dest", str(dest), "--manifest", str(REPO_ROOT / "install" / "retroarch.json"),
"--system", "nintendo-gba", "--yes", "--events", "--offline"],
env=env, capture_output=True, text=True, timeout=120, check=False,
stdin=subprocess.DEVNULL)
def test_the_file_is_named_and_sudo_is_not_offered(self):
root = Path(_tempdir(self))
(root / "scph5501.bin").write_bytes(b"x")
for dest, words in (
(root / "scph5501.bin", f"{root / 'scph5501.bin'} is a file, not a folder"),
(root / "scph5501.bin" / "sub",
f"cannot be a folder: {root / 'scph5501.bin'} is a file"),
):
with self.subTest(dest=dest):
proc = self._run(dest)
self.assertEqual(proc.returncode, 1, proc.stderr)
self.assertIn(words, proc.stderr)
self.assertNotIn("sudo", proc.stderr)
errors = [json.loads(line) for line in proc.stdout.splitlines()
if line.startswith("{") and '"error"' in line]
self.assertEqual([(e["stage"], e["reason"]) for e in errors],
[("dest", "not_a_folder")])
self.assertEqual((root / "scph5501.bin").read_bytes(), b"x")
class SaveFilesAListHoldsNoneOfAreNotPromised(unittest.TestCase):
"""RetroPie, ROCKNIX, AmberELEC, EmuELEC and RomM keep their save
directory in no fixed place: their lists leave those files out with no
hash or address. The run said "Name it with --save-dir", and said it
again with --save-dir given, which installs none of them."""
def test_the_reason_is_said_and_no_option_is_offered(self):
manifest = json.loads((REPO_ROOT / "install" / "retropie.json").read_text())
self.assertIsNone(manifest.get("save_directory"))
root = Path(_tempdir(self))
env = dict(os.environ, RETROBIOS_OS="linux")
for extra in ([], ["--save-dir", str(root / "saves")]):
with self.subTest(extra=extra):
proc = _install_run(
env, "--platform", "retropie", "--dest", str(root / "bios"),
"--manifest", "install/retropie.json", "--offline", "--no-input",
"--yes", "--emulator", "azahar", *extra)
out = proc.stdout + proc.stderr
self.assertIn("read from RetroPie's save directory left out: RetroPie "
"keeps it in no fixed place, and its file list holds none "
"of them.", out)
self.assertNotIn("Name it with --save-dir", out)
class AFolderThatRefusesWritesIsNamed(unittest.TestCase):
"""A read-only BIOS folder ended the run on a traceback (a subfolder to
create), or on three retries per file and "once that is resolved", and a
@@ -2673,6 +2731,52 @@ class RetroArchWhereItReallyLives(unittest.TestCase):
self.assertIn(("retroarch", support / "system"), found)
class LinuxRetroArchFollowsXdgConfigHome(unittest.TestCase):
"""RetroArch on Linux keeps its config under $XDG_CONFIG_HOME/retroarch
when the variable is set, then falls back to ~/.retroarch.cfg
(file_path_special.c fill_pathname_application_data, configuration.c
open_default_config_file). Only ~/.config/retroarch was read: a home
with its own XDG layout detected nothing."""
def _env(self) -> tuple[Path, Path]:
home = Path(_tempdir(self))
xdg = Path(_tempdir(self))
patcher = unittest.mock.patch.dict(os.environ, {"HOME": str(home)}, clear=True)
patcher.start()
self.addCleanup(patcher.stop)
return home, xdg
def test_the_config_under_xdg_config_home_is_read(self):
home, xdg = self._env()
(xdg / "retroarch").mkdir()
(xdg / "retroarch" / "retroarch.cfg").write_text('system_directory = "/srv/bios"\n')
# A config under ~/.config is not the one this RetroArch opens.
(home / ".config" / "retroarch").mkdir(parents=True)
(home / ".config" / "retroarch" / "retroarch.cfg").write_text(
'system_directory = "/elsewhere"\n')
os.environ["XDG_CONFIG_HOME"] = str(xdg)
found = install.detect_platforms("linux")
self.assertIn(("retroarch", Path("/srv/bios")), found)
self.assertNotIn(("retroarch", Path("/elsewhere")), found)
self.assertEqual(install._os_default_dests("linux")["retroarch"],
xdg / "retroarch" / "system")
def test_home_retroarch_cfg_is_the_fallback(self):
home, xdg = self._env()
os.environ["XDG_CONFIG_HOME"] = str(xdg)
(home / ".retroarch.cfg").write_text('system_directory = "default"\n')
found = install.detect_platforms("linux")
self.assertIn(("retroarch", xdg / "retroarch" / "system"), found)
def test_without_the_variable_the_folder_is_under_dot_config(self):
home, _ = self._env()
(home / ".config" / "retroarch").mkdir(parents=True)
(home / ".config" / "retroarch" / "retroarch.cfg").write_text(
'system_directory = "default"\n')
found = install.detect_platforms("linux")
self.assertIn(("retroarch", home / ".config" / "retroarch" / "system"), found)
class NoPythonNamesThisSystemsCommand(unittest.TestCase):
"""Without Python the bootstrap said `sudo apt install python3` on every
system: Termux has no sudo and installs with `pkg install python`, and
@@ -3790,6 +3894,96 @@ class DataDirectoriesAreInstalled(unittest.TestCase):
(len(body), len(body)))
emit.assert_not_called()
def test_a_cut_archive_resumes_where_it_stopped(self):
"""A data archive cut at 90% was deleted and fetched again from
zero, on the retry and on the next run, while the run said that
nothing was lost."""
work = Path(_tempdir(self))
body = self._zip(work).read_bytes()
keep = work / ".dolphin-sys.archive.part"
requests: list[dict] = []
class Response(io.BytesIO):
def __init__(self, data, status, headers):
super().__init__(data)
self.status, self.headers = status, headers
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def urlopen(request, timeout=None):
sent = dict(request.header_items())
requests.append(sent)
if "Range" in sent:
start = int(sent["Range"][len("bytes="):-1])
if sent.get("If-range") != '"v1"':
return Response(body, 200, {"ETag": '"v2"'})
return Response(body[start:], 206, {
"Content-Range": f"bytes {start}-{len(body) - 1}/{len(body)}",
"Content-Length": str(len(body) - start), "ETag": '"v1"'})
cut = len(body) * 9 // 10
class Cut(Response):
def read(self, size=-1):
if self.tell() >= cut:
raise ConnectionResetError("cut")
return super().read(min(size, cut - self.tell()))
return Cut(body, 200, {"Content-Length": str(len(body)), "ETag": '"v1"'})
with unittest.mock.patch.object(install.urllib.request, "urlopen", urlopen), \
unittest.mock.patch.object(install, "_network_allowed", lambda url: None), \
unittest.mock.patch.object(install, "_retry_delay", lambda *a: 0):
# The next run, after a stop: what the cut attempt wrote is kept.
with unittest.mock.patch.object(install, "MAX_RETRIES", 1), \
self.assertRaises(ConnectionResetError):
install._fetch_archive("https://example.invalid/D.zip", work, None, keep)
self.assertEqual(keep.stat().st_size, len(body) * 9 // 10)
path = install._fetch_archive("https://example.invalid/D.zip", work, None, keep)
self.assertEqual(path.read_bytes(), body)
self.assertEqual(requests[-1]["Range"], f"bytes={len(body) * 9 // 10}-")
self.assertEqual(requests[-1]["If-range"], '"v1"')
def test_a_changed_archive_is_fetched_whole(self):
work = Path(_tempdir(self))
body = self._zip(work).read_bytes()
keep = work / ".dolphin-sys.archive.part"
keep.write_bytes(b"older revision")
keep.with_name(keep.name + ".source").write_text(
'https://example.invalid/D.zip\n"v0"\n')
class Whole(io.BytesIO):
status, headers = 200, {"ETag": '"v1"'}
def __enter__(self):
return self
def __exit__(self, *exc):
return False
with unittest.mock.patch.object(install.urllib.request, "urlopen",
lambda request, timeout=None: Whole(body)), \
unittest.mock.patch.object(install, "_network_allowed", lambda url: None):
path = install._fetch_archive("https://example.invalid/D.zip", work, None, keep)
self.assertEqual(path.read_bytes(), body)
def test_a_tree_read_whole_leaves_no_archive(self):
work = Path(_tempdir(self))
archive = self._zip(work)
bios = work / "system"
bios.mkdir()
def fetch(url, directory, progress=None, keep=None):
shutil.copy(archive, keep)
keep.with_name(keep.name + ".source").write_text(f"{url}\n\"v1\"\n")
return keep
with unittest.mock.patch.object(install, "_fetch_archive", fetch):
install.install_data_directory(self._entry(), bios)
self.assertEqual([p.name for p in (bios / "dolphin-emu").iterdir()], ["Sys"])
def test_the_run_ends_on_the_archive_bytes_too(self):
from test_install_selection import FakeRepository # noqa: PLC0415
@@ -3811,13 +4005,44 @@ class DataDirectoriesAreInstalled(unittest.TestCase):
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertIn(f"{install.format_size(bios + archive)} downloaded in", proc.stdout)
def test_the_plan_announces_the_archive_not_the_tree(self):
"""yaps2's tarball is 21.2 MB for a 6.5 MB tree, Cemu's 21.4 MB for
9.9 KB: the plan announced the tree, and the run downloaded far more."""
from test_install_selection import FakeRepository # noqa: PLC0415
member = b"profile"
archive_size = 21_360_579
with FakeRepository() as repo:
listed = json.loads((repo.root / "install" / "retroarch.json").read_text())
listed["data_directories"] = [{
"ref": "cemu-gameprofiles", "dest": "cemu", "type": "tarball",
"url": "https://example.invalid/Cemu.tar.gz", "source_path": "Cemu-1/bin",
"systems": ["nintendo-gba"], "archive_size": archive_size,
"files": {"gameProfiles/a.ini": {"sha1": hashlib.sha1(member).hexdigest(),
"size": len(member)}}}]
(repo.root / "install" / "retroarch.json").write_text(json.dumps(listed))
proc = repo.run("--no-input", "--dry-run")
bios = sum(len(payload) for payload, *_ in FakeRepository.PAYLOADS.values())
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertIn(f"({install.format_size(bios + archive_size)})", proc.stdout)
self.assertIn(f"(a {install.format_size(archive_size)} archive)", proc.stdout)
def test_a_pack_or_an_old_list_counts_the_files_themselves(self):
needed = {"a": {"size": 7}}
entry = {"archive_size": 21_360_579}
self.assertEqual(install.data_fetch_bytes(entry, needed), 21_360_579)
self.assertEqual(install.data_fetch_bytes({}, needed), 7)
self.assertEqual(install.data_fetch_bytes(entry, {}), 0)
with unittest.mock.patch.object(install.LOCAL, "pack", object()):
self.assertEqual(install.data_fetch_bytes(entry, needed), 7)
def test_verified_members_land_the_rest_are_said(self):
work = Path(_tempdir(self))
archive = self._zip(work)
bios = work / "system"
bios.mkdir()
def fetch(url, directory, progress=None):
def fetch(url, directory, progress=None, keep=None):
copy = Path(directory) / "a.zip"
shutil.copy(archive, copy)
return copy
@@ -3853,8 +4078,9 @@ class DataDirectoriesAreInstalled(unittest.TestCase):
def members(_archive, _entry):
yield "Sys/GC/font.bin", 4, lambda: io.BufferedReader(Endless())
with unittest.mock.patch.object(install, "_fetch_archive",
lambda url, d, progress=None: Path(d) / "a"), \
with unittest.mock.patch.object(
install, "_fetch_archive",
lambda url, d, progress=None, keep=None: Path(d) / "a"), \
unittest.mock.patch.object(install, "_archive_members", members):
counts = install.install_data_directory(entry, work)
self.assertEqual(counts["changed"], 1)
@@ -3881,8 +4107,9 @@ class DataDirectoriesAreInstalled(unittest.TestCase):
def members(_archive, _entry):
yield "Sys/GC/font.bin", 4, lambda: io.BufferedReader(Broken())
with unittest.mock.patch.object(install, "_fetch_archive",
lambda url, d, progress=None: Path(d) / "a"), \
with unittest.mock.patch.object(
install, "_fetch_archive",
lambda url, d, progress=None, keep=None: Path(d) / "a"), \
unittest.mock.patch.object(install, "_archive_members", members):
counts = install.install_data_directory(entry, work)
self.assertTrue(counts["error"])
@@ -4422,6 +4649,50 @@ def _install_run(env: dict, *argv: str, **kwargs) -> subprocess.CompletedProcess
)
class AListingAppliesItsOptionsOrRefusesThem(unittest.TestCase):
"""--list-systems --list-cores printed the systems alone, and
--list-platforms, --list-targets and --list-emulators printed the same
list with --system, --region or --core as without, code 0."""
def test_two_listings_or_an_option_a_listing_ignores_are_refused(self):
env = dict(os.environ, RETROBIOS_OS="linux")
given = ["--platform", "retroarch", "--offline", "--no-input",
"--manifest", "install/retroarch.json"]
for argv, words in (
([*given, "--list-systems", "--list-cores"],
"--list-systems and --list-cores each print a list"),
([*given, "--list-cores", "--list-emulators"],
"--list-cores and --list-emulators each print a list"),
(["--list-platforms", "--list-systems", "--platform", "retroarch"],
"--list-platforms and --list-systems each print a list"),
(["--list-platforms", "--region", "us"],
"--list-platforms prints the whole list, which --region does not narrow"),
(["--list-platforms", "--platform", "retroarch"],
"--list-platforms prints the whole list, which --platform does not narrow"),
([*given, "--list-targets", "--system", "sony-playstation"],
"--list-targets prints the whole list, which --system does not narrow"),
([*given, "--list-targets", "--target", "switch"],
"--list-targets prints the whole list, which --target does not narrow"),
([*given, "--list-emulators", "--system", "sony-playstation"],
"--list-emulators prints the whole list, which --system does not narrow"),
([*given, "--list-emulators", "--core", "beetle_psx"],
"--list-emulators prints the whole list, which --core does not narrow"),
):
with self.subTest(argv=argv):
proc = _install_run(env, *argv)
self.assertEqual(proc.returncode, 2, proc.stdout + proc.stderr)
self.assertIn(words, proc.stderr)
def test_the_options_a_listing_reads_still_apply(self):
env = dict(os.environ, RETROBIOS_OS="linux")
given = ["--platform", "retroarch", "--offline", "--no-input",
"--manifest", "install/retroarch.json"]
whole = _install_run(env, *given, "--list-cores")
narrowed = _install_run(env, *given, "--list-cores", "--system", "nintendo-gba")
self.assertEqual((whole.returncode, narrowed.returncode), (0, 0), narrowed.stderr)
self.assertLess(len(narrowed.stdout), len(whole.stdout))
class AMissingPackNamesTheOptionAndThePacksBeside(unittest.TestCase):
"""A typo in --from-pack said "Error: X does not exist": not the option,
not the pack sitting next to the name typed."""
@@ -6562,7 +6833,7 @@ class AFileYouHadIsKept(unittest.TestCase):
"type": "zip", "files": {"Sys/a.ini": {
"sha1": hashlib.sha1(self.NEW).hexdigest(), "size": len(self.NEW)}}}
def fetch(url, directory, progress=None):
def fetch(url, directory, progress=None, keep=None):
copy = Path(directory) / "a.zip"
shutil.copy(archive, copy)
return copy
+6 -1
View File
@@ -778,7 +778,12 @@ class OfflineMakesNoRequest(Scratch):
self.assertIn("--offline", proc.stderr)
def test_a_listing_and_a_pack_need_no_network_anyway(self):
proc = self.run_installer("--offline", "--list-platforms")
# --platform alone with it is refused: the listing names every one.
proc = subprocess.run(
[sys.executable, str(REPO_ROOT / "install.py"), "--offline", "--list-platforms"],
env=_offline_env(), capture_output=True, text=True, timeout=120,
check=False, stdin=subprocess.DEVNULL,
)
self.assertEqual(proc.returncode, 0, proc.stderr)
+55 -9
View File
@@ -2967,12 +2967,44 @@ class SaveDirectoryLeftOut(unittest.TestCase):
UNPLACED = {"reason": "no_save_directory", "label": "no save directory", "files": 2,
"required": 0}
def _ended(self, manifest: dict) -> tuple:
state = fresh_state()
state.stack = ["setup"]
with mock.patch.object(install.Path, "exists", return_value=True):
install.tui_update(state, Key("enter"))
install.tui_receive(state, ("manifest", "retroarch", manifest))
summary = {"event": "summary", "mode": "check", "installed": 0, "up_to_date": 0,
"pending": 5, "failed": [], "skipped": [self.UNPLACED], "left_out": [],
"kept": [], "interrupted": False}
for event in [*FILES, *SYSTEMS, CHECK, summary]:
install.tui_receive(state, ("event", state.run_id, event))
with mock.patch.object(install, "_tui_folder_facts", return_value={
"free": 50 * 1024 ** 3, "writable": True, "cores": []}):
install.tui_receive(state, ("ended", state.run_id, 1, []))
return state, summary
def test_a_list_that_holds_none_of_them_offers_no_folder(self):
"""RetroPie's list leaves its save files out with no hash or address:
--save-dir installs none of them, and was advised again each run."""
state, summary = self._ended(MANIFEST)
line = ("2 files read from RetroArch's save directory left out: RetroArch keeps "
"it in no fixed place, and its file list holds none of them.")
plan = " ".join(" ".join(install.tui_view(state, 120, 40)).split())
self.assertIn(line, plan)
self.assertNotIn("--save-dir", plan)
state.stack.append("result")
state.code = 0
state.summary = {**summary, "mode": "install", "installed": 5}
self.assertEqual([text for text in install.tui_outcome(state)[1] if "save" in text],
[line])
def test_files_left_out_of_the_save_directory_are_said(self):
state = fresh_state()
state.stack = ["setup"]
with mock.patch.object(install.Path, "exists", return_value=True):
install.tui_update(state, Key("enter"))
install.tui_receive(state, ("manifest", "retroarch", MANIFEST))
install.tui_receive(state, ("manifest", "retroarch", {
**MANIFEST, "save_directory": {"folder": "saves", "by_core": True}}))
summary = {"event": "summary", "mode": "check", "installed": 0, "up_to_date": 0,
"pending": 5, "failed": [], "skipped": [self.UNPLACED], "left_out": [],
"kept": [], "interrupted": False}
@@ -3834,6 +3866,8 @@ class BackTicksAndSaveFolder(unittest.TestCase):
def test_the_save_folder_remedy_is_the_same_on_both_screens(self):
summary = {"skipped": [{"reason": "no_save_directory", "files": 1}]}
state = checked_state()
state.manifest = {**state.manifest, "save_directory": {"folder": "saves",
"by_core": True}}
lines = install._unplaced_lines(summary, state)
self.assertIn("Options, Save folder (--save-dir)", lines[0])
@@ -5898,7 +5932,10 @@ def _card_from_rules(root: Path, setup: install.CardSetup) -> Path:
(root / "bios").mkdir(exist_ok=True)
for value in rule.values:
(root / value).parent.mkdir(parents=True, exist_ok=True)
if rule.method == "games_part" and "." not in PurePosixPath(value).name:
# A marker the BIOS folder sits under is a folder on the card
# (muOS's MUOS/bios): written as a file, no folder fits below it.
if (rule.method == "games_part" and "." not in PurePosixPath(value).name
or str(setup.below).startswith(f"{value}/")):
(root / value).mkdir(exist_ok=True)
else:
(root / value).write_text("", encoding="utf-8")
@@ -9763,14 +9800,21 @@ class ScreenRowsAndFilters(unittest.TestCase):
from test_install_offline import PAYLOAD, _offline_env, _pack
blocker = self.root / "file"
blocker.write_text("not a folder")
if os.name == "nt" or os.geteuid() == 0:
self.skipTest("a folder that refuses writes is POSIX, and root writes anyway")
# A file in the way is refused before the plan, as not_a_folder.
blocker = self.root / "closed"
blocker.mkdir()
pack = _pack(self.root, "RetroArch_v1_BIOS_Pack.zip", PAYLOAD)
proc = subprocess.run(
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "retroarch",
"--dest", str(blocker / "system"), "--from-pack", str(pack), "--events"],
env=_offline_env(), capture_output=True, text=True, timeout=120, check=False,
stdin=subprocess.DEVNULL)
os.chmod(blocker, 0o500)
try:
proc = subprocess.run(
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "retroarch",
"--dest", str(blocker / "system"), "--from-pack", str(pack), "--events"],
env=_offline_env(), capture_output=True, text=True, timeout=120,
check=False, stdin=subprocess.DEVNULL)
finally:
os.chmod(blocker, 0o700)
events = [json.loads(line) for line in proc.stdout.splitlines()]
kinds = [event["event"] for event in events]
self.assertEqual(kinds[-3:], ["error", "summary", "end"], proc.stderr)
@@ -11836,6 +11880,8 @@ class OneChoicePerRun(unittest.TestCase):
def test_the_farewell_names_the_option_not_the_screen(self):
state = _installed_state(SYSTEM_DIR)
state.manifest = {**state.manifest, "save_directory": {"folder": "saves",
"by_core": True}}
state.summary = {**state.summary, "skipped": [
{"reason": "no_save_directory", "label": "", "files": 1, "required": False}]}
farewell = " ".join(install.tui_farewell(state))
+47 -2
View File
@@ -93,6 +93,26 @@ class RefreshConcurrency(unittest.TestCase):
)
self.assertEqual(seen, [True])
def test_the_archive_size_is_recorded_without_replacing_the_tree(self):
"""The installer announces a tree's download as the archive's size:
a tarball extracted to 10 KB is a download of 21 MB. A tree current
before the size was kept is measured, its files left as they are."""
payload = _zip_bytes({"a.bin": b"x" * 100})
entry = {"source_type": "zip", "source_url": "https://x/pak.zip",
"local_cache": str(self.cache)}
with mock.patch.object(rdd.urllib.request, "urlopen",
lambda *a, **k: _Response(payload)), \
mock.patch.object(rdd, "_get_remote_etag", return_value="e1"):
self.assertTrue(rdd.refresh_entry("pak", entry, versions_path=self.versions))
self.assertEqual(rdd._load_versions(self.versions)["pak"]["archive_size"],
len(payload))
rdd._record_version("pak", {"sha": "e1"}, self.versions)
(self.cache / "a.bin").write_bytes(b"kept")
self.assertFalse(rdd.refresh_entry("pak", entry, versions_path=self.versions))
self.assertEqual(rdd._load_versions(self.versions)["pak"],
{"sha": "e1", "archive_size": len(payload)})
self.assertEqual((self.cache / "a.bin").read_bytes(), b"kept")
def test_a_current_tree_is_not_waited_for(self):
"""A pipeline reads every tree for its whole run. A build beside it
waited on the exclusive lock before asking the remote, which would
@@ -100,7 +120,7 @@ class RefreshConcurrency(unittest.TestCase):
import fcntl # noqa: PLC0415
self.cache.mkdir(parents=True)
rdd._record_version("pak", {"sha": "e1"}, self.versions)
rdd._record_version("pak", {"sha": "e1", "archive_size": 1}, self.versions)
entry = {"source_type": "zip", "source_url": "https://x/pak.zip",
"local_cache": str(self.cache)}
result: list = []
@@ -258,7 +278,7 @@ class DryRunReport(unittest.TestCase):
def test_the_remote_tag_is_shown_beside_the_cached_one(self):
with tempfile.TemporaryDirectory() as tmp:
versions = Path(tmp) / "versions.json"
versions.write_text(json.dumps({"k": {"etag": '"2725165595"'}}))
versions.write_text(json.dumps({"k": {"etag": '"2725165595"', "archive_size": 1}}))
entry = {"source_type": "zip", "source_url": "https://x.invalid/a.zip",
"local_cache": tmp}
with mock.patch.object(rdd, "_get_remote_etag", return_value='"2685251384"'), \
@@ -278,5 +298,30 @@ class DryRunReport(unittest.TestCase):
)
class TheManifestCarriesTheArchiveSize(unittest.TestCase):
"""The size refresh_data_dirs recorded reaches the install list, which
the installer announces as the tree's download."""
def test_recorded_size_is_written_and_an_unrecorded_one_left_out(self):
from generate_pack import _manifest_data_directories # noqa: PLC0415
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
data = Path(tmp) / "data"
for ref in ("pak", "old"):
(data / ref).mkdir(parents=True)
(data / ref / "a.bin").write_bytes(b"abc")
(data / ".versions.json").write_text(json.dumps(
{"pak": {"sha": "e1", "archive_size": 21_360_579}, "old": {"sha": "e2"}}))
registry = {ref: {"source_type": "zip", "source_url": f"https://x/{ref}.zip",
"local_cache": str(data / ref)} for ref in ("pak", "old")}
members = [(str(data / ref / "a.bin"), f"{ref}/a.bin") for ref in ("pak", "old")]
refs = {f"{ref}/a.bin": (ref, ("s",), ref) for ref in ("pak", "old")}
listed = {entry["ref"]: entry for entry in
_manifest_data_directories(members, refs, registry, "")}
self.assertEqual(listed["pak"]["archive_size"], 21_360_579)
self.assertNotIn("archive_size", listed["old"])
if __name__ == "__main__":
unittest.main()