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.
@@ -1,6 +1,10 @@
1
+ import gzip
2
+ import hashlib
3
+ import json
1
4
  import os
2
5
  import stat
3
6
  import time
7
+ import warnings
4
8
  import webbrowser
5
9
  from enum import Enum
6
10
  from pathlib import Path
@@ -18,68 +22,134 @@ from urllib3.util.retry import Retry
18
22
 
19
23
  _MODEL_TO_APP: dict = {
20
24
  # stem
25
+ "breeding": "stem",
26
+ "cohort": "stem",
27
+ "cohortsubject": "stem",
28
+ "collection": "stem",
29
+ "collectionsession": "stem",
30
+ "epoch": "stem",
21
31
  "project": "stem",
22
- "subject": "stem",
32
+ "projectgroupmembershipinvitation": "stem",
33
+ "projectmembershipinvitation": "stem",
23
34
  "session": "stem",
24
- "collection": "stem",
25
- "cohort": "stem",
26
- "breeding": "stem",
27
- "project_membership_invitation": "stem",
28
- "project_group_membership_invitation": "stem",
35
+ "sessiondatastorage": "stem",
36
+ "specimen": "stem",
37
+ "specimenset": "stem",
38
+ "subject": "stem",
29
39
  # modules
30
- "procedure": "modules",
31
40
  "behavior": "modules",
41
+ "consumablestock": "modules",
32
42
  "dataacquisition": "modules",
33
- "manipulation": "modules",
43
+ "dataacquisitioninterface": "modules",
44
+ "dataacquisitionprocedure": "modules",
45
+ "dataacquisitiontemplate": "modules",
46
+ "episodepersonnel": "modules",
34
47
  "equipment": "modules",
35
- "consumablestock": "modules",
48
+ "experimentalinterface": "modules",
49
+ "experimentalinterfacetemplate": "modules",
50
+ "manipulation": "modules",
51
+ "manipulationinterface": "modules",
52
+ "manipulationtemplate": "modules",
53
+ "materialusage": "modules",
54
+ "materialusagetemplate": "modules",
55
+ "procedure": "modules",
56
+ "procedureepisode": "modules",
57
+ "procedureepisodetemplate": "modules",
36
58
  "procedurelog": "modules",
59
+ "proceduretemplate": "modules",
60
+ "specimenlog": "modules",
61
+ "specimenprocedure": "modules",
62
+ "specimenproceduretemplate": "modules",
37
63
  "subjectlog": "modules",
38
64
  # personal_attributes
39
65
  "behavioralassay": "personal_attributes",
66
+ "behavioralassaygroupmembershipinvitation": "personal_attributes",
67
+ "behavioralassaymembershipinvitation": "personal_attributes",
40
68
  "datastorage": "personal_attributes",
69
+ "datastoragegroupmembershipinvitation": "personal_attributes",
70
+ "datastoragemembershipinvitation": "personal_attributes",
41
71
  "inventory": "personal_attributes",
72
+ "inventorygroupmembershipinvitation": "personal_attributes",
73
+ "inventorymembershipinvitation": "personal_attributes",
42
74
  "license": "personal_attributes",
75
+ "licensegroupmembershipinvitation": "personal_attributes",
76
+ "licensemembershipinvitation": "personal_attributes",
43
77
  "protocol": "personal_attributes",
78
+ "protocolgroupmembershipinvitation": "personal_attributes",
79
+ "protocolmembershipinvitation": "personal_attributes",
44
80
  "setup": "personal_attributes",
81
+ "setupgroupmembershipinvitation": "personal_attributes",
82
+ "setupmembershipinvitation": "personal_attributes",
45
83
  # resources
46
84
  "consumable": "resources",
85
+ "consumableapproval": "resources",
47
86
  "hardwaredevice": "resources",
87
+ "hardwaredeviceapproval": "resources",
48
88
  "supplier": "resources",
89
+ "supplierapproval": "resources",
49
90
  # taxonomies
91
+ "anatomicalreference": "taxonomies",
92
+ "anatomicalregion": "taxonomies",
50
93
  "behavioralcategory": "taxonomies",
94
+ "behavioralcategoryapproval": "taxonomies",
51
95
  "behavioralparadigm": "taxonomies",
52
- "brainregion": "taxonomies",
96
+ "behavioralparadigmapproval": "taxonomies",
97
+ "coordinatetransform": "taxonomies",
53
98
  "regulatoryauthority": "taxonomies",
99
+ "regulatoryauthorityapproval": "taxonomies",
54
100
  "setuptype": "taxonomies",
101
+ "setuptypeapproval": "taxonomies",
55
102
  "species": "taxonomies",
103
+ "speciesapproval": "taxonomies",
56
104
  "strain": "taxonomies",
57
105
  "strainapproval": "taxonomies",
58
106
  # dissemination
59
107
  "journal": "dissemination",
60
108
  "journalapproval": "dissemination",
61
109
  "publication": "dissemination",
110
+ "publicationapproval": "dissemination",
62
111
  # users
112
+ "admingroup": "users",
63
113
  "group": "users",
64
- "group_membership_invitation": "users",
65
- "group_membership_request": "users",
114
+ "groupmembershipinvitation": "users",
115
+ "groupmembershiprequest": "users",
66
116
  "laboratory": "users",
117
+ "pendinginvitation": "users",
67
118
  "user": "users",
68
119
  }
69
120
 
70
121
  _TOKEN_FILE = Path.home() / ".config" / "brainstem" / "token"
71
122
 
123
+ # Deprecated model names, resolved to the server's current name with a warning:
124
+ # the underscored names earlier versions of this client used (they never matched
125
+ # a server route), and "brainregion", renamed "anatomicalregion".
126
+ _MODEL_ALIASES: dict = {
127
+ "brainregion": "anatomicalregion",
128
+ "project_membership_invitation": "projectmembershipinvitation",
129
+ "project_group_membership_invitation": "projectgroupmembershipinvitation",
130
+ "group_membership_invitation": "groupmembershipinvitation",
131
+ "group_membership_request": "groupmembershiprequest",
132
+ }
133
+
72
134
  # Derived from _MODEL_TO_APP so there is exactly one source of truth.
