ask-ta 0.2.3__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 (138) hide show
  1. ask_ta/__init__.py +31 -0
  2. ask_ta/_version.py +24 -0
  3. ask_ta/adapters/__init__.py +3 -0
  4. ask_ta/adapters/bundle/__init__.py +5 -0
  5. ask_ta/adapters/bundle/archive.py +296 -0
  6. ask_ta/adapters/bundle/store.py +519 -0
  7. ask_ta/adapters/llm/__init__.py +11 -0
  8. ask_ta/adapters/llm/client.py +392 -0
  9. ask_ta/adapters/llm/rate_limiter.py +80 -0
  10. ask_ta/adapters/pdf/__init__.py +17 -0
  11. ask_ta/adapters/pdf/fingerprint.py +50 -0
  12. ask_ta/adapters/pdf/highlighter.py +136 -0
  13. ask_ta/adapters/pdf/page_reader.py +53 -0
  14. ask_ta/adapters/retrieval/__init__.py +7 -0
  15. ask_ta/adapters/retrieval/engine.py +960 -0
  16. ask_ta/adapters/retrieval/keyword.py +112 -0
  17. ask_ta/adapters/retrieval/models.py +50 -0
  18. ask_ta/adapters/retrieval/vector.py +34 -0
  19. ask_ta/api/__init__.py +7 -0
  20. ask_ta/api/dependencies.py +105 -0
  21. ask_ta/api/errors.py +400 -0
  22. ask_ta/api/mappers.py +174 -0
  23. ask_ta/api/routes/__init__.py +27 -0
  24. ask_ta/api/routes/bundle.py +269 -0
  25. ask_ta/api/routes/chunks.py +127 -0
  26. ask_ta/api/routes/conversations.py +102 -0
  27. ask_ta/api/routes/health.py +92 -0
  28. ask_ta/api/routes/messages.py +236 -0
  29. ask_ta/api/routes/pics.py +79 -0
  30. ask_ta/api/routes/preferences.py +151 -0
  31. ask_ta/api/schemas/__init__.py +70 -0
  32. ask_ta/api/schemas/bundle.py +99 -0
  33. ask_ta/api/schemas/conversations.py +52 -0
  34. ask_ta/api/schemas/errors.py +81 -0
  35. ask_ta/api/schemas/health.py +49 -0
  36. ask_ta/api/schemas/messages.py +146 -0
  37. ask_ta/api/schemas/preferences.py +107 -0
  38. ask_ta/api/schemas/sse.py +99 -0
  39. ask_ta/api/spa.py +96 -0
  40. ask_ta/cli.py +33 -0
  41. ask_ta/config.py +298 -0
  42. ask_ta/data/__init__.py +424 -0
  43. ask_ta/db/__init__.py +25 -0
  44. ask_ta/db/manager.py +307 -0
  45. ask_ta/db/models.py +223 -0
  46. ask_ta/db/sql_helpers.py +28 -0
  47. ask_ta/domain/__init__.py +38 -0
  48. ask_ta/domain/_types.py +24 -0
  49. ask_ta/domain/bundle.py +985 -0
  50. ask_ta/domain/chat.py +343 -0
  51. ask_ta/domain/chat_manager.py +583 -0
  52. ask_ta/domain/errors.py +229 -0
  53. ask_ta/domain/generation_settings.py +39 -0
  54. ask_ta/domain/llm.py +150 -0
  55. ask_ta/domain/macro_expansion.py +66 -0
  56. ask_ta/domain/pdf.py +301 -0
  57. ask_ta/domain/preferences.py +119 -0
  58. ask_ta/domain/prompts.py +317 -0
  59. ask_ta/domain/query_router.py +374 -0
  60. ask_ta/domain/quote_verifier.py +301 -0
  61. ask_ta/domain/retrieval.py +103 -0
  62. ask_ta/domain/text_normalize.py +36 -0
  63. ask_ta/main.py +848 -0
  64. ask_ta/py.typed +0 -0
  65. ask_ta/repositories/__init__.py +11 -0
  66. ask_ta/repositories/chat_store.py +480 -0
  67. ask_ta/repositories/preferences_store.py +83 -0
  68. ask_ta/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  69. ask_ta/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  70. ask_ta/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  71. ask_ta/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  72. ask_ta/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  73. ask_ta/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  74. ask_ta/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  75. ask_ta/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  76. ask_ta/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  77. ask_ta/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  78. ask_ta/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  79. ask_ta/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  80. ask_ta/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  81. ask_ta/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  82. ask_ta/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  83. ask_ta/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  84. ask_ta/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  85. ask_ta/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  86. ask_ta/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  87. ask_ta/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  88. ask_ta/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  89. ask_ta/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  90. ask_ta/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  91. ask_ta/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  92. ask_ta/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  93. ask_ta/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  94. ask_ta/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  95. ask_ta/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  96. ask_ta/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  97. ask_ta/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  98. ask_ta/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  99. ask_ta/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  100. ask_ta/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  101. ask_ta/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  102. ask_ta/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  103. ask_ta/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  104. ask_ta/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  105. ask_ta/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  106. ask_ta/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  107. ask_ta/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  108. ask_ta/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  109. ask_ta/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  110. ask_ta/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  111. ask_ta/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  112. ask_ta/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  113. ask_ta/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  114. ask_ta/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  115. ask_ta/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  116. ask_ta/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  117. ask_ta/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  118. ask_ta/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  119. ask_ta/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  120. ask_ta/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  121. ask_ta/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  122. ask_ta/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  123. ask_ta/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  124. ask_ta/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  125. ask_ta/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  126. ask_ta/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  127. ask_ta/static/assets/index-CD99_6nl.js +631 -0
  128. ask_ta/static/assets/index-CGhcjghU.css +1 -0
  129. ask_ta/static/favicon.svg +1 -0
  130. ask_ta/static/index.html +14 -0
  131. ask_ta/utils/__init__.py +13 -0
  132. ask_ta/utils/log_levels.py +12 -0
  133. ask_ta/utils/logging.py +324 -0
  134. ask_ta-0.2.3.dist-info/METADATA +188 -0
  135. ask_ta-0.2.3.dist-info/RECORD +138 -0
  136. ask_ta-0.2.3.dist-info/WHEEL +4 -0
  137. ask_ta-0.2.3.dist-info/entry_points.txt +2 -0
  138. ask_ta-0.2.3.dist-info/licenses/LICENSE +21 -0
