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 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.2.2"
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
- with open(dest, "wb") as fh:
47
- for chunk in resp.iter_content(chunk_size=_CHUNK_SIZE):
48
- fh.write(chunk)
49
- written += len(chunk)
61
+ # BaseException so Ctrl-C also cleans up, not just exceptions.
62
+ try:
63
+ with open(dest, "wb") as fh:
64
+ for chunk in resp.iter_content(chunk_size=_CHUNK_SIZE):
65
+ fh.write(chunk)
66
+ written += len(chunk)
67
+
68
+ if expected is not None and written != expected:
69
+ raise IOError(
70
+ f"Truncated download: wrote {written} of {expected} bytes for {dest.name}"
71
+ )
72
+ except BaseException:
73
+ dest.unlink(missing_ok=True)
74
+ raise
75
+
50
76
  return written
hyperstudy/_http.py CHANGED
@@ -63,8 +63,14 @@ class HttpTransport:
63
63
  # Public helpers
64
64
  # ------------------------------------------------------------------
65
65
 
66
- def get(self, path: str, params: dict[str, Any] | None = None) -> dict:
67
- return self._request("GET", path, params=params)
66
+ def get(
67
+ self,
68
+ path: str,
69
+ params: dict[str, Any] | None = None,
70
+ *,
71
+ timeout: int | float | None = None,
72
+ ) -> dict:
73
+ return self._request("GET", path, params=params, timeout=timeout)
68
74
 
69
75
  def post(self, path: str, json: dict[str, Any] | None = None) -> dict:
70
76
  return self._request("POST", path, json=json)
@@ -81,7 +87,8 @@ class HttpTransport:
81
87
 
82
88
  def _request(self, method: str, path: str, **kwargs) -> dict:
83
89
  url = f"{self.base_url}/{path.lstrip('/')}"
84
- kwargs.setdefault("timeout", self.timeout)
90
+ if kwargs.get("timeout") is None:
91
+ kwargs["timeout"] = self.timeout
85
92
 
86
93
  resp = self._session.request(method, url, **kwargs)
87
94
  return self._handle_response(resp)
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
- recordings = self.get_recordings(
537
- scope_id,
538
- scope=scope,
539
- deployment_id=deployment_id,
540
- room_id=room_id,
541
- output="dict",
542
- )
537
+ try:
538
+ # Signing per-recording GCS URLs scales with count; 30s default is too short.
539
+ recordings = self._fetch_data(
540
+ "recordings",
541
+ scope_id,
542
+ scope=scope,
543
+ deployment_id=deployment_id,
544
+ room_id=room_id,
545
+ output="dict",
546
+ timeout=300,
547
+ )
548
+ except Exception as exc:
549
+ raise HyperStudyError(
550
+ f"Failed to list recordings for {scope}={scope_id!r}: {exc}",
551
+ code=getattr(exc, "code", "LIST_RECORDINGS_FAILED"),
552
+ status_code=getattr(exc, "status_code", None),
553
+ details=getattr(exc, "details", None),
554
+ ) from exc
543
555
 
544
556
  if recording_type:
545
557
  recordings = [
@@ -566,7 +578,9 @@ class HyperStudy(ExperimentMixin):
566
578
 
567
579
  if skip_existing and dest.exists():
568
580
  expected_size = rec.get("fileSize")
569
- if expected_size is None or dest.stat().st_size == expected_size:
581
+ # Require positive size match; missing size → re-download to
582
+ # avoid trusting a truncated file from a prior failed run.
583
+ if expected_size is not None and dest.stat().st_size == expected_size:
570
584
  local_paths.append(str(dest.resolve()))
571
585
  statuses.append("skipped")
572
586
  continue
@@ -582,6 +596,13 @@ class HyperStudy(ExperimentMixin):
582
596
  f"Failed to download recording {rec.get('recordingId')}: {exc}"
583
597
  )
584
598
 
599
+ failed_count = sum(1 for s in statuses if s == "failed")
600
+ if failed_count:
601
+ warnings.warn(
602
+ f"{failed_count}/{len(recordings)} recordings failed to download; "
603
+ "see the 'download_status' column for per-file results."
604
+ )
605
+
585
606
  df = to_pandas(recordings)
586
607
  if not df.empty:
