brainstem-python-api-tools 2.0.0__py3-none-any.whl → 2.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.
@@ -10,6 +10,8 @@ Usage examples
10
10
  brainstem load session --portal public --filters name.icontains=rat --sort -name
11
11
  brainstem load session --id <uuid>
12
12
  brainstem load session --limit 20 --offset 40
13
+ brainstem load session --per-page 20 --page 3
14
+ brainstem load session --all
13
15
  brainstem save session --data '{"name":"New","projects":["<uuid>"]}'
14
16
  brainstem save session --id <uuid> --data '{"description":"updated"}'
15
17
  brainstem delete session --id <uuid>
@@ -20,32 +22,50 @@ import json
20
22
  import os
21
23
  import sys
22
24
 
23
- from .brainstem_api_client import BrainstemClient, _MODEL_TO_APP, _TOKEN_FILE
25
+ import requests
24
26
 
27
+ from .brainstem_api_client import (
28
+ AuthenticationError, BrainstemClient, _MODEL_TO_APP, _TOKEN_FILE,
29
+ )
30
+
31
+
32
+ def _common_flags(suppress_defaults: bool = False) -> argparse.ArgumentParser:
33
+ """Shared flags available before and after the subcommand.
34
+
35
+ The subcommand copies use ``argparse.SUPPRESS`` defaults so they do not
36
+ overwrite a flag given before the subcommand (``brainstem --url X load``).
37
+ """
38
+ def default(value):
39
+ return argparse.SUPPRESS if suppress_defaults else value
25
40
 
26
- def _build_parser() -> argparse.ArgumentParser:
27
- # Shared flags available on every subcommand
28
41
  common = argparse.ArgumentParser(add_help=False)
29
42
  common.add_argument(
30
43
  "--token",
31
- default=os.environ.get("BRAINSTEM_API_TOKEN"),
44
+ default=default(os.environ.get("BRAINSTEM_API_TOKEN")),
32
45
  help="API token. Defaults to $BRAINSTEM_API_TOKEN or the cached token.",
33
46
  )
34
47
  common.add_argument(
35
48
  "--headless",
36
49
  action="store_true",
50
+ default=default(False),
37
51
  help="Print verification URL + code instead of opening a browser.",
38
52
  )
39
53
  common.add_argument(
40
54
  "--url",
41
- default=None,
42
- help="Base URL of the BrainSTEM server (default: https://www.brainstem.org/).",
55
+ default=default(os.environ.get("BRAINSTEM_API_URL")),
56
+ help="Base URL of the BrainSTEM server. Defaults to $BRAINSTEM_API_URL "
57
+ "or https://www.brainstem.org/.",
43
58
  )
59
+ return common
60
+
61
+
62
+ def _build_parser() -> argparse.ArgumentParser:
63
+ common = _common_flags(suppress_defaults=True)
44
64
 
45
65
  parser = argparse.ArgumentParser(
46
66
  prog="brainstem",
47
67
  description="BrainSTEM command-line API client.",
48
- parents=[common],
68
+ parents=[_common_flags()],
49
69
  )
50
70
 
51
71
  sub = parser.add_subparsers(dest="command", required=True)
@@ -87,8 +107,17 @@ def _build_parser() -> argparse.ArgumentParser:
87
107
  metavar="RELATION",
88
108
  help="Related models to embed.",
89
109
  )
90
- p_load.add_argument("--limit", type=int, help="Max records (API max: 100).")
91
- p_load.add_argument("--offset", type=int, help="Records to skip (pagination).")
110
+ p_load.add_argument("--limit", type=int,
111
+ help="Records per page (sent as per_page; default 100).")
112
+ p_load.add_argument("--offset", type=int,
113
+ help="Records to skip; must be a multiple of the page size.")
114
+ p_load.add_argument("--per-page", type=int, help="Records per page (default 100).")
115
+ p_load.add_argument("--page", type=int, help="Page number, starting at 1.")
116
+ p_load.add_argument(
117
+ "--all",
118
+ action="store_true",
119
+ help="Follow pagination and print all records merged into one JSON object.",
120
+ )
92
121
 
