hyperstudy 0.2.2__py3-none-any.whl → 0.3.0__py3-none-any.whl
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/__init__.py +56 -1
- hyperstudy/_downloads.py +31 -5
- hyperstudy/_http.py +10 -3
- hyperstudy/_pagination.py +3 -2
- hyperstudy/client.py +36 -10
- hyperstudy/experiments.py +105 -8
- hyperstudy/models.py +327 -0
- {hyperstudy-0.2.2.dist-info → hyperstudy-0.3.0.dist-info}/METADATA +32 -2
- hyperstudy-0.3.0.dist-info/RECORD +15 -0
- hyperstudy-0.2.2.dist-info/RECORD +0 -14
- {hyperstudy-0.2.2.dist-info → hyperstudy-0.3.0.dist-info}/WHEEL +0 -0
- {hyperstudy-0.2.2.dist-info → hyperstudy-0.3.0.dist-info}/licenses/LICENSE +0 -0
hyperstudy/__init__.py
CHANGED
|
@@ -6,6 +6,17 @@ Usage::
|
|
|
6
6
|
|
|
7
7
|
hs = hyperstudy.HyperStudy(api_key="hst_live_...")
|
|
8
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)
|
|
9
20
|
"""
|
|
10
21
|
|
|
11
22
|
from ._types import DataType, RatingKind, Scope
|
|
@@ -18,8 +29,30 @@ from .exceptions import (
|
|
|
18
29
|
ServerError,
|
|
19
30
|
ValidationError,
|
|
20
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
|
+
)
|
|
21
54
|
|
|
22
|
-
__version__ = "0.
|
|
55
|
+
__version__ = "0.3.0"
|
|
23
56
|
|
|
24
57
|
__all__ = [
|
|
25
58
|
"HyperStudy",
|
|
@@ -35,4 +68,26 @@ __all__ = [
|
|
|
35
68
|
"Scope",
|
|
36
69
|
"DataType",
|
|
37
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",
|
|
38
93
|
]
|
hyperstudy/_downloads.py
CHANGED
|
@@ -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
|
hyperstudy/_http.py
CHANGED
|
@@ -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)
|
hyperstudy/_pagination.py
CHANGED
|
@@ -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
|
|
hyperstudy/client.py
CHANGED
|
@@ -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)
|
hyperstudy/experiments.py
CHANGED
|
@@ -2,12 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import Any
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from pydantic.alias_generators import to_camel
|
|
6
9
|
|
|
7
10
|
from ._display import ExperimentInfo
|
|
8
11
|
from ._http import HttpTransport
|
|
9
12
|
from ._pagination import fetch_all_pages
|
|
10
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
|
+
|
|
11
49
|
|
|
12
50
|
class ExperimentMixin:
|
|
13
51
|
"""Methods for experiment management. Mixed into the main client."""
|
|
@@ -79,33 +117,92 @@ class ExperimentMixin:
|
|
|
79
117
|
# Write
|
|
80
118
|
# ------------------------------------------------------------------
|
|
81
119
|
|
|
82
|
-
def create_experiment(
|
|
120
|
+
def create_experiment(
|
|
121
|
+
self,
|
|
122
|
+
*,
|
|
123
|
+
experiment: "Experiment | None" = None,
|
|
124
|
+
name: str | None = None,
|
|
125
|
+
**kwargs,
|
|
126
|
+
) -> ExperimentInfo:
|
|
83
127
|
"""Create a new experiment.
|
|
84
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
|
+
|
|
85
143
|
Args:
|
|
86
|
-
|
|
87
|
-
|
|
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``.
|
|
88
148
|
|
|
89
149
|
Returns:
|
|
90
150
|
The created :class:`ExperimentInfo`.
|
|
91
151
|
"""
|
|
92
|
-
|
|
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
|
+
)
|
|
93
160
|
body = self._transport.post("experiments", json=payload)
|
|
94
161
|
data = body.get("data", [])
|
|
95
162
|
record = data[0] if isinstance(data, list) and data else data
|
|
96
163
|
return ExperimentInfo(record)
|
|
97
164
|
|
|
98
|
-
def update_experiment(
|
|
165
|
+
def update_experiment(
|
|
166
|
+
self,
|
|
167
|
+
experiment_id: str,
|
|
168
|
+
*,
|
|
169
|
+
experiment: "Experiment | None" = None,
|
|
170
|
+
**kwargs,
|
|
171
|
+
) -> dict[str, Any]:
|
|
99
172
|
"""Update an experiment.
|
|
100
173
|
|
|
101
174
|
Args:
|
|
102
175
|
experiment_id: ID of the experiment to update.
|
|
103
|
-
|
|
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``.
|
|
104
180
|
|
|
105
181
|
Returns:
|
|
106
182
|
Update confirmation dict.
|
|
107
183
|
"""
|
|
108
|
-
|
|
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)
|
|
109
206
|
data = body.get("data", [])
|
|
110
207
|
return data[0] if isinstance(data, list) and data else data
|
|
111
208
|
|
hyperstudy/models.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Typed builders for HyperStudy experiment definitions.
|
|
2
|
+
|
|
3
|
+
Mirrors the JSON schema at ``shared/schemas/experiment.schema.json`` in the
|
|
4
|
+
main repo. The outer shape (Experiment / State / FocusComponent / Role) is
|
|
5
|
+
typed; per-component ``config`` payloads are validated server-side and remain
|
|
6
|
+
``dict[str, Any]`` here on purpose. Unknown top-level fields are preserved
|
|
7
|
+
via ``extra="allow"`` so the SDK does not block on backend additions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import uuid
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
17
|
+
from pydantic.alias_generators import to_camel
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _new_id() -> str:
|
|
21
|
+
return uuid.uuid4().hex[:8]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ComponentType(str, Enum):
|
|
25
|
+
"""Focus component types (one per state)."""
|
|
26
|
+
|
|
27
|
+
SHOW_TEXT = "showtext"
|
|
28
|
+
SHOW_IMAGE = "showimage"
|
|
29
|
+
SHOW_VIDEO = "showvideo"
|
|
30
|
+
VAS_RATING = "vasrating"
|
|
31
|
+
TEXT_INPUT = "textinput"
|
|
32
|
+
MULTIPLE_CHOICE = "multiplechoice"
|
|
33
|
+
WAITING = "waiting"
|
|
34
|
+
CODE = "code"
|
|
35
|
+
CONTINUOUS_RATING = "continuousrating"
|
|
36
|
+
VIDEO_CHAT = "videochat"
|
|
37
|
+
TEXT_CHAT = "textchat"
|
|
38
|
+
RAPID_RATE = "rapidrate"
|
|
39
|
+
AUDIO_RECORDING = "audiorecording"
|
|
40
|
+
SPARSE_RATING = "sparserating"
|
|
41
|
+
TRIGGER = "trigger"
|
|
42
|
+
RANKING = "ranking"
|
|
43
|
+
SCANNER_PULSE_RECORDER = "scannerpulserecorder"
|
|
44
|
+
LIKERT_SCALE = "likertscale"
|
|
45
|
+
GAZE_OVERLAY = "gazeoverlay"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class GlobalComponentType(str, Enum):
|
|
49
|
+
"""Component types that may run as global (persistent) components."""
|
|
50
|
+
|
|
51
|
+
CONTINUOUS_RATING = "continuousrating"
|
|
52
|
+
VIDEO_CHAT = "videochat"
|
|
53
|
+
TEXT_CHAT = "textchat"
|
|
54
|
+
SPARSE_RATING = "sparserating"
|
|
55
|
+
SCANNER_PULSE_RECORDER = "scannerpulserecorder"
|
|
56
|
+
GAZE_OVERLAY = "gazeoverlay"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_BASE_CONFIG = ConfigDict(
|
|
60
|
+
populate_by_name=True,
|
|
61
|
+
alias_generator=to_camel,
|
|
62
|
+
extra="allow",
|
|
63
|
+
use_enum_values=True,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _Model(BaseModel):
|
|
68
|
+
model_config = _BASE_CONFIG
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class FocusComponent(_Model):
|
|
72
|
+
"""Primary component displayed in a single state.
|
|
73
|
+
|
|
74
|
+
``config`` is component-type-specific and validated by the backend's
|
|
75
|
+
``componentSchemaValidator``. Use the factory helpers in this module
|
|
76
|
+
(``show_text``, ``vas_rating``, ...) for ergonomic construction.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
type: ComponentType
|
|
80
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
81
|
+
id: Optional[str] = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TransitionRules(_Model):
|
|
85
|
+
"""Rules governing how the experiment transitions from a state."""
|
|
86
|
+
|
|
87
|
+
type: Optional[str] = None
|
|
88
|
+
duration_ms: Optional[int] = Field(default=None, ge=0)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class State(_Model):
|
|
92
|
+
"""An experiment state — a single screen/phase."""
|
|
93
|
+
|
|
94
|
+
id: str
|
|
95
|
+
name: Optional[str] = None
|
|
96
|
+
order: Optional[int] = Field(default=None, ge=0)
|
|
97
|
+
focus_component: Optional[FocusComponent] = None
|
|
98
|
+
transition_rules: Optional[TransitionRules] = None
|
|
99
|
+
global_components_visibility: Optional[dict[str, bool]] = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Role(_Model):
|
|
103
|
+
"""Participant role definition for multi-participant experiments."""
|
|
104
|
+
|
|
105
|
+
name: Optional[str] = None
|
|
106
|
+
participant_count: Optional[int] = Field(default=None, ge=0)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class WaitingRoomConfig(_Model):
|
|
110
|
+
max_wait_time_ms: Optional[int] = Field(default=None, ge=0)
|
|
111
|
+
countdown_time_ms: Optional[int] = Field(default=None, ge=0)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class DisconnectTimeout(_Model):
|
|
115
|
+
enabled: Optional[bool] = None
|
|
116
|
+
duration_ms: Optional[int] = Field(default=None, ge=0)
|
|
117
|
+
auto_reconnect_delay: Optional[int] = Field(default=None, ge=0)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class InstructionsPage(_Model):
|
|
121
|
+
title: Optional[str] = None
|
|
122
|
+
content: Optional[str] = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class PostExperimentQuestionnaire(_Model):
|
|
126
|
+
enabled: Optional[bool] = None
|
|
127
|
+
questions: Optional[list[dict[str, Any]]] = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Experiment(_Model):
|
|
131
|
+
"""Root experiment definition. Only ``name`` is required.
|
|
132
|
+
|
|
133
|
+
Use ``.model_dump(by_alias=True, exclude_none=True)`` to produce a
|
|
134
|
+
wire-ready payload, or pass directly to ``client.create_experiment(
|
|
135
|
+
experiment=exp)``.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
name: str = Field(min_length=1)
|
|
139
|
+
description: Optional[str] = None
|
|
140
|
+
required_participants: Optional[int] = Field(default=None, ge=1)
|
|
141
|
+
randomize_states: Optional[bool] = None
|
|
142
|
+
states: Optional[list[State]] = None
|
|
143
|
+
roles: Optional[dict[str, Role]] = None
|
|
144
|
+
global_components: Optional[dict[str, dict[str, Any]]] = None
|
|
145
|
+
variables: Optional[dict[str, Any]] = None
|
|
146
|
+
waiting_room_config: Optional[WaitingRoomConfig] = None
|
|
147
|
+
disconnect_timeout: Optional[DisconnectTimeout] = None
|
|
148
|
+
completion_screen_duration_ms: Optional[int] = Field(default=None, ge=0)
|
|
149
|
+
consent_form_enabled: Optional[bool] = None
|
|
150
|
+
consent_form_title: Optional[str] = None
|
|
151
|
+
consent_form_content: Optional[str] = None
|
|
152
|
+
instructions_enabled: Optional[bool] = None
|
|
153
|
+
instructions_title: Optional[str] = None
|
|
154
|
+
instructions_content: Optional[str] = None
|
|
155
|
+
instructions_pages: Optional[list[InstructionsPage]] = None
|
|
156
|
+
post_experiment_questionnaire: Optional[PostExperimentQuestionnaire] = None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Component factory helpers
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
#
|
|
163
|
+
# Each factory builds a ``FocusComponent`` with the standard config keys for
|
|
164
|
+
# its component type. Extra config keys pass through via ``**extra`` so users
|
|
165
|
+
# can set any field the backend accepts without needing a new factory.
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# Across all factories, ``**extra`` is spread FIRST so factory-set config
|
|
169
|
+
# keys win on collision. A user who accidentally passes the camelCase
|
|
170
|
+
# version of a documented snake_case arg (e.g. ``outputVariable=`` instead
|
|
171
|
+
# of ``output_variable=``) gets the factory's value, not their typo.
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def show_text(text: str, *, id: Optional[str] = None, **extra: Any) -> FocusComponent:
|
|
175
|
+
"""Build a ``showtext`` focus component."""
|
|
176
|
+
return FocusComponent(
|
|
177
|
+
type=ComponentType.SHOW_TEXT,
|
|
178
|
+
config={**extra, "text": text},
|
|
179
|
+
id=id or _new_id(),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def show_image(url: str, *, id: Optional[str] = None, **extra: Any) -> FocusComponent:
|
|
184
|
+
"""Build a ``showimage`` focus component."""
|
|
185
|
+
return FocusComponent(
|
|
186
|
+
type=ComponentType.SHOW_IMAGE,
|
|
187
|
+
config={**extra, "url": url},
|
|
188
|
+
id=id or _new_id(),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def show_video(url: str, *, id: Optional[str] = None, **extra: Any) -> FocusComponent:
|
|
193
|
+
"""Build a ``showvideo`` focus component."""
|
|
194
|
+
return FocusComponent(
|
|
195
|
+
type=ComponentType.SHOW_VIDEO,
|
|
196
|
+
config={**extra, "url": url},
|
|
197
|
+
id=id or _new_id(),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def vas_rating(
|
|
202
|
+
prompt: str,
|
|
203
|
+
*,
|
|
204
|
+
output_variable: str,
|
|
205
|
+
id: Optional[str] = None,
|
|
206
|
+
**extra: Any,
|
|
207
|
+
) -> FocusComponent:
|
|
208
|
+
"""Build a ``vasrating`` (visual analog scale) focus component."""
|
|
209
|
+
return FocusComponent(
|
|
210
|
+
type=ComponentType.VAS_RATING,
|
|
211
|
+
config={**extra, "prompt": prompt, "outputVariable": output_variable},
|
|
212
|
+
id=id or _new_id(),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def text_input(
|
|
217
|
+
prompt: str,
|
|
218
|
+
*,
|
|
219
|
+
output_variable: str,
|
|
220
|
+
id: Optional[str] = None,
|
|
221
|
+
**extra: Any,
|
|
222
|
+
) -> FocusComponent:
|
|
223
|
+
"""Build a ``textinput`` focus component."""
|
|
224
|
+
return FocusComponent(
|
|
225
|
+
type=ComponentType.TEXT_INPUT,
|
|
226
|
+
config={**extra, "prompt": prompt, "outputVariable": output_variable},
|
|
227
|
+
id=id or _new_id(),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def multiple_choice(
|
|
232
|
+
prompt: str,
|
|
233
|
+
options: list[str],
|
|
234
|
+
*,
|
|
235
|
+
output_variable: str,
|
|
236
|
+
id: Optional[str] = None,
|
|
237
|
+
**extra: Any,
|
|
238
|
+
) -> FocusComponent:
|
|
239
|
+
"""Build a ``multiplechoice`` focus component."""
|
|
240
|
+
return FocusComponent(
|
|
241
|
+
type=ComponentType.MULTIPLE_CHOICE,
|
|
242
|
+
config={
|
|
243
|
+
**extra,
|
|
244
|
+
"prompt": prompt,
|
|
245
|
+
"options": list(options),
|
|
246
|
+
"outputVariable": output_variable,
|
|
247
|
+
},
|
|
248
|
+
id=id or _new_id(),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def waiting(duration_ms: int, *, id: Optional[str] = None, **extra: Any) -> FocusComponent:
|
|
253
|
+
"""Build a ``waiting`` focus component."""
|
|
254
|
+
return FocusComponent(
|
|
255
|
+
type=ComponentType.WAITING,
|
|
256
|
+
config={**extra, "durationMs": int(duration_ms)},
|
|
257
|
+
id=id or _new_id(),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def likert_scale(
|
|
262
|
+
prompt: str,
|
|
263
|
+
*,
|
|
264
|
+
output_variable: str,
|
|
265
|
+
scale_points: int = 7,
|
|
266
|
+
id: Optional[str] = None,
|
|
267
|
+
**extra: Any,
|
|
268
|
+
) -> FocusComponent:
|
|
269
|
+
"""Build a ``likertscale`` focus component."""
|
|
270
|
+
return FocusComponent(
|
|
271
|
+
type=ComponentType.LIKERT_SCALE,
|
|
272
|
+
config={
|
|
273
|
+
**extra,
|
|
274
|
+
"prompt": prompt,
|
|
275
|
+
"outputVariable": output_variable,
|
|
276
|
+
"scalePoints": int(scale_points),
|
|
277
|
+
},
|
|
278
|
+
id=id or _new_id(),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def ranking(
|
|
283
|
+
prompt: str,
|
|
284
|
+
options: list[str],
|
|
285
|
+
*,
|
|
286
|
+
output_variable: str,
|
|
287
|
+
id: Optional[str] = None,
|
|
288
|
+
**extra: Any,
|
|
289
|
+
) -> FocusComponent:
|
|
290
|
+
"""Build a ``ranking`` focus component."""
|
|
291
|
+
return FocusComponent(
|
|
292
|
+
type=ComponentType.RANKING,
|
|
293
|
+
config={
|
|
294
|
+
**extra,
|
|
295
|
+
"prompt": prompt,
|
|
296
|
+
"options": list(options),
|
|
297
|
+
"outputVariable": output_variable,
|
|
298
|
+
},
|
|
299
|
+
id=id or _new_id(),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
__all__ = [
|
|
304
|
+
# Enums
|
|
305
|
+
"ComponentType",
|
|
306
|
+
"GlobalComponentType",
|
|
307
|
+
# Models
|
|
308
|
+
"Experiment",
|
|
309
|
+
"State",
|
|
310
|
+
"FocusComponent",
|
|
311
|
+
"TransitionRules",
|
|
312
|
+
"Role",
|
|
313
|
+
"WaitingRoomConfig",
|
|
314
|
+
"DisconnectTimeout",
|
|
315
|
+
"InstructionsPage",
|
|
316
|
+
"PostExperimentQuestionnaire",
|
|
317
|
+
# Factories
|
|
318
|
+
"show_text",
|
|
319
|
+
"show_image",
|
|
320
|
+
"show_video",
|
|
321
|
+
"vas_rating",
|
|
322
|
+
"text_input",
|
|
323
|
+
"multiple_choice",
|
|
324
|
+
"waiting",
|
|
325
|
+
"likert_scale",
|
|
326
|
+
"ranking",
|
|
327
|
+
]
|
|
@@ -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
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
hyperstudy/__init__.py,sha256=DROd0vzdbUR2kzYSH5d8QAWVdPp6fCn1vdRGyNGNQFE,1913
|
|
2
|
+
hyperstudy/_dataframe.py,sha256=8pjuMG8phMv8vwdY7sgZ-aF_6_o0UMgP2XXNp5muZ0s,3620
|
|
3
|
+
hyperstudy/_display.py,sha256=5svr4NFOUJkVif57-xJzrXNOSdA5O9tKesu8UhE_jYo,1896
|
|
4
|
+
hyperstudy/_downloads.py,sha256=MKUjzz9UDfo0V-n22UCdLEH2-tQxBeJMN_JoJ2spIT8,2459
|
|
5
|
+
hyperstudy/_http.py,sha256=_RS0ZaRAcKvFQ2NF9II7PpcojE-lTYT6CslaJNlgfPQ,4569
|
|
6
|
+
hyperstudy/_pagination.py,sha256=2NbvNGW_jFQkYquSTi3SNkAmGgsbKop7J9LkGaZWwWA,1812
|
|
7
|
+
hyperstudy/_types.py,sha256=22JTOlM68eGJ4hg4OH-oT-hmARG2CgbhnERJ5hGxHk8,614
|
|
8
|
+
hyperstudy/client.py,sha256=raAEBNxRsFPr260JWOFlOZNwmFk2SB80L2bsMoojUt0,24376
|
|
9
|
+
hyperstudy/exceptions.py,sha256=z_1Ngiq-RU-1yyDI_JqvQ5CvYm2jQ_lyJ9TUbqUKKv8,1529
|
|
10
|
+
hyperstudy/experiments.py,sha256=jp9kxXwumwuXz2Nq26f62BC_HnNu1lvzpFBpBFHiCeo,8180
|
|
11
|
+
hyperstudy/models.py,sha256=eCXD8-5jEWVhf-eJjZ_C8-D55Uuic3awx04dYn0Q5L8,9411
|
|
12
|
+
hyperstudy-0.3.0.dist-info/METADATA,sha256=P-AVQaBJ2ARQLs4zlErtNdvHCFg760BEz4gE03eye5g,6133
|
|
13
|
+
hyperstudy-0.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
14
|
+
hyperstudy-0.3.0.dist-info/licenses/LICENSE,sha256=WJdwRhBIOiiQ6lyK4l95u5IJYuuT-XxzwYRraS31NME,1067
|
|
15
|
+
hyperstudy-0.3.0.dist-info/RECORD,,
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
hyperstudy/__init__.py,sha256=E8l_qKgFt0TPaZycaGM9L09ThfDSB30c4RqIS4IgolQ,733
|
|
2
|
-
hyperstudy/_dataframe.py,sha256=8pjuMG8phMv8vwdY7sgZ-aF_6_o0UMgP2XXNp5muZ0s,3620
|
|
3
|
-
hyperstudy/_display.py,sha256=5svr4NFOUJkVif57-xJzrXNOSdA5O9tKesu8UhE_jYo,1896
|
|
4
|
-
hyperstudy/_downloads.py,sha256=nqIig09P84XbYLBlI5uPMWtFHISGt9H4qhsf5415kdg,1563
|
|
5
|
-
hyperstudy/_http.py,sha256=wMVAhgxNcp4-zcrc4lTHU_GHGSqsFiyMFWsrLP5EGFM,4430
|
|
6
|
-
hyperstudy/_pagination.py,sha256=Fdr2nW84r6NtXiYQs5jyJEUOjFvXl-RclpDIi-SPDlM,1738
|
|
7
|
-
hyperstudy/_types.py,sha256=22JTOlM68eGJ4hg4OH-oT-hmARG2CgbhnERJ5hGxHk8,614
|
|
8
|
-
hyperstudy/client.py,sha256=MTw81lv1sP16wDMIpF4QaJ6fsNFJmNy62PaAcUAL69k,23202
|
|
9
|
-
hyperstudy/exceptions.py,sha256=z_1Ngiq-RU-1yyDI_JqvQ5CvYm2jQ_lyJ9TUbqUKKv8,1529
|
|
10
|
-
hyperstudy/experiments.py,sha256=BHNf-ctKUA9uzy8Kya11nIyHPrsmOslqC_IJ5t4LxKA,4372
|
|
11
|
-
hyperstudy-0.2.2.dist-info/METADATA,sha256=RSn3To1gXSS0SHnz-QXrtQc1uamcHNkPeDUuckeGSUI,4833
|
|
12
|
-
hyperstudy-0.2.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
13
|
-
hyperstudy-0.2.2.dist-info/licenses/LICENSE,sha256=WJdwRhBIOiiQ6lyK4l95u5IJYuuT-XxzwYRraS31NME,1067
|
|
14
|
-
hyperstudy-0.2.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|