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.
- ask_ta/__init__.py +31 -0
- ask_ta/_version.py +24 -0
- ask_ta/adapters/__init__.py +3 -0
- ask_ta/adapters/bundle/__init__.py +5 -0
- ask_ta/adapters/bundle/archive.py +296 -0
- ask_ta/adapters/bundle/store.py +519 -0
- ask_ta/adapters/llm/__init__.py +11 -0
- ask_ta/adapters/llm/client.py +392 -0
- ask_ta/adapters/llm/rate_limiter.py +80 -0
- ask_ta/adapters/pdf/__init__.py +17 -0
- ask_ta/adapters/pdf/fingerprint.py +50 -0
- ask_ta/adapters/pdf/highlighter.py +136 -0
- ask_ta/adapters/pdf/page_reader.py +53 -0
- ask_ta/adapters/retrieval/__init__.py +7 -0
- ask_ta/adapters/retrieval/engine.py +960 -0
- ask_ta/adapters/retrieval/keyword.py +112 -0
- ask_ta/adapters/retrieval/models.py +50 -0
- ask_ta/adapters/retrieval/vector.py +34 -0
- ask_ta/api/__init__.py +7 -0
- ask_ta/api/dependencies.py +105 -0
- ask_ta/api/errors.py +400 -0
- ask_ta/api/mappers.py +174 -0
- ask_ta/api/routes/__init__.py +27 -0
- ask_ta/api/routes/bundle.py +269 -0
- ask_ta/api/routes/chunks.py +127 -0
- ask_ta/api/routes/conversations.py +102 -0
- ask_ta/api/routes/health.py +92 -0
- ask_ta/api/routes/messages.py +236 -0
- ask_ta/api/routes/pics.py +79 -0
- ask_ta/api/routes/preferences.py +151 -0
- ask_ta/api/schemas/__init__.py +70 -0
- ask_ta/api/schemas/bundle.py +99 -0
- ask_ta/api/schemas/conversations.py +52 -0
- ask_ta/api/schemas/errors.py +81 -0
- ask_ta/api/schemas/health.py +49 -0
- ask_ta/api/schemas/messages.py +146 -0
- ask_ta/api/schemas/preferences.py +107 -0
- ask_ta/api/schemas/sse.py +99 -0
- ask_ta/api/spa.py +96 -0
- ask_ta/cli.py +33 -0
- ask_ta/config.py +298 -0
- ask_ta/data/__init__.py +424 -0
- ask_ta/db/__init__.py +25 -0
- ask_ta/db/manager.py +307 -0
- ask_ta/db/models.py +223 -0
- ask_ta/db/sql_helpers.py +28 -0
- ask_ta/domain/__init__.py +38 -0
- ask_ta/domain/_types.py +24 -0
- ask_ta/domain/bundle.py +985 -0
- ask_ta/domain/chat.py +343 -0
- ask_ta/domain/chat_manager.py +583 -0
- ask_ta/domain/errors.py +229 -0
- ask_ta/domain/generation_settings.py +39 -0
- ask_ta/domain/llm.py +150 -0
- ask_ta/domain/macro_expansion.py +66 -0
- ask_ta/domain/pdf.py +301 -0
- ask_ta/domain/preferences.py +119 -0
- ask_ta/domain/prompts.py +317 -0
- ask_ta/domain/query_router.py +374 -0
- ask_ta/domain/quote_verifier.py +301 -0
- ask_ta/domain/retrieval.py +103 -0
- ask_ta/domain/text_normalize.py +36 -0
- ask_ta/main.py +848 -0
- ask_ta/py.typed +0 -0
- ask_ta/repositories/__init__.py +11 -0
- ask_ta/repositories/chat_store.py +480 -0
- ask_ta/repositories/preferences_store.py +83 -0
- ask_ta/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- ask_ta/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- ask_ta/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- ask_ta/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- ask_ta/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- ask_ta/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- ask_ta/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- ask_ta/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- ask_ta/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- ask_ta/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- ask_ta/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- ask_ta/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- ask_ta/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- ask_ta/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- ask_ta/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- ask_ta/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- ask_ta/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- ask_ta/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- ask_ta/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- ask_ta/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- ask_ta/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- ask_ta/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- ask_ta/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- ask_ta/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- ask_ta/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- ask_ta/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- ask_ta/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- ask_ta/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- ask_ta/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- ask_ta/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- ask_ta/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- ask_ta/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- ask_ta/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- ask_ta/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- ask_ta/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- ask_ta/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- ask_ta/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- ask_ta/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- ask_ta/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- ask_ta/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- ask_ta/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- ask_ta/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- ask_ta/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- ask_ta/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- ask_ta/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- ask_ta/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- ask_ta/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- ask_ta/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- ask_ta/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- ask_ta/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- ask_ta/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- ask_ta/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- ask_ta/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- ask_ta/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- ask_ta/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- ask_ta/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- ask_ta/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- ask_ta/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- ask_ta/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- ask_ta/static/assets/index-CD99_6nl.js +631 -0
- ask_ta/static/assets/index-CGhcjghU.css +1 -0
- ask_ta/static/favicon.svg +1 -0
- ask_ta/static/index.html +14 -0
- ask_ta/utils/__init__.py +13 -0
- ask_ta/utils/log_levels.py +12 -0
- ask_ta/utils/logging.py +324 -0
- ask_ta-0.2.3.dist-info/METADATA +188 -0
- ask_ta-0.2.3.dist-info/RECORD +138 -0
- ask_ta-0.2.3.dist-info/WHEEL +4 -0
- ask_ta-0.2.3.dist-info/entry_points.txt +2 -0
- 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,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
|
+
)
|