hyperstudy 0.2.3__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/CHANGELOG.md +19 -0
  2. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/PKG-INFO +32 -2
  3. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/README.md +30 -1
  4. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/pyproject.toml +2 -1
  5. hyperstudy-0.3.0/src/hyperstudy/__init__.py +93 -0
  6. hyperstudy-0.3.0/src/hyperstudy/experiments.py +222 -0
  7. hyperstudy-0.3.0/src/hyperstudy/models.py +327 -0
  8. hyperstudy-0.3.0/tests/fixtures/experiment.schema.json +227 -0
  9. hyperstudy-0.3.0/tests/test_experiments.py +398 -0
  10. hyperstudy-0.3.0/tests/test_models.py +348 -0
  11. hyperstudy-0.2.3/src/hyperstudy/__init__.py +0 -38
  12. hyperstudy-0.2.3/src/hyperstudy/experiments.py +0 -125
  13. hyperstudy-0.2.3/tests/test_experiments.py +0 -197
  14. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/.github/workflows/publish.yml +0 -0
  15. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/.github/workflows/sync-release-notes.yml +0 -0
  16. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/.github/workflows/test.yml +0 -0
  17. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/.gitignore +0 -0
  18. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/LICENSE +0 -0
  19. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/docs/superpowers/specs/2026-04-10-recording-downloads-design.md +0 -0
  20. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/src/hyperstudy/_dataframe.py +0 -0
  21. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/src/hyperstudy/_display.py +0 -0
  22. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/src/hyperstudy/_downloads.py +0 -0
  23. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/src/hyperstudy/_http.py +0 -0
  24. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/src/hyperstudy/_pagination.py +0 -0
  25. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/src/hyperstudy/_types.py +0 -0
  26. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/src/hyperstudy/client.py +0 -0
  27. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/src/hyperstudy/exceptions.py +0 -0
  28. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/__init__.py +0 -0
  29. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/conftest.py +0 -0
  30. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/fixtures/deployment_sessions_response.json +0 -0
  31. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/fixtures/deployment_single_response.json +0 -0
  32. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/fixtures/deployments_list_response.json +0 -0
  33. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/fixtures/error_401.json +0 -0
  34. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/fixtures/error_403.json +0 -0
  35. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/fixtures/events_response.json +0 -0
  36. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/fixtures/experiment_single_response.json +0 -0
  37. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/fixtures/experiments_list_response.json +0 -0
  38. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/fixtures/paginated_page1.json +0 -0
  39. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/fixtures/paginated_page2.json +0 -0
  40. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/fixtures/pre_experiment_response.json +0 -0
  41. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/fixtures/recordings_response.json +0 -0
  42. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/fixtures/sparse_ratings_response.json +0 -0
  43. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/fixtures/warnings_response.json +0 -0
  44. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/test_client.py +0 -0
  45. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/test_dataframe.py +0 -0
  46. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/test_downloads.py +0 -0
  47. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/test_http.py +0 -0
  48. {hyperstudy-0.2.3 → hyperstudy-0.3.0}/tests/test_pagination.py +0 -0
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.0
4
+
5
+ ### Features
6
+
7
+ - Typed experiment builders for programmatic authoring. New top-level exports: `Experiment`, `State`, `FocusComponent`, `Role`, `WaitingRoomConfig`, `DisconnectTimeout`, `InstructionsPage`, `PostExperimentQuestionnaire`, `TransitionRules`, `ComponentType`, `GlobalComponentType`. Snake_case Python fields are converted to the camelCase wire format automatically; unknown top-level fields are preserved via `extra="allow"` so the SDK doesn't block on backend schema additions.
8
+ - Component factory helpers: `show_text`, `show_image`, `show_video`, `vas_rating`, `text_input`, `multiple_choice`, `waiting`, `likert_scale`, `ranking`. Extra kwargs pass through to the component `config` for any backend-supported field.
9
+ - `create_experiment` and `update_experiment` now accept an `experiment=Experiment(...)` argument alongside the existing `**kwargs` form. When both are given, kwargs override builder fields.
10
+ - New `validate_experiment(experiment)` method — dry-run against `POST /experiments/validate`.
11
+ - Schema-drift guard test: vendored copy of `experiment.schema.json` is checked against `ComponentType` / `GlobalComponentType` / required-field declarations so backend additions surface in CI.
12
+
13
+ ### Backwards Compatibility
14
+
15
+ - `create_experiment(name=..., **kwargs)` continues to work unchanged.
16
+ - `update_experiment(experiment_id, **kwargs)` continues to work unchanged.
17
+
18
+ ### Dependencies
19
+
20
+ - Adds `pydantic>=2.5`.
21
+
3
22
  ## v0.2.0