93
122
  # ---- save -------------------------------------------------------
94
123
  p_save = sub.add_parser("save", parents=[common], help="Create or update a record.")
@@ -107,6 +136,16 @@ def _build_parser() -> argparse.ArgumentParser:
107
136
  p_delete.add_argument("--portal", default="private")
108
137
  p_delete.add_argument("--id", required=True, help="UUID of the record to delete.")
109
138
 
139
+ # ---- reference --------------------------------------------------
140
+ p_reference = sub.add_parser(
141
+ "reference", parents=[common],
142
+ help="Print a page of the server's agent reference (API and data model description).",
143
+ )
144
+ p_reference.add_argument(
145
+ "page", nargs="?", default="index.md",
146
+ help="Page path, e.g. rules.md, resources/session.md, guides/query-planning.md (default: index.md).",
147
+ )
148
+
110
149
  return parser
111
150
 
112
151
 
@@ -122,6 +161,16 @@ def main():
122
161
  print("No cached token found.")
123
162
  return
124
163
 
164
+ if args.command == "reference":
165
+ # The reference is public: never start a login for it.
166
+ client = BrainstemClient(anonymous=True, url=getattr(args, "url", None))
167
+ try:
168
+ print(client.agent_reference(args.page))
169
+ except (ValueError, requests.HTTPError) as exc:
170
+ print(f"Error: {exc}", file=sys.stderr)
171
+ sys.exit(1)
172
+ return
173
+
125
174
  client = BrainstemClient(
126
175
  token=args.token,
127
176
  headless=getattr(args, "headless", False),
@@ -129,8 +178,11 @@ def main():
129
178
  )
130
179
 
131
180
  if args.command == "login":
132
- print(f"Token: {client._token}")
133
- print(f"Cached at: {_TOKEN_FILE}")
181
+ if args.token:
182
+ print("Logged in with the token from --token / $BRAINSTEM_API_TOKEN "
183
+ "(not cached).")
184
+ else:
185
+ print(f"Logged in. Token cached at {_TOKEN_FILE}")
134
186
  return
135
187
 
136
188
  if args.command == "load":
@@ -141,16 +193,39 @@ def main():
141
193
  k, v = expr.split("=", 1)
142
194
  filters[k] = v
143
195
 
