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.
- exegete/__init__.py +16 -0
- exegete/coder_comparison.py +208 -0
- exegete/cursors.py +218 -0
- exegete/database.py +12621 -0
- exegete/env_settings.py +174 -0
- exegete/memo_privacy.py +215 -0
- exegete/names.py +68 -0
- exegete/new_project.py +1027 -0
- exegete/preview_tokens.py +613 -0
- exegete/project_settings.py +1082 -0
- exegete/pseudonymise.py +2796 -0
- exegete/refi_export.py +645 -0
- exegete/server.py +18899 -0
- exegete/sessions.py +1153 -0
- exegete/state_folder.py +360 -0
- exegete/transition.py +1049 -0
- exegete-0.14.1a0.dev1.dist-info/METADATA +515 -0
- exegete-0.14.1a0.dev1.dist-info/RECORD +24 -0
- exegete-0.14.1a0.dev1.dist-info/WHEEL +5 -0
- exegete-0.14.1a0.dev1.dist-info/entry_points.txt +2 -0
- exegete-0.14.1a0.dev1.dist-info/licenses/COPYING.LESSER +165 -0
- exegete-0.14.1a0.dev1.dist-info/licenses/NOTICE +773 -0
- exegete-0.14.1a0.dev1.dist-info/licenses/legal/GPL-3.0.txt +674 -0
- exegete-0.14.1a0.dev1.dist-info/top_level.txt +1 -0
exegete/state_folder.py
ADDED
|
@@ -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."))
|