4
23
 
5
24
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperstudy
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: Python SDK for the HyperStudy experiment platform API
5
5
  Project-URL: Homepage, https://hyperstudy.io
6
6
  Project-URL: Documentation, https://docs.hyperstudy.io/developers/python-sdk
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Scientific/Engineering
22
22
  Requires-Python: >=3.9
23
23
  Requires-Dist: pandas>=1.5.0
24
+ Requires-Dist: pydantic>=2.5
24
25
  Requires-Dist: requests>=2.28.0
25
26
  Requires-Dist: tqdm>=4.60.0
26
27
  Provides-Extra: dev
@@ -125,12 +126,41 @@ experiments = hs.list_experiments()
125
126
  # Get details (with rich display in notebooks)
126
127
  exp = hs.get_experiment("exp_id")
127
128
 
128
- # Create / update / delete
129
+ # Quick create / update / delete (kwargs form, backwards-compatible)
129
130
  new_exp = hs.create_experiment(name="My Study", description="...")
130
131
  hs.update_experiment("exp_id", name="Updated Name")
131
132
  hs.delete_experiment("exp_id")
132
133
  ```
133
134
 
135
+ ### Building experiments programmatically (0.3.0+)
136
+
137
+ For full experiment definitions — states, components, roles — use the typed `Experiment` builder. Snake_case Python fields convert to the camelCase wire format automatically.
138
+
139
+ ```python
140
+ from hyperstudy import Experiment, State, Role, show_text, vas_rating
141
+
142
+ exp = Experiment(
143
+ name="Two-person study",
144
+ required_participants=2,
145
+ states=[
146
+ State(id="intro", focus_component=show_text("Welcome")),
147
+ State(
148
+ id="rate",
149
+ focus_component=vas_rating("Rate the clip", output_variable="rating"),
150
+ ),
151
+ ],
152
+ roles={"speaker": Role(name="Speaker", participant_count=1)},
153
+ )
154
+
155
+ # Dry-run validation against the backend.
156
+ print(hs.validate_experiment(exp)) # → {"valid": True, ...}
157
+
158
+ # Create.
159
+ info = hs.create_experiment(experiment=exp)
160
+ ```
161
+
162
+ Component factories cover `show_text`, `show_image`, `show_video`, `vas_rating`, `text_input`, `multiple_choice`, `waiting`, `likert_scale`, `ranking`. For other component types, construct directly: `FocusComponent(type=ComponentType.X, config={...})`. See the [Experiment Authoring guide](https://docs.hyperstudy.io/experimenters/api-access/experiment-authoring) for the full reference.
163
+
134
164
  ## Deployments
135
165
 
136
166
  ```python
@@ -91,12 +91,41 @@ experiments = hs.list_experiments()
91
91
  # Get details (with rich display in notebooks)
92
92
  exp = hs.get_experiment("exp_id")
93
93
 
94
- # Create / update / delete
94
+ # Quick create / update / delete (kwargs form, backwards-compatible)
95
95
  new_exp = hs.create_experiment(name="My Study", description="...")
96
96
  hs.update_experiment("exp_id", name="Updated Name")
97
97
  hs.delete_experiment("exp_id")
