pyoncat 2.3__tar.gz → 2.5__tar.gz
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-2.3 → pyoncat-2.5}/PKG-INFO +1 -1
- {pyoncat-2.3 → pyoncat-2.5}/pyoncat/__init__.py +313 -17
- {pyoncat-2.3 → pyoncat-2.5}/pyproject.toml +1 -1
- {pyoncat-2.3 → pyoncat-2.5}/pyoncat/LICENSE +0 -0
- {pyoncat-2.3 → pyoncat-2.5}/pyoncat/README.md +0 -0
- {pyoncat-2.3 → pyoncat-2.5}/pyoncat/reduction.py +0 -0
|
@@ -14,6 +14,8 @@ import getpass
|
|
|
14
14
|
import json
|
|
15
15
|
import os
|
|
16
16
|
import pathlib
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
17
19
|
import warnings
|
|
18
20
|
import weakref
|
|
19
21
|
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal
|
|
@@ -28,13 +30,16 @@ from urllib3.exceptions import InsecureRequestWarning
|
|
|
28
30
|
|
|
29
31
|
from pyoncat.reduction import ReductionFileBuilder
|
|
30
32
|
|
|
31
|
-
__version__ = "2.
|
|
33
|
+
__version__ = "2.5"
|
|
32
34
|
|
|
33
35
|
# Constant for autoreduction user
|
|
34
36
|
AUTOREDUCTION_USER = "auto"
|
|
35
37
|
|
|
36
38
|
__all__ = [
|
|
37
39
|
"AUTOREDUCTION_USER",
|
|
40
|
+
"CLIENT_CREDENTIALS_FLOW",
|
|
41
|
+
"DEVICE_AUTHORIZATION_FLOW",
|
|
42
|
+
"RESOURCE_OWNER_CREDENTIALS_FLOW",
|
|
38
43
|
"FileSystemTokenStore",
|
|
39
44
|
"ONCat",
|
|
40
45
|
"ONCatObject",
|
|
@@ -52,13 +57,61 @@ __all__ = [
|
|
|
52
57
|
FlowType = Literal[
|
|
53
58
|
"client_credentials",
|
|
54
59
|
"resource_owner",
|
|
60
|
+
"device_authorization",
|
|
55
61
|
]
|
|
56
62
|
CLIENT_CREDENTIALS_FLOW: FlowType = "client_credentials"
|
|
57
63
|
RESOURCE_OWNER_CREDENTIALS_FLOW: FlowType = "resource_owner"
|
|
64
|
+
DEVICE_AUTHORIZATION_FLOW: FlowType = "device_authorization"
|
|
58
65
|
|
|
59
66
|
|
|
60
67
|
assert isinstance(CLIENT_CREDENTIALS_FLOW, str)
|
|
61
68
|
|
|
69
|
+
# RFC 8628 §3.5 default polling interval (seconds), used when the device
|
|
70
|
+
# authorization response does not specify one.
|
|
71
|
+
_DEFAULT_DEVICE_FLOW_INTERVAL = 5
|
|
72
|
+
|
|
73
|
+
# RFC 8628 §3.5: on `slow_down`, the client MUST increase the polling
|
|
74
|
+
# interval by 5 seconds for subsequent requests.
|
|
75
|
+
_DEVICE_FLOW_SLOW_DOWN_BUMP = 5
|
|
76
|
+
|
|
77
|
+
# `urn:ietf:params:oauth:grant-type:device_code` per RFC 8628 §3.4.
|
|
78
|
+
_DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
|
79
|
+
|
|
80
|
+
# Default OAuth IdP base URL. Used when `idp_url` is not provided to the
|
|
81
|
+
# `ONCat` constructor; resolves to ORNL's external PingFed instance, which
|
|
82
|
+
# hosts the device-authorization and token endpoints for the public
|
|
83
|
+
# `oncat_cli_user` client.
|
|
84
|
+
_DEFAULT_IDP_URL = "https://extidp.ornl.gov"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_DEVICE_FLOW_REFRESH_EXPIRED_MESSAGE = (
|
|
88
|
+
"Your ONCat session has expired and could not be automatically "
|
|
89
|
+
"refreshed.\n"
|
|
90
|
+
"\n"
|
|
91
|
+
"To re-authenticate, call:\n"
|
|
92
|
+
"\n"
|
|
93
|
+
" oncat.login()\n"
|
|
94
|
+
"\n"
|
|
95
|
+
"This will print a verification URL and a one-time code; complete the "
|
|
96
|
+
"flow in\n"
|
|
97
|
+
"your browser. PyONCat will then resume normally on the next API call."
|
|
98
|
+
"\n\n"
|
|
99
|
+
"If you want PyONCat to prompt you to re-authenticate automatically "
|
|
100
|
+
"when this\n"
|
|
101
|
+
"happens, construct the client with `reauth_on_expired=True` (the "
|
|
102
|
+
"default).\n"
|
|
103
|
+
"\n"
|
|
104
|
+
"Set `reauth_on_expired=False` for unattended / non-interactive use, "
|
|
105
|
+
"in which\n"
|
|
106
|
+
"case this exception is raised immediately on session expiry."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_DEVICE_FLOW_REAUTH_PROMPT = (
|
|
111
|
+
"Your ONCat session has expired. Press Enter to re-authenticate via "
|
|
112
|
+
"your browser, or Ctrl-C to abort."
|
|
113
|
+
)
|
|
114
|
+
|
|
62
115
|
|
|
63
116
|
class ONCat:
|
|
64
117
|
"""The ONCat API client.
|
|
@@ -157,6 +210,23 @@ class ONCat:
|
|
|
157
210
|
client will wait indefinitely for a response, as per the
|
|
158
211
|
underlying behavior of the `requests` module.
|
|
159
212
|
|
|
213
|
+
idp_url: str | None
|
|
214
|
+
The base URL of the OAuth identity provider (e.g.
|
|
215
|
+
`https://extidp.ornl.gov`). Used when `flow` is
|
|
216
|
+
`DEVICE_AUTHORIZATION_FLOW` so that device-authorization
|
|
217
|
+
and token requests bypass any ONCat-side OAuth proxy and
|
|
218
|
+
hit the IdP directly. When `None`, defaults to
|
|
219
|
+
`https://extidp.ornl.gov` (ORNL's external PingFed).
|
|
220
|
+
|
|
221
|
+
reauth_on_expired: bool
|
|
222
|
+
When using `DEVICE_AUTHORIZATION_FLOW` and the refresh
|
|
223
|
+
token has expired, controls how the client recovers.
|
|
224
|
+
`True` (the default) prompts on stderr for the user to
|
|
225
|
+
press Enter to re-authenticate via the browser, then
|
|
226
|
+
retries the original request once. `False` raises
|
|
227
|
+
`InvalidRefreshTokenError` immediately, which is
|
|
228
|
+
appropriate for unattended or non-interactive use.
|
|
229
|
+
|
|
160
230
|
"""
|
|
161
231
|
|
|
162
232
|
if TYPE_CHECKING:
|
|
@@ -179,8 +249,8 @@ class ONCat:
|
|
|
179
249
|
User: User
|
|
180
250
|
Task: Task
|
|
181
251
|
Info: Info
|
|
182
|
-
|
|
183
|
-
|
|
252
|
+
Cache: Cache
|
|
253
|
+
Usage: Usage
|
|
184
254
|
Template: Template
|
|
185
255
|
|
|
186
256
|
def __init__(
|
|
@@ -196,6 +266,8 @@ class ONCat:
|
|
|
196
266
|
scopes: list[str] | None = None,
|
|
197
267
|
verify: bool = True,
|
|
198
268
|
timeout: int | None = None,
|
|
269
|
+
idp_url: str | None = None,
|
|
270
|
+
reauth_on_expired: bool = True,
|
|
199
271
|
):
|
|
200
272
|
if not url:
|
|
201
273
|
msg = "A URL must be provided."
|
|
@@ -242,10 +314,25 @@ class ONCat:
|
|
|
242
314
|
self._scopes: list[Any] | None = scopes
|
|
243
315
|
self._verify = verify
|
|
244
316
|
self._timeout = timeout
|
|
317
|
+
self._idp_url = idp_url if idp_url is not None else _DEFAULT_IDP_URL
|
|
318
|
+
self._reauth_on_expired = reauth_on_expired
|
|
245
319
|
|
|
246
320
|
self._token = None
|
|
247
321
|
self._oauth_client = None
|
|
248
322
|
|
|
323
|
+
if flow == RESOURCE_OWNER_CREDENTIALS_FLOW:
|
|
324
|
+
warnings.warn(
|
|
325
|
+
"OAuth 2.0 Resource Owner Password Credentials grant is "
|
|
326
|
+
'deprecated. RFC 9700 §2.4: "The resource owner password '
|
|
327
|
+
'credentials grant [RFC6749] MUST NOT be used." Prefer '
|
|
328
|
+
"the device authorization flow "
|
|
329
|
+
"(flow=DEVICE_AUTHORIZATION_FLOW) for human-user "
|
|
330
|
+
"authentication. ROPC remains available indefinitely as "
|
|
331
|
+
"a compatibility path.",
|
|
332
|
+
DeprecationWarning,
|
|
333
|
+
stacklevel=2,
|
|
334
|
+
)
|
|
335
|
+
|
|
249
336
|
for resource_cls in _ONCatMixinMeta.REGISTERED_RESOURCES:
|
|
250
337
|
setattr(self, resource_cls.__name__, resource_cls(self))
|
|
251
338
|
|
|
@@ -334,15 +421,60 @@ class ONCat:
|
|
|
334
421
|
"your calls to the ONCat API.\n\n"
|
|
335
422
|
)
|
|
336
423
|
raise InvalidRefreshTokenError(msg) from error
|
|
424
|
+
if (
|
|
425
|
+
self._flow == DEVICE_AUTHORIZATION_FLOW
|
|
426
|
+
and "(invalid_grant)" in str(error)
|
|
427
|
+
):
|
|
428
|
+
return self._handle_device_flow_token_expiry(
|
|
429
|
+
send_request, error
|
|
430
|
+
)
|
|
337
431
|
msg = f'Error: "{error!s}"'
|
|
338
432
|
raise PyONCatError(msg) from error
|
|
339
433
|
except oauthlib.oauth2.TokenExpiredError as error:
|
|
340
434
|
if self._flow == CLIENT_CREDENTIALS_FLOW:
|
|
341
435
|
self.login()
|
|
342
436
|
return send_request()
|
|
437
|
+
if self._flow == DEVICE_AUTHORIZATION_FLOW:
|
|
438
|
+
return self._handle_device_flow_token_expiry(
|
|
439
|
+
send_request, error
|
|
440
|
+
)
|
|
343
441
|
msg = f'Error: "{error!s}"'
|
|
344
442
|
raise PyONCatError(msg) from error
|
|
345
443
|
|
|
444
|
+
def _handle_device_flow_token_expiry(self, send_request, error):
|
|
445
|
+
"""Recover from an expired Device Flow refresh token.
|
|
446
|
+
|
|
447
|
+
Clears the persisted token and, when `reauth_on_expired` is
|
|
448
|
+
`True`, prompts on stderr for the user to re-authenticate via
|
|
449
|
+
the browser before retrying the original request once. When
|
|
450
|
+
`reauth_on_expired` is `False`, raises immediately so callers
|
|
451
|
+
running unattended can react without blocking on `input()`.
|
|
452
|
+
"""
|
|
453
|
+
self._save_token(None)
|
|
454
|
+
|
|
455
|
+
if not self._reauth_on_expired:
|
|
456
|
+
raise InvalidRefreshTokenError(
|
|
457
|
+
_DEVICE_FLOW_REFRESH_EXPIRED_MESSAGE
|
|
458
|
+
) from error
|
|
459
|
+
|
|
460
|
+
print(_DEVICE_FLOW_REAUTH_PROMPT, file=sys.stderr)
|
|
461
|
+
try:
|
|
462
|
+
input()
|
|
463
|
+
except EOFError as eof_error:
|
|
464
|
+
# Non-interactive stdin (piped, closed, no human present);
|
|
465
|
+
# do not auto-recover -- the prompt's purpose was to confirm
|
|
466
|
+
# human presence.
|
|
467
|
+
raise InvalidRefreshTokenError(
|
|
468
|
+
_DEVICE_FLOW_REFRESH_EXPIRED_MESSAGE
|
|
469
|
+
) from eof_error
|
|
470
|
+
# KeyboardInterrupt propagates naturally; no special handling.
|
|
471
|
+
|
|
472
|
+
# Drop any cached OAuth session so `login()` re-runs the device
|
|
473
|
+
# flow rather than reusing an exhausted refresh token.
|
|
474
|
+
self._oauth_client = None
|
|
475
|
+
self.login()
|
|
476
|
+
return send_request()
|
|
477
|
+
|
|
346
478
|
def _load_oauth_client(self):
|
|
347
479
|
if not self._oauth_client:
|
|
348
480
|
self.login()
|
|
@@ -421,6 +553,8 @@ class ONCat:
|
|
|
421
553
|
self._save_token(None)
|
|
422
554
|
else:
|
|
423
555
|
self._login_resource_owner_credentials(username, password)
|
|
556
|
+
elif self._flow == DEVICE_AUTHORIZATION_FLOW:
|
|
557
|
+
self._login_device_authorization()
|
|
424
558
|
else:
|
|
425
559
|
raise AssertionError
|
|
426
560
|
|
|
@@ -522,6 +656,133 @@ class ONCat:
|
|
|
522
656
|
token_updater=self._save_token,
|
|
523
657
|
)
|
|
524
658
|
|
|
659
|
+
def _login_device_authorization(
|
|
660
|
+
self,
|
|
661
|
+
prompt: Callable[[str, str], None] | None = None,
|
|
662
|
+
sleep: Callable[[float], None] | None = None,
|
|
663
|
+
):
|
|
664
|
+
"""Drive the OAuth 2.0 Device Authorization Grant (RFC 8628).
|
|
665
|
+
|
|
666
|
+
The PingFed device-authorization endpoint is hit at
|
|
667
|
+
`<idp_url>/as/device_authz.oauth2` and the token endpoint at
|
|
668
|
+
`<idp_url>/as/token.oauth2`. After receiving a token, an
|
|
669
|
+
`OAuth2Session` is wired with `auto_refresh_url` so subsequent
|
|
670
|
+
API calls roll the refresh token transparently.
|
|
671
|
+
|
|
672
|
+
`prompt` and `sleep` exist to make the polling loop deterministic
|
|
673
|
+
in unit tests; production callers pass neither.
|
|
674
|
+
"""
|
|
675
|
+
if self._load_token() is not None:
|
|
676
|
+
self._setup_device_oauth_session(self._load_token())
|
|
677
|
+
return
|
|
678
|
+
|
|
679
|
+
if prompt is None:
|
|
680
|
+
prompt = _default_device_flow_prompt
|
|
681
|
+
if sleep is None:
|
|
682
|
+
sleep = time.sleep
|
|
683
|
+
|
|
684
|
+
assert self._client_id
|
|
685
|
+
|
|
686
|
+
device_auth_url = self._idp_url.rstrip("/") + "/as/device_authz.oauth2"
|
|
687
|
+
token_url = self._idp_url.rstrip("/") + "/as/token.oauth2"
|
|
688
|
+
|
|
689
|
+
scope_str = " ".join(self._scopes) if self._scopes else None
|
|
690
|
+
|
|
691
|
+
device_auth_body: dict[str, str] = {"client_id": self._client_id}
|
|
692
|
+
if scope_str:
|
|
693
|
+
device_auth_body["scope"] = scope_str
|
|
694
|
+
|
|
695
|
+
device_auth_response = requests.post(
|
|
696
|
+
device_auth_url,
|
|
697
|
+
data=device_auth_body,
|
|
698
|
+
verify=self._should_verify(),
|
|
699
|
+
timeout=self._timeout,
|
|
700
|
+
headers={
|
|
701
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
702
|
+
"Accept": "application/json",
|
|
703
|
+
},
|
|
704
|
+
)
|
|
705
|
+
if not device_auth_response.ok:
|
|
706
|
+
msg = (
|
|
707
|
+
"Device authorization request failed with status "
|
|
708
|
+
f"{device_auth_response.status_code}: "
|
|
709
|
+
f"{device_auth_response.text}"
|
|
710
|
+
)
|
|
711
|
+
raise DeviceAuthorizationError(msg)
|
|
712
|
+
|
|
713
|
+
device_auth = device_auth_response.json()
|
|
714
|
+
device_code = device_auth["device_code"]
|
|
715
|
+
user_code = device_auth["user_code"]
|
|
716
|
+
verification_uri = device_auth["verification_uri"]
|
|
717
|
+
interval = float(
|
|
718
|
+
device_auth.get("interval", _DEFAULT_DEVICE_FLOW_INTERVAL)
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
prompt(verification_uri, user_code)
|
|
722
|
+
|
|
723
|
+
token_body = {
|
|
724
|
+
"grant_type": _DEVICE_CODE_GRANT_TYPE,
|
|
725
|
+
"device_code": device_code,
|
|
726
|
+
"client_id": self._client_id,
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
while True:
|
|
730
|
+
sleep(interval)
|
|
731
|
+
|
|
732
|
+
token_response = requests.post(
|
|
733
|
+
token_url,
|
|
734
|
+
data=token_body,
|
|
735
|
+
verify=self._should_verify(),
|
|
736
|
+
timeout=self._timeout,
|
|
737
|
+
headers={
|
|
738
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
739
|
+
"Accept": "application/json",
|
|
740
|
+
},
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
if token_response.ok:
|
|
744
|
+
token = token_response.json()
|
|
745
|
+
break
|
|
746
|
+
|
|
747
|
+
try:
|
|
748
|
+
error_payload = token_response.json()
|
|
749
|
+
except ValueError:
|
|
750
|
+
error_payload = {}
|
|
751
|
+
error_code = error_payload.get("error")
|
|
752
|
+
|
|
753
|
+
if error_code == "authorization_pending":
|
|
754
|
+
continue
|
|
755
|
+
if error_code == "slow_down":
|
|
756
|
+
interval += _DEVICE_FLOW_SLOW_DOWN_BUMP
|
|
757
|
+
continue
|
|
758
|
+
if error_code in {"expired_token", "access_denied"}:
|
|
759
|
+
msg = f"Device authorization terminated: {error_code}"
|
|
760
|
+
raise DeviceAuthorizationError(msg)
|
|
761
|
+
msg = (
|
|
762
|
+
"Device token poll failed with status "
|
|
763
|
+
f"{token_response.status_code}: {token_response.text}"
|
|
764
|
+
)
|
|
765
|
+
raise DeviceAuthorizationError(msg)
|
|
766
|
+
|
|
767
|
+
self._save_token(token)
|
|
768
|
+
self._setup_device_oauth_session(token)
|
|
769
|
+
|
|
770
|
+
def _setup_device_oauth_session(self, token):
|
|
771
|
+
token_url = self._idp_url.rstrip("/") + "/as/token.oauth2"
|
|
772
|
+
with warnings.catch_warnings():
|
|
773
|
+
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
|
774
|
+
self._oauth_client = requests_oauthlib.OAuth2Session(
|
|
775
|
+
self._client_id,
|
|
776
|
+
token=token,
|
|
777
|
+
scope=self._scopes,
|
|
778
|
+
auto_refresh_url=token_url,
|
|
779
|
+
auto_refresh_kwargs={
|
|
780
|
+
"client_id": self._client_id,
|
|
781
|
+
"verify": self._should_verify(),
|
|
782
|
+
},
|
|
783
|
+
token_updater=self._save_token,
|
|
784
|
+
)
|
|
785
|
+
|
|
525
786
|
|
|
526
787
|
class ONCatObject:
|
|
527
788
|
"""A representation of a JSON object returned from the ONCat API.
|
|
@@ -868,6 +1129,14 @@ class Cycle(_ListableONCatMixin, _ControllerONCatMixin):
|
|
|
868
1129
|
_NAMESPACE = "api"
|
|
869
1130
|
|
|
870
1131
|
|
|
1132
|
+
class Directories(
|
|
1133
|
+
_ControllerONCatMixin,
|
|
1134
|
+
_ListableONCatMixin,
|
|
1135
|
+
):
|
|
1136
|
+
_RESOURCE_ENDPOINT = "directories"
|
|
1137
|
+
_NAMESPACE = "data"
|
|
1138
|
+
|
|
1139
|
+
|
|
871
1140
|
class Datafile(
|
|
872
1141
|
_ControllerONCatMixin,
|
|
873
1142
|
_CreatableONCatMixin,
|
|
@@ -914,12 +1183,12 @@ class Info(_ONCatResource):
|
|
|
914
1183
|
########################################################################
|
|
915
1184
|
|
|
916
1185
|
|
|
917
|
-
class
|
|
1186
|
+
class Cache(_UpdatableONCatMixin):
|
|
918
1187
|
_RESOURCE_ENDPOINT = "cache"
|
|
919
1188
|
_NAMESPACE = "admin"
|
|
920
1189
|
|
|
921
1190
|
|
|
922
|
-
class
|
|
1191
|
+
class Usage(_ListableONCatMixin):
|
|
923
1192
|
_RESOURCE_ENDPOINT = "usage"
|
|
924
1193
|
_NAMESPACE = "admin"
|
|
925
1194
|
|
|
@@ -940,42 +1209,42 @@ class Template(
|
|
|
940
1209
|
|
|
941
1210
|
|
|
942
1211
|
class ExperimentSummary(_ONCatResource):
|
|
943
|
-
_RESOURCE_ENDPOINT = "
|
|
1212
|
+
_RESOURCE_ENDPOINT = "experiments"
|
|
944
1213
|
_NAMESPACE = "summary"
|
|
945
1214
|
|
|
946
1215
|
|
|
947
1216
|
class GroupingSummary(_ONCatResource):
|
|
948
|
-
_RESOURCE_ENDPOINT = "
|
|
1217
|
+
_RESOURCE_ENDPOINT = "groupings"
|
|
949
1218
|
_NAMESPACE = "summary"
|
|
950
1219
|
|
|
951
1220
|
|
|
952
1221
|
class InstrumentSummary(_ONCatResource):
|
|
953
|
-
_RESOURCE_ENDPOINT = "
|
|
1222
|
+
_RESOURCE_ENDPOINT = "instruments"
|
|
954
1223
|
_NAMESPACE = "summary"
|
|
955
1224
|
|
|
956
1225
|
|
|
957
1226
|
class ReductionSummary(_ONCatResource):
|
|
958
|
-
_RESOURCE_ENDPOINT = "
|
|
1227
|
+
_RESOURCE_ENDPOINT = "reductions"
|
|
959
1228
|
_NAMESPACE = "summary"
|
|
960
1229
|
|
|
961
1230
|
|
|
962
1231
|
class RunSummary(_ONCatResource):
|
|
963
|
-
_RESOURCE_ENDPOINT = "
|
|
1232
|
+
_RESOURCE_ENDPOINT = "runs"
|
|
964
1233
|
_NAMESPACE = "summary"
|
|
965
1234
|
|
|
966
1235
|
|
|
967
1236
|
class SampleSummary(_ONCatResource):
|
|
968
|
-
_RESOURCE_ENDPOINT = "
|
|
1237
|
+
_RESOURCE_ENDPOINT = "samples"
|
|
969
1238
|
_NAMESPACE = "summary"
|
|
970
1239
|
|
|
971
1240
|
|
|
972
1241
|
class ScanSummary(_ONCatResource):
|
|
973
|
-
_RESOURCE_ENDPOINT = "
|
|
1242
|
+
_RESOURCE_ENDPOINT = "scans"
|
|
974
1243
|
_NAMESPACE = "summary"
|
|
975
1244
|
|
|
976
1245
|
|
|
977
1246
|
class SessionSummary(_ONCatResource):
|
|
978
|
-
_RESOURCE_ENDPOINT = "
|
|
1247
|
+
_RESOURCE_ENDPOINT = "sessions"
|
|
979
1248
|
_NAMESPACE = "summary"
|
|
980
1249
|
|
|
981
1250
|
|
|
@@ -1007,9 +1276,9 @@ class _InMemoryTokenStore:
|
|
|
1007
1276
|
class FileSystemTokenStore:
|
|
1008
1277
|
"""A token store that persists OAuth tokens to a file on disk.
|
|
1009
1278
|
|
|
1010
|
-
The file is created if it doesn't already exist,
|
|
1011
|
-
|
|
1012
|
-
exception will be raised.
|
|
1279
|
+
The file is created if it doesn't already exist, including any
|
|
1280
|
+
necessary parent directories. You must have write permissions to
|
|
1281
|
+
the parent directory else an exception will be raised.
|
|
1013
1282
|
|
|
1014
1283
|
This kind of store may be useful for long-lived applications where
|
|
1015
1284
|
you need to persist the token across uses of the client, and it is
|
|
@@ -1044,15 +1313,25 @@ class FileSystemTokenStore:
|
|
|
1044
1313
|
def write_token(self, token: dict | None):
|
|
1045
1314
|
"""Write the token to the file on disk.
|
|
1046
1315
|
|
|
1316
|
+
On first creation, the file is set to mode 0o600. On subsequent
|
|
1317
|
+
writes, the existing mode is preserved.
|
|
1318
|
+
|
|
1047
1319
|
Arguments:
|
|
1048
1320
|
---------
|
|
1049
1321
|
token: str | None
|
|
1050
1322
|
The token to write to the file, or `None` to clear the
|
|
1051
|
-
token.
|
|
1323
|
+
token (which persists a JSON `null` payload).
|
|
1052
1324
|
|
|
1053
1325
|
"""
|
|
1326
|
+
parent_dir = os.path.dirname(os.path.abspath(self.path))
|
|
1327
|
+
if parent_dir:
|
|
1328
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
1329
|
+
|
|
1330
|
+
is_new_file = not os.path.exists(self.path)
|
|
1054
1331
|
with open(self.path, "w") as storage:
|
|
1055
1332
|
json.dump(token, storage)
|
|
1333
|
+
if is_new_file:
|
|
1334
|
+
os.chmod(self.path, 0o600)
|
|
1056
1335
|
|
|
1057
1336
|
|
|
1058
1337
|
def prompt_user_for_login() -> tuple[str, str]:
|
|
@@ -1064,6 +1343,14 @@ def prompt_user_for_login() -> tuple[str, str]:
|
|
|
1064
1343
|
return username, password
|
|
1065
1344
|
|
|
1066
1345
|
|
|
1346
|
+
def _default_device_flow_prompt(verification_uri: str, user_code: str) -> None:
|
|
1347
|
+
"""Print the RFC 8628 §3.3 user-confirmation prompt to stdout."""
|
|
1348
|
+
print("Open this URL in a browser to authenticate:")
|
|
1349
|
+
print(f" {verification_uri}")
|
|
1350
|
+
print("Enter this code:")
|
|
1351
|
+
print(f" {user_code}")
|
|
1352
|
+
|
|
1353
|
+
|
|
1067
1354
|
class _UserConfigFile:
|
|
1068
1355
|
"""Subject to change and NOT recommended for use by end-users."""
|
|
1069
1356
|
|
|
@@ -1161,3 +1448,12 @@ class BadRequestError(PyONCatError):
|
|
|
1161
1448
|
|
|
1162
1449
|
Check your input and try again.
|
|
1163
1450
|
"""
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
class DeviceAuthorizationError(PyONCatError):
|
|
1454
|
+
"""An error that is raised when the OAuth 2.0 Device Authorization
|
|
1455
|
+
Grant (RFC 8628) flow cannot complete (for example, the user did not
|
|
1456
|
+
finish the verification step before the device code expired, the user
|
|
1457
|
+
explicitly denied the request, or the IdP returned an unrecognized
|
|
1458
|
+
error response).
|
|
1459
|
+
"""
|
|
@@ -7,7 +7,7 @@ build-backend = "poetry.masonry.api"
|
|
|
7
7
|
# https://github.com/python-poetry/roadmap/issues/3
|
|
8
8
|
[tool.poetry]
|
|
9
9
|
name = "pyoncat"
|
|
10
|
-
version = "2.
|
|
10
|
+
version = "2.5"
|
|
11
11
|
description = "A Python Client for ONCat (the ORNL Neutron Catalog)."
|
|
12
12
|
authors = ["ONCat Support <oncat-support@ornl.gov>"]
|
|
13
13
|
readme = ["pyoncat/README.md", "pyoncat/LICENSE"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|