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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyoncat
3
- Version: 2.3
3
+ Version: 2.5
4
4
  Summary: A Python Client for ONCat (the ORNL Neutron Catalog).
5
5
  Keywords: ornl,neutron,metadata,catalog
6
6
  Author: ONCat Support
@@ -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.3"
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
- CacheEntry: CacheEntry
183
- UsageEntry: UsageEntry
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 CacheEntry(_UpdatableONCatMixin):
1186
+ class Cache(_UpdatableONCatMixin):
918
1187
  _RESOURCE_ENDPOINT = "cache"
919
1188
  _NAMESPACE = "admin"
920
1189
 
921
1190
 
922
- class UsageEntry(_ListableONCatMixin):
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 = "experiment"
1212
+ _RESOURCE_ENDPOINT = "experiments"
944
1213
  _NAMESPACE = "summary"
945
1214
 
946
1215
 
947
1216
  class GroupingSummary(_ONCatResource):
948
- _RESOURCE_ENDPOINT = "grouping"
1217
+ _RESOURCE_ENDPOINT = "groupings"
949
1218
  _NAMESPACE = "summary"
950
1219
 
951
1220
 
952
1221
  class InstrumentSummary(_ONCatResource):
953
- _RESOURCE_ENDPOINT = "instrument"
1222
+ _RESOURCE_ENDPOINT = "instruments"
954
1223
  _NAMESPACE = "summary"
955
1224
 
956
1225
 
957
1226
  class ReductionSummary(_ONCatResource):
958
- _RESOURCE_ENDPOINT = "reduction"
1227
+ _RESOURCE_ENDPOINT = "reductions"
959
1228
  _NAMESPACE = "summary"
960
1229
 
961
1230
 
962
1231
  class RunSummary(_ONCatResource):
963
- _RESOURCE_ENDPOINT = "run"
1232
+ _RESOURCE_ENDPOINT = "runs"
964
1233
  _NAMESPACE = "summary"
965
1234
 
966
1235
 
967
1236
  class SampleSummary(_ONCatResource):
968
- _RESOURCE_ENDPOINT = "sample"
1237
+ _RESOURCE_ENDPOINT = "samples"
969
1238
  _NAMESPACE = "summary"
970
1239
 
971
1240
 
972
1241
  class ScanSummary(_ONCatResource):
973
- _RESOURCE_ENDPOINT = "scan"
1242
+ _RESOURCE_ENDPOINT = "scans"
974
1243
  _NAMESPACE = "summary"
975
1244
 
976
1245
 
977
1246
  class SessionSummary(_ONCatResource):
978
- _RESOURCE_ENDPOINT = "session"
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, however the parent
1011
- directory must exist and you must have write permissions else an
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.3"
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