From ff5ff8840253b227eb4d23da5619b07f74870821 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:57:26 -0500 Subject: [PATCH 01/10] =?UTF-8?q?=E2=9C=A8=20Add=20standard=20export/impor?= =?UTF-8?q?t=20for=20lists=20and=20saved=20graphs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing per-dashboard JSON format (x/i in each popup) so any list, dashboard, or saved graph can be shared as a single file — the format the upcoming Backup & Sync feature will read and write in bulk. --- src/hatty/controllers/graphs.py | 40 +++++++++ src/hatty/controllers/keybindings.py | 28 ++++++ src/hatty/controllers/lists.py | 46 ++++++++++ src/hatty/main.py | 77 +++++++++++++++- src/hatty/ui/graph/saved_graphs_popup.py | 7 ++ src/hatty/ui/json_export.py | 106 +++++++++++++++++++++++ src/hatty/ui/list_selection_popup.py | 7 ++ tests/test_list_management.py | 92 +++++++++++++++++++- tests/test_saved_graphs.py | 93 +++++++++++++++++++- tests/unit/binding_snapshot.json | 32 ++++++- tests/unit/test_graphs_controller.py | 56 ++++++++++++ tests/unit/test_lists_controller.py | 76 ++++++++++++++++ 12 files changed, 654 insertions(+), 6 deletions(-) create mode 100644 src/hatty/ui/json_export.py diff --git a/src/hatty/controllers/graphs.py b/src/hatty/controllers/graphs.py index 8d5bf20..4572195 100644 --- a/src/hatty/controllers/graphs.py +++ b/src/hatty/controllers/graphs.py @@ -2,6 +2,7 @@ """Graph/history state, the detail panel rendering, and saved graphs, extracted from HACLI.""" +import copy from collections import deque from datetime import datetime, timedelta, timezone @@ -14,6 +15,9 @@ from hatty.types import Entity from hatty.ui.graph.entity_detail import EntityDetailPanel +#: Bumped if the export payload shape ever changes incompatibly. +EXPORT_FORMAT_VERSION = 1 + def _trim_history(buf: deque, hours: float, ts_of=lambda item: item[0]) -> None: """Evict entries older than `hours` before the newest entry in `buf`, in place. @@ -269,6 +273,42 @@ def save_graph( self._app.persist("saved_graphs") self._app.notify(f"Graph saved as '{name}'.", title="Graph Saved") + # ── Export / import ────────────────────────────────────────────────────── + + def to_export_payload(self, name: str) -> dict: + """A JSON-serializable snapshot of saved graph `name`, versioned so a + future format change can be detected on import.""" + return { + "hatty_graph": EXPORT_FORMAT_VERSION, + "name": name, + "graph": copy.deepcopy(self.saved_graphs[name]), + } + + def import_from_payload(self, payload: dict) -> str: + """Create a new saved graph from a previously exported payload, + deduplicating its name against the existing collection. Raises + `ValueError` (with a user-facing message) if `payload` isn't a + recognizable export. Returns the final graph name.""" + if not isinstance(payload, dict) or payload.get("hatty_graph") != EXPORT_FORMAT_VERSION: + raise ValueError("Not a valid hatty saved graph export file.") + graph = payload.get("graph") + if not isinstance(graph, dict) or "entity_ids" not in graph: + raise ValueError("Saved graph export is missing entity_ids.") + + final = self._unique_name(str(payload.get("name") or "Imported")) + self.saved_graphs[final] = copy.deepcopy(graph) + self._app.persist("saved_graphs") + return final + + def _unique_name(self, name: str) -> str: + """`name`, or `name (2)`, `name (3)`, ... if it's already taken.""" + final = name + suffix = 2 + while final in self.saved_graphs: + final = f"{name} ({suffix})" + suffix += 1 + return final + def handle_saved_graphs_popup_action(self, result: dict) -> None: app = self._app action = result.get("action") diff --git a/src/hatty/controllers/keybindings.py b/src/hatty/controllers/keybindings.py index d99b9a8..0828bfb 100644 --- a/src/hatty/controllers/keybindings.py +++ b/src/hatty/controllers/keybindings.py @@ -1223,6 +1223,20 @@ class KeySpec(NamedTuple): action="delete_graph", description="Delete", ), + KeySpec( + id="saved_graphs_popup.export_graph", + scope="saved_graphs_popup", + key="x", + action="export_graph", + description="Export", + ), + KeySpec( + id="saved_graphs_popup.import_graph", + scope="saved_graphs_popup", + key="i", + action="import_graph", + description="Import", + ), KeySpec( id="nav.back", scope="saved_graphs_popup", @@ -1277,6 +1291,20 @@ class KeySpec(NamedTuple): action="view_as_dashboard", description="View as Dashboard", ), + KeySpec( + id="list_popup.export_list", + scope="list_popup", + key="x", + action="export_list", + description="Export", + ), + KeySpec( + id="list_popup.import_list", + scope="list_popup", + key="i", + action="import_list", + description="Import", + ), KeySpec( id="nav.back", scope="list_popup", diff --git a/src/hatty/controllers/lists.py b/src/hatty/controllers/lists.py index 7b48748..83158f5 100644 --- a/src/hatty/controllers/lists.py +++ b/src/hatty/controllers/lists.py @@ -4,6 +4,9 @@ from hatty.ui.confirm_popup import ConfirmPopup from hatty.ui.dashboard.screen import DashboardScreen +#: Bumped if the export payload shape ever changes incompatibly. +EXPORT_FORMAT_VERSION = 1 + class ListController: """Owns the entity-list collections, selection, and undo/redo for @@ -90,6 +93,49 @@ def _do_delete(confirmed, _name=list_name): elif action == "rename": self.rename_list(list_name, result.get("new_name")) + # ── Export / import ────────────────────────────────────────────────────── + + def to_export_payload(self, name: str) -> dict: + """A JSON-serializable snapshot of list `name`, versioned so a future + format change can be detected on import.""" + return { + "hatty_list": EXPORT_FORMAT_VERSION, + "name": name, + "entities": list(self.entity_lists.get(name, [])), + "manual": name in self.manual_lists, + "notify": name in self._app.notify_ctl.notify_lists, + } + + def import_from_payload(self, payload: dict) -> str: + """Create a new list from a previously exported payload, deduplicating + its name against the existing collection. Raises `ValueError` (with a + user-facing message) if `payload` isn't a recognizable export. Returns + the final list name.""" + if not isinstance(payload, dict) or payload.get("hatty_list") != EXPORT_FORMAT_VERSION: + raise ValueError("Not a valid hatty list export file.") + entities = payload.get("entities") + if not isinstance(entities, list): + raise ValueError("List export is missing its entities.") + + final = self._unique_name(str(payload.get("name") or "Imported")) + self.entity_lists[final] = list(entities) + self.list_names.append(final) + if payload.get("manual"): + self.manual_lists.add(final) + if payload.get("notify"): + self._app.notify_ctl.notify_lists.add(final) + self._app.persist("lists", "manual_lists", "notify_lists") + return final + + def _unique_name(self, name: str) -> str: + """`name`, or `name (2)`, `name (3)`, ... if it's already taken.""" + final = name + suffix = 2 + while final in self.entity_lists: + final = f"{name} ({suffix})" + suffix += 1 + return final + def rename_list(self, old_name: str | None, new_name: str | None) -> None: app = self._app new_name = (new_name or "").strip() diff --git a/src/hatty/main.py b/src/hatty/main.py index 88e81b3..af1a04d 100644 --- a/src/hatty/main.py +++ b/src/hatty/main.py @@ -461,12 +461,49 @@ def action_show_list_selection_popup(self) -> None: def callback(result) -> None: if isinstance(result, dict): - self.list_ctl.handle_popup_action(result) + action = result.get("action") + if action == "export": + self._export_list(result["list_name"]) + elif action == "import": + self._import_list() + else: + self.list_ctl.handle_popup_action(result) elif isinstance(result, str): self.select_or_create_list(result) self.push_screen(ListSelectionPopup(), callback) + def _export_list(self, name: str) -> None: + from hatty.ui.json_export import export_json, slugify + + export_json( + self, + payload=self.list_ctl.to_export_payload(name), + default_filename=f"{slugify(name)}.list.json", + title="Export list", + save_button="Export", + filters_label="List JSON", + success=lambda path: f"Exported '{name}' to {path}.", + success_title="List Exported", + ) + + def _import_list(self) -> None: + from hatty.ui.json_export import import_json + + def _apply(payload: dict) -> str: + final = self.list_ctl.import_from_payload(payload) + self.select_or_create_list(final) + return f"Imported list '{final}'." + + import_json( + self, + title="Import list", + open_button="Import", + filters_label="List JSON", + apply=_apply, + success_title="List Imported", + ) + def select_or_create_list(self, list_name: str) -> None: self.list_ctl.select_or_create(list_name) @@ -1064,10 +1101,46 @@ def action_show_saved_graphs_popup(self) -> None: def callback(result) -> None: if isinstance(result, dict): - self.graph_ctl.handle_saved_graphs_popup_action(result) + action = result.get("action") + if action == "export": + self._export_saved_graph(result["name"]) + elif action == "import": + self._import_saved_graph() + else: + self.graph_ctl.handle_saved_graphs_popup_action(result) self.push_screen(SavedGraphsPopup(), callback) + def _export_saved_graph(self, name: str) -> None: + from hatty.ui.json_export import export_json, slugify + + export_json( + self, + payload=self.graph_ctl.to_export_payload(name), + default_filename=f"{slugify(name)}.graph.json", + title="Export saved graph", + save_button="Export", + filters_label="Graph JSON", + success=lambda path: f"Exported '{name}' to {path}.", + success_title="Graph Exported", + ) + + def _import_saved_graph(self) -> None: + from hatty.ui.json_export import import_json + + def _apply(payload: dict) -> str: + final = self.graph_ctl.import_from_payload(payload) + return f"Imported saved graph '{final}'." + + import_json( + self, + title="Import saved graph", + open_button="Import", + filters_label="Graph JSON", + apply=_apply, + success_title="Graph Imported", + ) + # ── Config persistence ─────────────────────────────────────────────────── def watch_theme(self, theme: str) -> None: diff --git a/src/hatty/ui/graph/saved_graphs_popup.py b/src/hatty/ui/graph/saved_graphs_popup.py index 87496f2..0768cd5 100644 --- a/src/hatty/ui/graph/saved_graphs_popup.py +++ b/src/hatty/ui/graph/saved_graphs_popup.py @@ -95,6 +95,13 @@ def action_delete_graph(self) -> None: if self.selected_name: self.dismiss({"action": "delete", "name": self.selected_name}) + def action_export_graph(self) -> None: + if self.selected_name: + self.dismiss({"action": "export", "name": self.selected_name}) + + def action_import_graph(self) -> None: + self.dismiss({"action": "import"}) + def action_cancel(self) -> None: rename_input = self.query_one("#saved_graph_rename_input", Input) if rename_input.display: diff --git a/src/hatty/ui/json_export.py b/src/hatty/ui/json_export.py new file mode 100644 index 0000000..f24c1c7 --- /dev/null +++ b/src/hatty/ui/json_export.py @@ -0,0 +1,106 @@ +# hatty — MIT License. See LICENSE file for details. +"""Shared FileSave/FileOpen plumbing for hatty's single-object JSON export/import +popups (lists, saved graphs — mirrors dashboards' pre-existing export/import, +`ui/dashboard/screen.py`). One JSON file per object, always `{"hatty_": +, "name": ..., : {...}}`, so any file this writes can be dropped +into a directory another hatty instance treats as an import source.""" + +import json +from collections.abc import Callable +from pathlib import Path + +from textual_fspicker import FileOpen, FileSave, Filters + + +def json_filters(label: str) -> Filters: + return Filters( + (label, lambda p: p.suffix.lower() == ".json"), + ("All files", lambda _p: True), + ) + + +def slugify(name: str) -> str: + return name.strip().lower().replace(" ", "-") or "export" + + +def export_json( + host, + *, + payload: dict, + default_filename: str, + title: str, + save_button: str, + success: Callable[[Path], str], + filters_label: str, + error_title: str = "Export Failed", + success_title: str = "Exported", +) -> None: + """Push a FileSave dialog and write `payload` as indent=2 JSON to the chosen + path. `success(path)` builds the notify message on success. `host` is + anything with `push_screen`/`notify` directly — the App itself, or a Screen + via `self.app`.""" + + def _do_export(path: Path | None) -> None: + if path is None: + return + try: + path.expanduser().write_text(json.dumps(payload, indent=2)) + except OSError as exc: + host.notify(f"Could not write '{path}': {exc}", title=error_title, severity="error") + return + host.notify(success(path), title=success_title) + + host.push_screen( + FileSave( + location=str(Path.home()), + title=title, + save_button=save_button, + cancel_button="Cancel", + default_file=default_filename, + filters=json_filters(filters_label), + ), + _do_export, + ) + + +def import_json( + host, + *, + title: str, + open_button: str, + apply: Callable[[dict], str], + filters_label: str, + error_title: str = "Import Failed", + success_title: str = "Imported", +) -> None: + """Push a FileOpen dialog, parse the chosen file as JSON, and hand it to + `apply(payload)`, which performs the import and returns the notify message + (raising `ValueError` with a user-facing message to reject it). `host` is + anything with `push_screen`/`notify` directly — the App itself, or a Screen + via `self.app`.""" + + def _do_import(path: Path | None) -> None: + if path is None: + return + try: + payload = json.loads(path.expanduser().read_text()) + except (OSError, ValueError) as exc: + host.notify(f"Could not read '{path}': {exc}", title=error_title, severity="error") + return + try: + message = apply(payload) + except ValueError as exc: + host.notify(str(exc), title=error_title, severity="error") + return + host.notify(message, title=success_title) + + host.push_screen( + FileOpen( + location=str(Path.home()), + title=title, + open_button=open_button, + cancel_button="Cancel", + filters=json_filters(filters_label), + ), + _do_import, + ) diff --git a/src/hatty/ui/list_selection_popup.py b/src/hatty/ui/list_selection_popup.py index 9871102..9589238 100644 --- a/src/hatty/ui/list_selection_popup.py +++ b/src/hatty/ui/list_selection_popup.py @@ -143,6 +143,13 @@ def action_view_as_dashboard(self) -> None: if self.selected_name and self.selected_name != "View All": self.dismiss({"action": "view_as_dashboard", "list_name": self.selected_name}) + def action_export_list(self) -> None: + if self.selected_name and self.selected_name != "View All": + self.dismiss({"action": "export", "list_name": self.selected_name}) + + def action_import_list(self) -> None: + self.dismiss({"action": "import"}) + def action_toggle_notify(self) -> None: # Acts in place (like _move below) rather than dismissing, so several # lists can be toggled in one visit to the popup. diff --git a/tests/test_list_management.py b/tests/test_list_management.py index 197cf33..eebc3f1 100644 --- a/tests/test_list_management.py +++ b/tests/test_list_management.py @@ -1,5 +1,8 @@ # hatty — MIT License. See LICENSE file for details. -from textual.widgets import ListView +import json + +from textual.widgets import Input, ListView +from textual_fspicker import FileOpen, FileSave from hatty.ui.entity_table import EntitiesTable from hatty.ui.list_selection_popup import ListSelectionPopup @@ -425,3 +428,90 @@ async def test_reorder_refused_while_searching(make_app, sample_entities): assert app.list_names == original_order assert notified(app, message_contains="Clear the search") + + +# ── export / import ────────────────────────────────────────────────────────── + + +async def test_export_list_writes_file(make_app, tmp_path): + config_data = { + **make_config(), + "lists": {"list_a": ["switch.fan"]}, + "manual_lists": ["list_a"], + "notify_lists": ["list_a"], + } + app = make_app(config_data=config_data) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("l") + await pilot.pause() + await pilot.press("down", "down") + await pilot.press("x") + await pilot.pause() + assert isinstance(app.screen, FileSave) + + out_path = tmp_path / "export.json" + input_widget = app.screen.query_one(Input) + input_widget.value = str(out_path) + input_widget.focus() + await pilot.press("enter") + await pilot.pause() + + payload = json.loads(out_path.read_text()) + assert payload == { + "hatty_list": 1, + "name": "list_a", + "entities": ["switch.fan"], + "manual": True, + "notify": True, + } + assert notified(app, title="List Exported") + + +async def test_import_list_from_file(make_app, tmp_path): + app = make_app(config_data=NO_LIST_CONFIG) + payload = {"hatty_list": 1, "name": "Imported List", "entities": ["switch.fan"], "manual": False, "notify": False} + in_path = tmp_path / "import.json" + in_path.write_text(json.dumps(payload)) + + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("l") + await pilot.pause() + await pilot.press("i") + await pilot.pause() + assert isinstance(app.screen, FileOpen) + + input_widget = app.screen.query_one(Input) + input_widget.value = str(in_path) + input_widget.focus() + await pilot.press("enter") + await pilot.pause() + + assert "Imported List" in app.entity_lists + assert app.entity_lists["Imported List"] == ["switch.fan"] + assert app.current_list_name == "Imported List" + assert notified(app, title="List Imported") + assert not isinstance(app.screen, (FileOpen, ListSelectionPopup)) + + +async def test_import_list_rejects_malformed_file(make_app, tmp_path): + app = make_app(config_data=NO_LIST_CONFIG) + in_path = tmp_path / "bad.json" + in_path.write_text(json.dumps({"not": "a list export"})) + + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("l") + await pilot.pause() + await pilot.press("i") + await pilot.pause() + + input_widget = app.screen.query_one(Input) + input_widget.value = str(in_path) + input_widget.focus() + await pilot.press("enter") + await pilot.pause() + + assert notified(app, title="Import Failed") + assert app.current_list_name is None diff --git a/tests/test_saved_graphs.py b/tests/test_saved_graphs.py index 3661f70..4801931 100644 --- a/tests/test_saved_graphs.py +++ b/tests/test_saved_graphs.py @@ -1,11 +1,15 @@ # hatty — MIT License. See LICENSE file for details. +import json + from textual.coordinate import Coordinate +from textual.widgets import Input +from textual_fspicker import FileOpen, FileSave from hatty.ui.entity_table import EntitiesTable from hatty.ui.graph.preview_screen import GraphPreviewScreen from hatty.ui.graph.saved_graphs_popup import SavedGraphsPopup, SaveGraphNamePopup from hatty.ui.list_selection_popup import ListSelectionPopup -from tests.conftest import NO_LIST_CONFIG, make_config +from tests.conftest import NO_LIST_CONFIG, make_config, notified # Alphabetical order with no list: # Row 0: Fan Switch (switch.fan, off) @@ -485,3 +489,90 @@ async def test_l_on_fullscreen_graph_opens_picker_when_no_list(make_app, sample_ await pilot.pause() assert isinstance(app.screen, ListSelectionPopup) + + +# ── export / import ────────────────────────────────────────────────────────── + + +async def test_export_saved_graph_writes_file(make_app, sample_entities, tmp_path): + config_data = { + **NO_LIST_CONFIG, + "saved_graphs": { + "Temp Trend": {"entity_ids": ["sensor.temperature"], "graph_type": "line", "hours": 4}, + }, + } + app = make_app(entities=sample_entities, config_data=config_data) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("s") + await pilot.pause() + await pilot.press("x") + await pilot.pause() + assert isinstance(app.screen, FileSave) + + out_path = tmp_path / "export.json" + input_widget = app.screen.query_one(Input) + input_widget.value = str(out_path) + input_widget.focus() + await pilot.press("enter") + await pilot.pause() + + payload = json.loads(out_path.read_text()) + assert payload == { + "hatty_graph": 1, + "name": "Temp Trend", + "graph": app.saved_graphs["Temp Trend"], + } + assert notified(app, title="Graph Exported") + + +async def test_import_saved_graph_from_file(make_app, sample_entities, tmp_path): + app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG) + payload = { + "hatty_graph": 1, + "name": "Imported Graph", + "graph": {"entity_ids": ["sensor.temperature"], "graph_type": "scatter", "hours": 12}, + } + in_path = tmp_path / "import.json" + in_path.write_text(json.dumps(payload)) + + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("s") + await pilot.pause() + await pilot.press("i") + await pilot.pause() + assert isinstance(app.screen, FileOpen) + + input_widget = app.screen.query_one(Input) + input_widget.value = str(in_path) + input_widget.focus() + await pilot.press("enter") + await pilot.pause() + + assert "Imported Graph" in app.saved_graphs + assert app.saved_graphs["Imported Graph"] == payload["graph"] + assert notified(app, title="Graph Imported") + assert not isinstance(app.screen, (FileOpen, SavedGraphsPopup)) + + +async def test_import_saved_graph_rejects_malformed_file(make_app, sample_entities, tmp_path): + app = make_app(entities=sample_entities, config_data=NO_LIST_CONFIG) + in_path = tmp_path / "bad.json" + in_path.write_text(json.dumps({"not": "a graph export"})) + + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("s") + await pilot.pause() + await pilot.press("i") + await pilot.pause() + + input_widget = app.screen.query_one(Input) + input_widget.value = str(in_path) + input_widget.focus() + await pilot.press("enter") + await pilot.pause() + + assert notified(app, title="Import Failed") + assert app.saved_graphs == {} diff --git a/tests/unit/binding_snapshot.json b/tests/unit/binding_snapshot.json index c27f8d6..2e4ad2f 100644 --- a/tests/unit/binding_snapshot.json +++ b/tests/unit/binding_snapshot.json @@ -1440,7 +1440,7 @@ "file": "src/hatty/ui/graph/saved_graphs_popup.py", "class": "SavedGraphsPopup", "lineno": 45, - "count": 4, + "count": 6, "entries": [ { "form": "tuple", @@ -1456,6 +1456,20 @@ "description": "Delete", "__tuple_form__": true }, + { + "form": "tuple", + "key": "x", + "action": "export_graph", + "description": "Export", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "i", + "action": "import_graph", + "description": "Import", + "__tuple_form__": true + }, { "form": "tuple", "key": "escape", @@ -1528,7 +1542,7 @@ "file": "src/hatty/ui/list_selection_popup.py", "class": "ListSelectionPopup", "lineno": 20, - "count": 10, + "count": 12, "entries": [ { "form": "tuple", @@ -1565,6 +1579,20 @@ "description": "View as Dashboard", "__tuple_form__": true }, + { + "form": "tuple", + "key": "x", + "action": "export_list", + "description": "Export", + "__tuple_form__": true + }, + { + "form": "tuple", + "key": "i", + "action": "import_list", + "description": "Import", + "__tuple_form__": true + }, { "form": "tuple", "key": "escape", diff --git a/tests/unit/test_graphs_controller.py b/tests/unit/test_graphs_controller.py index 34c85ef..5f21139 100644 --- a/tests/unit/test_graphs_controller.py +++ b/tests/unit/test_graphs_controller.py @@ -173,3 +173,59 @@ def test_record_state_skips_unmapped_binary_state(): def test_record_state_missing_entity_id_is_noop(): ctl = _controller() ctl.record_state({"state": "21.5"}) # no entity_id -> early return, no error + + +# ── export / import ────────────────────────────────────────────────────────── + + +def test_export_payload_shape(): + ctl = _controller() + ctl.saved_graphs = {"Temps": {"entity_ids": ["sensor.a"], "graph_type": "line", "hours": 4.0}} + payload = ctl.to_export_payload("Temps") + assert payload["hatty_graph"] == 1 + assert payload["name"] == "Temps" + assert payload["graph"] == ctl.saved_graphs["Temps"] + # A deep copy, not a live reference. + assert payload["graph"] is not ctl.saved_graphs["Temps"] + + +def test_import_round_trip_creates_matching_graph(): + ctl = _controller() + ctl.saved_graphs = {"Temps": {"entity_ids": ["sensor.a"], "graph_type": "line", "hours": 4.0}} + payload = ctl.to_export_payload("Temps") + + ctl2 = _controller() + final = ctl2.import_from_payload(payload) + assert final == "Temps" + assert ctl2.saved_graphs["Temps"] == ctl.saved_graphs["Temps"] + assert ("saved_graphs",) in ctl2._app.persist_calls + + +def test_import_dedupes_name_on_collision(): + ctl = _controller() + ctl.saved_graphs = {"Temps": {"entity_ids": ["sensor.a"], "graph_type": "line", "hours": 4.0}} + payload = ctl.to_export_payload("Temps") + final = ctl.import_from_payload(payload) + assert final == "Temps (2)" + assert "Temps" in ctl.saved_graphs and "Temps (2)" in ctl.saved_graphs + + +def test_import_rejects_wrong_version(): + ctl = _controller() + for bad in ({"hatty_graph": 2, "name": "A", "graph": {"entity_ids": []}}, {}, "not a dict"): + try: + ctl.import_from_payload(bad) + assert False, "expected ValueError" + except ValueError: + pass + + +def test_import_rejects_missing_entity_ids(): + ctl = _controller() + for bad_graph in (None, "nope", {}, {"graph_type": "line"}): + payload = {"hatty_graph": 1, "name": "A", "graph": bad_graph} + try: + ctl.import_from_payload(payload) + assert False, "expected ValueError" + except ValueError: + pass diff --git a/tests/unit/test_lists_controller.py b/tests/unit/test_lists_controller.py index 509711e..ce8936c 100644 --- a/tests/unit/test_lists_controller.py +++ b/tests/unit/test_lists_controller.py @@ -337,3 +337,79 @@ def test_handle_popup_rename_routes_to_rename_list(): ctl.entity_lists = {"Kitchen": []} ctl.handle_popup_action({"action": "rename", "list_name": "Kitchen", "new_name": "Study"}) assert ctl.list_names == ["Study"] + + +# ── export / import ────────────────────────────────────────────────────────── + + +def test_export_payload_shape(): + ctl = _controller() + ctl.list_names = ["Kitchen"] + ctl.entity_lists = {"Kitchen": ["light.a", "light.b"]} + ctl.manual_lists = {"Kitchen"} + ctl._app.notify_ctl.notify_lists = {"Kitchen"} + payload = ctl.to_export_payload("Kitchen") + assert payload == { + "hatty_list": 1, + "name": "Kitchen", + "entities": ["light.a", "light.b"], + "manual": True, + "notify": True, + } + + +def test_export_payload_defaults_manual_and_notify_false(): + ctl = _controller() + ctl.list_names = ["Kitchen"] + ctl.entity_lists = {"Kitchen": []} + payload = ctl.to_export_payload("Kitchen") + assert payload["manual"] is False + assert payload["notify"] is False + + +def test_import_round_trip_creates_matching_list(): + ctl = _controller() + ctl.list_names = ["Kitchen"] + ctl.entity_lists = {"Kitchen": ["light.a"]} + ctl.manual_lists = {"Kitchen"} + ctl._app.notify_ctl.notify_lists = {"Kitchen"} + payload = ctl.to_export_payload("Kitchen") + + ctl2 = _controller() + final = ctl2.import_from_payload(payload) + assert final == "Kitchen" + assert ctl2.entity_lists["Kitchen"] == ["light.a"] + assert ctl2.manual_lists == {"Kitchen"} + assert ctl2._app.notify_ctl.notify_lists == {"Kitchen"} + assert ("lists", "manual_lists", "notify_lists") in ctl2._app.persist_calls + + +def test_import_dedupes_name_on_collision(): + ctl = _controller() + ctl.list_names = ["Kitchen"] + ctl.entity_lists = {"Kitchen": []} + payload = ctl.to_export_payload("Kitchen") + final = ctl.import_from_payload(payload) + assert final == "Kitchen (2)" + assert "Kitchen" in ctl.entity_lists and "Kitchen (2)" in ctl.entity_lists + + +def test_import_rejects_wrong_version(): + ctl = _controller() + for bad in ({"hatty_list": 2, "name": "A", "entities": []}, {}, "not a dict"): + try: + ctl.import_from_payload(bad) + assert False, "expected ValueError" + except ValueError: + pass + + +def test_import_rejects_missing_entities(): + ctl = _controller() + for bad_entities in (None, "nope", {}): + payload = {"hatty_list": 1, "name": "A", "entities": bad_entities} + try: + ctl.import_from_payload(payload) + assert False, "expected ValueError" + except ValueError: + pass From 53d9e07cc1d7caae818062d8c82b51ceeb021c7a Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:02:28 -0500 Subject: [PATCH 02/10] =?UTF-8?q?=E2=9C=A8=20Add=20backup.py=20for=20direc?= =?UTF-8?q?tory=20export/import=20of=20user=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composes the standard per-object formats (lists/dashboards/saved graphs) plus whole-collection files (entity names, settings, keybindings) into a directory, with byte-stable writes and stale-object pruning so a git diff stays minimal. No git integration yet — that's git_sync.py next. --- src/hatty/backup.py | 291 ++++++++++++++++++++++++++++++++++++++ tests/unit/test_backup.py | 249 ++++++++++++++++++++++++++++++++ 2 files changed, 540 insertions(+) create mode 100644 src/hatty/backup.py create mode 100644 tests/unit/test_backup.py diff --git a/src/hatty/backup.py b/src/hatty/backup.py new file mode 100644 index 0000000..1c2183c --- /dev/null +++ b/src/hatty/backup.py @@ -0,0 +1,291 @@ +# hatty — MIT License. See LICENSE file for details. +"""Directory export/import for the Backup & Sync feature: a directory of small +JSON files — one per list/dashboard/saved graph, plus a handful of +whole-collection files — that mirrors the single-object export format +`to_export_payload`/`import_from_payload` already use (`controllers/lists.py`, +`controllers/dashboards.py`, `controllers/graphs.py`), so any file here can +also be hand-exported/imported through the normal popups, and vice versa. + +No git here — `git_sync.py` is the separate layer that treats this directory +as an optional git working tree. This module only knows how to read and write +the files. + +Layout: + + /hatty-backup.json manifest: format version, which sections + have ever been exported here, and the + cross-object scalars default_list / + default_dashboard + /lists/.list.json one hatty_list export per list + /dashboards/.dashboard.json one hatty_dashboard export per dashboard + /graphs/.graph.json one hatty_graph export per saved graph + /entity_names.json the whole entity_names mapping + /settings.json display prefs (never the HA token or ntfy password) + /keybindings.json the keybinding overrides +""" + +import json +import os +from collections.abc import Sequence +from datetime import datetime, timezone +from pathlib import Path + +from hatty import __version__ +from hatty.const import ( + CONFIG_KEY_COLUMNS, + CONFIG_KEY_DEFAULT_DASHBOARD, + CONFIG_KEY_DEFAULT_LIST, + CONFIG_KEY_ENTITY_NAMES, + CONFIG_KEY_GRAPH_HOURS, + CONFIG_KEY_GRAPH_TYPE, + CONFIG_KEY_KEYBINDINGS, + CONFIG_KEY_LOG_HOURS, + CONFIG_KEY_NOTIFICATIONS, + CONFIG_KEY_TERMINAL_TITLE, + CONFIG_KEY_TERMINAL_TITLE_ENABLED, + CONFIG_KEY_THEME, +) + +#: Bumped if the manifest/object-file shapes ever change incompatibly. +BACKUP_FORMAT_VERSION = 1 + +MANIFEST_FILENAME = "hatty-backup.json" + +SECTIONS: tuple[str, ...] = ("lists", "dashboards", "saved_graphs", "entity_names", "settings", "keybindings") + +SECTION_LABELS: dict[str, str] = { + "lists": "Lists", + "dashboards": "Dashboards", + "saved_graphs": "Saved Graphs", + "entity_names": "Entity Name Overrides", + "settings": "Display Settings", + "keybindings": "Keybindings", +} + +# section id -> (subdirectory, single-object marker key). The three sections +# with one file per object, mirroring each controller's export/import format. +_OBJECT_SECTIONS: dict[str, tuple[str, str]] = { + "lists": ("lists", "hatty_list"), + "dashboards": ("dashboards", "hatty_dashboard"), + "saved_graphs": ("graphs", "hatty_graph"), +} + +# The "settings" section's config keys — display prefs only, never the HA +# token (not in this list) or the ntfy password (stripped from notifications +# below). +_SETTINGS_KEYS = ( + CONFIG_KEY_COLUMNS, + CONFIG_KEY_THEME, + CONFIG_KEY_GRAPH_TYPE, + CONFIG_KEY_GRAPH_HOURS, + CONFIG_KEY_LOG_HOURS, + CONFIG_KEY_TERMINAL_TITLE_ENABLED, + CONFIG_KEY_TERMINAL_TITLE, +) + + +def slug(name: str) -> str: + """Mirrors the dashboard-export filename rule (`ui/dashboard/screen.py`).""" + return name.strip().lower().replace(" ", "-") or "export" + + +def _validate_sections(sections: Sequence[str]) -> tuple[str, ...]: + unknown = set(sections) - set(SECTIONS) + if unknown: + raise ValueError(f"Unknown backup section(s): {', '.join(sorted(unknown))}") + return tuple(s for s in SECTIONS if s in sections) + + +def _settings_payload(cfg: dict) -> dict: + settings = {key: cfg.get(key) for key in _SETTINGS_KEYS} + notifications = dict(cfg.get(CONFIG_KEY_NOTIFICATIONS) or {}) + notifications.pop("ntfy_password", None) + settings[CONFIG_KEY_NOTIFICATIONS] = notifications + return settings + + +def build_files(app, sections: Sequence[str]) -> dict[str, dict]: + """{relative path: JSON-able payload} for `sections`. Object sections + (lists/dashboards/saved_graphs) delegate to the matching controller's + `to_export_payload` so there is exactly one definition of each format.""" + sections = _validate_sections(sections) + files: dict[str, dict] = {} + + if "lists" in sections: + for name in app.list_ctl.list_names: + files[f"lists/{slug(name)}.list.json"] = app.list_ctl.to_export_payload(name) + if "dashboards" in sections: + temp = app.dash_ctl.temp_dashboard_names + for name in app.dash_ctl.dashboard_names: + if name in temp: + continue + files[f"dashboards/{slug(name)}.dashboard.json"] = app.dash_ctl.to_export_payload(name) + if "saved_graphs" in sections: + for name in app.graph_ctl.saved_graphs: + files[f"graphs/{slug(name)}.graph.json"] = app.graph_ctl.to_export_payload(name) + if "entity_names" in sections: + files["entity_names.json"] = { + "hatty_entity_names": BACKUP_FORMAT_VERSION, + "names": dict(app.app_config.get(CONFIG_KEY_ENTITY_NAMES) or {}), + } + if "settings" in sections: + files["settings.json"] = { + "hatty_settings": BACKUP_FORMAT_VERSION, + "settings": _settings_payload(app.app_config), + } + if "keybindings" in sections: + files["keybindings.json"] = { + "hatty_keybindings": BACKUP_FORMAT_VERSION, + "keybindings": dict(app.app_config.get(CONFIG_KEY_KEYBINDINGS) or {}), + } + + # The manifest is a *patch*: write_export merges it over whatever manifest + # already exists, so exporting a subset of sections never forgets what a + # previous export (of other sections) recorded. + manifest: dict = {"hatty_backup": BACKUP_FORMAT_VERSION, "sections": list(sections)} + if "lists" in sections: + manifest[CONFIG_KEY_DEFAULT_LIST] = app.app_config.get(CONFIG_KEY_DEFAULT_LIST) + if "dashboards" in sections: + manifest[CONFIG_KEY_DEFAULT_DASHBOARD] = app.app_config.get(CONFIG_KEY_DEFAULT_DASHBOARD) + files[MANIFEST_FILENAME] = manifest + + return files + + +def _write_json_if_changed(path: Path, payload: dict) -> bool: + path.parent.mkdir(parents=True, exist_ok=True) + text = json.dumps(payload, indent=2) + "\n" + if path.exists() and path.read_text() == text: + return False + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + f.write(text) + return True + + +def _read_manifest_tolerant(directory: Path) -> dict: + """Best-effort read for merging — a missing/corrupt manifest just means + "nothing recorded yet", not an error (that's `read_export`'s job).""" + path = directory / MANIFEST_FILENAME + if not path.exists(): + return {} + try: + data = json.loads(path.read_text()) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def _prune_stale(directory: Path, files: dict[str, dict], sections: Sequence[str]) -> list[str]: + removed: list[str] = [] + for section in sections: + entry = _OBJECT_SECTIONS.get(section) + if entry is None: + continue + subdir_name, _marker = entry + subdir = directory / subdir_name + if not subdir.is_dir(): + continue + keep = {Path(p).name for p in files if p.startswith(f"{subdir_name}/")} + for existing in subdir.glob("*.json"): + if existing.name not in keep: + existing.unlink() + removed.append(f"{subdir_name}/{existing.name}") + return removed + + +def write_export(directory: Path, files: dict[str, dict], sections: Sequence[str]) -> tuple[list[str], list[str]]: + """Write `files` (from `build_files`) under `directory`, merge the + manifest patch over any existing manifest, and prune object files in the + exported sections whose object no longer exists. Only rewrites files whose + content changed, so git diffs stay minimal. Returns (written, removed), + both relative paths.""" + sections = _validate_sections(sections) + directory = Path(directory) + directory.mkdir(parents=True, exist_ok=True) + os.chmod(directory, 0o700) + + existing_manifest = _read_manifest_tolerant(directory) + manifest_patch = files.get(MANIFEST_FILENAME, {}) + manifest = {**existing_manifest, **manifest_patch} + manifest["sections"] = sorted(set(existing_manifest.get("sections") or []) | set(sections)) + manifest["hatty_backup"] = BACKUP_FORMAT_VERSION + manifest["exported_at"] = datetime.now(timezone.utc).isoformat() + manifest["hatty_version"] = __version__ + + to_write = {**files, MANIFEST_FILENAME: manifest} + written = [path for path, payload in to_write.items() if _write_json_if_changed(directory / path, payload)] + removed = _prune_stale(directory, files, sections) + return written, removed + + +def _read_object(path: Path, marker_key: str) -> dict: + try: + payload = json.loads(path.read_text()) + except (OSError, ValueError) as exc: + raise ValueError(f"Could not read {path}: {exc}") from exc + if not isinstance(payload, dict) or payload.get(marker_key) != BACKUP_FORMAT_VERSION: + raise ValueError(f"{path} is not a valid hatty export file.") + return payload + + +def read_export(directory: Path, sections: Sequence[str]) -> tuple[dict, list[str]]: + """Read `sections` back from `directory`. Returns `(payloads, found)`: + `found` is the subset of `sections` actually present, and `payloads[id]` + is either a list of raw single-object export payloads — for "lists" / + "dashboards" / "saved_graphs", feed each to the matching controller's + `import_from_payload` — or, for "entity_names" / "settings" / + "keybindings", the config value ready to assign directly. `payloads + ["_manifest"]` carries the manifest dict (default_list/default_dashboard). + Raises `ValueError` (with a user-facing message) on a missing/bad manifest + or an unreadable object file.""" + sections = _validate_sections(sections) + directory = Path(directory) + manifest_path = directory / MANIFEST_FILENAME + if not manifest_path.exists(): + raise ValueError(f"No hatty backup found in {directory} (missing {MANIFEST_FILENAME}).") + manifest = _read_object(manifest_path, "hatty_backup") + + payloads: dict = {"_manifest": manifest} + found: list[str] = [] + + for section, (subdir_name, marker) in _OBJECT_SECTIONS.items(): + if section not in sections: + continue + subdir = directory / subdir_name + if not subdir.is_dir(): + continue + payloads[section] = [_read_object(p, marker) for p in sorted(subdir.glob("*.json"))] + found.append(section) + + if "entity_names" in sections: + path = directory / "entity_names.json" + if path.exists(): + payload = _read_object(path, "hatty_entity_names") + names = payload.get("names") + if not isinstance(names, dict): + raise ValueError(f"{path} is missing its names.") + payloads["entity_names"] = dict(names) + found.append("entity_names") + + if "settings" in sections: + path = directory / "settings.json" + if path.exists(): + payload = _read_object(path, "hatty_settings") + settings = payload.get("settings") + if not isinstance(settings, dict): + raise ValueError(f"{path} is missing its settings.") + payloads["settings"] = dict(settings) + found.append("settings") + + if "keybindings" in sections: + path = directory / "keybindings.json" + if path.exists(): + payload = _read_object(path, "hatty_keybindings") + keybindings = payload.get("keybindings") + if not isinstance(keybindings, dict): + raise ValueError(f"{path} is missing its keybindings.") + payloads["keybindings"] = dict(keybindings) + found.append("keybindings") + + return payloads, found diff --git a/tests/unit/test_backup.py b/tests/unit/test_backup.py new file mode 100644 index 0000000..a067f7b --- /dev/null +++ b/tests/unit/test_backup.py @@ -0,0 +1,249 @@ +# hatty — MIT License. See LICENSE file for details. +"""Unit tests for backup.py: directory export/import, no git, no UI.""" + +import json + +from hatty import backup +from hatty.controllers.dashboards import DashboardController +from hatty.controllers.graphs import GraphController +from hatty.controllers.lists import ListController + + +class _StubNotifyCtl: + def __init__(self): + self.notify_lists: set[str] = set() + + +class _StubApp: + def __init__(self, app_config=None): + self.persist_calls = [] + self.notifications = [] + self.notify_ctl = _StubNotifyCtl() + self.app_config = app_config if app_config is not None else {} + self.list_ctl = ListController(self) + self.dash_ctl = DashboardController(self) + self.graph_ctl = GraphController(self) + + def persist(self, *keys): + self.persist_calls.append(keys) + + def notify(self, message, **kwargs): + self.notifications.append((message, kwargs)) + + +def _app(**config) -> _StubApp: + return _StubApp(app_config=config) + + +# ── build_files ─────────────────────────────────────────────────────────────── + + +def test_build_files_lists_section(): + app = _app() + app.list_ctl.list_names = ["Kitchen"] + app.list_ctl.entity_lists = {"Kitchen": ["light.a"]} + files = backup.build_files(app, ["lists"]) + assert files["lists/kitchen.list.json"] == { + "hatty_list": 1, + "name": "Kitchen", + "entities": ["light.a"], + "manual": False, + "notify": False, + } + assert backup.MANIFEST_FILENAME in files + assert files[backup.MANIFEST_FILENAME]["sections"] == ["lists"] + + +def test_build_files_dashboards_excludes_temp_dashboards(): + app = _app() + app.dash_ctl.create("Main", 2, 2) + app.dash_ctl.dashboards["Preview"] = {"rows": 1, "cols": 1, "slots": []} + app.dash_ctl.dashboard_names.append("Preview") + app.dash_ctl.temp_dashboard_names.add("Preview") + files = backup.build_files(app, ["dashboards"]) + assert "dashboards/main.dashboard.json" in files + assert "dashboards/preview.dashboard.json" not in files + + +def test_build_files_saved_graphs_section(): + app = _app() + app.graph_ctl.saved_graphs = {"Temps": {"entity_ids": ["sensor.a"], "graph_type": "line", "hours": 4}} + files = backup.build_files(app, ["saved_graphs"]) + assert files["graphs/temps.graph.json"]["hatty_graph"] == 1 + assert files["graphs/temps.graph.json"]["graph"] == app.graph_ctl.saved_graphs["Temps"] + + +def test_build_files_entity_names_settings_keybindings_sections(): + app = _app( + entity_names={"light.a": "Lamp"}, + columns=["name", "value"], + theme="nord", + notifications={"toast": True, "ntfy_password": "secret"}, + keybindings={"nav.search": "ctrl+f"}, + ) + files = backup.build_files(app, ["entity_names", "settings", "keybindings"]) + assert files["entity_names.json"] == {"hatty_entity_names": 1, "names": {"light.a": "Lamp"}} + assert files["settings.json"]["settings"]["columns"] == ["name", "value"] + assert files["settings.json"]["settings"]["theme"] == "nord" + # The ntfy password never leaves the app. + assert "ntfy_password" not in files["settings.json"]["settings"]["notifications"] + assert files["keybindings.json"] == {"hatty_keybindings": 1, "keybindings": {"nav.search": "ctrl+f"}} + + +def test_build_files_manifest_carries_defaults_only_for_exported_sections(): + app = _app(default_list="Kitchen", default_dashboard="Main") + files = backup.build_files(app, ["lists"]) + manifest = files[backup.MANIFEST_FILENAME] + assert manifest["default_list"] == "Kitchen" + assert "default_dashboard" not in manifest + + +def test_build_files_rejects_unknown_section(): + app = _app() + try: + backup.build_files(app, ["not_a_section"]) + assert False, "expected ValueError" + except ValueError: + pass + + +# ── write_export / read_export round trip ─────────────────────────────────── + + +def test_write_then_read_round_trip_lists(tmp_path): + app = _app() + app.list_ctl.list_names = ["Kitchen"] + app.list_ctl.entity_lists = {"Kitchen": ["light.a"]} + app.list_ctl.manual_lists = {"Kitchen"} + app.app_config["default_list"] = "Kitchen" + + files = backup.build_files(app, ["lists"]) + written, removed = backup.write_export(tmp_path, files, ["lists"]) + assert removed == [] + assert "lists/kitchen.list.json" in written + assert backup.MANIFEST_FILENAME in written + + payloads, found = backup.read_export(tmp_path, ["lists"]) + assert found == ["lists"] + assert payloads["lists"] == [ + {"hatty_list": 1, "name": "Kitchen", "entities": ["light.a"], "manual": True, "notify": False} + ] + assert payloads["_manifest"]["default_list"] == "Kitchen" + + +def test_write_export_is_byte_identical_to_dashboard_screen_export(tmp_path): + # The whole point of sharing the single-object format: a file this writes + # is exactly what "Export dashboard" already writes for the same object. + app = _app() + app.dash_ctl.create("Main", 2, 2) + app.dash_ctl.set_slot("Main", 0, 0, "sensor", "sensor.temp") + + files = backup.build_files(app, ["dashboards"]) + backup.write_export(tmp_path, files, ["dashboards"]) + + on_disk = json.loads((tmp_path / "dashboards" / "main.dashboard.json").read_text()) + assert on_disk == app.dash_ctl.to_export_payload("Main") + + +def test_write_export_only_rewrites_changed_files(tmp_path): + app = _app() + app.list_ctl.list_names = ["Kitchen"] + app.list_ctl.entity_lists = {"Kitchen": ["light.a"]} + files = backup.build_files(app, ["lists"]) + backup.write_export(tmp_path, files, ["lists"]) + + written_again, _removed = backup.write_export(tmp_path, files, ["lists"]) + # Nothing changed since the last export (aside from exported_at in the + # manifest, which always changes) -> the object file is skipped. + assert "lists/kitchen.list.json" not in written_again + assert backup.MANIFEST_FILENAME in written_again + + +def test_write_export_prunes_deleted_object_but_leaves_other_sections(tmp_path): + app = _app() + app.list_ctl.list_names = ["Kitchen", "Office"] + app.list_ctl.entity_lists = {"Kitchen": [], "Office": []} + files = backup.build_files(app, ["lists"]) + backup.write_export(tmp_path, files, ["lists"]) + assert (tmp_path / "lists" / "office.list.json").exists() + + app.list_ctl.list_names = ["Kitchen"] + del app.list_ctl.entity_lists["Office"] + files = backup.build_files(app, ["lists"]) + written, removed = backup.write_export(tmp_path, files, ["lists"]) + assert removed == ["lists/office.list.json"] + assert not (tmp_path / "lists" / "office.list.json").exists() + assert (tmp_path / "lists" / "kitchen.list.json").exists() + + +def test_write_export_of_subset_preserves_other_sections_manifest_fields(tmp_path): + app = _app(default_list="Kitchen", default_dashboard="Main") + app.list_ctl.list_names = ["Kitchen"] + app.list_ctl.entity_lists = {"Kitchen": []} + app.dash_ctl.create("Main", 1, 1) + + backup.write_export(tmp_path, backup.build_files(app, ["lists"]), ["lists"]) + backup.write_export(tmp_path, backup.build_files(app, ["dashboards"]), ["dashboards"]) + + manifest = json.loads((tmp_path / backup.MANIFEST_FILENAME).read_text()) + assert manifest["default_list"] == "Kitchen" + assert manifest["default_dashboard"] == "Main" + assert sorted(manifest["sections"]) == ["dashboards", "lists"] + + payloads, found = backup.read_export(tmp_path, ["lists", "dashboards"]) + assert sorted(found) == ["dashboards", "lists"] + + +def test_read_export_settings_omits_ntfy_password(tmp_path): + app = _app( + columns=["name"], + theme=None, + graph_type="line", + graph_hours=4, + log_hours=24, + terminal_title_enabled=True, + terminal_title="hatty", + notifications={"toast": True, "ntfy_password": "secret"}, + ) + backup.write_export(tmp_path, backup.build_files(app, ["settings"]), ["settings"]) + on_disk = (tmp_path / "settings.json").read_text() + assert "secret" not in on_disk + + payloads, found = backup.read_export(tmp_path, ["settings"]) + assert found == ["settings"] + assert "ntfy_password" not in payloads["settings"]["notifications"] + + +def test_read_export_missing_manifest_raises(): + import pytest + + with pytest.raises(ValueError, match="No hatty backup found"): + backup.read_export("/nonexistent/hatty-backup-dir", ["lists"]) + + +def test_read_export_bad_object_file_raises(tmp_path): + import pytest + + (tmp_path / "lists").mkdir() + (tmp_path / "lists" / "broken.list.json").write_text("not json") + (tmp_path / backup.MANIFEST_FILENAME).write_text(json.dumps({"hatty_backup": 1, "sections": ["lists"]})) + + with pytest.raises(ValueError, match="Could not read"): + backup.read_export(tmp_path, ["lists"]) + + +def test_read_export_only_reads_requested_sections(tmp_path): + app = _app() + app.list_ctl.list_names = ["Kitchen"] + app.list_ctl.entity_lists = {"Kitchen": []} + app.graph_ctl.saved_graphs = {"Temps": {"entity_ids": ["sensor.a"], "graph_type": "line", "hours": 4}} + backup.write_export(tmp_path, backup.build_files(app, ["lists", "saved_graphs"]), ["lists", "saved_graphs"]) + + payloads, found = backup.read_export(tmp_path, ["lists"]) + assert found == ["lists"] + assert "saved_graphs" not in payloads + + +def test_slug_matches_dashboard_slug_rule(): + assert backup.slug("Main Dashboard") == "main-dashboard" + assert backup.slug(" ") == "export" From a0e8cf07a8c94b38e8a22190aba4771e89e371e7 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:08:36 -0500 Subject: [PATCH 03/10] =?UTF-8?q?=E2=9C=A8=20Add=20git=5Fsync.py:=20the=20?= =?UTF-8?q?git=20CLI=20layer=20for=20Backup=20&=20Sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-interactive, timeout-bounded init/commit/pull/push over the export directory, shelling out to the git binary through one chokepoint so every invocation is hardened against credential prompts and hangs. No wiring into the app yet — that's the BackupController next. --- src/hatty/git_sync.py | 327 ++++++++++++++++++++++++ tests/test_backup_git.py | 135 ++++++++++ tests/unit/test_git_sync.py | 487 ++++++++++++++++++++++++++++++++++++ 3 files changed, 949 insertions(+) create mode 100644 src/hatty/git_sync.py create mode 100644 tests/test_backup_git.py create mode 100644 tests/unit/test_git_sync.py diff --git a/src/hatty/git_sync.py b/src/hatty/git_sync.py new file mode 100644 index 0000000..356b2ad --- /dev/null +++ b/src/hatty/git_sync.py @@ -0,0 +1,327 @@ +# hatty — MIT License. See LICENSE file for details. +"""The git CLI layer for the Backup & Sync feature: pull-on-start and +commit/push-on-exit for the directory `backup.py` writes. Shells out to the +`git` binary — no library dependency — through a single chokepoint +(`_run_git`, mirroring `terminal_title._run_tmux`) so every invocation is +non-interactive and time-bounded: it must never prompt for credentials, open +an editor, or hang the TUI. + +Every public function returns `(ok, message)` (or a `RepoInfo`) and never +raises — the `client.probe_connection` / `notifications.send_test_ntfy` +contract, so a config-screen status label or an exit-time overlay can just +display whatever comes back. +""" + +import asyncio +import os +import subprocess +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +#: Local, filesystem-only git operations (init, add, commit, status). +LOCAL_TIMEOUT = 10.0 +#: Operations that talk to a remote (pull, push). +NETWORK_TIMEOUT = 60.0 + +#: Outside git's own return-code range, so they're unambiguous in _explain. +_RC_NO_GIT = -101 +_RC_TIMEOUT = -102 + +# Applied to every invocation. core.editor=true makes any editor launch exit 0 +# instantly instead of blocking on a TTY; commit.gpgsign=false stops a +# passphrase prompt from hanging the TUI; gc.auto=0 keeps a commit from +# triggering a slow background gc the first time timing matters. +_GLOBAL_FLAGS = [ + "-c", + "core.editor=true", + "-c", + "core.pager=cat", + "-c", + "commit.gpgsign=false", + "-c", + "gc.auto=0", +] + + +def _git_env() -> dict[str, str]: + env = dict(os.environ) + env.update( + { + "GIT_TERMINAL_PROMPT": "0", # no username/password TTY prompt; error instead + "GIT_ASKPASS": "true", # ...and no askpass fallback + "SSH_ASKPASS": "true", + "SSH_ASKPASS_REQUIRE": "never", + "GIT_SSH_COMMAND": ("ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10"), + "GIT_PAGER": "cat", + "GIT_OPTIONAL_LOCKS": "0", # `status` won't block on index.lock + "LC_ALL": "C", # stable stderr strings for _explain() + } + ) + for var in ("DISPLAY", "WAYLAND_DISPLAY", "GIT_EDITOR", "EDITOR", "VISUAL"): + env.pop(var, None) # no GUI askpass window, no terminal editor + return env + + +def _run_git(args: list[str], cwd: str, timeout: float = LOCAL_TIMEOUT) -> tuple[int, str, str]: + """THE chokepoint every git invocation goes through. Unit tests monkeypatch + this one function (cf. `terminal_title._run_tmux`) and assert on the + recorded argument lists.""" + try: + p = subprocess.run( + ["git", *_GLOBAL_FLAGS, *args], + cwd=cwd, + env=_git_env(), + stdin=subprocess.DEVNULL, # git can never steal the TUI's stdin + capture_output=True, + text=True, + errors="replace", + timeout=timeout, + start_new_session=True, # own process group: no controlling TTY + ) + except FileNotFoundError: + return (_RC_NO_GIT, "", "git executable not found") + except subprocess.TimeoutExpired: + return (_RC_TIMEOUT, "", f"timed out after {timeout:g}s") + except OSError as e: + return (_RC_NO_GIT, "", str(e)) + return (p.returncode, p.stdout, p.stderr) + + +def _explain(op: str, rc: int, out: str, err: str) -> str: + if rc == _RC_NO_GIT: + return "git is not installed (or not on PATH)." + if rc == _RC_TIMEOUT: + return f"git {op} timed out." + text = f"{out}\n{err}".lower() + if "not a git repository" in text: + return "Not a git repository." + auth_markers = ( + "could not read username", + "authentication failed", + "permission denied (publickey", + "terminal prompts disabled", + ) + if any(s in text for s in auth_markers): + return "git rejected the credentials, and hatty can't prompt. Set up an SSH key or a credential helper." + if any(s in text for s in ("non-fast-forward", "updates were rejected", "fetch first")): + return "The remote has commits you don't have. Pull first, or resolve it manually." + if any(s in text for s in ("conflict", "automatic merge failed", "needs merge")): + return "Merge conflict — resolve it with git, then sync again." + if any(s in text for s in ("could not resolve host", "connection timed out", "network is unreachable")): + return "Could not reach the remote." + first_line = next((line for line in (*err.splitlines(), *out.splitlines()) if line.strip()), None) + return (first_line or f"git {op} failed (exit {rc}).")[:200] + + +def default_commit_message(now: datetime | None = None) -> str: + return f"hatty backup {(now or datetime.now()):%Y-%m-%d %H:%M:%S}" + + +def _current_branch(cwd: str) -> str: + rc, out, _err = _run_git(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd) + if rc == 0: + return out.strip() + rc, out, _err = _run_git(["rev-parse", "--short", "HEAD"], cwd) + return out.strip() if rc == 0 else "" + + +def _primary_remote(cwd: str) -> str: + rc, out, _err = _run_git(["remote"], cwd) + if rc != 0: + return "" + remotes = [r.strip() for r in out.splitlines() if r.strip()] + if not remotes: + return "" + return "origin" if "origin" in remotes else remotes[0] + + +def _upstream(cwd: str) -> str: + rc, out, _err = _run_git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], cwd) + return out.strip() if rc == 0 else "" + + +def _changed_count(cwd: str) -> int: + rc, out, _err = _run_git(["status", "--porcelain", "--untracked-files=all"], cwd) + if rc != 0: + return 0 + return len([line for line in out.splitlines() if line.strip()]) + + +def _identity_flags(cwd: str) -> list[str]: + """A fresh machine (or CI) with no git identity configured fails commit + with "Please tell me who you are" — supply a fallback, but never override + an identity the user already has.""" + rc, out, _err = _run_git(["config", "--get", "user.email"], cwd) + if rc == 0 and out.strip(): + return [] + return ["-c", "user.name=hatty", "-c", "user.email=hatty@localhost"] + + +@dataclass(frozen=True) +class RepoInfo: + ok: bool + message: str + installed: bool = False + is_repo: bool = False + root: str = "" + branch: str = "" + remote: str = "" + upstream: str = "" + changed: int = 0 + + +def repo_info(path: str) -> RepoInfo: + p = Path(path) + if not p.is_dir(): + return RepoInfo(ok=False, message=f"{path} does not exist.") + cwd = str(p) + rc, out, err = _run_git(["rev-parse", "--show-toplevel"], cwd) + if rc == _RC_NO_GIT: + return RepoInfo(ok=False, message=_explain("check", rc, out, err)) + if rc != 0: + return RepoInfo(ok=True, installed=True, is_repo=False, message="Not a git repository yet.") + + root = out.strip() + # --is-inside-work-tree would say True for a directory merely *inside* the + # user's dotfiles repo, silently committing there — compare roots instead. + if os.path.realpath(root) != os.path.realpath(cwd): + return RepoInfo( + ok=False, + installed=True, + is_repo=True, + root=root, + message=f"{path} is inside the repository at {root} — pick a dedicated directory.", + ) + + branch = _current_branch(cwd) + remote = _primary_remote(cwd) + upstream = _upstream(cwd) + changed = _changed_count(cwd) + summary = f"{branch or '(no commits yet)'} · {changed} changed" if branch or changed else "No commits yet." + return RepoInfo( + ok=True, + installed=True, + is_repo=True, + root=root, + branch=branch, + remote=remote, + upstream=upstream, + changed=changed, + message=summary, + ) + + +def init_repo(path: str) -> tuple[bool, str]: + p = Path(path) + if not p.is_dir(): + return False, f"{path} does not exist." + cwd = str(p) + rc, out, err = _run_git(["init", "-b", "main"], cwd) + if rc != 0: + rc, out, err = _run_git(["init"], cwd) # older git without -b + if rc != 0: + return False, _explain("init", rc, out, err) + return True, f"Initialized a git repository in {path}." + + +def commit_all(path: str, message: str) -> tuple[bool, str]: + p = Path(path) + if not p.is_dir(): + return False, f"{path} does not exist." + cwd = str(p) + + rc, out, err = _run_git(["add", "-A", "--", "."], cwd) + if rc != 0: + return False, _explain("add", rc, out, err) + + rc, _out, _err = _run_git(["diff", "--cached", "--quiet", "--"], cwd) + if rc == 0: + return True, "Nothing to commit — export is already up to date." + if rc not in (0, 1): + # No commits yet (or another diff failure) — fall back to status. + rc2, status_out, _err2 = _run_git(["status", "--porcelain"], cwd) + if rc2 == 0 and not status_out.strip(): + return True, "Nothing to commit — export is already up to date." + + args = [*_identity_flags(cwd), "commit", "--no-verify", "--no-gpg-sign", "-m", message] + rc, out, err = _run_git(args, cwd) + if rc != 0: + return False, _explain("commit", rc, out, err) + return True, "Committed." + + +def pull(path: str, *, rebase: bool = False) -> tuple[bool, str]: + p = Path(path) + if not p.is_dir(): + return False, f"{path} does not exist." + cwd = str(p) + + remote = _primary_remote(cwd) + if not remote: + return True, "No git remote configured; nothing to pull." + + args = ["pull", "--rebase", "--autostash"] if rebase else ["pull", "--ff-only", "--no-edit"] + args += ["--no-stat", "--no-tags"] + if not _upstream(cwd): + args += [remote, _current_branch(cwd) or "HEAD"] + rc, out, err = _run_git(args, cwd, timeout=NETWORK_TIMEOUT) + if rc != 0: + return False, _explain("pull", rc, out, err) + return True, "Pulled the latest changes." + + +def push(path: str) -> tuple[bool, str]: + p = Path(path) + if not p.is_dir(): + return False, f"{path} does not exist." + cwd = str(p) + + remote = _primary_remote(cwd) + if not remote: + return True, "No git remote configured; committed locally only." + branch = _current_branch(cwd) + if not branch: + return False, "No commits yet — nothing to push." + + args = ["push", "--porcelain"] + if not _upstream(cwd): + args.append("-u") + args += [remote, f"HEAD:refs/heads/{branch}"] + rc, out, err = _run_git(args, cwd, timeout=NETWORK_TIMEOUT) + if rc != 0: + return False, _explain("push", rc, out, err) + return True, "Pushed to the remote." + + +def commit_and_push(path: str, message: str) -> tuple[bool, str]: + # Deliberately no pull first — a conflict at quit time is the worst + # possible moment; the next start's pull handles a diverged remote. + ok, msg = commit_all(path, message) + if not ok: + return False, msg + return push(path) + + +async def repo_info_async(path: str) -> RepoInfo: + return await asyncio.to_thread(repo_info, path) + + +async def init_repo_async(path: str) -> tuple[bool, str]: + return await asyncio.to_thread(init_repo, path) + + +async def commit_all_async(path: str, message: str) -> tuple[bool, str]: + return await asyncio.to_thread(commit_all, path, message) + + +async def pull_async(path: str, *, rebase: bool = False) -> tuple[bool, str]: + return await asyncio.to_thread(pull, path, rebase=rebase) + + +async def push_async(path: str) -> tuple[bool, str]: + return await asyncio.to_thread(push, path) + + +async def commit_and_push_async(path: str, message: str) -> tuple[bool, str]: + return await asyncio.to_thread(commit_and_push, path, message) diff --git a/tests/test_backup_git.py b/tests/test_backup_git.py new file mode 100644 index 0000000..61d3767 --- /dev/null +++ b/tests/test_backup_git.py @@ -0,0 +1,135 @@ +# hatty — MIT License. See LICENSE file for details. +"""A handful of git_sync.py tests against a *real* git binary and a local +bare remote — the unit tests in tests/unit/test_git_sync.py fake _run_git and +prove the argument lists are right; these prove the hardening flags actually +work against real git (identity fallback, ff-only rejection, a genuine +clone-back round trip).""" + +import os +import shutil +import subprocess + +import pytest + +from hatty import git_sync + +pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git not installed") + + +@pytest.fixture(autouse=True) +def _isolated_git_config(monkeypatch): + # Never let the developer's (or CI runner's) ~/.gitconfig — commit.gpgsign, + # pull.rebase, init.defaultBranch, credential.helper — decide the outcome. + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull) + + +def _git(*args, cwd) -> None: + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True) + + +def test_init_export_commit_push_clone_back(tmp_path): + remote = tmp_path / "remote.git" + work = tmp_path / "work" + work.mkdir() + _git("init", "--bare", "-b", "main", str(remote), cwd=tmp_path) + + ok, msg = git_sync.init_repo(str(work)) + assert ok, msg + + (work / "hatty-backup.json").write_text('{"hatty_backup": 1}') + _git("remote", "add", "origin", str(remote), cwd=work) + + ok, msg = git_sync.commit_and_push(str(work), "initial backup") + assert ok, msg + + clone = tmp_path / "clone" + _git("clone", str(remote), str(clone), cwd=tmp_path) + assert (clone / "hatty-backup.json").exists() + + +def test_second_push_with_no_changes_is_a_noop(tmp_path): + remote = tmp_path / "remote.git" + work = tmp_path / "work" + work.mkdir() + _git("init", "--bare", "-b", "main", str(remote), cwd=tmp_path) + git_sync.init_repo(str(work)) + (work / "hatty-backup.json").write_text('{"hatty_backup": 1}') + _git("remote", "add", "origin", str(remote), cwd=work) + ok, msg = git_sync.commit_and_push(str(work), "initial") + assert ok, msg + + log = subprocess.run(["git", "log", "--oneline"], cwd=work, capture_output=True, text=True, check=True) + commit_count_before = len(log.stdout.splitlines()) + + # commit_all itself reports "Nothing to commit" (checked at the unit-test + # level); at this level what matters is that no *new* commit was created + # and the push (of an already up-to-date branch) still succeeds. + ok, msg = git_sync.commit_and_push(str(work), "no changes") + assert ok, msg + + log = subprocess.run(["git", "log", "--oneline"], cwd=work, capture_output=True, text=True, check=True) + assert len(log.stdout.splitlines()) == commit_count_before + + +def test_push_rejected_when_remote_has_diverged(tmp_path): + remote = tmp_path / "remote.git" + work_a = tmp_path / "work_a" + work_b = tmp_path / "work_b" + work_a.mkdir() + work_b.mkdir() + _git("init", "--bare", "-b", "main", str(remote), cwd=tmp_path) + + for work in (work_a, work_b): + git_sync.init_repo(str(work)) + _git("remote", "add", "origin", str(remote), cwd=work) + + (work_a / "hatty-backup.json").write_text('{"hatty_backup": 1}') + ok, msg = git_sync.commit_and_push(str(work_a), "first") + assert ok, msg + + # work_b never pulled work_a's commit -> its push is rejected non-ff. + (work_b / "other.json").write_text('{"x": 1}') + ok, msg = git_sync.commit_and_push(str(work_b), "second") + assert ok is False + assert msg + + +def test_commit_succeeds_with_no_identity_configured(tmp_path, monkeypatch): + # A fresh machine (or CI) may have no git identity at all — commit_all's + # fallback (-c user.name=hatty -c user.email=...) must still succeed. + for var in ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"): + monkeypatch.delenv(var, raising=False) + + work = tmp_path / "work" + work.mkdir() + git_sync.init_repo(str(work)) + (work / "hatty-backup.json").write_text('{"hatty_backup": 1}') + + ok, msg = git_sync.commit_all(str(work), "no identity") + assert ok, msg + assert msg == "Committed." + + +def test_pull_ff_only_success(tmp_path): + remote = tmp_path / "remote.git" + work_a = tmp_path / "work_a" + work_b = tmp_path / "work_b" + work_a.mkdir() + _git("init", "--bare", "-b", "main", str(remote), cwd=tmp_path) + + git_sync.init_repo(str(work_a)) + _git("remote", "add", "origin", str(remote), cwd=work_a) + (work_a / "hatty-backup.json").write_text('{"hatty_backup": 1}') + ok, msg = git_sync.commit_and_push(str(work_a), "first") + assert ok, msg + + _git("clone", str(remote), str(work_b), cwd=tmp_path) + + (work_a / "hatty-backup.json").write_text('{"hatty_backup": 2}') + ok, msg = git_sync.commit_and_push(str(work_a), "second") + assert ok, msg + + ok, msg = git_sync.pull(str(work_b)) + assert ok, msg + assert '"hatty_backup": 2' in (work_b / "hatty-backup.json").read_text() diff --git a/tests/unit/test_git_sync.py b/tests/unit/test_git_sync.py new file mode 100644 index 0000000..e4a563d --- /dev/null +++ b/tests/unit/test_git_sync.py @@ -0,0 +1,487 @@ +# hatty — MIT License. See LICENSE file for details. +"""Unit tests for git_sync.py: the _run_git chokepoint, the _explain +classifier, and every public git operation, all driven through a fake +_run_git (cf. tests/unit/test_terminal_title.py's _run_tmux fakes) so no test +here shells out to a real git binary.""" + +import subprocess + +import pytest + +from hatty import git_sync + +# ── _git_env / _run_git ────────────────────────────────────────────────────── + + +def test_git_env_hardens_prompting_and_strips_editor_vars(monkeypatch): + monkeypatch.setenv("DISPLAY", ":0") + monkeypatch.setenv("EDITOR", "vim") + env = git_sync._git_env() + assert env["GIT_TERMINAL_PROMPT"] == "0" + assert env["GIT_ASKPASS"] == "true" + assert env["SSH_ASKPASS_REQUIRE"] == "never" + assert "BatchMode=yes" in env["GIT_SSH_COMMAND"] + assert "DISPLAY" not in env + assert "EDITOR" not in env + + +def test_run_git_missing_binary_returns_no_git_code(monkeypatch): + def fake_run(*_args, **_kwargs): + raise FileNotFoundError() + + monkeypatch.setattr(subprocess, "run", fake_run) + rc, out, err = git_sync._run_git(["status"], "/tmp") + assert rc == git_sync._RC_NO_GIT + assert out == "" + + +def test_run_git_timeout_returns_timeout_code(monkeypatch): + def fake_run(*_args, **kwargs): + raise subprocess.TimeoutExpired(cmd="git", timeout=kwargs.get("timeout", 1)) + + monkeypatch.setattr(subprocess, "run", fake_run) + rc, _out, err = git_sync._run_git(["status"], "/tmp") + assert rc == git_sync._RC_TIMEOUT + assert "timed out" in err + + +def test_run_git_passes_hardening_flags_and_never_touches_stdin(monkeypatch): + captured = {} + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + captured["kwargs"] = kwargs + return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + git_sync._run_git(["status", "--porcelain"], "/tmp") + + assert captured["cmd"][0] == "git" + assert "-c" in captured["cmd"] and "core.editor=true" in captured["cmd"] + assert captured["cmd"][-2:] == ["status", "--porcelain"] + assert captured["kwargs"]["stdin"] == subprocess.DEVNULL + assert captured["kwargs"]["start_new_session"] is True + assert captured["kwargs"]["cwd"] == "/tmp" + + +# ── _explain ────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("rc", "out", "err", "expect_contains"), + [ + (git_sync._RC_NO_GIT, "", "git executable not found", "not installed"), + (git_sync._RC_TIMEOUT, "", "timed out after 10s", "timed out"), + (128, "", "fatal: not a git repository (or any of the parent directories)", "Not a git repository"), + (128, "", "fatal: could not read Username for 'https://example.com'", "credentials"), + (128, "", "remote: Permission denied (publickey).", "credentials"), + (1, "", "! [rejected] main -> main (non-fast-forward)", "commits you don't have"), + (1, "", "CONFLICT (content): Merge conflict in x.json", "Merge conflict"), + (128, "", "fatal: unable to access: Could not resolve host: example.com", "reach the remote"), + (1, "", "some other failure line", "some other failure line"), + ], +) +def test_explain_classifies_common_git_failures(rc, out, err, expect_contains): + assert expect_contains in git_sync._explain("op", rc, out, err) + + +def test_explain_truncates_long_unclassified_output(): + err = "x" * 500 + assert len(git_sync._explain("op", 1, "", err)) <= 200 + + +# ── fake _run_git dispatcher for the higher-level functions ──────────────────── + + +class _FakeGit: + """Dispatches on an args prefix; unmatched calls fail loudly so a test + that forgets to stub a step doesn't silently pass.""" + + def __init__(self, responses: dict[tuple, tuple[int, str, str]]): + self.responses = responses + self.calls: list[list[str]] = [] + + def __call__(self, args, cwd, timeout=git_sync.LOCAL_TIMEOUT): + self.calls.append(args) + for prefix, result in self.responses.items(): + n = len(prefix) + # Anywhere in args, not just a leading prefix — commit_all prepends + # -c user.name=... -c user.email=... ahead of "commit" itself. + if any(tuple(args[i : i + n]) == prefix for i in range(len(args) - n + 1)): + return result + raise AssertionError(f"unstubbed git invocation: {args}") + + +def _install(monkeypatch, responses) -> _FakeGit: + fake = _FakeGit(responses) + monkeypatch.setattr(git_sync, "_run_git", fake) + return fake + + +# ── repo_info ───────────────────────────────────────────────────────────────── + + +def test_repo_info_missing_directory(): + info = git_sync.repo_info("/does/not/exist") + assert info.ok is False + assert "does not exist" in info.message + + +def test_repo_info_not_yet_a_repo(monkeypatch, tmp_path): + _install(monkeypatch, {("rev-parse", "--show-toplevel"): (128, "", "fatal: not a git repository")}) + info = git_sync.repo_info(str(tmp_path)) + assert info.ok is True + assert info.is_repo is False + + +def test_repo_info_git_not_installed(monkeypatch, tmp_path): + _install(monkeypatch, {("rev-parse", "--show-toplevel"): (git_sync._RC_NO_GIT, "", "git executable not found")}) + info = git_sync.repo_info(str(tmp_path)) + assert info.ok is False + assert "not installed" in info.message + + +def test_repo_info_inside_another_repo(monkeypatch, tmp_path): + outer = tmp_path.parent + _install(monkeypatch, {("rev-parse", "--show-toplevel"): (0, f"{outer}\n", "")}) + info = git_sync.repo_info(str(tmp_path)) + assert info.ok is False + assert info.is_repo is True + assert "pick a dedicated directory" in info.message + + +def test_repo_info_healthy_repo(monkeypatch, tmp_path): + fake = _install( + monkeypatch, + { + ("rev-parse", "--show-toplevel"): (0, f"{tmp_path}\n", ""), + ("symbolic-ref",): (0, "main\n", ""), + ("remote",): (0, "origin\n", ""), + ("rev-parse", "--abbrev-ref"): (0, "origin/main\n", ""), + ("status",): (0, "?? untracked.txt\n", ""), + }, + ) + info = git_sync.repo_info(str(tmp_path)) + assert info.ok is True + assert info.is_repo is True + assert info.branch == "main" + assert info.remote == "origin" + assert info.upstream == "origin/main" + assert info.changed == 1 + assert fake.calls[0] == ["rev-parse", "--show-toplevel"] + + +def test_repo_info_prefers_origin_among_multiple_remotes(monkeypatch, tmp_path): + _install( + monkeypatch, + { + ("rev-parse", "--show-toplevel"): (0, f"{tmp_path}\n", ""), + ("symbolic-ref",): (0, "main\n", ""), + ("remote",): (0, "upstream\norigin\n", ""), + ("rev-parse", "--abbrev-ref"): (1, "", ""), + ("status",): (0, "", ""), + }, + ) + info = git_sync.repo_info(str(tmp_path)) + assert info.remote == "origin" + + +# ── init_repo ───────────────────────────────────────────────────────────────── + + +def test_init_repo_missing_directory(): + ok, msg = git_sync.init_repo("/does/not/exist") + assert ok is False + assert "does not exist" in msg + + +def test_init_repo_success(monkeypatch, tmp_path): + fake = _install(monkeypatch, {("init",): (0, "Initialized empty Git repository", "")}) + ok, msg = git_sync.init_repo(str(tmp_path)) + assert ok is True + assert "Initialized" in msg + assert fake.calls[0][0] == "init" + + +def test_init_repo_falls_back_without_dash_b(monkeypatch, tmp_path): + calls = [] + + def fake(args, cwd, timeout=git_sync.LOCAL_TIMEOUT): + calls.append(args) + if "-b" in args: + return (2, "", "error: unknown switch `b'") + return (0, "Initialized empty Git repository", "") + + monkeypatch.setattr(git_sync, "_run_git", fake) + ok, _msg = git_sync.init_repo(str(tmp_path)) + assert ok is True + assert calls[0] == ["init", "-b", "main"] + assert calls[1] == ["init"] + + +# ── commit_all ──────────────────────────────────────────────────────────────── + + +def test_commit_all_nothing_staged_is_ok(monkeypatch, tmp_path): + _install(monkeypatch, {("add",): (0, "", ""), ("diff",): (0, "", "")}) + ok, msg = git_sync.commit_all(str(tmp_path), "msg") + assert ok is True + assert "Nothing to commit" in msg + + +def test_commit_all_stages_and_commits(monkeypatch, tmp_path): + fake = _install( + monkeypatch, + { + ("add",): (0, "", ""), + ("diff",): (1, "", ""), # something staged + ("config", "--get", "user.email"): (0, "me@example.com\n", ""), + ("commit",): (0, "", ""), + }, + ) + ok, msg = git_sync.commit_all(str(tmp_path), "my message") + assert ok is True + assert msg == "Committed." + commit_call = next(c for c in fake.calls if c[0] == "commit") + assert commit_call == ["commit", "--no-verify", "--no-gpg-sign", "-m", "my message"] + + +def test_commit_all_no_commits_yet_falls_back_to_status(monkeypatch, tmp_path): + _install( + monkeypatch, + { + ("add",): (0, "", ""), + ("diff",): (128, "", "fatal: ambiguous argument 'HEAD'"), + ("status",): (0, "", ""), + }, + ) + ok, msg = git_sync.commit_all(str(tmp_path), "msg") + assert ok is True + assert "Nothing to commit" in msg + + +def test_commit_all_supplies_identity_when_missing(monkeypatch, tmp_path): + fake = _install( + monkeypatch, + { + ("add",): (0, "", ""), + ("diff",): (1, "", ""), + ("config", "--get", "user.email"): (1, "", ""), + ("commit",): (0, "", ""), + }, + ) + ok, _msg = git_sync.commit_all(str(tmp_path), "msg") + assert ok is True + commit_call = next(c for c in fake.calls if "commit" in c) + assert "-c" in commit_call and "user.email=hatty@localhost" in commit_call + + +def test_commit_all_add_failure_is_reported(monkeypatch, tmp_path): + _install(monkeypatch, {("add",): (128, "", "fatal: pathspec broken")}) + ok, msg = git_sync.commit_all(str(tmp_path), "msg") + assert ok is False + assert msg + + +# ── pull ────────────────────────────────────────────────────────────────────── + + +def test_pull_no_remote_is_ok(monkeypatch, tmp_path): + _install(monkeypatch, {("remote",): (0, "", "")}) + ok, msg = git_sync.pull(str(tmp_path)) + assert ok is True + assert "No git remote" in msg + + +def test_pull_uses_ff_only_by_default(monkeypatch, tmp_path): + fake = _install( + monkeypatch, + { + ("remote",): (0, "origin\n", ""), + ("rev-parse", "--abbrev-ref"): (0, "origin/main\n", ""), + ("pull",): (0, "", ""), + }, + ) + ok, _msg = git_sync.pull(str(tmp_path)) + assert ok is True + pull_call = next(c for c in fake.calls if c[0] == "pull") + assert "--ff-only" in pull_call + assert "--rebase" not in pull_call + + +def test_pull_rebase_option(monkeypatch, tmp_path): + fake = _install( + monkeypatch, + { + ("remote",): (0, "origin\n", ""), + ("rev-parse", "--abbrev-ref"): (0, "origin/main\n", ""), + ("pull",): (0, "", ""), + }, + ) + git_sync.pull(str(tmp_path), rebase=True) + pull_call = next(c for c in fake.calls if c[0] == "pull") + assert "--rebase" in pull_call + assert "--autostash" in pull_call + + +def test_pull_without_upstream_passes_explicit_remote_and_branch(monkeypatch, tmp_path): + fake = _install( + monkeypatch, + { + ("remote",): (0, "origin\n", ""), + ("rev-parse", "--abbrev-ref"): (1, "", ""), # no upstream configured + ("symbolic-ref",): (0, "main\n", ""), + ("pull",): (0, "", ""), + }, + ) + git_sync.pull(str(tmp_path)) + pull_call = next(c for c in fake.calls if c[0] == "pull") + assert pull_call[-2:] == ["origin", "main"] + + +def test_pull_failure_is_explained(monkeypatch, tmp_path): + _install( + monkeypatch, + { + ("remote",): (0, "origin\n", ""), + ("rev-parse", "--abbrev-ref"): (0, "origin/main\n", ""), + ("pull",): (1, "", "fatal: Could not resolve host: example.com"), + }, + ) + ok, msg = git_sync.pull(str(tmp_path)) + assert ok is False + assert "reach the remote" in msg + + +# ── push ────────────────────────────────────────────────────────────────────── + + +def test_push_no_remote_is_ok(monkeypatch, tmp_path): + _install( + monkeypatch, + {("remote",): (0, "", ""), ("symbolic-ref",): (0, "main\n", "")}, + ) + ok, msg = git_sync.push(str(tmp_path)) + assert ok is True + assert "committed locally only" in msg + + +def test_push_no_commits_yet(monkeypatch, tmp_path): + _install( + monkeypatch, + { + ("remote",): (0, "origin\n", ""), + ("symbolic-ref",): (1, "", ""), + ("rev-parse", "--short"): (1, "", ""), + }, + ) + ok, msg = git_sync.push(str(tmp_path)) + assert ok is False + assert "No commits yet" in msg + + +def test_push_adds_dash_u_when_no_upstream(monkeypatch, tmp_path): + fake = _install( + monkeypatch, + { + ("remote",): (0, "origin\n", ""), + ("symbolic-ref",): (0, "main\n", ""), + ("rev-parse", "--abbrev-ref"): (1, "", ""), + ("push",): (0, "", ""), + }, + ) + ok, _msg = git_sync.push(str(tmp_path)) + assert ok is True + push_call = next(c for c in fake.calls if c[0] == "push") + assert "-u" in push_call + assert push_call[-2:] == ["origin", "HEAD:refs/heads/main"] + + +def test_push_omits_dash_u_when_upstream_tracked(monkeypatch, tmp_path): + fake = _install( + monkeypatch, + { + ("remote",): (0, "origin\n", ""), + ("symbolic-ref",): (0, "main\n", ""), + ("rev-parse", "--abbrev-ref"): (0, "origin/main\n", ""), + ("push",): (0, "", ""), + }, + ) + git_sync.push(str(tmp_path)) + push_call = next(c for c in fake.calls if c[0] == "push") + assert "-u" not in push_call + + +def test_push_rejected_non_fast_forward(monkeypatch, tmp_path): + _install( + monkeypatch, + { + ("remote",): (0, "origin\n", ""), + ("symbolic-ref",): (0, "main\n", ""), + ("rev-parse", "--abbrev-ref"): (0, "origin/main\n", ""), + ("push",): (1, "", "! [rejected] main -> main (non-fast-forward)"), + }, + ) + ok, msg = git_sync.push(str(tmp_path)) + assert ok is False + assert "commits you don't have" in msg + + +# ── commit_and_push ─────────────────────────────────────────────────────────── + + +def test_commit_and_push_stops_after_failed_commit(monkeypatch, tmp_path): + fake = _install(monkeypatch, {("add",): (128, "", "fatal: broken")}) + ok, msg = git_sync.commit_and_push(str(tmp_path), "msg") + assert ok is False + assert msg + assert not any(c[0] == "push" for c in fake.calls) + + +def test_commit_and_push_happy_path(monkeypatch, tmp_path): + _install( + monkeypatch, + { + ("add",): (0, "", ""), + ("diff",): (1, "", ""), + ("config", "--get", "user.email"): (0, "me@example.com\n", ""), + ("commit",): (0, "", ""), + ("remote",): (0, "origin\n", ""), + ("symbolic-ref",): (0, "main\n", ""), + ("rev-parse", "--abbrev-ref"): (0, "origin/main\n", ""), + ("push",): (0, "", ""), + }, + ) + ok, msg = git_sync.commit_and_push(str(tmp_path), "msg") + assert ok is True + assert "Pushed" in msg + + +def test_commit_and_push_never_pulls_first(monkeypatch, tmp_path): + # A conflict at quit time is the worst possible moment; the next start's + # pull handles a diverged remote instead. + fake = _install( + monkeypatch, + { + ("add",): (0, "", ""), + ("diff",): (1, "", ""), + ("config", "--get", "user.email"): (0, "me@example.com\n", ""), + ("commit",): (0, "", ""), + ("remote",): (0, "origin\n", ""), + ("symbolic-ref",): (0, "main\n", ""), + ("rev-parse", "--abbrev-ref"): (0, "origin/main\n", ""), + ("push",): (0, "", ""), + }, + ) + git_sync.commit_and_push(str(tmp_path), "msg") + assert not any(c[0] == "pull" for c in fake.calls) + + +# ── default_commit_message ─────────────────────────────────────────────────── + + +def test_default_commit_message_format(): + from datetime import datetime + + msg = git_sync.default_commit_message(datetime(2026, 8, 19, 14, 3, 11)) + assert msg == "hatty backup 2026-08-19 14:03:11" From 409934f6a6fa5b41980738fc2dab8bf1100d5bd8 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:17:02 -0500 Subject: [PATCH 04/10] =?UTF-8?q?=E2=9C=A8=20Wire=20BackupController=20int?= =?UTF-8?q?o=20the=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges backup prefs on boot/config-save (mirrors KeybindingController), drives manual export/import against the running app's live collections, and fires pull-on-start. No UI entry point or exit-time git sync yet. --- src/hatty/config.py | 3 + src/hatty/const.py | 16 ++ src/hatty/controllers/backup.py | 222 ++++++++++++++++++ src/hatty/main.py | 8 + tests/unit/test_backup_controller.py | 326 +++++++++++++++++++++++++++ 5 files changed, 575 insertions(+) create mode 100644 src/hatty/controllers/backup.py create mode 100644 tests/unit/test_backup_controller.py diff --git a/src/hatty/config.py b/src/hatty/config.py index f0a0710..da14dec 100644 --- a/src/hatty/config.py +++ b/src/hatty/config.py @@ -5,6 +5,7 @@ import yaml from hatty.const import ( + CONFIG_KEY_BACKUP, CONFIG_KEY_COLUMNS, CONFIG_KEY_DASHBOARDS, CONFIG_KEY_DEFAULT_DASHBOARD, @@ -24,6 +25,7 @@ CONFIG_KEY_THEME, CONFIG_KEY_TOKEN, CONFIG_KEY_URL, + DEFAULT_BACKUP, DEFAULT_COLUMNS, DEFAULT_GRAPH_HOURS, DEFAULT_LOG_HOURS, @@ -82,6 +84,7 @@ def default_config() -> dict: CONFIG_KEY_TERMINAL_TITLE_ENABLED: True, CONFIG_KEY_TERMINAL_TITLE: DEFAULT_TERMINAL_TITLE, CONFIG_KEY_KEYBINDINGS: {}, + CONFIG_KEY_BACKUP: dict(DEFAULT_BACKUP), } diff --git a/src/hatty/const.py b/src/hatty/const.py index 7f1fe6c..7348907 100644 --- a/src/hatty/const.py +++ b/src/hatty/const.py @@ -201,6 +201,7 @@ def binary_state_label(state: str, device_class: str) -> str: CONFIG_KEY_TERMINAL_TITLE_ENABLED = "terminal_title_enabled" CONFIG_KEY_TERMINAL_TITLE = "terminal_title" CONFIG_KEY_KEYBINDINGS = "keybindings" +CONFIG_KEY_BACKUP = "backup" # Fallback/default value for the "terminal_title" config key (issue: set tmux # title to hatty or pref). @@ -225,3 +226,18 @@ def binary_state_label(state: str, device_class: str) -> str: "ntfy_username": "", "ntfy_password": "", } + +# Default Backup & Sync preferences (config key "backup"), merged over by +# BackupController whenever a config predates a given key. "sections" is +# spelled out literally (matching backup.SECTIONS) rather than imported, so +# const.py stays free of imports from the rest of the app. +DEFAULT_BACKUP = { + "path": "", # export directory; "" = feature idle + "sections": ["lists", "dashboards", "saved_graphs", "entity_names", "settings", "keybindings"], + "git_enabled": False, + "pull_on_start": False, # pull + import at boot + "import_on_pull": True, # after a successful pull, load the files back in + "commit_on_exit": False, # export + commit at quit + "push_on_exit": False, # ...and push (implies commit) + "pull_rebase": False, +} diff --git a/src/hatty/controllers/backup.py b/src/hatty/controllers/backup.py new file mode 100644 index 0000000..eb6404c --- /dev/null +++ b/src/hatty/controllers/backup.py @@ -0,0 +1,222 @@ +# hatty — MIT License. See LICENSE file for details. +"""Backup & Sync: owns the user's export/git prefs and drives backup.py + +git_sync.py against the running app's live state (`app.list_ctl`, `app.dash_ctl`, +`app.graph_ctl`, `app.app_config`). `apply()` is called from HACLI._apply_config +(boot, demo, and the post-onboarding restart) and again from _on_config_saved, +mirroring KeybindingController.""" + +import asyncio +from collections.abc import Sequence + +from hatty import backup as backup_module +from hatty import git_sync +from hatty.const import ( + CONFIG_KEY_BACKUP, + CONFIG_KEY_COLUMNS, + CONFIG_KEY_GRAPH_HOURS, + CONFIG_KEY_GRAPH_TYPE, + CONFIG_KEY_KEYBINDINGS, + CONFIG_KEY_LOG_HOURS, + CONFIG_KEY_NOTIFICATIONS, + CONFIG_KEY_TERMINAL_TITLE, + CONFIG_KEY_TERMINAL_TITLE_ENABLED, + CONFIG_KEY_THEME, + DEFAULT_BACKUP, +) + +# section id -> attribute holding the controller with to_export_payload / +# import_from_payload for that section's objects. +_OBJECT_CONTROLLERS = {"lists": "list_ctl", "dashboards": "dash_ctl", "saved_graphs": "graph_ctl"} + +# section id -> storage.PERSISTED keys it needs saved after a replace. +_OBJECT_PERSIST_KEYS = { + "lists": ("lists", "manual_lists", "notify_lists", "default_list"), + "dashboards": ("dashboards", "default_dashboard"), + "saved_graphs": ("saved_graphs",), +} + +_SETTINGS_KEYS = ( + CONFIG_KEY_COLUMNS, + CONFIG_KEY_THEME, + CONFIG_KEY_GRAPH_TYPE, + CONFIG_KEY_GRAPH_HOURS, + CONFIG_KEY_LOG_HOURS, + CONFIG_KEY_TERMINAL_TITLE_ENABLED, + CONFIG_KEY_TERMINAL_TITLE, +) + + +class BackupController: + def __init__(self, app) -> None: + self._app = app + self.prefs: dict = dict(DEFAULT_BACKUP) + + def apply(self, cfg: dict) -> None: + """Merge cfg[backup] over the defaults, store the result, and + normalize cfg in place so a save right afterwards writes back the + merged prefs (mirrors KeybindingController.apply).""" + merged = dict(DEFAULT_BACKUP) + merged.update(cfg.get(CONFIG_KEY_BACKUP) or {}) + merged["sections"] = [s for s in backup_module.SECTIONS if s in (merged.get("sections") or [])] + self.prefs = merged + cfg[CONFIG_KEY_BACKUP] = dict(merged) + + # ── Export / import ────────────────────────────────────────────────────── + + def export_now(self) -> tuple[bool, str]: + path = self.prefs.get("path") + if not path: + return False, "No backup directory set." + sections = self.prefs.get("sections") or [] + if not sections: + return False, "No sections selected to export." + try: + files = backup_module.build_files(self._app, sections) + written, removed = backup_module.write_export(path, files, sections) + except (OSError, ValueError) as exc: + return False, f"Export failed: {exc}" + parts = [] + if written: + parts.append(f"{len(written)} file(s) written") + if removed: + parts.append(f"{len(removed)} stale file(s) removed") + detail = f" ({', '.join(parts)})" if parts else " (already up to date)" + return True, f"Exported to {path}{detail}." + + def import_now(self, sections: Sequence[str]) -> tuple[bool, str, list[str]]: + path = self.prefs.get("path") + if not path: + return False, "No backup directory set.", [] + sections = [s for s in backup_module.SECTIONS if s in sections] + if not sections: + return False, "No sections selected to import.", [] + try: + payloads, found = backup_module.read_export(path, sections) + except ValueError as exc: + return False, str(exc), [] + + self._apply_imported(payloads, found) + + labels = [backup_module.SECTION_LABELS[s] for s in found] + return True, f"Imported {', '.join(labels)} from {path}.", found + + def _apply_imported(self, payloads: dict, found: Sequence[str]) -> None: + app = self._app + manifest = payloads.get("_manifest") or {} + persist_keys: set[str] = set() + + for section in ("lists", "dashboards", "saved_graphs"): + if section not in found: + continue + ctl = getattr(app, _OBJECT_CONTROLLERS[section]) + self._replace_collection(section, ctl) + for payload in payloads[section]: + try: + ctl.import_from_payload(payload) + except ValueError: + continue # one bad object shouldn't abort the whole import + persist_keys.update(_OBJECT_PERSIST_KEYS[section]) + + if "lists" in found: + default_list = manifest.get("default_list") + app.list_ctl.default_list_name = default_list if default_list in app.list_ctl.entity_lists else None + if "dashboards" in found: + default_dashboard = manifest.get("default_dashboard") + app.dash_ctl.default_dashboard_name = ( + default_dashboard if default_dashboard in app.dash_ctl.dashboards else None + ) + + if "entity_names" in found: + app.entity_names = payloads["entity_names"] + persist_keys.add("entity_names") + + if "settings" in found: + self._apply_settings(payloads["settings"]) + + if "keybindings" in found: + app.app_config[CONFIG_KEY_KEYBINDINGS] = payloads["keybindings"] + app.keys_ctl.apply(app.app_config) + + if persist_keys: + app.persist(*sorted(persist_keys)) + elif "settings" in found or "keybindings" in found: + app.persist() + + app._update_entities_display() + + def _replace_collection(self, section: str, ctl) -> None: + """Wipe `section`'s in-memory collection ahead of a wholesale replace + (behind a confirm popup in the UI) — dashboards keeps any in-session + temp/preview dashboards, which are never part of an export.""" + if section == "lists": + ctl.entity_lists.clear() + ctl.list_names.clear() + ctl.manual_lists.clear() + self._app.notify_ctl.notify_lists.clear() + elif section == "dashboards": + temp = ctl.temp_dashboard_names + for name in [n for n in ctl.dashboards if n not in temp]: + del ctl.dashboards[name] + ctl.dashboard_names[:] = [n for n in ctl.dashboard_names if n in temp] + if ctl.current_dashboard_name not in ctl.dashboards: + ctl.current_dashboard_name = ctl.dashboard_names[0] if ctl.dashboard_names else None + elif section == "saved_graphs": + ctl.saved_graphs.clear() + + def _apply_settings(self, settings: dict) -> None: + app = self._app + for key in _SETTINGS_KEYS: + if key in settings: + app.app_config[key] = settings[key] + if CONFIG_KEY_NOTIFICATIONS in settings: + merged = dict(app.app_config.get(CONFIG_KEY_NOTIFICATIONS) or {}) + merged.update(settings[CONFIG_KEY_NOTIFICATIONS]) # export never carries ntfy_password + app.app_config[CONFIG_KEY_NOTIFICATIONS] = merged + + app.columns = app.app_config.get(CONFIG_KEY_COLUMNS, app.columns) + new_theme = app.app_config.get(CONFIG_KEY_THEME) + if new_theme and new_theme in app.available_themes: + app.theme = new_theme + app._apply_terminal_title(app.app_config) + + # ── Git ─────────────────────────────────────────────────────────────────── + + def exit_sync_pending(self) -> bool: + if self._app._demo: + return False + if not self.prefs.get("git_enabled") or not self.prefs.get("path"): + return False + return bool(self.prefs.get("commit_on_exit") or self.prefs.get("push_on_exit")) + + async def pull_on_start(self) -> None: + app = self._app + if app._demo or not self.prefs.get("git_enabled") or not self.prefs.get("pull_on_start"): + return + path = self.prefs.get("path") + if not path: + return + ok, msg = await git_sync.pull_async(path, rebase=bool(self.prefs.get("pull_rebase"))) + if not ok: + app.notify(msg, title="Backup Pull Failed", severity="error") + return + if not self.prefs.get("import_on_pull"): + app.notify(msg, title="Backup Pulled") + return + ok, msg, _found = self.import_now(self.prefs.get("sections") or []) + title = "Backup Imported" if ok else "Backup Import Failed" + app.notify(msg, title=title, severity="information" if ok else "error") + + async def sync_on_exit(self, timeout: float = 30.0) -> tuple[bool, str]: + if not self.exit_sync_pending(): + return True, "" + path = self.prefs.get("path") or "" + ok, msg = self.export_now() + if not ok: + return False, msg + + message = git_sync.default_commit_message() + op = git_sync.commit_and_push_async if self.prefs.get("push_on_exit") else git_sync.commit_all_async + try: + return await asyncio.wait_for(op(path, message), timeout=timeout) + except asyncio.TimeoutError: + return False, "Timed out syncing with git." diff --git a/src/hatty/main.py b/src/hatty/main.py index af1a04d..2e61f01 100644 --- a/src/hatty/main.py +++ b/src/hatty/main.py @@ -39,6 +39,7 @@ DEFAULT_TERMINAL_TITLE, TOGGLABLE_DOMAINS, ) +from hatty.controllers.backup import BackupController from hatty.controllers.connection import ConnectionController from hatty.controllers.dashboards import DashboardController from hatty.controllers.graphs import GraphController, _trim_history # noqa: F401 (_trim_history re-exported for tests) @@ -114,6 +115,7 @@ def __init__(self, config_path: str | None = None, demo: bool = False): self.notify_ctl = NotificationController(self) self.log_ctl = LogbookController(self) self.keys_ctl = KeybindingController(self) + self.backup_ctl = BackupController(self) self.all_entities: list = [] self.entity_registry: list = [] @@ -316,6 +318,11 @@ def _apply_config(self, cfg: dict) -> None: self._apply_terminal_title(cfg) self.keys_ctl.apply(cfg) + self.backup_ctl.apply(cfg) + # Only at boot/restart (this method's three call sites), never on a + # plain reconnect from the config screen — pull_on_start() is itself a + # no-op in demo mode and when the pref is off. + self.spawn(self.backup_ctl.pull_on_start()) self.query_one("#detail_panel", EntityDetailPanel).apply_saved_graph_type(cfg.get(CONFIG_KEY_GRAPH_TYPE)) @@ -1650,6 +1657,7 @@ def _on_config_saved(self, result: dict | None) -> None: self.columns = result.get(CONFIG_KEY_COLUMNS, list(DEFAULT_COLUMNS)) self.entity_names = result.get(CONFIG_KEY_ENTITY_NAMES, {}) self.keys_ctl.apply(result) + self.backup_ctl.apply(result) self.set_title_based_on_focused_ui() new_graph_type = result.get(CONFIG_KEY_GRAPH_TYPE) self.query_one("#detail_panel", EntityDetailPanel).apply_saved_graph_type(new_graph_type) diff --git a/tests/unit/test_backup_controller.py b/tests/unit/test_backup_controller.py new file mode 100644 index 0000000..8b35f98 --- /dev/null +++ b/tests/unit/test_backup_controller.py @@ -0,0 +1,326 @@ +# hatty — MIT License. See LICENSE file for details. +"""Unit tests for BackupController: prefs merging, export/import wiring +against real (but disk-free) list/dashboard/graph controllers, and the +git-facing exit_sync_pending/sync_on_exit/pull_on_start gating. backup.py's +own directory read/write behavior is covered by tests/unit/test_backup.py; +these tests fake backup.build_files/write_export/read_export and git_sync so +they run without touching disk or a real git binary.""" + +from hatty import backup as backup_module +from hatty.const import CONFIG_KEY_BACKUP, DEFAULT_BACKUP +from hatty.controllers.backup import BackupController +from hatty.controllers.dashboards import DashboardController +from hatty.controllers.graphs import GraphController +from hatty.controllers.lists import ListController + + +class _StubKeysCtl: + def __init__(self): + self.applied = [] + + def apply(self, cfg): + self.applied.append(dict(cfg)) + + +class _StubNotifyCtl: + def __init__(self): + self.notify_lists: set[str] = set() + + +class _StubApp: + def __init__(self, app_config=None, demo=False): + self.persist_calls = [] + self.notifications = [] + self.display_updates = 0 + self.terminal_title_calls = [] + self._demo = demo + self.app_config = app_config if app_config is not None else {} + self.columns = self.app_config.get("columns", []) + self.theme = None + self.available_themes = {"nord", "textual-dark"} + self.entity_names = {} + self.notify_ctl = _StubNotifyCtl() + self.keys_ctl = _StubKeysCtl() + self.list_ctl = ListController(self) + self.dash_ctl = DashboardController(self) + self.graph_ctl = GraphController(self) + + def persist(self, *keys): + self.persist_calls.append(keys) + + def notify(self, message, **kwargs): + self.notifications.append((message, kwargs)) + + def _update_entities_display(self): + self.display_updates += 1 + + def _apply_terminal_title(self, cfg): + self.terminal_title_calls.append(dict(cfg)) + + +def _controller(**backup_prefs) -> tuple[BackupController, _StubApp]: + app = _StubApp(app_config={CONFIG_KEY_BACKUP: backup_prefs} if backup_prefs else {}) + ctl = BackupController(app) + ctl.apply(app.app_config) + return ctl, app + + +# ── apply / prefs merging ──────────────────────────────────────────────────── + + +def test_apply_merges_defaults(): + ctl, _app = _controller(path="/tmp/x") + assert ctl.prefs["path"] == "/tmp/x" + assert ctl.prefs["git_enabled"] is False + assert ctl.prefs["sections"] == list(DEFAULT_BACKUP["sections"]) + + +def test_apply_drops_unknown_sections(): + ctl, _app = _controller(sections=["lists", "not_a_section"]) + assert ctl.prefs["sections"] == ["lists"] + + +def test_apply_writes_normalized_prefs_back_into_cfg(): + cfg = {CONFIG_KEY_BACKUP: {"path": "/tmp/x"}} + ctl = BackupController(_StubApp()) + ctl.apply(cfg) + assert cfg[CONFIG_KEY_BACKUP]["git_enabled"] is False + assert cfg[CONFIG_KEY_BACKUP]["path"] == "/tmp/x" + + +# ── export_now ──────────────────────────────────────────────────────────────── + + +def test_export_now_no_path_configured(): + ctl, _app = _controller() + ok, msg = ctl.export_now() + assert ok is False + assert "No backup directory" in msg + + +def test_export_now_no_sections_selected(): + ctl, _app = _controller(path="/tmp/x", sections=[]) + ok, msg = ctl.export_now() + assert ok is False + assert "No sections" in msg + + +def test_export_now_reports_written_and_removed(monkeypatch, tmp_path): + ctl, app = _controller(path=str(tmp_path), sections=["lists"]) + app.list_ctl.list_names = ["Kitchen"] + app.list_ctl.entity_lists = {"Kitchen": []} + + monkeypatch.setattr(backup_module, "build_files", lambda app, sections: {"lists/kitchen.list.json": {}}) + monkeypatch.setattr( + backup_module, + "write_export", + lambda path, files, sections: (["lists/kitchen.list.json"], ["lists/old.list.json"]), + ) + + ok, msg = ctl.export_now() + assert ok is True + assert "1 file(s) written" in msg + assert "1 stale file(s) removed" in msg + + +def test_export_now_surfaces_errors(monkeypatch, tmp_path): + ctl, _app = _controller(path=str(tmp_path), sections=["lists"]) + + def _raise(*_a, **_kw): + raise OSError("disk full") + + monkeypatch.setattr(backup_module, "build_files", lambda app, sections: {}) + monkeypatch.setattr(backup_module, "write_export", _raise) + ok, msg = ctl.export_now() + assert ok is False + assert "disk full" in msg + + +# ── import_now ──────────────────────────────────────────────────────────────── + + +def _fake_read_export(payloads, found): + def _read(path, sections): + return payloads, found + + return _read + + +def test_import_now_no_path_configured(): + ctl, _app = _controller() + ok, msg, found = ctl.import_now(["lists"]) + assert ok is False + assert found == [] + + +def test_import_now_replaces_lists_and_persists(monkeypatch, tmp_path): + ctl, app = _controller(path=str(tmp_path), sections=["lists"]) + app.list_ctl.list_names = ["Stale"] + app.list_ctl.entity_lists = {"Stale": ["light.old"]} + + payloads = { + "_manifest": {"default_list": "Kitchen"}, + "lists": [ + {"hatty_list": 1, "name": "Kitchen", "entities": ["light.a"], "manual": False, "notify": False}, + ], + } + monkeypatch.setattr(backup_module, "read_export", _fake_read_export(payloads, ["lists"])) + + ok, msg, found = ctl.import_now(["lists"]) + assert ok is True + assert found == ["lists"] + assert "Stale" not in app.list_ctl.entity_lists + assert app.list_ctl.entity_lists["Kitchen"] == ["light.a"] + assert app.list_ctl.default_list_name == "Kitchen" + assert {"lists", "manual_lists", "notify_lists", "default_list"} in [set(c) for c in app.persist_calls] + + +def test_import_now_dashboards_preserves_temp_dashboards(monkeypatch, tmp_path): + ctl, app = _controller(path=str(tmp_path), sections=["dashboards"]) + app.dash_ctl.create("Stale", 1, 1) + app.dash_ctl.dashboards["Preview"] = {"rows": 1, "cols": 1, "slots": []} + app.dash_ctl.dashboard_names.append("Preview") + app.dash_ctl.temp_dashboard_names.add("Preview") + + payloads = { + "_manifest": {}, + "dashboards": [ + {"hatty_dashboard": 1, "name": "Main", "dashboard": {"rows": 2, "cols": 2, "slots": []}}, + ], + } + monkeypatch.setattr(backup_module, "read_export", _fake_read_export(payloads, ["dashboards"])) + + ok, _msg, _found = ctl.import_now(["dashboards"]) + assert ok is True + assert "Stale" not in app.dash_ctl.dashboards + assert "Preview" in app.dash_ctl.dashboards # temp dashboards survive a replace + assert "Main" in app.dash_ctl.dashboards + + +def test_import_now_bad_object_is_skipped_not_fatal(monkeypatch, tmp_path): + ctl, app = _controller(path=str(tmp_path), sections=["saved_graphs"]) + payloads = { + "_manifest": {}, + "saved_graphs": [ + {"hatty_graph": 1, "name": "Good", "graph": {"entity_ids": ["sensor.a"]}}, + {"hatty_graph": 1, "name": "Bad"}, # missing "graph" -> ValueError, skipped + ], + } + monkeypatch.setattr(backup_module, "read_export", _fake_read_export(payloads, ["saved_graphs"])) + + ok, _msg, _found = ctl.import_now(["saved_graphs"]) + assert ok is True + assert "Good" in app.graph_ctl.saved_graphs + assert "Bad" not in app.graph_ctl.saved_graphs + + +def test_import_now_entity_names_replaces_and_persists(monkeypatch, tmp_path): + ctl, app = _controller(path=str(tmp_path), sections=["entity_names"]) + app.entity_names = {"light.old": "Old"} + payloads = {"_manifest": {}, "entity_names": {"light.a": "Lamp"}} + monkeypatch.setattr(backup_module, "read_export", _fake_read_export(payloads, ["entity_names"])) + + ok, _msg, found = ctl.import_now(["entity_names"]) + assert ok is True + assert found == ["entity_names"] + assert app.entity_names == {"light.a": "Lamp"} + assert ("entity_names",) in app.persist_calls + + +def test_import_now_settings_applies_and_strips_password(monkeypatch, tmp_path): + ctl, app = _controller(path=str(tmp_path), sections=["settings"]) + payloads = { + "_manifest": {}, + "settings": { + "columns": ["name"], + "theme": "nord", + "notifications": {"toast": True}, + }, + } + monkeypatch.setattr(backup_module, "read_export", _fake_read_export(payloads, ["settings"])) + + ok, _msg, found = ctl.import_now(["settings"]) + assert ok is True + assert found == ["settings"] + assert app.app_config["columns"] == ["name"] + assert app.columns == ["name"] + assert app.theme == "nord" + assert app.terminal_title_calls # _apply_terminal_title was invoked + # No explicit sqlite key touched by settings -> falls back to a bare persist(). + assert () in app.persist_calls + + +def test_import_now_keybindings_calls_keys_ctl_apply(monkeypatch, tmp_path): + ctl, app = _controller(path=str(tmp_path), sections=["keybindings"]) + payloads = {"_manifest": {}, "keybindings": {"nav.search": "ctrl+f"}} + monkeypatch.setattr(backup_module, "read_export", _fake_read_export(payloads, ["keybindings"])) + + ok, _msg, found = ctl.import_now(["keybindings"]) + assert ok is True + assert found == ["keybindings"] + assert app.app_config["keybindings"] == {"nav.search": "ctrl+f"} + assert app.keys_ctl.applied # apply() was called with the updated cfg + + +def test_import_now_rejects_bad_export(monkeypatch, tmp_path): + ctl, _app = _controller(path=str(tmp_path), sections=["lists"]) + + def _raise(path, sections): + raise ValueError("No hatty backup found") + + monkeypatch.setattr(backup_module, "read_export", _raise) + ok, msg, found = ctl.import_now(["lists"]) + assert ok is False + assert "No hatty backup found" in msg + assert found == [] + + +# ── git gating ──────────────────────────────────────────────────────────────── + + +def test_exit_sync_pending_false_by_default(): + ctl, _app = _controller(path="/tmp/x") + assert ctl.exit_sync_pending() is False + + +def test_exit_sync_pending_requires_git_enabled_and_path(): + ctl, _app = _controller(git_enabled=True, commit_on_exit=True) # no path + assert ctl.exit_sync_pending() is False + + +def test_exit_sync_pending_true_when_commit_on_exit(tmp_path): + ctl, _app = _controller(path=str(tmp_path), git_enabled=True, commit_on_exit=True) + assert ctl.exit_sync_pending() is True + + +def test_exit_sync_pending_false_in_demo_mode(tmp_path): + app = _StubApp( + app_config={CONFIG_KEY_BACKUP: {"path": str(tmp_path), "git_enabled": True, "push_on_exit": True}}, + demo=True, + ) + ctl = BackupController(app) + ctl.apply(app.app_config) + assert ctl.exit_sync_pending() is False + + +async def test_sync_on_exit_noop_when_not_pending(): + ctl, _app = _controller() + ok, msg = await ctl.sync_on_exit() + assert ok is True + assert msg == "" + + +async def test_sync_on_exit_returns_export_failure_without_touching_git(monkeypatch, tmp_path): + ctl, _app = _controller(path=str(tmp_path), git_enabled=True, commit_on_exit=True, sections=[]) + called = [] + monkeypatch.setattr("hatty.git_sync.commit_all_async", lambda *a, **kw: called.append(a)) + ok, msg = await ctl.sync_on_exit() + assert ok is False + assert "No sections" in msg + assert called == [] + + +async def test_pull_on_start_noop_when_disabled(): + ctl, app = _controller(path="/tmp/x", git_enabled=True, pull_on_start=False) + await ctl.pull_on_start() + assert app.notifications == [] From a6a4908d79066bf4948dfe3de1011e797a95e7c7 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:29:38 -0500 Subject: [PATCH 05/10] =?UTF-8?q?=E2=9C=A8=20Wire=20the=20exit-time=20git?= =?UTF-8?q?=20sync=20into=20app=20quit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit action_quit shows an ExitSyncScreen overlay so commit_on_exit/push_on_exit never freezes the message pump; _on_exit_app backstops any exit() that skips it, and cli.py has a last-resort sync flush for a bare SIGINT. escape always skips straight to quitting, so a stalled push can never trap the user. Test suite gets a global git_sync._run_git stub so quitting the app in acceptance tests never shells out for real. --- src/hatty/cli.py | 36 +++++++++- src/hatty/controllers/backup.py | 5 +- src/hatty/main.py | 58 ++++++++++++++++ src/hatty/ui/exit_sync_screen.py | 98 ++++++++++++++++++++++++++ tests/conftest.py | 9 +++ tests/test_backup_exit_sync.py | 115 +++++++++++++++++++++++++++++++ tests/test_backup_git.py | 15 ++++ tests/unit/test_git_sync.py | 17 +++++ 8 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 src/hatty/ui/exit_sync_screen.py create mode 100644 tests/test_backup_exit_sync.py diff --git a/src/hatty/cli.py b/src/hatty/cli.py index b08d304..2995891 100644 --- a/src/hatty/cli.py +++ b/src/hatty/cli.py @@ -30,7 +30,41 @@ def main() -> None: from hatty.main import HACLI # imported here so TEXTUAL_LOG is set first - HACLI(config_path=args.config, demo=args.demo).run() + app = HACLI(config_path=args.config, demo=args.demo) + try: + app.run() + finally: + _flush_pending_exit_sync(app) + + +def _flush_pending_exit_sync(app) -> None: + """Last-resort fallback for an exit path that skipped both + HACLI.action_quit and HACLI._on_exit_app (a real SIGINT, or a panic) — + runs after app.run() returns, with no event loop, so it calls git_sync's + plain sync functions directly rather than through their asyncio.to_thread + wrappers. A save task started just before this path may have been + abandoned mid-flight, so the pushed data can be one save stale; that's + unavoidable without an event loop here. Never raises — a failed backup + sync must not turn into a crash on the way out.""" + if app._exit_sync_done or not app.backup_ctl.exit_sync_pending(): + return + app._exit_sync_done = True + + from hatty import git_sync + + try: + ok, msg = app.backup_ctl.export_now() + if not ok: + print(f"hatty: backup export failed: {msg}") + return + path = app.backup_ctl.prefs.get("path") or "" + message = git_sync.default_commit_message() + print("hatty: syncing backup with git…") + op = git_sync.commit_and_push if app.backup_ctl.prefs.get("push_on_exit") else git_sync.commit_all + ok, msg = op(path, message) + print(f"hatty: {msg}") + except Exception as e: + print(f"hatty: backup sync failed: {e}") if __name__ == "__main__": diff --git a/src/hatty/controllers/backup.py b/src/hatty/controllers/backup.py index eb6404c..bfee6cd 100644 --- a/src/hatty/controllers/backup.py +++ b/src/hatty/controllers/backup.py @@ -206,7 +206,10 @@ async def pull_on_start(self) -> None: title = "Backup Imported" if ok else "Backup Import Failed" app.notify(msg, title=title, severity="information" if ok else "error") - async def sync_on_exit(self, timeout: float = 30.0) -> tuple[bool, str]: + async def sync_on_exit(self, timeout: float = 75.0) -> tuple[bool, str]: + # 75s: room for git_sync's own NETWORK_TIMEOUT (60s) on the push plus a + # buffer for the local commit and export, as a belt-and-suspenders cap + # so a stalled network can't hang the exit-sync overlay indefinitely. if not self.exit_sync_pending(): return True, "" path = self.prefs.get("path") or "" diff --git a/src/hatty/main.py b/src/hatty/main.py index 2e61f01..47cac8b 100644 --- a/src/hatty/main.py +++ b/src/hatty/main.py @@ -136,6 +136,10 @@ def __init__(self, config_path: str | None = None, demo: bool = False): # Fire-and-forget tasks hold a reference here so asyncio can't GC them # mid-flight; done tasks remove themselves. self._bg_tasks: set[asyncio.Task] = set() + # Guards the exit-time git sync against running twice: action_quit is + # the primary path, _on_exit_app is a backstop for any exit() call + # that bypasses it. + self._exit_sync_done = False # Config key -> the app attribute that is its in-memory working copy, derived # from storage.PERSISTED so there is one source of truth (issue #168). @@ -148,6 +152,25 @@ def spawn(self, coro) -> asyncio.Task: task.add_done_callback(self._bg_tasks.discard) return task + async def drain_bg_tasks(self, timeout: float = 5.0) -> bool: + """Wait for the fire-and-forget tasks spawn() tracks (notably + _save_config_async) to finish, so an exit-time git commit picks up the + very last save. True if all completed. Uses asyncio.wait rather than + wait_for(gather(...)): the latter would cancel a still-running + storage.save_all mid-transaction on timeout, and a half-written + hatty.db is worse than a slow quit — stragglers are just left running. + Excludes the current task, since the caller (the exit-sync flow) is + itself usually running as a spawned task and would otherwise wait on + itself forever.""" + current = asyncio.current_task() + pending = {t for t in self._bg_tasks if t is not current and not t.done()} + if not pending: + return True + _done, still_pending = await asyncio.wait(pending, timeout=timeout) + if still_pending: + self.log.warning(f"{len(still_pending)} background task(s) still running at exit") + return not still_pending + def persist(self, *keys: str) -> None: """Mirror the named collections from their app attributes into app_config and schedule an async save. With no keys, just schedules @@ -251,6 +274,41 @@ def on_unmount(self) -> None: except Exception as e: self.log.error(f"Error closing storage: {e}") + async def action_quit(self) -> None: + """Overrides Textual's default (a bare `self.exit()`) so a pending + `commit_on_exit`/`push_on_exit` git sync gets a visible overlay + instead of silently blocking the message pump — this method is itself + awaited from inside the still-running pump (Textual's + `_check_bindings`), so anything it awaits directly would freeze + rendering. ExitSyncScreen does the actual awaiting, from its own + `on_mount`, via `self.spawn(...)`.""" + if self._exit_sync_done or not self.backup_ctl.exit_sync_pending(): + self.exit() + return + self._exit_sync_done = True + from hatty.ui.exit_sync_screen import ExitSyncScreen + + self.push_screen(ExitSyncScreen()) + + async def _on_exit_app(self) -> None: + """Backstop for an `exit()` that bypasses action_quit (a future call + site, `--press`, test autopilot). Textual's MRO dispatch + (`message_pump.py`) invokes `App._on_exit_app` right after this one — + do NOT call `super()._on_exit_app()`, or the shutdown sentinel gets + posted twice. Unlike action_quit, this runs with no live UI to show + progress in, so it just awaits the sync directly (frozen rendering is + expected here — the pump is already winding down).""" + if self._exit_sync_done or self._demo: + return + self._exit_sync_done = True + if not self.backup_ctl.exit_sync_pending(): + return + try: + await self.drain_bg_tasks(timeout=5.0) + await self.backup_ctl.sync_on_exit() + except Exception as e: + self.log.error(f"git sync on exit failed: {e}") + def _storage_db_path(self): from pathlib import Path diff --git a/src/hatty/ui/exit_sync_screen.py b/src/hatty/ui/exit_sync_screen.py new file mode 100644 index 0000000..ccbc510 --- /dev/null +++ b/src/hatty/ui/exit_sync_screen.py @@ -0,0 +1,98 @@ +# hatty — MIT License. See LICENSE file for details. +"""Full-screen overlay shown while an exit-time export + git sync +(`commit_on_exit`/`push_on_exit`) runs, pushed from `HACLI.action_quit`. +Never traps the user: escape or the (unrebindable, RESERVED_KEYS) quit key +skips the sync and exits immediately, mirroring SplashScreen's "any keypress +dismisses it" rule for a slow/failing connection.""" + +import asyncio +from typing import TYPE_CHECKING + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import Screen +from textual.widgets import Label, Static + +if TYPE_CHECKING: + from hatty.main import HACLI + + +class ExitSyncScreen(Screen): + app: "HACLI" # narrow Textual's inherited attr for type-checkers; annotation only, no runtime effect + + BINDINGS = [ + Binding("escape", "skip", "Skip and quit now"), + Binding("ctrl+q", "skip", "Skip and quit now", show=False), + ] + + DEFAULT_CSS = """ + ExitSyncScreen { + align: center middle; + background: $background; + } + #exit_sync_body { + width: auto; + height: auto; + align: center middle; + } + #exit_sync_title { + width: 100%; + text-align: center; + text-style: bold; + color: $accent; + } + #exit_sync_status { + width: 100%; + text-align: center; + color: $text-muted; + margin-top: 1; + } + #exit_sync_hint { + width: 100%; + text-align: center; + color: $text-disabled; + margin-top: 1; + } + """ + + def __init__(self) -> None: + super().__init__() + self._done = False + + def compose(self) -> ComposeResult: + with Vertical(id="exit_sync_body"): + yield Static("hatty — syncing", id="exit_sync_title") + yield Label("Saving…", id="exit_sync_status") + yield Label("escape to skip and quit now", id="exit_sync_hint") + + def on_mount(self) -> None: + self.app.spawn(self._sync()) + + def _set_status(self, text: str) -> None: + if self._done: + return + try: + self.query_one("#exit_sync_status", Label).update(text) + except Exception: + pass # the screen may already be torn down by a concurrent skip + + async def _sync(self) -> None: + try: + self._set_status("Saving…") + await self.app.drain_bg_tasks(timeout=5.0) + self._set_status("Syncing with git…") + ok, msg = await self.app.backup_ctl.sync_on_exit() + if msg: + self._set_status(msg) + if not ok: + await asyncio.sleep(2.0) # let the user read the failure + except Exception as e: + self.app.log.error(f"git sync on exit failed: {e}") + finally: + self._done = True + self.app.exit() + + def action_skip(self) -> None: + self._done = True + self.app.exit() diff --git a/tests/conftest.py b/tests/conftest.py index ed305d7..01b6c55 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,6 +35,15 @@ def _no_terminal_title_side_effects(monkeypatch): monkeypatch.setattr("hatty.terminal_title.restore", lambda prev: None) +@pytest.fixture(autouse=True) +def _no_real_git_calls(monkeypatch): + """Once commit_on_exit/push_on_exit exist, quitting the real HACLI app can + shell out to git. Stub the chokepoint so no acceptance test ever touches a + real git binary or the network at quit time; a test that wants the real + thing (tests/test_backup_git.py) opts back in with its own monkeypatch.""" + monkeypatch.setattr("hatty.git_sync._run_git", lambda args, cwd, timeout=None: (0, "", "")) + + def notified(app, *, title=None, message_contains=None): """True if a currently-live notification matches the given title and/or message substring. Prefer this over `len(app._notifications) > before` diff --git a/tests/test_backup_exit_sync.py b/tests/test_backup_exit_sync.py new file mode 100644 index 0000000..04d7dd8 --- /dev/null +++ b/tests/test_backup_exit_sync.py @@ -0,0 +1,115 @@ +# hatty — MIT License. See LICENSE file for details. +"""Acceptance tests for the exit-time git sync (HACLI.action_quit + +ExitSyncScreen): quitting with commit_on_exit set runs the export + git +commit before the app actually exits, quitting with no backup configured +never touches git, and escape on the overlay skips a slow sync instead of +trapping the user.""" + +import asyncio + +import pytest + +from hatty import git_sync +from hatty.ui.exit_sync_screen import ExitSyncScreen +from tests.conftest import make_config + + +@pytest.fixture +def git_spy(monkeypatch): + """Overrides tests/conftest.py's blanket _no_real_git_calls stub with one + that also records every invocation, so these tests can assert on it.""" + calls = [] + + def fake(args, cwd, timeout=None): + calls.append(args) + if args[:3] == ["diff", "--cached", "--quiet"]: + return (1, "", "") # rc 1 = something staged, so commit_all proceeds + if args[0] == "remote": + return (0, "origin\n", "") # a configured remote, so push actually runs + if args[:2] == ["symbolic-ref", "--quiet"]: + return (0, "main\n", "") + return (0, "", "") + + monkeypatch.setattr(git_sync, "_run_git", fake) + return calls + + +def _backup_config(path, **overrides): + return { + **make_config(), + "backup": { + "path": str(path), + "git_enabled": True, + "sections": ["lists"], + **overrides, + }, + } + + +async def test_commit_on_exit_runs_git_before_quitting(make_app, git_spy, tmp_path): + backup_dir = tmp_path / "backup" + backup_dir.mkdir() + app = make_app(config_data=_backup_config(backup_dir, commit_on_exit=True)) + + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("ctrl+q") + await pilot.pause() + await pilot.pause() + + assert any(c[0] == "add" for c in git_spy) + assert any("commit" in c for c in git_spy) + assert not any(c[0] == "push" for c in git_spy) # commit_on_exit alone never pushes + assert app._exit_sync_done is True + assert app._exit is True + + +async def test_push_on_exit_also_pushes(make_app, git_spy, tmp_path): + backup_dir = tmp_path / "backup" + backup_dir.mkdir() + app = make_app(config_data=_backup_config(backup_dir, push_on_exit=True)) + + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("ctrl+q") + await pilot.pause() + await pilot.pause() + + assert any("commit" in c for c in git_spy) + assert any(c[0] == "push" for c in git_spy) + + +async def test_no_backup_configured_quits_without_touching_git(make_app, git_spy): + app = make_app(config_data=make_config()) + + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("ctrl+q") + await pilot.pause() + + assert git_spy == [] + assert app._exit is True + + +async def test_escape_skips_a_slow_sync_and_quits_immediately(make_app, tmp_path, monkeypatch): + backup_dir = tmp_path / "backup" + backup_dir.mkdir() + app = make_app(config_data=_backup_config(backup_dir, commit_on_exit=True)) + + async def _slow_sync(timeout=75.0): + await asyncio.sleep(10) + return True, "done" + + async with app.run_test() as pilot: + await pilot.pause() + monkeypatch.setattr(app.backup_ctl, "sync_on_exit", _slow_sync) + + await pilot.press("ctrl+q") + await pilot.pause() + assert isinstance(app.screen, ExitSyncScreen) + + await pilot.press("escape") + await pilot.pause() + + # Reaching here (well under the 10s sync) proves escape didn't wait for it. + assert app._exit is True diff --git a/tests/test_backup_git.py b/tests/test_backup_git.py index 61d3767..bf8e53c 100644 --- a/tests/test_backup_git.py +++ b/tests/test_backup_git.py @@ -15,6 +15,21 @@ pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git not installed") +# tests/conftest.py's autouse _no_real_git_calls stubs git_sync._run_git for +# every test so the acceptance suite never shells out to git; captured here at +# import time (before any monkeypatch has run) so this module's own autouse +# fixture below can restore it. +_REAL_RUN_GIT = git_sync._run_git + + +@pytest.fixture(autouse=True) +def _use_real_git(monkeypatch): + # Runs after tests/conftest.py's _no_real_git_calls (a parent-conftest + # autouse fixture is instantiated before a same-scoped one defined in the + # test module itself), so this un-stubs it back to the real binary — the + # whole point of this file. + monkeypatch.setattr(git_sync, "_run_git", _REAL_RUN_GIT) + @pytest.fixture(autouse=True) def _isolated_git_config(monkeypatch): diff --git a/tests/unit/test_git_sync.py b/tests/unit/test_git_sync.py index e4a563d..cfaf154 100644 --- a/tests/unit/test_git_sync.py +++ b/tests/unit/test_git_sync.py @@ -10,6 +10,23 @@ from hatty import git_sync +# tests/conftest.py's autouse _no_real_git_calls stubs git_sync._run_git for +# every test (so the acceptance suite never shells out to git); captured here +# at import time, before any monkeypatch has run, so the fixture below can +# restore it for this module, which needs the real chokepoint to exercise its +# own subprocess.run wiring and to let each test install its own fake. +_REAL_RUN_GIT = git_sync._run_git + + +@pytest.fixture(autouse=True) +def _use_real_run_git(monkeypatch): + # A parent-conftest autouse fixture is instantiated before a same-scoped + # one defined in the test module itself, so this runs after (and undoes) + # tests/conftest.py's stub. Individual tests below still monkeypatch + # _run_git or subprocess.run themselves as needed. + monkeypatch.setattr(git_sync, "_run_git", _REAL_RUN_GIT) + + # ── _git_env / _run_git ────────────────────────────────────────────────────── From d93d8972499b446495d7626a47a8c435543deb61 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:39:56 -0500 Subject: [PATCH 06/10] =?UTF-8?q?=E2=9C=A8=20Add=20a=20Backup=20&=20Sync?= =?UTF-8?q?=20category=20to=20the=20config=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Directory picker, section/git toggles, and export/import/init/pull/push/ status buttons that all act on the currently-entered (unsaved) fields, mirroring the connection-test buttons. export_now/import_now gain optional path/sections overrides for this; also fixes a real bug where wrapping them in asyncio.to_thread broke persist()'s asyncio.create_task() (no running loop in a thread-pool worker). --- src/hatty/controllers/backup.py | 19 ++- src/hatty/ui/config_screen.py | 209 ++++++++++++++++++++++++++++ tests/test_config_screen_backup.py | 211 +++++++++++++++++++++++++++++ 3 files changed, 432 insertions(+), 7 deletions(-) create mode 100644 tests/test_config_screen_backup.py diff --git a/src/hatty/controllers/backup.py b/src/hatty/controllers/backup.py index bfee6cd..b8a76c7 100644 --- a/src/hatty/controllers/backup.py +++ b/src/hatty/controllers/backup.py @@ -7,6 +7,7 @@ import asyncio from collections.abc import Sequence +from pathlib import Path from hatty import backup as backup_module from hatty import git_sync @@ -63,16 +64,20 @@ def apply(self, cfg: dict) -> None: # ── Export / import ────────────────────────────────────────────────────── - def export_now(self) -> tuple[bool, str]: - path = self.prefs.get("path") + def export_now(self, path: str | None = None, sections: Sequence[str] | None = None) -> tuple[bool, str]: + """`path`/`sections` default to the saved prefs; the config screen + passes the currently-entered (unsaved) widget values instead, the + same "act on unsaved fields" precedent as action_test_connection.""" + path = path if path is not None else self.prefs.get("path") if not path: return False, "No backup directory set." - sections = self.prefs.get("sections") or [] + sections = sections if sections is not None else (self.prefs.get("sections") or []) + sections = [s for s in backup_module.SECTIONS if s in sections] if not sections: return False, "No sections selected to export." try: files = backup_module.build_files(self._app, sections) - written, removed = backup_module.write_export(path, files, sections) + written, removed = backup_module.write_export(Path(path), files, sections) except (OSError, ValueError) as exc: return False, f"Export failed: {exc}" parts = [] @@ -83,15 +88,15 @@ def export_now(self) -> tuple[bool, str]: detail = f" ({', '.join(parts)})" if parts else " (already up to date)" return True, f"Exported to {path}{detail}." - def import_now(self, sections: Sequence[str]) -> tuple[bool, str, list[str]]: - path = self.prefs.get("path") + def import_now(self, sections: Sequence[str], path: str | None = None) -> tuple[bool, str, list[str]]: + path = path if path is not None else self.prefs.get("path") if not path: return False, "No backup directory set.", [] sections = [s for s in backup_module.SECTIONS if s in sections] if not sections: return False, "No sections selected to import.", [] try: - payloads, found = backup_module.read_export(path, sections) + payloads, found = backup_module.read_export(Path(path), sections) except ValueError as exc: return False, str(exc), [] diff --git a/src/hatty/ui/config_screen.py b/src/hatty/ui/config_screen.py index fc56c5f..5a268d9 100644 --- a/src/hatty/ui/config_screen.py +++ b/src/hatty/ui/config_screen.py @@ -1,6 +1,7 @@ # hatty — MIT License. See LICENSE file for details. import os import subprocess +from pathlib import Path from typing import TYPE_CHECKING, cast from rich.table import Table @@ -23,11 +24,15 @@ Static, ) from textual.widgets.selection_list import Selection +from textual_fspicker import SelectDirectory +from hatty import backup as backup_module from hatty import config as config_module +from hatty import git_sync from hatty import storage as storage_module from hatty.client import probe_connection from hatty.const import ( + CONFIG_KEY_BACKUP, CONFIG_KEY_COLUMNS, CONFIG_KEY_DASHBOARDS, CONFIG_KEY_ENTITY_NAMES, @@ -43,12 +48,14 @@ CONFIG_KEY_THEME, CONFIG_KEY_TOKEN, CONFIG_KEY_URL, + DEFAULT_BACKUP, DEFAULT_GRAPH_HOURS, DEFAULT_NOTIFICATIONS, DEFAULT_TERMINAL_TITLE, ) from hatty.controllers import keybindings as keybindings_module from hatty.controllers.notifications import send_test_ntfy +from hatty.ui.confirm_popup import ConfirmPopup from hatty.ui.entity_table import COLUMNS from hatty.ui.key_capture_popup import KeyCapturePopup @@ -81,6 +88,18 @@ ("Highlight changed entity", "highlight"), ] +# Backup & Sync git toggles, shown as a SelectionList mirroring the +# notification channel toggles above. Keys match DEFAULT_BACKUP exactly minus +# "path"/"sections", which get their own widgets. +_BACKUP_GIT_TOGGLES = [ + ("Enable git", "git_enabled"), + ("Pull on start", "pull_on_start"), + ("Import after a successful pull", "import_on_pull"), + ("Commit on exit", "commit_on_exit"), + ("Push on exit", "push_on_exit"), + ("Rebase on pull (instead of fast-forward only)", "pull_rebase"), +] + # Top-level category menu (issue #252). Each entry is (display name, pane id, # one-line hint, first-focus widget id within the pane). "cat_menu" itself is # the ContentSwitcher's built-in first pane and isn't listed here. @@ -90,6 +109,7 @@ ("Notifications", "cat_notifications", "Toast/beep/desktop/ntfy alerts", "#cfg_notify"), ("Data & Collections", "cat_data", "Lists, name overrides, dashboards, saved graphs", "#cat_data"), ("Keybindings", "cat_keybindings", "Rebind keys for navigation, lists, log and graph", "#cfg_keys"), + ("Backup & Sync", "cat_backup", "Export/import your data, optional git repo", "#cfg_backup_path"), ] # Row key prefix for a non-interactive section-header row in #cfg_keys, so @@ -187,6 +207,30 @@ class ConfigScreen(Screen): #cfg_ntfy_status.-error { color: $error; } + #cfg_backup_path_row { + height: auto; + } + #cfg_backup_path_row Input { + width: 1fr; + } + #cfg_backup_status { + margin-top: 1; + color: $text; + } + #cfg_backup_status.-ok { + color: $success; + } + #cfg_backup_status.-error { + color: $error; + } + #cfg_backup_buttons { + height: auto; + margin-top: 1; + } + #cfg_backup_buttons Button { + margin-right: 2; + margin-top: 1; + } """ def __init__(self, raw_config: dict, config_path: str | None): @@ -237,6 +281,8 @@ def compose(self) -> ComposeResult: entity_names = self._raw_config.get(CONFIG_KEY_ENTITY_NAMES, {}) dashboards = self._raw_config.get(CONFIG_KEY_DASHBOARDS, {}) saved_graphs = self._raw_config.get(CONFIG_KEY_SAVED_GRAPHS, {}) + backup_prefs = {**DEFAULT_BACKUP, **(self._raw_config.get(CONFIG_KEY_BACKUP) or {})} + backup_sections = set(backup_prefs.get("sections") or []) themes = sorted(self.app.available_themes) theme_kwargs: dict = {"allow_blank": True} @@ -373,6 +419,33 @@ def compose(self) -> ComposeResult: yield DataTable(id="cfg_keys", cursor_type="row") yield Button("Reset all to defaults", id="cfg_keys_reset") + with VerticalScroll(id="cat_backup", classes="config-pane"): + yield Label("Backup Directory", classes="section-title") + with Horizontal(id="cfg_backup_path_row"): + yield Input(value=backup_prefs.get("path", ""), id="cfg_backup_path", placeholder="/path/to/backup") + yield Button("Browse…", id="cfg_backup_browse") + + yield Label("Sections to back up", classes="field-label") + section_selections = [ + Selection(backup_module.SECTION_LABELS[s], s, s in backup_sections) for s in backup_module.SECTIONS + ] + yield SelectionList(*section_selections, id="cfg_backup_sections") + + yield Label("Git", classes="section-title") + git_selections = [ + Selection(label, key, bool(backup_prefs.get(key))) for label, key in _BACKUP_GIT_TOGGLES + ] + yield SelectionList(*git_selections, id="cfg_backup_git") + + yield Label("", id="cfg_backup_status") + with Horizontal(id="cfg_backup_buttons"): + yield Button("Export now", id="cfg_backup_export") + yield Button("Import now", id="cfg_backup_import") + yield Button("Init repo", id="cfg_backup_init") + yield Button("Pull", id="cfg_backup_pull") + yield Button("Push", id="cfg_backup_push") + yield Button("Check status", id="cfg_backup_status_check") + yield Footer() def on_mount(self) -> None: @@ -462,6 +535,20 @@ def on_button_pressed(self, event: Button.Pressed) -> None: elif event.button.id == "cfg_keys_reset": self._keybindings = {} self._populate_keybindings_table() + elif event.button.id == "cfg_backup_browse": + self.action_backup_browse() + elif event.button.id == "cfg_backup_export": + self.action_backup_export() + elif event.button.id == "cfg_backup_import": + self.action_backup_import() + elif event.button.id == "cfg_backup_init": + self.action_backup_init() + elif event.button.id == "cfg_backup_pull": + self.action_backup_pull() + elif event.button.id == "cfg_backup_push": + self.action_backup_push() + elif event.button.id == "cfg_backup_status_check": + self.action_backup_status_check() def action_stop_watching_all(self) -> None: # A live write (like the l/d/s popups edit their own collections directly) @@ -515,6 +602,123 @@ async def _do_test_ntfy(self, prefs: dict) -> None: ok, message = await send_test_ntfy(prefs, "hatty", "🔔 Test notification from hatty") self._set_ntfy_status(message, ok=ok) + # ── Backup & Sync ───────────────────────────────────────────────────────── + # Every action here acts on the currently entered (unsaved) path/sections/git + # toggles, the action_test_connection/action_test_ntfy precedent — not on + # self.app.backup_ctl.prefs, which only reflects the last *saved* config. + + def _backup_path(self) -> str: + return self.query_one("#cfg_backup_path", Input).value.strip() + + def _backup_sections(self) -> list[str]: + selected = set(self.query_one("#cfg_backup_sections", SelectionList).selected) + return [s for s in backup_module.SECTIONS if s in selected] + + def _backup_git_prefs(self) -> dict: + selected = set(self.query_one("#cfg_backup_git", SelectionList).selected) + return {key: key in selected for _label, key in _BACKUP_GIT_TOGGLES} + + def _set_backup_status(self, text: str, ok: bool | None = None) -> None: + status = self.query_one("#cfg_backup_status", Label) + status.update(text) + status.set_class(ok is True, "-ok") + status.set_class(ok is False, "-error") + + def action_backup_browse(self) -> None: + current = self._backup_path() or str(Path.home()) + + def _picked(path: Path | None) -> None: + if path is not None: + self.query_one("#cfg_backup_path", Input).value = str(path) + + self.app.push_screen(SelectDirectory(location=current, title="Backup directory"), _picked) + + def action_backup_export(self) -> None: + path = self._backup_path() + sections = self._backup_sections() + if not path or not sections: + self._set_backup_status("Set a directory and at least one section first.", ok=False) + return + self._set_backup_status("Exporting…") + self.run_worker(self._do_backup_export(path, sections), exclusive=True) + + async def _do_backup_export(self, path: str, sections: list[str]) -> None: + # Not asyncio.to_thread: export_now/import_now (via the object + # controllers' import_from_payload) call app.persist(), which does + # asyncio.create_task() — that needs the app's own running loop, which + # a thread-pool worker thread doesn't have. + ok, msg = self.app.backup_ctl.export_now(path, sections) + self._set_backup_status(msg, ok=ok) + + def action_backup_import(self) -> None: + path = self._backup_path() + sections = self._backup_sections() + if not path or not sections: + self._set_backup_status("Set a directory and at least one section first.", ok=False) + return + labels = ", ".join(backup_module.SECTION_LABELS[s] for s in sections) + + def _confirmed(confirmed: bool | None) -> None: + if not confirmed: + return + self._set_backup_status("Importing…") + self.run_worker(self._do_backup_import(path, sections), exclusive=True) + + self.app.push_screen(ConfirmPopup(f"Replace {labels} with the backup in {path}?"), _confirmed) + + async def _do_backup_import(self, path: str, sections: list[str]) -> None: + ok, msg, _found = self.app.backup_ctl.import_now(sections, path) + self._set_backup_status(msg, ok=ok) + + def action_backup_init(self) -> None: + path = self._backup_path() + if not path: + self._set_backup_status("Set a directory first.", ok=False) + return + self._set_backup_status("Initializing…") + self.run_worker(self._do_backup_init(path), exclusive=True) + + async def _do_backup_init(self, path: str) -> None: + ok, msg = await git_sync.init_repo_async(path) + self._set_backup_status(msg, ok=ok) + + def action_backup_pull(self) -> None: + path = self._backup_path() + if not path: + self._set_backup_status("Set a directory first.", ok=False) + return + rebase = "pull_rebase" in self.query_one("#cfg_backup_git", SelectionList).selected + self._set_backup_status("Pulling…") + self.run_worker(self._do_backup_pull(path, rebase), exclusive=True) + + async def _do_backup_pull(self, path: str, rebase: bool) -> None: + ok, msg = await git_sync.pull_async(path, rebase=rebase) + self._set_backup_status(msg, ok=ok) + + def action_backup_push(self) -> None: + path = self._backup_path() + if not path: + self._set_backup_status("Set a directory first.", ok=False) + return + self._set_backup_status("Committing and pushing…") + self.run_worker(self._do_backup_push(path), exclusive=True) + + async def _do_backup_push(self, path: str) -> None: + ok, msg = await git_sync.commit_and_push_async(path, git_sync.default_commit_message()) + self._set_backup_status(msg, ok=ok) + + def action_backup_status_check(self) -> None: + path = self._backup_path() + if not path: + self._set_backup_status("Set a directory first.", ok=False) + return + self._set_backup_status("Checking…") + self.run_worker(self._do_backup_status_check(path), exclusive=True) + + async def _do_backup_status_check(self, path: str) -> None: + info = await git_sync.repo_info_async(path) + self._set_backup_status(info.message, ok=info.ok) + def action_save_and_close(self) -> None: url = self.query_one("#cfg_url", Input).value.strip() token = self.query_one("#cfg_token", Input).value.strip() @@ -562,6 +766,11 @@ def action_save_and_close(self) -> None: new_config[CONFIG_KEY_COLUMNS] = columns if columns else existing new_config[CONFIG_KEY_NOTIFICATIONS] = notifications new_config[CONFIG_KEY_KEYBINDINGS] = dict(self._keybindings) + new_config[CONFIG_KEY_BACKUP] = { + "path": self._backup_path(), + "sections": self._backup_sections(), + **self._backup_git_prefs(), + } # Collections live in SQLite; this screen only edits connection settings + # display preferences, so keep them out of the lean YAML it writes. The diff --git a/tests/test_config_screen_backup.py b/tests/test_config_screen_backup.py new file mode 100644 index 0000000..07c15d7 --- /dev/null +++ b/tests/test_config_screen_backup.py @@ -0,0 +1,211 @@ +# hatty — MIT License. See LICENSE file for details. +"""Config screen "Backup & Sync" category: navigation, saving prefs, and the +export/import/git buttons acting on the currently-entered (unsaved) widget +values — the action_test_connection precedent.""" + +from textual.widgets import Input, SelectionList + +from hatty import backup as backup_module +from hatty.ui.config_screen import ConfigScreen +from hatty.ui.confirm_popup import ConfirmPopup +from tests.conftest import make_config + +_CONFIG = {**make_config(), "lists": {}} + + +async def _open_backup(app, pilot): + await pilot.pause() + app.action_show_config() + await pilot.pause() + assert isinstance(app.screen, ConfigScreen) + screen = app.screen + screen.show_category("cat_backup") + await pilot.pause() + return screen + + +async def test_backup_category_listed_and_navigable(make_app, sample_entities): + app = make_app(entities=sample_entities, config_data=_CONFIG) + async with app.run_test() as pilot: + screen = await _open_backup(app, pilot) + assert screen.query_one("#cfg_backup_path", Input) + assert screen.focused is screen.query_one("#cfg_backup_path") + + +async def test_save_persists_path_sections_and_git_toggles(make_app, sample_entities, tmp_path): + app = make_app(entities=sample_entities, config_data=_CONFIG) + async with app.run_test() as pilot: + screen = await _open_backup(app, pilot) + + screen.query_one("#cfg_backup_path", Input).value = str(tmp_path) + sections = screen.query_one("#cfg_backup_sections", SelectionList) + sections.deselect_all() + sections.select("lists") + sections.select("dashboards") + git = screen.query_one("#cfg_backup_git", SelectionList) + git.select("git_enabled") + git.select("push_on_exit") + + await pilot.press("ctrl+s") + await pilot.pause() + + saved = app.app_config["backup"] + assert saved["path"] == str(tmp_path) + assert sorted(saved["sections"]) == ["dashboards", "lists"] + assert saved["git_enabled"] is True + assert saved["push_on_exit"] is True + assert saved["commit_on_exit"] is False + + +async def test_export_now_writes_files_for_selected_sections(make_app, sample_entities, tmp_path): + config_data = {**_CONFIG, "lists": {"list_a": ["switch.fan"]}} + app = make_app(entities=sample_entities, config_data=config_data) + async with app.run_test() as pilot: + screen = await _open_backup(app, pilot) + + screen.query_one("#cfg_backup_path", Input).value = str(tmp_path) + sections = screen.query_one("#cfg_backup_sections", SelectionList) + sections.deselect_all() + sections.select("lists") + + await pilot.press("tab") # leave the Input so its .value is committed + await screen.run_worker(screen._do_backup_export(str(tmp_path), ["lists"])).wait() + await pilot.pause() + + status = screen.query_one("#cfg_backup_status") + assert "Exported" in str(status.content) + + assert (tmp_path / "lists" / "list_a.list.json").exists() + assert not (tmp_path / "dashboards").exists() + + +async def test_export_requires_path_and_section(make_app, sample_entities): + app = make_app(entities=sample_entities, config_data=_CONFIG) + async with app.run_test() as pilot: + screen = await _open_backup(app, pilot) + screen.query_one("#cfg_backup_sections", SelectionList).deselect_all() + + screen.action_backup_export() + await pilot.pause() + + status = screen.query_one("#cfg_backup_status") + assert "directory and at least one section" in str(status.content) + + +async def test_import_now_confirms_then_replaces(make_app, sample_entities, tmp_path): + config_data = {**_CONFIG, "lists": {"stale": ["light.old"]}} + app = make_app(entities=sample_entities, config_data=config_data) + + export_dir = tmp_path / "export" + export_dir.mkdir() + files = { + "lists/kitchen.list.json": { + "hatty_list": 1, + "name": "Kitchen", + "entities": ["light.a"], + "manual": False, + "notify": False, + }, + } + backup_module.write_export(export_dir, files, ["lists"]) + + async with app.run_test() as pilot: + screen = await _open_backup(app, pilot) + screen.query_one("#cfg_backup_path", Input).value = str(export_dir) + sections = screen.query_one("#cfg_backup_sections", SelectionList) + sections.deselect_all() + sections.select("lists") + + screen.action_backup_import() + await pilot.pause() + assert isinstance(app.screen, ConfirmPopup) + await pilot.press("y") + await pilot.pause() + await pilot.pause() + + assert "stale" not in app.entity_lists + assert app.entity_lists["Kitchen"] == ["light.a"] + + +async def test_import_cancelled_leaves_data_untouched(make_app, sample_entities, tmp_path): + config_data = {**_CONFIG, "lists": {"stale": ["light.old"]}} + app = make_app(entities=sample_entities, config_data=config_data) + + export_dir = tmp_path / "export" + export_dir.mkdir() + files = { + "lists/kitchen.list.json": { + "hatty_list": 1, + "name": "Kitchen", + "entities": ["light.a"], + "manual": False, + "notify": False, + }, + } + backup_module.write_export(export_dir, files, ["lists"]) + + async with app.run_test() as pilot: + screen = await _open_backup(app, pilot) + screen.query_one("#cfg_backup_path", Input).value = str(export_dir) + sections = screen.query_one("#cfg_backup_sections", SelectionList) + sections.deselect_all() + sections.select("lists") + + screen.action_backup_import() + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + assert "stale" in app.entity_lists + assert "Kitchen" not in app.entity_lists + + +async def test_init_pull_push_status_buttons_drive_git_sync(make_app, sample_entities, tmp_path, monkeypatch): + from hatty import git_sync + + calls = [] + + def fake_run_git(args, cwd, timeout=None): + calls.append(args) + if args[:3] == ["diff", "--cached", "--quiet"]: + return (1, "", "") + if args[0] == "remote": + return (0, "origin\n", "") + if args[:2] == ["symbolic-ref", "--quiet"]: + return (0, "main\n", "") + return (0, "", "") + + monkeypatch.setattr(git_sync, "_run_git", fake_run_git) + + app = make_app(entities=sample_entities, config_data=_CONFIG) + async with app.run_test() as pilot: + screen = await _open_backup(app, pilot) + screen.query_one("#cfg_backup_path", Input).value = str(tmp_path) + + await screen.run_worker(screen._do_backup_init(str(tmp_path))).wait() + await pilot.pause() + assert any(c[0] == "init" for c in calls) + + await screen.run_worker(screen._do_backup_pull(str(tmp_path), False)).wait() + await pilot.pause() + assert any(c[0] == "pull" for c in calls) + + await screen.run_worker(screen._do_backup_push(str(tmp_path))).wait() + await pilot.pause() + assert any(c[0] == "push" for c in calls) + + await screen.run_worker(screen._do_backup_status_check(str(tmp_path))).wait() + await pilot.pause() + assert any(c[:2] == ["rev-parse", "--show-toplevel"] for c in calls) + + +async def test_git_buttons_require_path_first(make_app, sample_entities): + app = make_app(entities=sample_entities, config_data=_CONFIG) + async with app.run_test() as pilot: + screen = await _open_backup(app, pilot) + screen.query_one("#cfg_backup_path", Input).value = "" + + screen.action_backup_init() + await pilot.pause() + status = screen.query_one("#cfg_backup_status") + assert "directory first" in str(status.content) From bbc04e771379914d2b49a443de99b85668546bf1 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:50:28 -0500 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=93=9D=20Document=20Backup=20&=20Sy?= =?UTF-8?q?nc=20in=20CLAUDE.md=20and=20config.example.yaml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 32 ++++++++++++++++++++++++++------ config.example.yaml | 13 +++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b4c99a9..27f172f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,12 +43,28 @@ shared by `HACLI`'s docked panel, `GraphPreviewScreen`'s, and `DashboardScreen`' (`app.keys_ctl` — owns the user's keybinding overrides and pushes the resulting keymap onto the running app via `App.set_keymap`; every screen's `BINDINGS` is `bindings_for(scope)` from this module's `REGISTRY`, the single source of truth for all ~220 bindings in the app, rebindable from -Configuration ▸ Keybindings). Like `const.py`/`types.py`, `keybindings.py`'s registry half is -cycle-safe (no `hatty.ui`/`hatty.main` imports) since it's imported at class-definition time by -every screen module. **`HACLI` keeps its old attribute surface via property pairs** -(`app.dashboards`, `app.current_list_name`, `app._detail_entity_id`, …) so screens and tests -read/assign through the app unchanged; new UI code should call controllers directly instead -(`self.app.dash_ctl.set_slot(...)`). +Configuration ▸ Keybindings), `backup.py` (`app.backup_ctl` — Backup & Sync: owns the export-scope +and git prefs, drives `backup.py`/`git_sync.py` against the app's live collections, and fires +pull-on-start / the exit-time commit-and-push). Like `const.py`/`types.py`, `keybindings.py`'s +registry half is cycle-safe (no `hatty.ui`/`hatty.main` imports) since it's imported at +class-definition time by every screen module. **`HACLI` keeps its old attribute surface via +property pairs** (`app.dashboards`, `app.current_list_name`, `app._detail_entity_id`, …) so screens +and tests read/assign through the app unchanged; new UI code should call controllers directly +instead (`self.app.dash_ctl.set_slot(...)`). + +**Single-object export/import.** Lists, dashboards, and saved graphs each have a matching pair of +controller methods — `to_export_payload(name)` / `import_from_payload(payload)` on +`ListController`/`DashboardController`/`GraphController` — producing one small versioned JSON file +per object (`{"hatty_list": 1, ...}` / `{"hatty_dashboard": 1, ...}` / `{"hatty_graph": 1, ...}`), +reachable from each object's popup (`x`/`i`). `src/hatty/backup.py`'s directory export (Configuration +▸ Backup & Sync) is built entirely out of these same payloads — one file per object under +`lists/`/`dashboards/`/`graphs/` plus a handful of whole-collection files (`entity_names.json`, +`settings.json`, `keybindings.json`) and a `hatty-backup.json` manifest — so a file written by one +path is always readable by the other, and dropping a hand-exported object into the backup directory +just works. `src/hatty/git_sync.py` is a separate, git-agnostic layer that shells out to the `git` +CLI (hardened against credential prompts and hangs — see its module docstring) to optionally treat +that directory as a repo; neither module imports the other's caller, `controllers/backup.py` wires +them together. **Two-tier config persistence.** `config.yaml` is lean — connection settings and display preferences only. The user-data collections (`lists`, `entity_names`, `dashboards`, `saved_graphs`, @@ -77,6 +93,10 @@ params typed as `Entity` (not bare `dict`) and read `total=False` fields via `.g - `src/hatty/config.py` / `storage.py` — YAML config and SQLite collection persistence. - `src/hatty/const.py` / `types.py` / `service_calls.py` — shared constants, entity TypedDicts, and the pure per-domain functions that build `call_service` data for entity controls. +- `src/hatty/backup.py` — Backup & Sync's directory export/import: builds/writes/reads the JSON + files described above, no git involved. +- `src/hatty/git_sync.py` — the git CLI layer for Backup & Sync: init/commit/pull/push over the + export directory, every invocation non-interactive and time-bounded. - `src/hatty/ui/` — screens and popups, one module per surface (entity table, dashboard grid + widgets, device/area tree, graph panel/fullscreen/preview, per-domain control screens, config, onboarding). Each module's own docstring is the source of truth for its behavior — read the file diff --git a/config.example.yaml b/config.example.yaml index c6adaf6..a303873 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -17,3 +17,16 @@ log_hours: 24 # keybindings: # log.toggle: "A" # nav.back: "backspace" +# Backup & Sync, also editable from Configuration > Backup & Sync in the app. +# Exports lists/dashboards/saved_graphs/entity_names/settings/keybindings as a +# directory of JSON files under "path" (never the token above or the ntfy +# password); optionally treats that directory as a git repo. +# backup: +# path: "/home/you/hatty-backup" +# sections: ["lists", "dashboards", "saved_graphs", "entity_names", "settings", "keybindings"] +# git_enabled: true +# pull_on_start: true +# import_on_pull: true +# commit_on_exit: true +# push_on_exit: true +# pull_rebase: false From e8a18895cdcc80c68d54c751d07f1424d6cc9b54 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:34:21 -0500 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=93=9D=20Advertise=20Backup=20&=20S?= =?UTF-8?q?ync=20in=20the=20README=20feature=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b92a7ce..93037a5 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Your whole smart home, live, in a terminal. hatty connects to Home Assistant ove - Dedicated live-apply controls for lights, media players, and other entity attributes - Sensor/thermostat history as sparklines or a fullscreen graph, with multi-entity comparison and saved configs - Customizable **dashboards** of widgets — graphs, gauges, thermostats, panels — freely resized and split across tiles +- **Backup & Sync** — export lists, dashboards, saved graphs, entity-name overrides, and settings as a directory of small JSON files, one per object, plus a manifest — pick which sections to include, from Configuration ▸ Backup & Sync. Any single list/dashboard/graph can also be exported/imported on its own from its popup (`x`/`i`), in the exact same format, so a hand-shared file just drops into the backup directory. Optionally track that directory as a git repo, with pull-on-start, commit/push-on-exit, and manual init/pull/push — all non-interactive, never prompting for git credentials or hanging on a stalled network ## Demo From 816f632d21c0be2d8fbf428b4a9abad082e4ebe9 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:27:18 -0500 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=90=9B=20Don't=20bump=20exported=5F?= =?UTF-8?q?at=20when=20an=20export=20changes=20nothing=20else?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_export left the manifest's exported_at/hatty_version untouched whenever no data file actually changed, so a no-op export (e.g. at exit with nothing edited) no longer stages a git diff — commit_on_exit/ push_on_exit stop creating a commit for just the clock moving. --- src/hatty/backup.py | 38 ++++++++++++++++++++----- tests/test_backup_git.py | 58 ++++++++++++++++++++++++++++++++++++++- tests/unit/test_backup.py | 31 +++++++++++++++++---- 3 files changed, 114 insertions(+), 13 deletions(-) diff --git a/src/hatty/backup.py b/src/hatty/backup.py index 1c2183c..0871721 100644 --- a/src/hatty/backup.py +++ b/src/hatty/backup.py @@ -194,28 +194,52 @@ def _prune_stale(directory: Path, files: dict[str, dict], sections: Sequence[str return removed +#: Manifest keys that change on every export regardless of content, so they're +#: excluded when deciding whether an export actually changed anything. +_VOLATILE_MANIFEST_KEYS = ("exported_at", "hatty_version") + + +def _manifest_content_equal(a: dict, b: dict) -> bool: + def _strip(d: dict) -> dict: + return {k: v for k, v in d.items() if k not in _VOLATILE_MANIFEST_KEYS} + + return _strip(a) == _strip(b) + + def write_export(directory: Path, files: dict[str, dict], sections: Sequence[str]) -> tuple[list[str], list[str]]: """Write `files` (from `build_files`) under `directory`, merge the manifest patch over any existing manifest, and prune object files in the exported sections whose object no longer exists. Only rewrites files whose - content changed, so git diffs stay minimal. Returns (written, removed), - both relative paths.""" + content changed, so git diffs stay minimal — including the manifest's + `exported_at`/`hatty_version`, which only move when something else in the + export actually did, so a no-op export (nothing but the clock) never looks + like a change to git and never triggers a commit/push on exit. Returns + (written, removed), both relative paths.""" sections = _validate_sections(sections) directory = Path(directory) directory.mkdir(parents=True, exist_ok=True) os.chmod(directory, 0o700) + data_files = {k: v for k, v in files.items() if k != MANIFEST_FILENAME} + written = [path for path, payload in data_files.items() if _write_json_if_changed(directory / path, payload)] + removed = _prune_stale(directory, files, sections) + existing_manifest = _read_manifest_tolerant(directory) manifest_patch = files.get(MANIFEST_FILENAME, {}) manifest = {**existing_manifest, **manifest_patch} manifest["sections"] = sorted(set(existing_manifest.get("sections") or []) | set(sections)) manifest["hatty_backup"] = BACKUP_FORMAT_VERSION - manifest["exported_at"] = datetime.now(timezone.utc).isoformat() - manifest["hatty_version"] = __version__ - to_write = {**files, MANIFEST_FILENAME: manifest} - written = [path for path, payload in to_write.items() if _write_json_if_changed(directory / path, payload)] - removed = _prune_stale(directory, files, sections) + if written or removed or not _manifest_content_equal(existing_manifest, manifest): + manifest["exported_at"] = datetime.now(timezone.utc).isoformat() + manifest["hatty_version"] = __version__ + else: + manifest["exported_at"] = existing_manifest.get("exported_at", datetime.now(timezone.utc).isoformat()) + manifest["hatty_version"] = existing_manifest.get("hatty_version", __version__) + + if _write_json_if_changed(directory / MANIFEST_FILENAME, manifest): + written.append(MANIFEST_FILENAME) + return written, removed diff --git a/tests/test_backup_git.py b/tests/test_backup_git.py index bf8e53c..28cfe98 100644 --- a/tests/test_backup_git.py +++ b/tests/test_backup_git.py @@ -11,7 +11,8 @@ import pytest -from hatty import git_sync +from hatty import backup, git_sync +from hatty.controllers.lists import ListController pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git not installed") @@ -148,3 +149,58 @@ def test_pull_ff_only_success(tmp_path): ok, msg = git_sync.pull(str(work_b)) assert ok, msg assert '"hatty_backup": 2' in (work_b / "hatty-backup.json").read_text() + + +class _StubNotifyCtl: + def __init__(self): + self.notify_lists: set[str] = set() + + +class _StubApp: + """Just enough of the app surface for ListController.to_export_payload and + backup.build_files/write_export to run against real files on disk.""" + + def __init__(self, path): + self.app_config = {} + self.notify_ctl = _StubNotifyCtl() + self.list_ctl = ListController(self) + self.list_ctl.list_names = ["Kitchen"] + self.list_ctl.entity_lists = {"Kitchen": ["light.a"]} + self._path = path + + def persist(self, *_keys): + pass + + def notify(self, *_a, **_kw): + pass + + +def test_commit_on_exit_creates_no_commit_when_only_the_export_date_changed(tmp_path): + # The actual bug this guards against: write_export used to bump the + # manifest's exported_at on every call, so re-exporting *identical* data + # still staged a change and commit_all would create a pointless commit on + # every quit, even when nothing the user did actually changed anything. + work = tmp_path / "work" + work.mkdir() + git_sync.init_repo(str(work)) + app = _StubApp(work) + + written, _removed = backup.write_export(work, backup.build_files(app, ["lists"]), ["lists"]) + assert written # first export always writes something + ok, msg = git_sync.commit_all(str(work), "first") + assert ok, msg + log = subprocess.run(["git", "log", "--oneline"], cwd=work, capture_output=True, text=True, check=True) + commit_count = len(log.stdout.splitlines()) + assert commit_count == 1 + + # Re-export the exact same data — nothing the user changed. + written_again, removed_again = backup.write_export(work, backup.build_files(app, ["lists"]), ["lists"]) + assert written_again == [] + assert removed_again == [] + + ok, msg = git_sync.commit_all(str(work), "second") + assert ok, msg + assert "Nothing to commit" in msg + + log = subprocess.run(["git", "log", "--oneline"], cwd=work, capture_output=True, text=True, check=True) + assert len(log.stdout.splitlines()) == commit_count # no new commit was created diff --git a/tests/unit/test_backup.py b/tests/unit/test_backup.py index a067f7b..314c7d1 100644 --- a/tests/unit/test_backup.py +++ b/tests/unit/test_backup.py @@ -152,11 +152,32 @@ def test_write_export_only_rewrites_changed_files(tmp_path): files = backup.build_files(app, ["lists"]) backup.write_export(tmp_path, files, ["lists"]) - written_again, _removed = backup.write_export(tmp_path, files, ["lists"]) - # Nothing changed since the last export (aside from exported_at in the - # manifest, which always changes) -> the object file is skipped. - assert "lists/kitchen.list.json" not in written_again - assert backup.MANIFEST_FILENAME in written_again + written_again, removed_again = backup.write_export(tmp_path, files, ["lists"]) + # Truly nothing changed -> nothing is rewritten, not even the manifest's + # exported_at/hatty_version, so an unattended exit-time export never looks + # like a change to git and never triggers a pointless commit. + assert written_again == [] + assert removed_again == [] + + +def test_write_export_manifest_bumps_exported_at_only_when_something_changed(tmp_path): + app = _app() + app.list_ctl.list_names = ["Kitchen"] + app.list_ctl.entity_lists = {"Kitchen": []} + backup.write_export(tmp_path, backup.build_files(app, ["lists"]), ["lists"]) + first_manifest = json.loads((tmp_path / backup.MANIFEST_FILENAME).read_text()) + + # A no-op export leaves the manifest byte-identical, including exported_at. + backup.write_export(tmp_path, backup.build_files(app, ["lists"]), ["lists"]) + assert json.loads((tmp_path / backup.MANIFEST_FILENAME).read_text()) == first_manifest + + # A real change (a new list) does bump it. + app.list_ctl.list_names.append("Office") + app.list_ctl.entity_lists["Office"] = [] + written, _removed = backup.write_export(tmp_path, backup.build_files(app, ["lists"]), ["lists"]) + assert backup.MANIFEST_FILENAME in written + second_manifest = json.loads((tmp_path / backup.MANIFEST_FILENAME).read_text()) + assert second_manifest["exported_at"] != first_manifest["exported_at"] def test_write_export_prunes_deleted_object_but_leaves_other_sections(tmp_path): From 7d96d5dd52d6e5aee266ab469601daa534688ea6 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz <13026379+iTerminate@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:45:57 -0500 Subject: [PATCH 10/10] =?UTF-8?q?=E2=9C=A8=20Show=20per-phase=20status=20d?= =?UTF-8?q?uring=20the=20exit-time=20git=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExitSyncScreen (and the SIGINT fallback in cli.py) now show Exporting…/Committing…/Pushing… as each step runs instead of one static "Syncing with git…" for the whole duration, so a slow exit says what it's actually waiting on. --- src/hatty/cli.py | 9 +++-- src/hatty/controllers/backup.py | 28 ++++++++++++-- src/hatty/ui/exit_sync_screen.py | 7 +++- tests/unit/test_backup_controller.py | 55 ++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 9 deletions(-) diff --git a/src/hatty/cli.py b/src/hatty/cli.py index 2995891..1ee0721 100644 --- a/src/hatty/cli.py +++ b/src/hatty/cli.py @@ -53,15 +53,18 @@ def _flush_pending_exit_sync(app) -> None: from hatty import git_sync try: + print("hatty: exporting backup…") ok, msg = app.backup_ctl.export_now() if not ok: print(f"hatty: backup export failed: {msg}") return path = app.backup_ctl.prefs.get("path") or "" message = git_sync.default_commit_message() - print("hatty: syncing backup with git…") - op = git_sync.commit_and_push if app.backup_ctl.prefs.get("push_on_exit") else git_sync.commit_all - ok, msg = op(path, message) + print("hatty: committing…") + ok, msg = git_sync.commit_all(path, message) + if ok and app.backup_ctl.prefs.get("push_on_exit"): + print("hatty: pushing…") + ok, msg = git_sync.push(path) print(f"hatty: {msg}") except Exception as e: print(f"hatty: backup sync failed: {e}") diff --git a/src/hatty/controllers/backup.py b/src/hatty/controllers/backup.py index b8a76c7..3c61fe3 100644 --- a/src/hatty/controllers/backup.py +++ b/src/hatty/controllers/backup.py @@ -6,7 +6,7 @@ mirroring KeybindingController.""" import asyncio -from collections.abc import Sequence +from collections.abc import Callable, Sequence from pathlib import Path from hatty import backup as backup_module @@ -211,20 +211,40 @@ async def pull_on_start(self) -> None: title = "Backup Imported" if ok else "Backup Import Failed" app.notify(msg, title=title, severity="information" if ok else "error") - async def sync_on_exit(self, timeout: float = 75.0) -> tuple[bool, str]: + async def sync_on_exit( + self, status: Callable[[str], None] | None = None, timeout: float = 75.0 + ) -> tuple[bool, str]: # 75s: room for git_sync's own NETWORK_TIMEOUT (60s) on the push plus a # buffer for the local commit and export, as a belt-and-suspenders cap # so a stalled network can't hang the exit-sync overlay indefinitely. + # `status`, if given, is called before each phase — ExitSyncScreen + # passes its own label so a slow push doesn't look identical to a + # slow commit (issue: show what's happening during a slow exit). if not self.exit_sync_pending(): return True, "" path = self.prefs.get("path") or "" + + def _status(text: str) -> None: + if status is not None: + status(text) + + _status("Exporting…") ok, msg = self.export_now() if not ok: return False, msg message = git_sync.default_commit_message() - op = git_sync.commit_and_push_async if self.prefs.get("push_on_exit") else git_sync.commit_all_async + push_on_exit = bool(self.prefs.get("push_on_exit")) + + async def _run() -> tuple[bool, str]: + _status("Committing…") + ok, msg = await git_sync.commit_all_async(path, message) + if not ok or not push_on_exit: + return ok, msg + _status("Pushing…") + return await git_sync.push_async(path) + try: - return await asyncio.wait_for(op(path, message), timeout=timeout) + return await asyncio.wait_for(_run(), timeout=timeout) except asyncio.TimeoutError: return False, "Timed out syncing with git." diff --git a/src/hatty/ui/exit_sync_screen.py b/src/hatty/ui/exit_sync_screen.py index ccbc510..5a81fc3 100644 --- a/src/hatty/ui/exit_sync_screen.py +++ b/src/hatty/ui/exit_sync_screen.py @@ -81,8 +81,11 @@ async def _sync(self) -> None: try: self._set_status("Saving…") await self.app.drain_bg_tasks(timeout=5.0) - self._set_status("Syncing with git…") - ok, msg = await self.app.backup_ctl.sync_on_exit() + # sync_on_exit calls this back before each of its own phases + # (Exporting…/Committing…/Pushing…), so the overlay always shows + # what's actually happening instead of one static message for + # however long the whole thing takes. + ok, msg = await self.app.backup_ctl.sync_on_exit(status=self._set_status) if msg: self._set_status(msg) if not ok: diff --git a/tests/unit/test_backup_controller.py b/tests/unit/test_backup_controller.py index 8b35f98..a7c3390 100644 --- a/tests/unit/test_backup_controller.py +++ b/tests/unit/test_backup_controller.py @@ -320,6 +320,61 @@ async def test_sync_on_exit_returns_export_failure_without_touching_git(monkeypa assert called == [] +async def test_sync_on_exit_reports_commit_only_phases(monkeypatch, tmp_path): + ctl, app = _controller(path=str(tmp_path), sections=["lists"], git_enabled=True, commit_on_exit=True) + app.list_ctl.list_names = [] + + async def _fake_commit(_path, _message): + return True, "Committed." + + monkeypatch.setattr("hatty.git_sync.commit_all_async", _fake_commit) + + phases = [] + ok, msg = await ctl.sync_on_exit(status=phases.append) + assert ok is True + assert msg == "Committed." + assert phases == ["Exporting…", "Committing…"] # push_on_exit is off + + +async def test_sync_on_exit_reports_push_phase_when_enabled(monkeypatch, tmp_path): + ctl, app = _controller(path=str(tmp_path), sections=["lists"], git_enabled=True, push_on_exit=True) + app.list_ctl.list_names = [] + + async def _fake_commit(_path, _message): + return True, "Committed." + + async def _fake_push(_path): + return True, "Pushed to the remote." + + monkeypatch.setattr("hatty.git_sync.commit_all_async", _fake_commit) + monkeypatch.setattr("hatty.git_sync.push_async", _fake_push) + + phases = [] + ok, msg = await ctl.sync_on_exit(status=phases.append) + assert ok is True + assert msg == "Pushed to the remote." + assert phases == ["Exporting…", "Committing…", "Pushing…"] + + +async def test_sync_on_exit_skips_push_phase_when_commit_fails(monkeypatch, tmp_path): + ctl, app = _controller(path=str(tmp_path), sections=["lists"], git_enabled=True, push_on_exit=True) + app.list_ctl.list_names = [] + + async def _fake_commit(_path, _message): + return False, "git rejected the credentials." + + push_called = [] + monkeypatch.setattr("hatty.git_sync.commit_all_async", _fake_commit) + monkeypatch.setattr("hatty.git_sync.push_async", lambda *a: push_called.append(a)) + + phases = [] + ok, msg = await ctl.sync_on_exit(status=phases.append) + assert ok is False + assert "credentials" in msg + assert phases == ["Exporting…", "Committing…"] + assert push_called == [] + + async def test_pull_on_start_noop_when_disabled(): ctl, app = _controller(path="/tmp/x", git_enabled=True, pull_on_start=False) await ctl.pull_on_start()