587
608
  df["local_path"] = local_paths
@@ -649,6 +670,7 @@ class HyperStudy(ExperimentMixin):
649
670
  offset: int = 0,
650
671
  output: str = "pandas",
651
672
  progress: bool = True,
673
+ timeout: int | float | None = None,
652
674
  **extra_params,
653
675
  ):
654
676
  """Generic data-fetching logic shared by all ``get_*`` methods."""
@@ -675,11 +697,15 @@ class HyperStudy(ExperimentMixin):
675
697
  # Single page or all pages
676
698
  if limit is not None:
677
699
  params["limit"] = limit
678
- body = self._transport.get(path, params=params)
700
+ body = self._transport.get(path, params=params, timeout=timeout)
679
701
  data = body.get("data", [])
680
702
  else:
681
703
  data, _ = fetch_all_pages(
682
- self._transport, path, params=params, progress=progress
704
+ self._transport,
705
+ path,
706
+ params=params,
707
+ progress=progress,
708
+ timeout=timeout,
683
709
  )
684
710
 
685
711
  return self._convert_output(data, output)
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(self, *, name: str, **kwargs) -> ExperimentInfo:
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
- name: Experiment name (required).
87
- **kwargs: Additional fields (config, states, roles, description, etc.).
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
- payload = {"name": name, **kwargs}
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(self, experiment_id: str, **kwargs) -> dict[str, Any]:
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
- **kwargs: Fields to update (name, config, states, etc.).
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
- body = self._transport.put(f"experiments/{experiment_id}", json=kwargs)
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.2.2
3
+ Version: 0.3.0
4
4
  Summary: Python SDK for the HyperStudy experiment platform API
5
5
  Project-URL: Homepage, https://hyperstudy.io
6
6
  Project-URL: Documentation, https://docs.hyperstudy.io/developers/python-sdk
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Scientific/Engineering
22
22
  Requires-Python: >=3.9
23
23
  Requires-Dist: pandas>=1.5.0
24
+ Requires-Dist: pydantic>=2.5
24
25
  Requires-Dist: requests>=2.28.0
25
26
  Requires-Dist: tqdm>=4.60.0
26
27
  Provides-Extra: dev
@@ -125,12 +126,41 @@ experiments = hs.list_experiments()
125
126
  # Get details (with rich display in notebooks)
126
127
  exp = hs.get_experiment("exp_id")
127
128
 
128
- # Create / update / delete
129
+ # Quick create / update / delete (kwargs form, backwards-compatible)
129
130
  new_exp = hs.create_experiment(name="My Study", description="...")
130
131
  hs.update_experiment("exp_id", name="Updated Name")
131
132
  hs.delete_experiment("exp_id")
132
133
  ```
133
134
 
135
+ ### Building experiments programmatically (0.3.0+)
136
+
137
+ For full experiment definitions — states, components, roles — use the typed `Experiment` builder. Snake_case Python fields convert to the camelCase wire format automatically.
138
+
139
+ ```python
140
+ from hyperstudy import Experiment, State, Role, show_text, vas_rating
141
+
142
+ exp = Experiment(
143
+ name="Two-person study",
144
+ required_participants=2,
145
+ states=[
146
+ State(id="intro", focus_component=show_text("Welcome")),
147
+ State(
148
+ id="rate",
149
+ focus_component=vas_rating("Rate the clip", output_variable="rating"),
150
+ ),
151
+ ],
152
+ roles={"speaker": Role(name="Speaker", participant_count=1)},
153
+ )
154
+
155
+ # Dry-run validation against the backend.
156
+ print(hs.validate_experiment(exp)) # → {"valid": True, ...}
157
+
158
+ # Create.
159
+ info = hs.create_experiment(experiment=exp)
160
+ ```
161
+
162
+ Component factories cover `show_text`, `show_image`, `show_video`, `vas_rating`, `text_input`, `multiple_choice`, `waiting`, `likert_scale`, `ranking`. For other component types, construct directly: `FocusComponent(type=ComponentType.X, config={...})`. See the [Experiment Authoring guide](https://docs.hyperstudy.io/experimenters/api-access/experiment-authoring) for the full reference.
163
+
134
164
  ## Deployments
135
165
 
136
166
  ```python
@@ -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,,