io-magiclinks 0.1.0__tar.gz

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.
@@ -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,25 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.8.12,<0.9"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "io-magiclinks"
7
+ version = "0.1.0"
8
+ description = "Upload browser experiments and retrieve their data with MagicLinks"
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "httpx>=0.28,<1",
12
+ "pydantic>=2,<3",
13
+ "rich>=13.9,<15",
14
+ "typer>=0.27.2,<1",
15
+ ]
16
+
17
+ [project.scripts]
18
+ io-magiclinks = "magiclinks.cli:main"
19
+
20
+ [tool.uv.build-backend]
21
+ module-name = "magiclinks"
22
+ source-exclude = ["/README.md"]
23
+
24
+ [project.urls]
25
+ Repository = "https://github.com/intelligence-observatory/magiclinks"
@@ -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
+ ]
@@ -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()
@@ -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)
@@ -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