cdx-manager 0.2.1

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.
@@ -0,0 +1,244 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ from contextlib import contextmanager
5
+ from pathlib import Path
6
+
7
+ from .errors import CdxError
8
+
9
+
10
+ def _ensure_dir(path):
11
+ Path(path).mkdir(parents=True, exist_ok=True)
12
+
13
+
14
+ def _read_json(file_path, fallback):
15
+ try:
16
+ with open(file_path, "r", encoding="utf-8") as f:
17
+ return json.load(f)
18
+ except FileNotFoundError:
19
+ return fallback
20
+ except json.JSONDecodeError as error:
21
+ raise CdxError(f"Corrupt JSON file: {file_path}") from error
22
+
23
+
24
+ def _write_json(file_path, value):
25
+ directory = os.path.dirname(file_path)
26
+ _ensure_dir(directory)
27
+ fd, temp_path = tempfile.mkstemp(prefix=f".{os.path.basename(file_path)}.", suffix=".tmp", dir=directory)
28
+ try:
29
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
30
+ json.dump(value, f, indent=2)
31
+ f.write("\n")
32
+ f.flush()
33
+ os.fsync(f.fileno())
34
+ os.replace(temp_path, file_path)
35
+ _fsync_directory(directory)
36
+ except Exception:
37
+ try:
38
+ os.unlink(temp_path)
39
+ except FileNotFoundError:
40
+ pass
41
+ raise
42
+
43
+
44
+ def _fsync_directory(directory):
45
+ try:
46
+ fd = os.open(directory, os.O_RDONLY)
47
+ except OSError:
48
+ return
49
+ try:
50
+ os.fsync(fd)
51
+ finally:
52
+ os.close(fd)
53
+
54
+
55
+ @contextmanager
56
+ def _file_lock(lock_path):
57
+ _ensure_dir(os.path.dirname(lock_path))
58
+ with open(lock_path, "a", encoding="utf-8") as lock:
59
+ try:
60
+ import fcntl
61
+ except ImportError as error:
62
+ raise CdxError("Session store locking requires fcntl on this platform") from error
63
+ try:
64
+ fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
65
+ except OSError as error:
66
+ raise CdxError(f"Failed to lock session store: {error}") from error
67
+ try:
68
+ yield
69
+ finally:
70
+ try:
71
+ fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
72
+ except OSError as error:
73
+ raise CdxError(f"Failed to unlock session store: {error}") from error
74
+
75
+
76
+ def create_session_store(base_dir):
77
+ store_file = os.path.join(base_dir, "sessions.json")
78
+ lock_file = os.path.join(base_dir, ".sessions.lock")
79
+ state_dir = os.path.join(base_dir, "state")
80
+
81
+ def _state_file_path(name):
82
+ return os.path.join(state_dir, f"{_encode(name)}.json")
83
+
84
+ def _encode(name):
85
+ from urllib.parse import quote
86
+ return quote(name, safe="")
87
+
88
+ def _load():
89
+ data = _read_json(store_file, {"version": 1, "sessions": []})
90
+ return data.get("sessions", [])
91
+
92
+ def _save(sessions):
93
+ _write_json(store_file, {"version": 1, "sessions": sessions})
94
+
95
+ def _read_session_state_unlocked(name):
96
+ return _read_json(_state_file_path(name), None)
97
+
98
+ def _write_session_state_unlocked(name, state):
99
+ _write_json(_state_file_path(name), state)
100
+
101
+ def _remove_session_state_unlocked(name):
102
+ try:
103
+ os.remove(_state_file_path(name))
104
+ except FileNotFoundError:
105
+ pass
106
+
107
+ def _default_state(session):
108
+ return {
109
+ "provider": session["provider"],
110
+ "status": "ready",
111
+ "rehydratedAt": None,
112
+ }
113
+
114
+ def list_sessions():
115
+ with _file_lock(lock_file):
116
+ return sorted(_load(), key=lambda session: session.get("name", ""))
117
+
118
+ def get_session(name):
119
+ with _file_lock(lock_file):
120
+ for s in _load():
121
+ if s.get("name") == name:
122
+ return s
123
+ return None
124
+
125
+ def add_session(session):
126
+ with _file_lock(lock_file):
127
+ sessions = _load()
128
+ if any(s.get("name") == session["name"] for s in sessions):
129
+ return {"ok": False, "session": None}
130
+ _write_session_state_unlocked(session["name"], _default_state(session))
131
+ sessions.append(session)
132
+ try:
133
+ _save(sessions)
134
+ except Exception:
135
+ _remove_session_state_unlocked(session["name"])
136
+ raise
137
+ return {"ok": True, "session": session}
138
+
139
+ def update_session(name, updater):
140
+ with _file_lock(lock_file):
141
+ sessions = _load()
142
+ for i, s in enumerate(sessions):
143
+ if s.get("name") == name:
144
+ sessions[i] = updater(s)
145
+ _save(sessions)
146
+ return sessions[i]
147
+ return None
148
+
149
+ def remove_session(name):
150
+ with _file_lock(lock_file):
151
+ sessions = _load()
152
+ for i, s in enumerate(sessions):
153
+ if s.get("name") == name:
154
+ removed = sessions.pop(i)
155
+ old_state = _read_session_state_unlocked(name)
156
+ _remove_session_state_unlocked(name)
157
+ try:
158
+ _save(sessions)
159
+ except Exception:
160
+ if old_state is not None:
161
+ _write_session_state_unlocked(name, old_state)
162
+ raise
163
+ return removed
164
+ return None
165
+
166
+ def rename_session(source_name, dest_name, updater):
167
+ with _file_lock(lock_file):
168
+ sessions = _load()
169
+ source_index = None
170
+ for i, s in enumerate(sessions):
171
+ if s.get("name") == source_name:
172
+ source_index = i
173
+ elif s.get("name") == dest_name:
174
+ return {"ok": False, "session": None, "reason": "exists"}
175
+ if source_index is None:
176
+ return {"ok": False, "session": None, "reason": "missing"}
177
+
178
+ updated = updater(sessions[source_index])
179
+ source_state_path = _state_file_path(source_name)
180
+ dest_state_path = _state_file_path(dest_name)
181
+ moved_state = False
182
+ try:
183
+ os.replace(source_state_path, dest_state_path)
184
+ moved_state = True
185
+ except FileNotFoundError:
186
+ pass
187
+ sessions[source_index] = updated
188
+ try:
189
+ _save(sessions)
190
+ except Exception:
191
+ if moved_state:
192
+ os.replace(dest_state_path, source_state_path)
193
+ raise
194
+ return {"ok": True, "session": updated, "reason": None}
195
+
196
+ def replace_session(name, session):
197
+ with _file_lock(lock_file):
198
+ sessions = _load()
199
+ old_state = _read_session_state_unlocked(name)
200
+ old_session = None
201
+ replaced = False
202
+ for i, existing in enumerate(sessions):
203
+ if existing.get("name") == name:
204
+ old_session = existing
205
+ sessions[i] = session
206
+ replaced = True
207
+ break
208
+ if not replaced:
209
+ sessions.append(session)
210
+ _write_session_state_unlocked(session["name"], _default_state(session))
211
+ try:
212
+ _save(sessions)
213
+ except Exception:
214
+ if old_state is None:
215
+ _remove_session_state_unlocked(session["name"])
216
+ else:
217
+ _write_session_state_unlocked(session["name"], old_state)
218
+ if old_session is not None:
219
+ for i, existing in enumerate(sessions):
220
+ if existing.get("name") == name:
221
+ sessions[i] = old_session
222
+ break
223
+ raise
224
+ return {"ok": True, "session": session, "replaced": replaced}
225
+
226
+ def read_session_state(name):
227
+ with _file_lock(lock_file):
228
+ return _read_session_state_unlocked(name)
229
+
230
+ def write_session_state(name, state):
231
+ with _file_lock(lock_file):
232
+ _write_session_state_unlocked(name, state)
233
+
234
+ return {
235
+ "list_sessions": list_sessions,
236
+ "get_session": get_session,
237
+ "add_session": add_session,
238
+ "update_session": update_session,
239
+ "remove_session": remove_session,
240
+ "rename_session": rename_session,
241
+ "replace_session": replace_session,
242
+ "read_session_state": read_session_state,
243
+ "write_session_state": write_session_state,
244
+ }