hyperstudy 0.2.1__py3-none-any.whl → 0.2.3__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
@@ -19,7 +19,7 @@ from .exceptions import (
19
19
  ValidationError,
20
20
  )
21
21
 
22
- __version__ = "0.2.0"
22
+ __version__ = "0.2.3"
23
23
 
24
24
  __all__ = [
25
25
  "HyperStudy",
hyperstudy/_dataframe.py CHANGED
@@ -6,6 +6,51 @@ from typing import Any
6
6
 
7
7
  import pandas as pd
8
8
 
9
+ # Nested dict fields to flatten into top-level columns.
10
+ # Mapping of {field_name: prefix} — sub-keys become ``{prefix}_{sub_key}``.
11
+ FLATTEN_FIELDS: dict[str, str] = {
12
+ "sparseRatingData": "sparseRatingData",
13
+ "metadata": "metadata",
14
+ }
15
+
16
+
17
+ def _flatten_nested_dicts(
18
+ data: list[dict[str, Any]],
19
+ fields: dict[str, str] | None = None,
20
+ ) -> list[dict[str, Any]]:
21
+ """Promote sub-keys of nested dict fields to top-level keys.
22
+
23
+ For each *field* present in a record whose value is a ``dict``, every
24
+ sub-key is copied to ``{prefix}_{sub_key}``. The original nested dict
25
+ is preserved for backward compatibility.
26
+
27
+ Records where the target field is ``None`` or missing are left
28
+ untouched — downstream DataFrame construction fills those columns
29
+ with ``NaN`` / ``null``.
30
+ """
31
+ if not data:
32
+ return data
33
+
34
+ fields = fields if fields is not None else FLATTEN_FIELDS
35
+
36
+ # Quick check on first record — skip work when no target fields exist.
37
+ sample = data[0]
38
+ targets = [f for f in fields if f in sample and isinstance(sample[f], dict)]
39
+ if not targets:
40
+ return data
41
+
42
+ out: list[dict[str, Any]] = []
43
+ for record in data:
44
+ record = dict(record) # shallow copy to avoid mutating caller's data
45
+ for field in targets:
46
+ nested = record.get(field)
47
+ if isinstance(nested, dict):
48
+ prefix = fields[field]
49
+ for sub_key, sub_val in nested.items():
50
+ record[f"{prefix}_{sub_key}"] = sub_val
51
+ out.append(record)
52
+ return out
53
+
9
54
 
10
55
  def _post_process(df: pd.DataFrame) -> pd.DataFrame:
11
56
  """Shared post-processing for pandas DataFrames.
@@ -32,6 +77,7 @@ def to_pandas(data: list[dict[str, Any]]) -> pd.DataFrame:
32
77
  """Convert API response data to a pandas DataFrame with post-processing."""
33
78
  if not data:
34
79
  return pd.DataFrame()
80
+ data = _flatten_nested_dicts(data)
35
81
  df = pd.DataFrame(data)
36
82
  return _post_process(df)
37
83
 
@@ -51,6 +97,7 @@ def to_polars(data: list[dict[str, Any]]):
51
97
  if not data:
52
98
  return pl.DataFrame()
53
99
 
100
+ data = _flatten_nested_dicts(data)
54
101
  df = pl.DataFrame(data)
55
102
 
56
103
  # Parse timestamps
@@ -0,0 +1,76 @@
1
+ """Helpers for downloading recording files from signed URLs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import requests
10
+
11
+ _CHUNK_SIZE = 65536 # 64 KB — good balance for large video files
12
+ _UNSAFE_RE = re.compile(r"[^\w\-]")
13
+
14
+
15
+ def get_download_url(recording: dict[str, Any]) -> str | None:
16
+ """Return the best download URL from a recording dict, or ``None``."""
17
+ return recording.get("downloadUrl") or recording.get("url") or None
18
+
19
+
20
+ def build_filename(recording: dict[str, Any]) -> str:
21
+ """Build a filesystem-safe filename from recording metadata.
22
+
23
+ Pattern: ``{participantName}_{type}_{recordingId}.{ext}``
24
+ """
25
+ name = recording.get("participantName") or recording.get("participantId") or "unknown"
26
+ name = _UNSAFE_RE.sub("_", name)
27
+
28
+ meta = recording.get("metadata") or {}
29
+ rec_type = meta.get("type") or "recording"
30
+
31
+ rec_id = recording.get("recordingId") or recording.get("egressId") or "unknown"
32
+
33
+ fmt = recording.get("format")
34
+ if not fmt:
35
+ fmt = "webm" if rec_type == "audio" else "mp4"
36
+
37
+ return f"{name}_{rec_type}_{rec_id}.{fmt}"
38
+
39
+
40
+ def download_file(url: str, dest: Path, timeout: int = 300) -> int:
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
+ """
48
+ resp = requests.get(url, stream=True, timeout=timeout)
49
+ resp.raise_for_status()
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
+
60
+ written = 0
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
+
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
@@ -2,12 +2,18 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import warnings
6
+ from pathlib import Path
5
7
  from typing import Any
6
8
 
9
+ from tqdm.auto import tqdm
10
+
7
11
  from ._dataframe import to_pandas, to_polars
12
+ from ._downloads import build_filename, download_file, get_download_url
8
13
  from ._http import HttpTransport
9
14
  from ._pagination import fetch_all_pages
10
15
  from ._types import Scope
16
+ from .exceptions import HyperStudyError
11
17
  from .experiments import ExperimentMixin
12
18
 
13
19
 
@@ -466,6 +472,145 @@ class HyperStudy(ExperimentMixin):
466
472
  "consent": self.get_consent(participant_id, **common),
467
473
  }
468
474
 
475
+ # ------------------------------------------------------------------
476
+ # Recording downloads
477
+ # ------------------------------------------------------------------
478
+
479
+ def download_recording(
480
+ self,
481
+ recording: dict[str, Any],
482
+ output_dir: str = ".",
483
+ ) -> Path:
484
+ """Download a single recording file to disk.
485
+
486
+ Args:
487
+ recording: A recording dict (from ``get_recordings(output="dict")``).
488
+ output_dir: Directory to save the file.
489
+
490
+ Returns:
491
+ Path to the downloaded file.
492
+ """
493
+ url = get_download_url(recording)
494
+ if not url:
495
+ raise ValueError("Recording has no downloadUrl or url field")
496
+
497
+ dest_dir = Path(output_dir)
498
+ dest_dir.mkdir(parents=True, exist_ok=True)
499
+
500
+ filename = build_filename(recording)
501
+ dest = dest_dir / filename
502
+ download_file(url, dest)
503
+ return dest
504
+
505
+ def download_recordings(
506
+ self,
507
+ scope_id: str,
508
+ *,
509
+ output_dir: str,
510
+ scope: str = "experiment",
511
+ deployment_id: str | None = None,
512
+ room_id: str | None = None,
513
+ recording_type: str | None = None,
514
+ progress: bool = True,
515
+ skip_existing: bool = True,
516
+ ):
517
+ """Download recording files to disk.
518
+
519
+ Fetches recording metadata, downloads each file from its signed
520
+ URL, writes a ``recordings_metadata.csv`` sidecar, and returns a
521
+ DataFrame with a ``local_path`` column.
522
+
523
+ Args:
524
+ scope_id: Experiment, room, or participant ID.
525
+ output_dir: Directory to save files.
526
+ scope: ``"experiment"``, ``"room"``, or ``"participant"``.
527
+ deployment_id: Filter by deployment (experiment scope only).
528
+ room_id: Filter by room.
529
+ recording_type: ``"audio"``, ``"video"``, or ``None`` (both).
530
+ progress: Show progress bar.
531
+ skip_existing: Skip files already on disk with matching size.
532
+
533
+ Returns:
534
+ pandas DataFrame with recording metadata plus ``local_path``
535
+ and ``download_status`` columns.
536
+ """
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
555
+
556
+ if recording_type:
557
+ recordings = [
558
+ r for r in recordings
559
+ if (r.get("metadata") or {}).get("type") == recording_type
560
+ ]
561
+
562
+ dest_dir = Path(output_dir)
563
+ dest_dir.mkdir(parents=True, exist_ok=True)
564
+
565
+ local_paths: list[str | None] = []
566
+ statuses: list[str] = []
567
+
568
+ for rec in tqdm(recordings, desc="Downloading recordings", disable=not progress):
569
+ filename = build_filename(rec)
570
+ dest = dest_dir / filename
571
+
572
+ url = get_download_url(rec)
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
+
579
+ if skip_existing and dest.exists():
580
+ expected_size = rec.get("fileSize")
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:
584
+ local_paths.append(str(dest.resolve()))
585
+ statuses.append("skipped")
586
+ continue
587
+
588
+ try:
589
+ download_file(url, dest)
590
+ local_paths.append(str(dest.resolve()))
591
+ statuses.append("downloaded")
592
+ except Exception as exc:
593
+ local_paths.append(None)
594
+ statuses.append("failed")
595
+ warnings.warn(
596
+ f"Failed to download recording {rec.get('recordingId')}: {exc}"
597
+ )
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
+
606
+ df = to_pandas(recordings)
607
+ if not df.empty:
608
+ df["local_path"] = local_paths
609
+ df["download_status"] = statuses
610
+ df.to_csv(dest_dir / "recordings_metadata.csv", index=False)
611
+
612
+ return df
613
+
469
614
  # ------------------------------------------------------------------
470
615
  # Internal helpers
471
616
  # ------------------------------------------------------------------
@@ -525,6 +670,7 @@ class HyperStudy(ExperimentMixin):
525
670
  offset: int = 0,
526
671
  output: str = "pandas",
527
672
  progress: bool = True,
673
+ timeout: int | float | None = None,
528
674
  **extra_params,
529
675
  ):
530
676
  """Generic data-fetching logic shared by all ``get_*`` methods."""
@@ -551,11 +697,15 @@ class HyperStudy(ExperimentMixin):
551
697
  # Single page or all pages
552
698
  if limit is not None:
553
699
  params["limit"] = limit
554
- body = self._transport.get(path, params=params)
700
+ body = self._transport.get(path, params=params, timeout=timeout)
555
701
  data = body.get("data", [])
556
702
  else:
557
703
  data, _ = fetch_all_pages(
558
- self._transport, path, params=params, progress=progress
704
+ self._transport,
705
+ path,
706
+ params=params,
707
+ progress=progress,
708
+ timeout=timeout,
559
709
  )
560
710
 
561
711
  return self._convert_output(data, output)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperstudy
3
- Version: 0.2.1
3
+ Version: 0.2.3
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
@@ -0,0 +1,14 @@
1
+ hyperstudy/__init__.py,sha256=-FMsxfDASUokTGR0-iES4eb-SUEjTuThGMqPIg-Imyw,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=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=BHNf-ctKUA9uzy8Kya11nIyHPrsmOslqC_IJ5t4LxKA,4372
11
+ hyperstudy-0.2.3.dist-info/METADATA,sha256=l1DluLkNI41NjQxrCEUAMeWMfi8jZFhXOUdC9RPb_f0,4833
12
+ hyperstudy-0.2.3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ hyperstudy-0.2.3.dist-info/licenses/LICENSE,sha256=WJdwRhBIOiiQ6lyK4l95u5IJYuuT-XxzwYRraS31NME,1067
14
+ hyperstudy-0.2.3.dist-info/RECORD,,
@@ -1,13 +0,0 @@
1
- hyperstudy/__init__.py,sha256=aNCOChRV0HeP81B8L6vaXx4GTO0mbsNy_GfDRJT3nlg,733
2
- hyperstudy/_dataframe.py,sha256=gOHPE6RRoZ_a4Qkdl9XykkGTqH6I7P6pedKoFuAvKek,1966
3
- hyperstudy/_display.py,sha256=5svr4NFOUJkVif57-xJzrXNOSdA5O9tKesu8UhE_jYo,1896
4
- hyperstudy/_http.py,sha256=wMVAhgxNcp4-zcrc4lTHU_GHGSqsFiyMFWsrLP5EGFM,4430
5
- hyperstudy/_pagination.py,sha256=Fdr2nW84r6NtXiYQs5jyJEUOjFvXl-RclpDIi-SPDlM,1738
6
- hyperstudy/_types.py,sha256=22JTOlM68eGJ4hg4OH-oT-hmARG2CgbhnERJ5hGxHk8,614
7
- hyperstudy/client.py,sha256=fOmXnr9zQKw-Z1ajxBVPCZUgZhi6DlfAtZy_6OJTFP4,18966
8
- hyperstudy/exceptions.py,sha256=z_1Ngiq-RU-1yyDI_JqvQ5CvYm2jQ_lyJ9TUbqUKKv8,1529
9
- hyperstudy/experiments.py,sha256=BHNf-ctKUA9uzy8Kya11nIyHPrsmOslqC_IJ5t4LxKA,4372
10
- hyperstudy-0.2.1.dist-info/METADATA,sha256=x2hcUowRrTiDU2PDOhWgd4RkTg-VLp8xmosp3VoJyb4,4833
11
- hyperstudy-0.2.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
- hyperstudy-0.2.1.dist-info/licenses/LICENSE,sha256=WJdwRhBIOiiQ6lyK4l95u5IJYuuT-XxzwYRraS31NME,1067
13
- hyperstudy-0.2.1.dist-info/RECORD,,