brainstem-nwb 0.1.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.
- brainstem_nwb/__init__.py +34 -0
- brainstem_nwb/_version.py +21 -0
- brainstem_nwb/cli.py +308 -0
- brainstem_nwb/config.py +109 -0
- brainstem_nwb/errors.py +42 -0
- brainstem_nwb/export.py +539 -0
- brainstem_nwb/manifest/__init__.py +6 -0
- brainstem_nwb/manifest/io.py +38 -0
- brainstem_nwb/manifest/models.py +447 -0
- brainstem_nwb/manifest/schema.py +33 -0
- brainstem_nwb/manifest/warnings.py +180 -0
- brainstem_nwb/mapping/__init__.py +0 -0
- brainstem_nwb/mapping/acquisitions.py +98 -0
- brainstem_nwb/mapping/context.py +111 -0
- brainstem_nwb/mapping/describe.py +249 -0
- brainstem_nwb/mapping/devices.py +344 -0
- brainstem_nwb/mapping/docs.py +102 -0
- brainstem_nwb/mapping/epochs.py +94 -0
- brainstem_nwb/mapping/extension.py +330 -0
- brainstem_nwb/mapping/herd.py +108 -0
- brainstem_nwb/mapping/localization.py +116 -0
- brainstem_nwb/mapping/manipulations.py +88 -0
- brainstem_nwb/mapping/notes.py +175 -0
- brainstem_nwb/mapping/pipeline.py +228 -0
- brainstem_nwb/mapping/procedures.py +169 -0
- brainstem_nwb/mapping/registry.py +281 -0
- brainstem_nwb/mapping/session.py +203 -0
- brainstem_nwb/mapping/subject.py +204 -0
- brainstem_nwb/normalize/__init__.py +0 -0
- brainstem_nwb/normalize/identifiers.py +60 -0
- brainstem_nwb/normalize/kv.py +53 -0
- brainstem_nwb/normalize/quantities.py +73 -0
- brainstem_nwb/normalize/schemas.py +101 -0
- brainstem_nwb/normalize/text.py +67 -0
- brainstem_nwb/normalize/timeutil.py +110 -0
- brainstem_nwb/normalize/units.py +630 -0
- brainstem_nwb/nwb/__init__.py +0 -0
- brainstem_nwb/nwb/canonical.py +94 -0
- brainstem_nwb/nwb/writer.py +295 -0
- brainstem_nwb/py.typed +0 -0
- brainstem_nwb/raw/__init__.py +0 -0
- brainstem_nwb/raw/base.py +49 -0
- brainstem_nwb/raw/metadata.py +31 -0
- brainstem_nwb/raw/paths.py +74 -0
- brainstem_nwb/raw/registry.py +38 -0
- brainstem_nwb/retrieval/__init__.py +0 -0
- brainstem_nwb/retrieval/api.py +137 -0
- brainstem_nwb/retrieval/loader.py +182 -0
- brainstem_nwb/retrieval/schemas.py +52 -0
- brainstem_nwb/store/__init__.py +0 -0
- brainstem_nwb/store/datamodel.py +299 -0
- brainstem_nwb/store/graph.py +180 -0
- brainstem_nwb/store/inventory.py +95 -0
- brainstem_nwb/validation/__init__.py +36 -0
- brainstem_nwb/validation/checks.py +167 -0
- brainstem_nwb/validation/report.py +82 -0
- brainstem_nwb-0.1.0.dist-info/METADATA +158 -0
- brainstem_nwb-0.1.0.dist-info/RECORD +61 -0
- brainstem_nwb-0.1.0.dist-info/WHEEL +4 -0
- brainstem_nwb-0.1.0.dist-info/entry_points.txt +2 -0
- brainstem_nwb-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
brainstem-nwb: export BrainSTEM experimental metadata to Neurodata Without Borders (NWB) files.
|
|
3
|
+
|
|
4
|
+
from brainstem_nwb import export_project
|
|
5
|
+
|
|
6
|
+
export_project(project_id="PROJECT_UUID", output_dir="./nwb")
|
|
7
|
+
export_project(export_file="brainstem-export.json.gz", output_dir="./nwb")
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ._version import MAPPING_VERSION, __version__
|
|
11
|
+
from .config import ExportOptions
|
|
12
|
+
from .errors import AuthRequired, BrainstemNwbError, ConfigError, ExportFormatError, RetrievalError
|
|
13
|
+
from .export import ExportResult, build_manifest, export_project, export_session, validate_output
|
|
14
|
+
from .retrieval.api import fetch_export
|
|
15
|
+
from .retrieval.loader import ProjectExport, load_export
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"MAPPING_VERSION",
|
|
19
|
+
"AuthRequired",
|
|
20
|
+
"BrainstemNwbError",
|
|
21
|
+
"ConfigError",
|
|
22
|
+
"ExportFormatError",
|
|
23
|
+
"ExportOptions",
|
|
24
|
+
"ExportResult",
|
|
25
|
+
"ProjectExport",
|
|
26
|
+
"RetrievalError",
|
|
27
|
+
"__version__",
|
|
28
|
+
"build_manifest",
|
|
29
|
+
"export_project",
|
|
30
|
+
"export_session",
|
|
31
|
+
"fetch_export",
|
|
32
|
+
"load_export",
|
|
33
|
+
"validate_output",
|
|
34
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Version numbers of the package and of the formats it reads and writes."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
# Bumped whenever a mapping rule changes what is written to an NWB file. It is part of every
|
|
6
|
+
# file's identifier, so the same BrainSTEM records exported with a different mapping get a new one.
|
|
7
|
+
MAPPING_VERSION = "1.3.0"
|
|
8
|
+
|
|
9
|
+
# Version of the conversion manifest (brainstem-nwb-manifest.json) and of the validation report.
|
|
10
|
+
MANIFEST_SCHEMA_VERSION = "1.0.0"
|
|
11
|
+
REPORT_SCHEMA_VERSION = "1.0.0"
|
|
12
|
+
|
|
13
|
+
# Major version of the BrainSTEM project export format this release reads; newer minors are accepted.
|
|
14
|
+
SUPPORTED_EXPORT_MAJOR = 2
|
|
15
|
+
KNOWN_EXPORT_MINOR = 1
|
|
16
|
+
|
|
17
|
+
# Generations of the BrainSTEM data model (``data_model`` in the server's reference manifest) the mapping
|
|
18
|
+
# is written for. The data model itself is read from the server at run time.
|
|
19
|
+
SUPPORTED_DATA_MODELS = ("specimen-1",)
|
|
20
|
+
|
|
21
|
+
PACKAGE_URL = "https://github.com/brainstem-org/brainstem-nwb"
|
brainstem_nwb/cli.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Command-line interface: ``brainstem-nwb``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
from .errors import BrainstemNwbError
|
|
14
|
+
|
|
15
|
+
_SOURCE_HELP = "BrainSTEM project export (.json or .json.gz, from Project -> Download -> JSON)."
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _source_options(func: Any) -> Any:
|
|
19
|
+
options = [
|
|
20
|
+
click.option("--project", "project_id", metavar="UUID", help="BrainSTEM project id; its export is fetched."),
|
|
21
|
+
click.option("--export-file", type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
22
|
+
help=_SOURCE_HELP),
|
|
23
|
+
click.option("--session", "sessions", multiple=True, metavar="ID|NAME",
|
|
24
|
+
help="Only this session (repeatable). Default: every session of the project. A single "
|
|
25
|
+
"--session without --project or --export-file fetches that session's export."),
|
|
26
|
+
click.option("--config", type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
27
|
+
help="TOML configuration (experimenter, lab, institution, subject options)."),
|
|
28
|
+
click.option("--allow-no-subject", is_flag=True, default=None,
|
|
29
|
+
help="Export sessions without a subject (no NWB Subject)."),
|
|
30
|
+
click.option("--no-infer-subjects", "infer_subjects", flag_value=False, default=None,
|
|
31
|
+
help="Do not take subjects from linked records when a session lists none."),
|
|
32
|
+
click.option("--reproducible", is_flag=True, default=None,
|
|
33
|
+
help="Use the export time as creation time, so reruns give identical manifests."),
|
|
34
|
+
click.option("--extension", "extensions", multiple=True, type=click.Choice(["brainstem", "localization"]),
|
|
35
|
+
help="Also write NWB extensions (need the ndx-brainstem package: "
|
|
36
|
+
"pip install 'brainstem-nwb[extensions]'): 'brainstem' adds every "
|
|
37
|
+
"BrainSTEM record as ndx-brainstem tables, and the coordinates of procedures and interfaces "
|
|
38
|
+
"as ndx-anatomical-localization tables ('localization' is the same)."),
|
|
39
|
+
click.option("--embed-schemas", is_flag=True, default=None,
|
|
40
|
+
help="With --extension brainstem: fetch the BrainSTEM JSON schemas the records use from the "
|
|
41
|
+
"server's public schema service (--url, default www.brainstem.org) and embed them."),
|
|
42
|
+
click.option("--herd", is_flag=True, default=None,
|
|
43
|
+
help="Also write ontology terms (NCBITaxon, RRIDs, atlas regions) as a HERD zip next to each "
|
|
44
|
+
"NWB file."),
|
|
45
|
+
click.option("--token", envvar="BRAINSTEM_API_TOKEN", metavar="TOKEN",
|
|
46
|
+
help="BrainSTEM API token (default: $BRAINSTEM_API_TOKEN, then the token cached by "
|
|
47
|
+
"'brainstem login')."),
|
|
48
|
+
click.option("--url", metavar="URL", help="BrainSTEM server (default https://www.brainstem.org/)."),
|
|
49
|
+
click.option("--no-background", "background", flag_value=False, default=True,
|
|
50
|
+
help="Fetch the export in one request instead of a background export job."),
|
|
51
|
+
click.option("--headless", is_flag=True, help="For browser login: print the login URL instead of opening it."),
|
|
52
|
+
click.option("--no-device-flow", is_flag=True, help="Never start the browser login; fail without a token."),
|
|
53
|
+
]
|
|
54
|
+
for option in reversed(options):
|
|
55
|
+
func = option(func)
|
|
56
|
+
return func
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _fetch_kwargs(token: str | None, url: str | None, background: bool, headless: bool,
|
|
60
|
+
no_device_flow: bool) -> dict[str, Any]:
|
|
61
|
+
return {
|
|
62
|
+
"token": token or None,
|
|
63
|
+
"url": url,
|
|
64
|
+
"background": background,
|
|
65
|
+
"headless": headless,
|
|
66
|
+
"allow_device_flow": (not no_device_flow) and sys.stdin.isatty(),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _fail(exc: BrainstemNwbError) -> None:
|
|
71
|
+
click.echo(f"error: {exc}", err=True)
|
|
72
|
+
sys.exit(exc.exit_code)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
76
|
+
@click.version_option(__version__, prog_name="brainstem-nwb")
|
|
77
|
+
def main() -> None:
|
|
78
|
+
"""Export BrainSTEM experimental metadata to NWB files."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@main.command()
|
|
82
|
+
@_source_options
|
|
83
|
+
@click.option("-o", "--output", type=click.Path(file_okay=False, path_type=Path), required=True,
|
|
84
|
+
help="Output directory.")
|
|
85
|
+
@click.option("--dry-run", is_flag=True, help="Write only the manifest; no NWB files.")
|
|
86
|
+
@click.option("--no-validate", "validate", flag_value=False, default=True, help="Skip validation.")
|
|
87
|
+
@click.option("--overwrite", is_flag=True, help="Replace existing NWB files that differ.")
|
|
88
|
+
@click.option("--fail-fast", is_flag=True, help="Stop at the first unit that fails.")
|
|
89
|
+
@click.option("--inspector-config", default="dandi", show_default=True,
|
|
90
|
+
help="NWB Inspector configuration ('dandi' or 'none').")
|
|
91
|
+
@click.option("--fail-on", type=click.Choice(["error", "warning"]), default="error", show_default=True,
|
|
92
|
+
help="Validation severity that makes the exit code non-zero.")
|
|
93
|
+
@click.option("--no-save-export", "save_export", flag_value=False, default=True,
|
|
94
|
+
help="Do not keep a copy of the fetched export in OUTPUT/source/.")
|
|
95
|
+
@click.option("--json", "as_json", is_flag=True, help="Print the manifest summary as JSON.")
|
|
96
|
+
def export(project_id, export_file, sessions, config, allow_no_subject, infer_subjects, reproducible, extensions,
|
|
97
|
+
embed_schemas, herd, token, url, background, headless, no_device_flow, output, dry_run, validate, overwrite,
|
|
98
|
+
fail_fast, inspector_config, fail_on, save_export, as_json) -> None:
|
|
99
|
+
"""Export a project (or some of its sessions) to metadata-only NWB files.
|
|
100
|
+
|
|
101
|
+
One file is written per session and subject, as sub-<subject>/sub-<subject>_ses-<session>.nwb,
|
|
102
|
+
with brainstem-nwb-manifest.json and validation-report.{json,txt}.
|
|
103
|
+
"""
|
|
104
|
+
from .export import export_project
|
|
105
|
+
|
|
106
|
+
if not project_id and not export_file and len(sessions) != 1:
|
|
107
|
+
raise click.UsageError("give --project, --export-file, or one --session to fetch")
|
|
108
|
+
try:
|
|
109
|
+
result = export_project(
|
|
110
|
+
project_id, output, export_file=export_file, sessions=list(sessions) or None, config=config,
|
|
111
|
+
dry_run=dry_run, validate=validate, allow_no_subject=allow_no_subject, infer_subjects=infer_subjects,
|
|
112
|
+
reproducible=reproducible, extensions=list(extensions) or None, herd=herd,
|
|
113
|
+
embed_schemas=embed_schemas, overwrite=overwrite,
|
|
114
|
+
fail_fast=fail_fast,
|
|
115
|
+
inspector_config=None if inspector_config == "none" else inspector_config, fail_on=fail_on,
|
|
116
|
+
save_export=save_export, **_fetch_kwargs(token, url, background, headless, no_device_flow),
|
|
117
|
+
)
|
|
118
|
+
except BrainstemNwbError as exc:
|
|
119
|
+
_fail(exc)
|
|
120
|
+
return
|
|
121
|
+
_print_summary(result, as_json)
|
|
122
|
+
sys.exit(result.exit_code)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@main.command()
|
|
126
|
+
@_source_options
|
|
127
|
+
@click.option("-o", "--output", type=click.Path(path_type=Path), required=True,
|
|
128
|
+
help="Output directory, or a .json file name for the manifest.")
|
|
129
|
+
def manifest(project_id, export_file, sessions, config, allow_no_subject, infer_subjects, reproducible, extensions,
|
|
130
|
+
embed_schemas, herd, token, url, background, headless, no_device_flow, output) -> None:
|
|
131
|
+
"""Build the conversion manifest only (same as export --dry-run)."""
|
|
132
|
+
from .export import build_manifest
|
|
133
|
+
from .manifest.io import MANIFEST_FILENAME, write_manifest
|
|
134
|
+
from .retrieval.api import fetch_export
|
|
135
|
+
|
|
136
|
+
if not project_id and not export_file and len(sessions) != 1:
|
|
137
|
+
raise click.UsageError("give --project, --export-file, or one --session to fetch")
|
|
138
|
+
try:
|
|
139
|
+
session_id = sessions[0] if len(sessions) == 1 and not export_file else None
|
|
140
|
+
source = export_file or fetch_export(
|
|
141
|
+
None if session_id else project_id, session_id=session_id,
|
|
142
|
+
**_fetch_kwargs(token, url, background, headless, no_device_flow))
|
|
143
|
+
result = build_manifest(source, sessions=list(sessions) or None, config=config,
|
|
144
|
+
allow_no_subject=allow_no_subject, infer_subjects=infer_subjects,
|
|
145
|
+
reproducible=reproducible, extensions=list(extensions) or None, herd=herd,
|
|
146
|
+
embed_schemas=embed_schemas, schema_server=url)
|
|
147
|
+
except BrainstemNwbError as exc:
|
|
148
|
+
_fail(exc)
|
|
149
|
+
return
|
|
150
|
+
path = output if output.suffix == ".json" else output / MANIFEST_FILENAME
|
|
151
|
+
write_manifest(result, path)
|
|
152
|
+
s = result.summary
|
|
153
|
+
click.echo(f"{s.units} unit(s) planned from {s.sessions} session(s); {s.unit_errors} session(s) cannot be "
|
|
154
|
+
f"exported. Manifest: {path}")
|
|
155
|
+
sys.exit(1 if result.unit_errors else 0)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@main.command()
|
|
159
|
+
@click.argument("paths", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
|
|
160
|
+
@click.option("--manifest", "manifest_path", type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
161
|
+
help="Manifest to check the files against (default: the one next to the files).")
|
|
162
|
+
@click.option("--inspector-config", default="dandi", show_default=True)
|
|
163
|
+
@click.option("--report-dir", type=click.Path(file_okay=False, path_type=Path),
|
|
164
|
+
help="Write validation-report.{json,txt} here.")
|
|
165
|
+
@click.option("--fail-on", type=click.Choice(["error", "warning"]), default="error", show_default=True)
|
|
166
|
+
def validate(paths, manifest_path, inspector_config, report_dir, fail_on) -> None:
|
|
167
|
+
"""Validate NWB files (schema, NWB Inspector, BrainSTEM checks)."""
|
|
168
|
+
from .export import validate_output
|
|
169
|
+
from .validation import ValidationReport, render_text
|
|
170
|
+
|
|
171
|
+
combined = ValidationReport()
|
|
172
|
+
for path in paths:
|
|
173
|
+
report = validate_output(path, manifest=manifest_path,
|
|
174
|
+
inspector_config=None if inspector_config == "none" else inspector_config)
|
|
175
|
+
combined.tool_versions = report.tool_versions
|
|
176
|
+
combined.inspector_config = report.inspector_config
|
|
177
|
+
combined.files.extend(report.files)
|
|
178
|
+
combined.finalize()
|
|
179
|
+
if report_dir:
|
|
180
|
+
from .export import _write_report
|
|
181
|
+
|
|
182
|
+
_write_report(combined, report_dir)
|
|
183
|
+
click.echo(render_text(combined), nl=False)
|
|
184
|
+
failed = combined.has("error") or (fail_on == "warning" and combined.has("warning"))
|
|
185
|
+
sys.exit(4 if failed else 0)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@main.command()
|
|
189
|
+
@click.option("--project", "project_id", metavar="UUID")
|
|
190
|
+
@click.option("--session", "session_id", metavar="UUID", help="Fetch this session's export instead of a project's.")
|
|
191
|
+
@click.option("-o", "--output", type=click.Path(dir_okay=False, path_type=Path), required=True,
|
|
192
|
+
help="File to write (.json or .json.gz).")
|
|
193
|
+
@click.option("--token", envvar="BRAINSTEM_API_TOKEN", metavar="TOKEN")
|
|
194
|
+
@click.option("--url", metavar="URL")
|
|
195
|
+
@click.option("--no-background", "background", flag_value=False, default=True)
|
|
196
|
+
@click.option("--headless", is_flag=True)
|
|
197
|
+
@click.option("--no-device-flow", is_flag=True)
|
|
198
|
+
def fetch(project_id, session_id, output, token, url, background, headless, no_device_flow) -> None:
|
|
199
|
+
"""Download a project (or session) export, to convert later or offline."""
|
|
200
|
+
from .retrieval.api import fetch_export
|
|
201
|
+
|
|
202
|
+
if bool(project_id) == bool(session_id):
|
|
203
|
+
raise click.UsageError("give --project or --session")
|
|
204
|
+
try:
|
|
205
|
+
export_data = fetch_export(project_id, session_id=session_id, save_to=output,
|
|
206
|
+
**_fetch_kwargs(token, url, background, headless, no_device_flow))
|
|
207
|
+
except BrainstemNwbError as exc:
|
|
208
|
+
_fail(exc)
|
|
209
|
+
return
|
|
210
|
+
what = f"session {export_data.session_id}" if export_data.scope == "session" else \
|
|
211
|
+
f"project {export_data.project_id}"
|
|
212
|
+
click.echo(f"Saved export of {what} (format {export_data.format_version}, "
|
|
213
|
+
f"{export_data.info.get('record_count')} records) to {output}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@main.command()
|
|
217
|
+
@click.option("--format", "fmt", type=click.Choice(["md", "json"]), default="md", show_default=True)
|
|
218
|
+
@click.option("--warnings", "show_warnings", is_flag=True, help="Show the warning codes instead.")
|
|
219
|
+
@click.option("--check", "check_path", type=click.Path(dir_okay=False, path_type=Path),
|
|
220
|
+
help="Exit 1 if this file differs from the generated Markdown.")
|
|
221
|
+
def mapping(fmt, show_warnings, check_path) -> None:
|
|
222
|
+
"""Print the BrainSTEM-to-NWB mapping specification."""
|
|
223
|
+
from dataclasses import asdict
|
|
224
|
+
|
|
225
|
+
from .mapping.docs import render_mapping_spec, render_warnings
|
|
226
|
+
from .mapping.registry import RULES, _load_mappers
|
|
227
|
+
|
|
228
|
+
if fmt == "json":
|
|
229
|
+
_load_mappers()
|
|
230
|
+
click.echo(json.dumps([asdict(rule) for rule in sorted(RULES.values(), key=lambda r: r.id)], indent=2))
|
|
231
|
+
return
|
|
232
|
+
text = render_warnings() if show_warnings else render_mapping_spec()
|
|
233
|
+
if check_path:
|
|
234
|
+
current = check_path.read_text(encoding="utf-8") if check_path.exists() else ""
|
|
235
|
+
if current != text:
|
|
236
|
+
click.echo(f"{check_path} is out of date; regenerate it with brainstem-nwb mapping", err=True)
|
|
237
|
+
sys.exit(1)
|
|
238
|
+
return
|
|
239
|
+
click.echo(text, nl=False)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@main.command()
|
|
243
|
+
@click.argument("name", type=click.Choice(["manifest", "report"]))
|
|
244
|
+
def schema(name) -> None:
|
|
245
|
+
"""Print the JSON Schema of the manifest or the validation report."""
|
|
246
|
+
from .manifest.schema import manifest_schema, report_schema
|
|
247
|
+
|
|
248
|
+
data = manifest_schema() if name == "manifest" else report_schema()
|
|
249
|
+
click.echo(json.dumps(data, indent=2, sort_keys=True))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@main.command("data-model")
|
|
253
|
+
@click.option("--url", metavar="URL", help="BrainSTEM server (default https://www.brainstem.org/).")
|
|
254
|
+
@click.option("--refresh", is_flag=True, help="Fetch the documents again, even when the cached copy is fresh.")
|
|
255
|
+
def data_model(url, refresh) -> None:
|
|
256
|
+
"""Read the BrainSTEM data model from the server, cache it, and show which one it is.
|
|
257
|
+
|
|
258
|
+
brainstem-nwb ships no copy of the data model; exports read it from the server (cached for an hour, and
|
|
259
|
+
used from the cache when the server cannot be reached). Run this once before working offline.
|
|
260
|
+
"""
|
|
261
|
+
from ._version import SUPPORTED_DATA_MODELS
|
|
262
|
+
from .store.datamodel import cache_dir, inventory_data, load_documents
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
documents = load_documents(url, refresh=refresh)
|
|
266
|
+
except BrainstemNwbError as exc:
|
|
267
|
+
_fail(exc)
|
|
268
|
+
data = inventory_data(documents.documents)
|
|
269
|
+
supported = "supported" if data["data_model"] in SUPPORTED_DATA_MODELS else \
|
|
270
|
+
f"not supported by this release ({', '.join(SUPPORTED_DATA_MODELS)})"
|
|
271
|
+
click.echo(f"server: {documents.server}")
|
|
272
|
+
click.echo(f"data model: {data['data_model']} ({supported})")
|
|
273
|
+
click.echo(f"export format: {data['export_format_version']}")
|
|
274
|
+
click.echo(f"sha256: {data['sha256']}")
|
|
275
|
+
origin = {"fetched": "fetched now", "cache": "cached copy (fresh)", "directory": "BRAINSTEM_NWB_DATA_MODEL",
|
|
276
|
+
"stale_cache": f"cached copy; server not reachable: {documents.error}"}[documents.origin]
|
|
277
|
+
click.echo(f"read from: {origin}" + (f", {documents.fetched_at}" if documents.fetched_at else ""))
|
|
278
|
+
if documents.origin != "directory":
|
|
279
|
+
click.echo(f"cache: {cache_dir(documents.server)}")
|
|
280
|
+
if data.get("license"):
|
|
281
|
+
click.echo(f"terms: {data['license'].get('url')}")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _print_summary(result: Any, as_json: bool) -> None:
|
|
285
|
+
manifest = result.manifest
|
|
286
|
+
if as_json:
|
|
287
|
+
payload = {"exit_code": result.exit_code, "manifest": str(result.manifest_path),
|
|
288
|
+
"summary": manifest.summary.model_dump(),
|
|
289
|
+
"validation": result.report.summary if result.report else None}
|
|
290
|
+
click.echo(json.dumps(payload, indent=2))
|
|
291
|
+
return
|
|
292
|
+
s = manifest.summary
|
|
293
|
+
click.echo(f"Sessions: {s.sessions} units: {s.units} written: {s.written} up to date: {s.up_to_date} "
|
|
294
|
+
f"failed: {s.failed} not exportable: {s.unit_errors}")
|
|
295
|
+
for error in manifest.unit_errors:
|
|
296
|
+
click.echo(f" not exported: session '{error.session_name}' ({error.code}): {error.message}")
|
|
297
|
+
for unit in manifest.units:
|
|
298
|
+
if unit.status == "failed" and unit.error:
|
|
299
|
+
click.echo(f" failed: {unit.output.relative_path}: {unit.error.message}")
|
|
300
|
+
if result.report is not None:
|
|
301
|
+
r = result.report.summary
|
|
302
|
+
click.echo(f"Validation: {r.get('error', 0)} error(s), {r.get('warning', 0)} warning(s), "
|
|
303
|
+
f"{r.get('info', 0)} info (see validation-report.txt)")
|
|
304
|
+
click.echo(f"Manifest: {result.manifest_path}")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
if __name__ == "__main__": # pragma: no cover
|
|
308
|
+
main()
|
brainstem_nwb/config.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Export options and the optional TOML configuration file.
|
|
3
|
+
|
|
4
|
+
Some NWB fields have no source in a BrainSTEM export (who ran the session, the lab, the
|
|
5
|
+
institution). They can be given in a configuration file; they are never guessed::
|
|
6
|
+
|
|
7
|
+
# brainstem-nwb.toml
|
|
8
|
+
[nwbfile]
|
|
9
|
+
experimenter = ["Last, First"]
|
|
10
|
+
experimenter_source = "config" # or "session_creator"
|
|
11
|
+
lab = "Example Lab"
|
|
12
|
+
institution = "Example University"
|
|
13
|
+
|
|
14
|
+
[subject]
|
|
15
|
+
label_field = "name" # or "name_used_in_storage"
|
|
16
|
+
species_format = "binomial" # or "ncbitaxon" (NCBI Taxonomy IRI, as DANDI prefers)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Literal
|
|
24
|
+
|
|
25
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
26
|
+
|
|
27
|
+
from .errors import ConfigError
|
|
28
|
+
|
|
29
|
+
if sys.version_info >= (3, 11):
|
|
30
|
+
import tomllib
|
|
31
|
+
else: # pragma: no cover
|
|
32
|
+
import tomli as tomllib
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NWBFileConfig(BaseModel):
|
|
36
|
+
model_config = ConfigDict(extra="forbid")
|
|
37
|
+
|
|
38
|
+
experimenter: list[str] = Field(default_factory=list)
|
|
39
|
+
# "session_creator": use the user who created the session record in BrainSTEM as experimenter when no
|
|
40
|
+
# experimenter is configured. Off by default: the creator is not necessarily who ran the session.
|
|
41
|
+
experimenter_source: Literal["config", "session_creator"] = "config"
|
|
42
|
+
lab: str | None = None
|
|
43
|
+
institution: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SubjectConfig(BaseModel):
|
|
47
|
+
model_config = ConfigDict(extra="forbid")
|
|
48
|
+
|
|
49
|
+
label_field: Literal["name", "name_used_in_storage"] = "name"
|
|
50
|
+
species_format: Literal["binomial", "ncbitaxon"] = "binomial"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
Extension = Literal["brainstem", "localization"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ExportOptions(BaseModel):
|
|
57
|
+
"""Everything that changes what is exported. Recorded in the manifest (no secrets, no paths)."""
|
|
58
|
+
|
|
59
|
+
model_config = ConfigDict(extra="forbid")
|
|
60
|
+
|
|
61
|
+
# NWB extensions to write: "brainstem" adds the ndx-brainstem tables (every BrainSTEM record, lossless) and the
|
|
62
|
+
# ndx-anatomical-localization coordinates of procedures and interfaces; "localization" means the same
|
|
63
|
+
extensions: list[Extension] = Field(default_factory=list)
|
|
64
|
+
# Also write ontology terms (NCBITaxon, RRIDs, atlas regions) as a HERD zip next to each NWB file
|
|
65
|
+
herd: bool = False
|
|
66
|
+
# Fetch the BrainSTEM JSON schemas the records use from the server's public schema service and embed them in
|
|
67
|
+
# the ndx-brainstem schemas table (needs --extension brainstem)
|
|
68
|
+
embed_schemas: bool = False
|
|
69
|
+
nwbfile: NWBFileConfig = Field(default_factory=NWBFileConfig)
|
|
70
|
+
subject: SubjectConfig = Field(default_factory=SubjectConfig)
|
|
71
|
+
sessions: list[str] | None = None
|
|
72
|
+
allow_no_subject: bool = False
|
|
73
|
+
infer_subjects: bool = True
|
|
74
|
+
reproducible: bool = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_config(path: str | Path | None) -> dict[str, Any]:
|
|
78
|
+
"""Read a TOML configuration file into a dict of ``ExportOptions`` fields."""
|
|
79
|
+
if path is None:
|
|
80
|
+
return {}
|
|
81
|
+
path = Path(path)
|
|
82
|
+
try:
|
|
83
|
+
with path.open("rb") as handle:
|
|
84
|
+
data = tomllib.load(handle)
|
|
85
|
+
except OSError as exc:
|
|
86
|
+
raise ConfigError(f"cannot read configuration file {path.name}: {exc.strerror or exc}") from exc
|
|
87
|
+
except tomllib.TOMLDecodeError as exc:
|
|
88
|
+
raise ConfigError(f"invalid TOML in {path.name}: {exc}") from exc
|
|
89
|
+
unknown = set(data) - {"nwbfile", "subject"}
|
|
90
|
+
if unknown:
|
|
91
|
+
raise ConfigError(f"unknown section(s) in {path.name}: {', '.join(sorted(unknown))}")
|
|
92
|
+
return data
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_options(config: str | Path | dict[str, Any] | ExportOptions | None = None, **overrides: Any) -> ExportOptions:
|
|
96
|
+
"""Combine a configuration file (or dict) with explicit options; explicit options win."""
|
|
97
|
+
if isinstance(config, ExportOptions):
|
|
98
|
+
base = config.model_dump()
|
|
99
|
+
elif isinstance(config, dict):
|
|
100
|
+
base = dict(config)
|
|
101
|
+
else:
|
|
102
|
+
base = load_config(config)
|
|
103
|
+
for key, value in overrides.items():
|
|
104
|
+
if value is not None:
|
|
105
|
+
base[key] = value
|
|
106
|
+
try:
|
|
107
|
+
return ExportOptions.model_validate(base)
|
|
108
|
+
except ValidationError as exc:
|
|
109
|
+
raise ConfigError(f"invalid options: {exc}") from exc
|
brainstem_nwb/errors.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Exceptions raised by brainstem-nwb. Each carries the CLI exit code it maps to."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BrainstemNwbError(Exception):
|
|
5
|
+
"""Base class for all errors raised by brainstem-nwb."""
|
|
6
|
+
|
|
7
|
+
exit_code = 2
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExportFormatError(BrainstemNwbError):
|
|
11
|
+
"""The BrainSTEM export file is unreadable or in an unsupported format version."""
|
|
12
|
+
|
|
13
|
+
exit_code = 2
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigError(BrainstemNwbError):
|
|
17
|
+
"""Invalid options or configuration file."""
|
|
18
|
+
|
|
19
|
+
exit_code = 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AuthRequired(BrainstemNwbError):
|
|
23
|
+
"""No BrainSTEM token is available and interactive login is not allowed."""
|
|
24
|
+
|
|
25
|
+
exit_code = 3
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RetrievalError(BrainstemNwbError):
|
|
29
|
+
"""The project export could not be fetched from BrainSTEM."""
|
|
30
|
+
|
|
31
|
+
exit_code = 3
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class UnitError(BrainstemNwbError):
|
|
35
|
+
"""A session (or session x subject unit) cannot be exported; ``code`` names the reason."""
|
|
36
|
+
|
|
37
|
+
exit_code = 1
|
|
38
|
+
|
|
39
|
+
def __init__(self, code: str, message: str):
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.code = code
|
|
42
|
+
self.message = message
|