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.
Files changed (31) hide show
  1. somnio-0.1.0/LICENSE +21 -0
  2. somnio-0.1.0/PKG-INFO +86 -0
  3. somnio-0.1.0/README.md +27 -0
  4. somnio-0.1.0/pyproject.toml +102 -0
  5. somnio-0.1.0/src/somnio/__init__.py +6 -0
  6. somnio-0.1.0/src/somnio/cli/__init__.py +3 -0
  7. somnio-0.1.0/src/somnio/cli/commands/__init__.py +0 -0
  8. somnio-0.1.0/src/somnio/cli/commands/nsrr.py +202 -0
  9. somnio-0.1.0/src/somnio/cli/main.py +69 -0
  10. somnio-0.1.0/src/somnio/cli/utils/__init__.py +0 -0
  11. somnio-0.1.0/src/somnio/cli/utils/logging.py +84 -0
  12. somnio-0.1.0/src/somnio/data/__init__.py +20 -0
  13. somnio-0.1.0/src/somnio/data/adapters/__init__.py +1 -0
  14. somnio-0.1.0/src/somnio/data/adapters/mne.py +134 -0
  15. somnio-0.1.0/src/somnio/data/annotations.py +188 -0
  16. somnio-0.1.0/src/somnio/data/timeseries.py +294 -0
  17. somnio-0.1.0/src/somnio/devices/__init__.py +1 -0
  18. somnio-0.1.0/src/somnio/devices/zmax/__init__.py +35 -0
  19. somnio-0.1.0/src/somnio/devices/zmax/client.py +520 -0
  20. somnio-0.1.0/src/somnio/devices/zmax/constants.py +35 -0
  21. somnio-0.1.0/src/somnio/devices/zmax/enums.py +144 -0
  22. somnio-0.1.0/src/somnio/devices/zmax/protocol.py +49 -0
  23. somnio-0.1.0/src/somnio/io/__init__.py +15 -0
  24. somnio-0.1.0/src/somnio/io/base.py +33 -0
  25. somnio-0.1.0/src/somnio/io/edf/__init__.py +25 -0
  26. somnio-0.1.0/src/somnio/io/edf/standard.py +93 -0
  27. somnio-0.1.0/src/somnio/io/edf/utils.py +36 -0
  28. somnio-0.1.0/src/somnio/io/edf/zmax.py +188 -0
  29. somnio-0.1.0/src/somnio/io/hdf5/__init__.py +33 -0
  30. somnio-0.1.0/src/somnio/io/hdf5/native.py +203 -0
  31. 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"
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
+ logger.addHandler(logging.NullHandler())
@@ -0,0 +1,3 @@
1
+ from .main import app
2
+
3
+ __all__ = ["app"]
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."""