pyoncat 2.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.
pyoncat/__init__.py ADDED
@@ -0,0 +1,1066 @@
1
+ r"""A Python Client for the API of ONCat, the ORNL Neutron Catalog.
2
+
3
+ This package provides several convenience classes and methods which will
4
+ help to more easily interact with the ONCat API from Python.
5
+
6
+ For a complete description of this module, please see the documentation
7
+ hosted at https://oncat.ornl.gov.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import functools
14
+ import getpass
15
+ import json
16
+ import os
17
+ import pathlib
18
+ import sys
19
+ import warnings
20
+ import weakref
21
+ from typing import Any, Callable, ClassVar
22
+
23
+ import oauthlib.oauth2
24
+ import requests
25
+ import requests_oauthlib
26
+ from requests.packages.urllib3.exceptions import InsecureRequestWarning
27
+
28
+ __version__ = "2.0"
29
+
30
+ __all__ = [
31
+ "FileSystemTokenStore",
32
+ "ONCat",
33
+ "ONCatObject",
34
+ "PyONCatError",
35
+ "prompt_user_for_login",
36
+ ]
37
+
38
+
39
+ ########################################################################
40
+ # Client
41
+ ########################################################################
42
+
43
+
44
+ CLIENT_CREDENTIALS_FLOW = "client"
45
+ RESOURCE_OWNER_CREDENTIALS_FLOW = "resource_owner"
46
+
47
+
48
+ class ONCat:
49
+ """The ONCat API client.
50
+
51
+ This class is the main entry point for interacting with the ONCat
52
+ API. It aims to provide a slightly higher level of abstraction than
53
+ simply making raw GET, POST, PUT, and DELETE requests to the API,
54
+ and also wraps the relatively complex authentication logic that is
55
+ required to interact with most of the API.
56
+
57
+ Once instantiated correctly, the client object provides a set of
58
+ standardized sub-objects that represent the various resources that
59
+ are available in the API and the actions that can be taken on them.
60
+
61
+ Calling actions on a resource takes the following form:
62
+
63
+ client.Resource.action()
64
+
65
+ For example, to list all the facilities in the API, you would call:
66
+
67
+ client.Facility.list()
68
+
69
+ The client will automatically handle the authentication and
70
+ authorization required to make the request, and will raise an
71
+ exception if the request is not successful.
72
+
73
+ Various parameters can be passed to the actions to modify the
74
+ behaviour of the underlying request, such as query parameters, data
75
+ to be sent in the body of the request, etc.
76
+
77
+ A more complex example would be to return a list of each run title
78
+ contained in all the raw NeXus files acquired at ARCS:
79
+
80
+ oncat.Datafile.list(
81
+ facility="SNS",
82
+ instrument="ARCS",
83
+ projection=["metadata.entry.title"],
84
+ tags=["type/raw"],
85
+ exts=[".nxs.h5"],
86
+ )
87
+
88
+ A full explanation of each resource and the actions that can be
89
+ taken on them can be found in the ONCat API documentation.
90
+
91
+
92
+ Arguments:
93
+ ---------
94
+ url: str
95
+ The base URL of the ONCat API.
96
+
97
+ client_id: str | None
98
+ The client ID for the OAuth client, required if you are
99
+ using an OAuth flow to authenticate.
100
+
101
+ client_secret: str | None
102
+ The client secret for the OAuth client, which may be
103
+ required alongside the client ID if you are using one.
104
+
105
+ token_getter: Callable[[], str | None] | None
106
+ An optional function that will be used to load the current
107
+ OAuth token if one exists, else `None`.
108
+
109
+ token_setter: Callable[[str | None], None] | None
110
+ An optional function that will be used to save the current
111
+ OAuth token, to be retrieved later by the `token_getter`.
112
+
113
+ login_prompt: Callable[[], Tuple[str, str]] | None
114
+ An optional function that will prompt the user for their
115
+ username and password when appropriate, as per the OAuth
116
+ flow being used.
117
+
118
+ api_token: str | None
119
+ An optional API token that can be used by select clients to
120
+ authenticate requests to the API as an alternative to OAuth.
121
+
122
+ flow: CLIENT_CREDENTIALS_FLOW | RESOURCE_OWNER_CREDENTIALS_FLOW | None
123
+ The OAuth "flow" to use for authentication. Required if you
124
+ are using OAuth.
125
+
126
+ scopes: List[str] | None
127
+ The OAuth scopes that this client should have access to.
128
+ If using OAuth and a scope is not provided, then will
129
+ default server-side to `"api:read"`. Please see the ONCat
130
+ API documentation for more information on the scopes that
131
+ are required for each resource.
132
+
133
+ verify: bool
134
+ Whether or not to verify the SSL certificate of the server
135
+ when making requests. `True` by default and absolutely
136
+ required in production, setting this to `False` can be
137
+ useful for development or testing purposes.
138
+
139
+ timeout: int | None
140
+ The number of seconds to wait for a response from the server
141
+ before timing out. `None` by default, which means that the
142
+ client will wait indefinitely for a response, as per the
143
+ underlying behavior of the `requests` module.
144
+
145
+ """
146
+
147
+ def __init__(
148
+ self,
149
+ url: str,
150
+ client_id: str | None = None,
151
+ client_secret: str | None = None,
152
+ token_getter: Callable[[], str | None] | None = None,
153
+ token_setter: Callable[[str | None], None] | None = None,
154
+ login_prompt: Callable[[], tuple[str, str]] | None = None,
155
+ api_token: str | None = None,
156
+ flow: CLIENT_CREDENTIALS_FLOW
157
+ | RESOURCE_OWNER_CREDENTIALS_FLOW
158
+ | None = None,
159
+ scopes: list[str] | None = None,
160
+ verify: bool = True,
161
+ timeout: int | None = None,
162
+ ):
163
+ if not url:
164
+ msg = "A URL must be provided."
165
+ raise ValueError(msg)
166
+
167
+ using_oauth = (
168
+ client_id is not None
169
+ or client_secret is not None
170
+ or token_getter is not None
171
+ or token_setter is not None
172
+ or login_prompt is not None
173
+ or flow is not None
174
+ or scopes is not None
175
+ )
176
+
177
+ if using_oauth:
178
+ if not flow:
179
+ msg = "A flow must be provided when using OAuth."
180
+ raise ValueError(msg)
181
+ if not client_id:
182
+ msg = "A client ID must be provided when using OAuth."
183
+ raise ValueError(msg)
184
+ if api_token:
185
+ msg = "An API token cannot be used when using OAuth."
186
+ raise ValueError(msg)
187
+ if client_secret and not client_id:
188
+ msg = "A client secret must be paired with a client ID."
189
+ raise ValueError(msg)
190
+ if token_getter and not token_setter:
191
+ msg = "A token getter must be paired with a token setter."
192
+ raise ValueError(msg)
193
+ if token_setter and not token_getter:
194
+ msg = "A token setter must be paired with a token getter."
195
+ raise ValueError(msg)
196
+
197
+ self._token_getter = token_getter
198
+ self._token_setter = token_setter
199
+ self._login_prompt = login_prompt
200
+ self._api_token = api_token
201
+ self._client_id = client_id
202
+ self._client_secret = client_secret
203
+ self._url = url
204
+ self._flow = flow
205
+ self._scopes = scopes
206
+ self._verify = verify
207
+ self._timeout = timeout
208
+
209
+ self._token = None
210
+ self._oauth_client = None
211
+
212
+ for resource_cls in _ONCatMixinMeta.REGISTERED_RESOURCES:
213
+ setattr(self, resource_cls.__name__, resource_cls(self))
214
+
215
+ def _get(self, url: str, **kwargs):
216
+ return self._call_method("get", url, None, **kwargs)
217
+
218
+ def _put(self, url: str, data: Any, **kwargs):
219
+ result = self._call_method("put", url, data, **kwargs)
220
+
221
+ # Not all resources will return a confirmation representation.
222
+ return result if result != "" else None
223
+
224
+ def _post(self, url: str, data: Any, **kwargs):
225
+ return self._call_method("post", url, data, **kwargs)
226
+
227
+ def _delete(self, url: str, **kwargs):
228
+ self._call_method("delete", url, None, **kwargs)
229
+
230
+ def _call_method(self, method, url, data, **kwargs):
231
+ url = requests.utils.quote(url)
232
+ full_url = (
233
+ self._url + url if url.startswith("/") else self._url + "/" + url
234
+ )
235
+
236
+ def send_request():
237
+ if self._client_id:
238
+ response = getattr(self._load_oauth_client(), method)(
239
+ full_url,
240
+ params=kwargs,
241
+ json=data,
242
+ verify=self._should_verify(),
243
+ timeout=self._timeout,
244
+ )
245
+ else:
246
+ response = getattr(requests, method)(
247
+ full_url,
248
+ params=kwargs,
249
+ json=data,
250
+ verify=self._should_verify(),
251
+ headers={"Authorization": "Bearer " + self._api_token}
252
+ if self._api_token
253
+ else None,
254
+ timeout=self._timeout,
255
+ )
256
+ try:
257
+ response.raise_for_status()
258
+ except requests.exceptions.HTTPError as error:
259
+ if error.response.status_code == 400:
260
+ msg = "Bad request"
261
+ raise BadRequestError(msg) from error
262
+ if error.response.status_code == 401:
263
+ msg = f'Not authorized to access "{full_url}"'
264
+ raise UnauthorizedError(msg) from error
265
+ if error.response.status_code == 404:
266
+ msg = f'Could not find resource at "{full_url}"'
267
+ raise NotFoundError(msg) from error
268
+ msg = f'Error: "{error!s}"'
269
+ raise PyONCatError(msg) from error
270
+
271
+ try:
272
+ return response.json()
273
+ except ValueError:
274
+ return response.text
275
+
276
+ try:
277
+ return send_request()
278
+ except oauthlib.oauth2.rfc6749.errors.InvalidGrantError as error:
279
+ if (
280
+ self._flow == RESOURCE_OWNER_CREDENTIALS_FLOW
281
+ and "(invalid_grant)" in str(error)
282
+ and "unknown, invalid, or expired refresh token" in str(error)
283
+ ):
284
+ self._save_token(None)
285
+ if self._login_prompt:
286
+ self.login()
287
+ return send_request()
288
+ msg = (
289
+ "It looks like you've tried to use a refresh token "
290
+ "that has expired. Not to worry -- this is part of "
291
+ "the normal OAuth workflow when using refresh tokens, "
292
+ "since by default (and on a client-by-client basis) "
293
+ "they are set to expire after a certain length of "
294
+ "time. You should be catching this error in your "
295
+ "client code and then re-prompting the user for their "
296
+ "username and password so that you may proceed with "
297
+ "your calls to the ONCat API.\n\n"
298
+ )
299
+ raise InvalidRefreshTokenError(msg) from error
300
+ msg = f'Error: "{error!s}"'
301
+ raise PyONCatError(msg) from error
302
+ except oauthlib.oauth2.TokenExpiredError as error:
303
+ if self._flow == CLIENT_CREDENTIALS_FLOW:
304
+ self.login()
305
+ return send_request()
306
+ msg = f'Error: "{error!s}"'
307
+ raise PyONCatError(msg) from error
308
+
309
+ def _load_oauth_client(self):
310
+ if not self._oauth_client:
311
+ self.login()
312
+
313
+ return self._oauth_client
314
+
315
+ def _load_token(self):
316
+ if self._token_getter is not None:
317
+ return self._token_getter()
318
+
319
+ return self._token
320
+
321
+ def _save_token(self, token):
322
+ if self._token_setter is not None:
323
+ self._token_setter(token)
324
+ else:
325
+ self._token = token
326
+
327
+ def _should_verify(self):
328
+ if not self._verify:
329
+ return False
330
+
331
+ if (
332
+ "localhost" in self._url
333
+ or "load-balancer" in self._url
334
+ or "proxy" in self._url
335
+ ):
336
+ # Ignore invalid certs and lack of SSL for OAuth if
337
+ # deploying locally.
338
+ requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
339
+ os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
340
+
341
+ return (
342
+ "localhost" not in self._url
343
+ and "load-balancer" not in self._url
344
+ and "proxy" not in self._url
345
+ )
346
+
347
+ def login(self, username=None, password=None):
348
+ """Login to the ONCat API as part of an appropriate OAuth flow.
349
+
350
+ This method is no longer strictly necessary to call manually,
351
+ since if you have specified a login prompt callback or you are
352
+ using the "client credentials" flow, then the client will login
353
+ automatically when it needs to.
354
+
355
+ However, if you are using the "resource owner credentials" flow
356
+ and you have not provided a login prompt callback (or have not
357
+ previously logged in and stored a token which is still active),
358
+ then you will need to call this method manually to login.
359
+
360
+ Arguments:
361
+ ---------
362
+ username: str | None
363
+ The username to use when logging in.
364
+
365
+ password: str | None
366
+ The password to use when logging in.
367
+
368
+ """
369
+ if self._flow == CLIENT_CREDENTIALS_FLOW:
370
+ self._login_client_credentials()
371
+ elif self._flow == RESOURCE_OWNER_CREDENTIALS_FLOW:
372
+ if self._login_prompt:
373
+ while True:
374
+ try:
375
+ if self._load_token() is not None:
376
+ self._login_resource_owner_credentials(None, None)
377
+ else:
378
+ username, password = self._login_prompt()
379
+ self._login_resource_owner_credentials(
380
+ username, password
381
+ )
382
+ break
383
+ except InvalidUserCredentialsError:
384
+ self._save_token(None)
385
+ else:
386
+ self._login_resource_owner_credentials(username, password)
387
+ else:
388
+ raise AssertionError
389
+
390
+ def _login_client_credentials(self):
391
+ with warnings.catch_warnings():
392
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
393
+ self._oauth_client = requests_oauthlib.OAuth2Session(
394
+ client=oauthlib.oauth2.BackendApplicationClient(
395
+ client_id=self._client_id,
396
+ client_secret=self._client_secret,
397
+ scope=self._scopes,
398
+ ),
399
+ scope=self._scopes,
400
+ )
401
+ try:
402
+ token = self._oauth_client.fetch_token(
403
+ self._url + "/oauth/token",
404
+ client_id=self._client_id,
405
+ client_secret=self._client_secret,
406
+ include_client_id=True,
407
+ verify=self._should_verify(),
408
+ scope=self._scopes,
409
+ timeout=self._timeout,
410
+ )
411
+
412
+ except oauthlib.oauth2.rfc6749.errors.InvalidClientError as error:
413
+ msg = (
414
+ "You seem to have provided some invalid client "
415
+ "credentials. Are you sure they are correct?"
416
+ )
417
+ raise InvalidClientCredentialsError(msg) from error
418
+
419
+ self._save_token(token)
420
+
421
+ def _login_resource_owner_credentials(self, username, password):
422
+ if not username or not password:
423
+ token = self._load_token()
424
+ if not token:
425
+ msg = (
426
+ "A username and/or password was not provided when logging "
427
+ "in."
428
+ )
429
+ raise LoginRequiredError(msg)
430
+ else:
431
+ initial_oauth_client = requests_oauthlib.OAuth2Session(
432
+ client=oauthlib.oauth2.LegacyApplicationClient(
433
+ client_id=self._client_id,
434
+ client_secret=self._client_secret,
435
+ )
436
+ )
437
+
438
+ try:
439
+ token = initial_oauth_client.fetch_token(
440
+ self._url + "/oauth/token",
441
+ username=username,
442
+ password=password,
443
+ client_id=self._client_id,
444
+ client_secret=self._client_secret,
445
+ include_client_id=True,
446
+ verify=self._should_verify(),
447
+ scope=self._scopes,
448
+ timeout=self._timeout,
449
+ )
450
+ self._save_token(token)
451
+ except oauthlib.oauth2.rfc6749.errors.InvalidClientError as error:
452
+ msg = (
453
+ "You seem to have provided some invalid client "
454
+ "credentials. Are you sure they are correct?"
455
+ )
456
+ raise InvalidClientCredentialsError(msg) from error
457
+ except oauthlib.oauth2.rfc6749.errors.InvalidGrantError as error:
458
+ if "(invalid_grant)" in str(
459
+ error
460
+ ) and "username & password" in str(error):
461
+ msg = (
462
+ "The user seems to have provided an invalid "
463
+ "username and/or password. They should be shown "
464
+ "an appropriate error message and prompted to try "
465
+ "again."
466
+ )
467
+ raise InvalidUserCredentialsError(msg) from error
468
+
469
+ raise
470
+ auto_refresh_kwargs = {
471
+ "client_id": self._client_id,
472
+ "verify": self._should_verify(),
473
+ }
474
+ if self._client_secret:
475
+ auto_refresh_kwargs["client_secret"] = self._client_secret
476
+
477
+ self._oauth_client = requests_oauthlib.OAuth2Session(
478
+ self._client_id,
479
+ token=token,
480
+ scope=self._scopes,
481
+ auto_refresh_url=self._url + "/oauth/token",
482
+ auto_refresh_kwargs=auto_refresh_kwargs,
483
+ token_updater=self._save_token,
484
+ )
485
+
486
+
487
+ class ONCatObject:
488
+ """A representation of a JSON object returned from the ONCat API.
489
+
490
+ Note that in practice, this class should probably not be
491
+ instantiated directly, but rather should only be returned by the
492
+ ONCat client after a request is made to the API.
493
+
494
+ This class is a convenience wrapper around a dictionary that
495
+ represents a JSON object returned from the ONCat API. All objects
496
+ from the API are wrapped in this class before being returned.
497
+
498
+ The class provides a way to access top-level fields as if they were
499
+ attributes of the underlying dictionary, ways to access nested
500
+ fields using dot-delimited string notation, as well as a way to list
501
+ all the "nodes" in the (possibly nested) dictionary.
502
+
503
+ For a complete description of this class, including example usage,
504
+ please see the documentation on the ONCat website.
505
+
506
+ Arguments:
507
+ ---------
508
+ content: A dictionary
509
+
510
+ Attributes:
511
+ ----------
512
+ object: str
513
+ The type of object that this represents (e.g. "facility",
514
+ "experiment", etc.).
515
+
516
+ id: Any
517
+ The unique identifier for this object.
518
+
519
+ """
520
+
521
+ def __init__(self, content: dict):
522
+ self._content = content
523
+
524
+ def __repr__(self):
525
+ # https://stackoverflow.com/a/2626364/778572
526
+ ident_parts = [
527
+ type(self).__name__,
528
+ "object={}".format(self._content.get("object")),
529
+ "id={}".format(self._content.get("id")),
530
+ ]
531
+
532
+ unicode_repr = "<{} at {}> JSON: {}".format(
533
+ " ".join(ident_parts),
534
+ hex(id(self)),
535
+ str(self),
536
+ )
537
+
538
+ if sys.version_info[0] < 3:
539
+ return unicode_repr.encode("utf-8")
540
+ return unicode_repr
541
+
542
+ def __str__(self):
543
+ return json.dumps(self._content, sort_keys=True, indent=4)
544
+
545
+ def __getattr__(self, k):
546
+ if k[0] == "_":
547
+ raise AttributeError(k)
548
+
549
+ try:
550
+ return self._content[k]
551
+ except KeyError as e:
552
+ raise AttributeError(*e.args) from e
553
+
554
+ def __getitem__(self, key):
555
+ elements = key.split(".")
556
+ current = elements[0]
557
+ remainder = elements[1:] if len(elements) > 1 else None
558
+ if isinstance(self._content[current], dict):
559
+ value = ONCatObject(content=self._content[current])
560
+
561
+ if remainder:
562
+ remainder = ".".join(remainder)
563
+ return value.__getitem__(remainder)
564
+ elif remainder:
565
+ raise KeyError(".".join(remainder))
566
+ else:
567
+ value = self._content[current]
568
+
569
+ return value
570
+
571
+ def to_dict(self) -> dict:
572
+ """Return the underlying dictionary representation of the
573
+ ONCatObject."""
574
+ return self._content
575
+
576
+ def get(self, key: str, default: Any = None) -> Any:
577
+ """Get a value from the ONCatObject.
578
+
579
+ This method is similar to the `get` method of a dictionary, but
580
+ it allows for dot-delimited string notation to access nested
581
+ fields, e.g., `item.get("nested.field")` is roughly equivalent
582
+ to `item.get("nested").get("field")` or
583
+ `item["nested"]["field"]`.
584
+
585
+ Arguments:
586
+ ---------
587
+ key: str
588
+ The key to access in the ONCatObject.
589
+
590
+ default: Any
591
+ The value to return if the key is not found.
592
+
593
+ Returns:
594
+ -------
595
+ The value of the key in the ONCatObject, or the default value
596
+ if the key is not found.
597
+
598
+ """
599
+ try:
600
+ return self[key]
601
+ except KeyError:
602
+ return default
603
+
604
+ def nodes(
605
+ self, root: str | None = None, include_branches: bool = False
606
+ ) -> list[str]:
607
+ """List all the "nodes" in the ONCatObject.
608
+
609
+ This method returns a list of all the full paths that exist in
610
+ the ONCatObject, which can be used to access the values that are
611
+ contained within it.
612
+
613
+ If a `root` is provided, only the keys that start with that
614
+ string will be returned.
615
+
616
+ If `include_branches` is set to `True`, then the keys that are
617
+ dictionaries will also be returned, else only the keys that are
618
+ not dictionaries (i.e., "leaves") will be returned.
619
+
620
+ Arguments:
621
+ ---------
622
+ root: str
623
+ The dot-delimited root path to filter the nodes by.
624
+
625
+ include_branches: bool
626
+ Whether or not to include the branch nodes.
627
+
628
+ """
629
+ result = sorted(_yield_nodes(self._content, None, include_branches))
630
+
631
+ if root:
632
+ # TODO: Not the most performant way of doing this...
633
+ return [key for key in result if key.startswith(root)]
634
+ return result
635
+
636
+
637
+ # Alias name for backwards compatibility.
638
+ ONCatRepresentation = ONCatObject
639
+
640
+
641
+ ########################################################################
642
+ # Mixins
643
+ ########################################################################
644
+
645
+
646
+ class _ONCatMixinMeta(type):
647
+ REGISTERED_RESOURCES: ClassVar[list] = []
648
+
649
+ def __init__(cls, *args):
650
+ super().__init__(args)
651
+ if not cls.__name__.startswith("_"):
652
+ _ONCatMixinMeta.REGISTERED_RESOURCES.append(cls)
653
+
654
+
655
+ class _ONCatResource(metaclass=_ONCatMixinMeta):
656
+ def __init__(self, parent_oncat):
657
+ self._parent_oncat_weakref = weakref.ref(parent_oncat)
658
+
659
+ def retrieve(self, identifier, **kwargs):
660
+ oncat = self._parent_oncat_weakref()
661
+
662
+ if identifier.startswith("/"):
663
+ identifier = identifier[1:]
664
+
665
+ path = f"{self._NAMESPACE}/{self._RESOURCE_ENDPOINT}/{identifier}"
666
+
667
+ return ONCatObject(content=oncat._get(path, **kwargs))
668
+
669
+
670
+ class _ListableONCatMixin(_ONCatResource):
671
+ def list(self, **kwargs):
672
+ oncat = self._parent_oncat_weakref()
673
+
674
+ path = f"{self._NAMESPACE}/{self._RESOURCE_ENDPOINT}"
675
+
676
+ return [
677
+ ONCatObject(content=content)
678
+ for content in oncat._get(path, **kwargs)
679
+ ]
680
+
681
+
682
+ class _UpdatableONCatMixin(_ONCatResource):
683
+ def place(self, identifier, data, **kwargs):
684
+ oncat = self._parent_oncat_weakref()
685
+
686
+ if identifier.startswith("/"):
687
+ identifier = identifier[1:]
688
+
689
+ path = f"{self._NAMESPACE}/{self._RESOURCE_ENDPOINT}/{identifier}"
690
+
691
+ return ONCatObject(content=oncat._put(path, data, **kwargs))
692
+
693
+
694
+ class _CreatableONCatMixin(_ONCatResource):
695
+ def create(self, data, **kwargs):
696
+ oncat = self._parent_oncat_weakref()
697
+
698
+ path = f"{self._NAMESPACE}/{self._RESOURCE_ENDPOINT}"
699
+
700
+ return ONCatObject(content=oncat._post(path, data, **kwargs))
701
+
702
+
703
+ class _RemovableONCatMixin(_ONCatResource):
704
+ def remove(self, identifier, **kwargs):
705
+ oncat = self._parent_oncat_weakref()
706
+
707
+ if identifier.startswith("/"):
708
+ identifier = identifier[1:]
709
+
710
+ path = f"{self._NAMESPACE}/{self._RESOURCE_ENDPOINT}/{identifier}"
711
+
712
+ oncat._delete(path, **kwargs)
713
+
714
+
715
+ class _ControllerONCatMixin(_ONCatResource):
716
+ def __getattr__(self, k):
717
+ if k[0] == "_":
718
+ raise AttributeError(k)
719
+
720
+ return functools.partial(self._controller, k)
721
+
722
+ def _controller(self, name, *args, **kwargs):
723
+ if len(args) == 0 or len(args) > 2:
724
+ raise TypeError
725
+ if len(args) == 1:
726
+ if isinstance(args[0], str):
727
+ identifier = args[0]
728
+ data = None
729
+ else:
730
+ identifier = None
731
+ data = args[0]
732
+ else:
733
+ if not isinstance(args[0], str):
734
+ raise TypeError
735
+ identifier = args[0]
736
+ data = args[1]
737
+
738
+ if identifier is not None and identifier.startswith("/"):
739
+ identifier = identifier[1:]
740
+
741
+ oncat = self._parent_oncat_weakref()
742
+
743
+ if identifier is not None:
744
+ path = (
745
+ f"{self._NAMESPACE}/{self._RESOURCE_ENDPOINT}/"
746
+ + f"{identifier}/{name}"
747
+ )
748
+ else:
749
+ path = f"{self._NAMESPACE}/{self._RESOURCE_ENDPOINT}/{name}"
750
+
751
+ return ONCatObject(content=oncat._post(path, data, **kwargs))
752
+
753
+
754
+ ########################################################################
755
+ # Resources
756
+ ########################################################################
757
+
758
+
759
+ class Facility(_ListableONCatMixin):
760
+ _RESOURCE_ENDPOINT = "facilities"
761
+ _NAMESPACE = "api"
762
+
763
+
764
+ class Instrument(_ListableONCatMixin):
765
+ _RESOURCE_ENDPOINT = "instruments"
766
+ _NAMESPACE = "api"
767
+
768
+
769
+ class Experiment(_ListableONCatMixin):
770
+ _RESOURCE_ENDPOINT = "experiments"
771
+ _NAMESPACE = "api"
772
+
773
+
774
+ class Run(_ListableONCatMixin):
775
+ _RESOURCE_ENDPOINT = "runs"
776
+ _NAMESPACE = "experimental"
777
+
778
+
779
+ class Group(_ListableONCatMixin):
780
+ _RESOURCE_ENDPOINT = "groups"
781
+ _NAMESPACE = "experimental"
782
+
783
+
784
+ class Grouping(_ListableONCatMixin):
785
+ _RESOURCE_ENDPOINT = "groupings"
786
+ _NAMESPACE = "experimental"
787
+
788
+
789
+ class Mapping(_ListableONCatMixin):
790
+ _RESOURCE_ENDPOINT = "mappings"
791
+ _NAMESPACE = "experimental"
792
+
793
+
794
+ class Proposal(_ListableONCatMixin):
795
+ _RESOURCE_ENDPOINT = "proposals"
796
+ _NAMESPACE = "experimental"
797
+
798
+
799
+ class Item(_ListableONCatMixin):
800
+ _RESOURCE_ENDPOINT = "items"
801
+ _NAMESPACE = "experimental"
802
+
803
+
804
+ class Archive(
805
+ _ListableONCatMixin, _RemovableONCatMixin, _ControllerONCatMixin
806
+ ):
807
+ _RESOURCE_ENDPOINT = "archives"
808
+ _NAMESPACE = "api"
809
+
810
+
811
+ class Datafile(
812
+ _ControllerONCatMixin,
813
+ _CreatableONCatMixin,
814
+ _ListableONCatMixin,
815
+ _RemovableONCatMixin,
816
+ ):
817
+ _RESOURCE_ENDPOINT = "datafiles"
818
+ _NAMESPACE = "api"
819
+
820
+
821
+ class Reduction(
822
+ _ControllerONCatMixin,
823
+ _CreatableONCatMixin,
824
+ _ListableONCatMixin,
825
+ _RemovableONCatMixin,
826
+ ):
827
+ _RESOURCE_ENDPOINT = "reductions"
828
+ _NAMESPACE = "api"
829
+
830
+
831
+ class Simulation(_ControllerONCatMixin, _ListableONCatMixin):
832
+ _RESOURCE_ENDPOINT = "simulations"
833
+ _NAMESPACE = "api"
834
+
835
+
836
+ class User(_ListableONCatMixin):
837
+ _RESOURCE_ENDPOINT = "users"
838
+ _NAMESPACE = "api"
839
+
840
+
841
+ class Task(_ListableONCatMixin):
842
+ _RESOURCE_ENDPOINT = "tasks"
843
+ _NAMESPACE = "api"
844
+
845
+
846
+ ########################################################################
847
+
848
+
849
+ class Info(_ONCatResource):
850
+ _RESOURCE_ENDPOINT = "info"
851
+ _NAMESPACE = "data"
852
+
853
+
854
+ ########################################################################
855
+
856
+
857
+ class CacheEntry(_UpdatableONCatMixin):
858
+ _RESOURCE_ENDPOINT = "cache"
859
+ _NAMESPACE = "admin"
860
+
861
+
862
+ class UsageEntry(_ListableONCatMixin):
863
+ _RESOURCE_ENDPOINT = "usage"
864
+ _NAMESPACE = "admin"
865
+
866
+
867
+ ########################################################################
868
+
869
+
870
+ class Template(
871
+ _CreatableONCatMixin, _ListableONCatMixin, _RemovableONCatMixin
872
+ ):
873
+ _RESOURCE_ENDPOINT = "templates"
874
+ _NAMESPACE = "settings"
875
+
876
+
877
+ ########################################################################
878
+ # Misc Tools
879
+ ########################################################################
880
+
881
+
882
+ class _InMemoryTokenStore:
883
+ """A token store that persists OAuth tokens in memory only.
884
+
885
+ This kind of store is only really useful for testing or as an
886
+ example of the interface a token store should provide; in its
887
+ current form it does not offer anything that the `ONCat` client
888
+ itself does not already provide when leaving the getter and setter
889
+ parameters empty.
890
+ """
891
+
892
+ def __init__(self):
893
+ self._token = None
894
+
895
+ def set_token(self, token: str | None):
896
+ self._token = token
897
+
898
+ def get_token(self) -> str | None:
899
+ return self._token
900
+
901
+
902
+ class FileSystemTokenStore:
903
+ """A token store that persists OAuth tokens to a file on disk.
904
+
905
+ The file is created if it doesn't already exist, however the parent
906
+ directory must exist and you must have write permissions else an
907
+ exception will be raised.
908
+
909
+ This kind of store may be useful for long-lived applications where
910
+ you need to persist the token across uses of the client, and it is
911
+ an instance of an application that is run by a single user. This
912
+ kind of store is not suitable for applications that have the
913
+ expectation of being able to manage sessions for multiple users.
914
+
915
+ Arguments:
916
+ ---------
917
+ path: str
918
+ The path to the file where the token will be stored.
919
+
920
+ """
921
+
922
+ def __init__(self, path: str):
923
+ self.path = path
924
+
925
+ def read_token(self) -> str | None:
926
+ """Read the token from the file on disk, if it exists.
927
+
928
+ Return:
929
+ ------
930
+ The token if it exists, else `None`.
931
+
932
+ """
933
+ if not os.path.exists(self.path):
934
+ return None
935
+
936
+ with open(self.path) as storage:
937
+ return json.load(storage)
938
+
939
+ def write_token(self, token: str | None):
940
+ """Write the token to the file on disk.
941
+
942
+ Arguments:
943
+ ---------
944
+ token: str | None
945
+ The token to write to the file, or `None` to clear the
946
+ token.
947
+
948
+ """
949
+ with open(self.path, "w") as storage:
950
+ json.dump(token, storage)
951
+
952
+
953
+ def prompt_user_for_login() -> tuple[str, str]:
954
+ """Prompt the user for their login credentials via the command line."""
955
+ print("Enter UCAMS / XCAMS...")
956
+ username = input("Username: ")
957
+ password = getpass.getpass("Password: ")
958
+
959
+ return username, password
960
+
961
+
962
+ class _UserConfigFile:
963
+ """Subject to change and NOT recommended for use by end-users."""
964
+
965
+ def __init__(self, client_name, path=None):
966
+ self._client_name = client_name
967
+ self.path = (
968
+ os.path.join(os.path.expanduser("~"), ".oncatrc")
969
+ if path is None
970
+ else path
971
+ )
972
+
973
+ def _content(self):
974
+ with open(self.path) as user_config_file:
975
+ return json.load(user_config_file)
976
+
977
+ def client_id(self):
978
+ return self._content()[self._client_name]["client_id"]
979
+
980
+ def client_secret(self):
981
+ return self._content()[self._client_name]["client_secret"]
982
+
983
+ def token_getter(self):
984
+ return self._content()[self._client_name].get("current_token", None)
985
+
986
+ def token_setter(self, token):
987
+ content = self._content()
988
+ content[self._client_name]["current_token"] = token
989
+
990
+ pathlib.Path(self.path).write_text(
991
+ json.dumps(content, indent=4, sort_keys=True)
992
+ )
993
+
994
+
995
+ ########################################################################
996
+ # Helpers
997
+ ########################################################################
998
+
999
+
1000
+ def _yield_nodes(d, path, include_branches):
1001
+ for node in d:
1002
+ node_path = f"{path}.{node}" if path else node
1003
+ if isinstance(d[node], dict):
1004
+ if include_branches:
1005
+ yield node_path
1006
+ yield from _yield_nodes(d[node], node_path, include_branches)
1007
+ else:
1008
+ yield node_path
1009
+
1010
+
1011
+ ########################################################################
1012
+ # Exceptions
1013
+ ########################################################################
1014
+
1015
+
1016
+ class PyONCatError(Exception):
1017
+ """A base exception for all errors that may be raised by PyONCat."""
1018
+
1019
+ def __init__(self, message=None, original_error=None):
1020
+ if not message:
1021
+ message = ""
1022
+ with contextlib.suppress(Exception):
1023
+ message += " [" + original_error.response.json()["message"] + "]"
1024
+ super().__init__(message)
1025
+ self.original_error = original_error
1026
+
1027
+
1028
+ class UnauthorizedError(PyONCatError):
1029
+ """An error that is raised when the client is not authorized to
1030
+ access a resource.
1031
+ """
1032
+
1033
+
1034
+ class InvalidClientCredentialsError(PyONCatError):
1035
+ """An error that is raised when the client credentials provided are
1036
+ invalid.
1037
+ """
1038
+
1039
+
1040
+ class InvalidUserCredentialsError(PyONCatError):
1041
+ """An error that is raised when the user credentials provided are
1042
+ invalid.
1043
+ """
1044
+
1045
+
1046
+ class InvalidRefreshTokenError(PyONCatError):
1047
+ """An error that is raised when the refresh token provided is
1048
+ invalid.
1049
+ """
1050
+
1051
+
1052
+ class LoginRequiredError(PyONCatError):
1053
+ """An error that is raised when a login is required to access a
1054
+ resource.
1055
+ """
1056
+
1057
+
1058
+ class NotFoundError(PyONCatError):
1059
+ """An error that is raised when a resource cannot be found."""
1060
+
1061
+
1062
+ class BadRequestError(PyONCatError):
1063
+ """An error that is raised when a bad request is made to the API.
1064
+
1065
+ Check your input and try again.
1066
+ """