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.
- brainstem_api_tools/__init__.py +3 -1
- brainstem_api_tools/brainstem_api_client.py +1206 -213
- brainstem_api_tools/cli.py +255 -0
- brainstem_python_api_tools-2.1.0.dist-info/METADATA +330 -0
- brainstem_python_api_tools-2.1.0.dist-info/RECORD +9 -0
- {brainstem_python_api_tools-0.2.0.dist-info → brainstem_python_api_tools-2.1.0.dist-info}/WHEEL +1 -1
- brainstem_python_api_tools-2.1.0.dist-info/entry_points.txt +2 -0
- brainstem_python_api_tools-0.2.0.dist-info/METADATA +0 -54
- brainstem_python_api_tools-0.2.0.dist-info/RECORD +0 -7
- {brainstem_python_api_tools-0.2.0.dist-info → brainstem_python_api_tools-2.1.0.dist-info/licenses}/LICENSE +0 -0
- {brainstem_python_api_tools-0.2.0.dist-info → brainstem_python_api_tools-2.1.0.dist-info}/top_level.txt +0 -0
|
@@ -1,43 +1,174 @@
|
|
|
1
|
+
import gzip
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
1
4
|
import os
|
|
2
|
-
|
|
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
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
self
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
+
)
|