freva-client 2508.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 (22) hide show
  1. {freva_client-2508.0.0 → freva_client-2509.1.0}/PKG-INFO +1 -1
  2. {freva_client-2508.0.0 → freva_client-2509.1.0}/assets/share/freva/freva.toml +5 -0
  3. {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/__init__.py +2 -1
  4. {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/auth.py +36 -129
  5. {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/cli/auth_cli.py +6 -0
  6. freva_client-2509.1.0/src/freva_client/cli/cli_utils.py +69 -0
  7. {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/cli/databrowser_cli.py +232 -32
  8. {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/query.py +255 -38
  9. freva_client-2509.1.0/src/freva_client/utils/auth_utils.py +516 -0
  10. {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/utils/databrowser_utils.py +118 -32
  11. freva_client-2508.0.0/src/freva_client/cli/cli_utils.py +0 -32
  12. freva_client-2508.0.0/src/freva_client/utils/auth_utils.py +0 -240
  13. {freva_client-2508.0.0 → freva_client-2509.1.0}/MANIFEST.in +0 -0
  14. {freva_client-2508.0.0 → freva_client-2509.1.0}/README.md +0 -0
  15. {freva_client-2508.0.0 → freva_client-2509.1.0}/pyproject.toml +0 -0
  16. {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/__main__.py +0 -0
  17. {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/cli/__init__.py +0 -0
  18. {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/cli/cli_app.py +0 -0
  19. {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/cli/cli_parser.py +0 -0
  20. {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/py.typed +0 -0
  21. {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/utils/__init__.py +0 -0
  22. {freva_client-2508.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: 2508.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
@@ -17,3 +17,8 @@
17
17
  ## You can set a port by separating <hostname:port>
18
18
  ## for example freva.example.org:7777
19
19
  # host = ""
20
+
21
+ ##
22
+ ## The default flavour to use when accessing freva. You can simply list
23
+ ## the flavours you want to use and choose one as default.
24
+ # default_flavour = "cmip6"
@@ -12,10 +12,11 @@ science community. With help of Freva researchers can:
12
12
  The code described here is currently in testing phase. The client and server
13
13
  library described in the documentation only support searching for data. If you
14
14
  need to apply data analysis plugins, please visit the
15
+ official documentation: https://freva-org.github.io/freva-legacy
15
16
  """
16
17
 
17
18
  from .auth import authenticate
18
19
  from .query import databrowser
19
20
 
20
- __version__ = "2508.0.0"
21
+ __version__ = "2509.1.0"
21
22
  __all__ = ["authenticate", "databrowser", "__version__"]
@@ -2,27 +2,24 @@
2
2
 
3
3
  import datetime
4
4
  import json
5
- import socket
6
- import urllib.parse
7
- import webbrowser
8
- from http.server import BaseHTTPRequestHandler, HTTPServer
5
+ import os
9
6
  from pathlib import Path
10
- from threading import Event, Lock, Thread
11
- from typing import Any, Dict, List, Optional, Union
7
+ from typing import Any, Dict, Optional, Union
12
8
 
13
9
  import requests
14
10
 
15
11
  from .utils import logger
16
12
  from .utils.auth_utils import (
13
+ AuthError,
14
+ DeviceAuthClient,
17
15
  Token,
18
16
  choose_token_strategy,
19
17
  get_default_token_file,
18
+ is_interactive_auth_possible,
20
19
  load_token,
21
- wait_for_port,
22
20
  )
23
21
  from .utils.databrowser_utils import Config
24
22
 
25
- REDIRECT_URI = "http://localhost:{port}/callback"
26
23
  AUTH_FAILED_MSG = """Login failed or no valid token found in this environment.
27
24
  If you appear to be in a non-interactive or remote session create a new token
28
25
  via:
@@ -34,52 +31,11 @@ Then pass the token file using:"
34
31
  " {token_path} or set the $TOKEN_ENV_VAR env. variable."""
35
32
 
36
33
 
37
- class AuthError(Exception):
38
- """Athentication error."""
39
-
40
- pass
41
-
42
-
43
- class OAuthCallbackHandler(BaseHTTPRequestHandler):
44
- def log_message(self, format: str, *args: object) -> None:
45
- logger.debug(format, *args)
46
-
47
- def do_GET(self) -> None:
48
- query = urllib.parse.urlparse(self.path).query
49
- params = urllib.parse.parse_qs(query)
50
- if "code" in params:
51
- setattr(self.server, "auth_code", params["code"][0])
52
- self.send_response(200)
53
- self.end_headers()
54
- self.wfile.write(b"Login successful! You can close this tab.")
55
- else:
56
- self.send_response(400)
57
- self.end_headers()
58
- self.wfile.write(b"Authorization code not found.")
59
-
60
-
61
- def start_local_server(port: int, event: Event) -> HTTPServer:
62
- """Start local HTTP server to wait for a single callback."""
63
- server = HTTPServer(("localhost", port), OAuthCallbackHandler)
64
-
65
- def handle() -> None:
66
- logger.info("Waiting for browser callback on port %s ...", port)
67
- while not event.is_set():
68
- server.handle_request()
69
- if getattr(server, "auth_code", None):
70
- event.set()
71
-
72
- thread = Thread(target=handle, daemon=True)
73
- thread.start()
74
- return server
75
-
76
-
77
34
  class Auth:
78
35
  """Helper class for authentication."""
79
36
 
80
37
  _instance: Optional["Auth"] = None
81
38
  _auth_token: Optional[Token] = None
82
- _thread_lock: Lock = Lock()
83
39
 
84
40
  def __new__(cls, *args: Any, **kwargs: Any) -> "Auth":
85
41
  if cls._instance is None:
@@ -112,83 +68,29 @@ class Auth:
112
68
  def _login(
113
69
  self,
114
70
  auth_url: str,
115
- force: bool = False,
116
- _timeout: int = 30,
71
+ _timeout: Optional[int] = 30,
117
72
  ) -> Token:
118
- login_endpoint = f"{auth_url}/login"
73
+ device_endpoint = f"{auth_url}/device"
119
74
  token_endpoint = f"{auth_url}/token"
120
- port = self.find_free_port(auth_url)
121
- redirect_uri = REDIRECT_URI.format(port=port)
122
- params = {
123
- "redirect_uri": redirect_uri,
124
- }
125
- if force:
126
- params["prompt"] = "login"
127
- query = urllib.parse.urlencode(params)
128
- login_url = f"{login_endpoint}?{query}"
129
- logger.info("Opening browser for login:\n%s", login_url)
130
- logger.info(
131
- "If you are using this on a remote host, you might need to "
132
- "forward port %d:\n"
133
- " ssh -L %d:localhost:%d user@remotehost",
134
- port,
135
- port,
136
- port,
75
+ client = DeviceAuthClient(
76
+ device_endpoint=device_endpoint,
77
+ token_endpoint=token_endpoint,
78
+ timeout=_timeout,
137
79
  )
138
- event = Event()
139
- server = start_local_server(port, event)
140
- code: Optional[str] = None
141
- reason = "Login failed."
142
- try:
143
- wait_for_port("localhost", port)
144
- webbrowser.open(login_url)
145
- success = event.wait(timeout=_timeout)
146
- if not success:
147
- raise TimeoutError(
148
- f"Login did not complete within {_timeout} seconds. "
149
- "Possibly headless environment."
150
- )
151
- code = getattr(server, "auth_code", None)
152
- except Exception as error:
153
- logger.warning(
154
- "Could not open browser automatically. %s"
155
- "Please open the URL manually.",
156
- error,
157
- )
158
- reason = str(error)
159
-
160
- finally:
161
- logger.debug("Cleaning up login state")
162
- if hasattr(server, "server_close"):
163
- try:
164
- server.server_close()
165
- except Exception as error:
166
- logger.debug("Failed to close server cleanly: %s", error)
167
- if not code:
168
- raise AuthError(reason)
169
- return self.get_token(
170
- token_endpoint,
171
- data={
172
- "code": code,
173
- "redirect_uri": redirect_uri,
174
- "grant_type": "authorization_code",
175
- },
80
+ is_interactive_auth = int(
81
+ os.getenv("BROWSER_SESSION", str(int(is_interactive_auth_possible())))
176
82
  )
177
-
178
- @staticmethod
179
- def find_free_port(auth_url: str) -> int:
180
- """Get a free port where we can start the test server."""
181
- ports: List[int] = (
182
- 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"],
183
93
  )
184
- for port in ports:
185
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
186
- try:
187
- s.bind(("localhost", port))
188
- return port
189
- except (OSError, PermissionError):
190
- pass
191
- raise OSError("No free ports available for login flow")
192
94
 
193
95
  def set_token(
194
96
  self,
@@ -223,9 +125,7 @@ class Auth:
223
125
  return self._auth_token
224
126
 
225
127
  def _refresh(
226
- self,
227
- url: str,
228
- refresh_token: str,
128
+ self, url: str, refresh_token: str, timeout: Optional[int] = 30
229
129
  ) -> Token:
230
130
  """Refresh the access_token with a refresh token."""
231
131
  try:
@@ -235,7 +135,7 @@ class Auth:
235
135
  )
236
136
  except (AuthError, KeyError) as error:
237
137
  logger.warning("Failed to refresh token: %s", error)
238
- return self._login(url)
138
+ return self._login(url, _timeout=timeout)
239
139
 
240
140
  def authenticate(
241
141
  self,
@@ -243,6 +143,7 @@ class Auth:
243
143
  config: Optional[Config] = None,
244
144
  *,
245
145
  force: bool = False,
146
+ timeout: Optional[int] = 30,
246
147
  _cli: bool = False,
247
148
  ) -> Token:
248
149
  """Authenticate the user to the host."""
@@ -258,9 +159,11 @@ class Auth:
258
159
  return self._auth_token
259
160
  try:
260
161
  if strategy == "refresh_token" and token:
261
- return self._refresh(cfg.auth_url, token["refresh_token"])
162
+ return self._refresh(
163
+ cfg.auth_url, token["refresh_token"], timeout=timeout
164
+ )
262
165
  if strategy == "browser_auth":
263
- return self._login(cfg.auth_url, force=force)
166
+ return self._login(cfg.auth_url, _timeout=timeout)
264
167
  except AuthError as error:
265
168
  reason = str(error)
266
169
 
@@ -287,6 +190,7 @@ def authenticate(
287
190
  token_file: Optional[Union[Path, str]] = None,
288
191
  host: Optional[str] = None,
289
192
  force: bool = False,
193
+ timeout: Optional[int] = 30,
290
194
  ) -> Token:
291
195
  """Authenticate to the host.
292
196
 
@@ -294,13 +198,15 @@ def authenticate(
294
198
 
295
199
  Parameters
296
200
  ----------
297
- refresh_token: str, optional
201
+ token_file: str, optional
298
202
  Instead of setting a password, you can set a refresh token to refresh
299
203
  the access token. This is recommended for non-interactive environments.
300
204
  host: str, optional
301
205
  The hostname of the REST server.
302
206
  force: bool, default: False
303
207
  Force token recreation, even if current token is still valid.
208
+ timeout: int, default: 30
209
+ Set the timeout, None for indefinate.
304
210
 
305
211
  Returns
306
212
  -------
@@ -313,7 +219,7 @@ def authenticate(
313
219
  .. code-block:: python
314
220
 
315
221
  from freva_client import authenticate
316
- token = authenticate()
222
+ token = authenticate(timeout=120)
317
223
  print(token)
318
224
 
319
225
  Batch mode authentication with a refresh token:
@@ -327,4 +233,5 @@ def authenticate(
327
233
  return auth.authenticate(
328
234
  host=host,
329
235
  force=force,
236
+ timeout=timeout,
330
237
  )
@@ -44,6 +44,11 @@ def authenticate_cli(
44
44
  "-f",
45
45
  help="Force token recreation, even if current token is still valid.",
46
46
  ),
47
+ timeout: int = typer.Option(
48
+ 30,
49
+ "--timeout",
50
+ help="Set the timeout for login in secdonds, 0 for indefinate",
51
+ ),
47
52
  verbose: int = typer.Option(0, "-v", help="Increase verbosity", count=True),
48
53
  version: Optional[bool] = typer.Option(
49
54
  False,
@@ -59,5 +64,6 @@ def authenticate_cli(
59
64
  host=host,
60
65
  force=force,
61
66
  _cli=True,
67
+ timeout=timeout,
62
68
  )
63
69
  print(json.dumps(token, indent=3))
@@ -0,0 +1,69 @@
1
+ """Utilities for the command line interface."""
2
+
3
+ from typing import Any, Dict, List
4
+
5
+ import pandas as pd
6
+ import typer
7
+ from rich import print as pprint
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from freva_client import __version__
12
+ from freva_client.utils import logger
13
+
14
+ APP_NAME: str = "freva-client"
15
+
16
+
17
+ def version_callback(version: bool) -> None:
18
+ """Print the version and exit."""
19
+ if version:
20
+ pprint(f"{APP_NAME}: {__version__}")
21
+ raise typer.Exit()
22
+
23
+
24
+ def parse_cli_args(cli_args: List[str]) -> Dict[str, List[str]]:
25
+ """Convert the cli arguments to a dictionary."""
26
+ logger.debug("parsing command line arguments.")
27
+ kwargs = {}
28
+ for entry in cli_args:
29
+ key, _, value = entry.partition("=")
30
+ if value and key not in kwargs:
31
+ kwargs[key] = [value]
32
+ elif value:
33
+ kwargs[key].append(value)
34
+ logger.debug(kwargs)
35
+ return kwargs
36
+
37
+
38
+ def _summarize(val: Any, max_items: int = 6) -> str:
39
+ """Summarize values for table display, truncating long lists."""
40
+ n = len(val)
41
+ head = ", ".join(map(str, val[:max_items]))
42
+ if n > max_items:
43
+ return f"{head} … (+{n - max_items} more)"
44
+ return head
45
+
46
+
47
+ def print_df(s: pd.Series, max_items: int = 6) -> None:
48
+ """Print a pandas Series as a rich table.
49
+
50
+ Parameters
51
+ ----------
52
+ s : pd.Series
53
+ The pandas Series to print.
54
+ max_items : int, optional
55
+ Maximum number of items to display for list-like values,
56
+ by default 6.
57
+ """
58
+ left_col: str = s.index.name or "index"
59
+ right_col: str = s.name or "value"
60
+
61
+ table: Table = Table(show_header=True, header_style="bold magenta")
62
+ table.add_column(left_col, style="cyan", no_wrap=True)
63
+ table.add_column(right_col, style="green")
64
+
65
+ for key, val in s.items():
66
+ table.add_row(str(key), _summarize(val, max_items=max_items))
67
+
68
+ console: Console = Console()
69
+ console.print(table)