98
98
  ```
99
99
 
100
+ ### Building experiments programmatically (0.3.0+)
101
+
102
+ For full experiment definitions — states, components, roles — use the typed `Experiment` builder. Snake_case Python fields convert to the camelCase wire format automatically.
103
+
104
+ ```python
105
+ from hyperstudy import Experiment, State, Role, show_text, vas_rating
106
+
107
+ exp = Experiment(
108
+ name="Two-person study",
109
+ required_participants=2,
110
+ states=[
111
+ State(id="intro", focus_component=show_text("Welcome")),
112
+ State(
113
+ id="rate",
114
+ focus_component=vas_rating("Rate the clip", output_variable="rating"),
115
+ ),
116
+ ],
117
+ roles={"speaker": Role(name="Speaker", participant_count=1)},
118
+ )
119
+
120
+ # Dry-run validation against the backend.
121
+ print(hs.validate_experiment(exp)) # → {"valid": True, ...}
122
+
123
+ # Create.
124
+ info = hs.create_experiment(experiment=exp)
125
+ ```
126
+
127
+ Component factories cover `show_text`, `show_image`, `show_video`, `vas_rating`, `text_input`, `multiple_choice`, `waiting`, `likert_scale`, `ranking`. For other component types, construct directly: `FocusComponent(type=ComponentType.X, config={...})`. See the [Experiment Authoring guide](https://docs.hyperstudy.io/experimenters/api-access/experiment-authoring) for the full reference.
128
+
100
129
  ## Deployments
101
130
 
102
131
  ```python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hyperstudy"
7
- version = "0.2.3"
7
+ version = "0.3.0"
8
8
  description = "Python SDK for the HyperStudy experiment platform API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -28,6 +28,7 @@ dependencies = [
28
28
  "requests>=2.28.0",
29
29
  "pandas>=1.5.0",
30
30
  "tqdm>=4.60.0",
31
+ "pydantic>=2.5",
31
32
  ]
32
33
 
33
34
  [project.optional-dependencies]
