agent2learn 0.1.2__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.
Files changed (46) hide show
  1. agent2learn/__init__.py +3 -0
  2. agent2learn/_release.py +19 -0
  3. agent2learn/aipolicy.py +182 -0
  4. agent2learn/api.py +590 -0
  5. agent2learn/audit.py +358 -0
  6. agent2learn/auth/__init__.py +282 -0
  7. agent2learn/auth/cdp.py +1067 -0
  8. agent2learn/auth/paste.py +378 -0
  9. agent2learn/calendar.py +525 -0
  10. agent2learn/calibrate.py +347 -0
  11. agent2learn/check.py +1091 -0
  12. agent2learn/cli.py +2039 -0
  13. agent2learn/clock.py +39 -0
  14. agent2learn/config.py +205 -0
  15. agent2learn/console.py +229 -0
  16. agent2learn/convert.py +1223 -0
  17. agent2learn/doctor.py +1167 -0
  18. agent2learn/errors.py +32 -0
  19. agent2learn/ground.py +735 -0
  20. agent2learn/index.py +614 -0
  21. agent2learn/ingest.py +3229 -0
  22. agent2learn/locations.py +247 -0
  23. agent2learn/outlines.py +754 -0
  24. agent2learn/paths.py +683 -0
  25. agent2learn/pipeline.py +392 -0
  26. agent2learn/privacy.py +1123 -0
  27. agent2learn/schools/__init__.py +29 -0
  28. agent2learn/schools/_base.py +194 -0
  29. agent2learn/schools/generic.py +78 -0
  30. agent2learn/schools/uwaterloo.py +66 -0
  31. agent2learn/session.py +373 -0
  32. agent2learn/skills.py +1081 -0
  33. agent2learn/snapshot.py +399 -0
  34. agent2learn/submit.py +1047 -0
  35. agent2learn/transactions.py +157 -0
  36. agent2learn/upgrade.py +288 -0
  37. agent2learn/vault.py +1134 -0
  38. agent2learn-0.1.2.data/data/a2l-coursework/SKILL.md +52 -0
  39. agent2learn-0.1.2.data/data/a2l-setup/SKILL.md +27 -0
  40. agent2learn-0.1.2.data/data/a2l-study/SKILL.md +27 -0
  41. agent2learn-0.1.2.data/data/a2l-sync/SKILL.md +30 -0
  42. agent2learn-0.1.2.dist-info/METADATA +186 -0
  43. agent2learn-0.1.2.dist-info/RECORD +46 -0
  44. agent2learn-0.1.2.dist-info/WHEEL +4 -0
  45. agent2learn-0.1.2.dist-info/entry_points.txt +3 -0
  46. agent2learn-0.1.2.dist-info/licenses/LICENSE +202 -0
