brainstem-python-api-tools 0.2.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,43 +1,174 @@
1
+ import gzip
2
+ import hashlib
3
+ import json
1
4
  import os
2
- from getpass import getpass
5
+ import stat
6
+ import time
7
+ import warnings
8
+ import webbrowser
9
+ from enum import Enum
10
+ from pathlib import Path
11
+ from typing import Union
12
+
3
13
  import requests
14
+ from requests.adapters import HTTPAdapter
4
15
  from requests.models import Response
5
- import json
6
- from enum import Enum
16
+ from urllib3.util.retry import Retry
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Model → app routing table (single source of truth)
21
+ # ---------------------------------------------------------------------------
22
+
23
+ _MODEL_TO_APP: dict = {
24
+ # stem
25
+ "breeding": "stem",
26
+ "cohort": "stem",
27
+ "cohortsubject": "stem",
28
+ "collection": "stem",
29
+ "collectionsession": "stem",
30
+ "epoch": "stem",
31
+ "project": "stem",
32
+ "projectgroupmembershipinvitation": "stem",
33
+ "projectmembershipinvitation": "stem",
34
+ "session": "stem",
35
+ "sessiondatastorage": "stem",
36
+ "specimen": "stem",
37
+ "specimenset": "stem",
38
+ "subject": "stem",
39
+ # modules
40
+ "behavior": "modules",
41
+ "consumablestock": "modules",
42
+ "dataacquisition": "modules",
43
+ "dataacquisitioninterface": "modules",
44
+ "dataacquisitionprocedure": "modules",
45
+ "dataacquisitiontemplate": "modules",
46
+ "episodepersonnel": "modules",
47
+ "equipment": "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",
58
+ "procedurelog": "modules",
59
+ "proceduretemplate": "modules",
60
+ "specimenlog": "modules",
61
+ "specimenprocedure": "modules",
62
+ "specimenproceduretemplate": "modules",
63
+ "subjectlog": "modules",
64
+ # personal_attributes
65
+ "behavioralassay": "personal_attributes",
66
+ "behavioralassaygroupmembershipinvitation": "personal_attributes",
67
+ "behavioralassaymembershipinvitation": "personal_attributes",
68
+ "datastorage": "personal_attributes",
69
+ "datastoragegroupmembershipinvitation": "personal_attributes",
70
+ "datastoragemembershipinvitation": "personal_attributes",
71
+ "inventory": "personal_attributes",
72
+ "inventorygroupmembershipinvitation": "personal_attributes",
73
+ "inventorymembershipinvitation": "personal_attributes",
74
+ "license": "personal_attributes",
75
+ "licensegroupmembershipinvitation": "personal_attributes",
76
+ "licensemembershipinvitation": "personal_attributes",
77
+ "protocol": "personal_attributes",
78
+ "protocolgroupmembershipinvitation": "personal_attributes",
79
+ "protocolmembershipinvitation": "personal_attributes",
80
+ "setup": "personal_attributes",
81
+ "setupgroupmembershipinvitation": "personal_attributes",
82
+ "setupmembershipinvitation": "personal_attributes",
83
+ # resources
84
+ "consumable": "resources",
85
+ "consumableapproval": "resources",
86
+ "hardwaredevice": "resources",
87
+ "hardwaredeviceapproval": "resources",
88
+ "supplier": "resources",
89
+ "supplierapproval": "resources",
90
+ # taxonomies
91
+ "anatomicalreference": "taxonomies",
92
+ "anatomicalregion": "taxonomies",
93
+ "behavioralcategory": "taxonomies",
94
+ "behavioralcategoryapproval": "taxonomies",
95
+ "behavioralparadigm": "taxonomies",
96
+ "behavioralparadigmapproval": "taxonomies",
97
+ "coordinatetransform": "taxonomies",
98
+ "regulatoryauthority": "taxonomies",
99
+ "regulatoryauthorityapproval": "taxonomies",
100
+ "setuptype": "taxonomies",
101
+ "setuptypeapproval": "taxonomies",
102
+ "species": "taxonomies",
103
+ "speciesapproval": "taxonomies",
104
+ "strain": "taxonomies",
105
+ "strainapproval": "taxonomies",
106
+ # dissemination
107
+ "journal": "dissemination",
108
+ "journalapproval": "dissemination",
109
+ "publication": "dissemination",
110
+ "publicationapproval": "dissemination",
111
+ # users
112
+ "admingroup": "users",
113
+ "group": "users",
114
+ "groupmembershipinvitation": "users",
115
+ "groupmembershiprequest": "users",
116
+ "laboratory": "users",
117
+ "pendinginvitation": "users",
118
+ "user": "users",
119
+ }
120
+
121
+ _TOKEN_FILE = Path.home() / ".config" / "brainstem" / "token"
7
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
+ }
8
133
 
