somnio 0.1.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.
- somnio-0.1.0/LICENSE +21 -0
- somnio-0.1.0/PKG-INFO +86 -0
- somnio-0.1.0/README.md +27 -0
- somnio-0.1.0/pyproject.toml +102 -0
- somnio-0.1.0/src/somnio/__init__.py +6 -0
- somnio-0.1.0/src/somnio/cli/__init__.py +3 -0
- somnio-0.1.0/src/somnio/cli/commands/__init__.py +0 -0
- somnio-0.1.0/src/somnio/cli/commands/nsrr.py +202 -0
- somnio-0.1.0/src/somnio/cli/main.py +69 -0
- somnio-0.1.0/src/somnio/cli/utils/__init__.py +0 -0
- somnio-0.1.0/src/somnio/cli/utils/logging.py +84 -0
- somnio-0.1.0/src/somnio/data/__init__.py +20 -0
- somnio-0.1.0/src/somnio/data/adapters/__init__.py +1 -0
- somnio-0.1.0/src/somnio/data/adapters/mne.py +134 -0
- somnio-0.1.0/src/somnio/data/annotations.py +188 -0
- somnio-0.1.0/src/somnio/data/timeseries.py +294 -0
- somnio-0.1.0/src/somnio/devices/__init__.py +1 -0
- somnio-0.1.0/src/somnio/devices/zmax/__init__.py +35 -0
- somnio-0.1.0/src/somnio/devices/zmax/client.py +520 -0
- somnio-0.1.0/src/somnio/devices/zmax/constants.py +35 -0
- somnio-0.1.0/src/somnio/devices/zmax/enums.py +144 -0
- somnio-0.1.0/src/somnio/devices/zmax/protocol.py +49 -0
- somnio-0.1.0/src/somnio/io/__init__.py +15 -0
- somnio-0.1.0/src/somnio/io/base.py +33 -0
- somnio-0.1.0/src/somnio/io/edf/__init__.py +25 -0
- somnio-0.1.0/src/somnio/io/edf/standard.py +93 -0
- somnio-0.1.0/src/somnio/io/edf/utils.py +36 -0
- somnio-0.1.0/src/somnio/io/edf/zmax.py +188 -0
- somnio-0.1.0/src/somnio/io/hdf5/__init__.py +33 -0
- somnio-0.1.0/src/somnio/io/hdf5/native.py +203 -0
- somnio-0.1.0/src/somnio/io/hdf5/usleep.py +221 -0
somnio-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ali Saberi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
somnio-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: somnio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python library and CLI for sleep and physiological data workflows.
|
|
5
|
+
Keywords: sleep,physiology,polysomnography,hdf5,eeg,biosignals,neuroscience,zmax
|
|
6
|
+
Author: Ali Saberi
|
|
7
|
+
Author-email: Ali Saberi <ali.saberi96@gmail.com>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 Ali Saberi
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
Classifier: Development Status :: 3 - Alpha
|
|
30
|
+
Classifier: Intended Audience :: Developers
|
|
31
|
+
Classifier: Intended Audience :: Science/Research
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Operating System :: OS Independent
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
40
|
+
Classifier: Topic :: Scientific/Engineering
|
|
41
|
+
Requires-Dist: numpy>=1.24
|
|
42
|
+
Requires-Dist: loguru>=0.7.3 ; extra == 'cli'
|
|
43
|
+
Requires-Dist: python-dotenv>=1.0.0 ; extra == 'cli'
|
|
44
|
+
Requires-Dist: requests>=2.31.0 ; extra == 'cli'
|
|
45
|
+
Requires-Dist: tqdm>=4.67.3 ; extra == 'cli'
|
|
46
|
+
Requires-Dist: typer>=0.21.1 ; extra == 'cli'
|
|
47
|
+
Requires-Dist: edfio>=0.4 ; extra == 'edf'
|
|
48
|
+
Requires-Dist: mne>=1.5 ; extra == 'edf'
|
|
49
|
+
Requires-Dist: h5py>=3.8 ; extra == 'hdf5'
|
|
50
|
+
Requires-Python: >=3.10
|
|
51
|
+
Project-URL: Homepage, https://github.com/alitsaberi/somnio
|
|
52
|
+
Project-URL: Repository, https://github.com/alitsaberi/somnio
|
|
53
|
+
Project-URL: Documentation, https://alitsaberi.github.io/somnio/
|
|
54
|
+
Project-URL: Issues, https://github.com/alitsaberi/somnio/issues
|
|
55
|
+
Provides-Extra: cli
|
|
56
|
+
Provides-Extra: edf
|
|
57
|
+
Provides-Extra: hdf5
|
|
58
|
+
Description-Content-Type: text/markdown
|
|
59
|
+
|
|
60
|
+
# Somnio
|
|
61
|
+
|
|
62
|
+
Python library and CLI for sleep and physiological data workflows.
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install somnio
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Optional extras:
|
|
71
|
+
|
|
72
|
+
- `somnio[cli]` — command-line tools
|
|
73
|
+
- `somnio[hdf5]` — `somnio.io.hdf5` layouts (requires [h5py](https://www.h5py.org/))
|
|
74
|
+
|
|
75
|
+
With **uv**: `uv add somnio`, `uv add somnio[cli]`, or `uv add somnio[hdf5]`.
|
|
76
|
+
|
|
77
|
+
## Documentation
|
|
78
|
+
|
|
79
|
+
Site source lives under [`docs/`](docs/). Build or serve locally (with the `docs` dependency group installed):
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
uv sync --group docs
|
|
83
|
+
uv run mkdocs serve
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Reference material includes [data types](docs/reference/data.md) and [I/O / HDF5](docs/reference/io.md).
|
somnio-0.1.0/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Somnio
|
|
2
|
+
|
|
3
|
+
Python library and CLI for sleep and physiological data workflows.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install somnio
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Optional extras:
|
|
12
|
+
|
|
13
|
+
- `somnio[cli]` — command-line tools
|
|
14
|
+
- `somnio[hdf5]` — `somnio.io.hdf5` layouts (requires [h5py](https://www.h5py.org/))
|
|
15
|
+
|
|
16
|
+
With **uv**: `uv add somnio`, `uv add somnio[cli]`, or `uv add somnio[hdf5]`.
|
|
17
|
+
|
|
18
|
+
## Documentation
|
|
19
|
+
|
|
20
|
+
Site source lives under [`docs/`](docs/). Build or serve locally (with the `docs` dependency group installed):
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv sync --group docs
|
|
24
|
+
uv run mkdocs serve
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Reference material includes [data types](docs/reference/data.md) and [I/O / HDF5](docs/reference/io.md).
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "somnio"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python library and CLI for sleep and physiological data workflows."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Ali Saberi", email = "ali.saberi96@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
keywords = [
|
|
12
|
+
"sleep",
|
|
13
|
+
"physiology",
|
|
14
|
+
"polysomnography",
|
|
15
|
+
"hdf5",
|
|
16
|
+
"eeg",
|
|
17
|
+
"biosignals",
|
|
18
|
+
"neuroscience",
|
|
19
|
+
"zmax"
|
|
20
|
+
]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 3 - Alpha",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"Intended Audience :: Science/Research",
|
|
25
|
+
"License :: OSI Approved :: MIT License",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3.10",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Programming Language :: Python :: 3.14",
|
|
33
|
+
"Topic :: Scientific/Engineering",
|
|
34
|
+
]
|
|
35
|
+
dependencies = ["numpy>=1.24"]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/alitsaberi/somnio"
|
|
39
|
+
Repository = "https://github.com/alitsaberi/somnio"
|
|
40
|
+
Documentation = "https://alitsaberi.github.io/somnio/"
|
|
41
|
+
Issues = "https://github.com/alitsaberi/somnio/issues"
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
somnio = "somnio.cli.main:app"
|
|
45
|
+
|
|
46
|
+
[project.optional-dependencies]
|
|
47
|
+
hdf5 = ["h5py>=3.8"]
|
|
48
|
+
cli = [
|
|
49
|
+
"loguru>=0.7.3",
|
|
50
|
+
"python-dotenv>=1.0.0",
|
|
51
|
+
"requests>=2.31.0",
|
|
52
|
+
"tqdm>=4.67.3",
|
|
53
|
+
"typer>=0.21.1",
|
|
54
|
+
]
|
|
55
|
+
edf = [
|
|
56
|
+
"edfio>=0.4",
|
|
57
|
+
"mne>=1.5",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
[build-system]
|
|
61
|
+
requires = ["uv_build>=0.9.13,<0.10.0"]
|
|
62
|
+
build-backend = "uv_build"
|
|
63
|
+
|
|
64
|
+
[dependency-groups]
|
|
65
|
+
dev = [
|
|
66
|
+
{include-group = "lint"},
|
|
67
|
+
{include-group = "docs"},
|
|
68
|
+
{include-group = "test"},
|
|
69
|
+
"pre-commit>=4.5.1",
|
|
70
|
+
]
|
|
71
|
+
docs = [
|
|
72
|
+
"mkdocs>=1.6.1",
|
|
73
|
+
"mkdocs-material>=9.6.0",
|
|
74
|
+
"mkdocs-typer2>=0.2.0",
|
|
75
|
+
"pymdown-extensions>=10.14.0",
|
|
76
|
+
]
|
|
77
|
+
lint = [
|
|
78
|
+
"ruff>=0.14.14",
|
|
79
|
+
]
|
|
80
|
+
test = [
|
|
81
|
+
"pytest>=9.0.2",
|
|
82
|
+
"h5py>=3.8",
|
|
83
|
+
"mne>=1.5",
|
|
84
|
+
"edfio>=0.4",
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
[tool.uv.dependency-groups]
|
|
88
|
+
dev = {requires-python = ">=3.12"}
|
|
89
|
+
|
|
90
|
+
[tool.ruff.lint.pydocstyle]
|
|
91
|
+
convention = "google"
|
|
92
|
+
|
|
93
|
+
[tool.ruff.lint]
|
|
94
|
+
# Enforce a consistent TODO convention:
|
|
95
|
+
# - Use TODO (not FIXME/XXX)
|
|
96
|
+
# - Keep formatting consistent after `TODO:` / `TODO(name):`
|
|
97
|
+
# Note: We intentionally do NOT require authors (TD002) or issue links (TD003).
|
|
98
|
+
extend-select = ["TD001", "TD007"]
|
|
99
|
+
|
|
100
|
+
[tool.ruff.format]
|
|
101
|
+
docstring-code-format = true
|
|
102
|
+
docstring-code-line-length = "dynamic"
|
|
File without changes
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""NSRR (National Sleep Research Resource) dataset download commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from loguru import logger
|
|
10
|
+
from requests.adapters import HTTPAdapter
|
|
11
|
+
from urllib3.util.retry import Retry
|
|
12
|
+
import requests
|
|
13
|
+
from tqdm import tqdm
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_HTTP_RETRIES = 6
|
|
17
|
+
RETRY_STATUS_FORCELIST = (429, 500, 502, 503, 504)
|
|
18
|
+
|
|
19
|
+
DEFAULT_TIMEOUT_SECONDS = 300.0
|
|
20
|
+
DEFAULT_DOWNLOAD_RETRIES = 3
|
|
21
|
+
DOWNLOAD_RETRY_DELAY_SECONDS = 10
|
|
22
|
+
DOWNLOAD_RETRY_EXCEPTIONS = (
|
|
23
|
+
requests.exceptions.ConnectionError,
|
|
24
|
+
requests.exceptions.ReadTimeout,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _build_session(http_retries: int = DEFAULT_HTTP_RETRIES) -> requests.Session:
|
|
29
|
+
"""Create a requests Session with retries for transient NSRR issues.
|
|
30
|
+
|
|
31
|
+
NSRR occasionally returns 502/503/504; we retry those with exponential backoff.
|
|
32
|
+
"""
|
|
33
|
+
retry = Retry(
|
|
34
|
+
total=http_retries,
|
|
35
|
+
connect=http_retries,
|
|
36
|
+
read=http_retries,
|
|
37
|
+
status=http_retries,
|
|
38
|
+
backoff_factor=1.0,
|
|
39
|
+
status_forcelist=RETRY_STATUS_FORCELIST,
|
|
40
|
+
allowed_methods=frozenset(["GET"]),
|
|
41
|
+
raise_on_status=False,
|
|
42
|
+
respect_retry_after_header=True,
|
|
43
|
+
)
|
|
44
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
45
|
+
session = requests.Session()
|
|
46
|
+
session.mount("https://", adapter)
|
|
47
|
+
session.mount("http://", adapter)
|
|
48
|
+
return session
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _fetch_directory_listing(
|
|
52
|
+
session: requests.Session,
|
|
53
|
+
slug: str,
|
|
54
|
+
token: str,
|
|
55
|
+
path: str | None = None,
|
|
56
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
57
|
+
) -> list:
|
|
58
|
+
"""Fetch one level of the dataset file listing from the NSRR API."""
|
|
59
|
+
base_url = f"https://sleepdata.org/api/v1/datasets/{slug}/files.json"
|
|
60
|
+
params: dict[str, str] = {"auth_token": token}
|
|
61
|
+
if path:
|
|
62
|
+
params["path"] = path
|
|
63
|
+
response = session.get(base_url, params=params, timeout=timeout_seconds)
|
|
64
|
+
try:
|
|
65
|
+
response.raise_for_status()
|
|
66
|
+
except requests.HTTPError as e:
|
|
67
|
+
snippet = (response.text or "")[:500].replace("\n", " ").strip()
|
|
68
|
+
raise requests.HTTPError(
|
|
69
|
+
f"{e} (status={response.status_code}) url={response.url} body_snippet={snippet!r}"
|
|
70
|
+
) from e
|
|
71
|
+
return response.json()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _collect_all_files(
|
|
75
|
+
session: requests.Session,
|
|
76
|
+
slug: str,
|
|
77
|
+
token: str,
|
|
78
|
+
path: str | None = None,
|
|
79
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
80
|
+
) -> list:
|
|
81
|
+
"""Recursively collect all file entries under the given path."""
|
|
82
|
+
files: list[dict] = []
|
|
83
|
+
items = _fetch_directory_listing(
|
|
84
|
+
session, slug, token, path, timeout_seconds=timeout_seconds
|
|
85
|
+
)
|
|
86
|
+
for item in items:
|
|
87
|
+
if item["is_file"]:
|
|
88
|
+
files.append(item)
|
|
89
|
+
else:
|
|
90
|
+
files += _collect_all_files(
|
|
91
|
+
session, slug, token, item["full_path"], timeout_seconds=timeout_seconds
|
|
92
|
+
)
|
|
93
|
+
return files
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _download_file(
|
|
97
|
+
session: requests.Session,
|
|
98
|
+
slug: str,
|
|
99
|
+
token: str,
|
|
100
|
+
file_obj: dict,
|
|
101
|
+
base_dir: Path,
|
|
102
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Download a single file from NSRR, creating parent dirs and skipping if complete."""
|
|
105
|
+
download_url = (
|
|
106
|
+
f"https://sleepdata.org/datasets/{slug}/files/{file_obj['full_path']}"
|
|
107
|
+
f"?auth_token={token}"
|
|
108
|
+
)
|
|
109
|
+
local_path = base_dir / file_obj["full_path"]
|
|
110
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
|
|
112
|
+
if local_path.exists():
|
|
113
|
+
expected_size = file_obj.get("size")
|
|
114
|
+
if expected_size is not None:
|
|
115
|
+
if local_path.stat().st_size == expected_size:
|
|
116
|
+
logger.debug("Skipping (exists, size match): {}", file_obj["full_path"])
|
|
117
|
+
return
|
|
118
|
+
else:
|
|
119
|
+
logger.debug("Skipping (exists): {}", file_obj["full_path"])
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
logger.debug("Downloading {}...", file_obj["full_path"])
|
|
123
|
+
with session.get(download_url, stream=True, timeout=timeout_seconds) as r:
|
|
124
|
+
r.raise_for_status()
|
|
125
|
+
with local_path.open("wb") as f:
|
|
126
|
+
for chunk in r.iter_content(chunk_size=8192):
|
|
127
|
+
f.write(chunk)
|
|
128
|
+
logger.debug("Saved to {}", local_path)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def download(
|
|
132
|
+
slug: str = typer.Argument(..., help="Dataset slug (e.g., sof, shhs, mesa)."),
|
|
133
|
+
output_dir: Path = typer.Argument(
|
|
134
|
+
...,
|
|
135
|
+
path_type=Path,
|
|
136
|
+
help="Output directory; files are written to OUTPUT_DIR/SLUG/.",
|
|
137
|
+
),
|
|
138
|
+
token: str | None = typer.Option(
|
|
139
|
+
...,
|
|
140
|
+
"--token",
|
|
141
|
+
"-t",
|
|
142
|
+
envvar="NSRR_TOKEN",
|
|
143
|
+
help="NSRR auth token.",
|
|
144
|
+
),
|
|
145
|
+
path: str = typer.Option(
|
|
146
|
+
None,
|
|
147
|
+
"--path",
|
|
148
|
+
"-p",
|
|
149
|
+
help="Subpath to download (e.g., polysomnography). Default: entire dataset.",
|
|
150
|
+
),
|
|
151
|
+
timeout_seconds: float = typer.Option(
|
|
152
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
153
|
+
"--timeout-seconds",
|
|
154
|
+
help="Per-request timeout in seconds.",
|
|
155
|
+
),
|
|
156
|
+
download_retries: int = typer.Option(
|
|
157
|
+
DEFAULT_DOWNLOAD_RETRIES,
|
|
158
|
+
"--download-retries",
|
|
159
|
+
help="Retries per file on connection/read timeout.",
|
|
160
|
+
),
|
|
161
|
+
http_retries: int = typer.Option(
|
|
162
|
+
DEFAULT_HTTP_RETRIES,
|
|
163
|
+
"--http-retries",
|
|
164
|
+
help="Retries for HTTP requests.",
|
|
165
|
+
),
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Download files from an NSRR dataset."""
|
|
168
|
+
|
|
169
|
+
if not token:
|
|
170
|
+
logger.error("Missing NSRR token. Set NSRR_TOKEN in .env or pass --token.")
|
|
171
|
+
raise SystemExit(1)
|
|
172
|
+
|
|
173
|
+
session = _build_session(http_retries=http_retries)
|
|
174
|
+
target_path = path.strip() if path else None
|
|
175
|
+
all_files = _collect_all_files(
|
|
176
|
+
session, slug, token, target_path, timeout_seconds=timeout_seconds
|
|
177
|
+
)
|
|
178
|
+
logger.info("Found {} files under {!r}.", len(all_files), target_path or "(root)")
|
|
179
|
+
|
|
180
|
+
out = output_dir / slug
|
|
181
|
+
for file_obj in tqdm(all_files, desc="Downloading", unit="file"):
|
|
182
|
+
for attempt in range(1, download_retries + 1):
|
|
183
|
+
try:
|
|
184
|
+
_download_file(
|
|
185
|
+
session,
|
|
186
|
+
slug,
|
|
187
|
+
token,
|
|
188
|
+
file_obj,
|
|
189
|
+
out,
|
|
190
|
+
timeout_seconds=timeout_seconds,
|
|
191
|
+
)
|
|
192
|
+
break
|
|
193
|
+
except DOWNLOAD_RETRY_EXCEPTIONS as e:
|
|
194
|
+
if attempt == download_retries:
|
|
195
|
+
raise
|
|
196
|
+
|
|
197
|
+
delay = attempt * DOWNLOAD_RETRY_DELAY_SECONDS
|
|
198
|
+
tqdm.write(
|
|
199
|
+
f"Timeout/connection error, retrying in {delay}s... "
|
|
200
|
+
f"(attempt {attempt}/{download_retries}, {e!r})"
|
|
201
|
+
)
|
|
202
|
+
time.sleep(delay)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Somnio CLI app.
|
|
2
|
+
|
|
3
|
+
This module is the console entry point target.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
from loguru import logger
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from .commands.nsrr import download as download_nsrr
|
|
15
|
+
from .utils.logging import (
|
|
16
|
+
DEFAULT_LOG_DIRECTORY,
|
|
17
|
+
DEFAULT_LOG_FILE_NAME,
|
|
18
|
+
create_log_file_path,
|
|
19
|
+
setup_logging,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_app() -> "typer.Typer":
|
|
24
|
+
"""Create a new CLI app instance.
|
|
25
|
+
|
|
26
|
+
Tests should prefer this factory to avoid mutating global app state.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
app = typer.Typer()
|
|
30
|
+
app.command("download-nsrr")(download_nsrr)
|
|
31
|
+
|
|
32
|
+
@app.callback()
|
|
33
|
+
def _main_callback(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
file_logging: bool = typer.Option(
|
|
36
|
+
False,
|
|
37
|
+
"-l",
|
|
38
|
+
"--file-logging",
|
|
39
|
+
help=f"Enable file logging. By default, logs are written to {DEFAULT_LOG_DIRECTORY}/{DEFAULT_LOG_FILE_NAME}. Use before the subcommand.",
|
|
40
|
+
),
|
|
41
|
+
log_file: Path | None = typer.Option(
|
|
42
|
+
None,
|
|
43
|
+
"-f",
|
|
44
|
+
"--log-file",
|
|
45
|
+
path_type=Path,
|
|
46
|
+
help="Log to this file instead of the default. Implies file logging. Must appear before the subcommand.",
|
|
47
|
+
),
|
|
48
|
+
) -> None:
|
|
49
|
+
if file_logging and log_file is None:
|
|
50
|
+
command_name = ctx.invoked_subcommand or app.info.name
|
|
51
|
+
log_file = create_log_file_path(command_name)
|
|
52
|
+
|
|
53
|
+
setup_logging(log_file=log_file)
|
|
54
|
+
logger.debug(
|
|
55
|
+
"CLI: command={} subcommand={} params={} args={}",
|
|
56
|
+
ctx.info_name,
|
|
57
|
+
ctx.invoked_subcommand,
|
|
58
|
+
ctx.params,
|
|
59
|
+
ctx.args,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return app
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
app = create_app()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Logging utilities for the somnio CLI.
|
|
2
|
+
|
|
3
|
+
This module configures Loguru as the primary logger and bridges standard-library
|
|
4
|
+
``logging`` records into Loguru.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
import logging
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from types import FrameType
|
|
15
|
+
|
|
16
|
+
from loguru import logger
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
DEFAULT_STDERR_LOG_LEVEL = "INFO"
|
|
20
|
+
DEFAULT_FILE_LOG_LEVEL = "DEBUG"
|
|
21
|
+
DEFAULT_LOG_DIRECTORY = Path("logs")
|
|
22
|
+
DEFAULT_LOG_FILE_NAME = "{command}_{timestamp}.log"
|
|
23
|
+
|
|
24
|
+
# Redact auth_token (and similar) from log messages so tokens never appear in logs.
|
|
25
|
+
_REDACT_PATTERN = re.compile(
|
|
26
|
+
r"(auth_token|token)=[^&\s\"']+",
|
|
27
|
+
re.IGNORECASE,
|
|
28
|
+
)
|
|
29
|
+
_TIMESTAMP_FORMAT = "%Y%m%d_%H%M%S"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _InterceptHandler(logging.Handler):
|
|
33
|
+
"""Forward stdlib logging records to Loguru."""
|
|
34
|
+
|
|
35
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
36
|
+
try:
|
|
37
|
+
level: str | int = logger.level(record.levelname).name
|
|
38
|
+
except ValueError:
|
|
39
|
+
level = record.levelno
|
|
40
|
+
|
|
41
|
+
frame: FrameType | None = logging.currentframe()
|
|
42
|
+
depth = 2
|
|
43
|
+
while frame and frame.f_code.co_filename == logging.__file__:
|
|
44
|
+
frame = frame.f_back
|
|
45
|
+
depth += 1
|
|
46
|
+
|
|
47
|
+
logger.opt(depth=depth, exception=record.exc_info).log(
|
|
48
|
+
level, record.getMessage()
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _redact_sensitive(record: dict) -> bool:
|
|
53
|
+
"""Filter that redacts token values in log messages. Mutates record and returns True."""
|
|
54
|
+
record["message"] = _REDACT_PATTERN.sub(r"\1=***", record["message"])
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def setup_logging(
|
|
59
|
+
*,
|
|
60
|
+
stderr_level: str = DEFAULT_STDERR_LOG_LEVEL,
|
|
61
|
+
file_level: str = DEFAULT_FILE_LOG_LEVEL,
|
|
62
|
+
log_file: Path | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Configure Loguru and bridge stdlib logging to it."""
|
|
65
|
+
|
|
66
|
+
logger.remove()
|
|
67
|
+
logger.add(sys.stderr, level=stderr_level, filter=_redact_sensitive)
|
|
68
|
+
|
|
69
|
+
if log_file is not None:
|
|
70
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
logger.add(log_file, level=file_level, filter=_redact_sensitive)
|
|
72
|
+
logger.info("Logging to {}", log_file)
|
|
73
|
+
|
|
74
|
+
# Bridge stdlib logging -> Loguru (for library modules using `logging`)
|
|
75
|
+
logging.root.handlers = [_InterceptHandler()]
|
|
76
|
+
logging.root.setLevel(logging.NOTSET)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def create_log_file_path(command: str) -> Path:
|
|
80
|
+
"""Create a log file path for a given script name."""
|
|
81
|
+
timestamp = datetime.now().strftime(_TIMESTAMP_FORMAT)
|
|
82
|
+
return DEFAULT_LOG_DIRECTORY / DEFAULT_LOG_FILE_NAME.format(
|
|
83
|
+
command=command, timestamp=timestamp
|
|
84
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""somnio.data — in-memory data types for sleep signal data."""
|
|
2
|
+
|
|
3
|
+
from somnio.data.annotations import (
|
|
4
|
+
Event,
|
|
5
|
+
Epochs,
|
|
6
|
+
epochs_to_events,
|
|
7
|
+
events_to_epochs,
|
|
8
|
+
)
|
|
9
|
+
from somnio.data.timeseries import Sample, TimeSeries, collect_samples, concat
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Sample",
|
|
13
|
+
"TimeSeries",
|
|
14
|
+
"collect_samples",
|
|
15
|
+
"concat",
|
|
16
|
+
"Event",
|
|
17
|
+
"Epochs",
|
|
18
|
+
"epochs_to_events",
|
|
19
|
+
"events_to_epochs",
|
|
20
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional bridges from somnio types to third-party in-memory APIs."""
|