agent2learn/privacy.py ADDED
@@ -0,0 +1,1123 @@
1
+ """Preview-first, allowlisted removal of locally retained sensitive categories.
2
+
3
+ Privacy operations are intentionally more conservative than ordinary vault maintenance. This
4
+ module discovers exact managed files or exact structured records, validates the complete target
5
+ inventory, and only then permits a fresh interactive confirmation to install the change. It
6
+ never interprets arbitrary course text as permission to delete anything and it never delegates a
7
+ privacy purge to a recursive tree-removal helper.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ from collections.abc import Iterable, Mapping
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path, PurePosixPath
19
+ from typing import Any, Literal, cast
20
+
21
+ from agent2learn import config, console, paths
22
+ from agent2learn.errors import A2LError
23
+ from agent2learn.vault import Vault
24
+
25
+ Category = Literal["grades", "discussions", "logs"]
26
+ _CATEGORIES = frozenset({"grades", "discussions", "logs"})
27
+ _BACKUP_NAME = re.compile(r"^\.a2l-backup-v\d+(?:-\d+)?$")
28
+ _WINDOWS_ABSOLUTE = re.compile(r"^[A-Za-z]:[\\/]")
29
+ _GRADE_KEYS = frozenset({"grade", "grades", "my_grade", "my_grades", "grade_fields"})
30
+ _DISCUSSION_KEYS = frozenset({"discussion", "discussions", "forum", "forums"})
31
+ _KNOWN_LOG_NAMES = ("a2l.log", "a2l.log.1", "a2l.log.2", "a2l.log.3", "a2l.log.4")
32
+ _REVISION_FILENAME = "revision.json"
33
+ _INVENTORY_NAMES = ("privacy.json", "category-inventory.json")
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class PrivacyStatus:
38
+ """Redacted sensitive-category state suitable for terminal display."""
39
+
40
+ grades_enabled: bool
41
+ discussions_enabled: bool
42
+ grades_present: bool
43
+ discussions_present: bool
44
+ logs_present: bool
45
+ grade_location: str = "<vault>/<Term>/<Course>/_meta/my_grades.json and <vault>/.a2l/snapshots/"
46
+ discussion_location: str = "<vault>/<Term>/<Course>/discussions/ and <vault>/.a2l/private/"
47
+ log_location: str = "<configured log directory>/a2l.log[.1-.4]"
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class PurgeTarget:
52
+ """One exact planned file, record, or managed revision operation."""
53
+
54
+ path: Path
55
+ display: str
56
+ action: str
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class PurgePlan:
61
+ """A preview inventory bound to the exact bytes observed at discovery time."""
62
+
63
+ category: Category
64
+ targets: tuple[PurgeTarget, ...]
65
+ fingerprint: str
66
+ log_directory: Path | None = field(default=None, repr=False, compare=False)
67
+ _operations: tuple[_Operation, ...] = field(default=(), repr=False, compare=False)
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class PurgeResult:
72
+ """Mutation summary without echoing sensitive paths or values."""
73
+
74
+ category: Category
75
+ target_count: int
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class _StateRoot:
80
+ path: Path
81
+ display: str
82
+ trusted_root: Path
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class _Operation:
87
+ path: Path
88
+ action: Literal["unlink", "rewrite", "remove_tree", "rmdir"]
89
+ display: str
90
+ detail: str
91
+ payload: object | None = None
92
+ trusted_root: Path | None = None
93
+
94
+
95
+ def status(
96
+ vault: Vault,
97
+ cfg: config.Config,
98
+ *,
99
+ log_directory: Path | None = None,
100
+ ) -> PrivacyStatus:
101
+ """Return category flags and redacted retained-data locations without printing contents."""
102
+
103
+ if not paths.long_path(vault.root).is_dir():
104
+ return PrivacyStatus(
105
+ grades_enabled=cfg.include_grades,
106
+ discussions_enabled=cfg.include_discussions,
107
+ grades_present=False,
108
+ discussions_present=False,
109
+ logs_present=False,
110
+ )
111
+
112
+ grades = plan_purge(vault, "grades", log_directory=log_directory)
113
+ discussions = plan_purge(vault, "discussions", log_directory=log_directory)
114
+ logs = plan_purge(vault, "logs", log_directory=log_directory)
115
+ return PrivacyStatus(
116
+ grades_enabled=cfg.include_grades,
117
+ discussions_enabled=cfg.include_discussions,
118
+ grades_present=bool(grades.targets),
119
+ discussions_present=bool(discussions.targets),
120
+ logs_present=bool(logs.targets),
121
+ )
122
+
123
+
124
+ def render_status(value: PrivacyStatus) -> str:
125
+ """Render only policy state and structural redacted locations."""
126
+
127
+ def state(enabled: bool, present: bool) -> str:
128
+ collection = "enabled" if enabled else "disabled"
129
+ retained = "retained locally" if present else "not detected locally"
130
+ return f"{collection}; {retained}"
131
+
132
+ return "\n".join(
133
+ [
134
+ "Privacy status",
135
+ f"- grades: {state(value.grades_enabled, value.grades_present)}",
136
+ f" location: {value.grade_location}",
137
+ f"- discussions: {state(value.discussions_enabled, value.discussions_present)}",
138
+ f" location: {value.discussion_location}",
139
+ f"- logs: {'present' if value.logs_present else 'not detected'}",
140
+ f" location: {value.log_location}",
141
+ "",
142
+ ]
143
+ )
144
+
145
+
146
+ def plan_purge(
147
+ vault: Vault,
148
+ category: str,
149
+ *,
150
+ log_directory: Path | None = None,
151
+ ) -> PurgePlan:
152
+ """Discover and validate an exact allowlisted purge inventory without mutating anything."""
153
+
154
+ selected = _category(category)
155
+ _validate_vault_root(vault)
156
+ operations: list[_Operation] = []
157
+ if selected == "logs":
158
+ _discover_logs(operations, log_directory)
159
+ else:
160
+ states = _state_roots(vault)
161
+ _discover_course_artifacts(vault, selected, operations)
162
+ for state in states:
163
+ _discover_state_artifacts(vault, state, selected, operations)
164
+ operations = _deduplicate(operations)
165
+ return _make_plan(vault, selected, operations, log_directory=log_directory)
166
+
167
+
168
+ def execute_purge(
169
+ vault: Vault,
170
+ plan: PurgePlan,
171
+ *,
172
+ phrase: str,
173
+ interactive: bool,
174
+ log_directory: Path | None = None,
175
+ ) -> PurgeResult:
176
+ """Apply a preview only after an exact, action-specific interactive confirmation.
177
+
178
+ The plan is rediscovered after confirmation. Any changed target, including a changed JSON
179
+ file or a newly appearing allowlisted file, invalidates the confirmation instead of widening
180
+ the operation silently.
181
+ """
182
+
183
+ if not interactive:
184
+ raise A2LError("privacy purge requires an interactive terminal")
185
+ if not isinstance(phrase, str) or phrase != f"PURGE {plan.category.upper()}":
186
+ raise A2LError(f"privacy purge confirmation must be exactly PURGE {plan.category.upper()}")
187
+
188
+ refreshed = plan_purge(
189
+ vault,
190
+ plan.category,
191
+ log_directory=log_directory if log_directory is not None else plan.log_directory,
192
+ )
193
+ if refreshed.fingerprint != plan.fingerprint:
194
+ raise A2LError("privacy purge preview is stale; review a new preview")
195
+
196
+ if plan.category == "logs":
197
+ # Close only Agent2Learn's marked rotating handlers. In particular, do not configure a
198
+ # replacement handler after deleting the files: that would recreate a log immediately.
199
+ console.close_owned_handlers()
200
+ _apply(refreshed._operations)
201
+ return PurgeResult(plan.category, len(refreshed.targets))
202
+
203
+
204
+ def purge(
205
+ vault: Vault,
206
+ category: str,
207
+ *,
208
+ phrase: str | None = None,
209
+ interactive: bool = False,
210
+ log_directory: Path | None = None,
211
+ ) -> PurgePlan | PurgeResult:
212
+ """Return a preview by default, or execute it with the exact fresh confirmation phrase."""
213
+
214
+ plan = plan_purge(vault, category, log_directory=log_directory)
215
+ if phrase is None:
216
+ return plan
217
+ return execute_purge(
218
+ vault,
219
+ plan,
220
+ phrase=phrase,
221
+ interactive=interactive,
222
+ log_directory=log_directory,
223
+ )
224
+
225
+
226
+ def render_plan(plan: PurgePlan) -> str:
227
+ """Render an exact preview without reading or displaying target contents."""
228
+
229
+ lines = [f"Privacy purge preview · {plan.category}"]
230
+ if not plan.targets:
231
+ lines.append("- No retained files or records match this category.")
232
+ else:
233
+ lines.extend(f"- {target.display} — {target.action}" for target in plan.targets)
234
+ lines.extend(
235
+ [
236
+ "",
237
+ (
238
+ "Logical deletion only: filesystem snapshots, backups, and external copies "
239
+ "may retain data."
240
+ ),
241
+ f"To continue in a controlling terminal, type: PURGE {plan.category.upper()}",
242
+ ]
243
+ )
244
+ return "\n".join(lines) + "\n"
245
+
246
+
247
+ def _category(value: str) -> Category:
248
+ if not isinstance(value, str) or value not in _CATEGORIES:
249
+ raise A2LError("privacy category must be exactly grades, discussions, or logs")
250
+ return cast(Category, value)
251
+
252
+
253
+ def _validate_vault_root(vault: Vault) -> None:
254
+ root = vault.root
255
+ if paths.is_link(root):
256
+ raise A2LError("privacy purge refuses a symlinked vault root")
257
+ if not paths.long_path(root).is_dir():
258
+ raise A2LError("vault is unavailable; run: a2l init")
259
+ state = root / ".a2l"
260
+ if paths.is_link(state):
261
+ raise A2LError("privacy purge refuses a symlinked vault state directory")
262
+
263
+
264
+ def _state_roots(vault: Vault) -> tuple[_StateRoot, ...]:
265
+ result: list[_StateRoot] = []
266
+ current = vault.root / ".a2l"
267
+ if paths.long_path(current).is_dir():
268
+ _safe_path(current, vault.root)
269
+ result.append(_StateRoot(current, "<vault>/.a2l", vault.root))
270
+
271
+ result.extend(_backup_state_roots(vault.root, vault.root))
272
+ return tuple(result)
273
+
274
+
275
+ def _backup_state_roots(directory: Path, trusted_root: Path) -> list[_StateRoot]:
276
+ children = _directory_children(directory, "schema backup inventory")
277
+ result: list[_StateRoot] = []
278
+ for child in children:
279
+ if not _BACKUP_NAME.fullmatch(child.name):
280
+ continue
281
+ if paths.is_link(child):
282
+ raise A2LError("privacy purge refuses a symlinked schema backup")
283
+ if not paths.long_path(child).is_dir():
284
+ raise A2LError("schema backup path is not a directory")
285
+ state = child / ".a2l" if paths.long_path(child / ".a2l").is_dir() else child
286
+ _safe_path(child, trusted_root)
287
+ _safe_path(state, child)
288
+ display = "<schema backup>/.a2l" if state.name == ".a2l" else "<schema backup>"
289
+ result.append(_StateRoot(state, display, child))
290
+ return result
291
+
292
+
293
+ def _discover_course_artifacts(
294
+ vault: Vault,
295
+ category: Category,
296
+ operations: list[_Operation],
297
+ ) -> None:
298
+ root = vault.root
299
+ content_maps: list[Path] = []
300
+ for candidate in _named_paths(root, "content_map.json"):
301
+ if ".a2l" in candidate.parts:
302
+ continue
303
+ _safe_path(candidate, root)
304
+ content_maps.append(candidate)
305
+ course_dir = candidate.parent.parent
306
+ if category == "grades":
307
+ _add_unlink(
308
+ vault,
309
+ operations,
310
+ course_dir / "_meta" / "my_grades.json",
311
+ "remove grade projection",
312
+ )
313
+ else:
314
+ _add_unlink(
315
+ vault,
316
+ operations,
317
+ course_dir / "_meta" / "discussions.json",
318
+ "remove discussion projection",
319
+ )
320
+ discussion_dir = course_dir / "discussions"
321
+ if paths.is_link(discussion_dir):
322
+ raise A2LError("privacy purge refuses a symlinked discussions directory")
323
+ if paths.long_path(discussion_dir).is_dir():
324
+ _add_unlink(
325
+ vault,
326
+ operations,
327
+ discussion_dir / "discussions.md",
328
+ "remove generated discussion markdown",
329
+ )
330
+
331
+ if category == "discussions":
332
+ for discussion_dir in _discussion_directories(root):
333
+ if paths.is_link(discussion_dir):
334
+ raise A2LError("privacy purge refuses a symlinked discussions directory")
335
+ _add_discussion_paths_from_map(vault, operations, discussion_dir, content_maps)
336
+ _add_empty_directory_cleanup(vault, operations, discussion_dir)
337
+
338
+ for content_map in content_maps:
339
+ raw = _read_json(content_map, "content map")
340
+ topics = raw.get("topics")
341
+ if not isinstance(topics, list):
342
+ raise A2LError("content_map.json topics must be an array")
343
+ if category == "discussions":
344
+ kept = [topic for topic in topics if not _owned_row(topic, "discussions")]
345
+ if len(kept) != len(topics):
346
+ payload = dict(raw)
347
+ payload["topics"] = kept
348
+ _add_rewrite(
349
+ vault,
350
+ operations,
351
+ content_map,
352
+ payload,
353
+ "remove discussion content-map records",
354
+ )
355
+ elif any(_owned_row(topic, "grades") for topic in topics):
356
+ kept = [topic for topic in topics if not _owned_row(topic, "grades")]
357
+ payload = dict(raw)
358
+ payload["topics"] = kept
359
+ _add_rewrite(
360
+ vault, operations, content_map, payload, "remove grade content-map records"
361
+ )
362
+
363
+ # These projections are allowlisted by their structural location, not inferred from the
364
+ # presence of a content map. A damaged or hand-created course can still retain sensitive
365
+ # data, and privacy status/purge must not miss it merely because navigation metadata is absent.
366
+ projection_name = "my_grades.json" if category == "grades" else "discussions.json"
367
+ for projection in _named_paths(root, projection_name):
368
+ if ".a2l" in projection.parts or projection.parent.name != "_meta":
369
+ continue
370
+ _safe_path(projection, root)
371
+ _add_unlink(
372
+ vault,
373
+ operations,
374
+ projection,
375
+ "remove grade projection" if category == "grades" else "remove discussion projection",
376
+ )
377
+
378
+ for index_path in _named_paths(root, "INDEX.md"):
379
+ if ".a2l" in index_path.parts:
380
+ continue
381
+ _safe_path(index_path, root)
382
+ original = _read_text(index_path, "course index")
383
+ scrubbed = _scrub_index(original, category)
384
+ if scrubbed != original:
385
+ _add_rewrite(vault, operations, index_path, scrubbed, f"remove {category} index block")
386
+
387
+
388
+ def _discover_state_artifacts(
389
+ vault: Vault,
390
+ state: _StateRoot,
391
+ category: Category,
392
+ operations: list[_Operation],
393
+ ) -> None:
394
+ state_path = state.path
395
+ if paths.is_link(state_path):
396
+ raise A2LError("privacy purge refuses a symlinked managed state directory")
397
+ if not paths.long_path(state_path).is_dir():
398
+ return
399
+
400
+ if category == "grades":
401
+ _discover_snapshot_scrubs(vault, state, operations)
402
+ else:
403
+ _discover_discussion_private_files(vault, state, operations)
404
+
405
+ manifest = state_path / "manifest.json"
406
+ if paths.long_path(manifest).exists() or paths.is_link(manifest):
407
+ _safe_path(manifest, state.trusted_root)
408
+ raw = _read_json(manifest, "manifest")
409
+ _discover_manifest(vault, state, manifest, raw, category, operations)
410
+
411
+ for inventory_name in _INVENTORY_NAMES:
412
+ inventory = state_path / "private" / inventory_name
413
+ if paths.long_path(inventory).exists() or paths.is_link(inventory):
414
+ _safe_path(inventory, state.trusted_root)
415
+ raw = _read_json(inventory, "privacy inventory")
416
+ scrubbed = _scrub_inventory(raw, category)
417
+ if scrubbed != raw:
418
+ _add_rewrite(
419
+ vault,
420
+ operations,
421
+ inventory,
422
+ scrubbed,
423
+ f"remove {category} inventory",
424
+ trusted_root=state.trusted_root,
425
+ )
426
+
427
+ history = state_path / "history"
428
+ if paths.is_link(history):
429
+ raise A2LError("privacy purge refuses a symlinked managed history directory")
430
+ if paths.long_path(history).is_dir():
431
+ _reject_links_in_managed_directory(history, "history")
432
+ for revision in _named_paths(history, _REVISION_FILENAME):
433
+ _safe_path(revision, state.trusted_root)
434
+ raw = _read_json(revision, "revision metadata")
435
+ if _owned_record(raw, None, category):
436
+ _add_remove_tree(
437
+ vault,
438
+ operations,
439
+ revision.parent,
440
+ state.trusted_root,
441
+ f"remove {category} managed revision",
442
+ )
443
+
444
+
445
+ def _discover_snapshot_scrubs(
446
+ vault: Vault,
447
+ state: _StateRoot,
448
+ operations: list[_Operation],
449
+ ) -> None:
450
+ snapshots = state.path / "snapshots"
451
+ if paths.is_link(snapshots):
452
+ raise A2LError("privacy purge refuses a symlinked snapshot directory")
453
+ if not paths.long_path(snapshots).is_dir():
454
+ return
455
+ for snapshot_path in _json_files(snapshots):
456
+ _safe_path(snapshot_path, state.trusted_root)
457
+ raw = _read_json(snapshot_path, "snapshot")
458
+ scrubbed, changed = _scrub_snapshot(raw)
459
+ if changed:
460
+ _add_rewrite(
461
+ vault,
462
+ operations,
463
+ snapshot_path,
464
+ scrubbed,
465
+ "remove grades field",
466
+ trusted_root=state.trusted_root,
467
+ )
468
+
469
+
470
+ def _discover_discussion_private_files(
471
+ vault: Vault,
472
+ state: _StateRoot,
473
+ operations: list[_Operation],
474
+ ) -> None:
475
+ private = state.path / "private"
476
+ if paths.is_link(private):
477
+ raise A2LError("privacy purge refuses a symlinked private state directory")
478
+ key = private / "discussion-hmac.key"
479
+ if paths.long_path(key).exists() or paths.is_link(key):
480
+ _safe_path(key, state.trusted_root)
481
+ _add_unlink(
482
+ vault,
483
+ operations,
484
+ key,
485
+ "remove discussion pseudonym key",
486
+ trusted_root=state.trusted_root,
487
+ )
488
+
489
+
490
+ def _discover_manifest(
491
+ vault: Vault,
492
+ state: _StateRoot,
493
+ manifest: Path,
494
+ raw: dict[str, object],
495
+ category: Category,
496
+ operations: list[_Operation],
497
+ ) -> None:
498
+ entries = raw.get("entries")
499
+ if not isinstance(entries, dict):
500
+ raise A2LError("manifest entries must be an object")
501
+ kept: dict[str, object] = {}
502
+ changed = False
503
+ for key, entry in entries.items():
504
+ if not isinstance(key, str):
505
+ raise A2LError("manifest entry keys must be strings")
506
+ if _owned_record(entry, key, category):
507
+ changed = True
508
+ if isinstance(entry, Mapping):
509
+ for relative in _entry_paths(entry):
510
+ target = _relative_target(vault.root, relative)
511
+ if paths.long_path(target).exists() or paths.is_link(target):
512
+ _add_unlink(
513
+ vault,
514
+ operations,
515
+ target,
516
+ f"remove {category} manifest artifact",
517
+ trusted_root=vault.root,
518
+ )
519
+ continue
520
+ kept[key] = entry
521
+ if changed:
522
+ payload = dict(raw)
523
+ payload["entries"] = kept
524
+ _add_rewrite(
525
+ vault,
526
+ operations,
527
+ manifest,
528
+ payload,
529
+ f"remove {category} manifest records",
530
+ trusted_root=state.trusted_root,
531
+ )
532
+
533
+
534
+ def _discover_logs(operations: list[_Operation], log_directory: Path | None) -> None:
535
+ directory = (
536
+ Path(log_directory).expanduser()
537
+ if log_directory is not None
538
+ else Path(config.DIRS.user_log_path)
539
+ )
540
+ if paths.is_link(directory) or paths.has_link_component(directory):
541
+ raise A2LError("privacy log purge refuses a symlinked log directory")
542
+ if not paths.long_path(directory).is_dir():
543
+ return
544
+ for name in _KNOWN_LOG_NAMES:
545
+ candidate = directory / name
546
+ if paths.is_link(candidate):
547
+ raise A2LError("privacy log purge refuses a symlinked log file")
548
+ if paths.long_path(candidate).exists():
549
+ if not paths.long_path(candidate).is_file():
550
+ raise A2LError("privacy log purge found a non-file rotating log target")
551
+ _add_operation(
552
+ operations,
553
+ _Operation(
554
+ candidate,
555
+ "unlink",
556
+ f"<configured log directory>/{name}",
557
+ "remove rotating log",
558
+ trusted_root=directory,
559
+ ),
560
+ )
561
+
562
+
563
+ def _discussion_directories(root: Path) -> tuple[Path, ...]:
564
+ result: list[Path] = []
565
+ for candidate in _named_paths(root, "discussions"):
566
+ if paths.is_link(candidate):
567
+ raise A2LError("privacy purge refuses a symlinked discussions directory")
568
+ if candidate.parent.name == ".a2l":
569
+ continue
570
+ meta = candidate.parent / "_meta"
571
+ if paths.long_path(meta).is_dir() or paths.long_path(meta / "content_map.json").exists():
572
+ result.append(candidate)
573
+ return tuple(result)
574
+
575
+
576
+ def _add_discussion_paths_from_map(
577
+ vault: Vault,
578
+ operations: list[_Operation],
579
+ discussion_dir: Path,
580
+ content_maps: Iterable[Path],
581
+ ) -> None:
582
+ for content_map in content_maps:
583
+ if content_map.parent.parent != discussion_dir.parent:
584
+ continue
585
+ raw = _read_json(content_map, "content map")
586
+ topics = raw.get("topics")
587
+ if not isinstance(topics, list):
588
+ raise A2LError("content_map.json topics must be an array")
589
+ for topic in topics:
590
+ if not _owned_row(topic, "discussions") or not isinstance(topic, Mapping):
591
+ continue
592
+ for value in _entry_paths(topic):
593
+ if value:
594
+ target = _relative_target(vault.root, value)
595
+ _add_unlink(vault, operations, target, "remove discussion source or twin")
596
+
597
+
598
+ def _add_empty_directory_cleanup(
599
+ vault: Vault,
600
+ operations: list[_Operation],
601
+ directory: Path,
602
+ ) -> None:
603
+ _safe_path(directory, vault.root)
604
+ if not paths.long_path(directory).is_dir():
605
+ return
606
+ candidates = list(paths.walk(directory))
607
+ for candidate in candidates:
608
+ if paths.is_link(candidate):
609
+ raise A2LError("privacy purge refuses a symlinked discussions entry")
610
+
611
+ managed_files = {
612
+ operation.path
613
+ for operation in operations
614
+ if operation.action == "unlink" and operation.path.is_relative_to(directory)
615
+ }
616
+ removable: set[Path] = set()
617
+ directories = sorted(
618
+ (candidate for candidate in candidates if paths.long_path(candidate).is_dir()),
619
+ key=lambda value: len(value.relative_to(directory).parts),
620
+ reverse=True,
621
+ )
622
+ directories.append(directory)
623
+ for candidate in directories:
624
+ try:
625
+ children = _directory_children(candidate, "privacy discussions directory")
626
+ except OSError as exc:
627
+ raise A2LError("privacy discussions directory is unreadable") from exc
628
+ if all(child in managed_files or child in removable for child in children):
629
+ removable.add(candidate)
630
+ _add_operation(
631
+ operations,
632
+ _Operation(
633
+ candidate,
634
+ "rmdir",
635
+ _vault_display(vault, candidate),
636
+ "remove empty generated directory",
637
+ trusted_root=vault.root,
638
+ ),
639
+ )
640
+
641
+
642
+ def _scrub_snapshot(raw: dict[str, object]) -> tuple[dict[str, object], bool]:
643
+ courses = raw.get("courses")
644
+ if not isinstance(courses, list):
645
+ raise A2LError("snapshot courses must be a list")
646
+ payload = dict(raw)
647
+ new_courses: list[object] = []
648
+ changed = False
649
+ for course in courses:
650
+ if not isinstance(course, Mapping):
651
+ raise A2LError("snapshot courses must contain objects")
652
+ value = dict(course)
653
+ if "grades" in value:
654
+ value.pop("grades")
655
+ changed = True
656
+ new_courses.append(value)
657
+ if changed:
658
+ payload["courses"] = new_courses
659
+ return payload, changed
660
+
661
+
662
+ def _scrub_inventory(raw: dict[str, object], category: Category) -> dict[str, object]:
663
+ payload = dict(raw)
664
+ categories = payload.get("categories")
665
+ if isinstance(categories, list):
666
+ payload["categories"] = [
667
+ value for value in categories if not (_owned_record(value, None, category))
668
+ ]
669
+ elif isinstance(categories, dict):
670
+ payload["categories"] = {
671
+ key: value
672
+ for key, value in categories.items()
673
+ if not _owned_record(value, key, category)
674
+ }
675
+ return payload
676
+
677
+
678
+ def _scrub_index(text: str, category: Category) -> str:
679
+ lines = text.splitlines(keepends=True)
680
+ result: list[str] = []
681
+ inside = False
682
+ start_marker = f"<!-- a2l:{category}:start -->"
683
+ end_marker = f"<!-- a2l:{category}:end -->"
684
+ for line in lines:
685
+ lowered = line.casefold()
686
+ if start_marker in lowered:
687
+ inside = True
688
+ continue
689
+ if inside:
690
+ if end_marker in lowered:
691
+ inside = False
692
+ continue
693
+ if category == "grades" and re.match(r"^\s*[-*]\s+grades?\s*:", line, re.IGNORECASE):
694
+ continue
695
+ if category == "discussions" and (
696
+ "discussions/" in lowered
697
+ or re.match(r"^\s*#{1,3}\s+discussions?\s*$", line, re.IGNORECASE)
698
+ ):
699
+ continue
700
+ result.append(line)
701
+ return "".join(result)
702
+
703
+
704
+ def _owned_row(value: object, category: Category) -> bool:
705
+ if not isinstance(value, Mapping):
706
+ return False
707
+ return _owned_record(value, cast(str | None, value.get("source_key")), category)
708
+
709
+
710
+ def _owned_record(value: object, key: str | None, category: Category) -> bool:
711
+ if not isinstance(value, Mapping):
712
+ return False
713
+ markers: list[str] = []
714
+ for marker_key in ("category", "privacy_category", "kind", "source_key", "canonical_key"):
715
+ marker = value.get(marker_key)
716
+ if isinstance(marker, str):
717
+ markers.append(marker.casefold())
718
+ if key:
719
+ markers.append(key.casefold())
720
+ wanted = "grade" if category == "grades" else "discussion"
721
+ explicit_names = _GRADE_KEYS if category == "grades" else _DISCUSSION_KEYS
722
+ for marker in markers:
723
+ if marker in explicit_names or f":{wanted}:" in marker or f":{wanted}s:" in marker:
724
+ return True
725
+ path_values = _entry_paths(value)
726
+ if category == "grades":
727
+ return any(
728
+ PurePosixPath(path).name.casefold() in {"my_grades.json", "grades.json"}
729
+ for path in path_values
730
+ )
731
+ return any(_is_discussion_path(path) for path in path_values)
732
+
733
+
734
+ def _entry_paths(value: Mapping[str, object]) -> tuple[str, ...]:
735
+ result: list[str] = []
736
+ for key in ("path", "source_path", "stub_path"):
737
+ path = value.get(key)
738
+ if isinstance(path, str):
739
+ result.append(path)
740
+ derived = value.get("derived")
741
+ if isinstance(derived, Mapping):
742
+ for item in derived.values():
743
+ if isinstance(item, Mapping) and isinstance(item.get("path"), str):
744
+ result.append(cast(str, item["path"]))
745
+ return tuple(result)
746
+
747
+
748
+ def _is_discussion_path(value: str) -> bool:
749
+ try:
750
+ parts = PurePosixPath(value).parts
751
+ except (TypeError, ValueError):
752
+ return False
753
+ return "discussions" in {part.casefold() for part in parts}
754
+
755
+
756
+ def _relative_target(root: Path, value: str) -> Path:
757
+ if (
758
+ not isinstance(value, str)
759
+ or not value
760
+ or "\\" in value
761
+ or _WINDOWS_ABSOLUTE.match(value) is not None
762
+ ):
763
+ raise A2LError("privacy target path must be a relative POSIX path")
764
+ candidate = PurePosixPath(value)
765
+ if candidate.is_absolute() or ".." in candidate.parts or "." in candidate.parts:
766
+ raise A2LError("privacy target path escapes the vault")
767
+ path = root.joinpath(*candidate.parts)
768
+ _safe_path(path, root)
769
+ return path
770
+
771
+
772
+ def _named_paths(root: Path, name: str) -> tuple[Path, ...]:
773
+ if not paths.long_path(root).is_dir():
774
+ return ()
775
+ try:
776
+ return tuple(
777
+ sorted(
778
+ (candidate for candidate in paths.walk(root) if candidate.name == name),
779
+ key=os.fspath,
780
+ )
781
+ )
782
+ except OSError as exc:
783
+ raise A2LError("privacy target inventory is unreadable") from exc
784
+
785
+
786
+ def _json_files(root: Path) -> tuple[Path, ...]:
787
+ try:
788
+ candidates = list(paths.walk(root))
789
+ for candidate in candidates:
790
+ if paths.is_link(candidate):
791
+ raise A2LError("privacy purge refuses a symlinked JSON target")
792
+ return tuple(
793
+ sorted(
794
+ (
795
+ candidate
796
+ for candidate in candidates
797
+ if candidate.suffix.casefold() == ".json"
798
+ and not paths.long_path(candidate).is_dir()
799
+ ),
800
+ key=os.fspath,
801
+ )
802
+ )
803
+ except OSError as exc:
804
+ raise A2LError("privacy JSON inventory is unreadable") from exc
805
+
806
+
807
+ def _add_unlink(
808
+ vault: Vault,
809
+ operations: list[_Operation],
810
+ path: Path,
811
+ detail: str,
812
+ *,
813
+ trusted_root: Path | None = None,
814
+ ) -> None:
815
+ if not paths.long_path(path).exists() and not paths.is_link(path):
816
+ return
817
+ root = vault.root if trusted_root is None else trusted_root
818
+ _safe_path(path, root)
819
+ if not paths.long_path(path).is_file():
820
+ raise A2LError("privacy purge target is not a regular file")
821
+ _add_operation(
822
+ operations,
823
+ _Operation(path, "unlink", _display_path(vault, path, root), detail, trusted_root=root),
824
+ )
825
+
826
+
827
+ def _add_rewrite(
828
+ vault: Vault,
829
+ operations: list[_Operation],
830
+ path: Path,
831
+ payload: object,
832
+ detail: str,
833
+ *,
834
+ trusted_root: Path | None = None,
835
+ ) -> None:
836
+ root = vault.root if trusted_root is None else trusted_root
837
+ _safe_path(path, root)
838
+ if not paths.long_path(path).is_file():
839
+ raise A2LError("privacy JSON target is not a regular file")
840
+ _add_operation(
841
+ operations,
842
+ _Operation(
843
+ path,
844
+ "rewrite",
845
+ _display_path(vault, path, root),
846
+ detail,
847
+ payload,
848
+ root,
849
+ ),
850
+ )
851
+
852
+
853
+ def _add_remove_tree(
854
+ vault: Vault,
855
+ operations: list[_Operation],
856
+ path: Path,
857
+ trusted_root: Path,
858
+ detail: str,
859
+ ) -> None:
860
+ if not paths.long_path(path).is_dir():
861
+ raise A2LError("privacy managed revision is not a directory")
862
+ _validate_explicit_tree(path, trusted_root)
863
+ display = _display_path(vault, path, trusted_root)
864
+ _add_operation(
865
+ operations,
866
+ _Operation(path, "remove_tree", display, detail, trusted_root=trusted_root),
867
+ )
868
+
869
+
870
+ def _add_operation(operations: list[_Operation], operation: _Operation) -> None:
871
+ operations.append(operation)
872
+
873
+
874
+ def _deduplicate(operations: list[_Operation]) -> list[_Operation]:
875
+ result: list[_Operation] = []
876
+ seen: set[tuple[str, str]] = set()
877
+ for operation in operations:
878
+ identity = (os.fspath(operation.path), operation.action)
879
+ if identity in seen:
880
+ continue
881
+ seen.add(identity)
882
+ result.append(operation)
883
+ return sorted(result, key=lambda item: (item.action, os.fspath(item.path), item.detail))
884
+
885
+
886
+ def _make_plan(
887
+ vault: Vault,
888
+ category: Category,
889
+ operations: Iterable[_Operation],
890
+ *,
891
+ log_directory: Path | None,
892
+ ) -> PurgePlan:
893
+ selected = tuple(operations)
894
+ targets: list[PurgeTarget] = []
895
+ digest = hashlib.sha256()
896
+ for operation in selected:
897
+ trusted_root = operation.trusted_root
898
+ if trusted_root is None:
899
+ trusted_root = (
900
+ Path(log_directory).expanduser()
901
+ if category == "logs" and log_directory is not None
902
+ else Path(config.DIRS.user_log_path)
903
+ if category == "logs"
904
+ else vault.root
905
+ )
906
+ _safe_path(operation.path, trusted_root)
907
+ state = _object_fingerprint(operation.path)
908
+ digest.update(os.fspath(operation.path).encode("utf-8"))
909
+ digest.update(b"\0")
910
+ digest.update(operation.action.encode("utf-8"))
911
+ digest.update(b"\0")
912
+ digest.update(operation.detail.encode("utf-8"))
913
+ digest.update(b"\0")
914
+ digest.update(state.encode("ascii"))
915
+ digest.update(b"\0")
916
+ targets.append(PurgeTarget(operation.path, operation.display, operation.detail))
917
+ resolved_log_directory = (
918
+ Path(log_directory).expanduser()
919
+ if log_directory is not None
920
+ else Path(config.DIRS.user_log_path)
921
+ if category == "logs"
922
+ else None
923
+ )
924
+ return PurgePlan(
925
+ category,
926
+ tuple(targets),
927
+ digest.hexdigest(),
928
+ resolved_log_directory,
929
+ selected,
930
+ )
931
+
932
+
933
+ def _apply(operations: Iterable[_Operation]) -> None:
934
+ selected = tuple(operations)
935
+ rewrites = [operation for operation in selected if operation.action == "rewrite"]
936
+ for operation in rewrites:
937
+ trusted_root = operation.trusted_root or operation.path.parent
938
+ _safe_path(operation.path, trusted_root)
939
+ if not paths.long_path(operation.path).is_file():
940
+ raise A2LError("privacy JSON target changed before rewrite")
941
+ if isinstance(operation.payload, str):
942
+ text = operation.payload
943
+ else:
944
+ text = _canonical_json(operation.payload)
945
+ paths.atomic_write_text(operation.path, text, root=trusted_root)
946
+
947
+ for operation in sorted(
948
+ (item for item in selected if item.action == "remove_tree"),
949
+ key=lambda item: (len(item.path.parts), os.fspath(item.path)),
950
+ reverse=True,
951
+ ):
952
+ _remove_explicit_tree(operation.path, operation.trusted_root or operation.path.parent)
953
+ for operation in selected:
954
+ if operation.action == "unlink":
955
+ _unlink_exact(operation.path, operation.trusted_root or operation.path.parent)
956
+ for operation in sorted(
957
+ (item for item in selected if item.action == "rmdir"),
958
+ key=lambda item: (len(item.path.parts), os.fspath(item.path)),
959
+ reverse=True,
960
+ ):
961
+ _remove_empty_directory(operation.path, operation.trusted_root or operation.path.parent)
962
+
963
+
964
+ def _unlink_exact(path: Path, trusted_root: Path) -> None:
965
+ _safe_path(path, trusted_root)
966
+ if not paths.long_path(path).is_file():
967
+ raise A2LError("privacy purge target changed before removal")
968
+ os.unlink(os.fspath(paths.long_path(path)))
969
+
970
+
971
+ def _remove_empty_directory(path: Path, trusted_root: Path) -> None:
972
+ _safe_path(path, trusted_root)
973
+ if not paths.long_path(path).is_dir():
974
+ raise A2LError("privacy purge directory target changed before removal")
975
+ try:
976
+ os.rmdir(os.fspath(paths.long_path(path)))
977
+ except OSError as exc:
978
+ raise A2LError("privacy purge left an unexpected file in a managed directory") from exc
979
+
980
+
981
+ def _validate_explicit_tree(path: Path, trusted_root: Path) -> None:
982
+ _safe_path(path, trusted_root)
983
+ for candidate in paths.walk(path):
984
+ _safe_path(candidate, trusted_root)
985
+ if paths.is_link(candidate):
986
+ raise A2LError("privacy purge refuses a symlinked managed revision")
987
+ if not paths.long_path(candidate).is_file() and not paths.long_path(candidate).is_dir():
988
+ raise A2LError("privacy purge found an unsupported managed revision entry")
989
+
990
+
991
+ def _remove_explicit_tree(path: Path, trusted_root: Path) -> None:
992
+ _validate_explicit_tree(path, trusted_root)
993
+ candidates = list(paths.walk(path))
994
+ for candidate in sorted(
995
+ (item for item in candidates if paths.long_path(item).is_file()),
996
+ key=lambda item: len(item.relative_to(path).parts),
997
+ reverse=True,
998
+ ):
999
+ _unlink_exact(candidate, trusted_root)
1000
+ for candidate in sorted(
1001
+ (item for item in candidates if paths.long_path(item).is_dir()),
1002
+ key=lambda item: len(item.relative_to(path).parts),
1003
+ reverse=True,
1004
+ ):
1005
+ _remove_empty_directory(candidate, trusted_root)
1006
+ _remove_empty_directory(path, trusted_root)
1007
+
1008
+
1009
+ def _safe_path(path: Path, root: Path) -> None:
1010
+ if paths.is_link(path) or paths.has_link_component(path, root=root):
1011
+ raise A2LError("privacy purge refuses a symlinked target")
1012
+ absolute = Path(os.path.abspath(os.fspath(path)))
1013
+ trusted = Path(os.path.abspath(os.fspath(root)))
1014
+ try:
1015
+ absolute.relative_to(trusted)
1016
+ except ValueError as exc:
1017
+ raise A2LError("privacy target path escapes its trusted root") from exc
1018
+
1019
+
1020
+ def _object_fingerprint(path: Path) -> str:
1021
+ digest = hashlib.sha256()
1022
+ if paths.is_link(path):
1023
+ raise A2LError("privacy purge refuses a symlinked target")
1024
+ if paths.long_path(path).is_file():
1025
+ digest.update(b"file\0")
1026
+ with open(os.fspath(paths.long_path(path)), "rb") as handle:
1027
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
1028
+ digest.update(chunk)
1029
+ return digest.hexdigest()
1030
+ if paths.long_path(path).is_dir():
1031
+ digest.update(b"directory\0")
1032
+ for candidate in sorted(paths.walk(path), key=os.fspath):
1033
+ if paths.is_link(candidate):
1034
+ raise A2LError("privacy purge refuses a symlinked target")
1035
+ relative = candidate.relative_to(path).as_posix()
1036
+ digest.update(relative.encode("utf-8"))
1037
+ digest.update(b"\0")
1038
+ if paths.long_path(candidate).is_file():
1039
+ with open(os.fspath(paths.long_path(candidate)), "rb") as handle:
1040
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
1041
+ digest.update(chunk)
1042
+ digest.update(b"\0")
1043
+ return digest.hexdigest()
1044
+ raise A2LError("privacy purge target changed before preview")
1045
+
1046
+
1047
+ def _reject_links_in_managed_directory(directory: Path, label: str) -> None:
1048
+ try:
1049
+ for candidate in paths.walk(directory):
1050
+ if paths.is_link(candidate):
1051
+ raise A2LError(f"privacy purge refuses a symlinked managed {label} entry")
1052
+ except OSError as exc:
1053
+ raise A2LError(f"privacy {label} inventory is unreadable") from exc
1054
+
1055
+
1056
+ def _read_json(path: Path, label: str) -> dict[str, object]:
1057
+ try:
1058
+ with open(os.fspath(paths.long_path(path)), encoding="utf-8", newline="") as handle:
1059
+ raw: Any = json.load(handle)
1060
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
1061
+ raise A2LError(f"{label} is unreadable") from exc
1062
+ if not isinstance(raw, dict):
1063
+ raise A2LError(f"{label} must contain an object")
1064
+ return cast(dict[str, object], raw)
1065
+
1066
+
1067
+ def _read_text(path: Path, label: str) -> str:
1068
+ try:
1069
+ with open(os.fspath(paths.long_path(path)), encoding="utf-8", newline="") as handle:
1070
+ return handle.read()
1071
+ except (OSError, UnicodeError) as exc:
1072
+ raise A2LError(f"{label} is unreadable") from exc
1073
+
1074
+
1075
+ def _directory_children(directory: Path, label: str) -> list[Path]:
1076
+ try:
1077
+ with os.scandir(os.fspath(paths.long_path(directory))) as iterator:
1078
+ return sorted(
1079
+ (directory / entry.name for entry in iterator), key=lambda item: item.name
1080
+ )
1081
+ except OSError as exc:
1082
+ raise A2LError(f"{label} is unreadable") from exc
1083
+
1084
+
1085
+ def _canonical_json(value: object) -> str:
1086
+ return (
1087
+ json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2, separators=(",", ": "))
1088
+ + "\n"
1089
+ )
1090
+
1091
+
1092
+ def _vault_display(vault: Vault, path: Path) -> str:
1093
+ try:
1094
+ relative = path.relative_to(vault.root).as_posix()
1095
+ except ValueError:
1096
+ return "<schema backup>/managed state"
1097
+ return f"<vault>/{relative}"
1098
+
1099
+
1100
+ def _display_path(vault: Vault, path: Path, trusted_root: Path) -> str:
1101
+ if trusted_root == vault.root:
1102
+ return _vault_display(vault, path)
1103
+ if trusted_root == Path(config.DIRS.user_log_path):
1104
+ return f"<configured log directory>/{path.name}"
1105
+ try:
1106
+ relative = path.relative_to(trusted_root).as_posix()
1107
+ except ValueError:
1108
+ relative = path.name
1109
+ return f"<schema backup>/{relative}"
1110
+
1111
+
1112
+ __all__ = [
1113
+ "PrivacyStatus",
1114
+ "PurgePlan",
1115
+ "PurgeResult",
1116
+ "PurgeTarget",
1117
+ "execute_purge",
1118
+ "plan_purge",
1119
+ "purge",
1120
+ "render_plan",
1121
+ "render_status",
1122
+ "status",
1123
+ ]