9
- class ModelType(Enum):
10
- project = "project"
11
- subject = "subject"
12
- session = "session"
13
- collection = "collection"
14
- cohort = "cohort"
15
- procedure = "procedure"
16
- behavior = "behavior"
17
- dataacquisition = "dataacquisition"
18
- manipulation = "manipulation"
19
- equipment = "equipment"
20
- consumablestock = "consumablestock"
21
- procedurelog = "procedurelog"
22
- subjectlog = "subjectlog"
23
- behavioralparadigm = "behavioralparadigm"
24
- datastorage = "datastorage"
25
- inventory = "inventory"
26
- setup = "setup"
27
- consumable = "consumable"
28
- hardwaredevice = "hardwaredevice"
29
- supplier = "supplier"
30
- brainregion = "brainregion"
31
- setuptype = "setuptype"
32
- species = "species"
33
- strain = "strain"
34
- strainapproval = "strainapproval"
35
- journal = "journal"
36
- laboratory = "laboratory"
37
- publication = "publication"
38
- journalapproval = "journalapproval"
39
- user = "user"
40
- group = "group"
134
+ # Derived from _MODEL_TO_APP so there is exactly one source of truth.
135
+ ModelType = Enum( # type: ignore[misc]
136
+ "ModelType", {k: k for k in [*_MODEL_TO_APP, *_MODEL_ALIASES]}
137
+ )
138
+
139
+ _VALID_PORTALS = {"private", "public", "super"}
140
+
141
+
142
+ def _resolve_model(model) -> str:
143
+ """Accept a plain string or a ModelType member and return the server's resource name."""
144
+ if isinstance(model, ModelType):
145
+ model = model.value
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]
153
+ if model not in _MODEL_TO_APP:
154
+ raise ValueError(
155
+ f"Unknown model '{model}'. Valid models are: "
156
+ + ", ".join(sorted(_MODEL_TO_APP))
157
+ )
158
+ return model
159
+
160
+
161
+ def _resolve_portal(portal) -> str:
162
+ """Accept a plain string or a PortalType member and return the string value."""
163
+ if isinstance(portal, Enum):
164
+ portal = portal.value
165
+ portal = str(portal)
166
+ if portal not in _VALID_PORTALS:
167
+ raise ValueError(
168
+ f"Unknown portal '{portal}'. Valid portals are: "
169
+ + ", ".join(sorted(_VALID_PORTALS))
170
+ )
171
+ return portal
41
172
 
42
173
 
43
174
  class PortalType(Enum):
@@ -46,202 +177,1064 @@ class PortalType(Enum):
46
177
  super = "super"
47
178
 
48
179
 
49
- class LoginError(Exception):
50
- def __str__(self):
51
- return f'User/password combination incorrect or user does not exist'
180
+ # ---------------------------------------------------------------------------
181
+ # Exceptions
182
+ # ---------------------------------------------------------------------------
183
+
184
+ class AuthenticationError(Exception):
185
+ pass
186
+
187
+
188
+ class ExportError(Exception):
189
+ """A project export could not be produced or downloaded."""
190
+
52
191
 
192
+ # ---------------------------------------------------------------------------
193
+ # Client
194
+ # ---------------------------------------------------------------------------
53
195
 
54
196
  class BrainstemClient:
55
197
 
56
- def __init__(self,
57
- token: str = None) -> None:
198
+ BASE_URL = "https://www.brainstem.org/"
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
58
201
 