144
- resp = client.load(
145
- args.model,
196
+ limit, offset = args.limit, args.offset
197
+ if args.per_page is not None or args.page is not None:
198
+ if limit is not None or offset is not None:
199
+ parser.error("Use either --per-page/--page or --limit/--offset, not both.")
200
+ if (args.per_page is not None and args.per_page < 1) or \
201
+ (args.page is not None and args.page < 1):
202
+ parser.error("--per-page and --page must be at least 1.")
203
+ limit = args.per_page
204
+ if args.page is not None:
205
+ offset = (args.page - 1) * (args.per_page or 100)
206
+
207
+ load_kwargs = dict(
146
208
  portal=args.portal,
147
209
  id=args.id,
148
210
  filters=filters or None,
149
211
  sort=args.sort,
150
212
  include=args.include,
151
- limit=args.limit,
152
- offset=args.offset,
213
+ limit=limit,
214
+ offset=offset,
153
215
  )
216
+ if args.all:
217
+ try:
218
+ result = client.load(args.model, load_all=True, **load_kwargs)
219
+ except (ValueError, AuthenticationError, requests.HTTPError) as exc:
220
+ print(f"Error: {exc}", file=sys.stderr)
221
+ sys.exit(1)
222
+ print(json.dumps(result, indent=2))
223
+ return
224
+
225
+ try:
226
+ resp = client.load(args.model, **load_kwargs)
227
+ except ValueError as exc:
228
+ parser.error(str(exc))
154
229
 
155
230
  elif args.command == "save":
156
231
  try:
@@ -0,0 +1,330 @@
1
+ Metadata-Version: 2.4
2
+ Name: brainstem_python_api_tools
3
+ Version: 2.1.0
4
+ Summary: A Python toolset for interacting with the BrainSTEM API.
5
+ Author-email: BrainSTEM Team <petersen.peter@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/brainstem-org/brainstem_python_api_tools
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: requests>=2.28
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest; extra == "dev"
14
+ Requires-Dist: pytest-mock; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # BrainSTEM Python API Tools
18
+
19
+ The `brainstem_python_api_tools` is a Python toolset for interacting with the [BrainSTEM](https://www.brainstem.org/) API, designed for researchers and developers working with neuroscience data.
20
+
21
+ ## Installation
22
+ You can install the package using `pip`:
23
+ ```bash
24
+ pip install brainstem_python_api_tools
25
+ ```
26
+
27
+ ## Authentication
28
+
29
+ Authentication uses a **browser-based device authorization flow** that supports two-factor authentication (2FA). No credentials are entered into the tool itself.
30
+
31
+ ```python
32
+ from brainstem_api_tools import BrainstemClient
33
+
34
+ # First run: opens a browser window for secure login.
35
+ # The token is cached at ~/.config/brainstem/token and reused automatically.
36
+ client = BrainstemClient()
37
+ ```
38
+
39
+ To skip the browser flow, pass a token directly:
40
+ ```python
41
+ client = BrainstemClient(token="YOUR_TOKEN")
42
+ ```
43
+
44
+ For headless environments (no browser available):
45
+ ```python
46
+ client = BrainstemClient(headless=True) # prints a URL + code to enter manually
47
+ ```
48
+
49
+ To read public data without logging in (no token is sent; only `portal='public'` works):
50
+ ```python
51
+ client = BrainstemClient(anonymous=True)
52
+ client.load("project", portal="public", load_all=True)
53
+ ```
54
+
55
+ To connect to a different server (e.g. a local development instance):
56
+ ```python
57
+ client = BrainstemClient(url="http://127.0.0.1:8000/")
58
+ ```
59
+
60
+ The client and the CLI also read two environment variables when no `token` / `url` is given:
61
+
62
+ - `BRAINSTEM_API_TOKEN`: API token, used before the cached token.
63
+ - `BRAINSTEM_API_URL`: server base URL (default `https://www.brainstem.org/`).
64
+
65
+ > **Security note:** The cached token file is stored with owner-read-only permissions (`0600`). Treat it like a password and do not commit it to version control.
66
+
67
+ ## Getting Started
68
+ To get started with the BrainSTEM API tools, please refer to the tutorial script provided:
69
+
70
+ - **Tutorial:** `brainstem_api_tutorial.py`
71
+
72
+ The tutorial demonstrates how to:
73
+
74
+ - **Authenticate:** Load the client and authenticate via your browser.
75
+ - **Load Data:** Load sessions and filter data using flexible options.
76
+ - **Paginate:** Retrieve large datasets page by page (`limit` / `offset`) or all at once (`load_all=True`).
77
+ - **Convenience Loaders:** Use `load_session()`, `load_subject()`, etc. for common queries.
78
+ - **Update Entries:** Modify existing records and update them in the database.
79
+ - **Create Entries:** Submit new data entries with required fields.
80
+ - **Delete Entries:** Remove records by ID.
81
+ - **Load Public Data:** Access public projects and data using the public portal.
82
+
83
+ ## Example Usage
84
+ ```python
85
+ from brainstem_api_tools import BrainstemClient
86
+
87
+ client = BrainstemClient()
88
+
89
+ # Load sessions sorted by name
90
+ response = client.load('session', sort=['name'])
91
+ print(response.json())
92
+
93
+ # Filter and include related data
94
+ response = client.load(
95
+ 'session',
96
+ filters={'name.icontains': 'Rat'},
97
+ sort=['-name'],
98
+ include=['projects'],
99
+ )
100
+
101
+ # Paginate results (manual). The API pages with page/per_page: limit is sent
102
+ # as per_page (1 to 500, default 100) and offset is converted to a page number,
103
+ # so offset must be a multiple of limit. Here: page 2 of 20 records.
104
+ page2 = client.load('session', limit=20, offset=20).json()
105
+ print(page2['meta']) # {'page': 2, 'per_page': 20, 'total_results': ..., 'total_pages': ...}
106
+
107
+ # Auto-paginate — fetches every page and returns a merged dict. Records of
108
+ # sideloaded relations (include=[...]) are merged per key as well.
109
+ all_sessions = client.load('session', load_all=True)
110
+
111
+ # Convenience loaders — sensible defaults with named filter kwargs. Their
112
+ # filters send the bare field (filter{projects}=<uuid>), which every server accepts.
113
+ # load_session sideloads dataacquisition, behaviors, manipulations, epochs
114
+ sessions = client.load_session(name='Rat', load_all=True)
115
+
116
+ # load_subject sideloads procedures and subjectlogs
117
+ subjects = client.load_subject(sex='M', projects='<project-uuid>', load_all=True)
118
+
119
+ # load_project sideloads sessions, subjects, collections, cohorts
120
+ projects = client.load_project(name='MyProject')
121
+
122
+ # load_behavior / load_dataacquisition / load_manipulation scope by session UUID
123
+ behaviors = client.load_behavior(session='<session-uuid>', load_all=True)
124
+
125
+ # load_procedure / load_subjectlog scope by subject UUID; load_procedurelog by
126
+ # procedure, load_equipment by setup and load_strain by species UUID.
127
+ procedures = client.load_procedure(subject='<subject-uuid>', load_all=True)
128
+
129
+ # Create a record
130
+ client.save('session', data={'name': 'New session', 'projects': ['<project-uuid>']})
131
+
132
+ # Update a record
133
+ client.save('session', id='<session-uuid>', data={'description': 'updated'})
134
+
135
+ # Sending a list field replaces the whole list. Lists read with include (and
136
+ # the convenience loaders' default includes) hold only records you may read,
137
+ # so read the record without include before changing one:
138
+ session = client.load('session', id='<session-uuid>').json()['session']
139
+ client.save('session', id='<session-uuid>',
140
+ data={'projects': session['projects'] + ['<project-uuid>']})
141
+
142
+ # Delete a record
143
+ client.delete('session', id='<session-uuid>')
144
+ ```
145
+
146
+ ### Specimens, interfaces and materials
147
+
148
+ Specimen sets and specimens (`stem`), experimental interfaces, material usages and specimen procedures/logs (`modules`), and anatomical regions (`taxonomies`) load like any other model:
149
+
150
+ ```python
151
+ specimen_sets = client.load('specimenset', filters={'projects': '<project-uuid>'}, load_all=True)
152
+ interfaces = client.load('experimentalinterface', load_all=True)
153
+ ```
154
+
155
+ ### Species, strains and brain regions
156
+
157
+ ```python
158
+ # Search species and strains by common name as well as scientific name
159
+ mice = client.load_species(popular_name='mouse').json()
160
+ strains = client.load_strain(popular_name='black 6').json()
161
+
162
+ # Brain regions are anatomical regions; find one by acronym, then list its subregions
163
+ ca1 = client.load_anatomicalregion(acronym='CA1').json()['anatomical_regions'][0]
164
+ subregions = client.load_anatomicalregion_descendants(ca1['id']).json()['anatomical_regions']
165
+ for region in subregions:
166
+ print(region['depth'], region['acronym'], region['name'])
167
+ ```
168
+
169
+ `brainregion` and `load_brainregion()` are deprecated names for `anatomicalregion` and `load_anatomicalregion()`. They still work, with a `FutureWarning`, and load anatomical regions.
170
+
171
+ ### Known limitations
172
+
173
+ - Specimen-era models (specimen sets, specimens, experimental interfaces, material usages, anatomical regions, ...), `popular_name` and region descendants need a server with the specimen release. Older servers answer 404 for those models and 400 for a `popular_name` filter.
174
+ - `offset` must be a multiple of the page size (`limit`, default 100), and a page holds at most 500 records. Use `load_all=True` to get everything.
175
+ - Lists read with `include` show only the related records you may read; see the note on `save` above.
176
+
177
+ ## Exporting projects and sessions
178
+
179
+ `export_project()` returns a whole project as one JSON-serializable dict. It is the same file as the JSON download on the BrainSTEM website (**Project → Download → JSON**). Use it for backups, NWB conversion, external integrations or analysis pipelines. You need read access to the project.
180
+
181
+ ```python
182
+ import json
183
+
184
+ export = client.export_project('<project-uuid>')
185
+
186
+ print(export['export_info']['format_version'], export['export_info']['counts'])
187
+ for record in export['records']['stem.session']:
188
+ print(record['pk'], record['fields']['name'])
189
+
190
+ with open('project_backup.json', 'w') as f:
191
+ json.dump(export, f)
192
+ ```
193
+
194
+ ### Session exports
195
+
196
+ `export_session()` exports one session and everything it touches: the content of one NWB file. You need read access to one of the session's projects.
197
+
198
+ ```python
199
+ export = client.export_session('<session-uuid>')
200
+ ```
201
+
202
+ The file has the same format as a project export, with `export_info['scope'] == 'session'`. It contains:
203
+
204
+ - the session with its data acquisitions, manipulations, behaviors and epochs;
205
+ - the subjects and specimen sets linked to the session, with their full procedure and log history;
206
+ - the session's projects you can read (`export_info['project_ids']`; a session can belong to several);
207
+ - the lab resources, catalog entries and users these records reference.
208
+
209
+ A subject's procedure history is complete, including procedures after the session, so filter by date where that matters.
210
+
211
+ ### Large exports
212
+
213
+ Large exports can take longer than the server's 30-second request limit. For those, pass `background=True`: the server builds the export as a job, the client waits for it and downloads the result, and you also get an email when it is ready.
214
+
215
+ ```python
216
+ export = client.export_project('<project-uuid>', background=True)
217
+ export = client.export_session('<session-uuid>', background=True)
218
+ ```
219
+
220
+ ### Format
221
+
222
+ Both exports have this structure:
223
+
224
+ ```text
225
+ {
226
+ "export_info": {
227
+ "format_version": "2.1",
228
+ "exported_at": "<ISO 8601 UTC timestamp>",
229
+ "scope": "project", # or "session"
230
+ "project_id": "<project uuid>", # null for a session export
231
+ "project_ids": ["<project uuid>", ...],
232
+ "session_id": null, # the session's uuid for a session export
233
+ "lab_resources": "<which lab resources are included>",
234
+ "restricted": {"personal_attributes.datastorage": [{"pk": "<uuid>", "name": "Lab server"}], ...},
235
+ "record_count": 2344,
236
+ "counts": {"stem.project": 1, "stem.subject": 21, ...}
237
+ },
238
+ "records": {
239
+ "stem.project": [{"model": "stem.project", "pk": "<uuid>", "fields": {...}}],
240
+ "stem.subject": [...],
241
+ "stem.session": [...],
242
+ ...
243
+ }
244
+ }
245
+ ```
246
+
247
+ - `records` is keyed by model label (`app_label.model_name`). Every label is always present, with an empty list when there are no such records.
248
+ - Links to other records are primary keys. Many-to-many links are sorted lists of primary keys. Referenced catalog entries (species, brain regions, suppliers, ...) and users (names only) are included in the file.
249
+ - Lab resources (setups, data storages, equipment, protocols, ...) are included when you can read them and the exported records use them. Two people exporting the same project can therefore get different lab resources. Referenced lab resources you cannot read are listed by name in `export_info['restricted']`.
250
+ - A minor format version (2.1, 2.2, ...) only adds labels and keys, so ignore any you don't recognise.
251
+
252
+ Problems raise `ExportError` with a readable message, for example when the project or session doesn't exist or you can't read it, a background job fails, or the server doesn't support exports yet.
253
+
254
+ ## Agent reference
255
+
256
+ The server describes its own API and data model (resources, fields, relationships, JSON schemas, guides) at
257
+ `/reference/`. It is written for AI agents, public, and needs no token:
258
+
259
+ ```python
260
+ print(client.agent_reference()) # index of all pages
261
+ print(client.agent_reference("resources/session.md")) # one resource
262
+ print(client.agent_reference("guides/query-planning.md")) # a guide
263
+ ```
264
+
265
+ ```bash
266
+ brainstem reference rules.md
267
+ ```
268
+
269
+ ## Command-line Interface
270
+
271
+ After installation a `brainstem` command is available in your shell.
272
+
273
+ ### Authentication
274
+ ```bash
275
+ # Authenticate (opens browser) and cache token
276
+ brainstem login
277
+
278
+ # Headless — prints URL + code instead of opening browser
279
+ brainstem login --headless
280
+
281
+ # Connect to a local dev server
282
+ brainstem login --url http://127.0.0.1:8000/
283
+
284
+ # Remove cached token
285
+ brainstem logout
286
+ ```
287
+
288
+ ### Loading data
289
+ ```bash
290
+ # Load all sessions (private portal)
291
+ brainstem load session
292
+
293
+ # Filter, sort and embed related data
294
+ brainstem load session --filters name.icontains=Rat --sort -name --include projects
295
+
296
+ # Load a single record by UUID
297
+ brainstem load session --id <uuid>
298
+
299
+ # Manual pagination (page 2, 20 records per page)
300
+ brainstem load session --per-page 20 --page 2
301
+ brainstem load session --limit 20 --offset 20 # equivalent
302
+
303
+ # Fetch every page and print one merged JSON object
304
+ brainstem load session --all
305
+
306
+ # Public portal
307
+ brainstem load project --portal public
308
+ ```
309
+
310
+ ### Creating and updating records
311
+ ```bash
312
+ # Create a new session
313
+ brainstem save session --data '{"name":"New session","projects":["<uuid>"]}'
314
+
315
+ # Update an existing record
316
+ brainstem save session --id <uuid> --data '{"description":"updated"}'
317
+ ```
318
+
319
+ ### Deleting records
320
+ ```bash
321
+ brainstem delete session --id <uuid>
322
+ ```
323
+
324
+ All subcommands accept `--token`, `--headless`, and `--url` to override defaults (`--token` and `--url` default to `$BRAINSTEM_API_TOKEN` and `$BRAINSTEM_API_URL`). `brainstem login` never prints the token; it reports where it is cached.
325
+
326
+ ## Contributing
327
+ Contributions are welcome! Feel free to open issues or submit pull requests on GitHub.
328
+
329
+ ## License
330
+ This project is licensed under the MIT License. See the `LICENSE` file for more details.
@@ -0,0 +1,9 @@
1
+ brainstem_api_tools/__init__.py,sha256=irr6k2YpKq4f01ev4pFZELoEgpekmjle2sSjPWM4t5s,130
2
+ brainstem_api_tools/brainstem_api_client.py,sha256=OxV0DvfPMbNpf9-s6djlnAw60TaAvbmFYTtFGtzuZDc,52359
3
+ brainstem_api_tools/cli.py,sha256=2-6mM7j508hlBDZU0pDqekVP5beTbc31_tWD_XTtG9M,8886
4
+ brainstem_python_api_tools-2.1.0.dist-info/licenses/LICENSE,sha256=I4iwel7RmYcQggAinCAw6Gxcup59frUNXxFKf9hXCoc,1092
5
+ brainstem_python_api_tools-2.1.0.dist-info/METADATA,sha256=WquaSvbN2AfbW8WziiYOdcX9EiktDMsN9K2Bct3WCOE,13232
6
+ brainstem_python_api_tools-2.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ brainstem_python_api_tools-2.1.0.dist-info/entry_points.txt,sha256=o78cX6aY5foCZ6xskNVWc_Stjxi3dfYn_8l_kszjfZ4,59
8
+ brainstem_python_api_tools-2.1.0.dist-info/top_level.txt,sha256=66OUGJs8UdzQdEqtx32kt84z2x-IpYEHmI1lLGlEwPA,20
9
+ brainstem_python_api_tools-2.1.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5