73
- ModelType = Enum("ModelType", {k: k for k in _MODEL_TO_APP}) # type: ignore[misc]
135
+ ModelType = Enum( # type: ignore[misc]
136
+ "ModelType", {k: k for k in [*_MODEL_TO_APP, *_MODEL_ALIASES]}
137
+ )
74
138
 
75
139
  _VALID_PORTALS = {"private", "public", "super"}
76
140
 
77
141
 
78
142
  def _resolve_model(model) -> str:
79
- """Accept a plain string or a ModelType member and return the string value."""
143
+ """Accept a plain string or a ModelType member and return the server's resource name."""
80
144
  if isinstance(model, ModelType):
81
- return model.value
145
+ model = model.value
82
146
  model = str(model)
147
+ if model in _MODEL_ALIASES:
148
+ warnings.warn(
149
+ f"Model name '{model}' is deprecated; use '{_MODEL_ALIASES[model]}'.",
150
+ FutureWarning, stacklevel=3,
151
+ )
152
+ return _MODEL_ALIASES[model]
83
153
  if model not in _MODEL_TO_APP:
84
154
  raise ValueError(
85
155
  f"Unknown model '{model}'. Valid models are: "
@@ -115,6 +185,10 @@ class AuthenticationError(Exception):
115
185
  pass
116
186
 
117
187
 
188
+ class ExportError(Exception):
189
+ """A project export could not be produced or downloaded."""
190
+
191
+
118
192
  # ---------------------------------------------------------------------------
119
193
  # Client
120
194
  # ---------------------------------------------------------------------------
@@ -123,14 +197,34 @@ class BrainstemClient:
123
197
 
124
198
  BASE_URL = "https://www.brainstem.org/"
125
199
  DEFAULT_TIMEOUT: int = 30 # seconds; applied to all HTTP calls
200
+ MAX_PAGE_SIZE: int = 500 # the API returns at most this many records per page
126
201
 
127
202
  def __init__(
128
203
  self,
129
204
  token: str = None,
130
205
  headless: bool = False,
131
206
  url: str = None,
207
+ anonymous: bool = False,
132
208
  ) -> None:
209
+ """Create a client.
210
+
211
+ Parameters
212
+ ----------
213
+ token : API token. Defaults to ``$BRAINSTEM_API_TOKEN``, then the
214
+ cached token, then the browser device authorization flow.
215
+ headless : Print the verification URL and code instead of opening a browser.
216
+ url : Base URL of the server. Defaults to ``$BRAINSTEM_API_URL``,
217
+ then ``https://www.brainstem.org/``.
218
+ anonymous : Send no token and never start a login. Only the public
219
+ portal (``portal='public'``) is readable.
220
+ """
221
+ if url is None:
222
+ url = os.environ.get("BRAINSTEM_API_URL")
223
+ if token is None:
224
+ token = os.environ.get("BRAINSTEM_API_TOKEN")
225
+
133
226
  base = (url.rstrip("/") + "/") if url else self.BASE_URL
227
+ self._base_url = base
134
228
  self._address = base + "api/"
135
229
  self._session = requests.Session()
136
230
 
@@ -139,6 +233,10 @@ class BrainstemClient:
139
233
  self._session.mount("https://", HTTPAdapter(max_retries=_retry))
140
234
  self._session.mount("http://", HTTPAdapter(max_retries=_retry))
141
235
 
236
+ if anonymous:
237
+ self._token = None
238
+ return
239
+
142
240
  if token:
143
241
  self._token = token
144
242
  else:
@@ -209,8 +307,18 @@ class BrainstemClient:
209
307
  json={"device_code": device_code},
210
308
  timeout=10,
211
309
  )
212
- poll.raise_for_status()
213
- result = poll.json()
310
+ # The server answers 400 (invalid_device_code) or 500 (token_error)
311
+ # with a JSON "error" key; report those as authentication failures
312
+ # rather than bare HTTP errors.
313
+ try:
314
+ result = poll.json()
315
+ except ValueError:
316
+ result = {}
317
+ if not poll.ok:
318
+ error = result.get("error") if isinstance(result, dict) else None
319
+ if error:
320
+ raise AuthenticationError(f"Authorization failed: {error}")
321
+ poll.raise_for_status()
214
322
 
215
323
  if result.get("status") == "success":
216
324
  return result["token"]
@@ -260,11 +368,20 @@ class BrainstemClient:
260
368
  Supports ``.icontains``, ``.startswith``, ``.endswith``,
261
369
  ``.gt``, ``.gte``, ``.lt``, ``.lte``.
262
370
  sort : List of fields to sort by. Prefix with ``'-'`` for descending.
263
- include : Related models to embed, e.g. ``['dataacquisition', 'behaviors']``.
264
- limit : Max records per page (API maximum: 100).
265
- offset : Number of records to skip (for pagination).
371
+ include : Related fields to sideload, e.g. ``['dataacquisition', 'behaviors']``.
372
+ Their records are returned as extra lists next to the main
373
+ one, and only records you may read are included. See ``save``
374
+ before writing back a list field read this way.
375
+ limit : Records per page, 1 to 500 (default 100). Sent to the API as
376
+ ``per_page``. With *load_all*, a larger value is reduced to
377
+ 500, which returns the same records in more pages.
378
+ offset : Number of records to skip. The API pages with ``page`` and
379
+ ``per_page``, so *offset* must be a multiple of the page size
380
+ (*limit*, or 100 if *limit* is not set); it is sent as
381
+ ``page = offset // page_size + 1``.
266
382
  load_all : When ``True``, automatically follow pagination and return a
267
- combined ``dict`` with all records merged under the model key.
383
+ combined ``dict`` with all records merged under the model key,
384
+ starting at the page selected by *offset*.
268
385
  When ``False`` (default), returns the raw ``Response``.
