freva-client 2509.0.0__py3-none-any.whl → 2509.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

freva_client/__init__.py CHANGED
@@ -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__"]
freva_client/auth.py CHANGED
@@ -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
 
@@ -2,20 +2,95 @@
2
2
 
3
3
  import json
4
4
  import os
5
- import socket
5
+ import random
6
6
  import sys
7
7
  import time
8
+ import webbrowser
9
+ from contextlib import contextmanager
10
+ from dataclasses import dataclass, field
8
11
  from pathlib import Path
9
- from typing import Literal, Optional, TypedDict, Union, cast
12
+ from typing import (
13
+ Any,
14
+ Callable,
15
+ Dict,
16
+ Iterator,
17
+ Literal,
18
+ Optional,
19
+ TypedDict,
20
+ Union,
21
+ cast,
22
+ )
10
23
 
11
24
  import requests
25
+ import rich.console
26
+ import rich.spinner
12
27
  from appdirs import user_cache_dir
28
+ from rich.live import Live
29
+
30
+ from freva_client.utils import logger
13
31
 
14
32
  TOKEN_EXPIRY_BUFFER = 60 # seconds
15
33
  TOKEN_ENV_VAR = "FREVA_TOKEN_FILE"
16
34
 
17
35
 
18
- class Token(TypedDict):
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):
19
94
  """Token information."""
20
95
 
21
96
  access_token: str
@@ -26,9 +101,191 @@ class Token(TypedDict):
26
101
  scope: str
27
102
 
28
103
 
29
- def get_default_token_file() -> Path:
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:
30
287
  """Get the location of the default token file."""
31
- path_str = os.getenv(TOKEN_ENV_VAR, "").strip()
288
+ path_str = token_file or os.getenv(TOKEN_ENV_VAR, "").strip()
32
289
 
33
290
  path = Path(
34
291
  path_str or os.path.join(user_cache_dir("freva"), "auth-token.json")
@@ -224,27 +481,10 @@ def choose_token_strategy(
224
481
  return "fail"
225
482
 
226
483
 
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
484
  def requires_authentication(
245
- flavour: Optional[str],
246
- zarr: bool = False,
247
- databrowser_url: Optional[str] = None
485
+ flavour: Optional[str],
486
+ zarr: bool = False,
487
+ databrowser_url: Optional[str] = None,
248
488
  ) -> bool:
249
489
  """Check if authentication is required.
250
490
 
@@ -268,9 +508,7 @@ def requires_authentication(
268
508
  response.raise_for_status()
269
509
  result = {"flavours": response.json().get("flavours", [])}
270
510
  if "flavours" in result:
271
- global_flavour_names = {
272
- f["flavour_name"] for f in result["flavours"]
273
- }
511
+ global_flavour_names = {f["flavour_name"] for f in result["flavours"]}
274
512
  return flavour not in global_flavour_names
275
513
  except Exception:
276
514
  pass
@@ -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
@@ -1,6 +1,6 @@
1
- freva_client/__init__.py,sha256=6C-njQv12FgsYk2cWv2y3PPBZYfO99qPPeAFmyQhZyA,916
1
+ freva_client/__init__.py,sha256=m2rzqsvQ80LSRnN3aBwSfirKiSIqQb6qv6O9J_WhAOo,916
2
2
  freva_client/__main__.py,sha256=JVj12puT4o8JfhKLAggR2-NKCZa3wKwsYGi4HQ61DOQ,149
3
- freva_client/auth.py,sha256=yq_W8DjYA3W2b5HLKTplg5s_prJR9um9NV4uxHMIS2M,10897
3
+ freva_client/auth.py,sha256=_oHcMDKJjMvP7ZDNl6NK7NKelcxYW3jVpnQm8E-uvIU,7390
4
4
  freva_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
5
  freva_client/query.py,sha256=Dm6Wy9U8ofzZd4v50Sjhl4Guh7njrClSnNNV5_7UdM0,49433
6
6
  freva_client/cli/__init__.py,sha256=NgTqBZGdozmTZtJduJUMrZj-opGw2KoT20tg6sc_xqo,149
@@ -10,11 +10,11 @@ freva_client/cli/cli_parser.py,sha256=EyzvTr70TBCD0mO2P3mVNtuEeEbNk2OdrhKSEHuu6N
10
10
  freva_client/cli/cli_utils.py,sha256=9h2hlBQA-D3n-JlFIC1DSuzEEv73Bpu8lXLAqZBDaYI,1946
11
11
  freva_client/cli/databrowser_cli.py,sha256=LpK6NvHWsbHVeJvOHG_su0dh9FHkLxTmY4K8P75S8A0,39397
12
12
  freva_client/utils/__init__.py,sha256=ySHn-3CZBwfZW2s0EpZ3INxUWOw1V4LOlKIxSLYr52U,1000
13
- freva_client/utils/auth_utils.py,sha256=6didULriVK72D_Ptj8UV5wRrMl_w1C9vIoietpy3cG8,7206
13
+ freva_client/utils/auth_utils.py,sha256=H0knqoX2gvxM8hPzfIZQQZSKaT5tD1sRCSF2ddcdAzw,15058
14
14
  freva_client/utils/databrowser_utils.py,sha256=Hv39Q8Crd63l2U-lBJfppMn14BUm6ECY0ASsgP_8du8,17801
15
15
  freva_client/utils/logger.py,sha256=vjBbNb9KvyMriBPpgIoJjlQFCEj3DLqkJu8tYxfp2xI,2494
16
- freva_client-2509.0.0.data/data/share/freva/freva.toml,sha256=5xXWBqw0W6JyWfRMaSNnKDOGkDznx6NFaJvggh-cUPU,899
17
- freva_client-2509.0.0.dist-info/entry_points.txt,sha256=zGyEwHrH_kAGLsCXv00y7Qnp-WjXkUuIomHkfGMCxtA,53
18
- freva_client-2509.0.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
19
- freva_client-2509.0.0.dist-info/METADATA,sha256=gf2-uq7wTLtweozJYjxkFXkBOh-d8b2s1ys15nYv_rc,2487
20
- freva_client-2509.0.0.dist-info/RECORD,,
16
+ freva_client-2509.1.0.data/data/share/freva/freva.toml,sha256=5xXWBqw0W6JyWfRMaSNnKDOGkDznx6NFaJvggh-cUPU,899
17
+ freva_client-2509.1.0.dist-info/entry_points.txt,sha256=zGyEwHrH_kAGLsCXv00y7Qnp-WjXkUuIomHkfGMCxtA,53
18
+ freva_client-2509.1.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
19
+ freva_client-2509.1.0.dist-info/METADATA,sha256=S92UfQmSmjiv_Ol1g1fy9OCPeZkoZl0n3943pN1Jyoc,2487
20
+ freva_client-2509.1.0.dist-info/RECORD,,