hyperstudy 0.2.3__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 (50) hide show
  1. hyperstudy-0.3.1/CHANGELOG.md +64 -0
  2. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/PKG-INFO +32 -2
  3. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/README.md +30 -1
  4. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/pyproject.toml +2 -1
  5. hyperstudy-0.3.1/src/hyperstudy/__init__.py +93 -0
  6. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/src/hyperstudy/_downloads.py +13 -9
  7. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/src/hyperstudy/client.py +28 -12
  8. hyperstudy-0.3.1/src/hyperstudy/experiments.py +229 -0
  9. hyperstudy-0.3.1/src/hyperstudy/models.py +361 -0
  10. hyperstudy-0.3.1/tests/fixtures/experiment.schema.json +227 -0
  11. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/test_client.py +11 -14
  12. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/test_downloads.py +68 -0
  13. hyperstudy-0.3.1/tests/test_experiments.py +474 -0
  14. hyperstudy-0.3.1/tests/test_models.py +426 -0
  15. hyperstudy-0.3.1/tests/test_recordings_download.py +136 -0
  16. hyperstudy-0.2.3/CHANGELOG.md +0 -30
  17. hyperstudy-0.2.3/src/hyperstudy/__init__.py +0 -38
  18. hyperstudy-0.2.3/src/hyperstudy/experiments.py +0 -125
  19. hyperstudy-0.2.3/tests/test_experiments.py +0 -197
  20. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/.github/workflows/publish.yml +0 -0
  21. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/.github/workflows/sync-release-notes.yml +0 -0
  22. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/.github/workflows/test.yml +0 -0
  23. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/.gitignore +0 -0
  24. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/LICENSE +0 -0
  25. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/docs/superpowers/specs/2026-04-10-recording-downloads-design.md +0 -0
  26. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/src/hyperstudy/_dataframe.py +0 -0
  27. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/src/hyperstudy/_display.py +0 -0
  28. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/src/hyperstudy/_http.py +0 -0
  29. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/src/hyperstudy/_pagination.py +0 -0
  30. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/src/hyperstudy/_types.py +0 -0
  31. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/src/hyperstudy/exceptions.py +0 -0
  32. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/__init__.py +0 -0
  33. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/conftest.py +0 -0
  34. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/deployment_sessions_response.json +0 -0
  35. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/deployment_single_response.json +0 -0
  36. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/deployments_list_response.json +0 -0
  37. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/error_401.json +0 -0
  38. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/error_403.json +0 -0
  39. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/events_response.json +0 -0
  40. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/experiment_single_response.json +0 -0
  41. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/experiments_list_response.json +0 -0
  42. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/paginated_page1.json +0 -0
  43. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/paginated_page2.json +0 -0
  44. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/pre_experiment_response.json +0 -0
  45. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/recordings_response.json +0 -0
  46. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/sparse_ratings_response.json +0 -0
  47. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/warnings_response.json +0 -0
  48. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/test_dataframe.py +0 -0
  49. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/test_http.py +0 -0
  50. {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/test_pagination.py +0 -0
@@ -0,0 +1,64 @@
1
+ # Changelog
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
+
18
+ ## v0.3.0
19
+
20
+ ### Features
21
+
22
+ - Typed experiment builders for programmatic authoring. New top-level exports: `Experiment`, `State`, `FocusComponent`, `Role`, `WaitingRoomConfig`, `DisconnectTimeout`, `InstructionsPage`, `PostExperimentQuestionnaire`, `TransitionRules`, `ComponentType`, `GlobalComponentType`. Snake_case Python fields are converted to the camelCase wire format automatically; unknown top-level fields are preserved via `extra="allow"` so the SDK doesn't block on backend schema additions.
23
+ - Component factory helpers: `show_text`, `show_image`, `show_video`, `vas_rating`, `text_input`, `multiple_choice`, `waiting`, `likert_scale`, `ranking`. Extra kwargs pass through to the component `config` for any backend-supported field.
24
+ - `create_experiment` and `update_experiment` now accept an `experiment=Experiment(...)` argument alongside the existing `**kwargs` form. When both are given, kwargs override builder fields.
25
+ - New `validate_experiment(experiment)` method — dry-run against `POST /experiments/validate`.
26
+ - Schema-drift guard test: vendored copy of `experiment.schema.json` is checked against `ComponentType` / `GlobalComponentType` / required-field declarations so backend additions surface in CI.
27
+
28
+ ### Backwards Compatibility
29
+
30
+ - `create_experiment(name=..., **kwargs)` continues to work unchanged.
31
+ - `update_experiment(experiment_id, **kwargs)` continues to work unchanged.
32
+
33
+ ### Dependencies
34
+
35
+ - Adds `pydantic>=2.5`.
36
+
37
+ ## v0.2.0
38
+
39
+ ### Features
40
+
41
+ - Convenience methods for common event categories: `get_questionnaire`, `get_instructions`, `get_consent`
42
+ - Deployment management: `list_deployments`, `get_deployment`, `get_deployment_sessions`
43
+ - API warning surfacing: backend `_warnings` metadata now emitted via Python's `warnings` module
44
+ - `get_all_data` now includes `ratings_sparse`, `questionnaire`, `instructions`, and `consent`
45
+
46
+ ### Breaking Changes
47
+
48
+ - `get_all_data` return keys changed: `"ratings"` split into `"ratings_continuous"` and `"ratings_sparse"`
49
+
50
+ ## v0.1.0
51
+
52
+ Initial release.
53
+
54
+ ### Features
55
+
56
+ - `HyperStudy` client with API key authentication (constructor arg or `HYPERSTUDY_API_KEY` env var)
57
+ - Data retrieval methods: `get_events`, `get_recordings`, `get_chat`, `get_videochat`, `get_sync`, `get_ratings`, `get_components`, `get_participants`, `get_rooms`
58
+ - `get_all_data` convenience method for fetching all data types for a single participant
59
+ - Auto-pagination with `tqdm` progress bars (notebook and terminal)
60
+ - Output format options: `"pandas"` (default), `"polars"`, `"dict"`
61
+ - Experiment management: `list_experiments`, `get_experiment`, `get_experiment_config`, `create_experiment`, `update_experiment`, `delete_experiment`
62
+ - Rich notebook display (`_repr_html_`) for experiment objects
63
+ - Typed exceptions: `AuthenticationError`, `ForbiddenError`, `NotFoundError`, `ValidationError`, `ServerError`
64
+ - `health()` method for connection testing
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperstudy
3
- Version: 0.2.3
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
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Scientific/Engineering
22
22
  Requires-Python: >=3.9
23
23
  Requires-Dist: pandas>=1.5.0
24
+ Requires-Dist: pydantic>=2.5
24
25
  Requires-Dist: requests>=2.28.0
25
26
  Requires-Dist: tqdm>=4.60.0
26
27
  Provides-Extra: dev
@@ -125,12 +126,41 @@ experiments = hs.list_experiments()
125
126
  # Get details (with rich display in notebooks)
126
127
  exp = hs.get_experiment("exp_id")
127
128
 
128
- # Create / update / delete
129
+ # Quick create / update / delete (kwargs form, backwards-compatible)
129
130
  new_exp = hs.create_experiment(name="My Study", description="...")
130
131
  hs.update_experiment("exp_id", name="Updated Name")
131
132
  hs.delete_experiment("exp_id")
132
133
  ```
133
134
 
135
+ ### Building experiments programmatically (0.3.0+)
136
+
137
+ For full experiment definitions — states, components, roles — use the typed `Experiment` builder. Snake_case Python fields convert to the camelCase wire format automatically.
138
+
139
+ ```python
140
+ from hyperstudy import Experiment, State, Role, show_text, vas_rating
141
+
142
+ exp = Experiment(
143
+ name="Two-person study",
144
+ required_participants=2,
145
+ states=[
146
+ State(id="intro", focus_component=show_text("Welcome")),
147
+ State(
148
+ id="rate",
149
+ focus_component=vas_rating("Rate the clip", output_variable="rating"),
150
+ ),
151
+ ],
152
+ roles={"speaker": Role(name="Speaker", participant_count=1)},
153
+ )
154
+
155
+ # Dry-run validation against the backend.
156
+ print(hs.validate_experiment(exp)) # → {"valid": True, ...}
157
+
158
+ # Create.
159
+ info = hs.create_experiment(experiment=exp)
160
+ ```
161
+
162
+ Component factories cover `show_text`, `show_image`, `show_video`, `vas_rating`, `text_input`, `multiple_choice`, `waiting`, `likert_scale`, `ranking`. For other component types, construct directly: `FocusComponent(type=ComponentType.X, config={...})`. See the [Experiment Authoring guide](https://docs.hyperstudy.io/experimenters/api-access/experiment-authoring) for the full reference.
163
+
134
164
  ## Deployments
135
165
 
136
166
  ```python
@@ -91,12 +91,41 @@ experiments = hs.list_experiments()
91
91
  # Get details (with rich display in notebooks)
92
92
  exp = hs.get_experiment("exp_id")
93
93
 
94
- # Create / update / delete
94
+ # Quick create / update / delete (kwargs form, backwards-compatible)
95
95
  new_exp = hs.create_experiment(name="My Study", description="...")
96
96
  hs.update_experiment("exp_id", name="Updated Name")
97
97
  hs.delete_experiment("exp_id")
98
98
  ```
99
99
 
100
+ ### Building experiments programmatically (0.3.0+)
101
+
102
+ For full experiment definitions — states, components, roles — use the typed `Experiment` builder. Snake_case Python fields convert to the camelCase wire format automatically.
103
+
104
+ ```python
105
+ from hyperstudy import Experiment, State, Role, show_text, vas_rating
106
+
107
+ exp = Experiment(
108
+ name="Two-person study",
109
+ required_participants=2,
110
+ states=[
111
+ State(id="intro", focus_component=show_text("Welcome")),
112
+ State(
113
+ id="rate",
114
+ focus_component=vas_rating("Rate the clip", output_variable="rating"),
115
+ ),
116
+ ],
117
+ roles={"speaker": Role(name="Speaker", participant_count=1)},
118
+ )
119
+
120
+ # Dry-run validation against the backend.
121
+ print(hs.validate_experiment(exp)) # → {"valid": True, ...}
122
+
123
+ # Create.
124
+ info = hs.create_experiment(experiment=exp)
125
+ ```
126
+
127
+ Component factories cover `show_text`, `show_image`, `show_video`, `vas_rating`, `text_input`, `multiple_choice`, `waiting`, `likert_scale`, `ranking`. For other component types, construct directly: `FocusComponent(type=ComponentType.X, config={...})`. See the [Experiment Authoring guide](https://docs.hyperstudy.io/experimenters/api-access/experiment-authoring) for the full reference.
128
+
100
129
  ## Deployments
101
130
 
102
131
  ```python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hyperstudy"
7
- version = "0.2.3"
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"
@@ -28,6 +28,7 @@ dependencies = [
28
28
  "requests>=2.28.0",
29
29
  "pandas>=1.5.0",
30
30
  "tqdm>=4.60.0",
31
+ "pydantic>=2.5",
31
32
  ]
32
33
 
33
34
  [project.optional-dependencies]
@@ -0,0 +1,93 @@
1
+ """HyperStudy Python SDK — access experiment data from notebooks and scripts.
2
+
3
+ Usage::
4
+
5
+ import hyperstudy
6
+
7
+ hs = hyperstudy.HyperStudy(api_key="hst_live_...")
8
+ events = hs.get_events("experiment_id")
9
+
10
+ Building experiments programmatically::
11
+
12
+ from hyperstudy import HyperStudy, Experiment, State, show_text
13
+
14
+ exp = Experiment(
15
+ name="Welcome study",
16
+ states=[State(id="s1", focus_component=show_text("Hello"))],
17
+ )
18
+ hs = HyperStudy(api_key="hst_live_...")
19
+ info = hs.create_experiment(experiment=exp)
20
+ """
21
+
22
+ from ._types import DataType, RatingKind, Scope
23
+ from .client import HyperStudy
24
+ from .exceptions import (
25
+ AuthenticationError,
26
+ ForbiddenError,
27
+ HyperStudyError,
28
+ NotFoundError,
29
+ ServerError,
30
+ ValidationError,
31
+ )
32
+ from .models import (
33
+ ComponentType,
34
+ DisconnectTimeout,
35
+ Experiment,
36
+ FocusComponent,
37
+ GlobalComponentType,
38
+ InstructionsPage,
39
+ PostExperimentQuestionnaire,
40
+ Role,
41
+ State,
42
+ TransitionRules,
43
+ WaitingRoomConfig,
44
+ likert_scale,
45
+ multiple_choice,
46
+ ranking,
47
+ show_image,
48
+ show_text,
49
+ show_video,
50
+ text_input,
51
+ vas_rating,
52
+ waiting,
53
+ )
54
+
55
+ __version__ = "0.3.0"
56
+
57
+ __all__ = [
58
+ "HyperStudy",
59
+ "__version__",
60
+ # Exceptions
61
+ "HyperStudyError",
62
+ "AuthenticationError",
63
+ "ForbiddenError",
64
+ "NotFoundError",
65
+ "ServerError",
66
+ "ValidationError",
67
+ # Enums
68
+ "Scope",
69
+ "DataType",
70
+ "RatingKind",
71
+ "ComponentType",
72
+ "GlobalComponentType",
73
+ # Experiment builders
74
+ "Experiment",
75
+ "State",
76
+ "FocusComponent",
77
+ "Role",
78
+ "TransitionRules",
79
+ "WaitingRoomConfig",
80
+ "DisconnectTimeout",
81
+ "InstructionsPage",
82
+ "PostExperimentQuestionnaire",
83
+ # Component factories
84
+ "show_text",
85
+ "show_image",
86
+ "show_video",
87
+ "vas_rating",
88
+ "text_input",
89
+ "multiple_choice",
90
+ "waiting",
91
+ "likert_scale",
92
+ "ranking",
93
+ ]
@@ -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; "
@@ -0,0 +1,229 @@
1
+ """Experiment CRUD methods (mixed into HyperStudy via inheritance)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from pydantic import BaseModel
8
+ from pydantic.alias_generators import to_camel
9
+
10
+ from ._display import ExperimentInfo
11
+ from ._http import HttpTransport
12
+ from ._pagination import fetch_all_pages
13
+ from .models import camelize_wire
14
+
15
+ if TYPE_CHECKING:
16
+ from .models import Experiment
17
+
18
+
19
+ def _build_experiment_payload(
20
+ experiment: "Experiment | dict[str, Any] | None" = None,
21
+ **overrides: Any,
22
+ ) -> dict[str, Any]:
23
+ """Build a wire-ready dict from an Experiment instance or raw dict.
24
+
25
+ - ``Experiment`` → ``.model_dump(by_alias=True, exclude_none=True)``
26
+ - ``dict`` → shallow copy
27
+ - ``None`` → empty dict
28
+
29
+ ``overrides`` are merged on top (caller wins). Snake_case keys in
30
+ overrides are translated to camelCase so they actually override the
31
+ aliased fields of the dumped ``Experiment`` (otherwise a kwarg like
32
+ ``required_participants=5`` would leave the builder's
33
+ ``requiredParticipants`` in place and ALSO add ``required_participants``
34
+ to the wire payload — both keys would go).
35
+ """
36
+ if experiment is None:
37
+ payload: dict[str, Any] = {}
38
+ elif isinstance(experiment, BaseModel):
39
+ payload = experiment.model_dump(by_alias=True, exclude_none=True)
40
+ elif isinstance(experiment, dict):
41
+ payload = camelize_wire(experiment)
42
+ else:
43
+ raise TypeError(
44
+ f"experiment must be an Experiment or dict, got {type(experiment).__name__}"
45
+ )
46
+ for key, value in overrides.items():
47
+ ck = to_camel(key)
48
+ payload[ck] = camelize_wire(value, _parent_key=ck)
49
+ return payload
50
+
51
+
52
+ class ExperimentMixin:
53
+ """Methods for experiment management. Mixed into the main client."""
54
+
55
+ _transport: HttpTransport
56
+
57
+ # ------------------------------------------------------------------
58
+ # Read
59
+ # ------------------------------------------------------------------
60
+
61
+ def list_experiments(
62
+ self,
63
+ *,
64
+ search: str | None = None,
65
+ limit: int | None = None,
66
+ offset: int = 0,
67
+ output: str = "pandas",
68
+ progress: bool = True,
69
+ ):
70
+ """List experiments accessible to the authenticated user.
71
+
72
+ Args:
73
+ search: Filter experiments by name/description substring.
74
+ limit: Max experiments to return. ``None`` fetches all pages.
75
+ offset: Starting offset for pagination.
76
+ output: ``"pandas"`` (default), ``"polars"``, or ``"dict"``.
77
+ progress: Show progress bar when paginating.
78
+
79
+ Returns:
80
+ DataFrame or list of dicts depending on *output*.
81
+ """
82
+ params: dict[str, Any] = {"offset": offset}
83
+ if search:
84
+ params["search"] = search
85
+
86
+ if limit is not None:
87
+ params["limit"] = limit
88
+ body = self._transport.get("experiments", params=params)
89
+ data = body.get("data", [])
90
+ else:
91
+ data, _ = fetch_all_pages(
92
+ self._transport,
93
+ "experiments",
94
+ params=params,
95
+ page_size=50,
96
+ progress=progress,
97
+ )
98
+
99
+ return self._convert_output(data, output)
100
+
101
+ def get_experiment(self, experiment_id: str) -> ExperimentInfo:
102
+ """Get a single experiment with enriched metadata.
103
+
104
+ Returns:
105
+ An :class:`ExperimentInfo` object (dict-like, with ``_repr_html_``).
106
+ """
107
+ body = self._transport.get(f"experiments/{experiment_id}")
108
+ data = body.get("data", [])
109
+ record = data[0] if isinstance(data, list) and data else data
110
+ return ExperimentInfo(record)
111
+
112
+ def get_experiment_config(self, experiment_id: str) -> dict[str, Any]:
113
+ """Get raw experiment config (lightweight, no enrichment)."""
114
+ body = self._transport.get(f"experiments/{experiment_id}/config")
115
+ data = body.get("data", [])
116
+ return data[0] if isinstance(data, list) and data else data
117
+
118
+ # ------------------------------------------------------------------
119
+ # Write
120
+ # ------------------------------------------------------------------
121
+
122
+ def create_experiment(
123
+ self,
124
+ *,
125
+ experiment: "Experiment | None" = None,
126
+ name: str | None = None,
127
+ **kwargs,
128
+ ) -> ExperimentInfo:
129
+ """Create a new experiment.
130
+
131
+ Two equivalent ways to call this:
132
+
133
+ - **Typed builder** (recommended): pass an :class:`Experiment` instance
134
+ via the ``experiment`` argument. Snake_case fields on the model are
135
+ converted to the camelCase wire format automatically. Fields added
136
+ via ``extra="allow"`` (unknown to the SDK's typed shape) and entries
137
+ in the per-component ``config`` dict pass through *verbatim* — use
138
+ camelCase keys for those.
139
+ - **Raw kwargs**: pass ``name=`` (required) and any additional fields
140
+ as keyword arguments. Backwards-compatible with hyperstudy <= 0.2.x.
141
+
142
+ When both forms are mixed, keyword arguments override fields from
143
+ ``experiment`` — useful for ad-hoc overrides.
144
+
145
+ Args:
146
+ experiment: Typed experiment definition.
147
+ name: Experiment name (required when ``experiment`` is omitted).
148
+ **kwargs: Additional fields, or overrides applied on top of
149
+ ``experiment``.
150
+
151
+ Returns:
152
+ The created :class:`ExperimentInfo`.
153
+ """
154
+ if name is not None:
155
+ kwargs["name"] = name
156
+ payload = _build_experiment_payload(experiment, **kwargs)
157
+ if not payload.get("name"):
158
+ raise TypeError(
159
+ "create_experiment requires a 'name' "
160
+ "(pass name=... or experiment=Experiment(name=...))"
161
+ )
162
+ body = self._transport.post("experiments", json=payload)
163
+ data = body.get("data", [])
164
+ record = data[0] if isinstance(data, list) and data else data
165
+ return ExperimentInfo(record)
166
+
167
+ def update_experiment(
168
+ self,
169
+ experiment_id: str,
170
+ *,
171
+ experiment: "Experiment | None" = None,
172
+ **kwargs,
173
+ ) -> dict[str, Any]:
174
+ """Update an experiment.
175
+
176
+ Args:
177
+ experiment_id: ID of the experiment to update.
178
+ experiment: Optional typed :class:`Experiment` carrying the new
179
+ fields. Snake_case → camelCase conversion is applied.
180
+ **kwargs: Fields to update, or overrides applied on top of
181
+ ``experiment``.
182
+
183
+ Returns:
184
+ Update confirmation dict.
185
+ """
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
+ )
192
+ body = self._transport.put(f"experiments/{experiment_id}", json=payload)
193
+ data = body.get("data", [])
194
+ return data[0] if isinstance(data, list) and data else data
195
+
196
+ def validate_experiment(
197
+ self,
198
+ experiment: "Experiment | dict[str, Any]",
199
+ ) -> dict[str, Any]:
200
+ """Dry-run validation of an experiment definition against the backend.
201
+
202
+ Returns the ``ValidationResultResponse`` body — typically
203
+ ``{"valid": True}`` on success, or ``{"valid": False, "errors": [...]}``
204
+ with a list of issues.
205
+
206
+ Args:
207
+ experiment: An :class:`Experiment` or a raw config dict.
208
+ """
209
+ if experiment is None:
210
+ raise TypeError("validate_experiment requires an Experiment or dict")
211
+ payload = _build_experiment_payload(experiment)
212
+ body = self._transport.post("experiments/validate", json=payload)
213
+ data = body.get("data", [])
214
+ return data[0] if isinstance(data, list) and data else data
215
+
216
+ def delete_experiment(
217
+ self, experiment_id: str, *, skip_data_check: bool = False
218
+ ) -> None:
219
+ """Delete (soft-delete) an experiment.
220
+
221
+ Args:
222
+ experiment_id: ID of the experiment to delete.
223
+ skip_data_check: If True, skip the confirmation check for experiments
224
+ with collected data.
225
+ """
226
+ params = {}
227
+ if skip_data_check:
228
+ params["skipDataCheck"] = "true"
229
+ self._transport.delete(f"experiments/{experiment_id}", params=params)