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.
Files changed (43) hide show
  1. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/PKG-INFO +1 -1
  2. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/pyproject.toml +1 -1
  3. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/__init__.py +1 -1
  4. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/_downloads.py +31 -5
  5. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/_http.py +10 -3
  6. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/_pagination.py +3 -2
  7. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/client.py +36 -10
  8. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/test_client.py +135 -0
  9. hyperstudy-0.2.3/tests/test_downloads.py +188 -0
  10. hyperstudy-0.2.3/tests/test_http.py +54 -0
  11. hyperstudy-0.2.2/tests/test_downloads.py +0 -105
  12. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/.github/workflows/publish.yml +0 -0
  13. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/.github/workflows/sync-release-notes.yml +0 -0
  14. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/.github/workflows/test.yml +0 -0
  15. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/.gitignore +0 -0
  16. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/CHANGELOG.md +0 -0
  17. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/LICENSE +0 -0
  18. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/README.md +0 -0
  19. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/docs/superpowers/specs/2026-04-10-recording-downloads-design.md +0 -0
  20. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/_dataframe.py +0 -0
  21. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/_display.py +0 -0
  22. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/_types.py +0 -0
  23. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/exceptions.py +0 -0
  24. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/src/hyperstudy/experiments.py +0 -0
  25. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/__init__.py +0 -0
  26. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/conftest.py +0 -0
  27. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/deployment_sessions_response.json +0 -0
  28. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/deployment_single_response.json +0 -0
  29. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/deployments_list_response.json +0 -0
  30. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/error_401.json +0 -0
  31. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/error_403.json +0 -0
  32. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/events_response.json +0 -0
  33. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/experiment_single_response.json +0 -0
  34. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/experiments_list_response.json +0 -0
  35. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/paginated_page1.json +0 -0
  36. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/paginated_page2.json +0 -0
  37. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/pre_experiment_response.json +0 -0
  38. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/recordings_response.json +0 -0
  39. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/sparse_ratings_response.json +0 -0
  40. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/fixtures/warnings_response.json +0 -0
  41. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/test_dataframe.py +0 -0
  42. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/test_experiments.py +0 -0
  43. {hyperstudy-0.2.2 → hyperstudy-0.2.3}/tests/test_pagination.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperstudy
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Python SDK for the HyperStudy experiment platform API
5
5
  Project-URL: Homepage, https://hyperstudy.io
6
6
  Project-URL: Documentation, https://docs.hyperstudy.io/developers/python-sdk
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hyperstudy"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Python SDK for the HyperStudy experiment platform API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -19,7 +19,7 @@ from .exceptions import (
19
19
  ValidationError,
20
20
  )
21
21
 
22
- __version__ = "0.2.2"
22
+ __version__ = "0.2.3"
23
23
 
24
24
  __all__ = [
25
25
  "HyperStudy",
@@ -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
- with open(dest, "wb") as fh:
47
- for chunk in resp.iter_content(chunk_size=_CHUNK_SIZE):
48
- fh.write(chunk)
49
- written += len(chunk)
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(self, path: str, params: dict[str, Any] | None = None) -> dict:
67
- return self._request("GET", path, params=params)
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.setdefault("timeout", self.timeout)
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
- recordings = self.get_recordings(
537
- scope_id,
538
- scope=scope,
539
- deployment_id=deployment_id,
540
- room_id=room_id,
541
- output="dict",
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
- if expected_size is None or dest.stat().st_size == expected_size:
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, path, params=params, progress=progress
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