ask_ta/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Ask TA: a study assistant for the Foundations of Data Science course at UZH.
2
+
3
+ Exposes the public package metadata used by the app and its HTTP surface:
4
+
5
+ - :data:`__app_name__`: package name (from installed metadata).
6
+ - :data:`__version__`: package version (from installed metadata).
7
+ - :data:`__commit_sha__`: short git commit SHA, derived from the local
8
+ version segment of the package version string.
9
+
10
+ ``hatch-vcs`` embeds the commit SHA in the PEP 440 local-version segment of
11
+ the version string on every build (editable, sdist, wheel). For example,
12
+ ``"0.1.dev75+g304d82007.d20260417"`` contains ``g304d82007`` where
13
+ ``304d82007`` is the short SHA. Tagged releases at the tag's exact commit
14
+ have no local segment and resolve to ``unknown``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from importlib.metadata import metadata
20
+
21
+ __all__ = ["__app_name__", "__commit_sha__", "__version__"]
22
+
23
+ _meta = metadata(__name__)
24
+ __app_name__: str = _meta["Name"]
25
+ __version__: str = _meta["Version"]
26
+
27
+ __commit_sha__: str = "unknown"
28
+ if "+" in __version__:
29
+ _local = __version__.split("+", 1)[1]
30
+ if _local.startswith("g"):
31
+ __commit_sha__ = _local.split(".", 1)[0][1:]
ask_ta/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.2.3'
22
+ __version_tuple__ = version_tuple = (0, 2, 3)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,3 @@
1
+ """Infrastructure adapters: concrete implementations of domain ports."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,5 @@
1
+ """Filesystem adapters behind the :mod:`ask_ta.domain.bundle` ports: readers
2
+ for archives and directories (`archive`) and the versioned bundle store
3
+ (`store`). Composition wires them to the installer in ``main.py``."""
4
+
5
+ from __future__ import annotations
@@ -0,0 +1,296 @@
1
+ """Bundle-source readers: magic-byte sniffing, tar streaming, directory walk.
2
+
3
+ Implements the domain's :class:`~ask_ta.domain.bundle.ArchiveOpener` and
4
+ :class:`~ask_ta.domain.bundle.ArchiveReader` ports. Magic bytes decide the
5
+ format and the extension is ignored. A gzip file (``1f 8b``) is gunzipped and
6
+ untarred, and a bare ``ustar`` tar is tolerated, since Safari's "Open safe
7
+ files" gunzips downloads. A directory is walked, which covers a Safari
8
+ auto-expanded download and a CLI folder import. A zip is rejected with a
9
+ remedy telling the student to re-download the original archive instead of
10
+ re-compressing the folder.
11
+
12
+ Byte caps are enforced as the bytes stream in, so a header that
13
+ under-declares a member's size aborts mid-file. Each tar member also passes
14
+ ``tarfile.data_filter`` where the interpreter provides it
15
+ (3.10.12+/3.11.4+/3.12+), on top of the domain's pure vetting ladder, which
16
+ does the main checking.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import tarfile
23
+ import zlib
24
+ from contextlib import contextmanager, suppress
25
+ from typing import IO, TYPE_CHECKING, Final, Literal
26
+
27
+ from ask_ta.adapters.bundle.store import disk_space_errors
28
+ from ask_ta.domain.bundle import ArchiveEntry, MemberKind, display_member_name
29
+ from ask_ta.domain.errors import (
30
+ ArchiveUnreadableError,
31
+ BundleUnavailableError,
32
+ UnsafeArchiveError,
33
+ )
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Iterator, Sequence
37
+ from pathlib import Path
38
+
39
+ __all__ = [
40
+ "directory_content_sha256",
41
+ "file_sha256",
42
+ "open_bundle_source",
43
+ "require_pdf_magic",
44
+ ]
45
+
46
+ _GZIP_MAGIC: Final = b"\x1f\x8b"
47
+ _ZIP_MAGICS: Final = (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")
48
+ _USTAR_MAGIC: Final = b"ustar"
49
+ _USTAR_OFFSET: Final = 257
50
+ _PDF_MAGIC: Final = b"%PDF-"
51
+
52
+ _STREAM_CHUNK_BYTES: Final = 2**20
53
+
54
+ _ZIP_REMEDY: Final = (
55
+ "The imported file is a zip archive, but the course bundle is distributed "
56
+ "as a .tar.gz. Re-download the original file from your course page; "
57
+ "don't re-compress it."
58
+ )
59
+
60
+ #: Failures a corrupt gzip/tar stream can surface mid-read. ``OSError``
61
+ #: covers ``tarfile.ReadError``-wrapped I/O and ``gzip.BadGzipFile``;
62
+ #: ``zlib.error`` escapes raw from a corrupted deflate body.
63
+ _STREAM_ERRORS: Final = (tarfile.TarError, EOFError, OSError, zlib.error)
64
+
65
+
66
+ def _copy_stream(source: IO[bytes], dest: Path, *, max_bytes: int, member: str) -> int:
67
+ """Stream *source* to *dest* in 1 MiB chunks, enforcing *max_bytes* on the
68
+ bytes read; remove the partial file on any failure.
69
+
70
+ A write that runs out of room is reported as a storage fault, since the
71
+ archive itself is intact. Cleanup failures are swallowed so they cannot
72
+ replace that diagnosis with their own.
73
+ """
74
+ try:
75
+ with disk_space_errors():
76
+ dest.parent.mkdir(parents=True, exist_ok=True)
77
+ with source, dest.open("wb") as out:
78
+ return _pump(source, out, max_bytes=max_bytes, member=member)
79
+ except BaseException:
80
+ with suppress(OSError):
81
+ dest.unlink(missing_ok=True)
82
+ raise
83
+
84
+
85
+ def _pump(source: IO[bytes], out: IO[bytes], *, max_bytes: int, member: str) -> int:
86
+ written = 0
87
+ while True:
88
+ try:
89
+ chunk = source.read(_STREAM_CHUNK_BYTES)
90
+ except _STREAM_ERRORS as exc:
91
+ raise ArchiveUnreadableError(
92
+ f"Archive member {display_member_name(member)!r} could not be read: the stream is corrupt."
93
+ ) from exc
94
+ if not chunk:
95
+ return written
96
+ written += len(chunk)
97
+ if written > max_bytes:
98
+ raise UnsafeArchiveError(
99
+ f"Archive member {display_member_name(member)!r} exceeded the byte cap "
100
+ f"({max_bytes} bytes) while extracting."
101
+ )
102
+ out.write(chunk)
103
+
104
+
105
+ class _TarArchiveReader:
106
+ """ArchiveReader over an open :class:`tarfile.TarFile`."""
107
+
108
+ def __init__(self, tar: tarfile.TarFile) -> None:
109
+ self._tar = tar
110
+
111
+ def entries(self) -> Sequence[ArchiveEntry]:
112
+ try:
113
+ members = self._tar.getmembers()
114
+ except _STREAM_ERRORS as exc:
115
+ raise ArchiveUnreadableError(
116
+ f"The archive's member index could not be read: {exc}."
117
+ ) from exc
118
+ return [
119
+ ArchiveEntry(name=member.name, size=member.size, kind=_member_kind(member))
120
+ for member in members
121
+ ]
122
+
123
+ def extract_file(self, name: str, dest: Path, *, max_bytes: int) -> int:
124
+ try:
125
+ member = self._tar.getmember(name)
126
+ except KeyError as exc:
127
+ raise ArchiveUnreadableError(
128
+ f"Archive member {display_member_name(name)!r} vanished between listing and extraction."
129
+ ) from exc
130
+
131
+ try:
132
+ tarfile.data_filter(member, str(dest.parent))
133
+ except AttributeError:
134
+ # Pre-3.10.12/3.11.4 interpreters lack the PEP 706 filter; the
135
+ # domain's vetting ladder does the checking either way.
136
+ pass
137
+ except tarfile.FilterError as exc:
138
+ raise UnsafeArchiveError(
139
+ f"Archive member {display_member_name(name)!r} was rejected by the tar data filter."
140
+ ) from exc
141
+
142
+ try:
143
+ stream = self._tar.extractfile(member)
144
+ except _STREAM_ERRORS as exc:
145
+ raise ArchiveUnreadableError(
146
+ f"Archive member {display_member_name(name)!r} could not be opened for extraction."
147
+ ) from exc
148
+ if stream is None:
149
+ raise ArchiveUnreadableError(
150
+ f"Archive member {display_member_name(name)!r} has no extractable content."
151
+ )
152
+ return _copy_stream(stream, dest, max_bytes=max_bytes, member=name)
153
+
154
+
155
+ def _member_kind(member: tarfile.TarInfo) -> MemberKind:
156
+ if member.isreg():
157
+ return "file"
158
+ if member.isdir():
159
+ return "dir"
160
+ if member.issym() or member.islnk():
161
+ return "link"
162
+ return "other"
163
+
164
+
165
+ class _DirectoryBundleReader:
166
+ """ArchiveReader over a plain directory (CLI folder import, Safari
167
+ auto-expanded downloads). Entry names are root-relative POSIX paths."""
168
+
169
+ def __init__(self, root: Path) -> None:
170
+ self._root = root
171
+
172
+ def entries(self) -> Sequence[ArchiveEntry]:
173
+ listed: list[ArchiveEntry] = []
174
+ try:
175
+ paths = sorted(self._root.rglob("*"))
176
+ for path in paths:
177
+ name = path.relative_to(self._root).as_posix()
178
+ # is_symlink first: is_file()/is_dir() follow symlinks.
179
+ if path.is_symlink():
180
+ listed.append(ArchiveEntry(name=name, size=0, kind="link"))
181
+ elif path.is_dir():
182
+ listed.append(ArchiveEntry(name=name, size=0, kind="dir"))
183
+ elif path.is_file():
184
+ listed.append(ArchiveEntry(name=name, size=path.stat().st_size, kind="file"))
185
+ else:
186
+ listed.append(ArchiveEntry(name=name, size=0, kind="other"))
187
+ except OSError as exc:
188
+ raise ArchiveUnreadableError(f"The bundle folder could not be listed: {exc}.") from exc
189
+ return listed
190
+
191
+ def extract_file(self, name: str, dest: Path, *, max_bytes: int) -> int:
192
+ try:
193
+ stream = (self._root / name).open("rb")
194
+ except OSError as exc:
195
+ raise ArchiveUnreadableError(
196
+ f"Bundle folder member {display_member_name(name)!r} could not be read: {exc}."
197
+ ) from exc
198
+ return _copy_stream(stream, dest, max_bytes=max_bytes, member=name)
199
+
200
+
201
+ def _sniff_tar_mode(source: Path) -> Literal["r:gz", "r:"]:
202
+ """Return ``"r:gz"`` or ``"r:"`` by magic bytes; raise for zip and unknown bytes."""
203
+ try:
204
+ with source.open("rb") as handle:
205
+ head = handle.read(_USTAR_OFFSET + len(_USTAR_MAGIC))
206
+ except OSError as exc:
207
+ raise ArchiveUnreadableError(f"The imported file could not be read: {exc}.") from exc
208
+
209
+ if head.startswith(_GZIP_MAGIC):
210
+ return "r:gz"
211
+ if any(head.startswith(magic) for magic in _ZIP_MAGICS):
212
+ raise ArchiveUnreadableError(_ZIP_REMEDY)
213
+ if head[_USTAR_OFFSET : _USTAR_OFFSET + len(_USTAR_MAGIC)] == _USTAR_MAGIC:
214
+ return "r:"
215
+ raise ArchiveUnreadableError(
216
+ "The imported file is not a course bundle archive (expected a .tar.gz). "
217
+ "Re-download it from your course page."
218
+ )
219
+
220
+
221
+ @contextmanager
222
+ def open_bundle_source(source: Path) -> Iterator[_TarArchiveReader | _DirectoryBundleReader]:
223
+ """Open *source* (archive file or directory) as an ``ArchiveReader``.
224
+
225
+ Satisfies the :class:`~ask_ta.domain.bundle.ArchiveOpener` port.
226
+ """
227
+ if source.is_dir():
228
+ yield _DirectoryBundleReader(source)
229
+ return
230
+
231
+ mode = _sniff_tar_mode(source)
232
+ try:
233
+ tar = tarfile.open(source, mode=mode) # noqa: SIM115 - closed in finally
234
+ except _STREAM_ERRORS as exc:
235
+ raise ArchiveUnreadableError(
236
+ f"The imported file could not be opened as a tar archive: {exc}."
237
+ ) from exc
238
+ try:
239
+ yield _TarArchiveReader(tar)
240
+ finally:
241
+ tar.close()
242
+
243
+
244
+ def directory_content_sha256(root: Path, relpaths: Sequence[str]) -> str:
245
+ """Deterministic content identity for a folder import.
246
+
247
+ sha256 over each file's relpath + NUL + bytes + NUL, in sorted relpath
248
+ order, so the digest does not depend on listing order or on the archive
249
+ container that once wrapped the files. This is the folder counterpart of
250
+ the archive sha256 in the version id.
251
+ """
252
+ digest = hashlib.sha256()
253
+ for relpath in sorted(relpaths):
254
+ digest.update(relpath.encode("utf-8"))
255
+ digest.update(b"\x00")
256
+ with (root / relpath).open("rb") as handle:
257
+ while chunk := handle.read(_STREAM_CHUNK_BYTES):
258
+ digest.update(chunk)
259
+ digest.update(b"\x00")
260
+ return digest.hexdigest()
261
+
262
+
263
+ def file_sha256(path: Path) -> str:
264
+ """Streamed sha256 of *path*, the archive identity a CLI import supplies.
265
+
266
+ The HTTP route gets this for free from the spool; a CLI import reads a file
267
+ the student already has, and streaming keeps a 65 MB archive out of memory.
268
+ """
269
+ digest = hashlib.sha256()
270
+ with path.open("rb") as handle:
271
+ while chunk := handle.read(_STREAM_CHUNK_BYTES):
272
+ digest.update(chunk)
273
+ return digest.hexdigest()
274
+
275
+
276
+ def require_pdf_magic(path: Path) -> None:
277
+ """Probe-stage check that ``notes.pdf`` is present and PDF-shaped.
278
+
279
+ Only the magic bytes are read: parsing an imported PDF with PyMuPDF here
280
+ would enlarge the CVE surface, and the runtime opens it lazily per request
281
+ anyway. Raises the probe family's :class:`BundleUnavailableError` so the
282
+ composed probe can call this directly.
283
+ """
284
+ try:
285
+ with path.open("rb") as handle:
286
+ head = handle.read(len(_PDF_MAGIC))
287
+ except OSError as exc:
288
+ raise BundleUnavailableError(
289
+ f"Bundle notes.pdf at {path} could not be read: {exc}.",
290
+ missing_files=[path.name],
291
+ ) from exc
292
+ if head != _PDF_MAGIC:
293
+ raise BundleUnavailableError(
294
+ f"Bundle notes.pdf at {path} is not a PDF (bad magic bytes).",
295
+ missing_files=[path.name],
296
+ )