59
- # Server path
60
- self._address = 'https://www.brainstem.org/api/'
202
+ def __init__(
203
+ self,
204
+ token: str = None,
205
+ headless: bool = False,
206
+ url: str = None,
207
+ anonymous: bool = False,
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
+
226
+ base = (url.rstrip("/") + "/") if url else self.BASE_URL
227
+ self._base_url = base
228
+ self._address = base + "api/"
229
+ self._session = requests.Session()
230
+
231
+ # Automatically retry transient server errors
232
+ _retry = Retry(total=3, backoff_factor=0.5, status_forcelist={502, 503, 504})
233
+ self._session.mount("https://", HTTPAdapter(max_retries=_retry))
234
+ self._session.mount("http://", HTTPAdapter(max_retries=_retry))
235
+
236
+ if anonymous:
237
+ self._token = None
238
+ return
61
239
 
62
240
  if token:
63
241
  self._token = token
64
242
  else:
65
- username = input("Please enter your username:")
66
- password = getpass("Please enter your password:")
243
+ self._token = self._load_cached_token()
244
+ if not self._token:
245
+ self._token = self._device_auth_flow(headless=headless)
246
+ self._save_token(self._token)
247
+
248
+ self._session.headers.update({"Authorization": f"Bearer {self._token}"})
249
+
250
+ # ------------------------------------------------------------------
251
+ # Token management
252
+ # ------------------------------------------------------------------
253
+
254
+ def _load_cached_token(self) -> str:
255
+ """Return the cached token from disk, or None if not present."""
256
+ if _TOKEN_FILE.exists():
257
+ return _TOKEN_FILE.read_text().strip() or None
258
+ return None
259
+
260
+ def _save_token(self, token: str) -> None:
261
+ """Persist the token to disk with owner-read-only permissions."""
262
+ _TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
263
+ _TOKEN_FILE.write_text(token)
264
+ _TOKEN_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600
265
+
266
+ def _device_auth_flow(self, headless: bool = False, max_wait: int = 900) -> str:
267
+ """Run the BrainSTEM device authorization flow and return a token.
67
268
 
68
- self._token = self.__set_token_authentication(
69
- url=self._address + "token/",
70
- username=username,
71
- password=password,
269
+ The user authenticates in their browser (supports 2FA). No
270
+ credentials are sent by this tool.
271
+
272
+ Parameters
273
+ ----------
274
+ headless : If ``True``, print the verification URI and user code
275
+ instead of opening a browser window.
276
+ max_wait : Maximum seconds to wait for browser approval (default: 900).
277
+ """
278
+ # Step 1 — initiate a device session
279
+ resp = requests.post(
280
+ self._address + "auth/device/",
281
+ json={"client_name": "brainstem-python"},
282
+ timeout=10,
283
+ )
284
+ resp.raise_for_status()
285
+ data = resp.json()
286
+
287
+ device_code = data["device_code"]
288
+ interval = int(data.get("interval", 5))
289
+
290
+ # Step 2 — open browser or print instructions
291
+ if headless:
292
+ print(f"Open {data['verification_uri']} and enter: {data['user_code']}")
293
+ else:
294
+ webbrowser.open(data["verification_uri_complete"])
295
+ print("Waiting for browser approval...")
296
+
297
+ # Step 3 — poll until resolved (timeout after max_wait seconds)
298
+ deadline = time.monotonic() + max_wait
299
+ while True:
300
+ if time.monotonic() > deadline:
301
+ raise AuthenticationError(
302
+ f"Device authorization timed out after {max_wait} seconds."
303
+ )
304
+ time.sleep(interval)
305
+ poll = requests.post(
306
+ self._address + "auth/device/token/",
307
+ json={"device_code": device_code},
308
+ timeout=10,
72
309
  )
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()
322
+
323
+ if result.get("status") == "success":
324
+ return result["token"]
325
+ elif result.get("status") == "authorization_pending":
326
+ continue
327
+ else:
328
+ raise AuthenticationError(
329
+ f"Authorization failed: {result.get('error')}"
330
+ )
73
331
 
74
- print("Your authorization token is:\n", self._token)
75
- print("\nPlease keep it in a safe place.")
76
-
77
- def __set_token_authentication(self,
78
- url: str,
79
- username: str,
80
- password: str) -> str:
81
-
82
- headers = {
83
- "accept": "application/json",
84
- "Content-Type": "application/json"
85
- }
86
- params = {
87
- "username": username,
88
- "password": password
89
- }
90
- resp = requests.post(url, headers=headers, json=params)
91
-
92
- if resp.status_code != 200:
93
- raise LoginError()
94
-
95
- token = json.loads(resp.text)['token']
96
- return token
97
-
98
- def __get_app_from_model(self, modelname: str) -> str:
99
- app = None
100
-
101
- if modelname in ['project', 'subject', 'session', 'collection', 'cohort',
102
- 'projectmembershipinvitation',
103
- 'projectgroupmembershipinvitation']:
104
- app = 'stem'
105
-
106
- elif modelname in ['procedure', 'behavior', 'dataacquisition', 'manipulation',
107
- 'equipment', 'consumablestock', 'procedurelog', 'subjectlog']:
108
- app = 'modules'
109
-
110
- elif modelname in ['behavioralparadigm', 'datastorage', 'inventory',
111
- 'setup']:
112
- app = 'personal_attributes'
113
-
114
- elif any([x in modelname for x in
115
- ['consumable', 'hardwaredevice', 'supplier']]):
116
- app = 'resources'
117
-
118
- elif any([x in modelname for x in
119
- ['brainregion', 'setuptype',
120
- 'species', 'strain']]):
121
- app = 'taxonomies'
122
-
123
- elif any([x in modelname for x in
124
- ['journal', 'laboratory', 'publication']]):
125
- app = 'attributes'
126
-
127
- elif modelname in ['user', 'group', 'groupmembershipinvitation',
128
- 'groupmembershiprequest']:
129
- app = 'users'
130
-
131
- return app
132
-
133
- def load_model(self,
134
- model: ModelType,
135
- portal: PortalType = "private",
136
- id: str = None,
137
- options: str = None,
138
- filters: dict = None,
139
- sort: list = None,
140
- include: list = None) -> Response:
141
-
142
- app = self.__get_app_from_model(model)
143
- if app is None:
144
- resp = Response()
145
- resp.status_code = 404
146
- return resp
147
-
148
- if options is None:
149
- options = ""
332
+ # ------------------------------------------------------------------
333
+ # Internal helpers
334
+ # ------------------------------------------------------------------
150
335
 
336
+ def _build_url(self, portal: str, app: str, model: str,
337
+ id: str = None, options: str = None) -> str:
338
+ url = f"{self._address}{portal}/{app}/{model}/"
151
339
  if id:
152
- query_parameters = id + "/" + options
153
- else:
154
- query_parameters = ""
155
-
156
- if filters:
157
- for key in filters.keys():
158
- if query_parameters == "":
159
- prefix = "?"
160
- else:
161
- prefix = "&"
162
- query_parameters += (prefix
163
- + "filter{" + key + "}="
164
- + filters[key])
165
- if sort:
166
- for elem in sort:
167
- if query_parameters == "":
168
- prefix = "?"
169
- else:
170
- prefix = "&"
171
- query_parameters += prefix + "sort[]=" + elem
172
-
173
- if include:
174
- for elem in include:
175
- if query_parameters == "":
176
- prefix = "?"
177
- else:
178
- prefix = "&"
179
- query_parameters += prefix + "include[]=" + elem + ".*"
180
-
181
- request_url = (self._address + portal
182
- + "/" + app + "/" + model
183
- + "/" + query_parameters)
184
-
185
- resp = requests.get(request_url,
186
- headers={"Authorization": "Bearer %s"
187
- % self._token})
188
- return resp
189
-
190
- def save_model(self,
191
- model: ModelType,
192
- portal: PortalType = "private",
193
- id: str = None,
194
- data: dict = None,
195
- options: str = None) -> Response:
196
-
197
- app = self.__get_app_from_model(model)
198
- if app is None:
199
- resp = Response()
200
- resp.status_code = 404
201
- return resp
340
+ url += f"{id}/"
341
+ if options:
342
+ url += options
343
+ return url
344
+
345
+ # ------------------------------------------------------------------
346
+ # Public API
347
+ # ------------------------------------------------------------------
202
348
 
349
+ def load(self,
350
+ model,
351
+ portal="private",
352
+ id: str = None,
353
+ options: str = None,
354
+ filters: dict = None,
355
+ sort: list = None,
356
+ include: list = None,
357
+ limit: int = None,
358
+ offset: int = None,
359
+ load_all: bool = False) -> Union[Response, dict]:
360
+ """Load one or more records of *model*.
361
+
362
+ Parameters
363
+ ----------
364
+ model : Model name string or ``ModelType`` member, e.g. ``'session'``.
365
+ portal : ``'private'`` (default) or ``'public'``, or ``PortalType`` member.
366
+ id : UUID of a specific record. Returns a single object when set.
367
+ filters : Dict of field filters, e.g. ``{'name.icontains': 'rat'}``.
368
+ Supports ``.icontains``, ``.startswith``, ``.endswith``,
369
+ ``.gt``, ``.gte``, ``.lt``, ``.lte``.
370
+ sort : List of fields to sort by. Prefix with ``'-'`` for descending.
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``.
382
+ load_all : When ``True``, automatically follow pagination and return a
383
+ combined ``dict`` with all records merged under the model key,
384
+ starting at the page selected by *offset*.
385
+ When ``False`` (default), returns the raw ``Response``.
386
+ """
387
+ model = _resolve_model(model)
388
+ portal = _resolve_portal(portal)
389
+ app = _MODEL_TO_APP[model]
390
+ url = self._build_url(portal, app, model, id, options)
391
+
392
+ params = {}
393
+ if not id:
394
+ for key, val in (filters or {}).items():
395
+ params[f"filter{{{key}}}"] = val
396
+ for field in (sort or []):
397
+ params.setdefault("sort[]", []).append(field)
398
+ for rel in (include or []):
399
+ params.setdefault("include[]", []).append(f"{rel}.*")
400
+
401
+ if load_all and id is not None:
402
+ raise ValueError("load_all=True cannot be used together with id.")
403
+
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.
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
450
+ combined: dict = {}
451
+ seen_ids: dict = {}
452
+
453
+ while True:
454
+ data = self._get_json(url, dict(params, page=page))
455
+ lists = {k: v for k, v in data.items() if isinstance(v, list)}
456
+
457
+ if not combined:
458
+ if not lists:
459
+ raise ValueError(
460
+ "load_all=True requires a paginated list response, but the API "
461
+ "returned no list-valued key. Use load_all=False for single-object endpoints."
462
+ )
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()):
480
+ break
481
+ page += 1
482
+
483
+ return combined
484
+
485
+ def save(self,
486
+ model,
487
+ portal="private",
488
+ id: str = None,
489
+ data: dict = None,
490
+ options: str = None) -> Response:
491
+ """Create or update a record.
492
+
493
+ When *id* is provided the record is **updated** (PATCH).
494
+ When *id* is omitted a **new record** is created (POST).
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
+
504
+ Parameters
505
+ ----------
506
+ model : Model name string or ``ModelType`` member, e.g. ``'session'``.
507
+ portal : ``'private'`` (default) or ``'public'``, or ``PortalType`` member.
508
+ id : UUID of the record to update. Omit to create a new record.
509
+ data : Dict of fields to submit.
510
+ """
511
+ model = _resolve_model(model)
512
+ portal = _resolve_portal(portal)
513
+ app = _MODEL_TO_APP[model]
203
514
  if data is None:
204
515
  data = {}
205
516
 
206
- if options is None:
207
- options = ""
208
-
209
- # Check if entry already exists
210
517
  if id is not None:
211
- # This is an update request
212
- request_url = (self._address + portal + "/" + app + "/" + model
213
- + "/" + id + "/" + options)
214
- resp = requests.patch(request_url, json=data,
215
- headers={"Authorization": "Bearer %s"
216
- % self._token})
217
-
518
+ url = self._build_url(portal, app, model, id, options)
519
+ return self._session.patch(url, json=data, timeout=self.DEFAULT_TIMEOUT)
218
520
  else:
219
- # This is a create request
220
- request_url = (self._address + portal + "/" + app + "/"
221
- + model + "/" + options)
222
- resp = requests.post(request_url, json=data,
223
- headers={"Authorization": "Bearer %s"
224
- % self._token})
225
-
226
- return resp
227
-
228
- def delete_model(self,
229
- model: ModelType,
230
- portal: PortalType = "private",
231
- id: str = None):
232
-
233
- app = self.__get_app_from_model(model)
234
- if app is None:
235
- resp = Response()
236
- resp.status_code = 404
237
- return resp
238
-
239
- # Check if entry already exists
240
- if id is not None:
241
- request_url = (self._address + portal + "/" + app + "/" + model
242
- + "/" + id + "/")
243
- resp = requests.delete(request_url,
244
- headers={"Authorization": "Bearer %s"
245
- % self._token})
521
+ url = self._build_url(portal, app, model, options=options)
522
+ return self._session.post(url, json=data, timeout=self.DEFAULT_TIMEOUT)
523
+
524
+ def delete(self,
525
+ model,
526
+ portal="private",
527
+ id: str = None) -> Response:
528
+ """Delete a record by ID.
529
+
530
+ Parameters
531
+ ----------
532
+ model : Model name string or ``ModelType`` member, e.g. ``'session'``.
533
+ portal : ``'private'`` (default) or ``'public'``, or ``PortalType`` member.
534
+ id : UUID of the record to delete (required).
535
+ """
536
+ if id is None:
537
+ raise ValueError("'id' is required to delete a record.")
538
+ model = _resolve_model(model)
539
+ portal = _resolve_portal(portal)
540
+ app = _MODEL_TO_APP[model]
541
+ url = self._build_url(portal, app, model, id)
542
+ return self._session.delete(url, timeout=self.DEFAULT_TIMEOUT)
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
+
697
+ def __enter__(self):
698
+ return self
699
+
700
+ def __exit__(self, *args):
701
+ self._session.close()
702
+
703
+ # ------------------------------------------------------------------
704
+ # Convenience loaders (mirror the MATLAB load_* helpers)
705
+ # ------------------------------------------------------------------
706
+
707
+ def _convenience_load(self, model, default_include, filter_map,
708
+ portal, id, filters, sort, include, limit, offset,
709
+ load_all, **field_kwargs):
710
+ """Shared implementation for all convenience loaders."""
711
+ unknown = set(field_kwargs) - set(filter_map)
712
+ if unknown:
713
+ raise TypeError(
714
+ f"Unexpected keyword argument(s): {', '.join(sorted(unknown))}"
715
+ )
716
+ merged_filters = dict(filters or {})
717
+ for kwarg, api_field in filter_map.items():
718
+ value = field_kwargs.get(kwarg)
719
+ if value is not None:
720
+ merged_filters[api_field] = value
721
+ return self.load(
722
+ model,
723
+ portal=portal,
724
+ id=id,
725
+ filters=merged_filters or None,
726
+ sort=sort,
727
+ include=include if include is not None else default_include,
728
+ limit=limit,
729
+ offset=offset,
730
+ load_all=load_all,
731
+ )
732
+
733
+ def load_project(self, portal="private", id: str = None,
734
+ name: str = None, sessions: str = None,
735
+ subjects: str = None, tags: str = None,
736
+ filters: dict = None, sort: list = None,
737
+ include: list = None, limit: int = None,
738
+ offset: int = None, load_all: bool = False):
739
+ """Load project(s). Embeds sessions, subjects, collections and cohorts by default.
740
+
741
+ Parameters
742
+ ----------
743
+ name : Filter by project name (case-insensitive contains).
744
+ sessions : Filter by session UUID.
745
+ subjects : Filter by subject UUID.
746
+ tags : Filter by tag name (sent as ``filter{tags.name}``; records return tags as names).
747
+ """
748
+ return self._convenience_load(
749
+ "project",
750
+ default_include=["sessions", "subjects", "collections", "cohorts"],
751
+ filter_map={"name": "name.icontains", "sessions": "sessions",
752
+ "subjects": "subjects", "tags": "tags.name"},
753
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
754
+ limit=limit, offset=offset, load_all=load_all,
755
+ name=name, sessions=sessions, subjects=subjects, tags=tags,
756
+ )
757
+
758
+ def load_subject(self, portal="private", id: str = None,
759
+ name: str = None, projects: str = None,
760
+ strain: str = None, sex: str = None, tags: str = None,
761
+ filters: dict = None, sort: list = None,
762
+ include: list = None, limit: int = None,
763
+ offset: int = None, load_all: bool = False):
764
+ """Load subject(s). Embeds procedures and subjectlogs by default.
765
+
766
+ Parameters
767
+ ----------
768
+ name : Filter by subject name (case-insensitive contains).
769
+ projects : Filter by project UUID.
770
+ strain : Filter by strain UUID.
771
+ sex : Filter by sex (``'M'``, ``'F'``, or ``'U'``).
772
+ tags : Filter by tag name (sent as ``filter{tags.name}``; records return tags as names).
773
+ """
774
+ return self._convenience_load(
775
+ "subject",
776
+ default_include=["procedures", "subjectlogs"],
777
+ filter_map={"name": "name.icontains", "projects": "projects",
778
+ "strain": "strain", "sex": "sex", "tags": "tags.name"},
779
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
780
+ limit=limit, offset=offset, load_all=load_all,
781
+ name=name, projects=projects, strain=strain, sex=sex, tags=tags,
782
+ )
783
+
784
+ def load_session(self, portal="private", id: str = None,
785
+ name: str = None, projects: str = None,
786
+ datastorage: str = None, tags: str = None,
787
+ filters: dict = None, sort: list = None,
788
+ include: list = None, limit: int = None,
789
+ offset: int = None, load_all: bool = False):
790
+ """Load session(s). Embeds dataacquisition, behaviors, manipulations and epochs by default.
791
+
792
+ Parameters
793
+ ----------
794
+ name : Filter by session name (case-insensitive contains).
795
+ projects : Filter by project UUID.
796
+ datastorage : Filter by data storage UUID.
797
+ tags : Filter by tag name (sent as ``filter{tags.name}``; records return tags as names).
798
+ """
799
+ return self._convenience_load(
800
+ "session",
801
+ default_include=["dataacquisition", "behaviors", "manipulations", "epochs"],
802
+ filter_map={"name": "name.icontains", "projects": "projects",
803
+ "datastorage": "datastorage", "tags": "tags.name"},
804
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
805
+ limit=limit, offset=offset, load_all=load_all,
806
+ name=name, projects=projects, datastorage=datastorage, tags=tags,
807
+ )
808
+
809
+ def load_collection(self, portal="private", id: str = None,
810
+ name: str = None, tags: str = None,
811
+ filters: dict = None, sort: list = None,
812
+ include: list = None, limit: int = None,
813
+ offset: int = None, load_all: bool = False):
814
+ """Load collection(s). Embeds sessions by default.
815
+
816
+ Parameters
817
+ ----------
818
+ name : Filter by collection name (case-insensitive contains).
819
+ tags : Filter by tag name (sent as ``filter{tags.name}``; records return tags as names).
820
+ """
821
+ return self._convenience_load(
822
+ "collection",
823
+ default_include=["sessions"],
824
+ filter_map={"name": "name.icontains", "tags": "tags.name"},
825
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
826
+ limit=limit, offset=offset, load_all=load_all,
827
+ name=name, tags=tags,
828
+ )
829
+
830
+ def load_cohort(self, portal="private", id: str = None,
831
+ name: str = None, tags: str = None,
832
+ filters: dict = None, sort: list = None,
833
+ include: list = None, limit: int = None,
834
+ offset: int = None, load_all: bool = False):
835
+ """Load cohort(s). Embeds subjects by default.
836
+
837
+ Parameters
838
+ ----------
839
+ name : Filter by cohort name (case-insensitive contains).
840
+ tags : Filter by tag name (sent as ``filter{tags.name}``; records return tags as names).
841
+ """
842
+ return self._convenience_load(
843
+ "cohort",
844
+ default_include=["subjects"],
845
+ filter_map={"name": "name.icontains", "tags": "tags.name"},
846
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
847
+ limit=limit, offset=offset, load_all=load_all,
848
+ name=name, tags=tags,
849
+ )
850
+
851
+ def load_behavior(self, portal="private", id: str = None,
852
+ session: str = None,
853
+ filters: dict = None, sort: list = None,
854
+ include: list = None, limit: int = None,
855
+ offset: int = None, load_all: bool = False):
856
+ """Load behavior record(s).
857
+
858
+ Parameters
859
+ ----------
860
+ session : Filter by session UUID.
861
+ """
862
+ return self._convenience_load(
863
+ "behavior",
864
+ default_include=[],
865
+ filter_map={"session": "session"},
866
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
867
+ limit=limit, offset=offset, load_all=load_all,
868
+ session=session,
869
+ )
870
+
871
+ def load_dataacquisition(self, portal="private", id: str = None,
872
+ session: str = None,
873
+ filters: dict = None, sort: list = None,
874
+ include: list = None, limit: int = None,
875
+ offset: int = None, load_all: bool = False):
876
+ """Load data acquisition record(s).
877
+
878
+ Parameters
879
+ ----------
880
+ session : Filter by session UUID.
881
+ """
882
+ return self._convenience_load(
883
+ "dataacquisition",
884
+ default_include=[],
885
+ filter_map={"session": "session"},
886
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
887
+ limit=limit, offset=offset, load_all=load_all,
888
+ session=session,
889
+ )
890
+
891
+ def load_manipulation(self, portal="private", id: str = None,
892
+ session: str = None,
893
+ filters: dict = None, sort: list = None,
894
+ include: list = None, limit: int = None,
895
+ offset: int = None, load_all: bool = False):
896
+ """Load manipulation record(s).
897
+
898
+ Parameters
899
+ ----------
900
+ session : Filter by session UUID.
901
+ """
902
+ return self._convenience_load(
903
+ "manipulation",
904
+ default_include=[],
905
+ filter_map={"session": "session"},
906
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
907
+ limit=limit, offset=offset, load_all=load_all,
908
+ session=session,
909
+ )
910
+
911
+ def load_procedure(self, portal="private", id: str = None,
912
+ subject: str = None,
913
+ filters: dict = None, sort: list = None,
914
+ include: list = None, limit: int = None,
915
+ offset: int = None, load_all: bool = False):
916
+ """Load procedure record(s).
917
+
918
+ Parameters
919
+ ----------
920
+ subject : Filter by subject UUID.
921
+ """
922
+ return self._convenience_load(
923
+ "procedure",
924
+ default_include=[],
925
+ filter_map={"subject": "subject"},
926
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
927
+ limit=limit, offset=offset, load_all=load_all,
928
+ subject=subject,
929
+ )
930
+
931
+ def load_procedurelog(self, portal="private", id: str = None,
932
+ procedure: str = None,
933
+ filters: dict = None, sort: list = None,
934
+ include: list = None, limit: int = None,
935
+ offset: int = None, load_all: bool = False):
936
+ """Load procedure log record(s).
937
+
938
+ Parameters
939
+ ----------
940
+ procedure : Filter by procedure UUID.
941
+ """
942
+ return self._convenience_load(
943
+ "procedurelog",
944
+ default_include=[],
945
+ filter_map={"procedure": "procedure"},
946
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
947
+ limit=limit, offset=offset, load_all=load_all,
948
+ procedure=procedure,
949
+ )
950
+
951
+ def load_subjectlog(self, portal="private", id: str = None,
952
+ subject: str = None,
953
+ filters: dict = None, sort: list = None,
954
+ include: list = None, limit: int = None,
955
+ offset: int = None, load_all: bool = False):
956
+ """Load subject log record(s).
957
+
958
+ Parameters
959
+ ----------
960
+ subject : Filter by subject UUID.
961
+ """
962
+ return self._convenience_load(
963
+ "subjectlog",
964
+ default_include=[],
965
+ filter_map={"subject": "subject"},
966
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
967
+ limit=limit, offset=offset, load_all=load_all,
968
+ subject=subject,
969
+ )
970
+
971
+ def load_equipment(self, portal="private", id: str = None,
972
+ name: str = None, setup: str = None,
973
+ filters: dict = None, sort: list = None,
974
+ include: list = None, limit: int = None,
975
+ offset: int = None, load_all: bool = False):
976
+ """Load equipment record(s).
977
+
978
+ Parameters
979
+ ----------
980
+ name : Filter by equipment name (case-insensitive contains).
981
+ setup : Filter by setup UUID.
982
+ """
983
+ return self._convenience_load(
984
+ "equipment",
985
+ default_include=[],
986
+ filter_map={"name": "name.icontains", "setup": "setup"},
987
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
988
+ limit=limit, offset=offset, load_all=load_all,
989
+ name=name, setup=setup,
990
+ )
991
+
992
+ def load_consumablestock(self, portal="private", id: str = None,
993
+ inventory: str = None,
994
+ filters: dict = None, sort: list = None,
995
+ include: list = None, limit: int = None,
996
+ offset: int = None, load_all: bool = False):
997
+ """Load consumable stock record(s).
998
+
999
+ Parameters
1000
+ ----------
1001
+ inventory : Filter by inventory UUID.
1002
+ """
1003
+ return self._convenience_load(
1004
+ "consumablestock",
1005
+ default_include=[],
1006
+ filter_map={"inventory": "inventory"},
1007
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
1008
+ limit=limit, offset=offset, load_all=load_all,
1009
+ inventory=inventory,
1010
+ )
1011
+
1012
+ def load_behavioralassay(self, portal="private", id: str = None,
1013
+ name: str = None,
1014
+ filters: dict = None, sort: list = None,
1015
+ include: list = None, limit: int = None,
1016
+ offset: int = None, load_all: bool = False):
1017
+ """Load behavioral assay record(s).
1018
+
1019
+ Parameters
1020
+ ----------
1021
+ name : Filter by name (case-insensitive contains).
1022
+ """
1023
+ return self._convenience_load(
1024
+ "behavioralassay",
1025
+ default_include=[],
1026
+ filter_map={"name": "name.icontains"},
1027
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
1028
+ limit=limit, offset=offset, load_all=load_all,
1029
+ name=name,
1030
+ )
1031
+
1032
+ def load_datastorage(self, portal="private", id: str = None,
1033
+ name: str = None,
1034
+ filters: dict = None, sort: list = None,
1035
+ include: list = None, limit: int = None,
1036
+ offset: int = None, load_all: bool = False):
1037
+ """Load data storage record(s).
1038
+
1039
+ Parameters
1040
+ ----------
1041
+ name : Filter by name (case-insensitive contains).
1042
+ """
1043
+ return self._convenience_load(
1044
+ "datastorage",
1045
+ default_include=[],
1046
+ filter_map={"name": "name.icontains"},
1047
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
1048
+ limit=limit, offset=offset, load_all=load_all,
1049
+ name=name,
1050
+ )
1051
+
1052
+ def load_setup(self, portal="private", id: str = None,
1053
+ name: str = None,
1054
+ filters: dict = None, sort: list = None,
1055
+ include: list = None, limit: int = None,
1056
+ offset: int = None, load_all: bool = False):
1057
+ """Load setup record(s).
1058
+
1059
+ Parameters
1060
+ ----------
1061
+ name : Filter by name (case-insensitive contains).
1062
+ """
1063
+ return self._convenience_load(
1064
+ "setup",
1065
+ default_include=[],
1066
+ filter_map={"name": "name.icontains"},
1067
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
1068
+ limit=limit, offset=offset, load_all=load_all,
1069
+ name=name,
1070
+ )
1071
+
1072
+ def load_hardwaredevice(self, portal="private", id: str = None,
1073
+ name: str = None,
1074
+ filters: dict = None, sort: list = None,
1075
+ include: list = None, limit: int = None,
1076
+ offset: int = None, load_all: bool = False):
1077
+ """Load hardware device record(s).
1078
+
1079
+ Parameters
1080
+ ----------
1081
+ name : Filter by name (case-insensitive contains).
1082
+ """
1083
+ return self._convenience_load(
1084
+ "hardwaredevice",
1085
+ default_include=[],
1086
+ filter_map={"name": "name.icontains"},
1087
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
1088
+ limit=limit, offset=offset, load_all=load_all,
1089
+ name=name,
1090
+ )
1091
+
1092
+ def load_brainregion(self, portal="private", id: str = None,
1093
+ name: str = None,
1094
+ filters: dict = None, sort: list = None,
1095
+ include: list = None, limit: int = None,
1096
+ offset: int = None, load_all: bool = False):
1097
+ """Deprecated: use ``load_anatomicalregion``, which this calls.
1098
+
1099
+ Brain regions are ``anatomicalregion`` records; the response holds them
1100
+ under ``anatomical_regions``.
1101
+
1102
+ Parameters
1103
+ ----------
1104
+ name : Filter by name (case-insensitive contains).
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.
1128
+ """
1129
+ return self._convenience_load(
1130
+ "anatomicalregion",
1131
+ default_include=[],
1132
+ filter_map={"name": "name.icontains", "acronym": "acronym.iexact",
1133
+ "reference": "reference"},
1134
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
1135
+ limit=limit, offset=offset, load_all=load_all,
1136
+ name=name, acronym=acronym, reference=reference,
1137
+ )
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
+
1153
+ def load_species(self, portal="private", id: str = None,
1154
+ name: str = None,
1155
+ filters: dict = None, sort: list = None,
1156
+ include: list = None, limit: int = None,
1157
+ offset: int = None, load_all: bool = False,
1158
+ popular_name: str = None):
1159
+ """Load species record(s).
1160
+
1161
+ Parameters
1162
+ ----------
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.
1166
+ """
1167
+ return self._convenience_load(
1168
+ "species",
1169
+ default_include=[],
1170
+ filter_map={"name": "name.icontains",
1171
+ "popular_name": "popular_name.icontains"},
1172
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
1173
+ limit=limit, offset=offset, load_all=load_all,
1174
+ name=name, popular_name=popular_name,
1175
+ )
1176
+
1177
+ def load_strain(self, portal="private", id: str = None,
1178
+ name: str = None, species: str = None,
1179
+ filters: dict = None, sort: list = None,
1180
+ include: list = None, limit: int = None,
1181
+ offset: int = None, load_all: bool = False,
1182
+ popular_name: str = None):
1183
+ """Load strain record(s).
1184
+
1185
+ Parameters
1186
+ ----------
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.
1191
+ """
1192
+ return self._convenience_load(
1193
+ "strain",
1194
+ default_include=[],
1195
+ filter_map={"name": "name.icontains", "species": "species",
1196
+ "popular_name": "popular_name.icontains"},
1197
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
1198
+ limit=limit, offset=offset, load_all=load_all,
1199
+ name=name, species=species, popular_name=popular_name,
1200
+ )
1201
+
1202
+ def load_publication(self, portal="private", id: str = None,
1203
+ name: str = None,
1204
+ filters: dict = None, sort: list = None,
1205
+ include: list = None, limit: int = None,
1206
+ offset: int = None, load_all: bool = False):
1207
+ """Load publication record(s).
1208
+
1209
+ Parameters
1210
+ ----------
1211
+ name : Filter by name (case-insensitive contains).
1212
+ """
1213
+ return self._convenience_load(
1214
+ "publication",
1215
+ default_include=[],
1216
+ filter_map={"name": "name.icontains"},
1217
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
1218
+ limit=limit, offset=offset, load_all=load_all,
1219
+ name=name,
1220
+ )
1221
+
1222
+ def load_laboratory(self, portal="private", id: str = None,
1223
+ name: str = None,
1224
+ filters: dict = None, sort: list = None,
1225
+ include: list = None, limit: int = None,
1226
+ offset: int = None, load_all: bool = False):
1227
+ """Load laboratory record(s).
246
1228
 
247
- return resp
1229
+ Parameters
1230
+ ----------
1231
+ name : Filter by name (case-insensitive contains).
1232
+ """
1233
+ return self._convenience_load(
1234
+ "laboratory",
1235
+ default_include=[],
1236
+ filter_map={"name": "name.icontains"},
1237
+ portal=portal, id=id, filters=filters, sort=sort, include=include,
1238
+ limit=limit, offset=offset, load_all=load_all,
1239
+ name=name,
1240
+ )