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.
- hyperstudy-0.3.1/CHANGELOG.md +64 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/PKG-INFO +32 -2
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/README.md +30 -1
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/pyproject.toml +2 -1
- hyperstudy-0.3.1/src/hyperstudy/__init__.py +93 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/src/hyperstudy/_downloads.py +13 -9
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/src/hyperstudy/client.py +28 -12
- hyperstudy-0.3.1/src/hyperstudy/experiments.py +229 -0
- hyperstudy-0.3.1/src/hyperstudy/models.py +361 -0
- hyperstudy-0.3.1/tests/fixtures/experiment.schema.json +227 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/test_client.py +11 -14
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/test_downloads.py +68 -0
- hyperstudy-0.3.1/tests/test_experiments.py +474 -0
- hyperstudy-0.3.1/tests/test_models.py +426 -0
- hyperstudy-0.3.1/tests/test_recordings_download.py +136 -0
- hyperstudy-0.2.3/CHANGELOG.md +0 -30
- hyperstudy-0.2.3/src/hyperstudy/__init__.py +0 -38
- hyperstudy-0.2.3/src/hyperstudy/experiments.py +0 -125
- hyperstudy-0.2.3/tests/test_experiments.py +0 -197
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/.github/workflows/publish.yml +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/.github/workflows/sync-release-notes.yml +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/.github/workflows/test.yml +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/.gitignore +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/LICENSE +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/docs/superpowers/specs/2026-04-10-recording-downloads-design.md +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/src/hyperstudy/_dataframe.py +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/src/hyperstudy/_display.py +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/src/hyperstudy/_http.py +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/src/hyperstudy/_pagination.py +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/src/hyperstudy/_types.py +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/src/hyperstudy/exceptions.py +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/__init__.py +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/conftest.py +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/deployment_sessions_response.json +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/deployment_single_response.json +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/deployments_list_response.json +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/error_401.json +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/error_403.json +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/events_response.json +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/experiment_single_response.json +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/experiments_list_response.json +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/paginated_page1.json +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/paginated_page2.json +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/pre_experiment_response.json +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/recordings_response.json +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/sparse_ratings_response.json +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/fixtures/warnings_response.json +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/test_dataframe.py +0 -0
- {hyperstudy-0.2.3 → hyperstudy-0.3.1}/tests/test_http.py +0 -0
- {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.
|
|
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
|
-
#
|
|
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.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
|
|
42
|
+
"""Stream-download *url* to *dest*; returns bytes written.
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
Downloads to a temporary ``<dest>.part`` and atomically renames on success, so a
|
|
45
|
+
mid-stream failure (HTTP error, disconnect, length mismatch) removes only the temp
|
|
46
|
+
file and leaves any pre-existing complete file at ``dest`` intact. Verifies
|
|
47
|
+
``Content-Length`` when provided.
|
|
47
48
|
"""
|
|
48
49
|
resp = requests.get(url, stream=True, timeout=timeout)
|
|
49
50
|
resp.raise_for_status()
|
|
@@ -57,20 +58,23 @@ def download_file(url: str, dest: Path, timeout: int = 300) -> int:
|
|
|
57
58
|
else:
|
|
58
59
|
expected = None
|
|
59
60
|
|
|
61
|
+
tmp = dest.with_name(dest.name + ".part")
|
|
60
62
|
written = 0
|
|
61
|
-
# BaseException so Ctrl-C also cleans up, not just exceptions.
|
|
62
63
|
try:
|
|
63
|
-
with open(
|
|
64
|
+
with open(tmp, "wb") as fh:
|
|
64
65
|
for chunk in resp.iter_content(chunk_size=_CHUNK_SIZE):
|
|
65
66
|
fh.write(chunk)
|
|
66
67
|
written += len(chunk)
|
|
67
|
-
|
|
68
68
|
if expected is not None and written != expected:
|
|
69
69
|
raise IOError(
|
|
70
70
|
f"Truncated download: wrote {written} of {expected} bytes for {dest.name}"
|
|
71
71
|
)
|
|
72
|
+
os.replace(tmp, dest) # atomic on the same filesystem
|
|
72
73
|
except BaseException:
|
|
73
|
-
|
|
74
|
+
try:
|
|
75
|
+
tmp.unlink(missing_ok=True) # remove only the temp; never the existing dest
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
74
78
|
raise
|
|
75
79
|
|
|
76
80
|
return written
|
|
@@ -476,6 +476,19 @@ class HyperStudy(ExperimentMixin):
|
|
|
476
476
|
# Recording downloads
|
|
477
477
|
# ------------------------------------------------------------------
|
|
478
478
|
|
|
479
|
+
def _download_url_for(self, recording: dict[str, Any]) -> str | None:
|
|
480
|
+
"""Resolve a directly-downloadable URL for a recording.
|
|
481
|
+
|
|
482
|
+
Prefers the auth'd mint endpoint (``downloadPath`` -> short-lived signed GCS
|
|
483
|
+
URL); falls back to a legacy absolute ``downloadUrl``/``url`` for old
|
|
484
|
+
GCS-only recordings.
|
|
485
|
+
"""
|
|
486
|
+
download_path = recording.get("downloadPath")
|
|
487
|
+
if download_path:
|
|
488
|
+
body = self._transport.get(download_path)
|
|
489
|
+
return (body.get("data") or {}).get("url")
|
|
490
|
+
return get_download_url(recording)
|
|
491
|
+
|
|
479
492
|
def download_recording(
|
|
480
493
|
self,
|
|
481
494
|
recording: dict[str, Any],
|
|
@@ -490,7 +503,7 @@ class HyperStudy(ExperimentMixin):
|
|
|
490
503
|
Returns:
|
|
491
504
|
Path to the downloaded file.
|
|
492
505
|
"""
|
|
493
|
-
url =
|
|
506
|
+
url = self._download_url_for(recording)
|
|
494
507
|
if not url:
|
|
495
508
|
raise ValueError("Recording has no downloadUrl or url field")
|
|
496
509
|
|
|
@@ -564,27 +577,30 @@ class HyperStudy(ExperimentMixin):
|
|
|
564
577
|
|
|
565
578
|
local_paths: list[str | None] = []
|
|
566
579
|
statuses: list[str] = []
|
|
580
|
+
failed_count = 0
|
|
567
581
|
|
|
568
582
|
for rec in tqdm(recordings, desc="Downloading recordings", disable=not progress):
|
|
569
583
|
filename = build_filename(rec)
|
|
570
584
|
dest = dest_dir / filename
|
|
571
585
|
|
|
572
|
-
|
|
573
|
-
if not url:
|
|
574
|
-
local_paths.append(None)
|
|
575
|
-
statuses.append("failed")
|
|
576
|
-
warnings.warn(f"Recording {rec.get('recordingId')} has no download URL")
|
|
577
|
-
continue
|
|
578
|
-
|
|
586
|
+
# Check skip before resolving the URL (avoids minting a signed URL unnecessarily).
|
|
579
587
|
if skip_existing and dest.exists():
|
|
580
588
|
expected_size = rec.get("fileSize")
|
|
581
|
-
#
|
|
582
|
-
#
|
|
583
|
-
if expected_size is
|
|
589
|
+
# dest is always a fully-written file (atomic rename), so skip when size is
|
|
590
|
+
# unknown OR matches; only re-download on a definite size mismatch.
|
|
591
|
+
if expected_size is None or dest.stat().st_size == expected_size:
|
|
584
592
|
local_paths.append(str(dest.resolve()))
|
|
585
593
|
statuses.append("skipped")
|
|
586
594
|
continue
|
|
587
595
|
|
|
596
|
+
url = self._download_url_for(rec)
|
|
597
|
+
if not url:
|
|
598
|
+
local_paths.append(None)
|
|
599
|
+
statuses.append("failed")
|
|
600
|
+
failed_count += 1
|
|
601
|
+
warnings.warn(f"Recording {rec.get('recordingId')} has no download URL")
|
|
602
|
+
continue
|
|
603
|
+
|
|
588
604
|
try:
|
|
589
605
|
download_file(url, dest)
|
|
590
606
|
local_paths.append(str(dest.resolve()))
|
|
@@ -592,11 +608,11 @@ class HyperStudy(ExperimentMixin):
|
|
|
592
608
|
except Exception as exc:
|
|
593
609
|
local_paths.append(None)
|
|
594
610
|
statuses.append("failed")
|
|
611
|
+
failed_count += 1
|
|
595
612
|
warnings.warn(
|
|
596
613
|
f"Failed to download recording {rec.get('recordingId')}: {exc}"
|
|
597
614
|
)
|
|
598
615
|
|
|
599
|
-
failed_count = sum(1 for s in statuses if s == "failed")
|
|
600
616
|
if failed_count:
|
|
601
617
|
warnings.warn(
|
|
602
618
|
f"{failed_count}/{len(recordings)} recordings failed to download; "
|
|
@@ -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)
|