hyperstudy 0.2.2__tar.gz → 0.3.0__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 (49) hide show
  1. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/CHANGELOG.md +19 -0
  2. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/PKG-INFO +32 -2
  3. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/README.md +30 -1
  4. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/pyproject.toml +2 -1
  5. hyperstudy-0.3.0/src/hyperstudy/__init__.py +93 -0
  6. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/src/hyperstudy/_downloads.py +31 -5
  7. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/src/hyperstudy/_http.py +10 -3
  8. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/src/hyperstudy/_pagination.py +3 -2
  9. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/src/hyperstudy/client.py +36 -10
  10. hyperstudy-0.3.0/src/hyperstudy/experiments.py +222 -0
  11. hyperstudy-0.3.0/src/hyperstudy/models.py +327 -0
  12. hyperstudy-0.3.0/tests/fixtures/experiment.schema.json +227 -0
  13. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/test_client.py +135 -0
  14. hyperstudy-0.3.0/tests/test_downloads.py +188 -0
  15. hyperstudy-0.3.0/tests/test_experiments.py +398 -0
  16. hyperstudy-0.3.0/tests/test_http.py +54 -0
  17. hyperstudy-0.3.0/tests/test_models.py +348 -0
  18. hyperstudy-0.2.2/src/hyperstudy/__init__.py +0 -38
  19. hyperstudy-0.2.2/src/hyperstudy/experiments.py +0 -125
  20. hyperstudy-0.2.2/tests/test_downloads.py +0 -105
  21. hyperstudy-0.2.2/tests/test_experiments.py +0 -197
  22. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/.github/workflows/publish.yml +0 -0
  23. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/.github/workflows/sync-release-notes.yml +0 -0
  24. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/.github/workflows/test.yml +0 -0
  25. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/.gitignore +0 -0
  26. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/LICENSE +0 -0
  27. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/docs/superpowers/specs/2026-04-10-recording-downloads-design.md +0 -0
  28. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/src/hyperstudy/_dataframe.py +0 -0
  29. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/src/hyperstudy/_display.py +0 -0
  30. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/src/hyperstudy/_types.py +0 -0
  31. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/src/hyperstudy/exceptions.py +0 -0
  32. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/__init__.py +0 -0
  33. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/conftest.py +0 -0
  34. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/deployment_sessions_response.json +0 -0
  35. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/deployment_single_response.json +0 -0
  36. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/deployments_list_response.json +0 -0
  37. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/error_401.json +0 -0
  38. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/error_403.json +0 -0
  39. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/events_response.json +0 -0
  40. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/experiment_single_response.json +0 -0
  41. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/experiments_list_response.json +0 -0
  42. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/paginated_page1.json +0 -0
  43. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/paginated_page2.json +0 -0
  44. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/pre_experiment_response.json +0 -0
  45. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/recordings_response.json +0 -0
  46. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/sparse_ratings_response.json +0 -0
  47. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/warnings_response.json +0 -0
  48. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/test_dataframe.py +0 -0
  49. {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/test_pagination.py +0 -0
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.0
4
+
5
+ ### Features
6
+
7
+ - 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.
8
+ - 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.
9
+ - `create_experiment` and `update_experiment` now accept an `experiment=Experiment(...)` argument alongside the existing `**kwargs` form. When both are given, kwargs override builder fields.
10
+ - New `validate_experiment(experiment)` method — dry-run against `POST /experiments/validate`.
11
+ - Schema-drift guard test: vendored copy of `experiment.schema.json` is checked against `ComponentType` / `GlobalComponentType` / required-field declarations so backend additions surface in CI.
12
+
13
+ ### Backwards Compatibility
14
+
15
+ - `create_experiment(name=..., **kwargs)` continues to work unchanged.
16
+ - `update_experiment(experiment_id, **kwargs)` continues to work unchanged.
17
+
18
+ ### Dependencies
19
+
20
+ - Adds `pydantic>=2.5`.
21
+
3
22
  ## v0.2.0
4
23
 
5
24
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperstudy
3
- Version: 0.2.2
3
+ Version: 0.3.0
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.2"
7
+ version = "0.3.0"
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
+ ]
@@ -38,13 +38,39 @@ def build_filename(recording: dict[str, Any]) -> str:
38
38
 
