From 612b223a5930d17e35c0b72db920280464a1f711 Mon Sep 17 00:00:00 2001 From: Dash Date: Sat, 17 Oct 2026 11:46:23 +0200 Subject: [PATCH] pqcli: add --import-save and --export-save --import-save FILE adds the character in a .pqw file to the roster; it can be given more than once, and if any file fails, nothing is imported. --export-save NUM FILE writes a character out. Export never overwrites an existing file. The file handling lives in pqw.read_file and pqw.write_file, so the interfaces can share it. --- pqcli/__main__.py | 62 +++++++++++++++++++++++++++++++++++++++++++++++ pqcli/pqw.py | 27 +++++++++++++++++++++ tests/test_cli.py | 57 +++++++++++++++++++++++++++++++++++++++++++ tests/test_pqw.py | 33 ++++++++++++++++++++++++- 4 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 tests/test_cli.py diff --git a/pqcli/__main__.py b/pqcli/__main__.py index 8eb1156..e5d7139 100644 --- a/pqcli/__main__.py +++ b/pqcli/__main__.py @@ -5,6 +5,7 @@ from typing import TextIO from xdg_base_dirs import xdg_config_home +from pqcli import pqw from pqcli.game_data import ( DataError, GameData, @@ -81,6 +82,25 @@ def parse_args() -> argparse.Namespace: metavar="DIR", help="copy the built-in word lists to DIR for editing and exit", ) + parser.add_argument( + "--import-save", + type=Path, + action="append", + metavar="FILE", + help=( + "add the character in a Progress Quest web save (.pqw) to the " + "roster and exit; can be given more than once" + ), + ) + parser.add_argument( + "--export-save", + nargs=2, + metavar=("NUM", "FILE"), + help=( + "write the chosen character to a Progress Quest web save " + "(.pqw) and exit" + ), + ) return parser.parse_args() @@ -106,6 +126,40 @@ def bootstrap_first_run( raise SystemExit(1) +def import_saves( + roster: Roster, data: GameData, paths: list[Path], use_saves: bool +) -> None: + imported = [] + for path in paths: + try: + player, warnings = pqw.read_file(path, data) + except pqw.PqwError as ex: + print(f"pqcli: {ex}", file=sys.stderr) + exit(1) + for warning in warnings: + print(f"pqcli: {path}: {warning}", file=sys.stderr) + imported.append(player) + roster.players.extend(imported) + if use_saves: + roster.save() + for player in imported: + print(f"Imported {player.name}") + + +def export_save(roster: Roster, data: GameData, num: str, file: str) -> None: + if not num.isdigit() or not 1 <= int(num) <= len(roster.players): + print("Invalid player. Available players:", file=sys.stderr) + list_players(roster, file=sys.stderr) + exit(1) + player = roster.players[int(num) - 1] + try: + pqw.write_file(player, Path(file), data) + except pqw.PqwError as ex: + print(f"pqcli: {ex}", file=sys.stderr) + exit(1) + print(f"Exported {player.name} to {file}") + + def main() -> None: args = parse_args() @@ -130,6 +184,14 @@ def main() -> None: list_players(roster) exit(0) + if args.import_save: + import_saves(roster, data, args.import_save, args.use_saves) + exit(0) + + if args.export_save: + export_save(roster, data, *args.export_save) + exit(0) + player: Player | None = None if args.load_save: try: diff --git a/pqcli/pqw.py b/pqcli/pqw.py index 5eede19..d7e9c39 100644 --- a/pqcli/pqw.py +++ b/pqcli/pqw.py @@ -10,6 +10,7 @@ import binascii import copy import datetime import json +from pathlib import Path from typing import Any, cast from pqcli.config import Class, EquipmentType, Monster, Race, StatType @@ -351,3 +352,29 @@ def export_game(player: Player, data: GameData) -> dict[str, Any]: "TaskBar": bar(player.task_bar.position, player.task_bar.max_), "queue": [_encode_queue_entry(task) for task in player.queue], } + + +def read_file(path: Path, data: GameData) -> tuple[Player, list[str]]: + """Import the character in a .pqw file, plus any warnings.""" + try: + text = path.read_text(encoding="ascii", errors="replace") + except OSError as ex: + raise PqwError(f"cannot read {path}: {ex.strerror}") from ex + return import_game(decode(text), data) + + +def write_file(player: Player, path: Path, data: GameData) -> None: + """Export a character to a .pqw file, never overwriting one.""" + text = encode(export_game(player, data)) + try: + with path.open("x", encoding="ascii") as handle: + handle.write(text) + except FileExistsError as ex: + raise PqwError(f"{path} already exists") from ex + except OSError as ex: + raise PqwError(f"cannot write {path}: {ex.strerror}") from ex + + +def default_file_name(player: Player) -> str: + # the web version names its downloads the same way + return f"{player.name}.pqw" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a12b332 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,57 @@ +import os +import subprocess +import sys +from pathlib import Path + +from test_pqw import played + +from pqcli.game_data import load_game_data +from pqcli.pqw import read_file, write_file + + +def pqcli( + config: Path, *args: str, cwd: Path | None = None +) -> subprocess.CompletedProcess[str]: + env = {**os.environ, "XDG_CONFIG_HOME": str(config)} + return subprocess.run( + [sys.executable, "-m", "pqcli", *args], + capture_output=True, + text=True, + env=env, + cwd=cwd, + ) + + +def test_import_then_export(tmp_path: Path) -> None: + data = load_game_data() + source = tmp_path / "in.pqw" + write_file(played(3000).player, source, data) + + result = pqcli(tmp_path, "--import-save", str(source)) + assert result.returncode == 0, result.stderr + assert result.stdout == "Imported Exportée\n" + assert (tmp_path / "pqcli" / "save.dat").exists() + + listed = pqcli(tmp_path, "--list-saves") + assert listed.stdout == "1. Exportée\n" + + target = tmp_path / "out.pqw" + result = pqcli(tmp_path, "--export-save", "1", str(target)) + assert result.returncode == 0, result.stderr + assert read_file(target, data)[0].name == "Exportée" + + +def test_import_bad_file_changes_nothing(tmp_path: Path) -> None: + bad = tmp_path / "bad.pqw" + bad.write_text("garbage!") + result = pqcli(tmp_path, "--import-save", str(bad)) + assert result.returncode == 1 + assert "not a .pqw file" in result.stderr + assert not (tmp_path / "pqcli" / "save.dat").exists() + + +def test_export_rejects_unknown_character(tmp_path: Path) -> None: + result = pqcli(tmp_path, "--export-save", "3", str(tmp_path / "x.pqw")) + assert result.returncode == 1 + assert "Invalid player" in result.stderr + assert not (tmp_path / "x.pqw").exists() diff --git a/tests/test_pqw.py b/tests/test_pqw.py index 992c58f..06fdf05 100644 --- a/tests/test_pqw.py +++ b/tests/test_pqw.py @@ -1,6 +1,7 @@ import base64 import datetime import json +from pathlib import Path import pytest from upstream import bridge @@ -8,7 +9,16 @@ from upstream import bridge from pqcli.config import StatType from pqcli.game_data import load_game_data from pqcli.mechanic import Player, Simulation, Stats -from pqcli.pqw import PqwError, decode, encode, export_game, import_game +from pqcli.pqw import ( + PqwError, + decode, + default_file_name, + encode, + export_game, + import_game, + read_file, + write_file, +) from pqcli.random import Alea DATA = load_game_data() @@ -131,3 +141,24 @@ def test_round_trip_keeps_playing_identically(ticks: int) -> None: assert bridge.run(resumed, 10**8, 2000) == bridge.run( original, 10**8, 2000 ) + + +def test_files_round_trip(tmp_path: Path) -> None: + original = played(3000).player + path = tmp_path / default_file_name(original) + write_file(original, path, DATA) + copy, _warnings = read_file(path, DATA) + assert bridge.snapshot(copy) == bridge.snapshot(original) + + +def test_write_file_never_overwrites(tmp_path: Path) -> None: + path = tmp_path / "taken.pqw" + path.write_text("mine") + with pytest.raises(PqwError, match="already exists"): + write_file(played(10).player, path, DATA) + assert path.read_text() == "mine" + + +def test_read_file_reports_missing_file(tmp_path: Path) -> None: + with pytest.raises(PqwError, match="cannot read"): + read_file(tmp_path / "nope.pqw", DATA)