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/submit.py ADDED
@@ -0,0 +1,1047 @@
1
+ """Upload one finished local file to a selected LEARN Dropbox, after the human confirms it.
2
+
3
+ This is the only mutating LEARN action in Agent2Learn, and it is disabled in this build. Two
4
+ independent gates stand in front of it, and neither is sufficient alone:
5
+
6
+ 1. ``a2l enable-submit`` — a one-time local acknowledgement that stores ``submit_enabled`` and
7
+ uploads nothing.
8
+ 2. A fresh, per-file confirmation typed at a controlling terminal, containing a random code shown
9
+ only in that preview and the exact filename.
10
+
11
+ On top of both, :data:`agent2learn._release.SUBMISSION_AVAILABLE` gates the whole feature at build
12
+ time. Nothing here reads an environment variable, accepts a ``--yes``/``--force`` style flag, or
13
+ consumes piped input as consent, and there is no automatic retry: one confirmation authorises at
14
+ most one POST, and the code is consumed whether that POST succeeds or fails.
15
+
16
+ **What the terminal gate does and does not prove.** A controlling TTY proves interactivity, not
17
+ human identity. Hostile software that already controls the terminal can synthesise keystrokes.
18
+ The gate prevents accidental and ordinary unattended mutation; what keeps an *agent* from
19
+ submitting on the student's behalf is the skill contract requiring it to stop at the preview and
20
+ hand back control. This is not cryptographic proof of a human action, and must never be described
21
+ as one.
22
+
23
+ **Only a proven route.** v0.1 uses the documented individual-student route
24
+ ``…/dropbox/folders/{folder}/submissions/mysubmissions/`` and nothing else. There is no
25
+ ``mypost``, and no fallback between mutating endpoints: an instance whose LE version does not
26
+ support the route family keeps submission disabled. A group Dropbox is recognised only so the
27
+ preview can name it and refuse before any confirmation.
28
+
29
+ **Verification, not optimism.** After the single POST the submissions list is read back and must
30
+ contain exactly one record matching the folder, filename, byte size, and a timestamp after the
31
+ confirmation. A stale prior file, a duplicate name, a size mismatch, an ambiguous list, or an
32
+ unreadable read-back is a failed verification that reports uncertainty and sends the student to
33
+ LEARN — it never retries.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import os
40
+ import re
41
+ import secrets
42
+ import stat
43
+ import string
44
+ import tempfile
45
+ from collections.abc import Iterator
46
+ from dataclasses import dataclass, replace
47
+ from datetime import UTC, datetime, timedelta
48
+ from hashlib import sha256
49
+ from pathlib import Path
50
+ from typing import Protocol
51
+
52
+ from agent2learn import _release, clock, config, paths
53
+ from agent2learn import index as course_index
54
+ from agent2learn.errors import A2LError
55
+ from agent2learn.vault import Vault
56
+
57
+ SUBMISSION_RECEIPT_VERSION = 1
58
+ CONFIRMATION_TTL_SECONDS = 300
59
+ # Built from its intent rather than written out: the ambiguous glyphs are excluded so a user
60
+ # reading a code aloud or off a screen cannot confuse I/1 or O/0. Writing the 32 characters as
61
+ # a literal also reads as a high-entropy secret to scanners, which it is not.
62
+ CODE_ALPHABET = "".join(
63
+ character for character in string.ascii_uppercase + string.digits if character not in "IO01"
64
+ )
65
+ CODE_LENGTH = 6
66
+ MIN_LE_VERSION = (1, 82)
67
+ STAGING_DIRNAME = "submit-staging"
68
+ RECEIPTS_DIRNAME = "submissions"
69
+
70
+ _CONTROL = re.compile(r"[\x00-\x1f\x7f]")
71
+ _FILENAME_ESCAPE = {'"': "%22", "\\": "%5C", "\r": "", "\n": ""}
72
+
73
+
74
+ class SubmissionRefused(A2LError):
75
+ """A submission was refused before any mutating request was attempted."""
76
+
77
+
78
+ class SubmissionUnverified(A2LError):
79
+ """A POST was attempted but read-back could not prove the exact expected record."""
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class SubmissionCapability:
84
+ """Explicit permission for this process to attempt a mutating upload."""
85
+
86
+ available: bool
87
+ reason: str
88
+
89
+
90
+ def release_capability() -> SubmissionCapability:
91
+ """Return the capability this build ships with."""
92
+
93
+ return SubmissionCapability(
94
+ available=_release.SUBMISSION_AVAILABLE,
95
+ reason="upload is disabled in this build until the supervised release gate passes",
96
+ )
97
+
98
+
99
+ class SubmissionTransport(Protocol):
100
+ """The narrow transport surface a submission needs."""
101
+
102
+ def get_json(self, path: str) -> object: ...
103
+
104
+ def get_json_once(self, path: str) -> object: ...
105
+
106
+ def post_once(
107
+ self, path: str, body: bytes | SubmissionBody, *, content_type: str
108
+ ) -> object: ...
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class SubmissionBody:
113
+ """A repeatable multipart body streamed from the private staging file.
114
+
115
+ The body exposes its exact byte length for the API client's explicit ``Content-Length`` while
116
+ keeping the potentially large file payload out of memory. Iteration is repeatable so a test
117
+ transport can inspect it and Requests can consume it once for the actual POST.
118
+ """
119
+
120
+ path: Path
121
+ filename: str
122
+ size: int
123
+ boundary: str
124
+ root: Path | None = None
125
+
126
+ @property
127
+ def content_length(self) -> int:
128
+ return len(self._prefix()) + self.size + len(self._suffix())
129
+
130
+ def __iter__(self) -> Iterator[bytes]:
131
+ yield self._prefix()
132
+ if self.root is not None and paths.has_link_component(self.path, root=self.root):
133
+ raise SubmissionRefused("the staged path contains a symlink or junction")
134
+ if paths.is_link(self.path):
135
+ raise SubmissionRefused("the staged path contains a symlink or junction")
136
+ descriptor = os.open(
137
+ os.fspath(paths.long_path(self.path)), os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
138
+ )
139
+ try:
140
+ file_stat = os.fstat(descriptor)
141
+ if not stat.S_ISREG(file_stat.st_mode) or getattr(file_stat, "st_nlink", 1) != 1:
142
+ raise SubmissionRefused("the staged file is no longer a private regular file")
143
+ with os.fdopen(descriptor, "rb") as handle:
144
+ descriptor = -1
145
+ yield from iter(lambda: handle.read(1024 * 1024), b"")
146
+ finally:
147
+ if descriptor >= 0:
148
+ os.close(descriptor)
149
+ yield self._suffix()
150
+
151
+ def _prefix(self) -> bytes:
152
+ safe = safe_filename(self.filename)
153
+ descriptor = json.dumps({"Text": "", "HTML": None}, separators=(",", ":"))
154
+ marker = f"--{self.boundary}".encode()
155
+ return (
156
+ b"\r\n".join(
157
+ [
158
+ marker,
159
+ b'Content-Disposition: form-data; name=""',
160
+ b"Content-Type: application/json",
161
+ b"",
162
+ descriptor.encode("utf-8"),
163
+ marker,
164
+ f'Content-Disposition: form-data; name=""; filename="{safe}"'.encode(),
165
+ b"Content-Type: application/octet-stream",
166
+ b"",
167
+ ]
168
+ )
169
+ + b"\r\n"
170
+ )
171
+
172
+ def _suffix(self) -> bytes:
173
+ return f"\r\n--{self.boundary}--\r\n".encode()
174
+
175
+
176
+ @dataclass(frozen=True)
177
+ class SubmissionTarget:
178
+ """A resolved Dropbox folder and the exact routes that would be used."""
179
+
180
+ course: str
181
+ course_code: str
182
+ org_unit_id: int
183
+ folder_id: int
184
+ folder_name: str
185
+ endpoint: str
186
+ readback: str
187
+ group_submission: bool
188
+ group_note: str | None
189
+ upload_verified: bool
190
+
191
+
192
+ @dataclass(frozen=True)
193
+ class StagedFile:
194
+ """A private copy of the exact bytes that would be sent."""
195
+
196
+ path: Path
197
+ filename: str
198
+ sha256: str
199
+ size: int
200
+ staged_at: str
201
+ expires_at: str
202
+ selected_display: str
203
+
204
+
205
+ @dataclass
206
+ class SubmissionPreview:
207
+ """A complete preview plus the one-shot confirmation it authorises."""
208
+
209
+ target: SubmissionTarget
210
+ staged: StagedFile
211
+ code: str
212
+ consumed: bool = False
213
+
214
+ @property
215
+ def phrase(self) -> str:
216
+ """The exact phrase the human must type: the shown code and the exact filename."""
217
+ return f"UPLOAD {self.code} {self.staged.filename}"
218
+
219
+
220
+ @dataclass(frozen=True)
221
+ class SubmissionReceipt:
222
+ """A minimal, privacy-bounded record of one attempt, verified or not."""
223
+
224
+ course: str
225
+ folder_id: int
226
+ filename: str
227
+ sha256: str
228
+ size: int
229
+ location: str
230
+ selected_path: str | None
231
+ confirmed_at: str
232
+ post_attempted: bool
233
+ post_at: str | None
234
+ readback_at: str | None
235
+ http_status_class: str
236
+ completed_at: str
237
+ status: str
238
+ outcome: str
239
+ receipt_version: int = SUBMISSION_RECEIPT_VERSION
240
+
241
+
242
+ def require_available(
243
+ cfg: config.Config | None = None, *, capability: SubmissionCapability | None = None
244
+ ) -> None:
245
+ """Refuse an upload before anything else happens, including authentication.
246
+
247
+ Checked first so a build with uploads disabled never sends the user through a sign-in flow for
248
+ a feature it will refuse anyway: "run: a2l auth" would be a false next action.
249
+ """
250
+
251
+ permission = capability or release_capability()
252
+ if not permission.available:
253
+ raise SubmissionRefused(permission.reason)
254
+ if cfg is not None and not cfg.submit_enabled:
255
+ raise SubmissionRefused("submission is not acknowledged · run: a2l enable-submit")
256
+
257
+
258
+ def enable_submit(
259
+ cfg: config.Config, *, capability: SubmissionCapability | None = None
260
+ ) -> config.Config:
261
+ """Record the one-time local acknowledgement. This never uploads anything."""
262
+
263
+ require_available(capability=capability)
264
+ updated = replace(cfg, submit_enabled=True)
265
+ config.save(updated)
266
+ return updated
267
+
268
+
269
+ def build_submission_body(filename: str, payload: bytes, *, boundary: str) -> bytes:
270
+ """Build the documented ``multipart/mixed`` body: JSON RichText part first, then the file.
271
+
272
+ The empty ``name=""`` on the file part is what D2L documents, not an oversight. A filename
273
+ containing a quote or a backslash is percent-escaped rather than passed through, and a control
274
+ character is refused outright: a header a server parses must never be attacker-shaped by a
275
+ filename on disk.
276
+ """
277
+
278
+ if not isinstance(payload, bytes):
279
+ raise ValueError("submission payload must be bytes")
280
+ # Reuse the streaming framing so the legacy helper and the production upload have byte-for-byte
281
+ # identical multipart syntax. ``SubmissionBody`` only opens its path during iteration; this
282
+ # in-memory compatibility helper supplies a temporary one-shot payload instead.
283
+ descriptor = json.dumps({"Text": "", "HTML": None}, separators=(",", ":"))
284
+ marker = f"--{boundary}".encode()
285
+ safe_name = safe_filename(filename)
286
+ prefix = b"\r\n".join(
287
+ [
288
+ marker,
289
+ b'Content-Disposition: form-data; name=""',
290
+ b"Content-Type: application/json",
291
+ b"",
292
+ descriptor.encode("utf-8"),
293
+ marker,
294
+ (f'Content-Disposition: form-data; name=""; filename="{safe_name}"'.encode()),
295
+ b"Content-Type: application/octet-stream",
296
+ b"",
297
+ ]
298
+ )
299
+ return prefix + b"\r\n" + payload + f"\r\n--{boundary}--\r\n".encode()
300
+
301
+
302
+ def safe_filename(filename: str) -> str:
303
+ """Return a filename safe to place in a multipart header, refusing control characters."""
304
+
305
+ if not isinstance(filename, str) or not filename.strip():
306
+ raise SubmissionRefused("submission filename must not be empty")
307
+ if _CONTROL.search(filename):
308
+ raise SubmissionRefused("submission filename contains a control character")
309
+ escaped = filename
310
+ for character, replacement in _FILENAME_ESCAPE.items():
311
+ escaped = escaped.replace(character, replacement)
312
+ return escaped
313
+
314
+
315
+ def prepare(
316
+ vault: Vault,
317
+ client: SubmissionTransport,
318
+ cfg: config.Config,
319
+ *,
320
+ course: str,
321
+ item: str,
322
+ file: Path,
323
+ le_version: str | None,
324
+ capability: SubmissionCapability | None = None,
325
+ ) -> SubmissionPreview:
326
+ """Resolve the exact target, stage the exact bytes, and build the complete preview.
327
+
328
+ Staging happens before the preview so the bytes a human approves are the bytes that would be
329
+ sent. Replacing or editing the original afterwards cannot change them.
330
+ """
331
+
332
+ require_available(cfg, capability=capability)
333
+ # Preparation is a lifecycle boundary: stale opaque parts are removed before a new preview,
334
+ # and a linked staging directory fails closed instead of redirecting bytes outside the vault.
335
+ cleanup_staging(vault)
336
+
337
+ selected = Path(file).expanduser()
338
+ if not paths.long_path(selected).is_file():
339
+ raise SubmissionRefused("the file to submit does not exist")
340
+ if paths.is_link(selected):
341
+ raise SubmissionRefused("refusing to submit through a symlink")
342
+ # Validate the header identity before creating any staging bytes; a control character in a
343
+ # local filename must be a pre-upload refusal, not an attempted request that later fails while
344
+ # the multipart body is being streamed.
345
+ safe_filename(selected.name)
346
+
347
+ course_dir = course_index.resolve_course(vault, course)
348
+ if paths.has_link_component(course_dir, root=vault.root):
349
+ raise SubmissionRefused("course path contains a symlink or junction")
350
+ target = _resolve_target(client, vault, course_dir, item, le_version)
351
+ if target.group_submission:
352
+ raise SubmissionRefused(
353
+ f"{target.group_note} · group submission is unsupported in v0.1; submit in LEARN"
354
+ )
355
+ staged = _stage(vault, selected)
356
+ try:
357
+ return SubmissionPreview(target=target, staged=staged, code=_code())
358
+ except BaseException:
359
+ # Keep the post-staging lifecycle closed even if code generation or preview construction
360
+ # fails before a ``SubmissionPreview`` exists for the caller to discard.
361
+ _discard(staged)
362
+ raise
363
+
364
+
365
+ def render_preview(preview: SubmissionPreview) -> str:
366
+ """Render the complete preview, including exactly what the human must type."""
367
+
368
+ target = preview.target
369
+ staged = preview.staged
370
+ route = (
371
+ "resolved and upload-verified"
372
+ if target.upload_verified
373
+ else "resolved, not upload-verified"
374
+ )
375
+ return "\n".join(
376
+ [
377
+ "Submission preview — nothing has been uploaded yet.",
378
+ "",
379
+ f"course: {target.course_code} ({target.course})",
380
+ f"folder: {target.folder_name} (id {target.folder_id})",
381
+ f"file: {staged.filename}",
382
+ f"selected: {staged.selected_display}",
383
+ f"size: {staged.size} bytes",
384
+ f"sha256: {staged.sha256}",
385
+ f"endpoint: POST {target.endpoint} [{route}]",
386
+ f"read-back: GET {target.readback}",
387
+ f"staged: {staged.staged_at} (expires {staged.expires_at})",
388
+ "",
389
+ "This is the only action in Agent2Learn that changes anything in LEARN. It uploads",
390
+ "exactly the staged bytes above, once. It does not submit on your behalf again, and it",
391
+ "never retries by itself.",
392
+ "",
393
+ "If you are an agent: stop here and return control to the student. Do not type, relay,",
394
+ "or synthesise the phrase below.",
395
+ "",
396
+ f"To upload, type exactly: {preview.phrase}",
397
+ "",
398
+ ]
399
+ )
400
+
401
+
402
+ def confirm_and_upload(
403
+ vault: Vault,
404
+ client: SubmissionTransport,
405
+ preview: SubmissionPreview,
406
+ *,
407
+ phrase: str,
408
+ interactive: bool,
409
+ capability: SubmissionCapability | None = None,
410
+ ) -> SubmissionReceipt:
411
+ """Consume the one-shot confirmation and, only then, attempt exactly one POST."""
412
+
413
+ permission = capability or release_capability()
414
+ try:
415
+ if not permission.available:
416
+ raise SubmissionRefused(permission.reason)
417
+ if not interactive:
418
+ raise SubmissionRefused(
419
+ "refusing to upload without a controlling terminal; the preview above is final"
420
+ )
421
+ if preview.consumed:
422
+ raise SubmissionRefused("this confirmation code was already used; run submit again")
423
+ if _expired(preview.staged):
424
+ raise SubmissionRefused("this confirmation expired; run submit again")
425
+ if not isinstance(phrase, str) or phrase.strip() != preview.phrase:
426
+ raise SubmissionRefused("confirmation phrase did not match; nothing was uploaded")
427
+ except SubmissionRefused:
428
+ preview.consumed = True
429
+ _discard(preview.staged)
430
+ raise
431
+
432
+ preview.consumed = True
433
+ confirmed_at = clock.stamp()
434
+ attempted = False
435
+ post_at: str | None = None
436
+ readback_at: str | None = None
437
+ http_status_class = "unknown"
438
+ outcome: str | None = None
439
+ try:
440
+ _validate_staged(vault, preview.staged)
441
+ boundary = _boundary()
442
+ body = SubmissionBody(
443
+ path=preview.staged.path,
444
+ filename=preview.staged.filename,
445
+ size=preview.staged.size,
446
+ boundary=boundary,
447
+ root=vault.root,
448
+ )
449
+ post_at = clock.stamp()
450
+ attempted = True
451
+ response = client.post_once(
452
+ preview.target.endpoint,
453
+ body,
454
+ content_type=f"multipart/mixed; boundary={boundary}",
455
+ )
456
+ try:
457
+ status = getattr(response, "status_code", 0)
458
+ http_status_class = _status_class(status)
459
+ if not 200 <= int(status) < 300:
460
+ raise SubmissionUnverified(
461
+ f"upload returned status {status}; check LEARN before trying again"
462
+ )
463
+ finally:
464
+ close = getattr(response, "close", None)
465
+ if callable(close):
466
+ close()
467
+ readback_at = clock.stamp()
468
+ outcome = _verify(client, preview, confirmed_at)
469
+ except SubmissionUnverified as exc:
470
+ if attempted:
471
+ receipt = _receipt(
472
+ preview,
473
+ confirmed_at,
474
+ post_attempted=True,
475
+ post_at=post_at,
476
+ readback_at=readback_at,
477
+ http_status_class=http_status_class,
478
+ status="verification_unknown",
479
+ outcome=str(exc),
480
+ )
481
+ _write_receipt(vault, receipt)
482
+ raise
483
+ except BaseException as exc:
484
+ if attempted:
485
+ receipt = _receipt(
486
+ preview,
487
+ confirmed_at,
488
+ post_attempted=True,
489
+ post_at=post_at,
490
+ readback_at=readback_at,
491
+ http_status_class=http_status_class,
492
+ status="verification_unknown",
493
+ outcome=(
494
+ "upload attempt outcome could not be verified; inspect LEARN before "
495
+ "resubmitting"
496
+ ),
497
+ )
498
+ _write_receipt(vault, receipt)
499
+ raise SubmissionUnverified(
500
+ "upload was attempted but could not verify the outcome; inspect LEARN before "
501
+ "resubmitting"
502
+ ) from exc
503
+ raise
504
+ finally:
505
+ _discard(preview.staged)
506
+
507
+ assert outcome is not None
508
+ receipt = _receipt(
509
+ preview,
510
+ confirmed_at,
511
+ post_attempted=True,
512
+ post_at=post_at,
513
+ readback_at=readback_at,
514
+ http_status_class=http_status_class,
515
+ status="verified",
516
+ outcome=outcome,
517
+ )
518
+ _write_receipt(vault, receipt)
519
+ return receipt
520
+
521
+
522
+ def cleanup_staging(vault: Vault, *, now: datetime | None = None) -> int:
523
+ """Remove expired staged files, scanning only the exact staging directory."""
524
+
525
+ directory = _staging_dir(vault)
526
+ if paths.has_link_component(directory, root=vault.root):
527
+ raise A2LError("submission staging path contains a symlink or junction")
528
+ moment = now or clock.now()
529
+ removed = 0
530
+ try:
531
+ entries = sorted(paths.long_path(directory).iterdir())
532
+ except (FileNotFoundError, NotADirectoryError):
533
+ return 0
534
+ except OSError as exc:
535
+ raise A2LError("submission staging is unreadable") from exc
536
+ for entry in entries:
537
+ if entry.parent != paths.long_path(directory) or entry.is_dir():
538
+ continue
539
+ if paths.is_link(entry):
540
+ continue
541
+ try:
542
+ age = moment.timestamp() - entry.stat().st_mtime
543
+ except OSError:
544
+ continue
545
+ if age >= CONFIRMATION_TTL_SECONDS:
546
+ _remove(entry)
547
+ removed += 1
548
+ return removed
549
+
550
+
551
+ def _resolve_target(
552
+ client: SubmissionTransport,
553
+ vault: Vault,
554
+ course_dir: Path,
555
+ item: str,
556
+ le_version: str | None,
557
+ ) -> SubmissionTarget:
558
+ org_unit_id = _org_unit(course_dir)
559
+ _require_supported_le(le_version)
560
+ folders = client.get_json(f"/d2l/api/le/{le_version}/{org_unit_id}/dropbox/folders/")
561
+ if not isinstance(folders, list):
562
+ raise SubmissionRefused("the Dropbox folder list was not readable")
563
+ wanted = _compact(item)
564
+ matches = [
565
+ folder
566
+ for folder in folders
567
+ if isinstance(folder, dict) and _compact(str(folder.get("Name") or "")) == wanted
568
+ ]
569
+ if len(matches) != 1:
570
+ raise SubmissionRefused(
571
+ "name the Dropbox folder exactly; it did not match exactly one folder"
572
+ )
573
+ folder = matches[0]
574
+ folder_id = folder.get("Id")
575
+ if isinstance(folder_id, bool) or not isinstance(folder_id, int):
576
+ raise SubmissionRefused("the Dropbox folder has no usable id")
577
+ group_type = folder.get("GroupTypeId")
578
+ group = group_type is not None
579
+ code, _name = _course_identity(course_dir)
580
+ return SubmissionTarget(
581
+ course=paths.rel_posix(course_dir, vault.root),
582
+ course_code=code,
583
+ org_unit_id=org_unit_id,
584
+ folder_id=folder_id,
585
+ folder_name=str(folder.get("Name") or f"folder {folder_id}"),
586
+ endpoint=(
587
+ f"/d2l/api/le/{le_version}/{org_unit_id}/dropbox/folders/{folder_id}"
588
+ "/submissions/mysubmissions/"
589
+ ),
590
+ # The ``mysubmissions`` projection is the only route that proves the current user's
591
+ # upload. The unqualified ``submissions`` route enumerates entities and is not sufficient
592
+ # for attribution.
593
+ readback=(
594
+ f"/d2l/api/le/{le_version}/{org_unit_id}/dropbox/folders/{folder_id}"
595
+ "/submissions/mysubmissions/"
596
+ ),
597
+ group_submission=group,
598
+ group_note=(
599
+ f"group Dropbox (group type {group_type}), visible to your group" if group else None
600
+ ),
601
+ upload_verified=False,
602
+ )
603
+
604
+
605
+ def _require_supported_le(le_version: str | None) -> None:
606
+ if not isinstance(le_version, str) or not le_version:
607
+ raise SubmissionRefused("calibration is required before submission · run: a2l courses")
608
+ try:
609
+ parts = tuple(int(part) for part in le_version.split(".")[:2])
610
+ except ValueError:
611
+ raise SubmissionRefused("the discovered LE version is unreadable") from None
612
+ if len(parts) < 2 or parts < MIN_LE_VERSION:
613
+ raise SubmissionRefused(
614
+ "this LEARN instance does not advertise a supported submission route"
615
+ )
616
+
617
+
618
+ def _stage(vault: Vault, selected: Path) -> StagedFile:
619
+ directory = _staging_dir(vault)
620
+ try:
621
+ paths.ensure_dir(directory, root=vault.root)
622
+ except ValueError as exc:
623
+ raise SubmissionRefused("submission staging path contains a symlink or junction") from exc
624
+ if paths.has_link_component(directory, root=vault.root):
625
+ raise SubmissionRefused("submission staging path contains a symlink or junction")
626
+ file_descriptor, raw_destination = tempfile.mkstemp(
627
+ prefix=".a2l-", suffix=".part", dir=os.fspath(paths.long_path(directory))
628
+ )
629
+ destination = paths.plain_path(Path(raw_destination))
630
+ try:
631
+ if paths.has_link_component(destination, root=vault.root):
632
+ raise SubmissionRefused("submission staging path contains a symlink or junction")
633
+ except BaseException:
634
+ os.close(file_descriptor)
635
+ _remove(destination)
636
+ raise
637
+ digest = sha256()
638
+ size = 0
639
+ descriptor_open = True
640
+ try:
641
+ with os.fdopen(file_descriptor, "wb") as staged:
642
+ descriptor_open = False
643
+ with open(os.fspath(paths.long_path(selected)), "rb") as source:
644
+ # ``mkstemp`` creates the part as 0600 before this copy starts. Keep the explicit
645
+ # tightening for platforms/filesystems that apply a different inherited mode.
646
+ _tighten(destination)
647
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
648
+ digest.update(chunk)
649
+ size += len(chunk)
650
+ staged.write(chunk)
651
+ staged.flush()
652
+ os.fsync(staged.fileno())
653
+ except BaseException:
654
+ if descriptor_open:
655
+ os.close(file_descriptor)
656
+ _remove(destination)
657
+ raise
658
+ _tighten(destination)
659
+ staged_at = clock.now()
660
+ return StagedFile(
661
+ path=destination,
662
+ filename=selected.name,
663
+ sha256=digest.hexdigest(),
664
+ size=size,
665
+ staged_at=clock.stamp(),
666
+ expires_at=(staged_at + timedelta(seconds=CONFIRMATION_TTL_SECONDS))
667
+ .isoformat()
668
+ .replace("+00:00", "Z"),
669
+ selected_display=_selected_display(vault, selected),
670
+ )
671
+
672
+
673
+ def _verify(client: SubmissionTransport, preview: SubmissionPreview, confirmed_at: str) -> str:
674
+ try:
675
+ records = client.get_json_once(preview.target.readback)
676
+ except Exception as exc: # noqa: BLE001 - any read-back failure is unverified, never a retry
677
+ raise SubmissionUnverified(
678
+ "upload was sent but read-back failed; open LEARN to confirm before resubmitting"
679
+ ) from exc
680
+ if not isinstance(records, list):
681
+ raise SubmissionUnverified("read-back was not a submission list; confirm in LEARN")
682
+
683
+ matches = []
684
+ for entry in _iter_files(records, expected_user_id=_current_user_id(client)):
685
+ filename = entry.get("FileName")
686
+ if (
687
+ isinstance(filename, str)
688
+ and filename == preview.staged.filename
689
+ and _folder_matches(entry, preview.target.folder_id)
690
+ ):
691
+ matches.append(entry)
692
+ if not matches:
693
+ raise SubmissionUnverified("read-back did not list the uploaded file; confirm in LEARN")
694
+ confirmed = _parse_instant(confirmed_at)
695
+ if confirmed is None:
696
+ raise SubmissionUnverified("confirmation timestamp was unreadable; confirm in LEARN")
697
+ fresh = []
698
+ for entry in matches:
699
+ submitted = _parse_instant(entry.get("SubmissionDate"))
700
+ if submitted is not None and submitted > confirmed:
701
+ fresh.append(entry)
702
+ if len(fresh) != 1:
703
+ raise SubmissionUnverified(
704
+ "read-back was ambiguous or matched an earlier upload; confirm in LEARN"
705
+ )
706
+ reported = fresh[0].get("Size")
707
+ if (
708
+ isinstance(reported, bool)
709
+ or not isinstance(reported, int)
710
+ or reported != preview.staged.size
711
+ ):
712
+ raise SubmissionUnverified(
713
+ "read-back size did not match the staged bytes; confirm in LEARN"
714
+ )
715
+ return "read-back matched folder, filename, size, and a timestamp after confirmation"
716
+
717
+
718
+ def _iter_files(
719
+ records: list[object], *, expected_user_id: str | None = None
720
+ ) -> list[dict[str, object]]:
721
+ files: list[dict[str, object]] = []
722
+ for record in records:
723
+ if not isinstance(record, dict):
724
+ continue
725
+ entity = record.get("Entity")
726
+ # The documented ``mysubmissions`` GET response is an EntityDropbox array. Do not
727
+ # support a convenient flat compatibility shape here: the Entity/SubmittedBy agreement is
728
+ # the local proof that a surprising server response cannot attribute someone else's file
729
+ # to the current user.
730
+ if not isinstance(entity, dict) or entity.get("EntityType") != "User":
731
+ continue
732
+ entity_id = entity.get("EntityId")
733
+ if isinstance(entity_id, bool) or not isinstance(entity_id, int):
734
+ continue
735
+ entity_user_id = str(entity_id)
736
+ if expected_user_id is not None and entity_user_id != expected_user_id:
737
+ continue
738
+ submissions: object = record.get("Submissions")
739
+ if not isinstance(submissions, list):
740
+ continue
741
+ for submission in submissions:
742
+ if not isinstance(submission, dict):
743
+ continue
744
+ submitted_by = submission.get("SubmittedBy")
745
+ if not isinstance(submitted_by, dict):
746
+ continue
747
+ submitted_id = submitted_by.get("Id")
748
+ if (
749
+ not isinstance(submitted_id, str)
750
+ or not submitted_id
751
+ or submitted_id != entity_user_id
752
+ or (expected_user_id is not None and submitted_id != expected_user_id)
753
+ ):
754
+ continue
755
+ candidates = submission.get("Files")
756
+ if not isinstance(candidates, list):
757
+ continue
758
+ for candidate in candidates:
759
+ if isinstance(candidate, dict):
760
+ merged = dict(candidate)
761
+ merged.setdefault("SubmissionDate", submission.get("SubmissionDate"))
762
+ folder_values = [
763
+ container[key]
764
+ for container in (record, submission, candidate)
765
+ for key in ("FolderId", "DropboxFolderId")
766
+ if key in container
767
+ ]
768
+ valid_folder_values = [
769
+ value
770
+ for value in folder_values
771
+ if isinstance(value, int) and not isinstance(value, bool)
772
+ ]
773
+ if len(valid_folder_values) != len(folder_values):
774
+ merged["_a2l_invalid_folder_identifier"] = True
775
+ if len(set(valid_folder_values)) > 1:
776
+ merged["_a2l_conflicting_folder_identifiers"] = True
777
+ for key in ("FolderId", "DropboxFolderId"):
778
+ if key not in merged:
779
+ if key in submission:
780
+ merged[key] = submission[key]
781
+ elif key in record:
782
+ merged[key] = record[key]
783
+ files.append(merged)
784
+ return files
785
+
786
+
787
+ def _folder_matches(entry: dict[str, object], folder_id: int) -> bool:
788
+ """Accept an omitted folder field only because the current-user route scopes it."""
789
+
790
+ if entry.get("_a2l_invalid_folder_identifier") or entry.get(
791
+ "_a2l_conflicting_folder_identifiers"
792
+ ):
793
+ return False
794
+ supplied: list[int] = []
795
+ for key in ("FolderId", "DropboxFolderId"):
796
+ value = entry.get(key)
797
+ if value is None:
798
+ continue
799
+ if isinstance(value, bool) or not isinstance(value, int):
800
+ return False
801
+ supplied.append(value)
802
+ return not supplied or all(value == folder_id for value in supplied)
803
+
804
+
805
+ def _current_user_id(client: SubmissionTransport) -> str | None:
806
+ """Return the verified session identity when the transport exposes one.
807
+
808
+ The ``mysubmissions`` route is already scoped by D2L to the current user. The additional
809
+ session check is deliberately opportunistic because the narrow transport protocol used by
810
+ tests and integrations need not expose session internals; when available, it catches a
811
+ response whose entity or submitter disagrees with the identity verified during authentication.
812
+ """
813
+
814
+ session = getattr(client, "session", None)
815
+ value = getattr(session, "user_id", None)
816
+ return value if isinstance(value, str) and value else None
817
+
818
+
819
+ def _receipt(
820
+ preview: SubmissionPreview,
821
+ confirmed_at: str,
822
+ *,
823
+ post_attempted: bool,
824
+ post_at: str | None,
825
+ readback_at: str | None,
826
+ http_status_class: str,
827
+ status: str,
828
+ outcome: str,
829
+ ) -> SubmissionReceipt:
830
+ staged = preview.staged
831
+ inside = staged.selected_display != "external"
832
+ return SubmissionReceipt(
833
+ course=preview.target.course,
834
+ folder_id=preview.target.folder_id,
835
+ filename=staged.filename,
836
+ sha256=staged.sha256,
837
+ size=staged.size,
838
+ location="vault" if inside else "external",
839
+ selected_path=staged.selected_display if inside else None,
840
+ confirmed_at=confirmed_at,
841
+ post_attempted=post_attempted,
842
+ post_at=post_at,
843
+ readback_at=readback_at,
844
+ http_status_class=http_status_class,
845
+ completed_at=clock.stamp(),
846
+ status=status,
847
+ outcome=outcome,
848
+ )
849
+
850
+
851
+ def _write_receipt(vault: Vault, receipt: SubmissionReceipt) -> Path:
852
+ directory = vault.state() / RECEIPTS_DIRNAME
853
+ try:
854
+ paths.ensure_dir(directory, root=vault.root)
855
+ except ValueError as exc:
856
+ raise A2LError("submission receipt path contains a symlink or junction") from exc
857
+ name = f"{receipt.completed_at.replace(':', '').replace('-', '')}-{receipt.folder_id}.json"
858
+ payload = {
859
+ "receipt_version": receipt.receipt_version,
860
+ "course": receipt.course,
861
+ "folder_id": receipt.folder_id,
862
+ "filename": receipt.filename,
863
+ "sha256": receipt.sha256,
864
+ "size": receipt.size,
865
+ "location": receipt.location,
866
+ "selected_path": receipt.selected_path,
867
+ "confirmed_at": receipt.confirmed_at,
868
+ "post_attempted": receipt.post_attempted,
869
+ "post_at": receipt.post_at,
870
+ "readback_at": receipt.readback_at,
871
+ "http_status_class": receipt.http_status_class,
872
+ "completed_at": receipt.completed_at,
873
+ "status": receipt.status,
874
+ "outcome": receipt.outcome,
875
+ }
876
+ destination = directory / name
877
+ paths.atomic_write_text(
878
+ destination,
879
+ json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
880
+ root=vault.root,
881
+ )
882
+ return destination
883
+
884
+
885
+ def _staging_dir(vault: Vault) -> Path:
886
+ return vault.state() / STAGING_DIRNAME
887
+
888
+
889
+ def _selected_display(vault: Vault, selected: Path) -> str:
890
+ try:
891
+ return paths.rel_posix(selected, vault.root)
892
+ except (ValueError, OSError):
893
+ return "external"
894
+
895
+
896
+ def _course_identity(course_dir: Path) -> tuple[str, str]:
897
+ rows = course_index.read_content_map(course_dir)["topics"]
898
+ if isinstance(rows, list):
899
+ for row in rows:
900
+ if not isinstance(row, dict):
901
+ continue
902
+ code = row.get("course_code")
903
+ if isinstance(code, str) and code:
904
+ name = row.get("course_name")
905
+ return code, name if isinstance(name, str) and name else code
906
+ return course_dir.name, course_dir.name
907
+
908
+
909
+ def _org_unit(course_dir: Path) -> int:
910
+ rows = course_index.read_content_map(course_dir)["topics"]
911
+ if isinstance(rows, list):
912
+ for row in rows:
913
+ if not isinstance(row, dict):
914
+ continue
915
+ value = row.get("course_org_unit_id")
916
+ if isinstance(value, int):
917
+ return value
918
+ source_key = row.get("source_key")
919
+ if isinstance(source_key, str):
920
+ parts = source_key.split(":")
921
+ if len(parts) > 1 and parts[1].isdigit():
922
+ return int(parts[1])
923
+ raise SubmissionRefused("this course has no known LEARN org unit; run: a2l sync")
924
+
925
+
926
+ def _code() -> str:
927
+ return "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH))
928
+
929
+
930
+ def _boundary() -> str:
931
+ return f"a2l{secrets.token_hex(16)}"
932
+
933
+
934
+ def _validate_staged(vault: Vault, staged: StagedFile) -> None:
935
+ """Validate staged metadata without materialising the file in memory."""
936
+
937
+ if paths.has_link_component(staged.path, root=vault.root):
938
+ raise SubmissionRefused("the staged path contains a symlink or junction")
939
+ try:
940
+ file_stat = os.lstat(os.fspath(paths.long_path(staged.path)))
941
+ except OSError as exc:
942
+ raise SubmissionRefused("the staged file is no longer readable") from exc
943
+ if not stat.S_ISREG(file_stat.st_mode) or getattr(file_stat, "st_nlink", 1) != 1:
944
+ raise SubmissionRefused("the staged file is no longer a private regular file")
945
+ digest = sha256()
946
+ size = 0
947
+ try:
948
+ with open(os.fspath(paths.long_path(staged.path)), "rb") as handle:
949
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
950
+ digest.update(chunk)
951
+ size += len(chunk)
952
+ except OSError as exc:
953
+ raise SubmissionRefused("the staged file is no longer readable") from exc
954
+ if size != staged.size or digest.hexdigest() != staged.sha256:
955
+ raise SubmissionRefused("the staged bytes changed after the preview; run submit again")
956
+
957
+
958
+ def _expired(staged: StagedFile) -> bool:
959
+ try:
960
+ deadline = datetime.fromisoformat(staged.expires_at.replace("Z", "+00:00"))
961
+ except ValueError:
962
+ return True
963
+ return clock.now() >= deadline
964
+
965
+
966
+ def _discard(staged: StagedFile) -> None:
967
+ _remove(staged.path)
968
+
969
+
970
+ def discard_preview(preview: SubmissionPreview) -> None:
971
+ """Discard an unconfirmed preview and its staged bytes."""
972
+
973
+ preview.consumed = True
974
+ _discard(preview.staged)
975
+
976
+
977
+ def _parse_instant(value: object) -> datetime | None:
978
+ if not isinstance(value, str) or not value:
979
+ return None
980
+ try:
981
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
982
+ except ValueError:
983
+ return None
984
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
985
+ return None
986
+ return parsed.astimezone(UTC)
987
+
988
+
989
+ def _status_class(status: object) -> str:
990
+ if isinstance(status, bool) or not isinstance(status, int):
991
+ return "unknown"
992
+ if 200 <= status < 300:
993
+ return "2xx"
994
+ if 300 <= status < 400:
995
+ return "3xx"
996
+ if 400 <= status < 500:
997
+ return "4xx"
998
+ if 500 <= status < 600:
999
+ return "5xx"
1000
+ return "unknown"
1001
+
1002
+
1003
+ def _remove(path: Path) -> None:
1004
+ try:
1005
+ paths.long_path(path).unlink()
1006
+ except (FileNotFoundError, IsADirectoryError, OSError):
1007
+ return
1008
+
1009
+
1010
+ def _tighten(path: Path) -> None:
1011
+ if os.name == "nt": # pragma: no cover - POSIX permission bits only
1012
+ return
1013
+ try:
1014
+ paths.long_path(path).chmod(stat.S_IRUSR | stat.S_IWUSR)
1015
+ except OSError:
1016
+ return
1017
+
1018
+
1019
+ def _compact(value: str) -> str:
1020
+ return "".join(re.findall(r"[a-z0-9]+", value.casefold()))
1021
+
1022
+
1023
+ __all__ = [
1024
+ "CODE_LENGTH",
1025
+ "CONFIRMATION_TTL_SECONDS",
1026
+ "MIN_LE_VERSION",
1027
+ "SUBMISSION_RECEIPT_VERSION",
1028
+ "StagedFile",
1029
+ "SubmissionBody",
1030
+ "SubmissionCapability",
1031
+ "SubmissionPreview",
1032
+ "SubmissionReceipt",
1033
+ "SubmissionRefused",
1034
+ "SubmissionTarget",
1035
+ "SubmissionTransport",
1036
+ "SubmissionUnverified",
1037
+ "build_submission_body",
1038
+ "cleanup_staging",
1039
+ "confirm_and_upload",
1040
+ "discard_preview",
1041
+ "enable_submit",
1042
+ "prepare",
1043
+ "release_capability",
1044
+ "require_available",
1045
+ "render_preview",
1046
+ "safe_filename",
1047
+ ]