39
39
 
40
40
  def download_file(url: str, dest: Path, timeout: int = 300) -> int:
41
- """Stream-download *url* to *dest* and return bytes written."""
41
+ """Stream-download *url* to *dest* and return bytes written.
42
+
43
+ Verifies ``Content-Length`` (when the server provides it) against bytes
44
+ actually written. On any error — HTTP, mid-stream disconnect, or length
45
+ mismatch — the partial file on disk is removed so ``skip_existing``
46
+ logic won't mistake it for a complete download on retry.
47
+ """
42
48
  resp = requests.get(url, stream=True, timeout=timeout)
43
49
  resp.raise_for_status()
44
50
 
51
+ expected: int | None
52
+ if "Content-Length" in resp.headers:
53
+ try:
54
+ expected = int(resp.headers["Content-Length"])
55
+ except (TypeError, ValueError):
56
+ expected = None
57
+ else:
58
+ expected = None
59
+
45
60
  written = 0
46
- with open(dest, "wb") as fh:
47
- for chunk in resp.iter_content(chunk_size=_CHUNK_SIZE):
48
- fh.write(chunk)
49
- written += len(chunk)
61
+ # BaseException so Ctrl-C also cleans up, not just exceptions.
62
+ try:
63
+ with open(dest, "wb") as fh:
64
+ for chunk in resp.iter_content(chunk_size=_CHUNK_SIZE):
65
+ fh.write(chunk)
66
+ written += len(chunk)
67
+
68
+ if expected is not None and written != expected:
69
+ raise IOError(
70
+ f"Truncated download: wrote {written} of {expected} bytes for {dest.name}"
71
+ )
72
+ except BaseException:
73
+ dest.unlink(missing_ok=True)
74
+ raise
75
+
50
76
  return written
@@ -63,8 +63,14 @@ class HttpTransport:
63
63
  # Public helpers
64
64
  # ------------------------------------------------------------------
65
65
 
66
- def get(self, path: str, params: dict[str, Any] | None = None) -> dict:
67
- return self._request("GET", path, params=params)
66
+ def get(
67
+ self,
68
+ path: str,
69
+ params: dict[str, Any] | None = None,
70
+ *,
71
+ timeout: int | float | None = None,
72
+ ) -> dict:
73
+ return self._request("GET", path, params=params, timeout=timeout)
68
74
 
69
75
  def post(self, path: str, json: dict[str, Any] | None = None) -> dict:
70
76
  return self._request("POST", path, json=json)
@@ -81,7 +87,8 @@ class HttpTransport:
81
87
 
82
88
  def _request(self, method: str, path: str, **kwargs) -> dict:
83
89
  url = f"{self.base_url}/{path.lstrip('/')}"
84
- kwargs.setdefault("timeout", self.timeout)
90
+ if kwargs.get("timeout") is None:
91
+ kwargs["timeout"] = self.timeout
85
92
 
86
93
  resp = self._session.request(method, url, **kwargs)
87
94
  return self._handle_response(resp)
@@ -16,6 +16,7 @@ def fetch_all_pages(
16
16
  *,
17
17
  page_size: int = 1000,
18
18
  progress: bool = True,
19
+ timeout: int | float | None = None,
19
20
  ) -> tuple[list[dict], dict]:
20
21
  """Fetch every page of a paginated endpoint.
21
22
 
@@ -28,7 +29,7 @@ def fetch_all_pages(
28
29
  params.setdefault("offset", 0)
29
30
 
30
31
  # First request to learn total
31
- body = transport.get(path, params=params)
32
+ body = transport.get(path, params=params, timeout=timeout)
32
33
  metadata = body.get("metadata", {})
33
34
  pagination = metadata.get("pagination", {})
34
35
  total = pagination.get("total", 0)
@@ -43,7 +44,7 @@ def fetch_all_pages(
43
44
  while has_more:
44
45
  params["offset"] = pagination.get("nextOffset", params["offset"] + page_size)
45
46
 
46
- body = transport.get(path, params=params)
47
+ body = transport.get(path, params=params, timeout=timeout)
47
48
  metadata = body.get("metadata", {})
48
49
  pagination = metadata.get("pagination", {})
49
50
 
@@ -13,6 +13,7 @@ from ._downloads import build_filename, download_file, get_download_url
13
13
  from ._http import HttpTransport
14
14
  from ._pagination import fetch_all_pages
15
15
  from ._types import Scope
16
+ from .exceptions import HyperStudyError
16
17
  from .experiments import ExperimentMixin
17
18
 
18
19
 
@@ -533,13 +534,24 @@ class HyperStudy(ExperimentMixin):
533
534
  pandas DataFrame with recording metadata plus ``local_path``
534
535
  and ``download_status`` columns.
535
536
  """
536
- recordings = self.get_recordings(
537
- scope_id,
538
- scope=scope,
539
- deployment_id=deployment_id,
540
- room_id=room_id,
541
- output="dict",
542
- )
537
+ try:
538
+ # Signing per-recording GCS URLs scales with count; 30s default is too short.
539
+ recordings = self._fetch_data(
540
+ "recordings",
541
+ scope_id,
542
+ scope=scope,
543
+ deployment_id=deployment_id,
544
+ room_id=room_id,
545
+ output="dict",
546
+ timeout=300,
547
+ )
548
+ except Exception as exc:
549
+ raise HyperStudyError(
550
+ f"Failed to list recordings for {scope}={scope_id!r}: {exc}",
551
+ code=getattr(exc, "code", "LIST_RECORDINGS_FAILED"),
552
+ status_code=getattr(exc, "status_code", None),
553
+ details=getattr(exc, "details", None),
554
+ ) from exc
543
555
 
544
556
  if recording_type:
545
557
  recordings = [
@@ -566,7 +578,9 @@ class HyperStudy(ExperimentMixin):
566
578
 
567
579
  if skip_existing and dest.exists():
568
580
  expected_size = rec.get("fileSize")
569
- if expected_size is None or dest.stat().st_size == expected_size:
581
+ # Require positive size match; missing size → re-download to
582
+ # avoid trusting a truncated file from a prior failed run.
583
+ if expected_size is not None and dest.stat().st_size == expected_size:
570
584
  local_paths.append(str(dest.resolve()))
571
585
  statuses.append("skipped")
572
586
  continue
@@ -582,6 +596,13 @@ class HyperStudy(ExperimentMixin):
582
596
  f"Failed to download recording {rec.get('recordingId')}: {exc}"
583
597
  )
584
598
 
599
+ failed_count = sum(1 for s in statuses if s == "failed")
600
+ if failed_count:
601
+ warnings.warn(
602
+ f"{failed_count}/{len(recordings)} recordings failed to download; "
603
+ "see the 'download_status' column for per-file results."
604
+ )
605
+
585
606
  df = to_pandas(recordings)
586
607
  if not df.empty:
587
608
  df["local_path"] = local_paths
@@ -649,6 +670,7 @@ class HyperStudy(ExperimentMixin):
649
670
  offset: int = 0,
650
671
  output: str = "pandas",
651
672
  progress: bool = True,
673
+ timeout: int | float | None = None,
652
674
  **extra_params,
653
675
  ):
654
676
  """Generic data-fetching logic shared by all ``get_*`` methods."""
@@ -675,11 +697,15 @@ class HyperStudy(ExperimentMixin):
675
697
  # Single page or all pages
676
698
  if limit is not None:
677
699
  params["limit"] = limit
678
- body = self._transport.get(path, params=params)
700
+ body = self._transport.get(path, params=params, timeout=timeout)
679
701
  data = body.get("data", [])
680
702
  else:
681
703
  data, _ = fetch_all_pages(
682
- self._transport, path, params=params, progress=progress
704
+ self._transport,
705
+ path,
706
+ params=params,
707
+ progress=progress,
708
+ timeout=timeout,
683
709
  )
684
710
 
685
711
  return self._convert_output(data, output)