269
386
  """
270
387
  model = _resolve_model(model)
@@ -280,54 +397,88 @@ class BrainstemClient:
280
397
  params.setdefault("sort[]", []).append(field)
281
398
  for rel in (include or []):
282
399
  params.setdefault("include[]", []).append(f"{rel}.*")
283
- if limit is not None:
284
- params["limit"] = limit
285
- if offset is not None:
286
- params["offset"] = offset
287
-
288
- if not load_all:
289
- return self._session.get(url, params=params, timeout=self.DEFAULT_TIMEOUT)
290
400
 
291
- if id is not None:
401
+ if load_all and id is not None:
292
402
  raise ValueError("load_all=True cannot be used together with id.")
293
403
 
294
- # --- auto-paginate and merge all pages ---
295
- page_size = limit or 100
296
- params["limit"] = page_size
297
- params.setdefault("offset", offset or 0)
404
+ # The API paginates with page/per_page (it also accepts limit/offset
405
+ # as aliases under the same multiple-of-page-size rule); translate
406
+ # limit/offset here so the request is explicit and validated early.
407
+ page_size = 100 if limit is None else limit
408
+ if load_all:
409
+ page_size = min(page_size, self.MAX_PAGE_SIZE)
410
+ if not id and not 1 <= page_size <= self.MAX_PAGE_SIZE:
411
+ raise ValueError(
412
+ f"limit ({limit}) must be between 1 and {self.MAX_PAGE_SIZE}, "
413
+ "the API's maximum page size."
414
+ )
415
+ if not id and offset and offset % page_size:
416
+ raise ValueError(
417
+ f"offset ({offset}) must be a multiple of limit (the page size, "
418
+ f"{page_size}): the API pages with page/per_page."
419
+ )
420
+ start_page = (offset or 0) // page_size + 1
421
+
422
+ if load_all:
423
+ return self._load_all_pages(url, params, page_size, start_page=start_page)
424
+
425
+ if not id and (limit is not None or offset is not None):
426
+ params["per_page"] = page_size
427
+ params["page"] = start_page
428
+ return self._session.get(url, params=params, timeout=self.DEFAULT_TIMEOUT)
429
+
430
+ def _get_json(self, url: str, params: dict = None) -> dict:
431
+ """GET *url* and return the decoded JSON, raising on HTTP errors."""
432
+ resp = self._session.get(url, params=params, timeout=self.DEFAULT_TIMEOUT)
433
+ if resp.status_code == 401:
434
+ raise AuthenticationError(
435
+ "API token is invalid or expired. Run `brainstem login` to re-authenticate."
436
+ )
437
+ resp.raise_for_status()
438
+ return resp.json()
439
+
440
+ def _load_all_pages(self, url: str, params: dict,
441
+ page_size: int = 100, start_page: int = 1) -> dict:
442
+ """Follow pagination and merge every page into a single dict.
298
443
 