@@ -0,0 +1,93 @@
1
+ """HyperStudy Python SDK — access experiment data from notebooks and scripts.
2
+
3
+ Usage::
4
+
5
+ import hyperstudy
6
+
7
+ hs = hyperstudy.HyperStudy(api_key="hst_live_...")
8
+ events = hs.get_events("experiment_id")
9
+
10
+ Building experiments programmatically::
11
+
12
+ from hyperstudy import HyperStudy, Experiment, State, show_text
13
+
14
+ exp = Experiment(
15
+ name="Welcome study",
16
+ states=[State(id="s1", focus_component=show_text("Hello"))],
17
+ )
18
+ hs = HyperStudy(api_key="hst_live_...")
19
+ info = hs.create_experiment(experiment=exp)
20
+ """
21
+
22
+ from ._types import DataType, RatingKind, Scope
23
+ from .client import HyperStudy
24
+ from .exceptions import (
25
+ AuthenticationError,
26
+ ForbiddenError,
27
+ HyperStudyError,
28
+ NotFoundError,
29
+ ServerError,
30
+ ValidationError,
31
+ )
32
+ from .models import (
33
+ ComponentType,
34
+ DisconnectTimeout,
35
+ Experiment,
36
+ FocusComponent,
37
+ GlobalComponentType,
38
+ InstructionsPage,
39
+ PostExperimentQuestionnaire,
40
+ Role,
41
+ State,
42
+ TransitionRules,
43
+ WaitingRoomConfig,
44
+ likert_scale,
45
+ multiple_choice,
46
+ ranking,
47
+ show_image,
48
+ show_text,
49
+ show_video,
50
+ text_input,
51
+ vas_rating,
52
+ waiting,
53
+ )
54
+
55
+ __version__ = "0.3.0"
56
+
57
+ __all__ = [
58
+ "HyperStudy",
59
+ "__version__",
60
+ # Exceptions
61
+ "HyperStudyError",
62
+ "AuthenticationError",
63
+ "ForbiddenError",
64
+ "NotFoundError",
65
+ "ServerError",
66
+ "ValidationError",
67
+ # Enums
68
+ "Scope",
69
+ "DataType",
70
+ "RatingKind",
71
+ "ComponentType",
72
+ "GlobalComponentType",
73
+ # Experiment builders
74
+ "Experiment",
75
+ "State",
76
+ "FocusComponent",
77
+ "Role",
78
+ "TransitionRules",
79
+ "WaitingRoomConfig",
80
+ "DisconnectTimeout",
81
+ "InstructionsPage",
82
+ "PostExperimentQuestionnaire",
83
+ # Component factories
84
+ "show_text",
85
+ "show_image",
86
+ "show_video",
87
+ "vas_rating",
88
+ "text_input",
89
+ "multiple_choice",
90
+ "waiting",
91
+ "likert_scale",
92
+ "ranking",
93
+ ]
@@ -0,0 +1,222 @@
1
+ """Experiment CRUD methods (mixed into HyperStudy via inheritance)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from pydantic import BaseModel
8
+ from pydantic.alias_generators import to_camel
9
+
10
+ from ._display import ExperimentInfo
11
+ from ._http import HttpTransport
12
+ from ._pagination import fetch_all_pages
13
+
14
+ if TYPE_CHECKING:
15
+ from .models import Experiment
16
+
17
+
18
+ def _build_experiment_payload(
19
+ experiment: "Experiment | dict[str, Any] | None" = None,
20
+ **overrides: Any,
21
+ ) -> dict[str, Any]:
22
+ """Build a wire-ready dict from an Experiment instance or raw dict.
23
+
24
+ - ``Experiment`` → ``.model_dump(by_alias=True, exclude_none=True)``
25
+ - ``dict`` → shallow copy
26
+ - ``None`` → empty dict
27
+
28
+ ``overrides`` are merged on top (caller wins). Snake_case keys in
29
+ overrides are translated to camelCase so they actually override the
30
+ aliased fields of the dumped ``Experiment`` (otherwise a kwarg like
31
+ ``required_participants=5`` would leave the builder's
32
+ ``requiredParticipants`` in place and ALSO add ``required_participants``
33
+ to the wire payload — both keys would go).
34
+ """
35
+ if experiment is None:
36
+ payload: dict[str, Any] = {}
37
+ elif isinstance(experiment, BaseModel):
38
+ payload = experiment.model_dump(by_alias=True, exclude_none=True)
39
+ elif isinstance(experiment, dict):
40
+ payload = dict(experiment)
41
+ else:
42
+ raise TypeError(
43
+ f"experiment must be an Experiment or dict, got {type(experiment).__name__}"
44
+ )
45
+ for key, value in overrides.items():
46
+ payload[to_camel(key)] = value
47
+ return payload
48
+
49
+
50
+ class ExperimentMixin:
51
+ """Methods for experiment management. Mixed into the main client."""
52
+
53
+ _transport: HttpTransport
54
+
55
+ # ------------------------------------------------------------------
56
+ # Read
57
+ # ------------------------------------------------------------------
58
+
59
+ def list_experiments(
60
+ self,
61
+ *,
62
+ search: str | None = None,
63
+ limit: int | None = None,
64
+ offset: int = 0,
65
+ output: str = "pandas",
66
+ progress: bool = True,
67
+ ):
68
+ """List experiments accessible to the authenticated user.
69
+
70
+ Args:
71
+ search: Filter experiments by name/description substring.
72
+ limit: Max experiments to return. ``None`` fetches all pages.
73
+ offset: Starting offset for pagination.
74
+ output: ``"pandas"`` (default), ``"polars"``, or ``"dict"``.
75
+ progress: Show progress bar when paginating.
76
+
77
+ Returns:
78
+ DataFrame or list of dicts depending on *output*.
79
+ """
80
+ params: dict[str, Any] = {"offset": offset}
81
+ if search:
82
+ params["search"] = search
83
+
84
+ if limit is not None:
85
+ params["limit"] = limit
86
+ body = self._transport.get("experiments", params=params)
87
+ data = body.get("data", [])
88
+ else:
89
+ data, _ = fetch_all_pages(
90
+ self._transport,
91
+ "experiments",
92
+ params=params,
93
+ page_size=50,
94
+ progress=progress,
95
+ )
96
+
97
+ return self._convert_output(data, output)
98
+
99
+ def get_experiment(self, experiment_id: str) -> ExperimentInfo:
100
+ """Get a single experiment with enriched metadata.
101
+
102
+ Returns:
103
+ An :class:`ExperimentInfo` object (dict-like, with ``_repr_html_``).
104
+ """
105
+ body = self._transport.get(f"experiments/{experiment_id}")
106
+ data = body.get("data", [])
107
+ record = data[0] if isinstance(data, list) and data else data
108
+ return ExperimentInfo(record)
109
+
110
+ def get_experiment_config(self, experiment_id: str) -> dict[str, Any]:
111
+ """Get raw experiment config (lightweight, no enrichment)."""
112
+ body = self._transport.get(f"experiments/{experiment_id}/config")
113
+ data = body.get("data", [])
114
+ return data[0] if isinstance(data, list) and data else data
115
+
116
+ # ------------------------------------------------------------------
117
+ # Write
118
+ # ------------------------------------------------------------------
119
+
120
+ def create_experiment(
121
+ self,
122
+ *,
123
+ experiment: "Experiment | None" = None,
124
+ name: str | None = None,
125
+ **kwargs,
126
+ ) -> ExperimentInfo:
127
+ """Create a new experiment.
128
+
129
+ Two equivalent ways to call this:
130
+
131
+ - **Typed builder** (recommended): pass an :class:`Experiment` instance
132
+ via the ``experiment`` argument. Snake_case fields on the model are
133
+ converted to the camelCase wire format automatically. Fields added
134
+ via ``extra="allow"`` (unknown to the SDK's typed shape) and entries
135
+ in the per-component ``config`` dict pass through *verbatim* — use
136
+ camelCase keys for those.
137
+ - **Raw kwargs**: pass ``name=`` (required) and any additional fields
138
+ as keyword arguments. Backwards-compatible with hyperstudy <= 0.2.x.
139
+
140
+ When both forms are mixed, keyword arguments override fields from
141
+ ``experiment`` — useful for ad-hoc overrides.
142
+
143
+ Args:
144
+ experiment: Typed experiment definition.
145
+ name: Experiment name (required when ``experiment`` is omitted).
146
+ **kwargs: Additional fields, or overrides applied on top of
147
+ ``experiment``.
148
+
149
+ Returns:
150
+ The created :class:`ExperimentInfo`.
151
+ """
152
+ if name is not None:
153
+ kwargs["name"] = name
154
+ payload = _build_experiment_payload(experiment, **kwargs)
155
+ if not payload.get("name"):
156
+ raise TypeError(
157
+ "create_experiment requires a 'name' "
158
+ "(pass name=... or experiment=Experiment(name=...))"
159
+ )
160
+ body = self._transport.post("experiments", json=payload)
161
+ data = body.get("data", [])
162
+ record = data[0] if isinstance(data, list) and data else data
163
+ return ExperimentInfo(record)
164
+
165
+ def update_experiment(
166
+ self,
167
+ experiment_id: str,
168
+ *,
169
+ experiment: "Experiment | None" = None,
170
+ **kwargs,
171
+ ) -> dict[str, Any]:
172
+ """Update an experiment.
173
+
174
+ Args:
175
+ experiment_id: ID of the experiment to update.
176
+ experiment: Optional typed :class:`Experiment` carrying the new
177
+ fields. Snake_case → camelCase conversion is applied.
178
+ **kwargs: Fields to update, or overrides applied on top of
179
+ ``experiment``.
180
+
181
+ Returns:
182
+ Update confirmation dict.
183
+ """
184
+ payload = _build_experiment_payload(experiment, **kwargs)
185
+ body = self._transport.put(f"experiments/{experiment_id}", json=payload)
186
+ data = body.get("data", [])
187
+ return data[0] if isinstance(data, list) and data else data
188
+
189
+ def validate_experiment(
190
+ self,
191
+ experiment: "Experiment | dict[str, Any]",
192
+ ) -> dict[str, Any]:
193
+ """Dry-run validation of an experiment definition against the backend.
194
+
195
+ Returns the ``ValidationResultResponse`` body — typically
196
+ ``{"valid": True}`` on success, or ``{"valid": False, "errors": [...]}``
197
+ with a list of issues.
198
+
199
+ Args:
200
+ experiment: An :class:`Experiment` or a raw config dict.
201
+ """
202
+ if experiment is None:
203
+ raise TypeError("validate_experiment requires an Experiment or dict")
204
+ payload = _build_experiment_payload(experiment)
205
+ body = self._transport.post("experiments/validate", json=payload)
206
+ data = body.get("data", [])
207
+ return data[0] if isinstance(data, list) and data else data
208
+
209
+ def delete_experiment(
210
+ self, experiment_id: str, *, skip_data_check: bool = False
211
+ ) -> None:
212
+ """Delete (soft-delete) an experiment.
213
+
214
+ Args:
215
+ experiment_id: ID of the experiment to delete.
216
+ skip_data_check: If True, skip the confirmation check for experiments
217
+ with collected data.
218
+ """
219
+ params = {}
220
+ if skip_data_check:
221
+ params["skipDataCheck"] = "true"
222
+ self._transport.delete(f"experiments/{experiment_id}", params=params)