@@ -0,0 +1,222 @@
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
+
14
+ if TYPE_CHECKING:
15
+ from .models import Experiment
16
+
17
+
18
+ def _build_experiment_payload(
19
+ experiment: "Experiment | dict[str, Any] | None" = None,
20
+ **overrides: Any,
21
+ ) -> dict[str, Any]:
22
+ """Build a wire-ready dict from an Experiment instance or raw dict.
23
+
24
+ - ``Experiment`` → ``.model_dump(by_alias=True, exclude_none=True)``
25
+ - ``dict`` → shallow copy
26
+ - ``None`` → empty dict
27
+
28
+ ``overrides`` are merged on top (caller wins). Snake_case keys in
29
+ overrides are translated to camelCase so they actually override the
30
+ aliased fields of the dumped ``Experiment`` (otherwise a kwarg like
31
+ ``required_participants=5`` would leave the builder's
32
+ ``requiredParticipants`` in place and ALSO add ``required_participants``
33
+ to the wire payload — both keys would go).
34
+ """
35
+ if experiment is None:
36
+ payload: dict[str, Any] = {}
37
+ elif isinstance(experiment, BaseModel):
38
+ payload = experiment.model_dump(by_alias=True, exclude_none=True)
39
+ elif isinstance(experiment, dict):
40
+ payload = dict(experiment)
41
+ else:
42
+ raise TypeError(
43
+ f"experiment must be an Experiment or dict, got {type(experiment).__name__}"
44
+ )
45
+ for key, value in overrides.items():
46
+ payload[to_camel(key)] = value
47
+ return payload
48
+
49
+
50
+ class ExperimentMixin:
51
+ """Methods for experiment management. Mixed into the main client."""
52
+
53
+ _transport: HttpTransport
54
+
55
+ # ------------------------------------------------------------------
56
+ # Read
57
+ # ------------------------------------------------------------------
58
+
59
+ def list_experiments(
60
+ self,
61
+ *,
62
+ search: str | None = None,
63
+ limit: int | None = None,
64
+ offset: int = 0,
65
+ output: str = "pandas",
66
+ progress: bool = True,
67
+ ):
68
+ """List experiments accessible to the authenticated user.
69
+
70
+ Args:
71
+ search: Filter experiments by name/description substring.
72
+ limit: Max experiments to return. ``None`` fetches all pages.
73
+ offset: Starting offset for pagination.
74
+ output: ``"pandas"`` (default), ``"polars"``, or ``"dict"``.
75
+ progress: Show progress bar when paginating.
76
+
77
+ Returns:
78
+ DataFrame or list of dicts depending on *output*.
79
+ """
80
+ params: dict[str, Any] = {"offset": offset}
81
+ if search:
82
+ params["search"] = search
83
+
84
+ if limit is not None:
85
+ params["limit"] = limit
86
+ body = self._transport.get("experiments", params=params)
87
+ data = body.get("data", [])
88
+ else:
89
+ data, _ = fetch_all_pages(
90
+ self._transport,
91
+ "experiments",
92
+ params=params,
93
+ page_size=50,
94
+ progress=progress,
95
+ )
96
+
97
+ return self._convert_output(data, output)
98
+
99
+ def get_experiment(self, experiment_id: str) -> ExperimentInfo:
100
+ """Get a single experiment with enriched metadata.
101
+
102
+ Returns:
103
+ An :class:`ExperimentInfo` object (dict-like, with ``_repr_html_``).
104
+ """
105
+ body = self._transport.get(f"experiments/{experiment_id}")
106
+ data = body.get("data", [])
107
+ record = data[0] if isinstance(data, list) and data else data
108
+ return ExperimentInfo(record)
109
+
110
+ def get_experiment_config(self, experiment_id: str) -> dict[str, Any]:
111
+ """Get raw experiment config (lightweight, no enrichment)."""
112
+ body = self._transport.get(f"experiments/{experiment_id}/config")
113
+ data = body.get("data", [])
114
+ return data[0] if isinstance(data, list) and data else data
115
+
116
+ # ------------------------------------------------------------------
117
+ # Write
118
+ # ------------------------------------------------------------------
119
+
120
+ def create_experiment(
121
+ self,
122
+ *,
123
+ experiment: "Experiment | None" = None,
124
+ name: str | None = None,
125
+ **kwargs,
126
+ ) -> ExperimentInfo:
127
+ """Create a new experiment.
128
+
129
+ Two equivalent ways to call this:
130
+
131
+ - **Typed builder** (recommended): pass an :class:`Experiment` instance
132
+ via the ``experiment`` argument. Snake_case fields on the model are
133
+ converted to the camelCase wire format automatically. Fields added
134
+ via ``extra="allow"`` (unknown to the SDK's typed shape) and entries
135
+ in the per-component ``config`` dict pass through *verbatim* — use
136
+ camelCase keys for those.
137
+ - **Raw kwargs**: pass ``name=`` (required) and any additional fields
138
+ as keyword arguments. Backwards-compatible with hyperstudy <= 0.2.x.
139
+
140
+ When both forms are mixed, keyword arguments override fields from
141
+ ``experiment`` — useful for ad-hoc overrides.
142
+
143
+ Args:
144
+ experiment: Typed experiment definition.
145
+ name: Experiment name (required when ``experiment`` is omitted).
146
+ **kwargs: Additional fields, or overrides applied on top of
147
+ ``experiment``.
148
+
149
+ Returns:
150
+ The created :class:`ExperimentInfo`.
151
+ """
152
+ if name is not None:
153
+ kwargs["name"] = name
154
+ payload = _build_experiment_payload(experiment, **kwargs)
155
+ if not payload.get("name"):
156
+ raise TypeError(
157
+ "create_experiment requires a 'name' "
158
+ "(pass name=... or experiment=Experiment(name=...))"
159
+ )
160
+ body = self._transport.post("experiments", json=payload)
161
+ data = body.get("data", [])
162
+ record = data[0] if isinstance(data, list) and data else data
163
+ return ExperimentInfo(record)
164
+
165
+ def update_experiment(
166
+ self,
167
+ experiment_id: str,
168
+ *,
169
+ experiment: "Experiment | None" = None,
170
+ **kwargs,
171
+ ) -> dict[str, Any]:
172
+ """Update an experiment.
173
+
174
+ Args:
175
+ experiment_id: ID of the experiment to update.
176
+ experiment: Optional typed :class:`Experiment` carrying the new
177
+ fields. Snake_case → camelCase conversion is applied.
178
+ **kwargs: Fields to update, or overrides applied on top of
179
+ ``experiment``.
180
+
181
+ Returns:
182
+ Update confirmation dict.
183
+ """
184
+ payload = _build_experiment_payload(experiment, **kwargs)
185
+ body = self._transport.put(f"experiments/{experiment_id}", json=payload)
186
+ data = body.get("data", [])
187
+ return data[0] if isinstance(data, list) and data else data
188
+
189
+ def validate_experiment(
190
+ self,
191
+ experiment: "Experiment | dict[str, Any]",
192
+ ) -> dict[str, Any]:
193
+ """Dry-run validation of an experiment definition against the backend.
194
+
195
+ Returns the ``ValidationResultResponse`` body — typically
196
+ ``{"valid": True}`` on success, or ``{"valid": False, "errors": [...]}``
197
+ with a list of issues.
198
+
199
+ Args:
200
+ experiment: An :class:`Experiment` or a raw config dict.
201
+ """
202
+ if experiment is None:
203
+ raise TypeError("validate_experiment requires an Experiment or dict")
204
+ payload = _build_experiment_payload(experiment)
205
+ body = self._transport.post("experiments/validate", json=payload)
206
+ data = body.get("data", [])
207
+ return data[0] if isinstance(data, list) and data else data
208
+
209
+ def delete_experiment(
210
+ self, experiment_id: str, *, skip_data_check: bool = False
211
+ ) -> None:
212
+ """Delete (soft-delete) an experiment.
213
+
214
+ Args:
215
+ experiment_id: ID of the experiment to delete.
216
+ skip_data_check: If True, skip the confirmation check for experiments
217
+ with collected data.
218
+ """
219
+ params = {}
220
+ if skip_data_check:
221
+ params["skipDataCheck"] = "true"
222
+ self._transport.delete(f"experiments/{experiment_id}", params=params)