444
+ All list-valued keys are concatenated across pages: the primary
445
+ records as well as any sideloaded related records (de-duplicated by
446
+ ``id``). Non-list keys are taken from the first page.
447
+ """
448
+ params = dict(params, per_page=page_size)
449
+ page = start_page
299
450
  combined: dict = {}
300
- records_key: str = None
451
+ seen_ids: dict = {}
301
452
 
302
453
  while True:
303
- resp = self._session.get(url, params=params, timeout=self.DEFAULT_TIMEOUT)
304
- if resp.status_code == 401:
305
- raise AuthenticationError(
306
- "API token is invalid or expired. Run `brainstem login` to re-authenticate."
307
- )
308
- resp.raise_for_status()
309
- data = resp.json()
454
+ data = self._get_json(url, dict(params, page=page))
455
+ lists = {k: v for k, v in data.items() if isinstance(v, list)}
310
456
 
311
- if records_key is None:
312
- # Detect the list key (e.g. 'sessions', 'projects')
313
- records_key = next(
314
- (k for k, v in data.items() if isinstance(v, list)), None
315
- )
316
- if records_key is None:
457
+ if not combined:
458
+ if not lists:
317
459
  raise ValueError(
318
460
  "load_all=True requires a paginated list response, but the API "
319
461
  "returned no list-valued key. Use load_all=False for single-object endpoints."
320
462
  )
321
- combined = {k: v for k, v in data.items() if k != records_key}
322
- combined[records_key] = []
323
-
324
- combined[records_key].extend(data.get(records_key, []))
325
- total = data.get("count", len(combined[records_key]))
326
-
327
- if len(combined[records_key]) >= total:
463
+ combined = {k: v for k, v in data.items() if k not in lists}
464
+
465
+ for key, records in lists.items():
466
+ bucket = combined.setdefault(key, [])
467
+ ids = seen_ids.setdefault(key, set())
468
+ for record in records:
469
+ rid = record.get("id") if isinstance(record, dict) else None
470
+ if rid is not None:
471
+ if rid in ids:
472
+ continue
473
+ ids.add(rid)
474
+ bucket.append(record)
475
+
476
+ total_pages = (data.get("meta") or {}).get("total_pages")
477
+ if total_pages is None and "count" in data:
478
+ total_pages = -(-data["count"] // page_size) # ceil division
479
+ if total_pages is None or page >= total_pages or not any(lists.values()):
328
480
  break
329
-
330
- params["offset"] = params.get("offset", 0) + page_size
481
+ page += 1
331
482
 
332
483
  return combined
333
484
 
@@ -342,6 +493,14 @@ class BrainstemClient:
342
493
  When *id* is provided the record is **updated** (PATCH).
343
494
  When *id* is omitted a **new record** is created (POST).
344
495
 
496
+ .. warning::
497
+ Sending a list field (``projects``, ``subjects``, ``behaviors``, ...)
498
+ replaces the whole list. A list read with ``include`` (including the
499
+ convenience loaders' default includes) holds only the records you may
500
+ read, so writing it back drops links to records you cannot see. Read
501
+ the record without ``include`` first, e.g.
502
+ ``client.load('session', id=uid).json()``, and change that list.
503
+
345
504
  Parameters
346
505
  ----------
347
506
  model : Model name string or ``ModelType`` member, e.g. ``'session'``.
@@ -382,6 +541,159 @@ class BrainstemClient:
382
541
  url = self._build_url(portal, app, model, id)
383
542
  return self._session.delete(url, timeout=self.DEFAULT_TIMEOUT)
384
543
 
544
+ # ------------------------------------------------------------------
545
+ # Export
546
+ # ------------------------------------------------------------------
547
+
548
+ EXPORT_TIMEOUT: int = 90 # seconds; the server builds the whole export per request
549
+
550
+ def export_project(self, project_id: str, background: bool = False,
551
+ poll_interval: float = 5.0, timeout: float = 3600) -> dict:
552
+ """Export a project as the server's versioned JSON export.
553
+
554
+ Returns the same payload as the JSON download on the BrainSTEM website
555
+ (format documented in the server's ``docs/project-export-format.md``)::
556
+
557
+ {
558
+ "export_info": {"format_version", "exported_at", "scope",
559
+ "project_id", "project_ids", "session_id",
560
+ "lab_resources", "restricted", "record_count",
561
+ "counts"},
562
+ "records": {"stem.project": [...], "stem.subject": [...], ...}
563
+ }
564
+
565
+ Each record has the shape ``{"model", "pk", "fields"}``. Exporting
566
+ requires read access to the project.
567
+
568
+ Parameters
569
+ ----------
570
+ project_id : UUID of the project to export.
571
+ background : When ``True``, queue the export as a server-side job,
572
+ wait for it to finish and download the result. Use this
573
+ for large projects, which can exceed the server's
574
+ 30-second request limit. The server also emails you
575
+ when the job finishes.
576
+ poll_interval : Seconds between job status checks (background only).
577
+ timeout : Maximum seconds to wait for the job (background only).
578
+ """
579
+ return self._export("project", project_id, background, poll_interval, timeout)
580
+
581
+ def export_session(self, session_id: str, background: bool = False,
582
+ poll_interval: float = 5.0, timeout: float = 3600) -> dict:
583
+ """Export one session and everything it touches: the content of one NWB file.
584
+
585
+ Returns the same format as ``export_project`` (``export_info.scope`` is
586
+ ``"session"``), holding the session with its data acquisitions,
587
+ manipulations, behaviors and epochs; the subjects and specimen sets
588
+ linked to it with their full procedure and log history; the session's
589
+ projects; and the lab resources, catalog entries and users these
590
+ reference. Only records you can read are included, in any of the
591
+ session's projects.
592
+
593
+ A subject's procedure history is complete, including procedures after
594
+ the session, so filter by date where that matters. Exporting requires
595
+ read access to one of the session's projects.
596
+
597
+ Parameters are the same as for ``export_project``, with
598
+ ``session_id`` the UUID of the session to export.
599
+ """
600
+ return self._export("session", session_id, background, poll_interval, timeout)
601
+
602
+ def _export(self, model: str, record_id: str, background: bool,
603
+ poll_interval: float, timeout: float) -> dict:
604
+ """Run a direct or background export of a project or session record."""
605
+ if not record_id:
606
+ raise ValueError(f"'{model}_id' is required to export a {model}.")
607
+ url = self._build_url("private", "stem", model, record_id, "export/")
608
+
609
+ if not background:
610
+ # Bypass the session's automatic retries: a timed-out export would be
611
+ # rebuilt up to three more times, each counting against the export rate limit.
612
+ resp = requests.get(url, headers=self._session.headers, timeout=self.EXPORT_TIMEOUT)
613
+ if resp.status_code in (503, 504):
614
+ raise ExportError(
615
+ "The export did not finish within the server's request time limit. "
616
+ f"Use export_{model}(..., background=True) for large exports."
617
+ )
618
+ self._check_export_response(resp, model)
619
+ return resp.json()
620
+
621
+ resp = self._session.post(url, timeout=self.DEFAULT_TIMEOUT)
622
+ self._check_export_response(resp, model)
623
+ job = resp.json()["export_job"]
624
+ job_url = self._build_url("private", "stem", model, record_id,
625
+ f"export-jobs/{job['id']}/")
626
+
627
+ deadline = time.monotonic() + timeout
628
+ while job["status"] in ("pending", "running"):
629
+ if time.monotonic() > deadline:
630
+ raise ExportError(
631
+ f"Export job {job['id']} did not finish within {timeout} seconds "
632
+ f"(status: {job['status']}, {job.get('progress_percent', 0)}%)."
633
+ )
634
+ time.sleep(poll_interval)
635
+ job = self._get_json(job_url)["export_job"]
636
+
637
+ if job["status"] != "completed":
638
+ raise ExportError(job.get("error_message") or f"Export job ended as '{job['status']}'.")
639
+
640
+ resp = self._session.get(self._base_url + job["download_url"].lstrip("/"),
641
+ timeout=self.EXPORT_TIMEOUT)
642
+ self._check_export_response(resp, model)
643
+ content = resp.content
644
+ if content[:2] == b"\x1f\x8b": # gzip magic number
645
+ expected = job.get("file_sha256")
646
+ if expected and hashlib.sha256(content).hexdigest() != expected:
647
+ raise ExportError(f"Export job {job['id']}: downloaded file failed its checksum.")
648
+ content = gzip.decompress(content)
649
+ return json.loads(content)
650
+
651
+ def _check_export_response(self, resp: Response, model: str) -> None:
652
+ """Turn export endpoint errors into clear exceptions."""
653
+ if resp.ok:
654
+ return
655
+ if resp.status_code == 401:
656
+ raise AuthenticationError(
657
+ "API token is invalid or expired. Run `brainstem login` to re-authenticate."
658
+ )
659
+ is_json = "json" in resp.headers.get("Content-Type", "")
660
+ if resp.status_code == 404:
661
+ if not is_json:
662
+ raise ExportError(f"This BrainSTEM server does not support {model} export yet.")
663
+ # The API answers 404, not 403, when the record exists but you cannot read it.
664
+ raise ExportError(f"The {model} was not found, or you do not have read access to it.")
665
+ if resp.status_code == 403:
666
+ raise ExportError(f"You do not have permission to export this {model}.")
667
+ if resp.status_code in (400, 409, 410, 429) and is_json:
668
+ raise ExportError(resp.json().get("detail", resp.reason))
669
+ resp.raise_for_status()
670
+
671
+ # ------------------------------------------------------------------
672
+ # Agent reference
673
+ # ------------------------------------------------------------------
674
+
675
+ def agent_reference(self, page: str = "index.md") -> str:
676
+ """Return one page of the server's agent reference as text.
677
+
678
+ The server describes its own API and data model at ``/reference/`` (Markdown
679
+ pages such as ``rules.md``, ``resources/session.md`` or
680
+ ``guides/query-planning.md``, and JSON documents under ``data/``). It is
681
+ public, so this works with an anonymous client too.
682
+
683
+ Parameters
684
+ ----------
685
+ page : Page path relative to the reference root. Defaults to the index.
686
+ """
687
+ url = self._base_url + "reference/" + page.lstrip("/")
688
+ resp = self._session.get(url, timeout=self.DEFAULT_TIMEOUT)
689
+ if resp.status_code == 404:
690
+ raise ValueError(
691
+ f"No agent reference page '{page}' on this server. The page does not exist, "
692
+ "or the server predates the agent reference."
693
+ )
694
+ resp.raise_for_status()
695
+ return resp.text
696
+
385
697
  def __enter__(self):
386
698
  return self
387
699
 
@@ -431,13 +743,13 @@ class BrainstemClient:
431
743
  name : Filter by project name (case-insensitive contains).
432
744
  sessions : Filter by session UUID.
433
745
  subjects : Filter by subject UUID.
434
- tags : Filter by tag.
746
+ tags : Filter by tag name (sent as ``filter{tags.name}``; records return tags as names).
435
747
  """
436
748
  return self._convenience_load(
437
749
  "project",
438
750
  default_include=["sessions", "subjects", "collections", "cohorts"],
439
- filter_map={"name": "name.icontains", "sessions": "sessions.id",
440
- "subjects": "subjects.id", "tags": "tags"},
751
+ filter_map={"name": "name.icontains", "sessions": "sessions",
752
+ "subjects": "subjects", "tags": "tags.name"},
441
753
  portal=portal, id=id, filters=filters, sort=sort, include=include,
442
754
  limit=limit, offset=offset, load_all=load_all,
443
755
  name=name, sessions=sessions, subjects=subjects, tags=tags,
@@ -457,13 +769,13 @@ class BrainstemClient:
457
769
  projects : Filter by project UUID.
458
770
  strain : Filter by strain UUID.
459
771
  sex : Filter by sex (``'M'``, ``'F'``, or ``'U'``).
460
- tags : Filter by tag.
772
+ tags : Filter by tag name (sent as ``filter{tags.name}``; records return tags as names).
461
773
  """
462
774
  return self._convenience_load(
463
775
  "subject",
464
776
  default_include=["procedures", "subjectlogs"],
465
- filter_map={"name": "name.icontains", "projects": "projects.id",
466
- "strain": "strain.id", "sex": "sex", "tags": "tags"},
777
+ filter_map={"name": "name.icontains", "projects": "projects",
778
+ "strain": "strain", "sex": "sex", "tags": "tags.name"},
467
779
  portal=portal, id=id, filters=filters, sort=sort, include=include,
468
780
  limit=limit, offset=offset, load_all=load_all,
469
781
  name=name, projects=projects, strain=strain, sex=sex, tags=tags,
@@ -482,13 +794,13 @@ class BrainstemClient:
482
794
  name : Filter by session name (case-insensitive contains).
483
795
  projects : Filter by project UUID.
484
796
  datastorage : Filter by data storage UUID.
485
- tags : Filter by tag.
797
+ tags : Filter by tag name (sent as ``filter{tags.name}``; records return tags as names).
486
798
  """
487
799
  return self._convenience_load(
488
800
  "session",
489
801
  default_include=["dataacquisition", "behaviors", "manipulations", "epochs"],
490
- filter_map={"name": "name.icontains", "projects": "projects.id",
491
- "datastorage": "datastorage.id", "tags": "tags"},
802
+ filter_map={"name": "name.icontains", "projects": "projects",
803
+ "datastorage": "datastorage", "tags": "tags.name"},
492
804
  portal=portal, id=id, filters=filters, sort=sort, include=include,
493
805
  limit=limit, offset=offset, load_all=load_all,
494
806
  name=name, projects=projects, datastorage=datastorage, tags=tags,
@@ -504,12 +816,12 @@ class BrainstemClient:
504
816
  Parameters
505
817
  ----------
506
818
  name : Filter by collection name (case-insensitive contains).
507
- tags : Filter by tag.
819
+ tags : Filter by tag name (sent as ``filter{tags.name}``; records return tags as names).
508
820
  """
509
821
  return self._convenience_load(
510
822
  "collection",
511
823
  default_include=["sessions"],
512
- filter_map={"name": "name.icontains", "tags": "tags"},
824
+ filter_map={"name": "name.icontains", "tags": "tags.name"},
513
825
  portal=portal, id=id, filters=filters, sort=sort, include=include,
514
826
  limit=limit, offset=offset, load_all=load_all,
515
827
  name=name, tags=tags,
@@ -525,19 +837,19 @@ class BrainstemClient:
525
837
  Parameters
526
838
  ----------
527
839
  name : Filter by cohort name (case-insensitive contains).
528
- tags : Filter by tag.
840
+ tags : Filter by tag name (sent as ``filter{tags.name}``; records return tags as names).
529
841
  """
530
842
  return self._convenience_load(
531
843
  "cohort",
532
844
  default_include=["subjects"],
533
- filter_map={"name": "name.icontains", "tags": "tags"},
845
+ filter_map={"name": "name.icontains", "tags": "tags.name"},
534
846
  portal=portal, id=id, filters=filters, sort=sort, include=include,
535
847
  limit=limit, offset=offset, load_all=load_all,
536
848
  name=name, tags=tags,
537
849
  )
538
850
 
539
851
  def load_behavior(self, portal="private", id: str = None,
540
- session: str = None, tags: str = None,
852
+ session: str = None,
541
853
  filters: dict = None, sort: list = None,
542
854
  include: list = None, limit: int = None,
543
855
  offset: int = None, load_all: bool = False):
@@ -546,19 +858,18 @@ class BrainstemClient:
546
858
  Parameters
547
859
  ----------
548
860
  session : Filter by session UUID.
549
- tags : Filter by tag.
550
861
  """
551
862
  return self._convenience_load(
552
863
  "behavior",
553
864
  default_include=[],
554
- filter_map={"session": "session.id", "tags": "tags"},
865
+ filter_map={"session": "session"},
555
866
  portal=portal, id=id, filters=filters, sort=sort, include=include,
556
867
  limit=limit, offset=offset, load_all=load_all,
557
- session=session, tags=tags,
868
+ session=session,
558
869
  )
559
870
 
560
871
  def load_dataacquisition(self, portal="private", id: str = None,
561
- session: str = None, tags: str = None,
872
+ session: str = None,
562
873
  filters: dict = None, sort: list = None,
563
874
  include: list = None, limit: int = None,
564
875
  offset: int = None, load_all: bool = False):
@@ -567,19 +878,18 @@ class BrainstemClient:
567
878
  Parameters
568
879
  ----------
569
880
  session : Filter by session UUID.
570
- tags : Filter by tag.
571
881
  """
572
882
  return self._convenience_load(
573
883
  "dataacquisition",
574
884
  default_include=[],
575
- filter_map={"session": "session.id", "tags": "tags"},
885
+ filter_map={"session": "session"},
576
886
  portal=portal, id=id, filters=filters, sort=sort, include=include,
577
887
  limit=limit, offset=offset, load_all=load_all,
578
- session=session, tags=tags,
888
+ session=session,
579
889
  )
580
890
 
581
891
  def load_manipulation(self, portal="private", id: str = None,
582
- session: str = None, tags: str = None,
892
+ session: str = None,
583
893
  filters: dict = None, sort: list = None,
584
894
  include: list = None, limit: int = None,
585
895
  offset: int = None, load_all: bool = False):
@@ -588,19 +898,18 @@ class BrainstemClient:
588
898
  Parameters
589
899
  ----------
590
900
  session : Filter by session UUID.
591
- tags : Filter by tag.
592
901
  """
593
902
  return self._convenience_load(
594
903
  "manipulation",
595
904
  default_include=[],
596
- filter_map={"session": "session.id", "tags": "tags"},
905
+ filter_map={"session": "session"},
597
906
  portal=portal, id=id, filters=filters, sort=sort, include=include,
598
907
  limit=limit, offset=offset, load_all=load_all,
599
- session=session, tags=tags,
908
+ session=session,
600
909
  )
601
910
 
602
911
  def load_procedure(self, portal="private", id: str = None,
603
- subject: str = None, tags: str = None,
912
+ subject: str = None,
604
913
  filters: dict = None, sort: list = None,
605
914
  include: list = None, limit: int = None,
606
915
  offset: int = None, load_all: bool = False):
@@ -609,19 +918,18 @@ class BrainstemClient:
609
918
  Parameters
610
919
  ----------
611
920
  subject : Filter by subject UUID.
612
- tags : Filter by tag.
613
921
  """
614
922
  return self._convenience_load(
615
923
  "procedure",
616
924
  default_include=[],
617
- filter_map={"subject": "subject.id", "tags": "tags"},
925
+ filter_map={"subject": "subject"},
618
926
  portal=portal, id=id, filters=filters, sort=sort, include=include,
619
927
  limit=limit, offset=offset, load_all=load_all,
620
- subject=subject, tags=tags,
928
+ subject=subject,
621
929
  )
622
930
 
623
931
  def load_procedurelog(self, portal="private", id: str = None,
624
- procedure: str = None, tags: str = None,
932
+ procedure: str = None,
625
933
  filters: dict = None, sort: list = None,
626
934
  include: list = None, limit: int = None,
627
935
  offset: int = None, load_all: bool = False):
@@ -630,19 +938,18 @@ class BrainstemClient:
630
938
  Parameters
631
939
  ----------
632
940
  procedure : Filter by procedure UUID.
633
- tags : Filter by tag.
634
941
  """
635
942
  return self._convenience_load(
636
943
  "procedurelog",
637
944
  default_include=[],
638
- filter_map={"procedure": "procedure.id", "tags": "tags"},
945
+ filter_map={"procedure": "procedure"},
639
946
  portal=portal, id=id, filters=filters, sort=sort, include=include,
640
947
  limit=limit, offset=offset, load_all=load_all,
641
- procedure=procedure, tags=tags,
948
+ procedure=procedure,
642
949
  )
643
950
 
644
951
  def load_subjectlog(self, portal="private", id: str = None,
645
- subject: str = None, tags: str = None,
952
+ subject: str = None,
646
953
  filters: dict = None, sort: list = None,
647
954
  include: list = None, limit: int = None,
648
955
  offset: int = None, load_all: bool = False):
@@ -651,19 +958,18 @@ class BrainstemClient:
651
958
  Parameters
652
959
  ----------
653
960
  subject : Filter by subject UUID.
654
- tags : Filter by tag.
655
961
  """
656
962
  return self._convenience_load(
657
963
  "subjectlog",
658
964
  default_include=[],
659
- filter_map={"subject": "subject.id", "tags": "tags"},
965
+ filter_map={"subject": "subject"},
660
966
  portal=portal, id=id, filters=filters, sort=sort, include=include,
661
967
  limit=limit, offset=offset, load_all=load_all,
662
- subject=subject, tags=tags,
968
+ subject=subject,
663
969
  )
664
970
 
665
971
  def load_equipment(self, portal="private", id: str = None,
666
- name: str = None, setup: str = None, tags: str = None,
972
+ name: str = None, setup: str = None,
667
973
  filters: dict = None, sort: list = None,
668
974
  include: list = None, limit: int = None,
669
975
  offset: int = None, load_all: bool = False):
@@ -673,19 +979,18 @@ class BrainstemClient:
673
979
  ----------
674
980
  name : Filter by equipment name (case-insensitive contains).
675
981
  setup : Filter by setup UUID.
676
- tags : Filter by tag.
677
982
  """
678
983
  return self._convenience_load(
679
984
  "equipment",
680
985
  default_include=[],
681
- filter_map={"name": "name.icontains", "setup": "setup.id", "tags": "tags"},
986
+ filter_map={"name": "name.icontains", "setup": "setup"},
682
987
  portal=portal, id=id, filters=filters, sort=sort, include=include,
683
988
  limit=limit, offset=offset, load_all=load_all,
684
- name=name, setup=setup, tags=tags,
989
+ name=name, setup=setup,
685
990
  )
686
991
 
687
992
  def load_consumablestock(self, portal="private", id: str = None,
688
- tags: str = None,
993
+ inventory: str = None,
689
994
  filters: dict = None, sort: list = None,
690
995
  include: list = None, limit: int = None,
691
996
  offset: int = None, load_all: bool = False):
@@ -693,19 +998,19 @@ class BrainstemClient:
693
998
 
694
999
  Parameters
695
1000
  ----------
696
- tags : Filter by tag.
1001
+ inventory : Filter by inventory UUID.
697
1002
  """
698
1003
  return self._convenience_load(
699
1004
  "consumablestock",
700
1005
  default_include=[],
701
- filter_map={"tags": "tags"},
1006
+ filter_map={"inventory": "inventory"},
702
1007
  portal=portal, id=id, filters=filters, sort=sort, include=include,
703
1008
  limit=limit, offset=offset, load_all=load_all,
704
- tags=tags,
1009
+ inventory=inventory,
705
1010
  )
706
1011
 
707
1012
  def load_behavioralassay(self, portal="private", id: str = None,
708
- name: str = None, tags: str = None,
1013
+ name: str = None,
709
1014
  filters: dict = None, sort: list = None,
710
1015
  include: list = None, limit: int = None,
711
1016
  offset: int = None, load_all: bool = False):
@@ -714,19 +1019,18 @@ class BrainstemClient:
714
1019
  Parameters
715
1020
  ----------
716
1021
  name : Filter by name (case-insensitive contains).
717
- tags : Filter by tag.
718
1022
  """
719
1023
  return self._convenience_load(
720
1024
  "behavioralassay",
721
1025
  default_include=[],
722
- filter_map={"name": "name.icontains", "tags": "tags"},
1026
+ filter_map={"name": "name.icontains"},
723
1027
  portal=portal, id=id, filters=filters, sort=sort, include=include,
724
1028
  limit=limit, offset=offset, load_all=load_all,
725
- name=name, tags=tags,
1029
+ name=name,
726
1030
  )
727
1031
 
728
1032
  def load_datastorage(self, portal="private", id: str = None,
729
- name: str = None, tags: str = None,
1033
+ name: str = None,
730
1034
  filters: dict = None, sort: list = None,
731
1035
  include: list = None, limit: int = None,
732
1036
  offset: int = None, load_all: bool = False):
@@ -735,19 +1039,18 @@ class BrainstemClient:
735
1039
  Parameters
736
1040
  ----------
737
1041
  name : Filter by name (case-insensitive contains).
738
- tags : Filter by tag.
739
1042
  """
740
1043
  return self._convenience_load(
741
1044
  "datastorage",
742
1045
  default_include=[],
743
- filter_map={"name": "name.icontains", "tags": "tags"},
1046
+ filter_map={"name": "name.icontains"},
744
1047
  portal=portal, id=id, filters=filters, sort=sort, include=include,
745
1048
  limit=limit, offset=offset, load_all=load_all,
746
- name=name, tags=tags,
1049
+ name=name,
747
1050
  )
748
1051
 
749
1052
  def load_setup(self, portal="private", id: str = None,
750
- name: str = None, tags: str = None,
1053
+ name: str = None,
751
1054
  filters: dict = None, sort: list = None,
752
1055
  include: list = None, limit: int = None,
753
1056
  offset: int = None, load_all: bool = False):
@@ -756,19 +1059,18 @@ class BrainstemClient:
756
1059
  Parameters
757
1060
  ----------
758
1061
  name : Filter by name (case-insensitive contains).
759
- tags : Filter by tag.
760
1062
  """
761
1063
  return self._convenience_load(
762
1064
  "setup",
763
1065
  default_include=[],
764
- filter_map={"name": "name.icontains", "tags": "tags"},
1066
+ filter_map={"name": "name.icontains"},
765
1067
  portal=portal, id=id, filters=filters, sort=sort, include=include,
766
1068
  limit=limit, offset=offset, load_all=load_all,
767
- name=name, tags=tags,
1069
+ name=name,
768
1070
  )
769
1071
 
770
1072
  def load_hardwaredevice(self, portal="private", id: str = None,
771
- name: str = None, tags: str = None,
1073
+ name: str = None,
772
1074
  filters: dict = None, sort: list = None,
773
1075
  include: list = None, limit: int = None,
774
1076
  offset: int = None, load_all: bool = False):
@@ -777,83 +1079,128 @@ class BrainstemClient:
777
1079
  Parameters
778
1080
  ----------
779
1081
  name : Filter by name (case-insensitive contains).
780
- tags : Filter by tag.
781
1082
  """
782
1083
  return self._convenience_load(
783
1084
  "hardwaredevice",
784
1085
  default_include=[],
785
- filter_map={"name": "name.icontains", "tags": "tags"},
1086
+ filter_map={"name": "name.icontains"},
786
1087
  portal=portal, id=id, filters=filters, sort=sort, include=include,
787
1088
  limit=limit, offset=offset, load_all=load_all,
788
- name=name, tags=tags,
1089
+ name=name,
789
1090
  )
790
1091
 
791
1092
  def load_brainregion(self, portal="private", id: str = None,
792
- name: str = None, tags: str = None,
1093
+ name: str = None,
793
1094
  filters: dict = None, sort: list = None,
794
1095
  include: list = None, limit: int = None,
795
1096
  offset: int = None, load_all: bool = False):
796
- """Load brain region record(s).
1097
+ """Deprecated: use ``load_anatomicalregion``, which this calls.
1098
+
1099
+ Brain regions are ``anatomicalregion`` records; the response holds them
1100
+ under ``anatomical_regions``.
797
1101
 
798
1102
  Parameters
799
1103
  ----------
800
1104
  name : Filter by name (case-insensitive contains).
801
- tags : Filter by tag.
1105
+ """
1106
+ warnings.warn(
1107
+ "load_brainregion() is deprecated; use load_anatomicalregion().",
1108
+ FutureWarning, stacklevel=2,
1109
+ )
1110
+ return self.load_anatomicalregion(
1111
+ portal=portal, id=id, name=name, filters=filters, sort=sort,
1112
+ include=include, limit=limit, offset=offset, load_all=load_all,
1113
+ )
1114
+
1115
+ def load_anatomicalregion(self, portal="private", id: str = None,
1116
+ name: str = None, acronym: str = None,
1117
+ reference: str = None,
1118
+ filters: dict = None, sort: list = None,
1119
+ include: list = None, limit: int = None,
1120
+ offset: int = None, load_all: bool = False):
1121
+ """Load anatomical (brain) region record(s).
1122
+
1123
+ Parameters
1124
+ ----------
1125
+ name : Filter by name (case-insensitive contains).
1126
+ acronym : Filter by acronym (case-insensitive exact match), e.g. ``'CA1'``.
1127
+ reference : Filter by anatomical reference (atlas) UUID.
802
1128
  """
803
1129
  return self._convenience_load(
804
- "brainregion",
1130
+ "anatomicalregion",
805
1131
  default_include=[],
806
- filter_map={"name": "name.icontains", "tags": "tags"},
1132
+ filter_map={"name": "name.icontains", "acronym": "acronym.iexact",
1133
+ "reference": "reference"},
807
1134
  portal=portal, id=id, filters=filters, sort=sort, include=include,
808
1135
  limit=limit, offset=offset, load_all=load_all,
809
- name=name, tags=tags,
1136
+ name=name, acronym=acronym, reference=reference,
810
1137
  )
811
1138
 
1139
+ def load_anatomicalregion_descendants(self, id: str, portal="private") -> Response:
1140
+ """Load every region below anatomical region *id* in its atlas.
1141
+
1142
+ Returns the raw ``Response``; its JSON is
1143
+ ``{"anatomical_regions": [{"id", "name", "acronym", "parent_acronym",
1144
+ "is_obsolete", "depth"}, ...]}``, ordered by depth (1 = direct child).
1145
+ Atlases without a hierarchy return an empty list.
1146
+ """
1147
+ if not id:
1148
+ raise ValueError("'id' is required to load the descendants of a region.")
1149
+ url = self._build_url(_resolve_portal(portal), "taxonomies", "anatomicalregion",
1150
+ id, "descendants/")
1151
+ return self._session.get(url, timeout=self.DEFAULT_TIMEOUT)
1152
+
812
1153
  def load_species(self, portal="private", id: str = None,
813
- name: str = None, tags: str = None,
1154
+ name: str = None,
814
1155
  filters: dict = None, sort: list = None,
815
1156
  include: list = None, limit: int = None,
816
- offset: int = None, load_all: bool = False):
1157
+ offset: int = None, load_all: bool = False,
1158
+ popular_name: str = None):
817
1159
  """Load species record(s).
818
1160
 
819
1161
  Parameters
820
1162
  ----------
821
- name : Filter by name (case-insensitive contains).
822
- tags : Filter by tag.
1163
+ name : Filter by scientific name (case-insensitive contains).
1164
+ popular_name : Filter by common name (case-insensitive contains), e.g.
1165
+ ``'mouse'``. Needs a server with the specimen release.
823
1166
  """
824
1167
  return self._convenience_load(
825
1168
  "species",
826
1169
  default_include=[],
827
- filter_map={"name": "name.icontains", "tags": "tags"},
1170
+ filter_map={"name": "name.icontains",
1171
+ "popular_name": "popular_name.icontains"},
828
1172
  portal=portal, id=id, filters=filters, sort=sort, include=include,
829
1173
  limit=limit, offset=offset, load_all=load_all,
830
- name=name, tags=tags,
1174
+ name=name, popular_name=popular_name,
831
1175
  )
832
1176
 
833
1177
  def load_strain(self, portal="private", id: str = None,
834
- name: str = None, species: str = None, tags: str = None,
1178
+ name: str = None, species: str = None,
835
1179
  filters: dict = None, sort: list = None,
836
1180
  include: list = None, limit: int = None,
837
- offset: int = None, load_all: bool = False):
1181
+ offset: int = None, load_all: bool = False,
1182
+ popular_name: str = None):
838
1183
  """Load strain record(s).
839
1184
 
840
1185
  Parameters
841
1186
  ----------
842
- name : Filter by name (case-insensitive contains).
843
- species : Filter by species UUID.
844
- tags : Filter by tag.
1187
+ name : Filter by name (case-insensitive contains).
1188
+ species : Filter by species UUID.
1189
+ popular_name : Filter by common name (case-insensitive contains).
1190
+ Needs a server with the specimen release.
845
1191
  """
846
1192
  return self._convenience_load(
847
1193
  "strain",
848
1194
  default_include=[],
849
- filter_map={"name": "name.icontains", "species": "species.id", "tags": "tags"},
1195
+ filter_map={"name": "name.icontains", "species": "species",
1196
+ "popular_name": "popular_name.icontains"},
850
1197
  portal=portal, id=id, filters=filters, sort=sort, include=include,
851
1198
  limit=limit, offset=offset, load_all=load_all,
852
- name=name, species=species, tags=tags,
1199
+ name=name, species=species, popular_name=popular_name,
853
1200
  )
854
1201
 
855
1202
  def load_publication(self, portal="private", id: str = None,
856
- name: str = None, tags: str = None,
1203
+ name: str = None,
857
1204
  filters: dict = None, sort: list = None,
858
1205
  include: list = None, limit: int = None,
859
1206
  offset: int = None, load_all: bool = False):
@@ -862,19 +1209,18 @@ class BrainstemClient:
862
1209
  Parameters
863
1210
  ----------
864
1211
  name : Filter by name (case-insensitive contains).
865
- tags : Filter by tag.
866
1212
  """
867
1213
  return self._convenience_load(
868
1214
  "publication",
869
1215
  default_include=[],
870
- filter_map={"name": "name.icontains", "tags": "tags"},
1216
+ filter_map={"name": "name.icontains"},
871
1217
  portal=portal, id=id, filters=filters, sort=sort, include=include,
872
1218
  limit=limit, offset=offset, load_all=load_all,
873
- name=name, tags=tags,
1219
+ name=name,
874
1220
  )
875
1221
 
876
1222
  def load_laboratory(self, portal="private", id: str = None,
877
- name: str = None, tags: str = None,
1223
+ name: str = None,
878
1224
  filters: dict = None, sort: list = None,
879
1225
  include: list = None, limit: int = None,
880
1226
  offset: int = None, load_all: bool = False):
@@ -883,13 +1229,12 @@ class BrainstemClient:
883
1229
  Parameters
884
1230
  ----------
885
1231
  name : Filter by name (case-insensitive contains).
886
- tags : Filter by tag.
887
1232
  """
888
1233
  return self._convenience_load(
889
1234
  "laboratory",
890
1235
  default_include=[],
891
- filter_map={"name": "name.icontains", "tags": "tags"},
1236
+ filter_map={"name": "name.icontains"},
892
1237
  portal=portal, id=id, filters=filters, sort=sort, include=include,
893
1238
  limit=limit, offset=offset, load_all=load_all,
894
- name=name, tags=tags,
1239
+ name=name,
895
1240
  )