fix: read a typed folder and quit with 130

A folder typed in quotes at the BIOS folder question was used with its
quotes, and its missing parents were created without the question
--dest asks. The quotes go, ~ and variables expand on every system, and
a default folder that does not exist is named as such, not as found.
q at a menu now leaves with 130 like Ctrl+C, and Ctrl+C before anything
started no longer offers to carry on.
This commit is contained in:
Abdessamad Derraz committed 2026-10-23 13:50:16 +02:00
1 parent 69179c95de
commit 84646d00a6
7 files changed
+165 -23

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "81b72f480566791ccf1db34d20635bc7886190f2a21b150507c526130af1d499"
$defaultInstallSha256 = "1238dfb08b3168992cf4e390358540c6c1bbadf23ae5b74fb34e9101a2a083ef"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+48 -15
View File
@@ -283,6 +283,8 @@ def _os_default_dests(os_type: str) -> dict[str, Path]:
# Set when the run is interrupted, so queued downloads end instead of draining
# the pool: every future is submitted before the first one completes.
_stop = threading.Event()
# Set once a run starts writing: Ctrl+C before that has nothing to carry on.
_writing = threading.Event()
class _Interrupted(Exception):
"""Raised inside a worker once the run has been stopped."""
@@ -4852,6 +4854,7 @@ def download_files(
failed: list[tuple[str, str]] = []
interrupted = False
_stop.clear()
_writing.set()
term = term or Terminal()
if progress is None:
progress = Progress(len(files), sum(f.get("size", 0) for f in files), term)
@@ -5018,6 +5021,7 @@ def install_data_directory(entry: dict, bios_path: Path) -> dict:
"not_kept": []}
if not needed:
return counts
_writing.set()
try:
root.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=root) as scratch:
@@ -5393,7 +5397,7 @@ def _choose_platform(title: str, default: str = "") -> str:
if not choice and default:
return default
if choice.lower() == "q":
sys.exit(0)
_quit()
if choice.isdigit() and 1 <= int(choice) <= len(AVAILABLE_PLATFORMS):
return AVAILABLE_PLATFORMS[int(choice) - 1]
key = _name_key(choice)
@@ -5416,10 +5420,24 @@ def _prompt_manual_platform(os_type: str) -> list[tuple[str, Path]]:
)
if default_dest is not None:
break
dest = Path(raw).expanduser() if raw else default_dest
if os_type == "windows" and raw:
dest = Path(os.path.expandvars(raw))
return [(plat, dest)]
if raw:
return [(plat, _typed_folder(raw))]
state = "" if default_dest.exists() else ". It does not exist yet and will be created"
print(f" {display_name(plat)}'s default BIOS folder: {default_dest}{state}.")
return [(plat, default_dest)]
def _typed_folder(raw: str) -> Path:
"""A folder typed at a question, read as a shell reads an argument.
Explorer's "Copy as path" puts quotes around it, and users quote a path
with spaces: kept, they sent the files into a folder named `"` under
the current directory, and the run said Done.
"""
text = raw.strip()
if len(text) >= 2 and text[0] == text[-1] and text[0] in "'\"":
text = text[1:-1].strip()
return Path(os.path.expandvars(os.path.expanduser(text)))
# Platforms with no install location of their own: the BIOS folder sits
@@ -5500,7 +5518,7 @@ def _prompt_platform_choice(
while True:
choice = _ask("> ").lower()
if choice == "q":
sys.exit(0)
_quit()
idx = int(choice) if choice.isdigit() else 0
if 1 <= idx <= len(platforms):
return [platforms[idx - 1]]
@@ -5524,6 +5542,12 @@ def _ask(prompt: str) -> str:
raise KeyboardInterrupt from None
def _quit() -> None:
"""q at a question ends the run where Ctrl+C does, on 130: with 0,
`install.sh && retroarch` started RetroArch on no BIOS."""
sys.exit(130)
def _sized(files: list[dict]) -> str:
return f"{_plural(len(files), 'file')}, {format_size(sum(f.get('size', 0) for f in files))}"
@@ -5543,7 +5567,7 @@ def _pick(title: str, rows: list[tuple[str, list[dict]]], ordered: bool = False)
if not text:
return []
if text.lower() == "q":
sys.exit(0)
_quit()
try:
chosen = parse_selection(text, len(rows), ordered=ordered)
except ValueError as exc:
@@ -5579,7 +5603,7 @@ def _prompt_custom_selection(
if answer == "":
return None
if answer == "q":
sys.exit(0)
_quit()
if answer == "i" and installed is not None:
return INSTALLED_ONLY
if answer == "c":
@@ -6971,7 +6995,7 @@ def _open_local_sources(args: argparse.Namespace) -> None:
sys.exit(1)
def _missing_dest(dest: Path, ask: bool) -> None:
def _missing_dest(dest: Path, ask: bool, named: str = "named with --dest") -> None:
"""Say that a --dest folder does not exist before anything goes there.
A typo (~/.config/retroarh/system) was created with every missing
@@ -6990,7 +7014,7 @@ def _missing_dest(dest: Path, ask: bool) -> None:
if missing > 1 and ask:
answer = _ask(f" Create {_plural(missing, 'folder')} under {nearest}? [y/N] ")
if not answer.strip().lower().startswith("y"):
print(" Nothing installed. Check the folder named with --dest, then run "
print(f" Nothing installed. Check the folder {named}, then run "
"the command again.")
sys.exit(2)
@@ -7212,8 +7236,13 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
print(line)
sys.exit(1)
platforms = _prompt_manual_platform(os_type)
for name, path in platforms:
print(f" Found {found_label(name, path)}")
plat, typed = platforms[0]
if typed != _default_dest(os_type, plat):
_missing_dest(typed, not (args.check or args.dry_run or args.yes),
"typed")
else:
for name, path in platforms:
print(f" Found {found_label(name, path)}")
conflict = os_conflict(platforms)
if conflict and not interactive and not listing:
@@ -7800,7 +7829,11 @@ if __name__ == "__main__":
except KeyboardInterrupt:
# Outside the download phase (fetching the file list, checking the
# files present, rebuilding data directories): nothing is half
# written there, and a socket traceback read as a crash.
print("\nStopped. Run the same command again to carry on from here. "
"Nothing is lost.")
# written there, and a socket traceback read as a crash. Stopped at
# a question, nothing has started, and there is nothing to carry on.
if _writing.is_set():
print("\nStopped. Run the same command again to carry on from here. "
"Nothing is lost.")
else:
print("\nStopped. Nothing was installed.")
sys.exit(130)
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="81b72f480566791ccf1db34d20635bc7886190f2a21b150507c526130af1d499"
DEFAULT_INSTALL_SHA256="1238dfb08b3168992cf4e390358540c6c1bbadf23ae5b74fb34e9101a2a083ef"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+2 -1
View File
@@ -392,7 +392,8 @@ class ExitStatusTable(unittest.TestCase):
section = _section(_page("installer.md"), "Exit status")
documented = {int(row[0].strip("`")) for row in _table_rows(section)}
raised = {int(code) for code in re.findall(r"sys\.exit\((\d+)\)", source)}
self.assertEqual(documented, raised | {2})
# 0 is a run that returns, 2 the code argparse leaves with.
self.assertEqual(documented, raised | {0, 2})
self.assertIn("install.py: error:", section)
for option in set(re.findall(r"(--[a-z][a-z-]+)", section)):
self.assertIn(f'"{option}"', source, option)
+43 -3
View File
@@ -1784,7 +1784,7 @@ class TestDetectedPlatformPrompt(unittest.TestCase):
def test_q_quits_and_the_menu_says_so(self):
result, out = self._answer("q")
self.assertIsInstance(result, SystemExit)
self.assertEqual(result.code, 0)
self.assertEqual(result.code, 130)
self.assertIn("q) quit", out)
def test_a_wrong_answer_names_the_choices(self):
@@ -1820,11 +1820,51 @@ class TestManualPlatformPrompt(unittest.TestCase):
chosen = install._prompt_manual_platform("linux")
self.assertEqual(chosen, [("batocera", install.DEFAULT_DESTS["batocera"])])
def test_quit_exits_cleanly(self):
def test_quit_stops_like_ctrl_c(self):
"""`install.sh && retroarch` went on to start RetroArch with no BIOS
after a q, which left with 0 where Ctrl+C leaves with 130."""
with unittest.mock.patch("builtins.input", lambda _prompt="": "q"):
with self.assertRaises(SystemExit) as raised:
install._prompt_manual_platform("linux")
self.assertEqual(raised.exception.code, 0)
self.assertEqual(raised.exception.code, 130)
def test_quotes_around_a_typed_folder_are_dropped(self):
"""Explorer's "Copy as path" quotes the folder: the files went into a
folder named `"` under the current directory, and the run said Done."""
root = Path(_tempdir(self))
for typed in (f'"{root}/My Card/bios"', f"'{root}/My Card/bios'",
f' "{root}/My Card/bios" '):
answers = iter(["1", typed])
with self.subTest(typed=typed), \
unittest.mock.patch("builtins.input", lambda _prompt="": next(answers)), \
contextlib.redirect_stdout(io.StringIO()):
chosen = install._prompt_manual_platform("linux")
self.assertEqual(chosen, [("retroarch", root / "My Card" / "bios")])
def test_home_and_variables_are_expanded_on_every_system(self):
for os_type in ("linux", "windows", "darwin"):
answers = iter(["1", '"~/$RB_TEST_DIR/bios"'])
with self.subTest(os_type=os_type), \
unittest.mock.patch.dict(os.environ, {"RB_TEST_DIR": "cards"}), \
unittest.mock.patch("builtins.input", lambda _prompt="": next(answers)), \
contextlib.redirect_stdout(io.StringIO()):
chosen = install._prompt_manual_platform(os_type)
self.assertEqual(chosen[0][1], Path.home() / "cards" / "bios")
def test_a_default_folder_not_there_is_not_found(self):
"""With nothing installed, Enter printed "Found RetroArch at" a
folder that did not exist."""
root = Path(_tempdir(self))
answers = iter(["1", ""])
out = io.StringIO()
with unittest.mock.patch.object(install, "_default_dest",
return_value=root / "retroarch" / "system"), \
unittest.mock.patch("builtins.input", lambda _prompt="": next(answers)), \
contextlib.redirect_stdout(out):
install._prompt_manual_platform("linux")
self.assertIn("default BIOS folder", out.getvalue())
self.assertIn("will be created", out.getvalue())
self.assertNotIn("Found", out.getvalue())
def test_a_wrong_answer_says_what_to_type(self):
answers = iter(["99", "1", ""])
+68
View File
@@ -544,6 +544,74 @@ class TheOneLinerNarrows(unittest.TestCase):
self.assertIn("--system sony-playstation --region north-america", out)
self.assertEqual(repo.installed(), ["pcsx/extra.bin", "scph5501.bin"])
@unittest.skipUnless(shutil.which("script"), "script(1) provides the terminal")
def test_a_quoted_folder_typed_at_the_prompt(self):
"""The quotes "Copy as path" adds were kept: the files went into a
folder named `"` under the current directory, created without the
question --dest asks, and the run said Done."""
with FakeRepository() as repo:
home = repo.root / "home"
home.mkdir()
# Read for the standalone emulator folders, of which there are none.
(repo.root / "install" / "emudeck.json").write_text(json.dumps(
{"manifest_version": 2, "platform": "emudeck", "files": []}))
card = repo.root / "My Card" / "bios"
env = dict(repo.env(), HOME=str(home), USERPROFILE=str(home),
XDG_CONFIG_HOME=str(home), RETROBIOS_OS="linux")
command = f"{shlex.quote(sys.executable)} {shlex.quote(str(REPO_ROOT / 'install.py'))}"
for answer, code in (("n", 2), ("y", 0)):
with self.subTest(answer=answer):
proc = subprocess.run(
["script", "-qec", command, "/dev/null"],
input=f'1\n"{card}"\n{answer}\n\n', env=env, cwd=repo.root,
capture_output=True, text=True, timeout=60, check=False,
)
self.assertEqual(proc.returncode, code, proc.stdout + proc.stderr)
self.assertIn(f"Create 2 folders under {repo.root}?", proc.stdout)
self.assertFalse((repo.root / '"').exists())
self.assertEqual(len([p for p in card.rglob("*") if p.is_file()]), 5)
def test_q_at_every_menu_stops_on_130(self):
"""`install.sh && retroarch` started RetroArch after a q, which left
with 0 where Ctrl+C and Ctrl+D leave with 130."""
files = [{"dest": "a.bin", "size": 1, "systems": ["nintendo-gba"]}]
menus = (
lambda: install._choose_platform("Platform:"),
lambda: install._pick("Systems:", [("nintendo-gba", files)]),
lambda: install._prompt_custom_selection("retroarch", files, []),
)
for menu in menus:
with self.subTest(menu=menu), \
mock.patch("builtins.input", lambda _prompt="": "q"), \
mock.patch("sys.stdout", new_callable=lambda: open(os.devnull, "w")), \
self.assertRaises(SystemExit) as stop:
menu()
self.assertEqual(stop.exception.code, 130)
@unittest.skipIf(os.name == "nt", "a pseudo-terminal is a POSIX one")
def test_ctrl_d_at_the_first_question_has_nothing_to_carry_on(self):
import pty # noqa: PLC0415
with FakeRepository() as repo:
home = repo.root / "home"
home.mkdir()
(repo.root / "install" / "emudeck.json").write_text(json.dumps(
{"manifest_version": 2, "platform": "emudeck", "files": []}))
env = dict(repo.env(), HOME=str(home), USERPROFILE=str(home),
XDG_CONFIG_HOME=str(home), RETROBIOS_OS="linux")
leader, follower = pty.openpty()
proc = subprocess.Popen(
[sys.executable, str(REPO_ROOT / "install.py")], stdin=follower,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, text=True,
)
os.close(follower)
os.write(leader, b"\x04")
out, err = proc.communicate(timeout=60)
os.close(leader)
self.assertEqual(proc.returncode, 130, out + err)
self.assertIn("Stopped. Nothing was installed.", out)
self.assertNotIn("carry on", out)
@unittest.skipUnless(shutil.which("script"), "script(1) provides the terminal")
def test_enter_installs_everything_at_a_terminal(self):
with FakeRepository() as repo:
+2 -2
View File
@@ -1012,8 +1012,8 @@ fields, the installer refuses to narrow rather than install everything.
|------|---------|
| `0` | everything asked for is installed or already up to date |
| `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 (nothing was run), an unusable base URL, an unknown platform or target, `--installed-cores` with no core found, a manifest that fails validation, a fetch that never succeeded, no platform to install for in a non-interactive run, some files failed while others installed, `--check` found files to fetch, a `--from-pack` pack that cannot be read, lacks a part or is for another platform, or `--restore` could not put a file back |
| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), the drive has too little free space for the download (checked before any folder is created), or the question about a `--dest` folder that does not exist was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board` or `--from-pack`. The message then starts with `install.py: error:`. With `--restore`, a folder that is not a dated folder inside `retrobios-backup` |
| `130` | stopped with Ctrl+C, or with Ctrl+C or Ctrl+D at a question. Run the same command again to carry on |
| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), the drive has too little free space for the download (checked before any folder is created), or the question about a folder that does not exist, named with `--dest` or typed at the folder question, was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board` or `--from-pack`. The message then starts with `install.py: error:`. With `--restore`, a folder that is not a dated folder inside `retrobios-backup` |
| `130` | stopped with Ctrl+C, or with `q`, Ctrl+C or Ctrl+D at a question. Run the same command again to carry on |
| `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) |
`install.ps1` passes the installer's code on unchanged: run as a file it exits