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.
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/CHANGELOG.md +19 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/PKG-INFO +32 -2
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/README.md +30 -1
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/pyproject.toml +2 -1
- hyperstudy-0.3.0/src/hyperstudy/__init__.py +93 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/src/hyperstudy/_downloads.py +31 -5
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/src/hyperstudy/_http.py +10 -3
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/src/hyperstudy/_pagination.py +3 -2
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/src/hyperstudy/client.py +36 -10
- hyperstudy-0.3.0/src/hyperstudy/experiments.py +222 -0
- hyperstudy-0.3.0/src/hyperstudy/models.py +327 -0
- hyperstudy-0.3.0/tests/fixtures/experiment.schema.json +227 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/test_client.py +135 -0
- hyperstudy-0.3.0/tests/test_downloads.py +188 -0
- hyperstudy-0.3.0/tests/test_experiments.py +398 -0
- hyperstudy-0.3.0/tests/test_http.py +54 -0
- hyperstudy-0.3.0/tests/test_models.py +348 -0
- hyperstudy-0.2.2/src/hyperstudy/__init__.py +0 -38
- hyperstudy-0.2.2/src/hyperstudy/experiments.py +0 -125
- hyperstudy-0.2.2/tests/test_downloads.py +0 -105
- hyperstudy-0.2.2/tests/test_experiments.py +0 -197
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/.github/workflows/publish.yml +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/.github/workflows/sync-release-notes.yml +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/.github/workflows/test.yml +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/.gitignore +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/LICENSE +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/docs/superpowers/specs/2026-04-10-recording-downloads-design.md +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/src/hyperstudy/_dataframe.py +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/src/hyperstudy/_display.py +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/src/hyperstudy/_types.py +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/src/hyperstudy/exceptions.py +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/__init__.py +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/conftest.py +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/deployment_sessions_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/deployment_single_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/deployments_list_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/error_401.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/error_403.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/events_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/experiment_single_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/experiments_list_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/paginated_page1.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/paginated_page2.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/pre_experiment_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/recordings_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/sparse_ratings_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/fixtures/warnings_response.json +0 -0
- {hyperstudy-0.2.2 → hyperstudy-0.3.0}/tests/test_dataframe.py +0 -0
- {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.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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(
|
|
67
|
-
|
|
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.
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
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,
|
|
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)
|