io-magiclinks 0.1.0__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.
- io_magiclinks-0.1.0.dist-info/METADATA +10 -0
- io_magiclinks-0.1.0.dist-info/RECORD +23 -0
- io_magiclinks-0.1.0.dist-info/WHEEL +4 -0
- io_magiclinks-0.1.0.dist-info/entry_points.txt +3 -0
- magiclinks/__init__.py +11 -0
- magiclinks/_transfers.py +47 -0
- magiclinks/archive.py +401 -0
- magiclinks/auth.py +117 -0
- magiclinks/cli.py +486 -0
- magiclinks/client.py +403 -0
- magiclinks/errors.py +35 -0
- magiclinks/files.py +30 -0
- magiclinks/progress.py +63 -0
- magiclinks/py.typed +0 -0
- magiclinks/selectors.py +45 -0
- magiclinks/types/__init__.py +1 -0
- magiclinks/types/account_models.py +114 -0
- magiclinks/types/admin_models.py +73 -0
- magiclinks/types/dashboard_models.py +20 -0
- magiclinks/types/download_models.py +165 -0
- magiclinks/types/participant_models.py +107 -0
- magiclinks/types/sdk_models.py +383 -0
- magiclinks/uploads.py +459 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: io-magiclinks
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Upload browser experiments and retrieve their data with MagicLinks
|
|
5
|
+
Requires-Dist: httpx>=0.28,<1
|
|
6
|
+
Requires-Dist: pydantic>=2,<3
|
|
7
|
+
Requires-Dist: rich>=13.9,<15
|
|
8
|
+
Requires-Dist: typer>=0.27.2,<1
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Project-URL: Repository, https://github.com/intelligence-observatory/magiclinks
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
magiclinks/__init__.py,sha256=Jm2WRhpfipwttWBPiqvl4iJfjElzWq_cKi1kACjQZUk,320
|
|
2
|
+
magiclinks/_transfers.py,sha256=FPLffmABoS55AjwXie1nBrW9Tci2EzyzoqbgvCQdtI4,2020
|
|
3
|
+
magiclinks/archive.py,sha256=PbkM78lZ5SJ4zkLGHqYsV20tEVYYhgukhf2LFXIuQfg,20551
|
|
4
|
+
magiclinks/auth.py,sha256=GpMYh_rfxs9C4k-z2zO1jxNPfxVluvY45ziFF9YjgMI,4877
|
|
5
|
+
magiclinks/cli.py,sha256=mUHJZBz8nY3CBJZPqfefUcI1usllodL0fvY5jVxwhAI,23147
|
|
6
|
+
magiclinks/client.py,sha256=KXfAyEIgM7tGSzRlp4dGWLNpceYvs7uP65vCoRcvjcc,23006
|
|
7
|
+
magiclinks/errors.py,sha256=5LyRuuKUExHqUzG_bBGLvU4-84koNh_jmlLbhv_KS1s,1336
|
|
8
|
+
magiclinks/files.py,sha256=kS3IibQa7wePSUHKGze99BuqyO6LE9ORy-Ix2Qn0FfA,1054
|
|
9
|
+
magiclinks/progress.py,sha256=00V2VdxL5uryioD84IY_1j82VQVrLu6460ANhTW5ZGQ,2595
|
|
10
|
+
magiclinks/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
magiclinks/selectors.py,sha256=ua4BSFJ6r70bJLQLslxmq9AMfvSV_WH16BW62YNE0ms,1867
|
|
12
|
+
magiclinks/types/__init__.py,sha256=nIUYoyxqmOR56UpdVChTY4A86CET5zEpfLQkRX1XrN8,78
|
|
13
|
+
magiclinks/types/account_models.py,sha256=ocnLlvYDFepNIp5WBrdnPM7p0Ush7Hm6w4YyQUuKK4M,3065
|
|
14
|
+
magiclinks/types/admin_models.py,sha256=y5Cs7iCTTEAcjJhYRJgTJzRDLu2UatMX0gdaW4NIJKI,1918
|
|
15
|
+
magiclinks/types/dashboard_models.py,sha256=_Iu42pGJJ5u-qKw6IY6p_T_NtEaH_AsSgXFt9ineZVA,594
|
|
16
|
+
magiclinks/types/download_models.py,sha256=Ria9MHZdFpVFwXpsZJqkgtOfG4GzhS1cewVMoSXoK4Y,6554
|
|
17
|
+
magiclinks/types/participant_models.py,sha256=1vWgjuPF3MY0V6nYXkaCFKmj5tRhOFUxo4jRBXC_jds,3999
|
|
18
|
+
magiclinks/types/sdk_models.py,sha256=EOK6mL3SRF5DML5kd90Wb3QLWc0Mcldz4d2EeEuoQQ8,13093
|
|
19
|
+
magiclinks/uploads.py,sha256=34dP07c6FQxlIE51FkCBkftFT2PWaWb6DsJhB2sDz2U,18974
|
|
20
|
+
io_magiclinks-0.1.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
21
|
+
io_magiclinks-0.1.0.dist-info/entry_points.txt,sha256=PZwOP-gDEocu3_AswAWKP4QWCSsoOkrPR8PmJ775zDw,55
|
|
22
|
+
io_magiclinks-0.1.0.dist-info/METADATA,sha256=GVaNoECuEZmKVf37Vi4kBJo7OS1A9XVG-Vp77Yop1-w,359
|
|
23
|
+
io_magiclinks-0.1.0.dist-info/RECORD,,
|
magiclinks/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Public MagicLinks client and shared HTTP contracts."""
|
|
2
|
+
|
|
3
|
+
from .client import MagicLinksClient
|
|
4
|
+
from .errors import AuthenticationError, MagicLinksAPIError, MagicLinkCreationUncertainError
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AuthenticationError",
|
|
8
|
+
"MagicLinkCreationUncertainError",
|
|
9
|
+
"MagicLinksAPIError",
|
|
10
|
+
"MagicLinksClient",
|
|
11
|
+
]
|
magiclinks/_transfers.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Relay concurrent transfer progress and completion on the calling thread."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Generator, Iterator, Mapping
|
|
4
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from functools import partial
|
|
7
|
+
from queue import Queue
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# %% Ordered worker events
|
|
11
|
+
@contextmanager
|
|
12
|
+
def transfer_results[TKey, TItem, TResult](
|
|
13
|
+
items: Mapping[TKey, TItem],
|
|
14
|
+
operation: Callable[[TItem, Callable[[int], None] | None], TResult],
|
|
15
|
+
max_concurrency: int,
|
|
16
|
+
on_progress: Callable[[TKey, int], None] | None = None,
|
|
17
|
+
) -> Generator[Iterator[tuple[TKey, Future[TResult]]], None, None]:
|
|
18
|
+
"""Yield finished transfers after delivering their queued byte positions.
|
|
19
|
+
|
|
20
|
+
Workers report only byte counts. Callbacks and result/error handling remain
|
|
21
|
+
on the caller's thread, with completion ordered after each worker's progress.
|
|
22
|
+
"""
|
|
23
|
+
events: Queue[tuple[TKey, int | Future[TResult]]] = Queue()
|
|
24
|
+
|
|
25
|
+
def execute(key: TKey, item: TItem) -> TResult:
|
|
26
|
+
"""Attach item identity without passing credentials or policy to workers."""
|
|
27
|
+
return operation(item, (lambda count: events.put((key, count))) if on_progress is not None else None)
|
|
28
|
+
|
|
29
|
+
def completed(key: TKey, future: Future[TResult]) -> None:
|
|
30
|
+
"""Wake the consumer when a worker has returned or raised."""
|
|
31
|
+
events.put((key, future))
|
|
32
|
+
|
|
33
|
+
def results() -> Iterator[tuple[TKey, Future[TResult]]]:
|
|
34
|
+
"""Consume byte positions before exposing the corresponding completion."""
|
|
35
|
+
remaining = len(items)
|
|
36
|
+
while remaining:
|
|
37
|
+
key, event = events.get()
|
|
38
|
+
if isinstance(event, Future):
|
|
39
|
+
remaining -= 1
|
|
40
|
+
yield key, event
|
|
41
|
+
elif on_progress is not None:
|
|
42
|
+
on_progress(key, event)
|
|
43
|
+
|
|
44
|
+
with ThreadPoolExecutor(max_workers=max_concurrency) as pool:
|
|
45
|
+
for key, item in items.items():
|
|
46
|
+
pool.submit(execute, key, item).add_done_callback(partial(completed, key))
|
|
47
|
+
yield results()
|
magiclinks/archive.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""Persistent verified JSON downloads with the shared browser ZIP manifest."""
|
|
2
|
+
|
|
3
|
+
import gzip
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import zipfile
|
|
9
|
+
import zlib
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from hashlib import sha256
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from tempfile import NamedTemporaryFile
|
|
15
|
+
from typing import TYPE_CHECKING, Callable, Generator, Literal
|
|
16
|
+
from urllib.parse import quote
|
|
17
|
+
from uuid import UUID
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
from pydantic import AwareDatetime, BaseModel, Field, JsonValue
|
|
21
|
+
|
|
22
|
+
from ._transfers import transfer_results
|
|
23
|
+
from .errors import MagicLinksAPIError
|
|
24
|
+
from .files import atomic_json
|
|
25
|
+
from .progress import TransferProgress
|
|
26
|
+
from .types.download_models import (
|
|
27
|
+
CreateDownloadSelectionRequest, DownloadManifest, DownloadManifestMagicLink,
|
|
28
|
+
DownloadManifestSubmission, ListOpenDownloadSelectionResponseItem,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from .client import LinkSource, MagicLinksClient
|
|
33
|
+
|
|
34
|
+
_CHUNK_SIZE = 1024 * 1024
|
|
35
|
+
DEFAULT_MAX_SUBMISSION_BYTES = 256 * 1024**2
|
|
36
|
+
_STATE_FILENAME = ".io-magiclinks.json"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# %% Private pull state, excluded from exported research datasets
|
|
40
|
+
class _CachedSubmission(BaseModel):
|
|
41
|
+
"""Verified bytes and a complete metadata snapshot for one local submission."""
|
|
42
|
+
|
|
43
|
+
submission: DownloadManifestSubmission
|
|
44
|
+
payload_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
45
|
+
payload_size_bytes: int = Field(ge=0)
|
|
46
|
+
json_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
47
|
+
json_size_bytes: int = Field(ge=0)
|
|
48
|
+
metadata_retrieved_at: AwareDatetime
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _ArchiveState(BaseModel):
|
|
52
|
+
"""Operational cache state for efficient pulls from exactly one deployment."""
|
|
53
|
+
|
|
54
|
+
source_origin: str
|
|
55
|
+
submissions: dict[str, JsonValue] = Field(default_factory=dict)
|
|
56
|
+
selected_at: AwareDatetime | None = None
|
|
57
|
+
pulled_at: AwareDatetime | None = None
|
|
58
|
+
failures: dict[str, str] = Field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _save_state(directory: Path, state: _ArchiveState) -> None:
|
|
62
|
+
"""Checkpoint verified transfers without exposing internals in manifest.json."""
|
|
63
|
+
atomic_json(directory / _STATE_FILENAME, state.model_dump(mode="json"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# %% Local integrity and archive ownership
|
|
67
|
+
def _utc_now() -> datetime:
|
|
68
|
+
"""Use aware UTC instants for private transfer bookkeeping."""
|
|
69
|
+
return datetime.now(timezone.utc)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _private_directory(path: Path) -> None:
|
|
73
|
+
"""Create an owner-only archive directory without following a symlink."""
|
|
74
|
+
if path.is_symlink():
|
|
75
|
+
raise ValueError("Archive directories must not be symlinks")
|
|
76
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@contextmanager
|
|
80
|
+
def _archive_lock(directory: Path, origin: str) -> Generator[_ArchiveState, None, None]:
|
|
81
|
+
"""Serialize pulls and prevent an archive from mixing deployment origins."""
|
|
82
|
+
_private_directory(directory)
|
|
83
|
+
lock_path = directory / ".lock"
|
|
84
|
+
if lock_path.is_symlink():
|
|
85
|
+
raise ValueError("Archive lock must not be a symlink")
|
|
86
|
+
descriptor = os.open(lock_path, os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
87
|
+
with os.fdopen(descriptor, "r+b") as lock:
|
|
88
|
+
if sys.platform == "win32":
|
|
89
|
+
import msvcrt
|
|
90
|
+
lock.seek(0)
|
|
91
|
+
lock.write(b"\0")
|
|
92
|
+
lock.flush()
|
|
93
|
+
try:
|
|
94
|
+
msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1)
|
|
95
|
+
except OSError as error:
|
|
96
|
+
raise ValueError("Another download is using this archive") from error
|
|
97
|
+
else:
|
|
98
|
+
import fcntl
|
|
99
|
+
try:
|
|
100
|
+
fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
101
|
+
except BlockingIOError as error:
|
|
102
|
+
raise ValueError("Another download is using this archive") from error
|
|
103
|
+
state_path = directory / _STATE_FILENAME
|
|
104
|
+
if state_path.is_symlink():
|
|
105
|
+
raise ValueError("Archive metadata must not be a symlink")
|
|
106
|
+
if state_path.exists():
|
|
107
|
+
try:
|
|
108
|
+
state = _ArchiveState.model_validate_json(state_path.read_bytes())
|
|
109
|
+
except (OSError, ValueError) as error:
|
|
110
|
+
raise ValueError("Private archive state is unreadable; choose a new destination") from error
|
|
111
|
+
if state.source_origin != origin:
|
|
112
|
+
raise ValueError("This archive belongs to another deployment; choose a different destination")
|
|
113
|
+
else:
|
|
114
|
+
state = _ArchiveState(source_origin=origin)
|
|
115
|
+
_save_state(directory, state)
|
|
116
|
+
_private_directory(directory / "submissions")
|
|
117
|
+
yield state
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _digest_file(path: Path) -> tuple[int, str]:
|
|
121
|
+
"""Hash a regular local file in bounded chunks."""
|
|
122
|
+
if path.is_symlink() or not path.is_file():
|
|
123
|
+
raise ValueError("Submission is not a regular file")
|
|
124
|
+
digest = sha256()
|
|
125
|
+
size = 0
|
|
126
|
+
with path.open("rb") as source:
|
|
127
|
+
while chunk := source.read(_CHUNK_SIZE):
|
|
128
|
+
digest.update(chunk)
|
|
129
|
+
size += len(chunk)
|
|
130
|
+
return size, digest.hexdigest()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _submission(item: ListOpenDownloadSelectionResponseItem, runtime_context: dict[str, JsonValue]) -> DownloadManifestSubmission:
|
|
134
|
+
"""Project selected metadata onto the scientific dataset without transfer fields."""
|
|
135
|
+
return DownloadManifestSubmission(
|
|
136
|
+
open_id=item.open_id, magiclink_id=item.magiclink_id,
|
|
137
|
+
participant_id=item.participant_id, participant_namespace=item.participant_namespace,
|
|
138
|
+
participant_namespace_id=item.participant_namespace_id,
|
|
139
|
+
timestamp_started=item.timestamp_started, timestamp_submitted=item.timestamp_submitted,
|
|
140
|
+
path=f"submissions/{item.open_id}.json", runtime_context=runtime_context,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _cached_file(directory: Path, state: _ArchiveState, item: ListOpenDownloadSelectionResponseItem) -> _CachedSubmission | None:
|
|
145
|
+
"""Reuse decoded bytes only when both immutable identity and checksum match."""
|
|
146
|
+
try:
|
|
147
|
+
saved = _CachedSubmission.model_validate(state.submissions.get(str(item.open_id)))
|
|
148
|
+
if (saved.submission.open_id != item.open_id or saved.submission.magiclink_id != item.magiclink_id
|
|
149
|
+
or saved.payload_sha256 != item.payload_sha256 or saved.payload_size_bytes != item.payload_size_bytes):
|
|
150
|
+
return None
|
|
151
|
+
if _digest_file(directory / saved.submission.path) != (saved.json_size_bytes, saved.json_sha256):
|
|
152
|
+
return None
|
|
153
|
+
except (OSError, ValueError):
|
|
154
|
+
return None
|
|
155
|
+
# Refresh selection metadata while retaining the verified runtime snapshot.
|
|
156
|
+
return saved.model_copy(update={"submission": _submission(item, saved.submission.runtime_context)})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _reject_constant(value: str) -> None:
|
|
160
|
+
"""Match JSON.parse by rejecting NaN and Infinity extensions."""
|
|
161
|
+
raise ValueError(f"Invalid JSON constant: {value}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# %% Independent credential-free transfers
|
|
165
|
+
def _stream_verified(transfer: httpx.Client, url: str, expected_size: int, expected_sha256: str,
|
|
166
|
+
destination: Path, progress: Callable[[int], None] | None = None) -> None:
|
|
167
|
+
"""Receive the original signed bytes, checking length and SHA-256."""
|
|
168
|
+
digest = sha256()
|
|
169
|
+
size = 0
|
|
170
|
+
with destination.open("wb") as output:
|
|
171
|
+
with transfer.stream("GET", url, headers={"Accept-Encoding": "identity"}) as response:
|
|
172
|
+
if response.status_code != 200:
|
|
173
|
+
raise MagicLinksAPIError(response.status_code, "Storage transfer failed")
|
|
174
|
+
for chunk in response.iter_raw():
|
|
175
|
+
output.write(chunk)
|
|
176
|
+
digest.update(chunk)
|
|
177
|
+
size += len(chunk)
|
|
178
|
+
if size > expected_size:
|
|
179
|
+
raise ValueError("Stored payload exceeds the expected size")
|
|
180
|
+
if progress is not None:
|
|
181
|
+
progress(size)
|
|
182
|
+
output.flush()
|
|
183
|
+
os.fsync(output.fileno())
|
|
184
|
+
if size != expected_size or digest.hexdigest() != expected_sha256:
|
|
185
|
+
raise ValueError("Stored bytes failed size or SHA-256 verification")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _download_one(client: "MagicLinksClient", transfer: httpx.Client, directory: Path,
|
|
189
|
+
item: ListOpenDownloadSelectionResponseItem, on_progress: Callable[[int], None] | None,
|
|
190
|
+
max_submission_bytes: int) -> _CachedSubmission:
|
|
191
|
+
"""Download, verify, decode, validate, and atomically retain one submission."""
|
|
192
|
+
for attempt in range(3):
|
|
193
|
+
compressed_path: Path | None = None
|
|
194
|
+
decoded_path: Path | None = None
|
|
195
|
+
if on_progress is not None:
|
|
196
|
+
on_progress(0)
|
|
197
|
+
try:
|
|
198
|
+
detail = client.get_open(item.open_id)
|
|
199
|
+
if (detail.magiclink_id != item.magiclink_id or detail.status != "completed" or not detail.download_url
|
|
200
|
+
or detail.payload_sha256 != item.payload_sha256 or detail.payload_size_bytes != item.payload_size_bytes):
|
|
201
|
+
raise ValueError("Submission identity changed after selection; start a new download")
|
|
202
|
+
with NamedTemporaryFile(dir=directory, prefix=".payload.", delete=False) as compressed:
|
|
203
|
+
compressed_path = Path(compressed.name)
|
|
204
|
+
_stream_verified(transfer, detail.download_url, item.payload_size_bytes, item.payload_sha256,
|
|
205
|
+
compressed_path, on_progress)
|
|
206
|
+
digest = sha256()
|
|
207
|
+
size = 0
|
|
208
|
+
with NamedTemporaryFile(dir=directory / "submissions", prefix=f".{item.open_id}.", delete=False) as decoded:
|
|
209
|
+
decoded_path = Path(decoded.name)
|
|
210
|
+
try:
|
|
211
|
+
with gzip.open(compressed_path, "rb") as source:
|
|
212
|
+
while chunk := source.read(_CHUNK_SIZE):
|
|
213
|
+
size += len(chunk)
|
|
214
|
+
if size > max_submission_bytes:
|
|
215
|
+
raise ValueError("Decoded submission exceeds max_submission_bytes; raise the limit only for trusted data")
|
|
216
|
+
decoded.write(chunk)
|
|
217
|
+
digest.update(chunk)
|
|
218
|
+
except (EOFError, zlib.error) as error:
|
|
219
|
+
raise ValueError("Stored submission is not valid gzip-compressed JSON") from error
|
|
220
|
+
decoded.flush()
|
|
221
|
+
os.fsync(decoded.fileno())
|
|
222
|
+
# Validate UTF-8 JSON, then preserve the exact original bytes.
|
|
223
|
+
try:
|
|
224
|
+
with decoded_path.open("r", encoding="utf-8") as json_source:
|
|
225
|
+
json.load(json_source, parse_constant=_reject_constant)
|
|
226
|
+
except RecursionError as error:
|
|
227
|
+
raise ValueError("Submission JSON exceeds the decoder nesting limit") from error
|
|
228
|
+
metadata = _CachedSubmission(
|
|
229
|
+
submission=_submission(item, detail.runtime_context),
|
|
230
|
+
payload_sha256=item.payload_sha256, payload_size_bytes=item.payload_size_bytes,
|
|
231
|
+
json_sha256=digest.hexdigest(), json_size_bytes=size,
|
|
232
|
+
metadata_retrieved_at=_utc_now(),
|
|
233
|
+
)
|
|
234
|
+
os.replace(decoded_path, directory / metadata.submission.path)
|
|
235
|
+
return metadata
|
|
236
|
+
except (httpx.RequestError, MagicLinksAPIError) as error:
|
|
237
|
+
if isinstance(error, MagicLinksAPIError) and error.status_code not in {403, 408, 429, 500, 502, 503, 504}:
|
|
238
|
+
raise
|
|
239
|
+
if attempt == 2:
|
|
240
|
+
raise
|
|
241
|
+
time.sleep(0.25 * 2**attempt)
|
|
242
|
+
finally:
|
|
243
|
+
if compressed_path is not None:
|
|
244
|
+
compressed_path.unlink(missing_ok=True)
|
|
245
|
+
if decoded_path is not None:
|
|
246
|
+
decoded_path.unlink(missing_ok=True)
|
|
247
|
+
raise RuntimeError("Submission transfer failed")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _zip(directory: Path, manifest: DownloadManifest, files: dict[UUID, _CachedSubmission]) -> None:
|
|
251
|
+
"""Build a ZIP from disk and publish it only after every entry is complete."""
|
|
252
|
+
temporary: Path | None = None
|
|
253
|
+
try:
|
|
254
|
+
with NamedTemporaryFile(dir=directory, prefix=".submissions.", suffix=".zip", delete=False) as output:
|
|
255
|
+
temporary = Path(output.name)
|
|
256
|
+
with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6, allowZip64=True) as archive:
|
|
257
|
+
for submission in manifest.submissions:
|
|
258
|
+
cached = files[submission.open_id]
|
|
259
|
+
path = directory / submission.path
|
|
260
|
+
if path.is_symlink():
|
|
261
|
+
raise ValueError("A local submission changed while preparing the ZIP")
|
|
262
|
+
digest = sha256()
|
|
263
|
+
size = 0
|
|
264
|
+
with path.open("rb") as source, archive.open(submission.path, "w", force_zip64=True) as output:
|
|
265
|
+
while chunk := source.read(_CHUNK_SIZE):
|
|
266
|
+
output.write(chunk)
|
|
267
|
+
digest.update(chunk)
|
|
268
|
+
size += len(chunk)
|
|
269
|
+
if size != cached.json_size_bytes or digest.hexdigest() != cached.json_sha256:
|
|
270
|
+
raise ValueError("A local submission changed while preparing the ZIP; repeat the download")
|
|
271
|
+
archive.writestr("manifest.json", manifest.model_dump_json(indent=2) + "\n")
|
|
272
|
+
with temporary.open("rb") as output:
|
|
273
|
+
os.fsync(output.fileno())
|
|
274
|
+
name = "submissions-partial.zip" if manifest.is_partial else "submissions.zip"
|
|
275
|
+
os.replace(temporary, directory / name)
|
|
276
|
+
finally:
|
|
277
|
+
if temporary is not None:
|
|
278
|
+
temporary.unlink(missing_ok=True)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# %% Public pull workflow
|
|
282
|
+
def download(client: "MagicLinksClient", source: "LinkSource", directory: Path, *, open_ids: list[UUID] | None = None,
|
|
283
|
+
submitted_after: datetime | None = None, submitted_before: datetime | None = None,
|
|
284
|
+
participant_namespace: Literal["anon", "prolific"] | None = None, max_concurrency: int = 6,
|
|
285
|
+
max_submission_bytes: int = DEFAULT_MAX_SUBMISSION_BYTES, partial: bool = False, on_progress: Callable[[TransferProgress], None] | None = None,
|
|
286
|
+
on_new_open: Callable[[UUID], None] | None = None) -> DownloadManifest:
|
|
287
|
+
"""Rediscover exact membership and reuse verified persistent submission files."""
|
|
288
|
+
if not 1 <= max_concurrency <= 32:
|
|
289
|
+
raise ValueError("max_concurrency must be between 1 and 32")
|
|
290
|
+
if max_submission_bytes < 1:
|
|
291
|
+
raise ValueError("max_submission_bytes must be positive")
|
|
292
|
+
ids = [link.magiclink_id for link in client.resolve_magiclinks(source)]
|
|
293
|
+
request = CreateDownloadSelectionRequest(magiclink_ids=ids, open_ids=open_ids, submitted_after=submitted_after,
|
|
294
|
+
submitted_before=submitted_before, participant_namespace=participant_namespace)
|
|
295
|
+
selection = client.select_download(request)
|
|
296
|
+
with _archive_lock(directory, client.base_url) as state:
|
|
297
|
+
files: dict[UUID, _CachedSubmission] = {}
|
|
298
|
+
failures: dict[UUID, str] = {}
|
|
299
|
+
positions = {item.open_id: 0 for item in selection.items}
|
|
300
|
+
by_id = {item.open_id: item for item in selection.items}
|
|
301
|
+
total = sum(item.payload_size_bytes for item in selection.items)
|
|
302
|
+
completed_bytes = 0
|
|
303
|
+
reused_bytes = 0
|
|
304
|
+
|
|
305
|
+
def emit(open_id: UUID, count: int) -> None:
|
|
306
|
+
"""Publish worker byte positions on this calling thread."""
|
|
307
|
+
nonlocal completed_bytes
|
|
308
|
+
completed_bytes += count - positions[open_id]
|
|
309
|
+
positions[open_id] = count
|
|
310
|
+
if on_progress is not None:
|
|
311
|
+
on_progress(TransferProgress(completed_bytes=completed_bytes, total_bytes=total,
|
|
312
|
+
reused_bytes=reused_bytes, file_path=f"submissions/{open_id}.json",
|
|
313
|
+
file_completed_bytes=count, file_total_bytes=by_id[open_id].payload_size_bytes, phase="downloading"))
|
|
314
|
+
|
|
315
|
+
pending: list[ListOpenDownloadSelectionResponseItem] = []
|
|
316
|
+
for item in selection.items:
|
|
317
|
+
saved = _cached_file(directory, state, item)
|
|
318
|
+
if saved is not None and saved.json_size_bytes > max_submission_bytes:
|
|
319
|
+
failures[item.open_id] = "Decoded submission exceeds max_submission_bytes; raise the limit only for trusted data"
|
|
320
|
+
elif saved is not None:
|
|
321
|
+
files[item.open_id] = saved
|
|
322
|
+
state.submissions[str(item.open_id)] = saved.model_dump(mode="json")
|
|
323
|
+
reused_bytes += item.payload_size_bytes
|
|
324
|
+
emit(item.open_id, item.payload_size_bytes)
|
|
325
|
+
else:
|
|
326
|
+
pending.append(item)
|
|
327
|
+
if str(item.open_id) not in state.submissions and on_new_open is not None:
|
|
328
|
+
on_new_open(item.open_id)
|
|
329
|
+
state.selected_at = selection.selected_at
|
|
330
|
+
_save_state(directory, state)
|
|
331
|
+
checkpoint_at = time.monotonic()
|
|
332
|
+
with httpx.Client(timeout=client.timeout) as transfer:
|
|
333
|
+
def receive(item: ListOpenDownloadSelectionResponseItem, report: Callable[[int], None] | None) -> _CachedSubmission:
|
|
334
|
+
"""Keep authenticated metadata separate from storage transfers."""
|
|
335
|
+
return _download_one(client, transfer, directory, item, report, max_submission_bytes)
|
|
336
|
+
|
|
337
|
+
with transfer_results({item.open_id: item for item in pending}, receive,
|
|
338
|
+
max_concurrency, emit if on_progress is not None else None) as results:
|
|
339
|
+
for open_id, future in results:
|
|
340
|
+
try:
|
|
341
|
+
downloaded = future.result()
|
|
342
|
+
except (ValueError, OSError, httpx.RequestError, MagicLinksAPIError) as error:
|
|
343
|
+
failures[open_id] = _failure_message(error)
|
|
344
|
+
else:
|
|
345
|
+
files[open_id] = downloaded
|
|
346
|
+
state.submissions[str(open_id)] = downloaded.model_dump(mode="json")
|
|
347
|
+
# One shared JSON cache stays cheap even for many tiny submissions.
|
|
348
|
+
if time.monotonic() - checkpoint_at >= 1:
|
|
349
|
+
_save_state(directory, state)
|
|
350
|
+
checkpoint_at = time.monotonic()
|
|
351
|
+
emit(open_id, by_id[open_id].payload_size_bytes)
|
|
352
|
+
state.failures = {str(open_id): message for open_id, message in failures.items()}
|
|
353
|
+
state.pulled_at = _utc_now()
|
|
354
|
+
_save_state(directory, state)
|
|
355
|
+
criteria = request.model_dump(mode="json", exclude_none=True)
|
|
356
|
+
filters = {key: criteria[key] for key in ("submitted_after", "submitted_before", "participant_namespace") if key in criteria}
|
|
357
|
+
manifest = DownloadManifest(
|
|
358
|
+
magiclinks=[DownloadManifestMagicLink(magiclink_id=link.magiclink_id, name=link.name, tags=link.tags) for link in selection.magiclinks],
|
|
359
|
+
submissions=[files[item.open_id].submission for item in selection.items if item.open_id in files],
|
|
360
|
+
filters=filters, missing_open_ids=[item.open_id for item in selection.items if item.open_id in failures],
|
|
361
|
+
)
|
|
362
|
+
if not failures or partial:
|
|
363
|
+
_zip(directory, manifest, files)
|
|
364
|
+
atomic_json(directory / "manifest.json", manifest.model_dump(mode="json"))
|
|
365
|
+
return manifest
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _failure_message(error: Exception) -> str:
|
|
369
|
+
"""Keep signed storage URLs out of private per-Open error messages."""
|
|
370
|
+
if isinstance(error, httpx.RequestError):
|
|
371
|
+
return "Transfer connection failed after bounded retries"
|
|
372
|
+
if isinstance(error, MagicLinksAPIError):
|
|
373
|
+
return f"API or storage returned HTTP {error.status_code}"
|
|
374
|
+
if isinstance(error, OSError):
|
|
375
|
+
return "Could not read or write the local submission"
|
|
376
|
+
return str(error)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def download_file(client: "MagicLinksClient", reference: str | UUID, file_path: str, destination: Path) -> Path:
|
|
380
|
+
"""Follow one authenticated file redirect using a credential-free transfer."""
|
|
381
|
+
link = client.get_magiclink(reference)
|
|
382
|
+
entry = next((file for file in link.files if file.path == file_path), None)
|
|
383
|
+
if entry is None:
|
|
384
|
+
raise ValueError("The requested path is not in this MagicLink's file manifest")
|
|
385
|
+
if destination.exists() or destination.is_symlink():
|
|
386
|
+
raise FileExistsError(destination)
|
|
387
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
388
|
+
response = client._http.get(f"/api/magiclinks/{link.magiclink_id}/files/{quote(file_path, safe='/')}", follow_redirects=False)
|
|
389
|
+
if response.status_code != 307 or "location" not in response.headers:
|
|
390
|
+
raise MagicLinksAPIError(response.status_code, "File route did not return a download capability")
|
|
391
|
+
temporary: Path | None = None
|
|
392
|
+
try:
|
|
393
|
+
with NamedTemporaryFile(dir=destination.parent, prefix=f".{destination.name}.", delete=False) as output:
|
|
394
|
+
temporary = Path(output.name)
|
|
395
|
+
with httpx.Client(timeout=client.timeout) as transfer:
|
|
396
|
+
_stream_verified(transfer, response.headers["location"], entry.size_bytes, entry.sha256, temporary)
|
|
397
|
+
os.link(temporary, destination)
|
|
398
|
+
return destination
|
|
399
|
+
finally:
|
|
400
|
+
if temporary is not None:
|
|
401
|
+
temporary.unlink(missing_ok=True)
|
magiclinks/auth.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Discover a deployment and privately stored API key without named profiles."""
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from .errors import AuthenticationError
|
|
12
|
+
from .files import atomic_json
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://intelligence-observatory.org"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# %% Deployment configuration
|
|
18
|
+
def normalize_base_url(value: str) -> str:
|
|
19
|
+
"""Require HTTPS, allowing plain HTTP only for local development."""
|
|
20
|
+
parsed = urlsplit(value.rstrip("/"))
|
|
21
|
+
try:
|
|
22
|
+
parsed.port
|
|
23
|
+
except ValueError as error:
|
|
24
|
+
raise ValueError("Deployment URL has an invalid port") from error
|
|
25
|
+
if (
|
|
26
|
+
not parsed.hostname
|
|
27
|
+
or parsed.username is not None
|
|
28
|
+
or parsed.password is not None
|
|
29
|
+
or parsed.query
|
|
30
|
+
or parsed.fragment
|
|
31
|
+
or parsed.path not in {"", "/"}
|
|
32
|
+
or parsed.scheme not in {"http", "https"}
|
|
33
|
+
or (parsed.scheme == "http" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"})
|
|
34
|
+
):
|
|
35
|
+
raise ValueError("Use an HTTPS deployment origin, or HTTP on localhost for development")
|
|
36
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def configured_base_url(explicit: str | None = None) -> str:
|
|
40
|
+
"""Choose an explicit origin, environment origin, or public deployment."""
|
|
41
|
+
return normalize_base_url(explicit or os.environ.get("IO_MAGICLINKS_BASE_URL") or DEFAULT_BASE_URL)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def credential_path() -> Path:
|
|
45
|
+
"""Locate the configuration outside experiment and project directories."""
|
|
46
|
+
root = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
|
|
47
|
+
if not root.is_absolute():
|
|
48
|
+
raise AuthenticationError("XDG_CONFIG_HOME must be an absolute directory outside the project")
|
|
49
|
+
return root / "io-magiclinks" / "credentials.json"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def require_external_credentials(directory: Path) -> None:
|
|
53
|
+
"""Reject uploads that could include the client's managed credential file."""
|
|
54
|
+
root, credential = directory.resolve(), credential_path().resolve()
|
|
55
|
+
if credential.is_relative_to(root) or (
|
|
56
|
+
root.exists() and any(parent.exists() and parent.samefile(root) for parent in credential.parents)
|
|
57
|
+
):
|
|
58
|
+
raise AuthenticationError("Keep the credential file outside the uploaded folder")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# %% Private credential storage
|
|
62
|
+
def _normalize_api_key(value: str) -> str:
|
|
63
|
+
"""Validate header-safe key text without echoing a malformed credential."""
|
|
64
|
+
key = value.strip()
|
|
65
|
+
if not key or any(not 33 <= ord(character) <= 126 for character in key):
|
|
66
|
+
raise AuthenticationError("API key must contain printable ASCII without whitespace")
|
|
67
|
+
return key
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _read_credentials() -> dict[str, str]:
|
|
71
|
+
"""Read only a regular owner-only credential file, never following symlinks."""
|
|
72
|
+
path = credential_path()
|
|
73
|
+
if not path.exists():
|
|
74
|
+
return {}
|
|
75
|
+
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
76
|
+
with os.fdopen(descriptor, "r", encoding="utf-8") as source:
|
|
77
|
+
info = os.fstat(source.fileno())
|
|
78
|
+
if not stat.S_ISREG(info.st_mode) or (os.name != "nt" and (info.st_mode & 0o077 or info.st_uid != os.getuid())):
|
|
79
|
+
raise AuthenticationError("Credential file must be owned by you and readable only by you (chmod 600)")
|
|
80
|
+
try:
|
|
81
|
+
data = json.load(source)
|
|
82
|
+
except (ValueError, OSError) as error:
|
|
83
|
+
raise AuthenticationError("Could not read the local credential file") from error
|
|
84
|
+
if not isinstance(data, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in data.items()):
|
|
85
|
+
raise AuthenticationError("Local credential file has an invalid format")
|
|
86
|
+
return data
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def discover_api_key(base_url: str, explicit: str | None = None) -> str | None:
|
|
90
|
+
"""Prefer a supplied key, then environment, then private local credentials."""
|
|
91
|
+
key = explicit if explicit is not None else os.environ.get("IO_MAGICLINKS_API_KEY")
|
|
92
|
+
if key is None:
|
|
93
|
+
key = _read_credentials().get(base_url)
|
|
94
|
+
return _normalize_api_key(key) if key is not None else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def save_api_key(base_url: str, api_key: str) -> None:
|
|
98
|
+
"""Atomically persist an accepted API key outside the working directory."""
|
|
99
|
+
key = _normalize_api_key(api_key)
|
|
100
|
+
data = _read_credentials()
|
|
101
|
+
data[base_url] = key
|
|
102
|
+
path = credential_path()
|
|
103
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
104
|
+
atomic_json(path, data)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def prompt_secret(label: str) -> str:
|
|
108
|
+
"""Read a hidden credential only from a terminal."""
|
|
109
|
+
if not sys.stdin.isatty():
|
|
110
|
+
raise AuthenticationError(
|
|
111
|
+
"Credentials are missing in a noninteractive session. Set IO_MAGICLINKS_API_KEY "
|
|
112
|
+
"or run the command once in a terminal to paste your API key."
|
|
113
|
+
)
|
|
114
|
+
value = getpass.getpass(label, stream=sys.stderr).strip()
|
|
115
|
+
if not value:
|
|
116
|
+
raise AuthenticationError("No credential was provided")
|
|
117
|
+
return value
|