pyoncat 2.5__tar.gz → 2.7__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,20 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyoncat
3
- Version: 2.5
3
+ Version: 2.7
4
4
  Summary: A Python Client for ONCat (the ORNL Neutron Catalog).
5
5
  Keywords: ornl,neutron,metadata,catalog
6
6
  Author: ONCat Support
7
7
  Author-email: oncat-support@ornl.gov
8
- Requires-Python: >=3.7,<4
8
+ Requires-Python: >=3.9,<4
9
9
  Classifier: Programming Language :: Python :: 3
10
- Classifier: Programming Language :: Python :: 3.7
11
- Classifier: Programming Language :: Python :: 3.8
12
10
  Classifier: Programming Language :: Python :: 3.9
13
11
  Classifier: Programming Language :: Python :: 3.10
14
12
  Classifier: Programming Language :: Python :: 3.11
15
13
  Classifier: Programming Language :: Python :: 3.12
16
14
  Classifier: Programming Language :: Python :: 3.13
17
15
  Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Programming Language :: Python :: 3.15
18
17
  Requires-Dist: requests
19
18
  Project-URL: Homepage, https://oncat.ornl.gov
20
19
  Description-Content-Type: text/markdown
@@ -59,6 +58,10 @@ Alternatively, a Conda package is available up on [Anaconda](https://anaconda.or
59
58
 
60
59
  To use the authenticated version of the API, you need to obtain credentials from an ONCat administrator. Please contact ONCat Support at [oncat-support@ornl.gov](mailto:oncat-support@ornl.gov) to request credentials.
61
60
 
61
+ ### Request headers
62
+
63
+ Every request PyONCat makes carries two headers: `User-Agent: pyoncat/<version> requests/<version> Python/<major.minor>`, naming PyONCat, the `requests` library, and the Python major.minor version, and `API-Client: v1 pyoncat/<version>`. They let server logs tell PyONCat traffic from other clients.
64
+
62
65
  ### Examples
63
66
 
64
67
  For usage examples, please refer to the API documentation at [oncat.ornl.gov](https://oncat.ornl.gov).
@@ -38,6 +38,10 @@ Alternatively, a Conda package is available up on [Anaconda](https://anaconda.or
38
38
 
39
39
  To use the authenticated version of the API, you need to obtain credentials from an ONCat administrator. Please contact ONCat Support at [oncat-support@ornl.gov](mailto:oncat-support@ornl.gov) to request credentials.
40
40
 
41
+ ### Request headers
42
+
43
+ Every request PyONCat makes carries two headers: `User-Agent: pyoncat/<version> requests/<version> Python/<major.minor>`, naming PyONCat, the `requests` library, and the Python major.minor version, and `API-Client: v1 pyoncat/<version>`. They let server logs tell PyONCat traffic from other clients.
44
+
41
45
  ### Examples
42
46
 
43
47
  For usage examples, please refer to the API documentation at [oncat.ornl.gov](https://oncat.ornl.gov).
@@ -18,7 +18,8 @@ import sys
18
18
  import time
19
19
  import warnings
20
20
  import weakref
21
- from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal
21
+ from dataclasses import dataclass
22
+ from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal, Union
22
23
  from urllib.parse import quote
23
24
 
24
25
  import oauthlib.oauth2
@@ -30,7 +31,21 @@ from urllib3.exceptions import InsecureRequestWarning
30
31
 
31
32
  from pyoncat.reduction import ReductionFileBuilder
32
33
 
33
- __version__ = "2.5"
34
+ if TYPE_CHECKING:
35
+ import threading
36
+
37
+ __version__ = "2.7"
38
+
39
+ # Sent on every request PyONCat makes, API calls and token requests alike,
40
+ # so server logs can tell PyONCat traffic from other clients.
41
+ _USER_AGENT = (
42
+ f"pyoncat/{__version__} requests/{requests.__version__} "
43
+ f"Python/{sys.version_info.major}.{sys.version_info.minor}"
44
+ )
45
+ _CLIENT_HEADERS = {
46
+ "User-Agent": _USER_AGENT,
47
+ "API-Client": f"v1 pyoncat/{__version__}",
48
+ }
34
49
 
35
50
  # Constant for autoreduction user
36
51
  AUTOREDUCTION_USER = "auto"
@@ -39,8 +54,19 @@ __all__ = [
39
54
  "AUTOREDUCTION_USER",
40
55
  "CLIENT_CREDENTIALS_FLOW",
41
56
  "DEVICE_AUTHORIZATION_FLOW",
57
+ "REAUTH_INTERACTION_REQUIRED",
58
+ "REAUTH_NEVER",
59
+ "REAUTH_PROMPT",
42
60
  "RESOURCE_OWNER_CREDENTIALS_FLOW",
61
+ "DeviceAuthorizationCancelled",
62
+ "DeviceAuthorizationChallenge",
63
+ "DeviceAuthorizationDenied",
64
+ "DeviceAuthorizationError",
65
+ "DeviceAuthorizationExpired",
66
+ "DeviceAuthorizationNetworkError",
67
+ "DeviceAuthorizationTimeout",
43
68
  "FileSystemTokenStore",
69
+ "InteractionRequiredError",
44
70
  "ONCat",
45
71
  "ONCatObject",
46
72
  "PyONCatError",
@@ -66,6 +92,50 @@ DEVICE_AUTHORIZATION_FLOW: FlowType = "device_authorization"
66
92
 
67
93
  assert isinstance(CLIENT_CREDENTIALS_FLOW, str)
68
94
 
95
+ # Named `reauth_on_expired` modes selecting how the Device Authorization
96
+ # Grant recovers when the refresh token has expired. These string
97
+ # constants are the canonical, documented way to select a mode; the
98
+ # legacy `True`/`False` booleans remain accepted and are normalized to
99
+ # `REAUTH_NEVER` at the constructor boundary.
100
+ #
101
+ # - `REAUTH_PROMPT`: re-run the Device Authorization Grant (re-prompt)
102
+ # on expiry, then retry the original request once. Equivalent to
103
+ # the legacy `True`.
104
+ # - `REAUTH_NEVER`: raise on expiry without re-authenticating, for
105
+ # unattended / non-interactive callers. Equivalent to the legacy `False`.
106
+ # - `REAUTH_INTERACTION_REQUIRED`: attempt a silent refresh and, if the
107
+ # refresh token itself is dead, raise `InteractionRequiredError` rather
108
+ # than re-running `login()` from inside an ordinary API call. Its value
109
+ # stays the literal `"raise"` for backward compatibility.
110
+ ReauthMode = Literal["prompt", "never", "raise"]
111
+ ReauthOnExpired = Union[bool, ReauthMode]
112
+ REAUTH_PROMPT: ReauthMode = "prompt"
113
+ REAUTH_NEVER: ReauthMode = "never"
114
+ REAUTH_INTERACTION_REQUIRED: ReauthMode = "raise"
115
+
116
+
117
+ def _normalize_reauth_on_expired(value: ReauthOnExpired) -> ReauthMode:
118
+ """Map a `reauth_on_expired` argument onto a canonical `ReauthMode`.
119
+
120
+ Accepts the legacy booleans (`True` -> `REAUTH_PROMPT`, `False` ->
121
+ `REAUTH_NEVER`) for backward compatibility alongside the named string
122
+ constants, so the dispatch logic only ever compares against the
123
+ `REAUTH_*` constants.
124
+ """
125
+ if value is True:
126
+ return REAUTH_PROMPT
127
+ if value is False:
128
+ return REAUTH_NEVER
129
+ if value in {REAUTH_PROMPT, REAUTH_NEVER, REAUTH_INTERACTION_REQUIRED}:
130
+ return value
131
+ msg = (
132
+ "reauth_on_expired must be one of REAUTH_PROMPT, REAUTH_NEVER, "
133
+ "REAUTH_INTERACTION_REQUIRED (or the legacy True/False), "
134
+ f"not {value!r}."
135
+ )
136
+ raise ValueError(msg)
137
+
138
+
69
139
  # RFC 8628 §3.5 default polling interval (seconds), used when the device
70
140
  # authorization response does not specify one.
71
141
  _DEFAULT_DEVICE_FLOW_INTERVAL = 5
@@ -83,6 +153,68 @@ _DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
83
153
  # `oncat_cli_user` client.
84
154
  _DEFAULT_IDP_URL = "https://extidp.ornl.gov"
85
155
 
156
+ # Finite default connect/read timeout (seconds) for the two
157
+ # device-authorization HTTP POSTs (the authorization request and the token
158
+ # poll). The general `self._timeout` defaults to `None` (wait
159
+ # indefinitely), which is the right semantics for ordinary API calls but a
160
+ # footgun for the device-authorization poll loop: a hung IdP connection
161
+ # would block the loop forever. This bounds each individual POST so a
162
+ # stalled connection backs off and the loop keeps polling within the
163
+ # overall deadline instead of hanging. It is distinct from both
164
+ # `self._timeout` (per-API-request) and `device_authorization_timeout`
165
+ # (the overall caller deadline). Override per call via the `http_timeout`
166
+ # argument to `login()`.
167
+ _DEFAULT_DEVICE_AUTHORIZATION_HTTP_TIMEOUT = 30.0
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class DeviceAuthorizationChallenge:
172
+ """The user-facing details of a Device Authorization Grant challenge.
173
+
174
+ The Device Authorization Grant (often called the device flow) is the
175
+ OAuth 2.0 sign-in defined by RFC 8628. PyONCat passes one of these to
176
+ a configured `verification_handler`
177
+ whenever the user needs to approve a sign-in (RFC 8628 §3.2). The app
178
+ shows the user the link (and, if needed, the code) so they can approve
179
+ in a browser.
180
+
181
+ Attributes
182
+ ----------
183
+ verification_uri: str
184
+ The URL the user opens to approve the sign-in. Show this (with
185
+ `user_code`) when `verification_uri_complete` is `None`.
186
+
187
+ user_code: str
188
+ The one-time code the user enters at `verification_uri`.
189
+
190
+ verification_uri_complete: str | None
191
+ A ready-to-use link that already embeds `user_code` (RFC 8628
192
+ §3.3.1; OPTIONAL). Prefer showing this when present.
193
+
194
+ expires_in: int | None
195
+ Seconds, from when the challenge was issued, after which the
196
+ device code expires (RFC 8628 §3.2). `None` if the IdP omitted
197
+ it.
198
+
199
+ interval: float
200
+ The minimum number of seconds to wait between token polls
201
+ (RFC 8628 §3.2).
202
+
203
+ expires_at: float | None
204
+ The monotonic-clock deadline (`time.monotonic()` basis) after
205
+ which the challenge expires, derived from `expires_in` at the
206
+ moment the challenge was created. `None` when `expires_in` is
207
+ absent.
208
+
209
+ """
210
+
211
+ verification_uri: str
212
+ user_code: str
213
+ verification_uri_complete: str | None
214
+ expires_in: int | None
215
+ interval: float
216
+ expires_at: float | None
217
+
86
218
 
87
219
  _DEVICE_FLOW_REFRESH_EXPIRED_MESSAGE = (
88
220
  "Your ONCat session has expired and could not be automatically "
@@ -98,11 +230,11 @@ _DEVICE_FLOW_REFRESH_EXPIRED_MESSAGE = (
98
230
  "\n\n"
99
231
  "If you want PyONCat to prompt you to re-authenticate automatically "
100
232
  "when this\n"
101
- "happens, construct the client with `reauth_on_expired=True` (the "
102
- "default).\n"
233
+ "happens, construct the client with "
234
+ "`reauth_on_expired=REAUTH_PROMPT` (the default).\n"
103
235
  "\n"
104
- "Set `reauth_on_expired=False` for unattended / non-interactive use, "
105
- "in which\n"
236
+ "Set `reauth_on_expired=REAUTH_NEVER` for unattended / "
237
+ "non-interactive use, in which\n"
106
238
  "case this exception is raised immediately on session expiry."
107
239
  )
108
240
 
@@ -218,14 +350,46 @@ class ONCat:
218
350
  hit the IdP directly. When `None`, defaults to
219
351
  `https://extidp.ornl.gov` (ORNL's external PingFed).
220
352
 
221
- reauth_on_expired: bool
353
+ reauth_on_expired: ReauthOnExpired
222
354
  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
355
+ token has expired, controls how the client recovers. Select
356
+ a mode with one of the named constants (the canonical form);
357
+ the legacy `True`/`False` booleans are still accepted and map
358
+ to `REAUTH_PROMPT` / `REAUTH_NEVER` respectively.
359
+ `REAUTH_PROMPT` (the default) prompts on stderr for the user
360
+ to press Enter to re-authenticate via the browser, then
361
+ retries the original request once. `REAUTH_NEVER` raises
227
362
  `InvalidRefreshTokenError` immediately, which is
228
363
  appropriate for unattended or non-interactive use.
364
+ `REAUTH_INTERACTION_REQUIRED` first attempts a silent
365
+ refresh and, only if the refresh token itself is dead,
366
+ raises `InteractionRequiredError` rather than re-running
367
+ `login()` (and firing the verification callback) from
368
+ inside an ordinary API call. This lets a GUI decide *when*
369
+ to prompt the user instead of having a data call pop a
370
+ sign-in dialog from a worker thread.
371
+
372
+ verification_handler:
373
+ Callable[[DeviceAuthorizationChallenge], None] | None
374
+ A function PyONCat calls whenever the user needs to sign in -
375
+ on the first login and again later if the session expires.
376
+ It receives a single `DeviceAuthorizationChallenge` carrying the
377
+ verification URI, user code, complete URI, expiry, and
378
+ polling interval, so the app has everything it needs (e.g.
379
+ to show a code-expiry countdown). Your function is
380
+ responsible for displaying the sign-in link to the user, who
381
+ opens it in a browser to approve the sign-in:
382
+
383
+ - By default, show `verification_uri_complete`: a ready-to-use
384
+ link that already includes the code.
385
+ - If it is `None`, show `verification_uri` instead and have the
386
+ user enter `user_code` on that page.
387
+
388
+ Supplying this callback is what lets sign-in work in a GUI app
389
+ that has no console. When it is set, the same callback is
390
+ reused if the session later needs the user to sign in again, so
391
+ the app is never left waiting on console input (see
392
+ `reauth_on_expired`).
229
393
 
230
394
  """
231
395
 
@@ -250,7 +414,6 @@ class ONCat:
250
414
  Task: Task
251
415
  Info: Info
252
416
  Cache: Cache
253
- Usage: Usage
254
417
  Template: Template
255
418
 
256
419
  def __init__(
@@ -267,7 +430,9 @@ class ONCat:
267
430
  verify: bool = True,
268
431
  timeout: int | None = None,
269
432
  idp_url: str | None = None,
270
- reauth_on_expired: bool = True,
433
+ reauth_on_expired: ReauthOnExpired = REAUTH_PROMPT,
434
+ verification_handler: Callable[[DeviceAuthorizationChallenge], None]
435
+ | None = None,
271
436
  ):
272
437
  if not url:
273
438
  msg = "A URL must be provided."
@@ -306,6 +471,7 @@ class ONCat:
306
471
  self._token_getter = token_getter
307
472
  self._token_setter = token_setter
308
473
  self._login_prompt = login_prompt
474
+ self._verification_handler = verification_handler
309
475
  self._api_token = api_token
310
476
  self._client_id = client_id
311
477
  self._client_secret = client_secret
@@ -315,7 +481,9 @@ class ONCat:
315
481
  self._verify = verify
316
482
  self._timeout = timeout
317
483
  self._idp_url = idp_url if idp_url is not None else _DEFAULT_IDP_URL
318
- self._reauth_on_expired = reauth_on_expired
484
+ self._reauth_on_expired = _normalize_reauth_on_expired(
485
+ reauth_on_expired
486
+ )
319
487
 
320
488
  self._token = None
321
489
  self._oauth_client = None
@@ -325,7 +493,7 @@ class ONCat:
325
493
  "OAuth 2.0 Resource Owner Password Credentials grant is "
326
494
  'deprecated. RFC 9700 §2.4: "The resource owner password '
327
495
  'credentials grant [RFC6749] MUST NOT be used." Prefer '
328
- "the device authorization flow "
496
+ "the Device Authorization Grant "
329
497
  "(flow=DEVICE_AUTHORIZATION_FLOW) for human-user "
330
498
  "authentication. ROPC remains available indefinitely as "
331
499
  "a compatibility path.",
@@ -367,14 +535,17 @@ class ONCat:
367
535
  timeout=self._timeout,
368
536
  )
369
537
  else:
538
+ auth_headers = (
539
+ {"Authorization": "Bearer " + self._api_token}
540
+ if self._api_token
541
+ else {}
542
+ )
370
543
  response = getattr(requests, method)(
371
544
  full_url,
372
545
  params=kwargs,
373
546
  json=data,
374
547
  verify=self._should_verify(),
375
- headers={"Authorization": "Bearer " + self._api_token}
376
- if self._api_token
377
- else None,
548
+ headers={**_CLIENT_HEADERS, **auth_headers},
378
549
  timeout=self._timeout,
379
550
  )
380
551
  try:
@@ -399,6 +570,20 @@ class ONCat:
399
570
 
400
571
  try:
401
572
  return send_request()
573
+ except UnauthorizedError as error:
574
+ # Device Authorization Grant only: a 401 on the data call
575
+ # means the access token is dead server-side but oauthlib did
576
+ # not proactively refresh -- e.g. a token persisted without
577
+ # `expires_at` by an older PyONCat, so the proactive gate
578
+ # never fired. Attempt exactly ONE silent refresh + ONE retry;
579
+ # if that still fails, fall through to the expiry handler. The
580
+ # other flows keep the 2.5 behavior: re-raise the 401
581
+ # untouched.
582
+ if self._flow != DEVICE_AUTHORIZATION_FLOW:
583
+ raise
584
+ return self._handle_device_authorization_unauthorized(
585
+ send_request, error
586
+ )
402
587
  except rfc6749.errors.InvalidGrantError as error:
403
588
  if (
404
589
  self._flow == RESOURCE_OWNER_CREDENTIALS_FLOW
@@ -441,40 +626,232 @@ class ONCat:
441
626
  msg = f'Error: "{error!s}"'
442
627
  raise PyONCatError(msg) from error
443
628
 
444
- def _handle_device_flow_token_expiry(self, send_request, error):
445
- """Recover from an expired Device Flow refresh token.
629
+ def _handle_device_authorization_unauthorized(self, send_request, error):
630
+ """Bounded reactive recovery from a Device Authorization Grant
631
+ data-call 401.
632
+
633
+ Attempts exactly ONE silent refresh (the `refresh_token` grant via
634
+ the wired `OAuth2Session`) and ONE retry of the original request.
635
+ This terminates by construction:
636
+
637
+ - The retried `send_request()` is called directly here, NOT through
638
+ `_call_method`, so a second consecutive 401 raises straight back
639
+ to the caller without re-entering this handler -- there is no
640
+ loop.
641
+ - If the silent refresh itself fails (the refresh token is dead),
642
+ control falls through to `_handle_device_flow_token_expiry`,
643
+ which raises `InteractionRequiredError` under
644
+ `reauth_on_expired=REAUTH_INTERACTION_REQUIRED` (or behaves
645
+ per the `REAUTH_PROMPT` / `REAUTH_NEVER` modes).
646
+
647
+ This is the backstop for a token that lacks `expires_at` (so
648
+ oauthlib never refreshed proactively) and for a server-side
649
+ revocation between proactive checks.
650
+ """
651
+ token = self._load_token()
652
+ refresh_token = token.get("refresh_token") if token else None
653
+ oauth_client = self._oauth_client
654
+
655
+ if refresh_token and oauth_client is not None:
656
+ token_url = self._idp_url.rstrip("/") + "/as/token.oauth2"
657
+ refresh_kwargs = {
658
+ "client_id": self._client_id,
659
+ "verify": self._should_verify(),
660
+ }
661
+ try:
662
+ with warnings.catch_warnings():
663
+ warnings.filterwarnings(
664
+ "ignore", category=DeprecationWarning
665
+ )
666
+ refreshed = oauth_client.refresh_token(
667
+ token_url,
668
+ refresh_token=refresh_token,
669
+ **refresh_kwargs,
670
+ )
671
+ except requests.exceptions.RequestException as exc:
672
+ # A transport-layer failure is not evidence that the
673
+ # refresh token is dead, so the stored token is left
674
+ # intact and the failure surfaces as a typed network
675
+ # error the caller can branch on without being signed
676
+ # out by a blip.
677
+ msg = f"Silent token refresh failed: {exc}"
678
+ raise DeviceAuthorizationNetworkError(msg) from exc
679
+ except rfc6749.errors.OAuth2Error:
680
+ # A genuine OAuth rejection (e.g. `invalid_grant`) means
681
+ # the refresh token itself is dead; hand off to the
682
+ # expiry handler for the re-auth decision.
683
+ return self._handle_device_flow_token_expiry(
684
+ send_request, error
685
+ )
446
686
 
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()`.
687
+ self._save_token(refreshed)
688
+ # The single retry runs `send_request()` directly: another 401
689
+ # here propagates without re-triggering a refresh (no loop).
690
+ return send_request()
691
+
692
+ # No usable refresh token: defer to the expiry handler.
693
+ return self._handle_device_flow_token_expiry(send_request, error)
694
+
695
+ def _handle_device_flow_token_expiry(self, send_request, error):
696
+ """Recover from an expired Device Authorization Grant refresh
697
+ token.
698
+
699
+ Reached only after `requests_oauthlib`'s silent refresh has
700
+ already failed (the refresh token itself is dead). Clears the
701
+ persisted token and recovers according to `reauth_on_expired`:
702
+
703
+ - `REAUTH_PROMPT` (the default): prompts on stderr for the user
704
+ to re-authenticate via the browser before retrying the original
705
+ request once. When a `verification_handler` is configured, the
706
+ stdin "press Enter" gate is skipped entirely -- the callback
707
+ itself is the
708
+ console-less app's presence/notification surface, so the flow
709
+ re-runs (invoking the callback) and the original request is
710
+ retried once.
711
+ - `REAUTH_NEVER`: raises `InvalidRefreshTokenError` immediately so
712
+ callers running unattended can react without blocking on
713
+ `input()`.
714
+ - `REAUTH_INTERACTION_REQUIRED`: raises
715
+ `InteractionRequiredError` so a GUI can decide when and how to
716
+ prompt the user, rather than having an ordinary data call fire
717
+ the verification callback from a worker thread.
452
718
  """
453
719
  self._save_token(None)
454
720
 
455
- if not self._reauth_on_expired:
456
- raise InvalidRefreshTokenError(
721
+ if self._reauth_on_expired == REAUTH_INTERACTION_REQUIRED:
722
+ raise InteractionRequiredError(
457
723
  _DEVICE_FLOW_REFRESH_EXPIRED_MESSAGE
458
724
  ) from error
459
725
 
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.
726
+ if self._reauth_on_expired == REAUTH_NEVER:
467
727
  raise InvalidRefreshTokenError(
468
728
  _DEVICE_FLOW_REFRESH_EXPIRED_MESSAGE
469
- ) from eof_error
470
- # KeyboardInterrupt propagates naturally; no special handling.
729
+ ) from error
471
730
 
472
- # Drop any cached OAuth session so `login()` re-runs the device
473
- # flow rather than reusing an exhausted refresh token.
731
+ if self._verification_handler is None:
732
+ print(_DEVICE_FLOW_REAUTH_PROMPT, file=sys.stderr)
733
+ try:
734
+ input()
735
+ except EOFError as eof_error:
736
+ # Non-interactive stdin (piped, closed, no human present);
737
+ # do not auto-recover -- the prompt's purpose was to confirm
738
+ # human presence.
739
+ raise InvalidRefreshTokenError(
740
+ _DEVICE_FLOW_REFRESH_EXPIRED_MESSAGE
741
+ ) from eof_error
742
+ # KeyboardInterrupt propagates naturally; no special handling.
743
+
744
+ # Drop any cached OAuth session so `login()` re-runs the Device
745
+ # Authorization Grant rather than reusing an exhausted refresh
746
+ # token.
474
747
  self._oauth_client = None
475
748
  self.login()
476
749
  return send_request()
477
750
 
751
+ def logout(self, http_timeout: float | None = None):
752
+ """Sign out: revoke the refresh token and clear the local token.
753
+
754
+ The public counterpart to `login()`. When a token with a
755
+ `refresh_token` is present, this POSTs to the IdP's RFC 7009 token
756
+ revocation endpoint (`<idp_url>/as/revoke_token.oauth2`, derived the
757
+ same way as the device-authorization and token endpoints) to revoke
758
+ the refresh token server-side, then always clears the locally stored
759
+ token so a sign-out succeeds locally even when the server call does
760
+ not. The cached OAuth session is torn down so a subsequent `login()`
761
+ starts a fresh Device Authorization Grant rather than reusing an
762
+ exhausted session.
763
+
764
+ Revocation uses the configured public `client_id` and no secret
765
+ (the device-authorization client is a public client, RFC 8628
766
+ / ADR-0058).
767
+
768
+ Per RFC 7009 §2.2 the revocation endpoint returns HTTP 200 even for
769
+ a token that is already invalid, so a 200 confirms the request was
770
+ accepted -- not that a specific token was live at the time.
771
+
772
+ Arguments:
773
+ ---------
774
+ http_timeout: float | None
775
+ The connect/read HTTP timeout (seconds) for the revocation
776
+ POST. `None` (the default) uses
777
+ `_DEFAULT_DEVICE_AUTHORIZATION_HTTP_TIMEOUT`, the same
778
+ finite bound the Device Authorization Grant applies, so a
779
+ hung IdP connection backs off
780
+ instead of blocking the sign-out indefinitely.
781
+
782
+ Raises:
783
+ ------
784
+ PyONCatError
785
+ If the revocation request fails (non-2xx response or a
786
+ transport error). The local token is cleared first, so the
787
+ caller is signed out locally regardless; the exception
788
+ surfaces that server-side revocation did not succeed.
789
+
790
+ """
791
+ token = self._load_token()
792
+ refresh_token = token.get("refresh_token") if token else None
793
+
794
+ revocation_error: Exception | None = None
795
+ if refresh_token:
796
+ revocation_url = (
797
+ self._idp_url.rstrip("/") + "/as/revoke_token.oauth2"
798
+ )
799
+ post_timeout = (
800
+ http_timeout
801
+ if http_timeout is not None
802
+ else _DEFAULT_DEVICE_AUTHORIZATION_HTTP_TIMEOUT
803
+ )
804
+ try:
805
+ revocation_response = requests.post(
806
+ revocation_url,
807
+ data={
808
+ "token": refresh_token,
809
+ "token_type_hint": "refresh_token",
810
+ "client_id": self._client_id,
811
+ },
812
+ verify=self._should_verify(),
813
+ timeout=post_timeout,
814
+ headers={
815
+ **_CLIENT_HEADERS,
816
+ "Content-Type": "application/x-www-form-urlencoded",
817
+ "Accept": "application/json",
818
+ },
819
+ )
820
+ except requests.exceptions.RequestException as exc:
821
+ revocation_error = exc
822
+ else:
823
+ if not revocation_response.ok:
824
+ revocation_error = PyONCatError(
825
+ "Token revocation request failed with status "
826
+ f"{revocation_response.status_code}: "
827
+ f"{revocation_response.text}"
828
+ )
829
+
830
+ # Drop the cached session first so the in-memory teardown holds even
831
+ # if the token-store write raises: a caller-supplied store can fail,
832
+ # and a failed store write must not leave a live session behind. The
833
+ # store-write exception still propagates to the caller below.
834
+ self._oauth_client = None
835
+ self._save_token(None)
836
+
837
+ if revocation_error is not None:
838
+ msg = (
839
+ "Signed out locally, but server-side token revocation "
840
+ f"failed: {revocation_error}"
841
+ )
842
+ raise PyONCatError(msg) from revocation_error
843
+
844
+ def has_stored_token(self) -> bool:
845
+ """Return True if a (non-null) token is already persisted.
846
+
847
+ Reads through the configured token store / token_getter; does
848
+ NOT contact the IdP and does NOT validate the token (it may be
849
+ expired). Lets a GUI decide its initial signed-in vs signed-out
850
+ state without a network round trip. A session cleared by
851
+ logout() (persisted as JSON null) reads as absent.
852
+ """
853
+ return self._load_token() is not None
854
+
478
855
  def _load_oauth_client(self):
479
856
  if not self._oauth_client:
480
857
  self.login()
@@ -513,7 +890,14 @@ class ONCat:
513
890
  and "proxy" not in self._url
514
891
  )
515
892
 
516
- def login(self, username=None, password=None):
893
+ def login(
894
+ self,
895
+ username=None,
896
+ password=None,
897
+ cancel_event: threading.Event | None = None,
898
+ device_authorization_timeout: float | None = None,
899
+ http_timeout: float | None = None,
900
+ ):
517
901
  """Login to the ONCat API as part of an appropriate OAuth flow.
518
902
 
519
903
  This method is no longer strictly necessary to call manually,
@@ -534,6 +918,37 @@ class ONCat:
534
918
  password: str | None
535
919
  The password to use when logging in.
536
920
 
921
+ cancel_event: threading.Event | None
922
+ Device Authorization Grant only. When supplied, the
923
+ poll loop checks it around each sleep and before each
924
+ token POST; if it is set, the loop raises
925
+ `DeviceAuthorizationCancelled`. This is how a GUI
926
+ "Cancel" button truly aborts a sign-in in progress: Qt
927
+ (or any toolkit) cannot interrupt blocking Python, so
928
+ cancellation must be cooperative and live in PyONCat.
929
+ `None` (the default) means today's behavior -- no
930
+ cancellation.
931
+
932
+ device_authorization_timeout: float | None
933
+ Device Authorization Grant only. An overall caller
934
+ deadline in seconds for the sign-in, distinct from the
935
+ RFC 8628 server-side `expires_in`. When set, polling
936
+ stops at the deadline with `DeviceAuthorizationTimeout`.
937
+ Polling also stops no later than the server's own
938
+ `expires_in` (the effective deadline is the earlier of
939
+ the two). `None` (the default) means no overall deadline
940
+ -- today's behavior.
941
+
942
+ http_timeout: float | None
943
+ Device Authorization Grant only. The connect/read HTTP
944
+ timeout (seconds) applied to the two device-authorization POSTs
945
+ (device-authorization and token poll). `None` (the
946
+ default) uses `_DEFAULT_DEVICE_AUTHORIZATION_HTTP_TIMEOUT`, a
947
+ finite value so a hung IdP connection backs off and the
948
+ loop keeps polling within the overall deadline instead of
949
+ hanging forever. This is distinct from the constructor's
950
+ `timeout` (which applies to ordinary API requests).
951
+
537
952
  """
538
953
  if self._flow == CLIENT_CREDENTIALS_FLOW:
539
954
  self._login_client_credentials()
@@ -554,7 +969,11 @@ class ONCat:
554
969
  else:
555
970
  self._login_resource_owner_credentials(username, password)
556
971
  elif self._flow == DEVICE_AUTHORIZATION_FLOW:
557
- self._login_device_authorization()
972
+ self._login_device_authorization(
973
+ cancel_event=cancel_event,
974
+ device_authorization_timeout=device_authorization_timeout,
975
+ http_timeout=http_timeout,
976
+ )
558
977
  else:
559
978
  raise AssertionError
560
979
 
@@ -570,6 +989,7 @@ class ONCat:
570
989
  ),
571
990
  scope=self._scopes,
572
991
  )
992
+ self._oauth_client.headers.update(_CLIENT_HEADERS)
573
993
  try:
574
994
  token = self._oauth_client.fetch_token(
575
995
  self._url + "/oauth/token",
@@ -607,6 +1027,7 @@ class ONCat:
607
1027
  client_secret=self._client_secret,
608
1028
  )
609
1029
  )
1030
+ initial_oauth_client.headers.update(_CLIENT_HEADERS)
610
1031
 
611
1032
  try:
612
1033
  token = initial_oauth_client.fetch_token(
@@ -655,11 +1076,16 @@ class ONCat:
655
1076
  auto_refresh_kwargs=auto_refresh_kwargs,
656
1077
  token_updater=self._save_token,
657
1078
  )
1079
+ self._oauth_client.headers.update(_CLIENT_HEADERS)
658
1080
 
659
1081
  def _login_device_authorization(
660
1082
  self,
661
- prompt: Callable[[str, str], None] | None = None,
1083
+ prompt: Callable[[str, str, str | None], None] | None = None,
662
1084
  sleep: Callable[[float], None] | None = None,
1085
+ cancel_event: threading.Event | None = None,
1086
+ device_authorization_timeout: float | None = None,
1087
+ http_timeout: float | None = None,
1088
+ monotonic: Callable[[], float] | None = None,
663
1089
  ):
664
1090
  """Drive the OAuth 2.0 Device Authorization Grant (RFC 8628).
665
1091
 
@@ -669,17 +1095,36 @@ class ONCat:
669
1095
  `OAuth2Session` is wired with `auto_refresh_url` so subsequent
670
1096
  API calls roll the refresh token transparently.
671
1097
 
672
- `prompt` and `sleep` exist to make the polling loop deterministic
673
- in unit tests; production callers pass neither.
1098
+ `cancel_event` lets a caller cooperatively abort the poll loop;
1099
+ `device_authorization_timeout` is the overall caller deadline (distinct
1100
+ from the server's `expires_in`); `http_timeout` bounds each
1101
+ individual device-authorization POST. See `login()` for the public
1102
+ contract.
1103
+
1104
+ `prompt`, `sleep`, and `monotonic` exist to make the polling loop
1105
+ deterministic in unit tests; production callers pass none of them.
674
1106
  """
675
1107
  if self._load_token() is not None:
1108
+ # No need to validate or refresh the token here: once the
1109
+ # device-authorization token carries an absolute `expires_at`
1110
+ # (`_stamp_token_expires_at`), the wired `OAuth2Session` owns
1111
+ # expiry -- it refreshes proactively before the first call and
1112
+ # reactively on a 401. Do not add a redundant validate/refresh.
676
1113
  self._setup_device_oauth_session(self._load_token())
677
1114
  return
678
1115
 
679
1116
  if prompt is None:
680
1117
  prompt = _default_device_flow_prompt
681
1118
  if sleep is None:
682
- sleep = time.sleep
1119
+ sleep = self._device_authorization_sleep(cancel_event)
1120
+ if monotonic is None:
1121
+ monotonic = time.monotonic
1122
+
1123
+ post_timeout = (
1124
+ http_timeout
1125
+ if http_timeout is not None
1126
+ else _DEFAULT_DEVICE_AUTHORIZATION_HTTP_TIMEOUT
1127
+ )
683
1128
 
684
1129
  assert self._client_id
685
1130
 
@@ -692,16 +1137,38 @@ class ONCat:
692
1137
  if scope_str:
693
1138
  device_auth_body["scope"] = scope_str
694
1139
 
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
- },
1140
+ # The caller's `device_authorization_timeout` bounds the WHOLE sign-in
1141
+ # (ADR-0097), so the deadline clock starts BEFORE the initial
1142
+ # device-authorization POST. A slow initial POST therefore counts
1143
+ # against the overall budget. `http_timeout` still caps each
1144
+ # individual POST; the caller deadline is the cumulative bound.
1145
+ started_at = monotonic()
1146
+ caller_deadline = (
1147
+ started_at + device_authorization_timeout
1148
+ if device_authorization_timeout is not None
1149
+ else None
704
1150
  )
1151
+
1152
+ self._raise_if_cancelled(cancel_event)
1153
+ self._raise_if_past_deadline(caller_deadline, monotonic)
1154
+
1155
+ try:
1156
+ device_auth_response = requests.post(
1157
+ device_auth_url,
1158
+ data=device_auth_body,
1159
+ verify=self._should_verify(),
1160
+ timeout=post_timeout,
1161
+ headers={
1162
+ **_CLIENT_HEADERS,
1163
+ "Content-Type": "application/x-www-form-urlencoded",
1164
+ "Accept": "application/json",
1165
+ },
1166
+ )
1167
+ except requests.exceptions.RequestException as exc:
1168
+ msg = f"Device authorization request failed: {exc}"
1169
+ raise DeviceAuthorizationNetworkError(msg) from exc
1170
+ # A slow initial POST may have already exhausted the caller budget.
1171
+ self._raise_if_past_deadline(caller_deadline, monotonic)
705
1172
  if not device_auth_response.ok:
706
1173
  msg = (
707
1174
  "Device authorization request failed with status "
@@ -710,15 +1177,56 @@ class ONCat:
710
1177
  )
711
1178
  raise DeviceAuthorizationError(msg)
712
1179
 
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)
1180
+ try:
1181
+ device_auth = device_auth_response.json()
1182
+ device_code = device_auth["device_code"]
1183
+ user_code = device_auth["user_code"]
1184
+ verification_uri = device_auth["verification_uri"]
1185
+ # RFC 8628 §3.3.1: `verification_uri_complete` is OPTIONAL; it
1186
+ # may be absent, in which case the prompt falls back to the
1187
+ # bare URI.
1188
+ verification_uri_complete = device_auth.get(
1189
+ "verification_uri_complete"
1190
+ )
1191
+ # RFC 8628 §3.2: `expires_in` is the device-code lifetime;
1192
+ # OPTIONAL in practice, so tolerate its absence.
1193
+ expires_in_raw = device_auth.get("expires_in")
1194
+ expires_in = (
1195
+ int(expires_in_raw) if expires_in_raw is not None else None
1196
+ )
1197
+ interval = float(
1198
+ device_auth.get("interval", _DEFAULT_DEVICE_FLOW_INTERVAL)
1199
+ )
1200
+ except (ValueError, KeyError, TypeError) as err:
1201
+ msg = (
1202
+ "Device authorization response was not a valid JSON object "
1203
+ "with the required fields."
1204
+ )
1205
+ raise DeviceAuthorizationError(msg) from err
1206
+
1207
+ # The effective deadline is the earlier of the caller's overall
1208
+ # `device_authorization_timeout` (clocked from before the
1209
+ # initial POST) and the server's own `expires_in` (clocked from
1210
+ # now, once known).
1211
+ server_expiry = (
1212
+ monotonic() + expires_in if expires_in is not None else None
1213
+ )
1214
+ deadlines = [
1215
+ d for d in (caller_deadline, server_expiry) if d is not None
1216
+ ]
1217
+ deadline = min(deadlines) if deadlines else None
1218
+
1219
+ self._dispatch_device_authorization_challenge(
1220
+ prompt,
1221
+ DeviceAuthorizationChallenge(
1222
+ verification_uri=verification_uri,
1223
+ user_code=user_code,
1224
+ verification_uri_complete=verification_uri_complete,
1225
+ expires_in=expires_in,
1226
+ interval=interval,
1227
+ expires_at=server_expiry,
1228
+ ),
719
1229
  )
720
-
721
- prompt(verification_uri, user_code)
722
1230
 
723
1231
  token_body = {
724
1232
  "grant_type": _DEVICE_CODE_GRANT_TYPE,
@@ -727,21 +1235,40 @@ class ONCat:
727
1235
  }
728
1236
 
729
1237
  while True:
730
- sleep(interval)
1238
+ self._raise_if_cancelled(cancel_event)
1239
+ self._raise_if_past_deadline(deadline, monotonic)
1240
+ sleep(self._poll_wait_seconds(interval, deadline, monotonic))
1241
+ self._raise_if_cancelled(cancel_event)
1242
+ self._raise_if_past_deadline(deadline, monotonic)
731
1243
 
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
- )
1244
+ try:
1245
+ token_response = requests.post(
1246
+ token_url,
1247
+ data=token_body,
1248
+ verify=self._should_verify(),
1249
+ timeout=post_timeout,
1250
+ headers={
1251
+ **_CLIENT_HEADERS,
1252
+ "Content-Type": "application/x-www-form-urlencoded",
1253
+ "Accept": "application/json",
1254
+ },
1255
+ )
1256
+ except requests.exceptions.Timeout:
1257
+ # A connection/read timeout on a single poll is recoverable:
1258
+ # back off and keep polling within the overall deadline
1259
+ # rather than aborting the whole sign-in. The next loop
1260
+ # iteration re-checks the deadline and cancel event.
1261
+ continue
1262
+ except requests.exceptions.RequestException as exc:
1263
+ msg = f"Device token poll failed: {exc}"
1264
+ raise DeviceAuthorizationNetworkError(msg) from exc
742
1265
 
743
1266
  if token_response.ok:
744
- token = token_response.json()
1267
+ try:
1268
+ token = token_response.json()
1269
+ except (ValueError, KeyError, TypeError) as err:
1270
+ msg = "Device token response was not a valid JSON object."
1271
+ raise DeviceAuthorizationError(msg) from err
745
1272
  break
746
1273
 
747
1274
  try:
@@ -755,18 +1282,125 @@ class ONCat:
755
1282
  if error_code == "slow_down":
756
1283
  interval += _DEVICE_FLOW_SLOW_DOWN_BUMP
757
1284
  continue
758
- if error_code in {"expired_token", "access_denied"}:
759
- msg = f"Device authorization terminated: {error_code}"
760
- raise DeviceAuthorizationError(msg)
1285
+ if error_code == "expired_token":
1286
+ msg = "Device authorization terminated: expired_token"
1287
+ raise DeviceAuthorizationExpired(msg)
1288
+ if error_code == "access_denied":
1289
+ msg = "Device authorization terminated: access_denied"
1290
+ raise DeviceAuthorizationDenied(msg)
761
1291
  msg = (
762
1292
  "Device token poll failed with status "
763
1293
  f"{token_response.status_code}: {token_response.text}"
764
1294
  )
765
1295
  raise DeviceAuthorizationError(msg)
766
1296
 
1297
+ self._stamp_token_expires_at(token)
767
1298
  self._save_token(token)
768
1299
  self._setup_device_oauth_session(token)
769
1300
 
1301
+ @staticmethod
1302
+ def _stamp_token_expires_at(token: dict) -> None:
1303
+ """Stamp an absolute `expires_at` on a device-authorization
1304
+ token in place.
1305
+
1306
+ PingFed's device-token response carries only the relative
1307
+ `expires_in`. oauthlib's proactive-refresh gate keys on the
1308
+ absolute `expires_at`, recomputing it as `now + expires_in` on
1309
+ every reload -- so a persisted token without `expires_at` looks
1310
+ perpetually fresh and silent refresh never fires. PyONCat's other
1311
+ flows go through `OAuth2Session.fetch_token()`, which stamps
1312
+ `expires_at` via oauthlib's parser; the manual device poll does
1313
+ not, so it is stamped here to the same wall-clock shape
1314
+ (`round(time.time()) + expires_in`, matching oauthlib) anchored at
1315
+ the moment the token was received.
1316
+
1317
+ RFC 6749 §5.1 makes `expires_in` only RECOMMENDED. When it is
1318
+ absent or non-numeric, no `expires_at` is synthesized -- the
1319
+ bounded reactive 401 net (`_call_method`) is the backstop for
1320
+ that case.
1321
+ """
1322
+ if "expires_at" in token:
1323
+ return
1324
+ try:
1325
+ expires_in = int(token["expires_in"])
1326
+ except (KeyError, ValueError, TypeError):
1327
+ return
1328
+ token["expires_at"] = round(time.time()) + expires_in
1329
+
1330
+ @staticmethod
1331
+ def _device_authorization_sleep(
1332
+ cancel_event: threading.Event | None,
1333
+ ) -> Callable[[float], None]:
1334
+ """Resolve the inter-poll wait primitive for the Device
1335
+ Authorization Grant.
1336
+
1337
+ When a `cancel_event` is supplied the wait blocks on the event,
1338
+ so setting it returns immediately rather than after the full
1339
+ interval; `threading.Event.wait(timeout)` returns as soon as the
1340
+ event is set. Without a `cancel_event` the wait is a plain
1341
+ `time.sleep`.
1342
+ """
1343
+ if cancel_event is None:
1344
+ return time.sleep
1345
+
1346
+ def wait(seconds: float) -> None:
1347
+ cancel_event.wait(seconds)
1348
+
1349
+ return wait
1350
+
1351
+ @staticmethod
1352
+ def _poll_wait_seconds(
1353
+ interval: float,
1354
+ deadline: float | None,
1355
+ monotonic: Callable[[], float],
1356
+ ) -> float:
1357
+ """Seconds to wait before the next poll, bounded by the deadline.
1358
+
1359
+ The wait is capped at the time remaining until `deadline` (the
1360
+ earlier of the caller and server expiries) so a pending sign-in
1361
+ observes the deadline without waiting a full `interval` past it.
1362
+ """
1363
+ if deadline is None:
1364
+ return interval
1365
+ return max(0.0, min(interval, deadline - monotonic()))
1366
+
1367
+ @staticmethod
1368
+ def _raise_if_cancelled(cancel_event: threading.Event | None) -> None:
1369
+ if cancel_event is not None and cancel_event.is_set():
1370
+ msg = "Device authorization was cancelled by the caller."
1371
+ raise DeviceAuthorizationCancelled(msg)
1372
+
1373
+ @staticmethod
1374
+ def _raise_if_past_deadline(
1375
+ deadline: float | None, monotonic: Callable[[], float]
1376
+ ) -> None:
1377
+ if deadline is not None and monotonic() >= deadline:
1378
+ msg = (
1379
+ "Device authorization timed out before the user completed "
1380
+ "the sign-in."
1381
+ )
1382
+ raise DeviceAuthorizationTimeout(msg)
1383
+
1384
+ def _dispatch_device_authorization_challenge(
1385
+ self,
1386
+ prompt: Callable[[str, str, str | None], None],
1387
+ challenge: DeviceAuthorizationChallenge,
1388
+ ) -> None:
1389
+ """Notify the caller of a pending challenge.
1390
+
1391
+ A configured `verification_handler` receives the structured
1392
+ `DeviceAuthorizationChallenge`; otherwise the resolved 3-arg `prompt`
1393
+ callback is used (the console default, or a unit-test hook).
1394
+ """
1395
+ if self._verification_handler is not None:
1396
+ self._verification_handler(challenge)
1397
+ return
1398
+ prompt(
1399
+ challenge.verification_uri,
1400
+ challenge.user_code,
1401
+ challenge.verification_uri_complete,
1402
+ )
1403
+
770
1404
  def _setup_device_oauth_session(self, token):
771
1405
  token_url = self._idp_url.rstrip("/") + "/as/token.oauth2"
772
1406
  with warnings.catch_warnings():
@@ -782,6 +1416,7 @@ class ONCat:
782
1416
  },
783
1417
  token_updater=self._save_token,
784
1418
  )
1419
+ self._oauth_client.headers.update(_CLIENT_HEADERS)
785
1420
 
786
1421
 
787
1422
  class ONCatObject:
@@ -941,6 +1576,12 @@ ONCatRepresentation = ONCatObject
941
1576
  ########################################################################
942
1577
 
943
1578
 
1579
+ # TODO: The REST documentation page derives the PyONCat class and method
1580
+ # in each code example from the resource's namespace and path, without
1581
+ # consulting this module. Where a resource has no class here, or the
1582
+ # class lacks the method, the example does not run. Decide how the
1583
+ # documentation should learn what this module provides or otherwise
1584
+ # resolve this mismatch.
944
1585
  class _ONCatMixinMeta(type):
945
1586
  REGISTERED_RESOURCES: ClassVar[list] = []
946
1587
 
@@ -1104,7 +1745,7 @@ class Grouping(_ListableONCatMixin):
1104
1745
 
1105
1746
  class Mapping(_ListableONCatMixin):
1106
1747
  _RESOURCE_ENDPOINT = "mappings"
1107
- _NAMESPACE = "settings"
1748
+ _NAMESPACE = "option"
1108
1749
 
1109
1750
 
1110
1751
  class Proposal(_ListableONCatMixin):
@@ -1188,11 +1829,6 @@ class Cache(_UpdatableONCatMixin):
1188
1829
  _NAMESPACE = "admin"
1189
1830
 
1190
1831
 
1191
- class Usage(_ListableONCatMixin):
1192
- _RESOURCE_ENDPOINT = "usage"
1193
- _NAMESPACE = "admin"
1194
-
1195
-
1196
1832
  ########################################################################
1197
1833
 
1198
1834
 
@@ -1203,51 +1839,6 @@ class Template(
1203
1839
  _NAMESPACE = "settings"
1204
1840
 
1205
1841
 
1206
- ########################################################################
1207
- # Summary Resources
1208
- ########################################################################
1209
-
1210
-
1211
- class ExperimentSummary(_ONCatResource):
1212
- _RESOURCE_ENDPOINT = "experiments"
1213
- _NAMESPACE = "summary"
1214
-
1215
-
1216
- class GroupingSummary(_ONCatResource):
1217
- _RESOURCE_ENDPOINT = "groupings"
1218
- _NAMESPACE = "summary"
1219
-
1220
-
1221
- class InstrumentSummary(_ONCatResource):
1222
- _RESOURCE_ENDPOINT = "instruments"
1223
- _NAMESPACE = "summary"
1224
-
1225
-
1226
- class ReductionSummary(_ONCatResource):
1227
- _RESOURCE_ENDPOINT = "reductions"
1228
- _NAMESPACE = "summary"
1229
-
1230
-
1231
- class RunSummary(_ONCatResource):
1232
- _RESOURCE_ENDPOINT = "runs"
1233
- _NAMESPACE = "summary"
1234
-
1235
-
1236
- class SampleSummary(_ONCatResource):
1237
- _RESOURCE_ENDPOINT = "samples"
1238
- _NAMESPACE = "summary"
1239
-
1240
-
1241
- class ScanSummary(_ONCatResource):
1242
- _RESOURCE_ENDPOINT = "scans"
1243
- _NAMESPACE = "summary"
1244
-
1245
-
1246
- class SessionSummary(_ONCatResource):
1247
- _RESOURCE_ENDPOINT = "sessions"
1248
- _NAMESPACE = "summary"
1249
-
1250
-
1251
1842
  ########################################################################
1252
1843
  # Misc Tools
1253
1844
  ########################################################################
@@ -1343,8 +1934,21 @@ def prompt_user_for_login() -> tuple[str, str]:
1343
1934
  return username, password
1344
1935
 
1345
1936
 
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."""
1937
+ def _default_device_flow_prompt(
1938
+ verification_uri: str,
1939
+ user_code: str,
1940
+ verification_uri_complete: str | None = None,
1941
+ ) -> None:
1942
+ """Print the RFC 8628 §3.3 user-confirmation prompt to stdout.
1943
+
1944
+ When the IdP supplies `verification_uri_complete` (RFC 8628 §3.3.1,
1945
+ the pre-filled one-click URL), render it directly; otherwise fall
1946
+ back to the bare verification URL plus the user code.
1947
+ """
1948
+ if verification_uri_complete:
1949
+ print("Open this URL in a browser to authenticate:")
1950
+ print(f" {verification_uri_complete}")
1951
+ return
1348
1952
  print("Open this URL in a browser to authenticate:")
1349
1953
  print(f" {verification_uri}")
1350
1954
  print("Enter this code:")
@@ -1456,4 +2060,57 @@ class DeviceAuthorizationError(PyONCatError):
1456
2060
  finish the verification step before the device code expired, the user
1457
2061
  explicitly denied the request, or the IdP returned an unrecognized
1458
2062
  error response).
2063
+
2064
+ The specific failure modes are surfaced as subclasses
2065
+ (`DeviceAuthorizationDenied`, `DeviceAuthorizationExpired`,
2066
+ `DeviceAuthorizationCancelled`, `DeviceAuthorizationTimeout`,
2067
+ `DeviceAuthorizationNetworkError`); catching this base type still
2068
+ catches all of them.
2069
+ """
2070
+
2071
+
2072
+ # The four outcome subclasses below intentionally omit the `Error` suffix
2073
+ # (triggering N818): the names are the public 2.6 device-authorization
2074
+ # API contract
2075
+ # (ADR-0097), spelled to read as outcomes the caller catches by reason --
2076
+ # Denied / Expired / Cancelled / Timeout. They subclass
2077
+ # `DeviceAuthorizationError`, so the suffixed base type still catches them.
2078
+ class DeviceAuthorizationDenied(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-0097)
2079
+ """Raised when the user explicitly denied the sign-in request
2080
+ (the IdP returned `access_denied`, RFC 8628 §3.5)."""
2081
+
2082
+
2083
+ class DeviceAuthorizationExpired(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-0097)
2084
+ """Raised when the device code expired before the user approved the
2085
+ sign-in (the IdP returned `expired_token`, RFC 8628 §3.5)."""
2086
+
2087
+
2088
+ class DeviceAuthorizationCancelled(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-0097)
2089
+ """Raised when the caller cancelled the sign-in in progress via the
2090
+ `cancel_event` passed to `login()`."""
2091
+
2092
+
2093
+ class DeviceAuthorizationTimeout(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-0097)
2094
+ """Raised when the sign-in did not complete before the overall
2095
+ caller deadline (`device_authorization_timeout`) or the server's own
2096
+ `expires_in`, whichever came first."""
2097
+
2098
+
2099
+ class DeviceAuthorizationNetworkError(DeviceAuthorizationError):
2100
+ """Raised when an underlying HTTP request in the Device Authorization
2101
+ Grant failed at the transport layer (the original
2102
+ `requests.RequestException` is preserved as `__cause__`)."""
2103
+
2104
+
2105
+ class InteractionRequiredError(PyONCatError):
2106
+ """Raised when a Device Authorization Grant session has expired and a
2107
+ silent refresh is not possible, so the user must interactively sign
2108
+ in again.
2109
+
2110
+ Raised only when the client is constructed with
2111
+ `reauth_on_expired=REAUTH_INTERACTION_REQUIRED`: instead of
2112
+ transparently re-running `login()` (and firing the verification
2113
+ callback) from inside an ordinary API call, PyONCat raises this so
2114
+ the application can decide when and how to prompt the user to sign
2115
+ in again.
1459
2116
  """
@@ -566,7 +566,7 @@ class ReductionFileBuilder:
566
566
  """
567
567
  try:
568
568
  return self.write(path, overwrite)
569
- except Exception as e: # noqa: BLE001
569
+ except Exception as e: # ruff: ignore[blind-except]
570
570
  # Intentionally catch all exceptions - design requirement for
571
571
  # autoreduction scripts where cataloging failures should never
572
572
  # break the reduction workflow
@@ -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.5"
10
+ version = "2.7"
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"]
@@ -21,7 +21,7 @@ packages = [
21
21
  ]
22
22
 
23
23
  [tool.poetry.dependencies]
24
- python = ">=3.7, <4"
24
+ python = ">=3.9, <4"
25
25
  requests = "*"
26
26
  oauthlib = { version = "*", optional = true }
27
27
  requests-oauthlib = { version = "*", optional = true }
File without changes