hyperstudy 0.2.2__tar.gz → 0.2.3__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.
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/PKG-INFO +1 -1
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/pyproject.toml +1 -1
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/__init__.py +1 -1
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/_downloads.py +31 -5
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/_http.py +10 -3
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/_pagination.py +3 -2
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/client.py +36 -10
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/test_client.py +135 -0
- hyperstudy-0.2.3/tests/test_downloads.py +188 -0
- hyperstudy-0.2.3/tests/test_http.py +54 -0
- hyperstudy-0.2.2/tests/test_downloads.py +0 -105
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/.github/workflows/publish.yml +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/.github/workflows/sync-release-notes.yml +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/.github/workflows/test.yml +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/.gitignore +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/CHANGELOG.md +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/LICENSE +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/README.md +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/docs/superpowers/specs/2026-04-10-recording-downloads-design.md +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/_dataframe.py +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/_display.py +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/_types.py +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/exceptions.py +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/experiments.py +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/__init__.py +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/conftest.py +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/deployment_sessions_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/deployment_single_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/deployments_list_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/error_401.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/error_403.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/events_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/experiment_single_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/experiments_list_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/paginated_page1.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/paginated_page2.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/pre_experiment_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/recordings_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/sparse_ratings_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/warnings_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/test_dataframe.py +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/test_experiments.py +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/test_pagination.py +0 -0
|
@@ -38,13 +38,39 @@ def build_filename(recording: dict[str, Any]) -> str:
|
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
def download_file(url: str, dest: Path, timeout: int = 300) -> int:
|
|
41
|
-
"""Stream-download *url* to *dest* and return bytes written.
|
|
41
|
+
"""Stream-download *url* to *dest* and return bytes written.
|
|
42
|
+
|
|
43
|
+
Verifies ``Content-Length`` (when the server provides it) against bytes
|
|
44
|
+
actually written. On any error — HTTP, mid-stream disconnect, or length
|
|
45
|
+
mismatch — the partial file on disk is removed so ``skip_existing``
|
|
46
|
+
logic won't mistake it for a complete download on retry.
|
|
47
|
+
"""
|
|
42
48
|
resp = requests.get(url, stream=True, timeout=timeout)
|
|
43
49
|
resp.raise_for_status()
|
|
44
50
|
|
|
51
|
+
expected: int | None
|
|
52
|
+
if "Content-Length" in resp.headers:
|
|
53
|
+
try:
|
|
54
|
+
expected = int(resp.headers["Content-Length"])
|
|
55
|
+
except (TypeError, ValueError):
|
|
56
|
+
expected = None
|
|
57
|
+
else:
|
|
58
|
+
expected = None
|
|
59
|
+
|
|
45
60
|
written = 0
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
61
|
+
# BaseException so Ctrl-C also cleans up, not just exceptions.
|
|
62
|
+
try:
|
|
63
|
+
with open(dest, "wb") as fh:
|
|
64
|
+
for chunk in resp.iter_content(chunk_size=_CHUNK_SIZE):
|
|
65
|
+
fh.write(chunk)
|
|
66
|
+
written += len(chunk)
|
|
67
|
+
|
|
68
|
+
if expected is not None and written != expected:
|
|
69
|
+
raise IOError(
|
|
70
|
+
f"Truncated download: wrote {written} of {expected} bytes for {dest.name}"
|
|
71
|
+
)
|
|
72
|
+
except BaseException:
|
|
73
|
+
dest.unlink(missing_ok=True)
|
|
74
|
+
raise
|
|
75
|
+
|
|
50
76
|
return written
|
|
@@ -63,8 +63,14 @@ class HttpTransport:
|
|
|
63
63
|
# Public helpers
|
|
64
64
|
# ------------------------------------------------------------------
|
|
65
65
|
|
|
66
|
-
def get(
|
|
67
|
-
|
|
66
|
+
def get(
|
|
67
|
+
self,
|
|
68
|
+
path: str,
|
|
69
|
+
params: dict[str, Any] | None = None,
|
|
70
|
+
*,
|
|
71
|
+
timeout: int | float | None = None,
|
|
72
|
+
) -> dict:
|
|
73
|
+
return self._request("GET", path, params=params, timeout=timeout)
|
|
68
74
|
|
|
69
75
|
def post(self, path: str, json: dict[str, Any] | None = None) -> dict:
|
|
70
76
|
return self._request("POST", path, json=json)
|
|
@@ -81,7 +87,8 @@ class HttpTransport:
|
|
|
81
87
|
|
|
82
88
|
def _request(self, method: str, path: str, **kwargs) -> dict:
|
|
83
89
|
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
84
|
-
kwargs.
|
|
90
|
+
if kwargs.get("timeout") is None:
|
|
91
|
+
kwargs["timeout"] = self.timeout
|
|
85
92
|
|
|
86
93
|
resp = self._session.request(method, url, **kwargs)
|
|
87
94
|
return self._handle_response(resp)
|
|
@@ -16,6 +16,7 @@ def fetch_all_pages(
|
|
|
16
16
|
*,
|
|
17
17
|
page_size: int = 1000,
|
|
18
18
|
progress: bool = True,
|
|
19
|
+
timeout: int | float | None = None,
|
|
19
20
|
) -> tuple[list[dict], dict]:
|
|
20
21
|
"""Fetch every page of a paginated endpoint.
|
|
21
22
|
|
|
@@ -28,7 +29,7 @@ def fetch_all_pages(
|
|
|
28
29
|
params.setdefault("offset", 0)
|
|
29
30
|
|
|
30
31
|
# First request to learn total
|
|
31
|
-
body = transport.get(path, params=params)
|
|
32
|
+
body = transport.get(path, params=params, timeout=timeout)
|
|
32
33
|
metadata = body.get("metadata", {})
|
|
33
34
|
pagination = metadata.get("pagination", {})
|
|
34
35
|
total = pagination.get("total", 0)
|
|
@@ -43,7 +44,7 @@ def fetch_all_pages(
|
|
|
43
44
|
while has_more:
|
|
44
45
|
params["offset"] = pagination.get("nextOffset", params["offset"] + page_size)
|
|
45
46
|
|
|
46
|
-
body = transport.get(path, params=params)
|
|
47
|
+
body = transport.get(path, params=params, timeout=timeout)
|
|
47
48
|
metadata = body.get("metadata", {})
|
|
48
49
|
pagination = metadata.get("pagination", {})
|
|
49
50
|
|
|
@@ -13,6 +13,7 @@ from ._downloads import build_filename, download_file, get_download_url
|
|
|
13
13
|
from ._http import HttpTransport
|
|
14
14
|
from ._pagination import fetch_all_pages
|
|
15
15
|
from ._types import Scope
|
|
16
|
+
from .exceptions import HyperStudyError
|
|
16
17
|
from .experiments import ExperimentMixin
|
|
17
18
|
|
|
18
19
|
|
|
@@ -533,13 +534,24 @@ class HyperStudy(ExperimentMixin):
|
|
|
533
534
|
pandas DataFrame with recording metadata plus ``local_path``
|
|
534
535
|
and ``download_status`` columns.
|
|
535
536
|
"""
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
537
|
+
try:
|
|
538
|
+
# Signing per-recording GCS URLs scales with count; 30s default is too short.
|
|
539
|
+
recordings = self._fetch_data(
|
|
540
|
+
"recordings",
|
|
541
|
+
scope_id,
|
|
542
|
+
scope=scope,
|
|
543
|
+
deployment_id=deployment_id,
|
|
544
|
+
room_id=room_id,
|
|
545
|
+
output="dict",
|
|
546
|
+
timeout=300,
|
|
547
|
+
)
|
|
548
|
+
except Exception as exc:
|
|
549
|
+
raise HyperStudyError(
|
|
550
|
+
f"Failed to list recordings for {scope}={scope_id!r}: {exc}",
|
|
551
|
+
code=getattr(exc, "code", "LIST_RECORDINGS_FAILED"),
|
|
552
|
+
status_code=getattr(exc, "status_code", None),
|
|
553
|
+
details=getattr(exc, "details", None),
|
|
554
|
+
) from exc
|
|
543
555
|
|
|
544
556
|
if recording_type:
|
|
545
557
|
recordings = [
|
|
@@ -566,7 +578,9 @@ class HyperStudy(ExperimentMixin):
|
|
|
566
578
|
|
|
567
579
|
if skip_existing and dest.exists():
|
|
568
580
|
expected_size = rec.get("fileSize")
|
|
569
|
-
|
|
581
|
+
# Require positive size match; missing size → re-download to
|
|
582
|
+
# avoid trusting a truncated file from a prior failed run.
|
|
583
|
+
if expected_size is not None and dest.stat().st_size == expected_size:
|
|
570
584
|
local_paths.append(str(dest.resolve()))
|
|
571
585
|
statuses.append("skipped")
|
|
572
586
|
continue
|
|
@@ -582,6 +596,13 @@ class HyperStudy(ExperimentMixin):
|
|
|
582
596
|
f"Failed to download recording {rec.get('recordingId')}: {exc}"
|
|
583
597
|
)
|
|
584
598
|
|
|
599
|
+
failed_count = sum(1 for s in statuses if s == "failed")
|
|
600
|
+
if failed_count:
|
|
601
|
+
warnings.warn(
|
|
602
|
+
f"{failed_count}/{len(recordings)} recordings failed to download; "
|
|
603
|
+
"see the 'download_status' column for per-file results."
|
|
604
|
+
)
|
|
605
|
+
|
|
585
606
|
df = to_pandas(recordings)
|
|
586
607
|
if not df.empty:
|
|
587
608
|
df["local_path"] = local_paths
|
|
@@ -649,6 +670,7 @@ class HyperStudy(ExperimentMixin):
|
|
|
649
670
|
offset: int = 0,
|
|
650
671
|
output: str = "pandas",
|
|
651
672
|
progress: bool = True,
|
|
673
|
+
timeout: int | float | None = None,
|
|
652
674
|
**extra_params,
|
|
653
675
|
):
|
|
654
676
|
"""Generic data-fetching logic shared by all ``get_*`` methods."""
|
|
@@ -675,11 +697,15 @@ class HyperStudy(ExperimentMixin):
|
|
|
675
697
|
# Single page or all pages
|
|
676
698
|
if limit is not None:
|
|
677
699
|
params["limit"] = limit
|
|
678
|
-
body = self._transport.get(path, params=params)
|
|
700
|
+
body = self._transport.get(path, params=params, timeout=timeout)
|
|
679
701
|
data = body.get("data", [])
|
|
680
702
|
else:
|
|
681
703
|
data, _ = fetch_all_pages(
|
|
682
|
-
self._transport,
|
|
704
|
+
self._transport,
|
|
705
|
+
path,
|
|
706
|
+
params=params,
|
|
707
|
+
progress=progress,
|
|
708
|
+
timeout=timeout,
|
|
683
709
|
)
|
|
684
710
|
|
|
685
711
|
return self._convert_output(data, output)
|
|
@@ -356,6 +356,141 @@ def test_download_recordings_skip_existing(api_key, recordings_response, tmp_pat
|
|
|
356
356
|
assert df["download_status"].iloc[1] == "downloaded"
|
|
357
357
|
|
|
358
358
|
|
|
359
|
+
@responses.activate
|
|
360
|
+
def test_download_recordings_skip_requires_known_size(
|
|
361
|
+
api_key, recordings_response, tmp_path
|
|
362
|
+
):
|
|
363
|
+
"""When server-side fileSize is missing, skip_existing must NOT accept a
|
|
364
|
+
stale on-disk file — it must re-download so truncated prior attempts
|
|
365
|
+
don't linger forever."""
|
|
366
|
+
# Strip fileSize from the video record to simulate older recordings where
|
|
367
|
+
# the backend didn't store file size metadata.
|
|
368
|
+
recordings_response["data"][0]["fileSize"] = None
|
|
369
|
+
|
|
370
|
+
responses.get(
|
|
371
|
+
f"{BASE_URL}/data/recordings/experiment/exp_abc123",
|
|
372
|
+
json=recordings_response,
|
|
373
|
+
status=200,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# Pre-create a (probably partial) video file on disk.
|
|
377
|
+
video_path = tmp_path / "Alice_video_EG_video_001.mp4"
|
|
378
|
+
video_path.write_bytes(b"stale-partial-content")
|
|
379
|
+
|
|
380
|
+
fresh_video = b"freshly downloaded video bytes" * 20
|
|
381
|
+
responses.get(
|
|
382
|
+
recordings_response["data"][0]["downloadUrl"],
|
|
383
|
+
body=fresh_video,
|
|
384
|
+
status=200,
|
|
385
|
+
)
|
|
386
|
+
responses.get(
|
|
387
|
+
recordings_response["data"][1]["downloadUrl"],
|
|
388
|
+
body=b"audio",
|
|
389
|
+
status=200,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
client = HyperStudy(api_key=api_key, base_url=BASE_URL)
|
|
393
|
+
df = client.download_recordings(
|
|
394
|
+
"exp_abc123", output_dir=str(tmp_path), progress=False, skip_existing=True
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
assert df["download_status"].iloc[0] == "downloaded"
|
|
398
|
+
assert video_path.read_bytes() == fresh_video
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def test_download_recordings_list_call_uses_long_timeout(
|
|
402
|
+
api_key, recordings_response, monkeypatch, tmp_path
|
|
403
|
+
):
|
|
404
|
+
"""The recordings-list call must use a longer timeout than the default
|
|
405
|
+
30s API timeout, because signing many GCS URLs on the backend can easily
|
|
406
|
+
push the listing latency past that."""
|
|
407
|
+
captured_timeouts = []
|
|
408
|
+
|
|
409
|
+
class FakeResp:
|
|
410
|
+
status_code = 200
|
|
411
|
+
ok = True
|
|
412
|
+
reason = "OK"
|
|
413
|
+
|
|
414
|
+
def json(self):
|
|
415
|
+
return recordings_response
|
|
416
|
+
|
|
417
|
+
def fake_request(method, url, **kwargs):
|
|
418
|
+
captured_timeouts.append(kwargs.get("timeout"))
|
|
419
|
+
return FakeResp()
|
|
420
|
+
|
|
421
|
+
client = HyperStudy(api_key=api_key, base_url=BASE_URL, timeout=30)
|
|
422
|
+
monkeypatch.setattr(client._transport._session, "request", fake_request)
|
|
423
|
+
|
|
424
|
+
# Stub downloads so we don't need HTTP mocks for the signed URLs.
|
|
425
|
+
import hyperstudy.client as client_mod
|
|
426
|
+
|
|
427
|
+
monkeypatch.setattr(
|
|
428
|
+
client_mod, "download_file", lambda url, dest, **kw: dest.write_bytes(b"") or 0
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
client.download_recordings(
|
|
432
|
+
"exp_abc123", output_dir=str(tmp_path), progress=False, skip_existing=False
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
assert captured_timeouts, "no list call was made"
|
|
436
|
+
# First captured timeout is the recordings-list call.
|
|
437
|
+
assert captured_timeouts[0] is not None and captured_timeouts[0] >= 300, (
|
|
438
|
+
f"expected list-call timeout >= 300s, got {captured_timeouts[0]}"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@responses.activate
|
|
443
|
+
def test_download_recordings_raises_clear_error_on_list_failure(api_key, tmp_path):
|
|
444
|
+
"""A failure fetching recording metadata must surface a HyperStudyError
|
|
445
|
+
that names the failing phase. Previously a bare ReadTimeout/ServerError
|
|
446
|
+
leaked out, giving users no hint whether the list or a download failed."""
|
|
447
|
+
from hyperstudy.exceptions import HyperStudyError
|
|
448
|
+
|
|
449
|
+
responses.get(
|
|
450
|
+
f"{BASE_URL}/data/recordings/experiment/exp_abc123",
|
|
451
|
+
status=504,
|
|
452
|
+
json={
|
|
453
|
+
"status": "error",
|
|
454
|
+
"error": {"code": "GATEWAY_TIMEOUT", "message": "upstream timeout"},
|
|
455
|
+
},
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
client = HyperStudy(api_key=api_key, base_url=BASE_URL)
|
|
459
|
+
with pytest.raises(HyperStudyError, match="[Ll]ist recordings"):
|
|
460
|
+
client.download_recordings("exp_abc123", output_dir=str(tmp_path), progress=False)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@responses.activate
|
|
464
|
+
def test_download_recordings_warns_summary_on_partial_failure(
|
|
465
|
+
api_key, recordings_response, tmp_path
|
|
466
|
+
):
|
|
467
|
+
"""When some files fail to download, a single summary warning must
|
|
468
|
+
surface so interactive users see the failure count at a glance."""
|
|
469
|
+
responses.get(
|
|
470
|
+
f"{BASE_URL}/data/recordings/experiment/exp_abc123",
|
|
471
|
+
json=recordings_response,
|
|
472
|
+
status=200,
|
|
473
|
+
)
|
|
474
|
+
# Video download fails with 404; audio succeeds.
|
|
475
|
+
responses.get(recordings_response["data"][0]["downloadUrl"], status=404)
|
|
476
|
+
responses.get(
|
|
477
|
+
recordings_response["data"][1]["downloadUrl"], body=b"audio", status=200
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
client = HyperStudy(api_key=api_key, base_url=BASE_URL)
|
|
481
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
482
|
+
warnings.simplefilter("always")
|
|
483
|
+
df = client.download_recordings(
|
|
484
|
+
"exp_abc123", output_dir=str(tmp_path), progress=False
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
messages = [str(w.message) for w in caught]
|
|
488
|
+
assert any("1" in m and "2" in m and "failed" in m.lower() for m in messages), (
|
|
489
|
+
f"expected a '1/2 failed' summary warning, got: {messages}"
|
|
490
|
+
)
|
|
491
|
+
assert list(df["download_status"]) == ["failed", "downloaded"]
|
|
492
|
+
|
|
493
|
+
|
|
359
494
|
@responses.activate
|
|
360
495
|
def test_download_recording_single(api_key, tmp_path):
|
|
361
496
|
"""download_recording downloads a single file."""
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Tests for recording download helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import responses
|
|
7
|
+
|
|
8
|
+
from hyperstudy._downloads import build_filename, download_file
|
|
9
|
+
|
|
10
|
+
# ------------------------------------------------------------------
|
|
11
|
+
# build_filename
|
|
12
|
+
# ------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
VIDEO_RECORDING = {
|
|
16
|
+
"recordingId": "EG_video_001",
|
|
17
|
+
"participantName": "Alice",
|
|
18
|
+
"format": "mp4",
|
|
19
|
+
"metadata": {"type": "video"},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
AUDIO_RECORDING = {
|
|
23
|
+
"recordingId": "EG_audio_002",
|
|
24
|
+
"participantName": "Alice",
|
|
25
|
+
"format": "webm",
|
|
26
|
+
"metadata": {"type": "audio"},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_build_filename_video():
|
|
31
|
+
assert build_filename(VIDEO_RECORDING) == "Alice_video_EG_video_001.mp4"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_build_filename_audio():
|
|
35
|
+
assert build_filename(AUDIO_RECORDING) == "Alice_audio_EG_audio_002.webm"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_build_filename_missing_fields():
|
|
39
|
+
rec = {"egressId": "EG_123"}
|
|
40
|
+
name = build_filename(rec)
|
|
41
|
+
assert name == "unknown_recording_EG_123.mp4"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_build_filename_sanitizes_name():
|
|
45
|
+
rec = {
|
|
46
|
+
"recordingId": "EG_001",
|
|
47
|
+
"participantName": "Alice O'Brien (test)",
|
|
48
|
+
"format": "mp4",
|
|
49
|
+
"metadata": {"type": "video"},
|
|
50
|
+
}
|
|
51
|
+
name = build_filename(rec)
|
|
52
|
+
assert name == "Alice_O_Brien__test__video_EG_001.mp4"
|
|
53
|
+
# No special characters remain
|
|
54
|
+
assert "'" not in name
|
|
55
|
+
assert "(" not in name
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_build_filename_uses_participant_id_fallback():
|
|
59
|
+
rec = {
|
|
60
|
+
"recordingId": "EG_001",
|
|
61
|
+
"participantId": "user_42",
|
|
62
|
+
"format": "mp4",
|
|
63
|
+
"metadata": {"type": "video"},
|
|
64
|
+
}
|
|
65
|
+
assert build_filename(rec) == "user_42_video_EG_001.mp4"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_build_filename_audio_default_format():
|
|
69
|
+
"""Audio recording with no format field defaults to webm."""
|
|
70
|
+
rec = {
|
|
71
|
+
"recordingId": "EG_001",
|
|
72
|
+
"participantName": "Bob",
|
|
73
|
+
"metadata": {"type": "audio"},
|
|
74
|
+
}
|
|
75
|
+
assert build_filename(rec).endswith(".webm")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
# download_file
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@responses.activate
|
|
84
|
+
def test_download_file(tmp_path):
|
|
85
|
+
url = "https://storage.example.com/file.mp4"
|
|
86
|
+
content = b"fake video content " * 100
|
|
87
|
+
responses.get(url, body=content, status=200)
|
|
88
|
+
|
|
89
|
+
dest = tmp_path / "output.mp4"
|
|
90
|
+
written = download_file(url, dest)
|
|
91
|
+
|
|
92
|
+
assert dest.exists()
|
|
93
|
+
assert dest.read_bytes() == content
|
|
94
|
+
assert written == len(content)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@responses.activate
|
|
98
|
+
def test_download_file_raises_on_error(tmp_path):
|
|
99
|
+
url = "https://storage.example.com/missing.mp4"
|
|
100
|
+
responses.get(url, status=404)
|
|
101
|
+
|
|
102
|
+
dest = tmp_path / "output.mp4"
|
|
103
|
+
with pytest.raises(Exception):
|
|
104
|
+
download_file(url, dest)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@responses.activate
|
|
108
|
+
def test_download_file_detects_content_length_mismatch(tmp_path):
|
|
109
|
+
"""If the response ends with fewer bytes than Content-Length advertised,
|
|
110
|
+
an error must propagate AND the partial file must be removed so it can't
|
|
111
|
+
be mistaken for a complete download on retry."""
|
|
112
|
+
url = "https://storage.example.com/truncated.mp4"
|
|
113
|
+
short_body = b"only-1kb" * 128 # 1024 bytes
|
|
114
|
+
# Lie about the length: claim 10x larger than what we send.
|
|
115
|
+
responses.get(
|
|
116
|
+
url,
|
|
117
|
+
body=short_body,
|
|
118
|
+
status=200,
|
|
119
|
+
headers={"Content-Length": str(len(short_body) * 10)},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
dest = tmp_path / "truncated.mp4"
|
|
123
|
+
with pytest.raises(Exception):
|
|
124
|
+
download_file(url, dest)
|
|
125
|
+
|
|
126
|
+
assert not dest.exists(), "partial file must be deleted on truncation"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_download_file_raises_on_short_body_without_protocol_error(tmp_path, monkeypatch):
|
|
130
|
+
"""Guards the explicit Content-Length check: when the HTTP layer itself
|
|
131
|
+
does NOT detect a short body (e.g. server closes cleanly after fewer
|
|
132
|
+
bytes than advertised), download_file must still raise and clean up."""
|
|
133
|
+
|
|
134
|
+
class ShortResponse:
|
|
135
|
+
status_code = 200
|
|
136
|
+
headers = {"Content-Length": "1000"}
|
|
137
|
+
|
|
138
|
+
def raise_for_status(self):
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def iter_content(self, chunk_size):
|
|
142
|
+
# Deliberately yield fewer bytes than Content-Length claims,
|
|
143
|
+
# then stop cleanly — urllib3 won't notice.
|
|
144
|
+
yield b"x" * 10
|
|
145
|
+
|
|
146
|
+
import hyperstudy._downloads as downloads_mod
|
|
147
|
+
|
|
148
|
+
monkeypatch.setattr(
|
|
149
|
+
downloads_mod.requests, "get", lambda *a, **kw: ShortResponse()
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
dest = tmp_path / "short.mp4"
|
|
153
|
+
with pytest.raises(IOError, match="[Tt]runcated"):
|
|
154
|
+
download_file("https://example.com/short.mp4", dest)
|
|
155
|
+
|
|
156
|
+
assert not dest.exists()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@responses.activate
|
|
160
|
+
def test_download_file_cleans_up_on_stream_error(tmp_path):
|
|
161
|
+
"""If iter_content raises mid-stream, the partial file must be removed."""
|
|
162
|
+
url = "https://storage.example.com/explode.mp4"
|
|
163
|
+
|
|
164
|
+
class ExplodingResponse:
|
|
165
|
+
status_code = 200
|
|
166
|
+
headers: dict = {}
|
|
167
|
+
|
|
168
|
+
def raise_for_status(self):
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
def iter_content(self, chunk_size):
|
|
172
|
+
yield b"first-chunk"
|
|
173
|
+
raise ConnectionError("simulated network drop")
|
|
174
|
+
|
|
175
|
+
import hyperstudy._downloads as downloads_mod
|
|
176
|
+
|
|
177
|
+
def fake_get(url, stream, timeout): # noqa: ARG001
|
|
178
|
+
return ExplodingResponse()
|
|
179
|
+
|
|
180
|
+
original = downloads_mod.requests.get
|
|
181
|
+
downloads_mod.requests.get = fake_get
|
|
182
|
+
try:
|
|
183
|
+
dest = tmp_path / "explode.mp4"
|
|
184
|
+
with pytest.raises(ConnectionError):
|
|
185
|
+
download_file(url, dest)
|
|
186
|
+
assert not dest.exists(), "partial file must be deleted on mid-stream error"
|
|
187
|
+
finally:
|
|
188
|
+
downloads_mod.requests.get = original
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Tests for the HttpTransport wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from hyperstudy._http import HttpTransport
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _FakeResponse:
|
|
9
|
+
status_code = 200
|
|
10
|
+
ok = True
|
|
11
|
+
reason = "OK"
|
|
12
|
+
|
|
13
|
+
def json(self):
|
|
14
|
+
return {"status": "success", "data": [], "metadata": {}}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _make_transport(timeout=30):
|
|
18
|
+
return HttpTransport(api_key="hst_test_key", timeout=timeout)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_get_uses_instance_timeout_by_default(monkeypatch):
|
|
22
|
+
transport = _make_transport(timeout=30)
|
|
23
|
+
captured = {}
|
|
24
|
+
|
|
25
|
+
def fake_request(method, url, **kwargs):
|
|
26
|
+
captured.update(kwargs)
|
|
27
|
+
return _FakeResponse()
|
|
28
|
+
|
|
29
|
+
monkeypatch.setattr(transport._session, "request", fake_request)
|
|
30
|
+
|
|
31
|
+
transport.get("/data/ping")
|
|
32
|
+
|
|
33
|
+
assert captured["timeout"] == 30
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_get_accepts_per_call_timeout_override(monkeypatch):
|
|
37
|
+
"""A per-call timeout must override the instance default without
|
|
38
|
+
mutating it — critical so download_recordings can give the list call
|
|
39
|
+
a longer window than other API calls."""
|
|
40
|
+
transport = _make_transport(timeout=30)
|
|
41
|
+
captured = []
|
|
42
|
+
|
|
43
|
+
def fake_request(method, url, **kwargs):
|
|
44
|
+
captured.append(kwargs)
|
|
45
|
+
return _FakeResponse()
|
|
46
|
+
|
|
47
|
+
monkeypatch.setattr(transport._session, "request", fake_request)
|
|
48
|
+
|
|
49
|
+
transport.get("/data/slow", timeout=300)
|
|
50
|
+
transport.get("/data/normal")
|
|
51
|
+
|
|
52
|
+
assert captured[0]["timeout"] == 300
|
|
53
|
+
assert captured[1]["timeout"] == 30
|
|
54
|
+
assert transport.timeout == 30 # unchanged
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
"""Tests for recording download helpers."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import responses
|
|
6
|
-
import pytest
|
|
7
|
-
|
|
8
|
-
from hyperstudy._downloads import build_filename, download_file
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
# ------------------------------------------------------------------
|
|
12
|
-
# build_filename
|
|
13
|
-
# ------------------------------------------------------------------
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
VIDEO_RECORDING = {
|
|
17
|
-
"recordingId": "EG_video_001",
|
|
18
|
-
"participantName": "Alice",
|
|
19
|
-
"format": "mp4",
|
|
20
|
-
"metadata": {"type": "video"},
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
AUDIO_RECORDING = {
|
|
24
|
-
"recordingId": "EG_audio_002",
|
|
25
|
-
"participantName": "Alice",
|
|
26
|
-
"format": "webm",
|
|
27
|
-
"metadata": {"type": "audio"},
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def test_build_filename_video():
|
|
32
|
-
assert build_filename(VIDEO_RECORDING) == "Alice_video_EG_video_001.mp4"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def test_build_filename_audio():
|
|
36
|
-
assert build_filename(AUDIO_RECORDING) == "Alice_audio_EG_audio_002.webm"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def test_build_filename_missing_fields():
|
|
40
|
-
rec = {"egressId": "EG_123"}
|
|
41
|
-
name = build_filename(rec)
|
|
42
|
-
assert name == "unknown_recording_EG_123.mp4"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def test_build_filename_sanitizes_name():
|
|
46
|
-
rec = {
|
|
47
|
-
"recordingId": "EG_001",
|
|
48
|
-
"participantName": "Alice O'Brien (test)",
|
|
49
|
-
"format": "mp4",
|
|
50
|
-
"metadata": {"type": "video"},
|
|
51
|
-
}
|
|
52
|
-
name = build_filename(rec)
|
|
53
|
-
assert name == "Alice_O_Brien__test__video_EG_001.mp4"
|
|
54
|
-
# No special characters remain
|
|
55
|
-
assert "'" not in name
|
|
56
|
-
assert "(" not in name
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def test_build_filename_uses_participant_id_fallback():
|
|
60
|
-
rec = {
|
|
61
|
-
"recordingId": "EG_001",
|
|
62
|
-
"participantId": "user_42",
|
|
63
|
-
"format": "mp4",
|
|
64
|
-
"metadata": {"type": "video"},
|
|
65
|
-
}
|
|
66
|
-
assert build_filename(rec) == "user_42_video_EG_001.mp4"
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def test_build_filename_audio_default_format():
|
|
70
|
-
"""Audio recording with no format field defaults to webm."""
|
|
71
|
-
rec = {
|
|
72
|
-
"recordingId": "EG_001",
|
|
73
|
-
"participantName": "Bob",
|
|
74
|
-
"metadata": {"type": "audio"},
|
|
75
|
-
}
|
|
76
|
-
assert build_filename(rec).endswith(".webm")
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
# ------------------------------------------------------------------
|
|
80
|
-
# download_file
|
|
81
|
-
# ------------------------------------------------------------------
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
@responses.activate
|
|
85
|
-
def test_download_file(tmp_path):
|
|
86
|
-
url = "https://storage.example.com/file.mp4"
|
|
87
|
-
content = b"fake video content " * 100
|
|
88
|
-
responses.get(url, body=content, status=200)
|
|
89
|
-
|
|
90
|
-
dest = tmp_path / "output.mp4"
|
|
91
|
-
written = download_file(url, dest)
|
|
92
|
-
|
|
93
|
-
assert dest.exists()
|
|
94
|
-
assert dest.read_bytes() == content
|
|
95
|
-
assert written == len(content)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
@responses.activate
|
|
99
|
-
def test_download_file_raises_on_error(tmp_path):
|
|
100
|
-
url = "https://storage.example.com/missing.mp4"
|
|
101
|
-
responses.get(url, status=404)
|
|
102
|
-
|
|
103
|
-
dest = tmp_path / "output.mp4"
|
|
104
|
-
with pytest.raises(Exception):
|
|
105
|
-
download_file(url, dest)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|