exegete 0.14.1a0.dev1__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.
@@ -0,0 +1,360 @@
1
+ # SPDX-License-Identifier: LGPL-3.0-or-later
2
+ """The state folder, and its one move from ~/.qualcoder_mcp (v0.14.1).
3
+
4
+ The state folder holds the preview-token key (which also seals every
5
+ pseudonymisation run record), the AI coding sessions awaiting review,
6
+ the last-project hint and the run records. It was ~/.qualcoder_mcp and
7
+ is ~/.exegete from 0.14.1.
8
+
9
+ `move` runs once per real start, from `main()` only (never at import,
10
+ never for --version), after every start-up refusal:
11
+
12
+ - ~/.exegete absent and ~/.qualcoder_mcp a folder: ONE `os.rename` of
13
+ the whole folder (the same volume, so it is atomic, modes are kept and
14
+ nothing is copied: the move never copies or duplicates the key), then
15
+ a link under the old name so that a copy still on 0.14 on the same
16
+ computer uses the same folder: a relative symbolic link on macOS and
17
+ Linux (it survives a home folder carried to another computer or user
18
+ name), a directory junction on Windows (which needs neither Developer
19
+ Mode nor administrator rights, as a symbolic link would).
20
+ - The link cannot be made: if something now exists at the old path (an
21
+ older copy wrote there between the rename and the link, or another
22
+ start made the link), both are left as they are, and the folder stays
23
+ moved (that older copy's new folder has a key of its own, which this
24
+ server never uses); otherwise the folder is renamed straight back, and
25
+ the next start tries again.
26
+ - The rename fails (a file held open on Windows, permissions): this run
27
+ uses ~/.qualcoder_mcp, nothing is copied, the next start tries again.
28
+ - Both exist and the old one is a real folder: only session files the
29
+ new folder lacks are moved (never overwriting); the old key, hint and
30
+ records stay where they are. The log says so once for that folder, not
31
+ at every start (a small note in the new folder remembers it), and
32
+ again only when a later start moves a session file.
33
+ - The old path is already a link to the new folder: nothing to do. A
34
+ link there that leads to no folder (most often the one this server
35
+ left, after ~/.exegete was removed by hand) is left as it is, and a
36
+ fresh, empty ~/.exegete is made, which such a link reaches again:
37
+ renamed, it would have become a ~/.exegete that is a link to itself,
38
+ where no key could ever be made. Any other link there is moved as it
39
+ is.
40
+ """
41
+
42
+ import hashlib
43
+ import os
44
+ import subprocess
45
+ from pathlib import Path
46
+ from typing import NamedTuple, Optional
47
+
48
+ from . import names
49
+
50
+ # The folder chosen for this run when the move could not be made (the
51
+ # old one), or None: the new one.
52
+ _this_run: Optional[Path] = None
53
+
54
+
55
+ class MoveResult(NamedTuple):
56
+ """Where this run keeps its state, what happened, and the one log
57
+ line to write (None when there is nothing to say)."""
58
+ state_home: Path
59
+ outcome: str
60
+ message: Optional[str]
61
+
62
+
63
+ def new_path(home: Optional[Path] = None) -> Path:
64
+ return Path(home if home is not None else Path.home()) / \
65
+ names.STATE_FOLDER
66
+
67
+
68
+ def old_path(home: Optional[Path] = None) -> Path:
69
+ return Path(home if home is not None else Path.home()) / \
70
+ names.OLD_STATE_FOLDER
71
+
72
+
73
+ def current() -> Path:
74
+ """The state folder for this run: the new one, unless the move
75
+ failed at this run's start."""
76
+ return _this_run if _this_run is not None else new_path()
77
+
78
+
79
+ def use_for_this_run(path: Optional[Path]) -> None:
80
+ global _this_run
81
+ _this_run = None if path is None else Path(path)
82
+
83
+
84
+ def other() -> Path:
85
+ """The one of the two folders this run does not use: ~/.qualcoder_mcp,
86
+ or ~/.exegete in a run whose move could not be made. The guards
87
+ refuse it as well as this run's own, so both are refused in every
88
+ run."""
89
+ return old_path() if _this_run is None else new_path()
90
+
91
+
92
+ def shown() -> str:
93
+ """This run's state folder as a message names it: `~/.exegete`, or
94
+ `~/.qualcoder_mcp` in a run whose move could not be made."""
95
+ return f"~/{current().name}"
96
+
97
+
98
+ def in_this_run(text: str) -> str:
99
+ """A message that points at the state folder, naming the one this
100
+ run uses (it is written naming ~/.exegete)."""
101
+ here = shown()
102
+ usual = f"~/{names.STATE_FOLDER}"
103
+ return text if here == usual else text.replace(usual, here)
104
+
105
+
106
+ # A small note in the new folder that the log has already said the old
107
+ # path is a folder of its own (or a link elsewhere), so that the warning
108
+ # is not repeated at every start. It holds a digest of what identifies
109
+ # that old path (the folder's device and inode, or the link's target),
110
+ # never a path or a name; a different old folder is said again.
111
+ NOTE_FILE = "old_folder_noted"
112
+
113
+
114
+ def _identity(old: Path) -> Optional[str]:
115
+ try:
116
+ if is_link(old):
117
+ what = "link " + os.readlink(old)
118
+ else:
119
+ st = os.stat(old)
120
+ what = f"folder {st.st_dev} {st.st_ino}"
121
+ except (OSError, ValueError):
122
+ return None
123
+ return hashlib.sha256(
124
+ what.encode("utf-8", "surrogateescape")).hexdigest()
125
+
126
+
127
+ def _already_said(new: Path, old: Path) -> bool:
128
+ identity = _identity(old)
129
+ note = new / NOTE_FILE
130
+ if identity is None or is_link(note):
131
+ return False
132
+ try:
133
+ with open(note, "r", encoding="ascii") as f:
134
+ return f.read(200).strip() == identity
135
+ except (OSError, ValueError):
136
+ return False
137
+
138
+
139
+ def _note_said(new: Path, old: Path) -> bool:
140
+ """Remember that the log has said it; False when it could not be
141
+ written (the next start then says it again)."""
142
+ identity = _identity(old)
143
+ if identity is None:
144
+ return False
145
+ flags = (os.O_WRONLY | os.O_CREAT | os.O_TRUNC
146
+ | getattr(os, "O_NOFOLLOW", 0))
147
+ try:
148
+ descriptor = os.open(new / NOTE_FILE, flags, 0o600)
149
+ with os.fdopen(descriptor, "w", encoding="ascii") as f:
150
+ f.write(identity + "\n")
151
+ except OSError:
152
+ return False
153
+ return True
154
+
155
+
156
+ ONCE = " Later starts do not repeat this."
157
+
158
+
159
+ def is_link(path: Path) -> bool:
160
+ """A symbolic link, or on Windows a junction (or another reparse
161
+ point), rather than a real folder."""
162
+ if os.path.islink(path):
163
+ return True
164
+ isjunction = getattr(os.path, "isjunction", None) # Python 3.12
165
+ if isjunction is not None and isjunction(path):
166
+ return True
167
+ if os.name == "nt":
168
+ try:
169
+ attributes = getattr(os.lstat(path), "st_file_attributes", 0)
170
+ except OSError:
171
+ return False
172
+ return bool(attributes & 0x400) # FILE_ATTRIBUTE_REPARSE_POINT
173
+ return False
174
+
175
+
176
+ def same_folder(first: Path, second: Path) -> bool:
177
+ try:
178
+ return os.path.normcase(os.path.realpath(first)) == \
179
+ os.path.normcase(os.path.realpath(second))
180
+ except (OSError, ValueError):
181
+ return False
182
+
183
+
184
+ def _make_junction(target: Path, link: Path) -> None:
185
+ """A directory junction at `link` to `target` (absolute, as a
186
+ junction must be): CPython's own `_winapi.CreateJunction`, which it
187
+ has but does not document, else `mklink /J`."""
188
+ try:
189
+ import _winapi
190
+ _winapi.CreateJunction(str(target), str(link))
191
+ return
192
+ except (ImportError, AttributeError):
193
+ pass
194
+ except OSError:
195
+ if os.path.lexists(link):
196
+ raise
197
+ result = subprocess.run(["cmd", "/c", "mklink", "/J", str(link),
198
+ str(target)], capture_output=True)
199
+ if result.returncode != 0 or not os.path.lexists(link):
200
+ raise OSError(f"mklink /J failed ({result.returncode})")
201
+
202
+
203
+ def make_link(new: Path, old: Path, windows: Optional[bool] = None) -> None:
204
+ """The link under the old name, pointing at the new folder."""
205
+ windows = (os.name == "nt") if windows is None else windows
206
+ if windows:
207
+ _make_junction(new, old)
208
+ else:
209
+ # Relative: `.exegete`, beside the link, in the home folder
210
+ os.symlink(new.name, str(old), target_is_directory=True)
211
+
212
+
213
+ def move_missing_sessions(old: Path, new: Path,
214
+ windows: Optional[bool] = None) -> int:
215
+ """Move the session files the new folder lacks from the old one;
216
+ never overwrite one. How many were moved."""
217
+ windows = (os.name == "nt") if windows is None else windows
218
+ source = old / "sessions"
219
+ target = new / "sessions"
220
+ if not source.is_dir() or is_link(source):
221
+ return 0
222
+ moved = 0
223
+ for path in sorted(source.glob("session_*.json")):
224
+ if is_link(path) or not path.is_file():
225
+ continue
226
+ destination = target / path.name
227
+ if os.path.lexists(destination):
228
+ continue
229
+ try:
230
+ target.mkdir(parents=True, exist_ok=True)
231
+ if windows:
232
+ os.rename(path, destination) # refuses an existing target
233
+ else:
234
+ os.link(path, destination) # refuses an existing target
235
+ os.unlink(path)
236
+ except OSError:
237
+ continue
238
+ moved += 1
239
+ return moved
240
+
241
+
242
+ def _names(home: Optional[Path]):
243
+ return (f"~/{names.STATE_FOLDER}", f"~/{names.OLD_STATE_FOLDER}")
244
+
245
+
246
+ def _leave_a_link_to_nothing(new: Path, old: Path, new_name: str,
247
+ old_name: str) -> MoveResult:
248
+ """~/.exegete absent and ~/.qualcoder_mcp a link that leads to no
249
+ folder: most often the link this server left, after ~/.exegete was
250
+ removed by hand. Renamed, it would become a ~/.exegete that is a
251
+ link to itself (or to nothing), where no key could ever be made. So
252
+ it is left as it is, and a fresh, empty ~/.exegete is made, owner-only,
253
+ which a link naming it then reaches again. Nothing is moved."""
254
+ try:
255
+ os.mkdir(new, 0o700)
256
+ except OSError:
257
+ pass # made by another start, or left to the first write
258
+ if os.path.isdir(new) and not is_link(new) and same_folder(old, new):
259
+ return MoveResult(new, "old link led nowhere", (
260
+ f"{old_name} was a link to {new_name}, which did not exist "
261
+ f"(removed by hand?); the link was left as it is, and a fresh, "
262
+ f"empty {new_name} was made, which it reaches again. Nothing "
263
+ f"was moved."))
264
+ said = os.path.isdir(new) and _note_said(new, old)
265
+ return MoveResult(new, "old link led nowhere", (
266
+ f"{old_name} is a link that leads to no folder; it was left as it "
267
+ f"is, nothing was moved, and this server uses a fresh {new_name}."
268
+ + (ONCE if said else "")))
269
+
270
+
271
+ def move(home: Optional[Path] = None,
272
+ windows: Optional[bool] = None) -> MoveResult:
273
+ """Move the old state folder to the new one, once (module docstring).
274
+
275
+ Nothing here removes, copies or rewrites a file of the state: a
276
+ folder is renamed (or renamed back), a link is made, an empty folder
277
+ is made, session files the new folder lacks are moved one by one, or
278
+ the small note that a warning was given is written (NOTE_FILE). The
279
+ log lines name no path beyond the two folders' names in the home
280
+ folder.
281
+ """
282
+ new, old = new_path(home), old_path(home)
283
+ new_name, old_name = _names(home)
284
+ if os.path.lexists(new):
285
+ if not os.path.lexists(old):
286
+ return MoveResult(new, "in place", None)
287
+ if is_link(old):
288
+ if same_folder(old, new):
289
+ return MoveResult(new, "linked", None)
290
+ if _already_said(new, old):
291
+ return MoveResult(new, "old path is another link", None)
292
+ return MoveResult(new, "old path is another link", (
293
+ f"{old_name} is a link, but not to {new_name} (to another "
294
+ f"folder, or to none); it was left as it is, and this "
295
+ f"server uses {new_name}."
296
+ + (ONCE if _note_said(new, old) else "")))
297
+ if os.path.isdir(old):
298
+ count = move_missing_sessions(old, new, windows)
299
+ moved = (f"{count} session file(s) {new_name} lacked were moved "
300
+ f"from {old_name}")
301
+ if _already_said(new, old):
302
+ return MoveResult(new, "both", None if not count else (
303
+ f"{moved}, where an older copy of the server may still "
304
+ f"be writing; nothing else there was touched."))
305
+ return MoveResult(new, "both", (
306
+ f"Both {new_name} and {old_name} are folders: an older copy "
307
+ f"of the server, or a restore, may have made the second one, "
308
+ f"and any secret in it is that copy's own, which this server "
309
+ f"does not use. This server uses {new_name}; {moved}, and "
310
+ f"nothing else there was touched or overwritten. INSTALL.md's "
311
+ f"troubleshooting says what to do."
312
+ + (ONCE if _note_said(new, old) else "")))
313
+ return MoveResult(new, "old path is not a folder", None)
314
+ if not os.path.lexists(old):
315
+ return MoveResult(new, "fresh", None)
316
+ if is_link(old) and not os.path.isdir(old):
317
+ return _leave_a_link_to_nothing(new, old, new_name, old_name)
318
+ if not os.path.isdir(old):
319
+ return MoveResult(new, "old path is not a folder", None)
320
+ try:
321
+ os.rename(old, new)
322
+ except OSError as error:
323
+ if os.path.isdir(new) and (not os.path.lexists(old)
324
+ or same_folder(old, new)):
325
+ # Another start moved it a moment ago
326
+ return MoveResult(new, "moved by another start", None)
327
+ use_old = f"{old_name} could not be renamed to {new_name}"
328
+ return MoveResult(old, "rename failed", (
329
+ f"{use_old} ({type(error).__name__}); this run uses "
330
+ f"{old_name} as before, nothing was copied, and the next start "
331
+ f"tries again."))
332
+ try:
333
+ make_link(new, old, windows)
334
+ except OSError as error:
335
+ if os.path.lexists(old):
336
+ if is_link(old) and same_folder(old, new):
337
+ return MoveResult(new, "moved", None)
338
+ return MoveResult(new, "moved, old path taken", (
339
+ f"The state folder was moved to {new_name}, but something "
340
+ f"was written to {old_name} before the link could be made "
341
+ f"(an older copy of the server?). Both are kept and nothing "
342
+ f"was overwritten; the next start moves the session files "
343
+ f"{new_name} lacks."))
344
+ try:
345
+ os.rename(new, old)
346
+ except OSError:
347
+ return MoveResult(new, "moved, no link", (
348
+ f"The state folder was moved to {new_name}, but neither a "
349
+ f"link under {old_name} could be made ({type(error).__name__}) "
350
+ f"nor the folder put back. This server uses {new_name}; an "
351
+ f"older copy of the server would not find it."))
352
+ return MoveResult(old, "link failed, put back", (
353
+ f"A link under {old_name} could not be made "
354
+ f"({type(error).__name__}), so the state folder was put back "
355
+ f"there; this run uses {old_name}, and the next start tries "
356
+ f"again."))
357
+ return MoveResult(new, "moved", (
358
+ f"The state folder was moved from {old_name} to {new_name}, whole, "
359
+ f"and a link was left under the old name for older copies of the "
360
+ f"server."))