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.
Files changed (46) hide show
  1. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/CHANGELOG.md +15 -0
  2. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/PKG-INFO +1 -1
  3. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/pyproject.toml +1 -1
  4. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/_downloads.py +13 -9
  5. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/client.py +28 -12
  6. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/experiments.py +9 -2
  7. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/models.py +56 -22
  8. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/test_client.py +11 -14
  9. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/test_downloads.py +68 -0
  10. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/test_experiments.py +76 -0
  11. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/test_models.py +78 -0
  12. hyperstudy-0.3.1/tests/test_recordings_download.py +136 -0
  13. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/.github/workflows/publish.yml +0 -0
  14. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/.github/workflows/sync-release-notes.yml +0 -0
  15. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/.github/workflows/test.yml +0 -0
  16. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/.gitignore +0 -0
  17. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/LICENSE +0 -0
  18. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/README.md +0 -0
  19. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/docs/superpowers/specs/2026-04-10-recording-downloads-design.md +0 -0
  20. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/__init__.py +0 -0
  21. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/_dataframe.py +0 -0
  22. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/_display.py +0 -0
  23. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/_http.py +0 -0
  24. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/_pagination.py +0 -0
  25. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/_types.py +0 -0
  26. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/src/hyperstudy/exceptions.py +0 -0
  27. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/__init__.py +0 -0
  28. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/conftest.py +0 -0
  29. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/deployment_sessions_response.json +0 -0
  30. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/deployment_single_response.json +0 -0
  31. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/deployments_list_response.json +0 -0
  32. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/error_401.json +0 -0
  33. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/error_403.json +0 -0
  34. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/events_response.json +0 -0
  35. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/experiment.schema.json +0 -0
  36. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/experiment_single_response.json +0 -0
  37. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/experiments_list_response.json +0 -0
  38. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/paginated_page1.json +0 -0
  39. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/paginated_page2.json +0 -0
  40. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/pre_experiment_response.json +0 -0
  41. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/recordings_response.json +0 -0
  42. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/sparse_ratings_response.json +0 -0
  43. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/fixtures/warnings_response.json +0 -0
  44. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/test_dataframe.py +0 -0
  45. {hyperstudy-0.3.0 → hyperstudy-0.3.1}/tests/test_http.py +0 -0
  46. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperstudy
3
- Version: 0.3.0
3
+ Version: 0.3.1
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.3.0"
7
+ version = "0.3.1"
8
8
  description = "Python SDK for the HyperStudy experiment platform API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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* and return bytes written.
42
+ """Stream-download *url* to *dest*; returns bytes written.
42
43
 
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.
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(dest, "wb") as fh:
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
- dest.unlink(missing_ok=True)
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 = get_download_url(recording)
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
- url = get_download_url(rec)
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
- # 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:
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 = dict(experiment)
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
- payload[to_camel(key)] = value
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: Optional[str] = None
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[str, dict[str, Any]]] = None
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 or _new_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 or _new_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 or _new_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 or _new_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 or _new_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 or _new_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 or _new_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 or _new_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 or _new_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 test_download_recordings_skip_requires_known_size(
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 must NOT accept a
364
- stale on-disk file it must re-download so truncated prior attempts
365
- don't linger forever."""
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 (probably partial) video file on disk.
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(b"stale-partial-content")
379
+ video_path.write_bytes(existing_bytes)
379
380
 
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
- )
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
- assert df["download_status"].iloc[0] == "downloaded"
398
- assert video_path.read_bytes() == fresh_video
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