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.
This commit is contained in:
Dash committed 2026-10-17 15:56:02 +02:00
1 parent 0beb550548
commit 612b223a59
4 files changed
+178 -1

No files matched your search

+62
View File
@@ -5,6 +5,7 @@ from typing import TextIO
from xdg_base_dirs import xdg_config_home from xdg_base_dirs import xdg_config_home
from pqcli import pqw
from pqcli.game_data import ( from pqcli.game_data import (
DataError, DataError,
GameData, GameData,
@@ -81,6 +82,25 @@ def parse_args() -> argparse.Namespace:
metavar="DIR", metavar="DIR",
help="copy the built-in word lists to DIR for editing and exit", 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() return parser.parse_args()
@@ -106,6 +126,40 @@ def bootstrap_first_run(
raise SystemExit(1) 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: def main() -> None:
args = parse_args() args = parse_args()
@@ -130,6 +184,14 @@ def main() -> None:
list_players(roster) list_players(roster)
exit(0) 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 player: Player | None = None
if args.load_save: if args.load_save:
try: try:
+27
View File
@@ -10,6 +10,7 @@ import binascii
import copy import copy
import datetime import datetime
import json import json
from pathlib import Path
from typing import Any, cast from typing import Any, cast
from pqcli.config import Class, EquipmentType, Monster, Race, StatType 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_), "TaskBar": bar(player.task_bar.position, player.task_bar.max_),
"queue": [_encode_queue_entry(task) for task in player.queue], "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"
+57
View File
@@ -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()
+32 -1
View File
@@ -1,6 +1,7 @@
import base64 import base64
import datetime import datetime
import json import json
from pathlib import Path
import pytest import pytest
from upstream import bridge from upstream import bridge
@@ -8,7 +9,16 @@ from upstream import bridge
from pqcli.config import StatType from pqcli.config import StatType
from pqcli.game_data import load_game_data from pqcli.game_data import load_game_data
from pqcli.mechanic import Player, Simulation, Stats 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 from pqcli.random import Alea
DATA = load_game_data() 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( assert bridge.run(resumed, 10**8, 2000) == bridge.run(
original, 10**8, 2000 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)