hyperstudy 0.3.0__tar.gz → 0.3.1__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.3.0 → hyperstudy-0.3.1}/CHANGELOG.md +15 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/PKG-INFO +1 -1
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/pyproject.toml +1 -1
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/_downloads.py +13 -9
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/client.py +28 -12
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/experiments.py +9 -2
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/models.py +56 -22
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/test_client.py +11 -14
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/test_downloads.py +68 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/test_experiments.py +76 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/test_models.py +78 -0
- hyperstudy-0.3.1/tests/test_recordings_download.py +136 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/.github/workflows/publish.yml +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/.github/workflows/sync-release-notes.yml +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/.github/workflows/test.yml +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/.gitignore +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/LICENSE +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/README.md +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/docs/superpowers/specs/2026-04-10-recording-downloads-design.md +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/__init__.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/_dataframe.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/_display.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/_http.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/_pagination.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/_types.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/exceptions.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/__init__.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/conftest.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/deployment_sessions_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/deployment_single_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/deployments_list_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/error_401.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/error_403.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/events_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/experiment.schema.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/experiment_single_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/experiments_list_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/paginated_page1.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/paginated_page2.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/pre_experiment_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/recordings_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/sparse_ratings_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/warnings_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/test_dataframe.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/test_http.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/test_pagination.py +0 -0
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.3.1
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- `download_recording` / `download_recordings` now mint a signed download URL via the recordings download endpoint (using each recording's `downloadPath`) and fall back to the legacy `downloadUrl`/`url` for older GCS-only recordings. Fixes downloads failing after the backend list endpoint stopped embedding signed URLs.
|
|
8
|
+
|
|
9
|
+
### Fixes
|
|
10
|
+
|
|
11
|
+
- Snake_case→camelCase conversion is now recursive for the raw-dict and `**kwargs` paths: nested keys (e.g. `waiting_room_config={"max_wait_time_ms": 5000}` → `{"waitingRoomConfig": {"maxWaitTimeMs": 5000}}`) and factory `**extra` kwargs are converted. Free-form map fields (`variables`, `roles`, `globalComponents`, `globalComponentsVisibility`) preserve their user-defined keys while still converting nested schema fields. Previously nested/extra snake_case keys were silently dropped on the wire.
|
|
12
|
+
- Downloads are now atomic: each file streams to `<dest>.part` and is renamed on success, so a mid-stream failure never destroys a pre-existing complete file. `skip_existing` again skips when `fileSize` is unknown (an existing file is necessarily complete).
|
|
13
|
+
- `update_experiment()` with no fields now raises `ValueError` instead of issuing an empty PUT.
|
|
14
|
+
- `State.id` enforces `min_length=1`; `Experiment.global_components` keys are validated against `GlobalComponentType` (rejected at construction, still serialized as strings on the wire).
|
|
15
|
+
- `FocusComponent.id` is auto-generated when omitted, including direct construction without a factory helper.
|
|
16
|
+
|
|
17
|
+
|
|
3
18
|
## v0.3.0
|
|
4
19
|
|
|
5
20
|
### Features
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
import re
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import Any
|
|
@@ -38,12 +39,12 @@ def build_filename(recording: dict[str, Any]) -> str:
|
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
def download_file(url: str, dest: Path, timeout: int = 300) -> int:
|
|
41
|
-
"""Stream-download *url* to *dest
|
|
42
|
+
"""Stream-download *url* to *dest*; returns bytes written.
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
Downloads to a temporary ``<dest>.part`` and atomically renames on success, so a
|
|
45
|
+
mid-stream failure (HTTP error, disconnect, length mismatch) removes only the temp
|
|
46
|
+
file and leaves any pre-existing complete file at ``dest`` intact. Verifies
|
|
47
|
+
``Content-Length`` when provided.
|
|
47
48
|
"""
|
|
48
49
|
resp = requests.get(url, stream=True, timeout=timeout)
|
|
49
50
|
resp.raise_for_status()
|
|
@@ -57,20 +58,23 @@ def download_file(url: str, dest: Path, timeout: int = 300) -> int:
|
|
|
57
58
|
else:
|
|
58
59
|
expected = None
|
|
59
60
|
|
|
61
|
+
tmp = dest.with_name(dest.name + ".part")
|
|
60
62
|
written = 0
|
|
61
|
-
# BaseException so Ctrl-C also cleans up, not just exceptions.
|
|
62
63
|
try:
|
|
63
|
-
with open(
|
|
64
|
+
with open(tmp, "wb") as fh:
|
|
64
65
|
for chunk in resp.iter_content(chunk_size=_CHUNK_SIZE):
|
|
65
66
|
fh.write(chunk)
|
|
66
67
|
written += len(chunk)
|
|
67
|
-
|
|
68
68
|
if expected is not None and written != expected:
|
|
69
69
|
raise IOError(
|
|
70
70
|
f"Truncated download: wrote {written} of {expected} bytes for {dest.name}"
|
|
71
71
|
)
|
|
72
|
+
os.replace(tmp, dest) # atomic on the same filesystem
|
|
72
73
|
except BaseException:
|
|
73
|
-
|
|
74
|
+
try:
|
|
75
|
+
tmp.unlink(missing_ok=True) # remove only the temp; never the existing dest
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
74
78
|
raise
|
|
75
79
|
|
|
76
80
|
return written
|
|
@@ -476,6 +476,19 @@ class HyperStudy(ExperimentMixin):
|
|
|
476
476
|
# Recording downloads
|
|
477
477
|
# ------------------------------------------------------------------
|
|
478
478
|
|
|
479
|
+
def _download_url_for(self, recording: dict[str, Any]) -> str | None:
|
|
480
|
+
"""Resolve a directly-downloadable URL for a recording.
|
|
481
|
+
|
|
482
|
+
Prefers the auth'd mint endpoint (``downloadPath`` -> short-lived signed GCS
|
|
483
|
+
URL); falls back to a legacy absolute ``downloadUrl``/``url`` for old
|
|
484
|
+
GCS-only recordings.
|
|
485
|
+
"""
|
|
486
|
+
download_path = recording.get("downloadPath")
|
|
487
|
+
if download_path:
|
|
488
|
+
body = self._transport.get(download_path)
|
|
489
|
+
return (body.get("data") or {}).get("url")
|
|
490
|
+
return get_download_url(recording)
|
|
491
|
+
|
|
479
492
|
def download_recording(
|
|
480
493
|
self,
|
|
481
494
|
recording: dict[str, Any],
|
|
@@ -490,7 +503,7 @@ class HyperStudy(ExperimentMixin):
|
|
|
490
503
|
Returns:
|
|
491
504
|
Path to the downloaded file.
|
|
492
505
|
"""
|
|
493
|
-
url =
|
|
506
|
+
url = self._download_url_for(recording)
|
|
494
507
|
if not url:
|
|
495
508
|
raise ValueError("Recording has no downloadUrl or url field")
|
|
496
509
|
|
|
@@ -564,27 +577,30 @@ class HyperStudy(ExperimentMixin):
|
|
|
564
577
|
|
|
565
578
|
local_paths: list[str | None] = []
|
|
566
579
|
statuses: list[str] = []
|
|
580
|
+
failed_count = 0
|
|
567
581
|
|
|
568
582
|
for rec in tqdm(recordings, desc="Downloading recordings", disable=not progress):
|
|
569
583
|
filename = build_filename(rec)
|
|
570
584
|
dest = dest_dir / filename
|
|
571
585
|
|
|
572
|
-
|
|
573
|
-
if not url:
|
|
574
|
-
local_paths.append(None)
|
|
575
|
-
statuses.append("failed")
|
|
576
|
-
warnings.warn(f"Recording {rec.get('recordingId')} has no download URL")
|
|
577
|
-
continue
|
|
578
|
-
|
|
586
|
+
# Check skip before resolving the URL (avoids minting a signed URL unnecessarily).
|
|
579
587
|
if skip_existing and dest.exists():
|
|
580
588
|
expected_size = rec.get("fileSize")
|
|
581
|
-
#
|
|
582
|
-
#
|
|
583
|
-
if expected_size is
|
|
589
|
+
# dest is always a fully-written file (atomic rename), so skip when size is
|
|
590
|
+
# unknown OR matches; only re-download on a definite size mismatch.
|
|
591
|
+
if expected_size is None or dest.stat().st_size == expected_size:
|
|
584
592
|
local_paths.append(str(dest.resolve()))
|
|
585
593
|
statuses.append("skipped")
|
|
586
594
|
continue
|
|
587
595
|
|
|
596
|
+
url = self._download_url_for(rec)
|
|
597
|
+
if not url:
|
|
598
|
+
local_paths.append(None)
|
|
599
|
+
statuses.append("failed")
|
|
600
|
+
failed_count += 1
|
|
601
|
+
warnings.warn(f"Recording {rec.get('recordingId')} has no download URL")
|
|
602
|
+
continue
|
|
603
|
+
|
|
588
604
|
try:
|
|
589
605
|
download_file(url, dest)
|
|
590
606
|
local_paths.append(str(dest.resolve()))
|
|
@@ -592,11 +608,11 @@ class HyperStudy(ExperimentMixin):
|
|
|
592
608
|
except Exception as exc:
|
|
593
609
|
local_paths.append(None)
|
|
594
610
|
statuses.append("failed")
|
|
611
|
+
failed_count += 1
|
|
595
612
|
warnings.warn(
|
|
596
613
|
f"Failed to download recording {rec.get('recordingId')}: {exc}"
|
|
597
614
|
)
|
|
598
615
|
|
|
599
|
-
failed_count = sum(1 for s in statuses if s == "failed")
|
|
600
616
|
if failed_count:
|
|
601
617
|
warnings.warn(
|
|
602
618
|
f"{failed_count}/{len(recordings)} recordings failed to download; "
|
|
@@ -10,6 +10,7 @@ from pydantic.alias_generators import to_camel
|
|
|
10
10
|
from ._display import ExperimentInfo
|
|
11
11
|
from ._http import HttpTransport
|
|
12
12
|
from ._pagination import fetch_all_pages
|
|
13
|
+
from .models import camelize_wire
|
|
13
14
|
|
|
14
15
|
if TYPE_CHECKING:
|
|
15
16
|
from .models import Experiment
|
|
@@ -37,13 +38,14 @@ def _build_experiment_payload(
|
|
|
37
38
|
elif isinstance(experiment, BaseModel):
|
|
38
39
|
payload = experiment.model_dump(by_alias=True, exclude_none=True)
|
|
39
40
|
elif isinstance(experiment, dict):
|
|
40
|
-
payload =
|
|
41
|
+
payload = camelize_wire(experiment)
|
|
41
42
|
else:
|
|
42
43
|
raise TypeError(
|
|
43
44
|
f"experiment must be an Experiment or dict, got {type(experiment).__name__}"
|
|
44
45
|
)
|
|
45
46
|
for key, value in overrides.items():
|
|
46
|
-
|
|
47
|
+
ck = to_camel(key)
|
|
48
|
+
payload[ck] = camelize_wire(value, _parent_key=ck)
|
|
47
49
|
return payload
|
|
48
50
|
|
|
49
51
|
|
|
@@ -182,6 +184,11 @@ class ExperimentMixin:
|
|
|
182
184
|
Update confirmation dict.
|
|
183
185
|
"""
|
|
184
186
|
payload = _build_experiment_payload(experiment, **kwargs)
|
|
187
|
+
if not payload:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
"update_experiment requires at least one field to update "
|
|
190
|
+
"(pass experiment=... or keyword fields)."
|
|
191
|
+
)
|
|
185
192
|
body = self._transport.put(f"experiments/{experiment_id}", json=payload)
|
|
186
193
|
data = body.get("data", [])
|
|
187
194
|
return data[0] if isinstance(data, list) and data else data
|
|
@@ -13,7 +13,7 @@ import uuid
|
|
|
13
13
|
from enum import Enum
|
|
14
14
|
from typing import Any, Optional
|
|
15
15
|
|
|
16
|
-
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
17
17
|
from pydantic.alias_generators import to_camel
|
|
18
18
|
|
|
19
19
|
|
|
@@ -21,6 +21,33 @@ def _new_id() -> str:
|
|
|
21
21
|
return uuid.uuid4().hex[:8]
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
# Fields whose IMMEDIATE child keys are user-defined names (variable names, role
|
|
25
|
+
# names, component ids), NOT schema fields — their keys must NOT be camelCased.
|
|
26
|
+
# Their values are still recursed into.
|
|
27
|
+
_FREEFORM_MAP_FIELDS = frozenset({
|
|
28
|
+
"variables", "roles", "globalComponents", "globalComponentsVisibility",
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def camelize_wire(value: Any, _parent_key: "str | None" = None) -> Any:
|
|
33
|
+
"""Recursively convert snake_case dict keys to camelCase for the wire format.
|
|
34
|
+
|
|
35
|
+
Skips converting the immediate keys of free-form map fields (variables, roles,
|
|
36
|
+
globalComponents, globalComponentsVisibility) — those are user-defined names —
|
|
37
|
+
but still recurses into their values.
|
|
38
|
+
"""
|
|
39
|
+
if isinstance(value, dict):
|
|
40
|
+
skip_keys = _parent_key in _FREEFORM_MAP_FIELDS
|
|
41
|
+
out: dict[str, Any] = {}
|
|
42
|
+
for k, v in value.items():
|
|
43
|
+
new_key = k if skip_keys else (to_camel(k) if isinstance(k, str) else k)
|
|
44
|
+
out[new_key] = camelize_wire(v, _parent_key=new_key)
|
|
45
|
+
return out
|
|
46
|
+
if isinstance(value, list):
|
|
47
|
+
return [camelize_wire(v, _parent_key=_parent_key) for v in value]
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
24
51
|
class ComponentType(str, Enum):
|
|
25
52
|
"""Focus component types (one per state)."""
|
|
26
53
|
|
|
@@ -78,7 +105,12 @@ class FocusComponent(_Model):
|
|
|
78
105
|
|
|
79
106
|
type: ComponentType
|
|
80
107
|
config: dict[str, Any] = Field(default_factory=dict)
|
|
81
|
-
id:
|
|
108
|
+
id: str = Field(default_factory=_new_id)
|
|
109
|
+
|
|
110
|
+
@field_validator("id", mode="before")
|
|
111
|
+
@classmethod
|
|
112
|
+
def _default_id(cls, v: "str | None") -> str:
|
|
113
|
+
return v or _new_id()
|
|
82
114
|
|
|
83
115
|
|
|
84
116
|
class TransitionRules(_Model):
|
|
@@ -91,7 +123,7 @@ class TransitionRules(_Model):
|
|
|
91
123
|
class State(_Model):
|
|
92
124
|
"""An experiment state — a single screen/phase."""
|
|
93
125
|
|
|
94
|
-
id: str
|
|
126
|
+
id: str = Field(min_length=1)
|
|
95
127
|
name: Optional[str] = None
|
|
96
128
|
order: Optional[int] = Field(default=None, ge=0)
|
|
97
129
|
focus_component: Optional[FocusComponent] = None
|
|
@@ -141,7 +173,7 @@ class Experiment(_Model):
|
|
|
141
173
|
randomize_states: Optional[bool] = None
|
|
142
174
|
states: Optional[list[State]] = None
|
|
143
175
|
roles: Optional[dict[str, Role]] = None
|
|
144
|
-
global_components: Optional[dict[
|
|
176
|
+
global_components: Optional[dict[GlobalComponentType, dict[str, Any]]] = None
|
|
145
177
|
variables: Optional[dict[str, Any]] = None
|
|
146
178
|
waiting_room_config: Optional[WaitingRoomConfig] = None
|
|
147
179
|
disconnect_timeout: Optional[DisconnectTimeout] = None
|
|
@@ -175,8 +207,8 @@ def show_text(text: str, *, id: Optional[str] = None, **extra: Any) -> FocusComp
|
|
|
175
207
|
"""Build a ``showtext`` focus component."""
|
|
176
208
|
return FocusComponent(
|
|
177
209
|
type=ComponentType.SHOW_TEXT,
|
|
178
|
-
config={**extra, "text": text},
|
|
179
|
-
id=id
|
|
210
|
+
config={**camelize_wire(extra), "text": text},
|
|
211
|
+
id=id,
|
|
180
212
|
)
|
|
181
213
|
|
|
182
214
|
|
|
@@ -184,8 +216,8 @@ def show_image(url: str, *, id: Optional[str] = None, **extra: Any) -> FocusComp
|
|
|
184
216
|
"""Build a ``showimage`` focus component."""
|
|
185
217
|
return FocusComponent(
|
|
186
218
|
type=ComponentType.SHOW_IMAGE,
|
|
187
|
-
config={**extra, "url": url},
|
|
188
|
-
id=id
|
|
219
|
+
config={**camelize_wire(extra), "url": url},
|
|
220
|
+
id=id,
|
|
189
221
|
)
|
|
190
222
|
|
|
191
223
|
|
|
@@ -193,8 +225,8 @@ def show_video(url: str, *, id: Optional[str] = None, **extra: Any) -> FocusComp
|
|
|
193
225
|
"""Build a ``showvideo`` focus component."""
|
|
194
226
|
return FocusComponent(
|
|
195
227
|
type=ComponentType.SHOW_VIDEO,
|
|
196
|
-
config={**extra, "url": url},
|
|
197
|
-
id=id
|
|
228
|
+
config={**camelize_wire(extra), "url": url},
|
|
229
|
+
id=id,
|
|
198
230
|
)
|
|
199
231
|
|
|
200
232
|
|
|
@@ -208,8 +240,8 @@ def vas_rating(
|
|
|
208
240
|
"""Build a ``vasrating`` (visual analog scale) focus component."""
|
|
209
241
|
return FocusComponent(
|
|
210
242
|
type=ComponentType.VAS_RATING,
|
|
211
|
-
config={**extra, "prompt": prompt, "outputVariable": output_variable},
|
|
212
|
-
id=id
|
|
243
|
+
config={**camelize_wire(extra), "prompt": prompt, "outputVariable": output_variable},
|
|
244
|
+
id=id,
|
|
213
245
|
)
|
|
214
246
|
|
|
215
247
|
|
|
@@ -223,8 +255,8 @@ def text_input(
|
|
|
223
255
|
"""Build a ``textinput`` focus component."""
|
|
224
256
|
return FocusComponent(
|
|
225
257
|
type=ComponentType.TEXT_INPUT,
|
|
226
|
-
config={**extra, "prompt": prompt, "outputVariable": output_variable},
|
|
227
|
-
id=id
|
|
258
|
+
config={**camelize_wire(extra), "prompt": prompt, "outputVariable": output_variable},
|
|
259
|
+
id=id,
|
|
228
260
|
)
|
|
229
261
|
|
|
230
262
|
|
|
@@ -240,12 +272,12 @@ def multiple_choice(
|
|
|
240
272
|
return FocusComponent(
|
|
241
273
|
type=ComponentType.MULTIPLE_CHOICE,
|
|
242
274
|
config={
|
|
243
|
-
**extra,
|
|
275
|
+
**camelize_wire(extra),
|
|
244
276
|
"prompt": prompt,
|
|
245
277
|
"options": list(options),
|
|
246
278
|
"outputVariable": output_variable,
|
|
247
279
|
},
|
|
248
|
-
id=id
|
|
280
|
+
id=id,
|
|
249
281
|
)
|
|
250
282
|
|
|
251
283
|
|
|
@@ -253,8 +285,8 @@ def waiting(duration_ms: int, *, id: Optional[str] = None, **extra: Any) -> Focu
|
|
|
253
285
|
"""Build a ``waiting`` focus component."""
|
|
254
286
|
return FocusComponent(
|
|
255
287
|
type=ComponentType.WAITING,
|
|
256
|
-
config={**extra, "durationMs": int(duration_ms)},
|
|
257
|
-
id=id
|
|
288
|
+
config={**camelize_wire(extra), "durationMs": int(duration_ms)},
|
|
289
|
+
id=id,
|
|
258
290
|
)
|
|
259
291
|
|
|
260
292
|
|
|
@@ -270,12 +302,12 @@ def likert_scale(
|
|
|
270
302
|
return FocusComponent(
|
|
271
303
|
type=ComponentType.LIKERT_SCALE,
|
|
272
304
|
config={
|
|
273
|
-
**extra,
|
|
305
|
+
**camelize_wire(extra),
|
|
274
306
|
"prompt": prompt,
|
|
275
307
|
"outputVariable": output_variable,
|
|
276
308
|
"scalePoints": int(scale_points),
|
|
277
309
|
},
|
|
278
|
-
id=id
|
|
310
|
+
id=id,
|
|
279
311
|
)
|
|
280
312
|
|
|
281
313
|
|
|
@@ -291,12 +323,12 @@ def ranking(
|
|
|
291
323
|
return FocusComponent(
|
|
292
324
|
type=ComponentType.RANKING,
|
|
293
325
|
config={
|
|
294
|
-
**extra,
|
|
326
|
+
**camelize_wire(extra),
|
|
295
327
|
"prompt": prompt,
|
|
296
328
|
"options": list(options),
|
|
297
329
|
"outputVariable": output_variable,
|
|
298
330
|
},
|
|
299
|
-
id=id
|
|
331
|
+
id=id,
|
|
300
332
|
)
|
|
301
333
|
|
|
302
334
|
|
|
@@ -304,6 +336,8 @@ __all__ = [
|
|
|
304
336
|
# Enums
|
|
305
337
|
"ComponentType",
|
|
306
338
|
"GlobalComponentType",
|
|
339
|
+
# Wire conversion helper
|
|
340
|
+
"camelize_wire",
|
|
307
341
|
# Models
|
|
308
342
|
"Experiment",
|
|
309
343
|
"State",
|
|
@@ -357,12 +357,12 @@ def test_download_recordings_skip_existing(api_key, recordings_response, tmp_pat
|
|
|
357
357
|
|
|
358
358
|
|
|
359
359
|
@responses.activate
|
|
360
|
-
def
|
|
360
|
+
def test_download_recordings_skip_when_filesize_unknown(
|
|
361
361
|
api_key, recordings_response, tmp_path
|
|
362
362
|
):
|
|
363
|
-
"""When server-side fileSize is missing, skip_existing
|
|
364
|
-
|
|
365
|
-
|
|
363
|
+
"""When server-side fileSize is missing, skip_existing should skip an
|
|
364
|
+
existing on-disk file. Because downloads are atomic (Fix C), any file
|
|
365
|
+
that exists is necessarily complete — truncated partials cannot remain."""
|
|
366
366
|
# Strip fileSize from the video record to simulate older recordings where
|
|
367
367
|
# the backend didn't store file size metadata.
|
|
368
368
|
recordings_response["data"][0]["fileSize"] = None
|
|
@@ -373,16 +373,12 @@ def test_download_recordings_skip_requires_known_size(
|
|
|
373
373
|
status=200,
|
|
374
374
|
)
|
|
375
375
|
|
|
376
|
-
# Pre-create a
|
|
376
|
+
# Pre-create a complete video file on disk (atomic rename guarantees this).
|
|
377
|
+
existing_bytes = b"already-complete-video-content"
|
|
377
378
|
video_path = tmp_path / "Alice_video_EG_video_001.mp4"
|
|
378
|
-
video_path.write_bytes(
|
|
379
|
+
video_path.write_bytes(existing_bytes)
|
|
379
380
|
|
|
380
|
-
|
|
381
|
-
responses.get(
|
|
382
|
-
recordings_response["data"][0]["downloadUrl"],
|
|
383
|
-
body=fresh_video,
|
|
384
|
-
status=200,
|
|
385
|
-
)
|
|
381
|
+
# Audio still needs to download (does not exist on disk).
|
|
386
382
|
responses.get(
|
|
387
383
|
recordings_response["data"][1]["downloadUrl"],
|
|
388
384
|
body=b"audio",
|
|
@@ -394,8 +390,9 @@ def test_download_recordings_skip_requires_known_size(
|
|
|
394
390
|
"exp_abc123", output_dir=str(tmp_path), progress=False, skip_existing=True
|
|
395
391
|
)
|
|
396
392
|
|
|
397
|
-
|
|
398
|
-
assert
|
|
393
|
+
# Video was skipped (pre-existing, no GCS call needed).
|
|
394
|
+
assert df["download_status"].iloc[0] == "skipped"
|
|
395
|
+
assert video_path.read_bytes() == existing_bytes
|
|
399
396
|
|
|
400
397
|
|
|
401
398
|
def test_download_recordings_list_call_uses_long_timeout(
|
|
@@ -186,3 +186,71 @@ def test_download_file_cleans_up_on_stream_error(tmp_path):
|
|
|
186
186
|
assert not dest.exists(), "partial file must be deleted on mid-stream error"
|
|
187
187
|
finally:
|
|
188
188
|
downloads_mod.requests.get = original
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
# Fix C — atomic download: pre-existing dest must survive a failed download
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_download_file_atomic_preserves_existing_on_truncation(tmp_path, monkeypatch):
|
|
197
|
+
"""A pre-existing complete file is NOT removed when the new download fails.
|
|
198
|
+
|
|
199
|
+
The new code streams to a .part file; on failure only the .part is removed,
|
|
200
|
+
leaving the original intact.
|
|
201
|
+
"""
|
|
202
|
+
import hyperstudy._downloads as downloads_mod
|
|
203
|
+
|
|
204
|
+
dest = tmp_path / "output.mp4"
|
|
205
|
+
original_bytes = b"ORIGINAL_COMPLETE_DATA"
|
|
206
|
+
dest.write_bytes(original_bytes)
|
|
207
|
+
|
|
208
|
+
# Simulate a truncated download: Content-Length claims 100 but body is short.
|
|
209
|
+
class TruncatedResponse:
|
|
210
|
+
status_code = 200
|
|
211
|
+
headers = {"Content-Length": "100"}
|
|
212
|
+
|
|
213
|
+
def raise_for_status(self):
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
def iter_content(self, chunk_size):
|
|
217
|
+
yield b"SHORT" # only 5 bytes, not 100
|
|
218
|
+
|
|
219
|
+
monkeypatch.setattr(
|
|
220
|
+
downloads_mod.requests, "get", lambda *a, **kw: TruncatedResponse()
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
with pytest.raises(IOError, match="[Tt]runcated"):
|
|
224
|
+
download_file("https://example.com/trunc.mp4", dest)
|
|
225
|
+
|
|
226
|
+
# Original file must still exist with original content
|
|
227
|
+
assert dest.exists(), "pre-existing complete file was destroyed by a failed download"
|
|
228
|
+
assert dest.read_bytes() == original_bytes
|
|
229
|
+
|
|
230
|
+
# No .part file should remain
|
|
231
|
+
part = dest.with_name(dest.name + ".part")
|
|
232
|
+
assert not part.exists(), ".part file not cleaned up after failure"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_download_file_no_part_file_on_success(tmp_path, monkeypatch):
|
|
236
|
+
"""On success, no .part file lingers."""
|
|
237
|
+
import hyperstudy._downloads as downloads_mod
|
|
238
|
+
|
|
239
|
+
class OkResponse:
|
|
240
|
+
status_code = 200
|
|
241
|
+
headers: dict = {}
|
|
242
|
+
|
|
243
|
+
def raise_for_status(self):
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
def iter_content(self, chunk_size):
|
|
247
|
+
yield b"GOOD DATA"
|
|
248
|
+
|
|
249
|
+
monkeypatch.setattr(
|
|
250
|
+
downloads_mod.requests, "get", lambda *a, **kw: OkResponse()
|
|
251
|
+
)
|
|
252
|
+
dest = tmp_path / "ok.mp4"
|
|
253
|
+
download_file("https://example.com/ok.mp4", dest)
|
|
254
|
+
assert dest.exists()
|
|
255
|
+
part = dest.with_name(dest.name + ".part")
|
|
256
|
+
assert not part.exists()
|
|
@@ -396,3 +396,79 @@ def test_get_experiment_not_found():
|
|
|
396
396
|
client = HyperStudy(api_key="hst_test_key", base_url=BASE_URL)
|
|
397
397
|
with pytest.raises(NotFoundError, match="not found"):
|
|
398
398
|
client.get_experiment("exp_nonexistent")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
# Fix A1 — recursive snake→camel on dict experiment path and overrides
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def test_build_payload_dict_experiment_camelizes_keys():
|
|
407
|
+
"""dict-form experiments have snake_case keys converted to camelCase."""
|
|
408
|
+
from hyperstudy.experiments import _build_experiment_payload
|
|
409
|
+
|
|
410
|
+
payload = _build_experiment_payload(
|
|
411
|
+
{"waiting_room_config": {"max_wait_time_ms": 5000}}
|
|
412
|
+
)
|
|
413
|
+
assert payload == {"waitingRoomConfig": {"maxWaitTimeMs": 5000}}
|
|
414
|
+
assert "waiting_room_config" not in payload
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def test_build_payload_dict_nested_list_camelized():
|
|
418
|
+
"""Nested list values in dicts are also camelized."""
|
|
419
|
+
from hyperstudy.experiments import _build_experiment_payload
|
|
420
|
+
|
|
421
|
+
payload = _build_experiment_payload(
|
|
422
|
+
{
|
|
423
|
+
"states": [
|
|
424
|
+
{
|
|
425
|
+
"id": "s1",
|
|
426
|
+
"focus_component": {"type": "showtext", "config": {"text": "hi"}},
|
|
427
|
+
}
|
|
428
|
+
]
|
|
429
|
+
}
|
|
430
|
+
)
|
|
431
|
+
assert "focusComponent" in payload["states"][0]
|
|
432
|
+
assert "focus_component" not in payload["states"][0]
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def test_build_payload_freeform_keys_preserved():
|
|
436
|
+
"""variables keys are NOT camelized (user-defined names), but values are."""
|
|
437
|
+
from hyperstudy.experiments import _build_experiment_payload
|
|
438
|
+
|
|
439
|
+
payload = _build_experiment_payload({"variables": {"my_var_name": 1}})
|
|
440
|
+
assert payload == {"variables": {"my_var_name": 1}}
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def test_build_payload_roles_name_preserved_value_camelized():
|
|
444
|
+
"""Role names (keys) are preserved; nested fields in values are camelized."""
|
|
445
|
+
from hyperstudy.experiments import _build_experiment_payload
|
|
446
|
+
|
|
447
|
+
payload = _build_experiment_payload(
|
|
448
|
+
{"roles": {"my_role": {"participant_count": 2}}}
|
|
449
|
+
)
|
|
450
|
+
assert "my_role" in payload["roles"]
|
|
451
|
+
assert payload["roles"]["my_role"] == {"participantCount": 2}
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def test_build_payload_override_value_camelized():
|
|
455
|
+
"""Override values (nested dicts/lists) are also recursively camelized."""
|
|
456
|
+
from hyperstudy.experiments import _build_experiment_payload
|
|
457
|
+
|
|
458
|
+
payload = _build_experiment_payload(
|
|
459
|
+
None,
|
|
460
|
+
waiting_room_config={"max_wait_time_ms": 3000},
|
|
461
|
+
)
|
|
462
|
+
assert payload == {"waitingRoomConfig": {"maxWaitTimeMs": 3000}}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# ---------------------------------------------------------------------------
|
|
466
|
+
# Fix B — empty-PUT guard
|
|
467
|
+
# ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def test_update_experiment_empty_raises():
|
|
471
|
+
"""update_experiment with no experiment and no kwargs raises ValueError."""
|
|
472
|
+
client = HyperStudy(api_key="hst_test_key", base_url=BASE_URL)
|
|
473
|
+
with pytest.raises(ValueError, match="at least one field"):
|
|
474
|
+
client.update_experiment("exp123")
|
|
@@ -282,6 +282,84 @@ def test_factory_generates_id_when_omitted():
|
|
|
282
282
|
assert a.id and b.id and a.id != b.id
|
|
283
283
|
|
|
284
284
|
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
# Fix A2 — factory **extra camelization
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_factory_extra_snake_case_camelized():
|
|
291
|
+
"""Extra kwargs to factories are camelized so they land correctly on the wire."""
|
|
292
|
+
c = show_text("hi", max_width=600)
|
|
293
|
+
assert "maxWidth" in c.config
|
|
294
|
+
assert c.config["maxWidth"] == 600
|
|
295
|
+
assert "max_width" not in c.config
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_vas_rating_factory_extra_camelized():
|
|
299
|
+
"""vas_rating extra kwargs with snake_case are camelized."""
|
|
300
|
+
c = vas_rating("p", output_variable="ov", min_label="Low")
|
|
301
|
+
assert "minLabel" in c.config
|
|
302
|
+
assert c.config["minLabel"] == "Low"
|
|
303
|
+
assert "outputVariable" in c.config
|
|
304
|
+
assert "outputVariable" in c.config
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
# Fix E — schema enforcement on State.id and global_components
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_state_id_empty_rejected():
|
|
313
|
+
"""State.id must be non-empty (minLength:1)."""
|
|
314
|
+
with pytest.raises(ValidationError):
|
|
315
|
+
State(id="")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_global_components_invalid_type_rejected():
|
|
319
|
+
"""global_components keys must be GlobalComponentType values."""
|
|
320
|
+
with pytest.raises(ValidationError):
|
|
321
|
+
Experiment(name="x", global_components={"showvideo": {}})
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_global_components_valid_type_accepted():
|
|
325
|
+
"""Valid GlobalComponentType keys are accepted."""
|
|
326
|
+
exp = Experiment(name="x", global_components={"videochat": {"option": 1}})
|
|
327
|
+
wire = exp.model_dump(by_alias=True, exclude_none=True)
|
|
328
|
+
assert wire["globalComponents"] == {"videochat": {"option": 1}}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_global_components_dumps_string_keys():
|
|
332
|
+
"""global_components keys must serialize as strings, not enum reprs."""
|
|
333
|
+
exp = Experiment(name="x", global_components={"textchat": {}})
|
|
334
|
+
wire = exp.model_dump(by_alias=True, exclude_none=True)
|
|
335
|
+
keys = list(wire["globalComponents"].keys())
|
|
336
|
+
assert keys == ["textchat"], f"Expected string keys but got: {keys}"
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
# Fix F — centralized FocusComponent id via before-validator
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_focus_component_direct_construction_gets_id():
|
|
345
|
+
"""FocusComponent(type=..., config=...) gets an auto-generated id."""
|
|
346
|
+
fc = FocusComponent(type=ComponentType.SHOW_TEXT, config={})
|
|
347
|
+
assert fc.id is not None
|
|
348
|
+
assert len(fc.id) == 8
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def test_show_text_id_auto_generated():
|
|
352
|
+
"""show_text() without id= gets a non-empty id."""
|
|
353
|
+
fc = show_text("hi")
|
|
354
|
+
assert fc.id and len(fc.id) == 8
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def test_show_text_explicit_id_preserved():
|
|
358
|
+
"""show_text(id='x') keeps the explicit id."""
|
|
359
|
+
fc = show_text("hi", id="x")
|
|
360
|
+
assert fc.id == "x"
|
|
361
|
+
|
|
362
|
+
|
|
285
363
|
# ---------------------------------------------------------------------------
|
|
286
364
|
# Schema-drift guard
|
|
287
365
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
import responses
|
|
6
|
+
import hyperstudy
|
|
7
|
+
|
|
8
|
+
BASE_URL = "https://api.hyperstudy.io/api/v3"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Fix D — skip_existing when fileSize is unknown
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@responses.activate
|
|
17
|
+
def test_skip_existing_no_filesize(tmp_path):
|
|
18
|
+
"""When fileSize is absent and file already exists, skip without re-downloading."""
|
|
19
|
+
# Register the list-recordings endpoint
|
|
20
|
+
responses.add(
|
|
21
|
+
responses.GET,
|
|
22
|
+
f"{BASE_URL}/data/recordings/experiment/exp1",
|
|
23
|
+
json={
|
|
24
|
+
"status": "success",
|
|
25
|
+
"metadata": {"total": 1, "limit": 50, "offset": 0, "dataType": "recordings"},
|
|
26
|
+
"data": [
|
|
27
|
+
{
|
|
28
|
+
"recordingId": "EG_001",
|
|
29
|
+
"participantName": "Alice",
|
|
30
|
+
"format": "mp4",
|
|
31
|
+
"metadata": {"type": "video"},
|
|
32
|
+
"downloadPath": "/data/recordings/download/room_1/EG_001",
|
|
33
|
+
# No fileSize key
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
status=200,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
dest_dir = tmp_path
|
|
41
|
+
dest = dest_dir / "Alice_video_EG_001.mp4"
|
|
42
|
+
dest.write_bytes(b"EXISTING_CONTENT")
|
|
43
|
+
|
|
44
|
+
hs = hyperstudy.HyperStudy(api_key="hst_test_" + "a" * 32)
|
|
45
|
+
|
|
46
|
+
df = hs.download_recordings("exp1", output_dir=str(dest_dir), skip_existing=True)
|
|
47
|
+
|
|
48
|
+
# No download call should have been made for the GCS URL
|
|
49
|
+
download_calls = [c for c in responses.calls if "signed" in c.request.url]
|
|
50
|
+
assert len(download_calls) == 0, "Should have skipped the download"
|
|
51
|
+
|
|
52
|
+
assert df["download_status"].iloc[0] == "skipped"
|
|
53
|
+
# Pre-existing file must be intact
|
|
54
|
+
assert dest.read_bytes() == b"EXISTING_CONTENT"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@responses.activate
|
|
58
|
+
def test_redownload_on_filesize_mismatch(tmp_path):
|
|
59
|
+
"""When fileSize is present but mismatched, re-download the file."""
|
|
60
|
+
responses.add(
|
|
61
|
+
responses.GET,
|
|
62
|
+
f"{BASE_URL}/data/recordings/experiment/exp2",
|
|
63
|
+
json={
|
|
64
|
+
"status": "success",
|
|
65
|
+
"metadata": {"total": 1, "limit": 50, "offset": 0, "dataType": "recordings"},
|
|
66
|
+
"data": [
|
|
67
|
+
{
|
|
68
|
+
"recordingId": "EG_002",
|
|
69
|
+
"participantName": "Bob",
|
|
70
|
+
"format": "mp4",
|
|
71
|
+
"metadata": {"type": "video"},
|
|
72
|
+
"downloadPath": "/data/recordings/download/room_2/EG_002",
|
|
73
|
+
"fileSize": 9999, # mismatch with existing file
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
status=200,
|
|
78
|
+
)
|
|
79
|
+
# mint endpoint
|
|
80
|
+
responses.add(
|
|
81
|
+
responses.GET,
|
|
82
|
+
f"{BASE_URL}/data/recordings/download/room_2/EG_002",
|
|
83
|
+
json={"status": "success", "data": {"url": "https://signed.example/Bob.mp4"}},
|
|
84
|
+
status=200,
|
|
85
|
+
)
|
|
86
|
+
# GCS signed URL
|
|
87
|
+
new_bytes = b"NEW_DOWNLOAD_CONTENT"
|
|
88
|
+
responses.add(
|
|
89
|
+
responses.GET,
|
|
90
|
+
"https://signed.example/Bob.mp4",
|
|
91
|
+
body=new_bytes,
|
|
92
|
+
status=200,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
dest_dir = tmp_path
|
|
96
|
+
dest = dest_dir / "Bob_video_EG_002.mp4"
|
|
97
|
+
dest.write_bytes(b"OLD") # size 3, doesn't match fileSize=9999
|
|
98
|
+
|
|
99
|
+
hs = hyperstudy.HyperStudy(api_key="hst_test_" + "a" * 32)
|
|
100
|
+
df = hs.download_recordings("exp2", output_dir=str(dest_dir), skip_existing=True)
|
|
101
|
+
|
|
102
|
+
assert df["download_status"].iloc[0] == "downloaded"
|
|
103
|
+
assert dest.read_bytes() == new_bytes
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@responses.activate
|
|
107
|
+
def test_download_recording_mints_signed_url(tmp_path):
|
|
108
|
+
base = "https://api.hyperstudy.io/api/v3"
|
|
109
|
+
# 1) mint endpoint returns a signed url in the envelope
|
|
110
|
+
responses.add(
|
|
111
|
+
responses.GET,
|
|
112
|
+
f"{base}/data/recordings/download/room_EXP_1_a/EG_x",
|
|
113
|
+
json={"status": "success", "data": {"url": "https://signed.example/x.mp4"}},
|
|
114
|
+
status=200,
|
|
115
|
+
)
|
|
116
|
+
# 2) the signed url serves bytes
|
|
117
|
+
responses.add(responses.GET, "https://signed.example/x.mp4", body=b"VIDEOBYTES", status=200)
|
|
118
|
+
|
|
119
|
+
hs = hyperstudy.HyperStudy(api_key="hst_test_" + "a" * 32)
|
|
120
|
+
rec = {
|
|
121
|
+
"downloadPath": "/data/recordings/download/room_EXP_1_a/EG_x",
|
|
122
|
+
"fileName": "x.mp4",
|
|
123
|
+
"participantId": "p1",
|
|
124
|
+
"recordingId": "EG_x",
|
|
125
|
+
}
|
|
126
|
+
dest = hs.download_recording(rec, output_dir=str(tmp_path))
|
|
127
|
+
assert dest.read_bytes() == b"VIDEOBYTES"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@responses.activate
|
|
131
|
+
def test_download_recording_falls_back_to_legacy_url(tmp_path):
|
|
132
|
+
responses.add(responses.GET, "https://legacy.example/y.mp4", body=b"LEGACY", status=200)
|
|
133
|
+
hs = hyperstudy.HyperStudy(api_key="hst_test_" + "a" * 32)
|
|
134
|
+
rec = {"url": "https://legacy.example/y.mp4", "fileName": "y.mp4", "recordingId": "old"}
|
|
135
|
+
dest = hs.download_recording(rec, output_dir=str(tmp_path))
|
|
136
|
+
assert dest.read_bytes() == b"LEGACY"
|
|
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
|
|
File without changes
|
|
File without changes
|