From f42983ee62da1abdbf2bdbfb35efbc6d1d4ef93a Mon Sep 17 00:00:00 2001 From: Brad Coudriet Date: Tue, 1 Sep 2026 06:35:52 -0400 Subject: [PATCH 1/3] Add cross-platform user preferences --- .github/copilot-instructions.md | 11 +- .github/workflows/tests.yml | 13 +- README.md | 37 ++++- gear_core.py | 5 - gear_tui.py | 238 ++++++++++++++++++++++++++++++-- packrat_preferences.py | 110 +++++++++++++++ pyproject.toml | 1 + requirements.txt | 1 + tests/test_preferences.py | 75 ++++++++++ tests/test_tui.py | 109 +++++++++++++++ uv.lock | 7 +- 11 files changed, 581 insertions(+), 26 deletions(-) create mode 100644 packrat_preferences.py create mode 100644 tests/test_preferences.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4a09495..31e845b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,7 +7,7 @@ Install dependencies and run: uv run python main.py ``` -The app auto-creates `gear_data.json` with seeded example data on first run, so you can immediately see the full workflow. Use `uv` (recommended) for automatic environment management; fall back to `pip install -r requirements.txt` if needed. +On first run the app asks for a storage folder, remembers it in the platform's standard application-config location, and creates `gear_data.json` there with seeded example data. Use `uv` (recommended) for automatic environment management; fall back to `pip install -r requirements.txt` if needed. --- @@ -22,6 +22,11 @@ The app auto-creates `gear_data.json` with seeded example data on first run, so - Constants: `CATEGORIES`, `CATEGORY_EMOJI`, `BIG_THREE`, `WEIGHT_TYPES`, thresholds - Helper functions: `find_gear()`, `find_trip()`, `compute_trip_summary()`, `total_weight_oz()`, etc. +- **`packrat_preferences.py`** — Cross-platform preference layer. Handles: + - OS-standard config and suggested data directories via `platformdirs` + - Atomic `preferences.json` reads/writes + - Startup precedence between `--data`, remembered storage, and onboarding + - **`gear_tui.py`** — Textual UI layer. Handles: - Screens (gear inventory, trips tab, reports tab) and modal dialogs (forms, pickers, confirmations) - DataTable widgets, search filtering, keyboard + mouse bindings @@ -87,6 +92,8 @@ The app auto-creates `gear_data.json` with seeded example data on first run, so ### Data Persistence - `load_data(path)` — reads JSON; auto-seeds with `example_data()` if file missing - `save_data(path, data)` — atomic writes via temp file (`path + ".tmp"`, then `os.replace()`) +- The selected folder is remembered outside the repository; its library is always named `gear_data.json` +- `--data` is an exact-file override for one launch and does not update preferences - **No locking** — avoid editing from multiple machines simultaneously or sync conflicts will create backup files ### Markdown Export @@ -162,7 +169,7 @@ uv run python main.py ## Common Gotchas - **Modal dialogs cut off:** Terminal too small (need ≥130×42). Zoom or expand window. -- **Data file not created:** Check write permissions in the working directory. +- **Data file not created:** Check write permissions in the selected storage folder. - **Markdown export missing:** Files go to `exports/` subdirectory auto-created next to `gear_data.json`. - **Weight calculations wrong:** Verify `qty` is set (defaults to 1) and `weight_type` matches the calculation logic (base/worn/consumable are summed separately). - **Stale UI after edit:** Modal dismisses and returns updated dict; catch with `@on(SomeScreen.ScreenType.Submitted)` or similar pattern. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4934069..2b130ba 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,10 +10,19 @@ permissions: jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - python-version: ["3.9", "3.12"] + include: + - os: ubuntu-latest + python-version: "3.9" + - os: ubuntu-latest + python-version: "3.12" + - os: windows-latest + python-version: "3.12" + - os: macos-latest + python-version: "3.12" steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 5cc0f13..9bb5480 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ A mouse-and-keyboard terminal app for tracking backpacking gear and building per-trip pack lists, built with [Textual](https://textual.textualize.io). -Data lives in one JSON file next to the code, so the whole thing is -portable — drop the folder in iCloud Drive / Dropbox / OneDrive and run it -from any Mac or Windows machine. +Data lives in one JSON file in a folder you choose on first launch. Packrat +remembers that folder in your operating system's standard application-config +location, so the code and your personal data remain separate. Choose an +iCloud Drive, Dropbox, or OneDrive folder if you want the library synced +between Windows, macOS, and Linux computers. ## Requirements @@ -19,6 +21,10 @@ With `uv` (installs the right Python packages automatically, no manual venv): uv run python main.py ``` +The first launch opens a short setup screen. Accept the suggested +platform-specific folder or enter another folder; Packrat creates +`gear_data.json` there with clearly labeled example items. + Without `uv`: ```bash @@ -26,7 +32,8 @@ pip install -r requirements.txt # or: pip install textual python3 main.py ``` -To point at a data file somewhere else (e.g. a specific iCloud folder): +To use an exact data file for one launch without changing the remembered +preference: ```bash uv run python main.py --data "/Users/you/Library/Mobile Documents/com~apple~CloudDocs/Gear/gear_data.json" @@ -38,9 +45,10 @@ uv run python main.py --data "/Users/you/Library/Mobile Documents/com~apple~Clou - `gear_tui.py` — the Textual UI: screens, forms, tables, styling. - `gear_core.py` — data model, JSON persistence, and Markdown rendering. Pure functions, no UI code — this is what generates the pack-list exports. -- `gear_data.json` — your data. Created automatically on first run if it - doesn't exist, seeded with a few example items/trip (clearly labeled) so - the format is obvious. +- `packrat_preferences.py` — cross-platform preference paths and persistence. +- `gear_data.json` — created in your selected storage folder and seeded with + a few example items/trip (clearly labeled) so the format is obvious. The + repository copy is not used as the runtime default. - `pyproject.toml` / `uv.lock` — project + locked dependencies for `uv`. - `requirements.txt` — plain-pip fallback if you're not using `uv`. @@ -58,6 +66,8 @@ are: edits an item's quantity/note and **P** opens the pack audit. - **Ctrl+S** saves forms and picker dialogs; **Enter** confirms confirmations. - **Ctrl+B** writes a manual `.bak` snapshot beside your data file. +- **Ctrl+P** opens Storage Preferences to open another library or copy the + current library to a new folder and switch to it. - **Click a table row** to select it; **click it again** (or press Enter) to open/edit it. This two-step click mirrors how most file browsers work @@ -99,6 +109,19 @@ It is still a flat JSON file rather than a mergeable database. If Packrat reports an external-change conflict, restart it to load the newer file before editing again. +## Storage preferences + +The setup screen and Storage Preferences select a folder; the library inside +that folder is always named `gear_data.json`. **Open / Create** opens an +existing valid library or creates a new example library. **Copy Current & +Switch** copies the active library to an unused destination and leaves the +original file intact. It will not overwrite an existing destination library. + +If the saved preference is damaged or its library cannot be opened, Packrat +returns to setup with the error instead of silently using another data file. +The `--data` option remains useful for scripts and one-off libraries and never +changes the remembered folder by itself. + ## Backing it up Press **Ctrl+B** for an on-demand snapshot, copy the JSON file whenever you diff --git a/gear_core.py b/gear_core.py index 1fbd383..edf6a94 100644 --- a/gear_core.py +++ b/gear_core.py @@ -35,11 +35,6 @@ AUDIT_STATUSES = ("covered", "omitted", "unresolved") DATA_VERSION = 2 -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -DEFAULT_DATA_PATH = os.path.join(SCRIPT_DIR, "gear_data.json") -DEFAULT_EXPORT_DIR = os.path.join(SCRIPT_DIR, "exports") - - class DataValidationError(ValueError): """Raised when a data file doesn't match Packrat's expected schema.""" diff --git a/gear_tui.py b/gear_tui.py index bfee592..6726b28 100644 --- a/gear_tui.py +++ b/gear_tui.py @@ -13,8 +13,9 @@ import argparse import copy import os +import tempfile from datetime import date -from typing import Optional +from typing import Optional, Tuple from rich.text import Text from textual import on @@ -36,6 +37,7 @@ ) import gear_core as gc +import packrat_preferences as preferences # --------------------------------------------------------------------------- # Styling — dark, outdoors-y palette; mouse hover states come free from @@ -139,6 +141,34 @@ overflow-y: auto; } +#setup-dialog { + background: #182015; + border: thick #4A7856; + padding: 2 3; + width: 90%; + max-width: 76; + height: auto; +} + +#setup-dialog Label, #preferences-dialog Label { + color: #9AAE8C; + padding-top: 1; +} + +#setup-error, #preferences-error { + color: #F2A29B; + padding: 1 0; +} + +#preferences-dialog { + background: #182015; + border: thick #4A7856; + padding: 1 2; + width: 90%; + max-width: 76; + height: auto; +} + .picker-dialog { width: 95%; max-width: 76; @@ -1365,6 +1395,118 @@ def _export_inventory(self) -> None: # --------------------------------------------------------------------------- +def _prepare_library(directory: str) -> Tuple[str, dict, bool]: + """Validate a folder and load or create its Packrat library.""" + normalized_directory = preferences.normalize_path(directory) + if os.path.exists(normalized_directory) and not os.path.isdir(normalized_directory): + raise OSError(f"not a folder: {normalized_directory}") + os.makedirs(normalized_directory, exist_ok=True) + + descriptor, probe_path = tempfile.mkstemp(prefix=".packrat-write-test.", dir=normalized_directory) + os.close(descriptor) + os.unlink(probe_path) + + data_path = preferences.data_path_for_directory(normalized_directory) + created = not os.path.exists(data_path) + if created: + data = gc.example_data() + gc.save_data(data_path, data) + else: + data = gc.load_data(data_path) + return data_path, data, created + + +class SetupApp(App): + """Small bootstrap app shown before a first Packrat library exists.""" + + CSS = APP_CSS + "\nScreen { align: center middle; }" + TITLE = "Packrat Setup" + + def __init__(self, initial_error: str = "", preferences_path: Optional[str] = None): + super().__init__() + self.initial_error = initial_error + self.preferences_path = preferences_path + + def compose(self) -> ComposeResult: + with Vertical(id="setup-dialog"): + yield Static("🎒 Welcome to Packrat", classes="dialog-title") + yield Static( + "Choose where Packrat should keep your gear library. " + "New libraries start with clearly labeled example gear." + ) + yield Label("Gear storage folder") + yield Input(value=str(preferences.suggested_data_directory()), id="setup-folder") + yield Static(self.initial_error, id="setup-error") + with Horizontal(classes="dialog-buttons"): + yield Button("Quit", id="setup-quit") + yield Button("Use This Folder", id="setup-use", variant="success") + + @on(Button.Pressed, "#setup-use") + def _use_folder(self) -> None: + directory = self.query_one("#setup-folder", Input).value + try: + data_path, _data, created = _prepare_library(directory) + try: + preferences.save_preferences( + os.path.dirname(data_path), path=self.preferences_path + ) + except Exception: + if created: + try: + os.unlink(data_path) + except OSError: + pass + raise + except (OSError, preferences.PreferencesError, gc.DataValidationError) as exc: + self.query_one("#setup-error", Static).update(f"Could not use that folder: {exc}") + return + self.exit(data_path) + + @on(Button.Pressed, "#setup-quit") + def _quit_setup(self) -> None: + self.exit(None) + + +class PreferencesScreen(ModalScreen[Optional[Tuple[str, str]]]): + """Choose whether to open another library or copy the current one.""" + + BINDINGS = [Binding("escape", "cancel", "Cancel")] + + def __init__(self, current_directory: str): + super().__init__() + self.current_directory = current_directory + + def compose(self) -> ComposeResult: + with Vertical(id="preferences-dialog"): + yield Static("⚙ Storage Preferences", classes="dialog-title") + yield Static( + "Open a library in another folder, or copy the current library there. " + "Packrat never overwrites an existing destination when copying." + ) + yield Label("Gear storage folder") + yield Input(value=self.current_directory, id="preferences-folder") + yield Static("", id="preferences-error") + with Horizontal(classes="dialog-buttons"): + yield Button("Cancel", id="preferences-cancel") + yield Button("Open / Create", id="preferences-open", variant="success") + yield Button("Copy Current & Switch", id="preferences-copy") + + def action_cancel(self) -> None: + self.dismiss(None) + + @on(Button.Pressed, "#preferences-cancel") + def _cancel(self) -> None: + self.dismiss(None) + + @on(Button.Pressed, "#preferences-open") + def _open(self) -> None: + self.dismiss(("open", self.query_one("#preferences-folder", Input).value)) + + @on(Button.Pressed, "#preferences-copy") + def _copy(self) -> None: + self.dismiss(("copy", self.query_one("#preferences-folder", Input).value)) + + class GearTrackerApp(App): CSS = APP_CSS TITLE = "Backpacking Gear Tracker" @@ -1375,13 +1517,15 @@ class GearTrackerApp(App): Binding("slash", "search", "Search"), Binding("question_mark", "show_help", "Help"), Binding("ctrl+b", "backup", "Backup"), + Binding("ctrl+p", "preferences", "Preferences"), Binding("q", "quit", "Quit"), Binding("ctrl+c", "quit", "Quit", show=False), ] - def __init__(self, data_path: str): + def __init__(self, data_path: str, preferences_path: Optional[str] = None): super().__init__() self.data_path = os.path.abspath(os.path.expanduser(data_path)) + self.preferences_path = preferences_path self.export_dir = gc.export_dir_for_data(self.data_path) self.data = gc.load_data(self.data_path) if not os.path.exists(self.data_path): @@ -1418,6 +1562,58 @@ def action_search(self) -> None: def action_show_help(self) -> None: self.push_screen(ShortcutHelpScreen()) + def action_preferences(self) -> None: + self.push_screen( + PreferencesScreen(os.path.dirname(self.data_path)), + self._change_library, + ) + + def _change_library(self, result: Optional[Tuple[str, str]]) -> None: + if result is None: + return + operation, directory = result + created_path: Optional[str] = None + try: + normalized_directory = preferences.normalize_path(directory) + destination_path = preferences.data_path_for_directory(normalized_directory) + if operation == "copy": + if os.path.exists(destination_path): + raise FileExistsError( + f"{destination_path} already exists; use Open / Create to open it" + ) + if os.path.exists(normalized_directory) and not os.path.isdir(normalized_directory): + raise OSError(f"not a folder: {normalized_directory}") + os.makedirs(normalized_directory, exist_ok=True) + gc.save_data(destination_path, copy.deepcopy(self.data)) + created_path = destination_path + new_data = gc.load_data(destination_path) + elif operation == "open": + destination_path, new_data, created = _prepare_library(normalized_directory) + if created: + created_path = destination_path + else: + raise ValueError(f"unknown preference operation: {operation}") + + preferences.save_preferences( + normalized_directory, path=self.preferences_path + ) + except (OSError, ValueError, preferences.PreferencesError, gc.DataValidationError) as exc: + if created_path is not None: + try: + os.unlink(created_path) + except OSError: + pass + self.notify(f"Library switch failed: {exc}", severity="error", timeout=7) + return + + self.data_path = destination_path + self.export_dir = gc.export_dir_for_data(destination_path) + self.data = new_data + self._data_signature = gc.file_signature(destination_path) + self._last_saved_data = copy.deepcopy(new_data) + self._refresh_tab(self.query_one(TabbedContent).active) + self.notify(f"Now using {destination_path}", title="Library changed", timeout=5) + def action_backup(self) -> None: try: path = gc.backup_data(self.data_path) @@ -1468,15 +1664,39 @@ def write_export(self, filename: str, content: str) -> Optional[str]: return path -def main(): +def main(argv=None): parser = argparse.ArgumentParser(description="Backpacking Gear Tracker (Textual TUI)") - parser.add_argument("--data", default=gc.DEFAULT_DATA_PATH, - help="Path to the JSON data file (default: gear_data.json next to this script)") - args = parser.parse_args() + parser.add_argument( + "--data", + default=None, + help="Path to a JSON data file for this launch only (does not change preferences)", + ) + args = parser.parse_args(argv) + + setup_error = "" try: - app = GearTrackerApp(args.data) - except (OSError, gc.DataValidationError) as exc: - parser.error(f"could not load data file: {exc}") + data_path = preferences.resolve_startup_data_path(args.data) + except preferences.PreferencesError as exc: + data_path = None + setup_error = f"Your saved preference could not be read: {exc}" + + if data_path is not None: + try: + app = GearTrackerApp(data_path) + except (OSError, gc.DataValidationError) as exc: + if args.data is not None: + parser.error(f"could not load data file: {exc}") + data_path = None + setup_error = f"Your remembered library could not be opened: {exc}" + + if data_path is None: + data_path = SetupApp(initial_error=setup_error).run() + if data_path is None: + return + try: + app = GearTrackerApp(data_path) + except (OSError, gc.DataValidationError) as exc: + parser.error(f"could not load data file after setup: {exc}") app.run() diff --git a/packrat_preferences.py b/packrat_preferences.py new file mode 100644 index 0000000..e4ed1b9 --- /dev/null +++ b/packrat_preferences.py @@ -0,0 +1,110 @@ +"""Cross-platform preference storage and startup path resolution for Packrat.""" + +import json +import os +import tempfile +from pathlib import Path +from typing import Optional, Union + +from platformdirs import user_config_path, user_data_path + + +APP_NAME = "Packrat" +PREFERENCES_VERSION = 1 +PREFERENCES_FILENAME = "preferences.json" +DATA_FILENAME = "gear_data.json" + +PathLike = Union[str, os.PathLike] + + +class PreferencesError(ValueError): + """Raised when saved Packrat preferences are unreadable or invalid.""" + + +def normalize_path(path: PathLike) -> str: + """Return an expanded absolute path without requiring it to exist.""" + value = os.fspath(path).strip() + if not value: + raise PreferencesError("the storage folder cannot be blank") + return os.path.abspath(os.path.expanduser(value)) + + +def preferences_file() -> Path: + return user_config_path(APP_NAME, appauthor=False) / PREFERENCES_FILENAME + + +def suggested_data_directory() -> Path: + return user_data_path(APP_NAME, appauthor=False) + + +def data_path_for_directory(directory: PathLike) -> str: + return os.path.join(normalize_path(directory), DATA_FILENAME) + + +def load_preferences(path: Optional[PathLike] = None) -> Optional[str]: + """Load and return the remembered data directory, or ``None`` if absent.""" + preferences_path = Path(path) if path is not None else preferences_file() + if not preferences_path.exists(): + return None + try: + with preferences_path.open("r", encoding="utf-8") as handle: + payload = json.load(handle) + except json.JSONDecodeError as exc: + raise PreferencesError( + f"invalid preferences JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}" + ) from exc + except OSError as exc: + raise PreferencesError(f"could not read preferences: {exc}") from exc + + if not isinstance(payload, dict): + raise PreferencesError("preferences must contain a JSON object") + version = payload.get("version") + if version != PREFERENCES_VERSION: + raise PreferencesError( + f"unsupported preferences version {version!r}; expected {PREFERENCES_VERSION}" + ) + directory = payload.get("data_directory") + if not isinstance(directory, str) or not directory.strip(): + raise PreferencesError("preferences.data_directory must be a non-empty string") + return normalize_path(directory) + + +def save_preferences(directory: PathLike, path: Optional[PathLike] = None) -> str: + """Atomically remember a normalized data directory and return it.""" + normalized = normalize_path(directory) + preferences_path = Path(path) if path is not None else preferences_file() + preferences_path.parent.mkdir(parents=True, exist_ok=True) + content = json.dumps( + {"version": PREFERENCES_VERSION, "data_directory": normalized}, + indent=2, + ensure_ascii=False, + ) + "\n" + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{preferences_path.name}.", suffix=".tmp", dir=preferences_path.parent + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_name, preferences_path) + except Exception: + try: + os.unlink(temporary_name) + except FileNotFoundError: + pass + raise + return normalized + + +def resolve_startup_data_path( + cli_data: Optional[PathLike] = None, + preferences_path: Optional[PathLike] = None, +) -> Optional[str]: + """Resolve CLI precedence, returning ``None`` when onboarding is required.""" + if cli_data is not None: + return normalize_path(cli_data) + directory = load_preferences(preferences_path) + if directory is None: + return None + return data_path_for_directory(directory) diff --git a/pyproject.toml b/pyproject.toml index 513ae23..c57e010 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" description = "A portable terminal app for backpacking gear and trip pack lists" requires-python = ">=3.9" dependencies = [ + "platformdirs>=4.0.0", "textual>=0.60.0", ] diff --git a/requirements.txt b/requirements.txt index 969b8c4..ca7b155 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ +platformdirs>=4.0.0 textual>=0.60.0 diff --git a/tests/test_preferences.py b/tests/test_preferences.py new file mode 100644 index 0000000..c8ba372 --- /dev/null +++ b/tests/test_preferences.py @@ -0,0 +1,75 @@ +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import packrat_preferences as preferences + + +class PreferencePersistenceTests(unittest.TestCase): + def test_platform_paths_are_used(self): + with patch("packrat_preferences.user_config_path") as config_path: + config_path.return_value = Path("platform-config") + self.assertEqual( + preferences.preferences_file(), + Path("platform-config") / "preferences.json", + ) + with patch("packrat_preferences.user_data_path") as data_path: + data_path.return_value = Path("platform-data") + self.assertEqual(preferences.suggested_data_directory(), Path("platform-data")) + + def test_preferences_round_trip_normalizes_directory(self): + with tempfile.TemporaryDirectory() as directory: + preference_path = Path(directory) / "config" / "preferences.json" + relative_data_dir = Path(directory) / "data" / ".." / "gear" + saved = preferences.save_preferences(relative_data_dir, preference_path) + + self.assertTrue(os.path.isabs(saved)) + self.assertEqual(preferences.load_preferences(preference_path), saved) + payload = json.loads(preference_path.read_text(encoding="utf-8")) + self.assertEqual(payload["version"], preferences.PREFERENCES_VERSION) + self.assertEqual(payload["data_directory"], saved) + self.assertFalse(any(path.suffix == ".tmp" for path in preference_path.parent.iterdir())) + + def test_missing_preferences_require_onboarding(self): + with tempfile.TemporaryDirectory() as directory: + preference_path = Path(directory) / "missing.json" + self.assertIsNone(preferences.load_preferences(preference_path)) + self.assertIsNone(preferences.resolve_startup_data_path(None, preference_path)) + + def test_malformed_preferences_are_reported(self): + with tempfile.TemporaryDirectory() as directory: + preference_path = Path(directory) / "preferences.json" + preference_path.write_text('{"version": 1,', encoding="utf-8") + with self.assertRaisesRegex(preferences.PreferencesError, "line 1"): + preferences.load_preferences(preference_path) + + preference_path.write_text( + json.dumps({"version": 1, "data_directory": ""}), encoding="utf-8" + ) + with self.assertRaisesRegex(preferences.PreferencesError, "non-empty"): + preferences.load_preferences(preference_path) + + def test_cli_path_has_precedence_without_writing_preferences(self): + with tempfile.TemporaryDirectory() as directory: + preference_path = Path(directory) / "preferences.json" + override = Path(directory) / "one-off.json" + resolved = preferences.resolve_startup_data_path(override, preference_path) + self.assertEqual(resolved, str(override.resolve())) + self.assertFalse(preference_path.exists()) + + def test_remembered_directory_resolves_fixed_filename(self): + with tempfile.TemporaryDirectory() as directory: + preference_path = Path(directory) / "preferences.json" + data_directory = Path(directory) / "library" + preferences.save_preferences(data_directory, preference_path) + self.assertEqual( + preferences.resolve_startup_data_path(None, preference_path), + str((data_directory / preferences.DATA_FILENAME).resolve()), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tui.py b/tests/test_tui.py index 9d3c271..efbe65d 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -1,3 +1,4 @@ +import copy import tempfile import unittest from pathlib import Path @@ -9,10 +10,13 @@ GearTrackerApp, PackAuditScreen, ShortcutHelpScreen, + SetupApp, TripComparisonScreen, TripDashboardScreen, TripItemFormScreen, ) +import gear_core as gc +import packrat_preferences as preferences class KeyboardWorkflowTests(unittest.IsolatedAsyncioTestCase): @@ -78,5 +82,110 @@ async def test_duplicate_compare_quantity_and_audit_workflow(self): self.assertTrue(app.data["trips"][1]["audit"]) +class PreferenceWorkflowTests(unittest.IsolatedAsyncioTestCase): + async def test_first_run_creates_example_library_and_remembers_folder(self): + with tempfile.TemporaryDirectory() as directory: + storage = Path(directory) / "library" + preference_path = Path(directory) / "config" / "preferences.json" + app = SetupApp(preferences_path=str(preference_path)) + async with app.run_test(size=(100, 30)) as pilot: + app.query_one("#setup-folder", Input).value = str(storage) + await pilot.click("#setup-use") + + data_path = storage / preferences.DATA_FILENAME + self.assertTrue(data_path.exists()) + self.assertTrue(gc.load_data(data_path)["gear"]) + self.assertEqual(preferences.load_preferences(preference_path), str(storage.resolve())) + + async def test_first_run_quit_creates_nothing(self): + with tempfile.TemporaryDirectory() as directory: + storage = Path(directory) / "unused-library" + preference_path = Path(directory) / "config" / "preferences.json" + app = SetupApp(preferences_path=str(preference_path)) + async with app.run_test(size=(100, 30)) as pilot: + app.query_one("#setup-folder", Input).value = str(storage) + await pilot.click("#setup-quit") + + self.assertFalse(storage.exists()) + self.assertFalse(preference_path.exists()) + + async def test_open_create_switches_library_and_updates_derived_paths(self): + with tempfile.TemporaryDirectory() as directory: + original = Path(directory) / "original" / "gear.json" + preference_path = Path(directory) / "config" / "preferences.json" + app = GearTrackerApp(str(original), preferences_path=str(preference_path)) + destination = Path(directory) / "other" + + async with app.run_test(size=(120, 40)): + app._change_library(("open", str(destination))) + + expected = destination / preferences.DATA_FILENAME + self.assertEqual(Path(app.data_path), expected.resolve()) + self.assertEqual(Path(app.export_dir), destination.resolve() / "exports") + self.assertEqual(preferences.load_preferences(preference_path), str(destination.resolve())) + self.assertTrue(expected.exists()) + + async def test_copy_switch_preserves_source_and_refuses_overwrite(self): + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "source" / "gear.json" + preference_path = Path(directory) / "config" / "preferences.json" + app = GearTrackerApp(str(source), preferences_path=str(preference_path)) + app.data["gear"][0]["name"] = "Current library marker" + self.assertTrue(app.save()) + source_before = source.read_text(encoding="utf-8") + destination = Path(directory) / "copy" + + async with app.run_test(size=(120, 40)): + app._change_library(("copy", str(destination))) + copied_path = destination / preferences.DATA_FILENAME + self.assertEqual(gc.load_data(copied_path)["gear"][0]["name"], "Current library marker") + self.assertEqual(source.read_text(encoding="utf-8"), source_before) + + first_destination_data = copy.deepcopy(app.data) + second_destination = Path(directory) / "occupied" + second_destination.mkdir() + occupied_path = second_destination / preferences.DATA_FILENAME + gc.save_data(occupied_path, gc.blank_data()) + current_path = app.data_path + app._change_library(("copy", str(second_destination))) + self.assertEqual(app.data_path, current_path) + self.assertEqual(app.data, first_destination_data) + self.assertEqual(gc.load_data(occupied_path)["gear"], []) + + async def test_invalid_library_rolls_back_path_data_and_preference(self): + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "source" / "gear.json" + preference_path = Path(directory) / "config" / "preferences.json" + app = GearTrackerApp(str(source), preferences_path=str(preference_path)) + preferences.save_preferences(source.parent, preference_path) + original_data = copy.deepcopy(app.data) + invalid_directory = Path(directory) / "invalid" + invalid_directory.mkdir() + (invalid_directory / preferences.DATA_FILENAME).write_text("{bad json", encoding="utf-8") + + async with app.run_test(size=(120, 40)): + app._change_library(("open", str(invalid_directory))) + self.assertEqual(Path(app.data_path), source.resolve()) + self.assertEqual(app.data, original_data) + self.assertEqual(preferences.load_preferences(preference_path), str(source.parent.resolve())) + + async def test_preference_write_failure_removes_new_destination(self): + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "source" / "gear.json" + blocking_file = Path(directory) / "not-a-config-directory" + blocking_file.write_text("blocked", encoding="utf-8") + preference_path = blocking_file / "preferences.json" + app = GearTrackerApp(str(source), preferences_path=str(preference_path)) + destination = Path(directory) / "destination" + original_path = app.data_path + original_data = copy.deepcopy(app.data) + + async with app.run_test(size=(120, 40)): + app._change_library(("copy", str(destination))) + self.assertEqual(app.data_path, original_path) + self.assertEqual(app.data, original_data) + self.assertFalse((destination / preferences.DATA_FILENAME).exists()) + + if __name__ == "__main__": unittest.main() diff --git a/uv.lock b/uv.lock index 54d93f6..cf3b5f2 100644 --- a/uv.lock +++ b/uv.lock @@ -536,6 +536,8 @@ name = "gear-tracker" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "platformdirs", version = "4.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "textual" }, ] @@ -545,7 +547,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "textual", specifier = ">=0.60.0" }] +requires-dist = [ + { name = "platformdirs", specifier = ">=4.0.0" }, + { name = "textual", specifier = ">=0.60.0" }, +] [package.metadata.requires-dev] dev = [{ name = "textual-dev", specifier = ">=1.8.0" }] From 26feeb08da1eefb7439951e88328dc661d92de16 Mon Sep 17 00:00:00 2001 From: Brad Coudriet Date: Tue, 1 Sep 2026 06:39:38 -0400 Subject: [PATCH 2/3] Fix macOS preference path assertions --- tests/test_preferences.py | 4 ++-- tests/test_tui.py | 25 +++++++++++++++++++------ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/test_preferences.py b/tests/test_preferences.py index c8ba372..784b636 100644 --- a/tests/test_preferences.py +++ b/tests/test_preferences.py @@ -57,7 +57,7 @@ def test_cli_path_has_precedence_without_writing_preferences(self): preference_path = Path(directory) / "preferences.json" override = Path(directory) / "one-off.json" resolved = preferences.resolve_startup_data_path(override, preference_path) - self.assertEqual(resolved, str(override.resolve())) + self.assertEqual(resolved, preferences.normalize_path(override)) self.assertFalse(preference_path.exists()) def test_remembered_directory_resolves_fixed_filename(self): @@ -67,7 +67,7 @@ def test_remembered_directory_resolves_fixed_filename(self): preferences.save_preferences(data_directory, preference_path) self.assertEqual( preferences.resolve_startup_data_path(None, preference_path), - str((data_directory / preferences.DATA_FILENAME).resolve()), + preferences.data_path_for_directory(data_directory), ) diff --git a/tests/test_tui.py b/tests/test_tui.py index efbe65d..8b5d46f 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -1,4 +1,5 @@ import copy +import os import tempfile import unittest from pathlib import Path @@ -95,7 +96,10 @@ async def test_first_run_creates_example_library_and_remembers_folder(self): data_path = storage / preferences.DATA_FILENAME self.assertTrue(data_path.exists()) self.assertTrue(gc.load_data(data_path)["gear"]) - self.assertEqual(preferences.load_preferences(preference_path), str(storage.resolve())) + self.assertEqual( + preferences.load_preferences(preference_path), + preferences.normalize_path(storage), + ) async def test_first_run_quit_creates_nothing(self): with tempfile.TemporaryDirectory() as directory: @@ -120,9 +124,15 @@ async def test_open_create_switches_library_and_updates_derived_paths(self): app._change_library(("open", str(destination))) expected = destination / preferences.DATA_FILENAME - self.assertEqual(Path(app.data_path), expected.resolve()) - self.assertEqual(Path(app.export_dir), destination.resolve() / "exports") - self.assertEqual(preferences.load_preferences(preference_path), str(destination.resolve())) + self.assertEqual(app.data_path, preferences.normalize_path(expected)) + self.assertEqual( + app.export_dir, + os.path.join(preferences.normalize_path(destination), "exports"), + ) + self.assertEqual( + preferences.load_preferences(preference_path), + preferences.normalize_path(destination), + ) self.assertTrue(expected.exists()) async def test_copy_switch_preserves_source_and_refuses_overwrite(self): @@ -165,9 +175,12 @@ async def test_invalid_library_rolls_back_path_data_and_preference(self): async with app.run_test(size=(120, 40)): app._change_library(("open", str(invalid_directory))) - self.assertEqual(Path(app.data_path), source.resolve()) + self.assertEqual(app.data_path, preferences.normalize_path(source)) self.assertEqual(app.data, original_data) - self.assertEqual(preferences.load_preferences(preference_path), str(source.parent.resolve())) + self.assertEqual( + preferences.load_preferences(preference_path), + preferences.normalize_path(source.parent), + ) async def test_preference_write_failure_removes_new_destination(self): with tempfile.TemporaryDirectory() as directory: From 321c031f5beb0cf9b3493056807403fd9a75e5f1 Mon Sep 17 00:00:00 2001 From: Brad Coudriet Date: Tue, 1 Sep 2026 06:45:18 -0400 Subject: [PATCH 3/3] Polish storage preferences workflow --- README.md | 2 +- gear_tui.py | 45 +++++++++++++++++++++++++++++++++++++++------ tests/test_tui.py | 42 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 9bb5480..edb2085 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ platform-specific folder or enter another folder; Packrat creates Without `uv`: ```bash -pip install -r requirements.txt # or: pip install textual +pip install -r requirements.txt # or: pip install textual platformdirs python3 main.py ``` diff --git a/gear_tui.py b/gear_tui.py index 6726b28..d6a2e3b 100644 --- a/gear_tui.py +++ b/gear_tui.py @@ -7,7 +7,7 @@ python3 gear_tui.py python3 gear_tui.py --data /path/to/gear_data.json -Requires: pip install textual (see requirements.txt) +Requires: pip install textual platformdirs (see requirements.txt) """ import argparse @@ -22,6 +22,7 @@ from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.css.query import NoMatches from textual.screen import ModalScreen, Screen from textual.widgets import ( Button, @@ -745,7 +746,7 @@ def compose(self) -> ComposeResult: help_text = """[b]Keyboard shortcuts[/b] [b]Anywhere[/b] 1 / 2 / 3 Switch tabs / Search ? This help - Ctrl+B Backup data Q Quit + Ctrl+B Backup data Ctrl+P Preferences Q Quit [b]Gear[/b] A Add E Edit Delete Delete R Review filter [b]Trips[/b] A Add Enter Open D Duplicate C Compare Delete Delete @@ -1421,6 +1422,7 @@ class SetupApp(App): CSS = APP_CSS + "\nScreen { align: center middle; }" TITLE = "Packrat Setup" + AUTO_FOCUS = "#setup-folder" def __init__(self, initial_error: str = "", preferences_path: Optional[str] = None): super().__init__() @@ -1441,6 +1443,19 @@ def compose(self) -> ComposeResult: yield Button("Quit", id="setup-quit") yield Button("Use This Folder", id="setup-use", variant="success") + def on_mount(self) -> None: + self.call_after_refresh(self._select_folder) + + def _select_folder(self) -> None: + try: + self.query_one("#setup-folder", Input).select_all() + except NoMatches: + pass + + @on(Input.Submitted, "#setup-folder") + def _submit_folder(self) -> None: + self._use_folder() + @on(Button.Pressed, "#setup-use") def _use_folder(self) -> None: directory = self.query_one("#setup-folder", Input).value @@ -1471,10 +1486,12 @@ class PreferencesScreen(ModalScreen[Optional[Tuple[str, str]]]): """Choose whether to open another library or copy the current one.""" BINDINGS = [Binding("escape", "cancel", "Cancel")] + AUTO_FOCUS = "#preferences-folder" - def __init__(self, current_directory: str): + def __init__(self, current_directory: str, initial_error: str = ""): super().__init__() self.current_directory = current_directory + self.initial_error = initial_error def compose(self) -> ComposeResult: with Vertical(id="preferences-dialog"): @@ -1485,12 +1502,25 @@ def compose(self) -> ComposeResult: ) yield Label("Gear storage folder") yield Input(value=self.current_directory, id="preferences-folder") - yield Static("", id="preferences-error") + yield Static(self.initial_error, id="preferences-error") with Horizontal(classes="dialog-buttons"): yield Button("Cancel", id="preferences-cancel") yield Button("Open / Create", id="preferences-open", variant="success") yield Button("Copy Current & Switch", id="preferences-copy") + def on_mount(self) -> None: + self.call_after_refresh(self._select_folder) + + def _select_folder(self) -> None: + try: + self.query_one("#preferences-folder", Input).select_all() + except NoMatches: + pass + + @on(Input.Submitted, "#preferences-folder") + def _submit_folder(self) -> None: + self._open() + def action_cancel(self) -> None: self.dismiss(None) @@ -1517,7 +1547,7 @@ class GearTrackerApp(App): Binding("slash", "search", "Search"), Binding("question_mark", "show_help", "Help"), Binding("ctrl+b", "backup", "Backup"), - Binding("ctrl+p", "preferences", "Preferences"), + Binding("ctrl+p", "preferences", "Preferences", priority=True), Binding("q", "quit", "Quit"), Binding("ctrl+c", "quit", "Quit", show=False), ] @@ -1603,7 +1633,10 @@ def _change_library(self, result: Optional[Tuple[str, str]]) -> None: os.unlink(created_path) except OSError: pass - self.notify(f"Library switch failed: {exc}", severity="error", timeout=7) + self.push_screen( + PreferencesScreen(directory, initial_error=f"Library switch failed: {exc}"), + self._change_library, + ) return self.data_path = destination_path diff --git a/tests/test_tui.py b/tests/test_tui.py index 8b5d46f..d0e6899 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -4,12 +4,13 @@ import unittest from pathlib import Path -from textual.widgets import Input, TabbedContent +from textual.widgets import Input, Static, TabbedContent from gear_tui import ( GearFormScreen, GearTrackerApp, PackAuditScreen, + PreferencesScreen, ShortcutHelpScreen, SetupApp, TripComparisonScreen, @@ -90,8 +91,10 @@ async def test_first_run_creates_example_library_and_remembers_folder(self): preference_path = Path(directory) / "config" / "preferences.json" app = SetupApp(preferences_path=str(preference_path)) async with app.run_test(size=(100, 30)) as pilot: - app.query_one("#setup-folder", Input).value = str(storage) - await pilot.click("#setup-use") + folder = app.query_one("#setup-folder", Input) + self.assertTrue(folder.has_focus) + folder.value = str(storage) + await pilot.press("enter") data_path = storage / preferences.DATA_FILENAME self.assertTrue(data_path.exists()) @@ -135,6 +138,27 @@ async def test_open_create_switches_library_and_updates_derived_paths(self): ) self.assertTrue(expected.exists()) + async def test_preferences_focuses_folder_and_enter_opens_library(self): + with tempfile.TemporaryDirectory() as directory: + original = Path(directory) / "original" / "gear.json" + preference_path = Path(directory) / "config" / "preferences.json" + destination = Path(directory) / "keyboard-library" + app = GearTrackerApp(str(original), preferences_path=str(preference_path)) + + async with app.run_test(size=(120, 40)) as pilot: + await pilot.press("ctrl+p") + self.assertIsInstance(app.screen, PreferencesScreen) + folder = app.screen.query_one("#preferences-folder", Input) + self.assertTrue(folder.has_focus) + folder.value = str(destination) + await pilot.press("enter") + + self.assertNotIsInstance(app.screen, PreferencesScreen) + self.assertEqual( + app.data_path, + preferences.data_path_for_directory(destination), + ) + async def test_copy_switch_preserves_source_and_refuses_overwrite(self): with tempfile.TemporaryDirectory() as directory: source = Path(directory) / "source" / "gear.json" @@ -173,14 +197,24 @@ async def test_invalid_library_rolls_back_path_data_and_preference(self): invalid_directory.mkdir() (invalid_directory / preferences.DATA_FILENAME).write_text("{bad json", encoding="utf-8") - async with app.run_test(size=(120, 40)): + async with app.run_test(size=(120, 40)) as pilot: app._change_library(("open", str(invalid_directory))) + await pilot.pause() self.assertEqual(app.data_path, preferences.normalize_path(source)) self.assertEqual(app.data, original_data) self.assertEqual( preferences.load_preferences(preference_path), preferences.normalize_path(source.parent), ) + self.assertIsInstance(app.screen, PreferencesScreen) + self.assertEqual( + app.screen.query_one("#preferences-folder", Input).value, + str(invalid_directory), + ) + self.assertIn( + "Library switch failed", + str(app.screen.query_one("#preferences-error", Static).render()), + ) async def test_preference_write_failure_removes_new_destination(self): with tempfile.TemporaryDirectory() as directory: