freva-client 2509.0.0__tar.gz → 2509.1.0__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.

Potentially problematic release.


This version of freva-client might be problematic. Click here for more details.

Files changed (21) hide show
  1. {freva_client-2509.0.0 → freva_client-2509.1.0}/PKG-INFO +1 -1
  2. {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/__init__.py +1 -1
  3. {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/auth.py +23 -125
  4. freva_client-2509.1.0/src/freva_client/utils/auth_utils.py +516 -0
  5. freva_client-2509.0.0/src/freva_client/utils/auth_utils.py +0 -278
  6. {freva_client-2509.0.0 → freva_client-2509.1.0}/MANIFEST.in +0 -0
  7. {freva_client-2509.0.0 → freva_client-2509.1.0}/README.md +0 -0
  8. {freva_client-2509.0.0 → freva_client-2509.1.0}/assets/share/freva/freva.toml +0 -0
  9. {freva_client-2509.0.0 → freva_client-2509.1.0}/pyproject.toml +0 -0
  10. {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/__main__.py +0 -0
  11. {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/cli/__init__.py +0 -0
  12. {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/cli/auth_cli.py +0 -0
  13. {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/cli/cli_app.py +0 -0
  14. {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/cli/cli_parser.py +0 -0
  15. {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/cli/cli_utils.py +0 -0
  16. {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/cli/databrowser_cli.py +0 -0
  17. {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/py.typed +0 -0
  18. {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/query.py +0 -0
  19. {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/utils/__init__.py +0 -0
  20. {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/utils/databrowser_utils.py +0 -0
  21. {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/utils/logger.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: freva-client
3
- Version: 2509.0.0
3
+ Version: 2509.1.0
4
4
  Summary: Search for climate data based on key-value pairs
5
5
  Author-email: "DKRZ, Clint" <freva@dkrz.de>
6
6
  Requires-Python: >=3.8
@@ -18,5 +18,5 @@ official documentation: https://freva-org.github.io/freva-legacy
18
18
  from .auth import authenticate
19
19
  from .query import databrowser
20
20
 
21
- __version__ = "2509.0.0"
21
+ __version__ = "2509.1.0"
22
22
  __all__ = ["authenticate", "databrowser", "__version__"]
@@ -2,28 +2,24 @@
2
2
 
3
3
  import datetime
4
4
  import json
5
- import socket
6
- import urllib.parse
7
- import webbrowser
8
- from getpass import getuser
9
- from http.server import BaseHTTPRequestHandler, HTTPServer
5
+ import os
10
6
  from pathlib import Path
11
- from threading import Event, Lock, Thread
12
- from typing import Any, Dict, List, Optional, Union
7
+ from typing import Any, Dict, Optional, Union
13
8
 
14
9
  import requests
15
10
 
16
11
  from .utils import logger
17
12
  from .utils.auth_utils import (
13
+ AuthError,
14
+ DeviceAuthClient,
18
15
  Token,
19
16
  choose_token_strategy,
20
17
  get_default_token_file,
18
+ is_interactive_auth_possible,
21
19
  load_token,
22
- wait_for_port,
23
20
  )
24
21
  from .utils.databrowser_utils import Config
25
22
 
26
- REDIRECT_URI = "http://localhost:{port}/callback"
27
23
  AUTH_FAILED_MSG = """Login failed or no valid token found in this environment.
28
24
  If you appear to be in a non-interactive or remote session create a new token
29
25
  via:
@@ -35,52 +31,11 @@ Then pass the token file using:"
35
31
  " {token_path} or set the $TOKEN_ENV_VAR env. variable."""
36
32
 
37
33
 
38
- class AuthError(Exception):
39
- """Athentication error."""
40
-
41
- pass
42
-
43
-
44
- class OAuthCallbackHandler(BaseHTTPRequestHandler):
45
- def log_message(self, format: str, *args: object) -> None:
46
- logger.debug(format, *args)
47
-
48
- def do_GET(self) -> None:
49
- query = urllib.parse.urlparse(self.path).query
50
- params = urllib.parse.parse_qs(query)
51
- if "code" in params:
52
- setattr(self.server, "auth_code", params["code"][0])
53
- self.send_response(200)
54
- self.end_headers()
55
- self.wfile.write(b"Login successful! You can close this tab.")
56
- else:
57
- self.send_response(400)
58
- self.end_headers()
59
- self.wfile.write(b"Authorization code not found.")
60
-
61
-
62
- def start_local_server(port: int, event: Event) -> HTTPServer:
63
- """Start local HTTP server to wait for a single callback."""
64
- server = HTTPServer(("localhost", port), OAuthCallbackHandler)
65
-
66
- def handle() -> None:
67
- logger.info("Waiting for browser callback on port %s ...", port)
68
- while not event.is_set():
69
- server.handle_request()
70
- if getattr(server, "auth_code", None):
71
- event.set()
72
-
73
- thread = Thread(target=handle, daemon=True)
74
- thread.start()
75
- return server
76
-
77
-
78
34
  class Auth:
79
35
  """Helper class for authentication."""
80
36
 
81
37
  _instance: Optional["Auth"] = None
82
38
  _auth_token: Optional[Token] = None
83
- _thread_lock: Lock = Lock()
84
39
 
85
40
  def __new__(cls, *args: Any, **kwargs: Any) -> "Auth":
86
41
  if cls._instance is None:
@@ -113,86 +68,29 @@ class Auth:
113
68
  def _login(
114
69
  self,
115
70
  auth_url: str,
116
- force: bool = False,
117
71
  _timeout: Optional[int] = 30,
118
72
  ) -> Token:
119
- login_endpoint = f"{auth_url}/login"
73
+ device_endpoint = f"{auth_url}/device"
120
74
  token_endpoint = f"{auth_url}/token"
121
- port = self.find_free_port(auth_url)
122
- redirect_uri = REDIRECT_URI.format(port=port)
123
- params = {
124
- "redirect_uri": redirect_uri,
125
- "offline_access": "true",
126
- }
127
- if force:
128
- params["prompt"] = "login"
129
- query = urllib.parse.urlencode(params)
130
- login_url = f"{login_endpoint}?{query}"
131
- logger.info("Opening browser for login:\n%s", login_url)
132
- logger.info(
133
- "If you are using this on a remote host, you might need to "
134
- "increase the login timeout and forward port %d:\n"
135
- " ssh -L %d:localhost:%d %s@%s",
136
- port,
137
- port,
138
- port,
139
- getuser(),
140
- socket.gethostname(),
75
+ client = DeviceAuthClient(
76
+ device_endpoint=device_endpoint,
77
+ token_endpoint=token_endpoint,
78
+ timeout=_timeout,
141
79
  )
142
- event = Event()
143
- server = start_local_server(port, event)
144
- code: Optional[str] = None
145
- reason = "Login failed."
146
- try:
147
- wait_for_port("localhost", port)
148
- webbrowser.open(login_url)
149
- success = event.wait(timeout=_timeout or None)
150
- if not success:
151
- raise TimeoutError(
152
- f"Login did not complete within {_timeout} seconds. "
153
- "Possibly headless environment."
154
- )
155
- code = getattr(server, "auth_code", None)
156
- except Exception as error:
157
- logger.warning(
158
- "Could not open browser automatically. %s"
159
- "Please open the URL manually.",
160
- error,
161
- )
162
- reason = str(error)
163
-
164
- finally:
165
- logger.debug("Cleaning up login state")
166
- if hasattr(server, "server_close"):
167
- try:
168
- server.server_close()
169
- except Exception as error:
170
- logger.debug("Failed to close server cleanly: %s", error)
171
- if not code:
172
- raise AuthError(reason)
173
- return self.get_token(
174
- token_endpoint,
175
- data={
176
- "code": code,
177
- "redirect_uri": redirect_uri,
178
- "grant_type": "authorization_code",
179
- },
80
+ is_interactive_auth = int(
81
+ os.getenv("BROWSER_SESSION", str(int(is_interactive_auth_possible())))
180
82
  )
181
-
182
- @staticmethod
183
- def find_free_port(auth_url: str) -> int:
184
- """Get a free port where we can start the test server."""
185
- ports: List[int] = (
186
- requests.get(f"{auth_url}/auth-ports").json().get("valid_ports", [])
83
+ response = client.login(
84
+ token_normalizer=self.get_token, auto_open=bool(is_interactive_auth)
85
+ )
86
+ return self.set_token(
87
+ access_token=response["access_token"],
88
+ token_type=response["token_type"],
89
+ expires=response["expires"],
90
+ refresh_token=response["refresh_token"],
91
+ refresh_expires=response["refresh_expires"],
92
+ scope=response["scope"],
187
93
  )
188
- for port in ports:
189
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
190
- try:
191
- s.bind(("localhost", port))
192
- return port
193
- except (OSError, PermissionError):
194
- pass
195
- raise OSError("No free ports available for login flow")
196
94
 
197
95
  def set_token(
198
96
  self,
@@ -265,7 +163,7 @@ class Auth:
265
163
  cfg.auth_url, token["refresh_token"], timeout=timeout
266
164
  )
267
165
  if strategy == "browser_auth":
268
- return self._login(cfg.auth_url, force=force, _timeout=timeout)
166
+ return self._login(cfg.auth_url, _timeout=timeout)
269
167
  except AuthError as error:
270
168
  reason = str(error)
271
169
 
@@ -0,0 +1,516 @@
1
+ """Helper functions for authentication."""
2
+
3
+ import json
4
+ import os
5
+ import random
6
+ import sys
7
+ import time
8
+ import webbrowser
9
+ from contextlib import contextmanager
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import (
13
+ Any,
14
+ Callable,
15
+ Dict,
16
+ Iterator,
17
+ Literal,
18
+ Optional,
19
+ TypedDict,
20
+ Union,
21
+ cast,
22
+ )
23
+
24
+ import requests
25
+ import rich.console
26
+ import rich.spinner
27
+ from appdirs import user_cache_dir
28
+ from rich.live import Live
29
+
30
+ from freva_client.utils import logger
31
+
32
+ TOKEN_EXPIRY_BUFFER = 60 # seconds
33
+ TOKEN_ENV_VAR = "FREVA_TOKEN_FILE"
34
+
35
+
36
+ BROWSER_MESSAGE = """Will attempt to open the auth url in your browser.
37
+
38
+ If this doesn't work, try opening the following url:
39
+
40
+ {interactive}{uri}{interactive_end}
41
+
42
+ You might have to enter the this code manually {interactive}{user_code}{interactive_end}
43
+ """
44
+
45
+ NON_INTERACTIVE_MESSAGE = """
46
+
47
+ Visit the following url to authorise your session:
48
+
49
+ {interactive}{uri}{interactive_end}
50
+
51
+ You might have to enter the this code manually {interactive}{user_code}{interactive_end}
52
+
53
+ """
54
+
55
+
56
+ @contextmanager
57
+ def _clock(timeout: Optional[int] = None) -> Iterator[None]:
58
+ Console = rich.console.Console(
59
+ force_terminal=is_interactive_shell(), stderr=True
60
+ )
61
+ txt = f"- Timeout: {timeout:>3,.0f}s " if timeout else ""
62
+ interactive = int(Console.is_terminal)
63
+ if int(os.getenv("INTERACIVE_SESSION", str(interactive))):
64
+ spinner = rich.spinner.Spinner(
65
+ "moon", text=f"[b]Waiting for code {txt}... [/]"
66
+ )
67
+ live = Live(
68
+ spinner, console=Console, refresh_per_second=2.5, transient=True
69
+ )
70
+ try:
71
+ live.start()
72
+ yield
73
+ finally:
74
+ live.stop()
75
+ else:
76
+ yield
77
+
78
+
79
+ class AuthError(Exception):
80
+ """Athentication error."""
81
+
82
+ def __init__(
83
+ self, message: str, detail: Optional[Dict[str, str]] = None
84
+ ) -> None:
85
+ super().__init__(message)
86
+ self.message = message
87
+ self.detail = detail or {}
88
+
89
+ def __str__(self) -> str:
90
+ return f"{self.message}: {self.detail}" if self.detail else self.message
91
+
92
+
93
+ class Token(TypedDict, total=False):
94
+ """Token information."""
95
+
96
+ access_token: str
97
+ token_type: str
98
+ expires: int
99
+ refresh_token: str
100
+ refresh_expires: int
101
+ scope: str
102
+
103
+
104
+ @dataclass
105
+ class DeviceAuthClient:
106
+ """
107
+ Minimal OIDC Device Authorization Grant client.
108
+
109
+ Parameters
110
+ ----------
111
+ device_endpoint : str
112
+ Device endpoint URL, e.g. ``{issuer}/protocol/openid-connect/auth/device``.
113
+ token_endpoint : str
114
+ Token endpoint URL, e.g. ``{issuer}/protocol/openid-connect/token``.
115
+ scope : str, optional
116
+ Space-separated scopes; include ``offline_access`` if you need offline RTs.
117
+ timeout : int | None, optional
118
+ Overall timeout (seconds) for user approval. ``None`` waits indefinitely.
119
+ session : requests.Session | None, optional
120
+ Reusable HTTP session. A new one is created if omitted.
121
+
122
+ Notes
123
+ -----
124
+ - For a CLI UX, show both ``verification_uri_complete`` (if present) and
125
+ ``verification_uri`` + ``user_code`` as fallback.
126
+ - Polling respects ``interval`` and ``slow_down`` per RFC 8628.
127
+ """
128
+
129
+ device_endpoint: str
130
+ token_endpoint: str
131
+ timeout: Optional[int] = 600
132
+ session: requests.Session = field(default_factory=requests.Session)
133
+
134
+ # ---------- Public API ----------
135
+
136
+ def login(
137
+ self,
138
+ *,
139
+ auto_open: bool = True,
140
+ token_normalizer: Optional[Callable[[str, Dict[str, str]], Token]] = None,
141
+ ) -> Token:
142
+ """
143
+ Run the full device flow and return an OIDC token payload.
144
+
145
+ Parameters
146
+ ----------
147
+ auto_open : bool, optional
148
+ Attempt to open ``verification_uri_complete`` in a browser.
149
+ token_normalizer : Callable, optional
150
+ If provided, called as ``token_normalizer(self.token_endpoint, data)``
151
+ to normalize/store tokens (e.g., your existing ``self.get_token``).
152
+ If omitted, the raw token JSON from the token endpoint is returned.
153
+
154
+ Returns
155
+ -------
156
+ Token
157
+ The token payload. Includes a refresh token if granted and allowed.
158
+
159
+ Raises
160
+ ------
161
+ AuthError
162
+ If the device flow fails or times out.
163
+ """
164
+ init = self._authorize()
165
+
166
+ uri = init.get("verification_uri_complete") or init["verification_uri"]
167
+ user_code = init["user_code"]
168
+
169
+ Console = rich.console.Console(
170
+ force_terminal=is_interactive_shell(), stderr=True
171
+ )
172
+ pprint = Console.print if Console.is_terminal else print
173
+ interactive, interactive_end = (
174
+ ("[b]", "[/b]") if Console.is_terminal else ("", "")
175
+ )
176
+
177
+ if auto_open and init.get("verification_uri_complete"):
178
+ try:
179
+ pprint(
180
+ BROWSER_MESSAGE.format(
181
+ user_code=user_code,
182
+ uri=uri,
183
+ interactive=interactive,
184
+ interactive_end=interactive_end,
185
+ )
186
+ )
187
+ webbrowser.open(init["verification_uri_complete"])
188
+ except Exception as error:
189
+ logger.warning("Could not auto-open browser: %s", error)
190
+ else:
191
+ pprint(
192
+ NON_INTERACTIVE_MESSAGE.format(
193
+ user_code=user_code,
194
+ uri=uri,
195
+ interactive=interactive,
196
+ interactive_end=interactive_end,
197
+ )
198
+ )
199
+ return self._poll_for_token(
200
+ device_code=init["device_code"],
201
+ base_interval=int(init.get("interval", 5)),
202
+ )
203
+
204
+ # ---------- Internals ----------
205
+
206
+ def _authorize(self) -> Dict[str, Any]:
207
+ """Start device authorization; return the raw init payload."""
208
+ payload = self._post_form(self.device_endpoint)
209
+ # Validate essentials
210
+ for k in ("device_code", "user_code", "verification_uri", "expires_in"):
211
+ if k not in payload:
212
+ raise AuthError(f"Device authorization missing '{k}'")
213
+ return payload
214
+
215
+ def _poll_for_token(self, *, device_code: str, base_interval: int) -> Token:
216
+ """
217
+ Poll token endpoint until approved/denied/expired; return token JSON.
218
+
219
+ Raises
220
+ ------
221
+ AuthError
222
+ On timeout, denial, or other OAuth errors.
223
+ """
224
+ start = time.monotonic()
225
+ interval = max(1, base_interval)
226
+ with _clock(self.timeout):
227
+ while True:
228
+ sleep = interval + random.uniform(-0.2, 0.4)
229
+ if (
230
+ self.timeout is not None
231
+ and time.monotonic() - start > self.timeout
232
+ ):
233
+ raise AuthError(
234
+ "Login did not complete within the allotted time; "
235
+ "approve the request in your browser and try again."
236
+ )
237
+
238
+ data = {"device-code": device_code}
239
+ try:
240
+ return cast(Token, self._post_form(self.token_endpoint, data))
241
+ except AuthError as error:
242
+ err = (
243
+ error.detail.get("error")
244
+ if isinstance(error.detail, dict)
245
+ else None
246
+ )
247
+ if err is None or "authorization_pending" in err:
248
+ time.sleep(sleep)
249
+ elif "slow_down" in err:
250
+ interval += 5
251
+ time.sleep(sleep)
252
+ elif "expired_token" in err or "access_denied" in err:
253
+ raise AuthError(f"Device flow failed: {err}")
254
+ else:
255
+ # Unknown OAuth error
256
+ raise # pragma: no cover
257
+
258
+ def _post_form(
259
+ self, url: str, data: Optional[Dict[str, str]] = None
260
+ ) -> Dict[str, Any]:
261
+ """POST x-www-form-urlencoded and return JSON or raise AuthError."""
262
+ resp = self.session.post(
263
+ url,
264
+ data=data,
265
+ headers={
266
+ "Content-Type": "application/x-www-form-urlencoded",
267
+ "Connection": "close",
268
+ },
269
+ timeout=30,
270
+ )
271
+ if resp.status_code >= 400:
272
+ try:
273
+ payload = resp.json()
274
+ except Exception:
275
+ payload = {
276
+ "error": "http_error",
277
+ "error_description": resp.text[:300],
278
+ }
279
+ raise AuthError(f"{url} -> {resp.status_code}", detail=payload)
280
+ try:
281
+ return cast(Dict[str, Any], resp.json())
282
+ except Exception as error:
283
+ raise AuthError(f"Invalid JSON from {url}: {error}")
284
+
285
+
286
+ def get_default_token_file(token_file: Optional[Union[str, Path]] = None) -> Path:
287
+ """Get the location of the default token file."""
288
+ path_str = token_file or os.getenv(TOKEN_ENV_VAR, "").strip()
289
+
290
+ path = Path(
291
+ path_str or os.path.join(user_cache_dir("freva"), "auth-token.json")
292
+ )
293
+ path.parent.mkdir(exist_ok=True, parents=True)
294
+ return path
295
+
296
+
297
+ def is_job_env() -> bool:
298
+ """Detect whether we are running in a batch or job-managed environment.
299
+
300
+ Returns
301
+ -------
302
+ bool
303
+ True if common batch or workload manager environment variables are present.
304
+ """
305
+
306
+ job_env_vars = [
307
+ # Slurm, PBS, Moab
308
+ "SLURM_JOB_ID",
309
+ "SLURM_NODELIST",
310
+ "PBS_JOBID",
311
+ "PBS_ENVIRONMENT",
312
+ "PBS_NODEFILE",
313
+ # SGE
314
+ "JOB_ID",
315
+ "SGE_TASK_ID",
316
+ "PE_HOSTFILE",
317
+ # LSF
318
+ "LSB_JOBID",
319
+ "LSB_HOSTS",
320
+ # OAR
321
+ "OAR_JOB_ID",
322
+ "OAR_NODEFILE",
323
+ # MPI
324
+ "OMPI_COMM_WORLD_SIZE",
325
+ "PMI_RANK",
326
+ "MPI_LOCALRANKID",
327
+ # Kubernetes
328
+ "KUBERNETES_SERVICE_HOST",
329
+ "KUBERNETES_PORT",
330
+ # FREVA BATCH MODE
331
+ "FREVA_BATCH_JOB",
332
+ # JHUB SESSION
333
+ "JUPYTERHUB_USER",
334
+ ]
335
+ return any(var in os.environ for var in job_env_vars)
336
+
337
+
338
+ def is_jupyter_notebook() -> bool:
339
+ """Check if running in a Jupyter notebook.
340
+
341
+ Returns
342
+ -------
343
+ bool
344
+ True if inside a Jupyter notebook or Jupyter kernel.
345
+ """
346
+ try:
347
+ from IPython import get_ipython # type: ignore[attr-defined]
348
+
349
+ return get_ipython() is not None # pragma: no cover
350
+ except Exception:
351
+ return False
352
+
353
+
354
+ def is_interactive_shell() -> bool:
355
+ """Check whether we are running in an interactive terminal.
356
+
357
+ Returns
358
+ -------
359
+ bool
360
+ True if stdin and stdout are TTYs.
361
+ """
362
+ return sys.stdin.isatty() and sys.stdout.isatty()
363
+
364
+
365
+ def is_interactive_auth_possible() -> bool:
366
+ """Decide if an interactive browser-based auth flow is possible.
367
+
368
+ Returns
369
+ -------
370
+ bool
371
+ True if not in a batch/job/JupyterHub context and either in a TTY or
372
+ local Jupyter.
373
+ """
374
+ return (is_interactive_shell() or is_jupyter_notebook()) and not (
375
+ is_job_env()
376
+ )
377
+
378
+
379
+ def resolve_token_path(custom_path: Optional[Union[str, Path]] = None) -> Path:
380
+ """Resolve the path to the token file.
381
+
382
+ Parameters
383
+ ----------
384
+ custom_path : str or None
385
+ Optional path override.
386
+
387
+ Returns
388
+ -------
389
+ Path
390
+ The resolved path to the token file.
391
+ """
392
+ if custom_path:
393
+ return Path(custom_path).expanduser().absolute()
394
+ path = get_default_token_file()
395
+ return path.expanduser().absolute()
396
+
397
+
398
+ def load_token(path: Optional[Union[str, Path]]) -> Optional[Token]:
399
+ """Load a token dictionary from the given file path.
400
+
401
+ Parameters
402
+ ----------
403
+ path : Path or None
404
+ Path to the token file.
405
+
406
+ Returns
407
+ -------
408
+ dict or None
409
+ Parsed token dict or None if load fails.
410
+ """
411
+ path = resolve_token_path(path)
412
+ try:
413
+ token: Token = json.loads(path.read_text())
414
+ return token
415
+ except Exception:
416
+ return None
417
+
418
+
419
+ def is_token_valid(
420
+ token: Optional[Token], token_type: Literal["access_token", "refresh_token"]
421
+ ) -> bool:
422
+ """Check if a refresh token is available.
423
+
424
+ Parameters
425
+ ----------
426
+ token : dict
427
+ Token dictionary.
428
+ typken_type: str
429
+ What type of token to check for.
430
+
431
+ Returns
432
+ -------
433
+ bool
434
+ True if a refresh token is present.
435
+ """
436
+ exp = cast(
437
+ Literal["refresh_expires", "expires"],
438
+ {
439
+ "refresh_token": "refresh_expires",
440
+ "access_token": "expires",
441
+ }[token_type],
442
+ )
443
+ return cast(
444
+ bool,
445
+ (
446
+ token
447
+ and token_type in token
448
+ and exp in token
449
+ and (time.time() + TOKEN_EXPIRY_BUFFER < token[exp])
450
+ ),
451
+ )
452
+
453
+
454
+ def choose_token_strategy(
455
+ token: Optional[Token] = None, token_file: Optional[Path] = None
456
+ ) -> Literal["use_token", "refresh_token", "browser_auth", "fail"]:
457
+ """Decide what action to take based on token state and environment.
458
+
459
+ Parameters
460
+ ----------
461
+ token : dict|None, default: None
462
+ Token dictionary or None if no token file found.
463
+ token_file: Path|None, default: None
464
+ Path to the file holding token information.
465
+
466
+ Returns
467
+ -------
468
+ str
469
+ One of:
470
+ - "use_token" : Access token is valid and usable.
471
+ - "refresh_token" : Refresh token should be used to get new access token.
472
+ - "browser_auth" : Interactive login via browser is allowed.
473
+ - "fail" : No way to log in in current environment.
474
+ """
475
+ if is_token_valid(token, "access_token"):
476
+ return "use_token"
477
+ if is_token_valid(token, "refresh_token"):
478
+ return "refresh_token"
479
+ if is_interactive_auth_possible():
480
+ return "browser_auth"
481
+ return "fail"
482
+
483
+
484
+ def requires_authentication(
485
+ flavour: Optional[str],
486
+ zarr: bool = False,
487
+ databrowser_url: Optional[str] = None,
488
+ ) -> bool:
489
+ """Check if authentication is required.
490
+
491
+ Parameters
492
+ ----------
493
+ flavour : str or None
494
+ The data flavour to check.
495
+ zarr : bool, default: False
496
+ Whether the request is for zarr data.
497
+ databrowser_url : str or None
498
+ The URL of the databrowser to query for available flavours.
499
+ If None, the function will skip querying and assume authentication
500
+ is required for non-default flavours.
501
+ """
502
+ if zarr:
503
+ return True
504
+ if flavour in {"freva", "cmip6", "cmip5", "cordex", "user", None}:
505
+ return False
506
+ try:
507
+ response = requests.get(f"{databrowser_url}/flavours", timeout=30)
508
+ response.raise_for_status()
509
+ result = {"flavours": response.json().get("flavours", [])}
510
+ if "flavours" in result:
511
+ global_flavour_names = {f["flavour_name"] for f in result["flavours"]}
512
+ return flavour not in global_flavour_names
513
+ except Exception:
514
+ pass
515
+
516
+ return True
@@ -1,278 +0,0 @@
1
- """Helper functions for authentication."""
2
-
3
- import json
4
- import os
5
- import socket
6
- import sys
7
- import time
8
- from pathlib import Path
9
- from typing import Literal, Optional, TypedDict, Union, cast
10
-
11
- import requests
12
- from appdirs import user_cache_dir
13
-
14
- TOKEN_EXPIRY_BUFFER = 60 # seconds
15
- TOKEN_ENV_VAR = "FREVA_TOKEN_FILE"
16
-
17
-
18
- class Token(TypedDict):
19
- """Token information."""
20
-
21
- access_token: str
22
- token_type: str
23
- expires: int
24
- refresh_token: str
25
- refresh_expires: int
26
- scope: str
27
-
28
-
29
- def get_default_token_file() -> Path:
30
- """Get the location of the default token file."""
31
- path_str = os.getenv(TOKEN_ENV_VAR, "").strip()
32
-
33
- path = Path(
34
- path_str or os.path.join(user_cache_dir("freva"), "auth-token.json")
35
- )
36
- path.parent.mkdir(exist_ok=True, parents=True)
37
- return path
38
-
39
-
40
- def is_job_env() -> bool:
41
- """Detect whether we are running in a batch or job-managed environment.
42
-
43
- Returns
44
- -------
45
- bool
46
- True if common batch or workload manager environment variables are present.
47
- """
48
-
49
- job_env_vars = [
50
- # Slurm, PBS, Moab
51
- "SLURM_JOB_ID",
52
- "SLURM_NODELIST",
53
- "PBS_JOBID",
54
- "PBS_ENVIRONMENT",
55
- "PBS_NODEFILE",
56
- # SGE
57
- "JOB_ID",
58
- "SGE_TASK_ID",
59
- "PE_HOSTFILE",
60
- # LSF
61
- "LSB_JOBID",
62
- "LSB_HOSTS",
63
- # OAR
64
- "OAR_JOB_ID",
65
- "OAR_NODEFILE",
66
- # MPI
67
- "OMPI_COMM_WORLD_SIZE",
68
- "PMI_RANK",
69
- "MPI_LOCALRANKID",
70
- # Kubernetes
71
- "KUBERNETES_SERVICE_HOST",
72
- "KUBERNETES_PORT",
73
- # FREVA BATCH MODE
74
- "FREVA_BATCH_JOB",
75
- # JHUB SESSION
76
- "JUPYTERHUB_USER",
77
- ]
78
- return any(var in os.environ for var in job_env_vars)
79
-
80
-
81
- def is_jupyter_notebook() -> bool:
82
- """Check if running in a Jupyter notebook.
83
-
84
- Returns
85
- -------
86
- bool
87
- True if inside a Jupyter notebook or Jupyter kernel.
88
- """
89
- try:
90
- from IPython import get_ipython # type: ignore[attr-defined]
91
-
92
- return get_ipython() is not None # pragma: no cover
93
- except Exception:
94
- return False
95
-
96
-
97
- def is_interactive_shell() -> bool:
98
- """Check whether we are running in an interactive terminal.
99
-
100
- Returns
101
- -------
102
- bool
103
- True if stdin and stdout are TTYs.
104
- """
105
- return sys.stdin.isatty() and sys.stdout.isatty()
106
-
107
-
108
- def is_interactive_auth_possible() -> bool:
109
- """Decide if an interactive browser-based auth flow is possible.
110
-
111
- Returns
112
- -------
113
- bool
114
- True if not in a batch/job/JupyterHub context and either in a TTY or
115
- local Jupyter.
116
- """
117
- return (is_interactive_shell() or is_jupyter_notebook()) and not (
118
- is_job_env()
119
- )
120
-
121
-
122
- def resolve_token_path(custom_path: Optional[Union[str, Path]] = None) -> Path:
123
- """Resolve the path to the token file.
124
-
125
- Parameters
126
- ----------
127
- custom_path : str or None
128
- Optional path override.
129
-
130
- Returns
131
- -------
132
- Path
133
- The resolved path to the token file.
134
- """
135
- if custom_path:
136
- return Path(custom_path).expanduser().absolute()
137
- path = get_default_token_file()
138
- return path.expanduser().absolute()
139
-
140
-
141
- def load_token(path: Optional[Union[str, Path]]) -> Optional[Token]:
142
- """Load a token dictionary from the given file path.
143
-
144
- Parameters
145
- ----------
146
- path : Path or None
147
- Path to the token file.
148
-
149
- Returns
150
- -------
151
- dict or None
152
- Parsed token dict or None if load fails.
153
- """
154
- path = resolve_token_path(path)
155
- try:
156
- token: Token = json.loads(path.read_text())
157
- return token
158
- except Exception:
159
- return None
160
-
161
-
162
- def is_token_valid(
163
- token: Optional[Token], token_type: Literal["access_token", "refresh_token"]
164
- ) -> bool:
165
- """Check if a refresh token is available.
166
-
167
- Parameters
168
- ----------
169
- token : dict
170
- Token dictionary.
171
- typken_type: str
172
- What type of token to check for.
173
-
174
- Returns
175
- -------
176
- bool
177
- True if a refresh token is present.
178
- """
179
- exp = cast(
180
- Literal["refresh_expires", "expires"],
181
- {
182
- "refresh_token": "refresh_expires",
183
- "access_token": "expires",
184
- }[token_type],
185
- )
186
- return cast(
187
- bool,
188
- (
189
- token
190
- and token_type in token
191
- and exp in token
192
- and (time.time() + TOKEN_EXPIRY_BUFFER < token[exp])
193
- ),
194
- )
195
-
196
-
197
- def choose_token_strategy(
198
- token: Optional[Token] = None, token_file: Optional[Path] = None
199
- ) -> Literal["use_token", "refresh_token", "browser_auth", "fail"]:
200
- """Decide what action to take based on token state and environment.
201
-
202
- Parameters
203
- ----------
204
- token : dict|None, default: None
205
- Token dictionary or None if no token file found.
206
- token_file: Path|None, default: None
207
- Path to the file holding token information.
208
-
209
- Returns
210
- -------
211
- str
212
- One of:
213
- - "use_token" : Access token is valid and usable.
214
- - "refresh_token" : Refresh token should be used to get new access token.
215
- - "browser_auth" : Interactive login via browser is allowed.
216
- - "fail" : No way to log in in current environment.
217
- """
218
- if is_token_valid(token, "access_token"):
219
- return "use_token"
220
- if is_token_valid(token, "refresh_token"):
221
- return "refresh_token"
222
- if is_interactive_auth_possible():
223
- return "browser_auth"
224
- return "fail"
225
-
226
-
227
- def wait_for_port(host: str, port: int, timeout: float = 5.0) -> None:
228
- """Wait until a TCP port starts accepting connections."""
229
- deadline = time.time() + timeout
230
- while time.time() < deadline:
231
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
232
- sock.settimeout(0.5)
233
- try:
234
- if sock.connect_ex((host, port)) == 0:
235
- return
236
- except OSError:
237
- pass
238
- time.sleep(0.05)
239
- raise TimeoutError(
240
- f"Port {port} on {host} did not open within {timeout} seconds."
241
- )
242
-
243
-
244
- def requires_authentication(
245
- flavour: Optional[str],
246
- zarr: bool = False,
247
- databrowser_url: Optional[str] = None
248
- ) -> bool:
249
- """Check if authentication is required.
250
-
251
- Parameters
252
- ----------
253
- flavour : str or None
254
- The data flavour to check.
255
- zarr : bool, default: False
256
- Whether the request is for zarr data.
257
- databrowser_url : str or None
258
- The URL of the databrowser to query for available flavours.
259
- If None, the function will skip querying and assume authentication
260
- is required for non-default flavours.
261
- """
262
- if zarr:
263
- return True
264
- if flavour in {"freva", "cmip6", "cmip5", "cordex", "user", None}:
265
- return False
266
- try:
267
- response = requests.get(f"{databrowser_url}/flavours", timeout=30)
268
- response.raise_for_status()
269
- result = {"flavours": response.json().get("flavours", [])}
270
- if "flavours" in result:
271
- global_flavour_names = {
272
- f["flavour_name"] for f in result["flavours"]
273
- }
274
- return flavour not in global_flavour_names
275
- except Exception:
276
- pass
277
-
278
- return True