hoist-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hoist/config.py ADDED
@@ -0,0 +1,131 @@
1
+ """Persistent state for hoisted apps.
2
+
3
+ Several hoist processes can touch this file at once: one sync timer per app
4
+ (two apps on the same repo fire on the same push), the dashboard, and the CLI.
5
+ So every write goes to a private temp file and is renamed into place, the
6
+ previous good copy is kept as a backup, and read-modify-write sections take an
7
+ exclusive lock via `transaction()`. A file that fails to parse is never read
8
+ as "no apps": that would let the next save wipe every app.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import fcntl
15
+ import json
16
+ import os
17
+ import shutil
18
+ import threading
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+ from typing import Any, Iterator
22
+
23
+ STATE_DIR = Path(os.environ.get("HOIST_HOME", Path.home() / ".config" / "hoist"))
24
+ STATE_FILE = STATE_DIR / "apps.json"
25
+
26
+ _DEFAULT: dict[str, Any] = {"version": 1, "domain": None, "apps": {}}
27
+ _local = threading.local()
28
+
29
+
30
+ class StateError(Exception):
31
+ pass
32
+
33
+
34
+ def _fresh() -> dict[str, Any]:
35
+ return {"version": 1, "domain": None, "apps": {}}
36
+
37
+
38
+ def _backup() -> Path:
39
+ return STATE_FILE.with_name(STATE_FILE.name + ".bak")
40
+
41
+
42
+ def _parse(path: Path) -> dict[str, Any]:
43
+ data = json.loads(path.read_text())
44
+ if not isinstance(data, dict):
45
+ raise json.JSONDecodeError("state is not an object", "", 0)
46
+ for key, value in _DEFAULT.items():
47
+ data.setdefault(key, value if not isinstance(value, dict) else dict(value))
48
+ return data
49
+
50
+
51
+ def load() -> dict[str, Any]:
52
+ if not STATE_FILE.exists():
53
+ return _fresh()
54
+ try:
55
+ return _parse(STATE_FILE)
56
+ except (json.JSONDecodeError, OSError) as exc:
57
+ backup = _backup()
58
+ if backup.exists():
59
+ try:
60
+ return _parse(backup)
61
+ except (json.JSONDecodeError, OSError):
62
+ pass
63
+ raise StateError(
64
+ f"{STATE_FILE} is unreadable ({exc}) and there is no good backup; "
65
+ "fix or move it aside before running hoist again"
66
+ ) from exc
67
+
68
+
69
+ def save(state: dict[str, Any]) -> None:
70
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
71
+ tmp = STATE_FILE.with_name(f".{STATE_FILE.name}.{os.getpid()}.{threading.get_ident()}.tmp")
72
+ with open(tmp, "w") as fh:
73
+ fh.write(json.dumps(state, indent=2, sort_keys=True) + "\n")
74
+ fh.flush()
75
+ os.fsync(fh.fileno())
76
+ with _lock():
77
+ if STATE_FILE.exists():
78
+ try:
79
+ _parse(STATE_FILE) # only a good copy becomes the backup
80
+ shutil.copy2(STATE_FILE, _backup())
81
+ except (json.JSONDecodeError, OSError):
82
+ pass
83
+ os.replace(tmp, STATE_FILE)
84
+
85
+
86
+ @contextlib.contextmanager
87
+ def _lock() -> Iterator[None]:
88
+ """An exclusive, re-entrant (per thread) lock on the state directory."""
89
+ depth = getattr(_local, "depth", 0)
90
+ if depth:
91
+ _local.depth = depth + 1
92
+ try:
93
+ yield
94
+ finally:
95
+ _local.depth -= 1
96
+ return
97
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
98
+ fh = open(STATE_DIR / ".lock", "a+")
99
+ fcntl.flock(fh, fcntl.LOCK_EX)
100
+ _local.depth = 1
101
+ try:
102
+ yield
103
+ finally:
104
+ _local.depth = 0
105
+ fcntl.flock(fh, fcntl.LOCK_UN)
106
+ fh.close()
107
+
108
+
109
+ @contextlib.contextmanager
110
+ def transaction() -> Iterator[dict[str, Any]]:
111
+ """Load, let the caller change, and save -- with no other writer in between."""
112
+ with _lock():
113
+ state = load()
114
+ yield state
115
+ save(state)
116
+
117
+
118
+ def now() -> str:
119
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
120
+
121
+
122
+ def get_app(state: dict[str, Any], name: str) -> dict[str, Any] | None:
123
+ return state["apps"].get(name)
124
+
125
+
126
+ def put_app(state: dict[str, Any], app: dict[str, Any]) -> None:
127
+ state["apps"][app["name"]] = app
128
+
129
+
130
+ def drop_app(state: dict[str, Any], name: str) -> None:
131
+ state["apps"].pop(name, None)