|
| 1 | +"""Shared file handling for the JSON config stores. |
| 2 | +
|
| 3 | +`PresetStore` and `CronStore` are the same problem twice: a list of |
| 4 | +dataclasses in one JSON file under the user's config dir, edited by dialogs |
| 5 | +that address entries by index. This holds the parts that are identical - the |
| 6 | +atomic write, and picking up a file a *second qtxterm instance* has written. |
| 7 | +
|
| 8 | +On the second instance: each process loads its config once at launch and |
| 9 | +keeps it in memory, so without this a job added in one window is invisible to |
| 10 | +the other, and the next save from the stale window writes its old list over |
| 11 | +the new one. Polling closes most of that gap - see `reload_if_changed`. |
| 12 | +""" |
| 13 | + |
| 14 | +from __future__ import annotations |
| 15 | + |
| 16 | +import json |
| 17 | +import os |
| 18 | +from contextlib import contextmanager |
| 19 | +from pathlib import Path |
| 20 | + |
| 21 | +from PySide6.QtCore import QObject, Signal |
| 22 | + |
| 23 | +# What we compare to decide the file changed under us. mtime alone is too |
| 24 | +# coarse on filesystems with a 1-2s timestamp granularity (FAT, some network |
| 25 | +# shares), where two writes in the same second look identical; the size |
| 26 | +# catches the common case of those two writes differing in length. |
| 27 | +_Stamp = tuple[int, int] | None |
| 28 | + |
| 29 | + |
| 30 | +class ConfigStore(QObject): |
| 31 | + """A JSON list on disk, reloaded when another process rewrites it. |
| 32 | +
|
| 33 | + Subclasses own the shape of the payload (`_apply_payload` / |
| 34 | + `_build_payload`) and what an absent file means (`_apply_missing`); this |
| 35 | + class owns the bytes. |
| 36 | + """ |
| 37 | + |
| 38 | + changed = Signal() |
| 39 | + |
| 40 | + def __init__(self, path: Path) -> None: |
| 41 | + super().__init__() |
| 42 | + self.path = path |
| 43 | + self._stamp: _Stamp = None |
| 44 | + self._suspended = 0 |
| 45 | + |
| 46 | + # -- subclass hooks ---------------------------------------------------- |
| 47 | + |
| 48 | + def _apply_payload(self, raw: list) -> None: |
| 49 | + raise NotImplementedError |
| 50 | + |
| 51 | + def _build_payload(self) -> list: |
| 52 | + raise NotImplementedError |
| 53 | + |
| 54 | + def _apply_missing(self) -> None: |
| 55 | + """Called by load() when the file does not exist yet.""" |
| 56 | + raise NotImplementedError |
| 57 | + |
| 58 | + # -- file handling ----------------------------------------------------- |
| 59 | + |
| 60 | + def load(self) -> None: |
| 61 | + if not self.path.exists(): |
| 62 | + self._stamp = None |
| 63 | + self._apply_missing() |
| 64 | + return |
| 65 | + self._apply_payload(json.loads(self.path.read_text(encoding="utf-8"))) |
| 66 | + self._stamp = self._current_stamp() |
| 67 | + |
| 68 | + def save(self) -> None: |
| 69 | + """Write the whole file, atomically, and announce it. |
| 70 | +
|
| 71 | + Atomically because another instance may be polling this exact file: |
| 72 | + a plain write is visible in its truncated middle, and the reader gets |
| 73 | + a JSONDecodeError instead of a config. `os.replace` means a reader |
| 74 | + sees either the old file or the new one. |
| 75 | + """ |
| 76 | + self.path.parent.mkdir(parents=True, exist_ok=True) |
| 77 | + text = json.dumps(self._build_payload(), indent=2) |
| 78 | + temp = self.path.with_name(f"{self.path.name}.{os.getpid()}.tmp") |
| 79 | + try: |
| 80 | + temp.write_text(text, encoding="utf-8") |
| 81 | + os.replace(temp, self.path) |
| 82 | + except OSError: |
| 83 | + temp.unlink(missing_ok=True) |
| 84 | + raise |
| 85 | + self._stamp = self._current_stamp() |
| 86 | + self.changed.emit() |
| 87 | + |
| 88 | + def reload_if_changed(self) -> bool: |
| 89 | + """Re-read if another process has rewritten the file. Did we reload? |
| 90 | +
|
| 91 | + Cheap enough to call on a timer: a stat, and a parse only when the |
| 92 | + stamp moved. Callers get `changed` exactly as if the edit had been |
| 93 | + made here, so every menu and sidebar already knows what to do with it. |
| 94 | +
|
| 95 | + Never raises. A file that is unreadable *now* - mid-write by an older |
| 96 | + qtxterm without the atomic save, half-synced by a cloud drive, or |
| 97 | + hand-edited into invalid JSON - leaves the in-memory list alone and |
| 98 | + is retried on the next poll. Throwing from a timer would take out the |
| 99 | + window over a file that is very likely fine a second later. |
| 100 | + """ |
| 101 | + if self._suspended: |
| 102 | + return False |
| 103 | + stamp = self._current_stamp() |
| 104 | + if stamp == self._stamp: |
| 105 | + return False |
| 106 | + if stamp is None: |
| 107 | + # Deleted out from under us. Keeping what we have is the kinder |
| 108 | + # reading: the file coming back is a reload, and until then the |
| 109 | + # user's presets do not evaporate mid-session. |
| 110 | + self._stamp = None |
| 111 | + return False |
| 112 | + try: |
| 113 | + raw = json.loads(self.path.read_text(encoding="utf-8")) |
| 114 | + except (OSError, ValueError): |
| 115 | + return False |
| 116 | + # Stamped only on success, so a failed read is retried rather than |
| 117 | + # remembered as done. |
| 118 | + self._apply_payload(raw) |
| 119 | + self._stamp = stamp |
| 120 | + self.changed.emit() |
| 121 | + return True |
| 122 | + |
| 123 | + def suspend_reload(self) -> None: |
| 124 | + """Hold off reloads while an editor is open over this store. |
| 125 | +
|
| 126 | + The editors address entries by index (`update(index, ...)`), and a |
| 127 | + modal dialog still runs the event loop, so a poll landing mid-edit |
| 128 | + would shift the list under the open form and save the user's changes |
| 129 | + onto whichever entry inherited that index. Counted rather than a |
| 130 | + flag, because more than one editor can be open over one store. |
| 131 | + """ |
| 132 | + self._suspended += 1 |
| 133 | + |
| 134 | + def resume_reload(self) -> None: |
| 135 | + self._suspended = max(0, self._suspended - 1) |
| 136 | + |
| 137 | + @contextmanager |
| 138 | + def reload_suspended(self): |
| 139 | + self.suspend_reload() |
| 140 | + try: |
| 141 | + yield |
| 142 | + finally: |
| 143 | + self.resume_reload() |
| 144 | + |
| 145 | + def _current_stamp(self) -> _Stamp: |
| 146 | + try: |
| 147 | + info = self.path.stat() |
| 148 | + except OSError: |
| 149 | + return None |
| 150 | + return (info.st_mtime_ns, info.st_size) |
0 commit comments