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.
Files changed (61) hide show
  1. brainstem_nwb/__init__.py +34 -0
  2. brainstem_nwb/_version.py +21 -0
  3. brainstem_nwb/cli.py +308 -0
  4. brainstem_nwb/config.py +109 -0
  5. brainstem_nwb/errors.py +42 -0
  6. brainstem_nwb/export.py +539 -0
  7. brainstem_nwb/manifest/__init__.py +6 -0
  8. brainstem_nwb/manifest/io.py +38 -0
  9. brainstem_nwb/manifest/models.py +447 -0
  10. brainstem_nwb/manifest/schema.py +33 -0
  11. brainstem_nwb/manifest/warnings.py +180 -0
  12. brainstem_nwb/mapping/__init__.py +0 -0
  13. brainstem_nwb/mapping/acquisitions.py +98 -0
  14. brainstem_nwb/mapping/context.py +111 -0
  15. brainstem_nwb/mapping/describe.py +249 -0
  16. brainstem_nwb/mapping/devices.py +344 -0
  17. brainstem_nwb/mapping/docs.py +102 -0
  18. brainstem_nwb/mapping/epochs.py +94 -0
  19. brainstem_nwb/mapping/extension.py +330 -0
  20. brainstem_nwb/mapping/herd.py +108 -0
  21. brainstem_nwb/mapping/localization.py +116 -0
  22. brainstem_nwb/mapping/manipulations.py +88 -0
  23. brainstem_nwb/mapping/notes.py +175 -0
  24. brainstem_nwb/mapping/pipeline.py +228 -0
  25. brainstem_nwb/mapping/procedures.py +169 -0
  26. brainstem_nwb/mapping/registry.py +281 -0
  27. brainstem_nwb/mapping/session.py +203 -0
  28. brainstem_nwb/mapping/subject.py +204 -0
  29. brainstem_nwb/normalize/__init__.py +0 -0
  30. brainstem_nwb/normalize/identifiers.py +60 -0
  31. brainstem_nwb/normalize/kv.py +53 -0
  32. brainstem_nwb/normalize/quantities.py +73 -0
  33. brainstem_nwb/normalize/schemas.py +101 -0
  34. brainstem_nwb/normalize/text.py +67 -0
  35. brainstem_nwb/normalize/timeutil.py +110 -0
  36. brainstem_nwb/normalize/units.py +630 -0
  37. brainstem_nwb/nwb/__init__.py +0 -0
  38. brainstem_nwb/nwb/canonical.py +94 -0
  39. brainstem_nwb/nwb/writer.py +295 -0
  40. brainstem_nwb/py.typed +0 -0
  41. brainstem_nwb/raw/__init__.py +0 -0
  42. brainstem_nwb/raw/base.py +49 -0
  43. brainstem_nwb/raw/metadata.py +31 -0
  44. brainstem_nwb/raw/paths.py +74 -0
  45. brainstem_nwb/raw/registry.py +38 -0
  46. brainstem_nwb/retrieval/__init__.py +0 -0
  47. brainstem_nwb/retrieval/api.py +137 -0
  48. brainstem_nwb/retrieval/loader.py +182 -0
  49. brainstem_nwb/retrieval/schemas.py +52 -0
  50. brainstem_nwb/store/__init__.py +0 -0
  51. brainstem_nwb/store/datamodel.py +299 -0
  52. brainstem_nwb/store/graph.py +180 -0
  53. brainstem_nwb/store/inventory.py +95 -0
  54. brainstem_nwb/validation/__init__.py +36 -0
  55. brainstem_nwb/validation/checks.py +167 -0
  56. brainstem_nwb/validation/report.py +82 -0
  57. brainstem_nwb-0.1.0.dist-info/METADATA +158 -0
  58. brainstem_nwb-0.1.0.dist-info/RECORD +61 -0
  59. brainstem_nwb-0.1.0.dist-info/WHEEL +4 -0
  60. brainstem_nwb-0.1.0.dist-info/entry_points.txt +2 -0
  61. 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()
@@ -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
@@ -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