freva-client 2509.2.0__tar.gz → 2510.0.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 (20) hide show
  1. {freva_client-2509.2.0 → freva_client-2510.0.0}/PKG-INFO +1 -1
  2. {freva_client-2509.2.0 → freva_client-2510.0.0}/src/freva_client/__init__.py +1 -1
  3. {freva_client-2509.2.0 → freva_client-2510.0.0}/src/freva_client/auth.py +15 -4
  4. {freva_client-2509.2.0 → freva_client-2510.0.0}/src/freva_client/cli/databrowser_cli.py +68 -0
  5. {freva_client-2509.2.0 → freva_client-2510.0.0}/src/freva_client/query.py +43 -3
  6. {freva_client-2509.2.0 → freva_client-2510.0.0}/src/freva_client/utils/auth_utils.py +174 -10
  7. {freva_client-2509.2.0 → freva_client-2510.0.0}/MANIFEST.in +0 -0
  8. {freva_client-2509.2.0 → freva_client-2510.0.0}/README.md +0 -0
  9. {freva_client-2509.2.0 → freva_client-2510.0.0}/assets/share/freva/freva.toml +0 -0
  10. {freva_client-2509.2.0 → freva_client-2510.0.0}/pyproject.toml +0 -0
  11. {freva_client-2509.2.0 → freva_client-2510.0.0}/src/freva_client/__main__.py +0 -0
  12. {freva_client-2509.2.0 → freva_client-2510.0.0}/src/freva_client/cli/__init__.py +0 -0
  13. {freva_client-2509.2.0 → freva_client-2510.0.0}/src/freva_client/cli/auth_cli.py +0 -0
  14. {freva_client-2509.2.0 → freva_client-2510.0.0}/src/freva_client/cli/cli_app.py +0 -0
  15. {freva_client-2509.2.0 → freva_client-2510.0.0}/src/freva_client/cli/cli_parser.py +0 -0
  16. {freva_client-2509.2.0 → freva_client-2510.0.0}/src/freva_client/cli/cli_utils.py +0 -0
  17. {freva_client-2509.2.0 → freva_client-2510.0.0}/src/freva_client/py.typed +0 -0
  18. {freva_client-2509.2.0 → freva_client-2510.0.0}/src/freva_client/utils/__init__.py +0 -0
  19. {freva_client-2509.2.0 → freva_client-2510.0.0}/src/freva_client/utils/databrowser_utils.py +0 -0
  20. {freva_client-2509.2.0 → freva_client-2510.0.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.2.0
3
+ Version: 2510.0.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.2.0"
21
+ __version__ = "2510.0.0"
22
22
  __all__ = ["authenticate", "databrowser", "__version__"]
@@ -11,6 +11,7 @@ import requests
11
11
  from .utils import logger
12
12
  from .utils.auth_utils import (
13
13
  AuthError,
14
+ CodeAuthClient,
14
15
  DeviceAuthClient,
15
16
  Token,
16
17
  choose_token_strategy,
@@ -72,17 +73,27 @@ class Auth:
72
73
  ) -> Token:
73
74
  device_endpoint = f"{auth_url}/device"
74
75
  token_endpoint = f"{auth_url}/token"
75
- client = DeviceAuthClient(
76
+ device_client = DeviceAuthClient(
76
77
  device_endpoint=device_endpoint,
77
78
  token_endpoint=token_endpoint,
78
79
  timeout=_timeout,
79
80
  )
81
+ code_client = CodeAuthClient(
82
+ login_endpoint=f"{auth_url}/login",
83
+ token_endpoint=f"{auth_url}/token",
84
+ port_endpoint=f"{auth_url}/auth-ports",
85
+ )
86
+
80
87
  is_interactive_auth = int(
81
88
  os.getenv("BROWSER_SESSION", str(int(is_interactive_auth_possible())))
82
89
  )
83
- response = client.login(
84
- token_normalizer=self.get_token, auto_open=bool(is_interactive_auth)
85
- )
90
+ try:
91
+ response = device_client.login(auto_open=bool(is_interactive_auth))
92
+ except AuthError as error:
93
+ if error.status_code == 503:
94
+ response = code_client.login()
95
+ else:
96
+ raise
86
97
  return self.set_token(
87
98
  access_token=response["access_token"],
88
99
  token_type=response["token_type"],
@@ -33,6 +33,7 @@ class UniqKeys(str, Enum):
33
33
 
34
34
  class FlavourAction(str, Enum):
35
35
  add = "add"
36
+ update = "update"
36
37
  delete = "delete"
37
38
  list = "list"
38
39
 
@@ -1056,6 +1057,73 @@ def flavour_add(
1056
1057
  )
1057
1058
 
1058
1059
 
1060
+ @flavour_app.command("update", help="Update an existing custom flavour.")
1061
+ @exception_handler
1062
+ def flavour_update(
1063
+ name: str = typer.Argument(..., help="Name of the flavour to update"),
1064
+ mapping: List[str] = typer.Option(
1065
+ [],
1066
+ "--map",
1067
+ "-m",
1068
+ help="Key-value mappings to update in the format key=value",
1069
+ ),
1070
+ new_name: Optional[str] = typer.Option(
1071
+ None,
1072
+ "--new-name",
1073
+ help="New name for the flavour (optional)",
1074
+ ),
1075
+ global_: bool = typer.Option(
1076
+ False,
1077
+ "--global",
1078
+ help="Update global flavour (requires admin privileges)",
1079
+ ),
1080
+ host: Optional[str] = typer.Option(
1081
+ None,
1082
+ "--host",
1083
+ help=(
1084
+ "Set the hostname of the databrowser. If not set (default), "
1085
+ "the hostname is read from a config file."
1086
+ ),
1087
+ ),
1088
+ token_file: Optional[Path] = typer.Option(
1089
+ None,
1090
+ "--token-file",
1091
+ "-tf",
1092
+ help=(
1093
+ "Instead of authenticating via code based authentication flow "
1094
+ "you can set the path to the json file that contains a "
1095
+ "`refresh token` containing a refresh_token key."
1096
+ ),
1097
+ ),
1098
+ verbose: int = typer.Option(0, "-v", help="Increase verbosity", count=True),
1099
+ ) -> None:
1100
+ """Update an existing custom flavour in the databrowser.
1101
+ This command allows partial updates to flavour mappings. Only the keys
1102
+ provided will be updated; other keys remain unchanged."""
1103
+ logger.set_verbosity(verbose)
1104
+ logger.debug(f"Updating flavour '{name}' with mapping {mapping}")
1105
+ Auth(token_file).authenticate(host=host, _cli=True)
1106
+
1107
+ mapping_dict = {}
1108
+ for map_item in mapping:
1109
+ if "=" not in map_item:
1110
+ logger.error(
1111
+ f"Invalid mapping format: {map_item}. Expected format: key=value."
1112
+ )
1113
+ raise typer.Exit(code=1)
1114
+ key, value = map_item.split("=", 1)
1115
+ mapping_dict[key] = value
1116
+
1117
+ databrowser.flavour(
1118
+ action="update",
1119
+ name=name,
1120
+ mapping=mapping_dict if mapping_dict else None,
1121
+ new_name=new_name,
1122
+ is_global=global_,
1123
+ host=host,
1124
+ )
1125
+
1126
+
1059
1127
  @flavour_app.command("delete", help="Delete an existing custom flavour.")
1060
1128
  @exception_handler
1061
1129
  def flavour_delete(
@@ -721,7 +721,9 @@ class databrowser:
721
721
  for k, v in self._facet_search(extended_search=True).items()
722
722
  ], columns=["facet", "values"])
723
723
  .explode("values")
724
- .groupby("facet")["values"].apply(list)
724
+ .groupby("facet")["values"].apply(
725
+ lambda x: [v for v in x if pd.notna(v)]
726
+ )
725
727
  )
726
728
 
727
729
  @classmethod
@@ -907,7 +909,9 @@ class databrowser:
907
909
  ], columns=["facet", "values"]
908
910
  )
909
911
  .explode("values")
910
- .groupby("facet")["values"].apply(list)
912
+ .groupby("facet")["values"].apply(
913
+ lambda x: [v for v in x if pd.notna(v)]
914
+ )
911
915
  )
912
916
 
913
917
  @classmethod
@@ -1104,8 +1108,9 @@ class databrowser:
1104
1108
  @classmethod
1105
1109
  def flavour(
1106
1110
  cls,
1107
- action: Literal["add", "delete", "list"],
1111
+ action: Literal["add", "update", "delete", "list"],
1108
1112
  name: Optional[str] = None,
1113
+ new_name: Optional[str] = None,
1109
1114
  mapping: Optional[Dict[str, str]] = None,
1110
1115
  is_global: bool = False,
1111
1116
  host: Optional[str] = None,
@@ -1168,6 +1173,18 @@ class databrowser:
1168
1173
  is_global=False
1169
1174
  )
1170
1175
 
1176
+ Updating a custom flavour:
1177
+
1178
+ .. code-block:: python
1179
+
1180
+ from freva_client import databrowser
1181
+ databrowser.flavour(
1182
+ action="update",
1183
+ name="klimakataster",
1184
+ mapping={"experiment": "Experiment"},
1185
+ new_name="klimakataster_v2",
1186
+ is_global=False
1187
+ )
1171
1188
  Listing all custom flavours:
1172
1189
 
1173
1190
  .. code-block:: python
@@ -1210,6 +1227,29 @@ class databrowser:
1210
1227
  msg = result.json().get("status", "Flavour added successfully")
1211
1228
  pprint(f"[b][green] {msg} [/green][/b]")
1212
1229
 
1230
+ elif action == "update":
1231
+ token = this._auth.authenticate(config=this._cfg)
1232
+ headers = {"Authorization": f"Bearer {token['access_token']}"}
1233
+ if not name:
1234
+ raise ValueError("'name' is required for update action")
1235
+
1236
+ payload_update: Dict[str, Any] = {
1237
+ "is_global": is_global,
1238
+ "mapping": mapping or {},
1239
+ }
1240
+ if new_name:
1241
+ payload_update["flavour_name"] = new_name
1242
+
1243
+ result = this._request(
1244
+ "PUT",
1245
+ f"{this._cfg.databrowser_url}/flavours/{name}",
1246
+ data=payload_update,
1247
+ headers=headers,
1248
+ )
1249
+ if result is not None:
1250
+ msg = result.json().get("status", "Flavour updated successfully")
1251
+ pprint(f"[b][green] {msg} [/green][/b]")
1252
+
1213
1253
  elif action == "delete":
1214
1254
  token = this._auth.authenticate(config=this._cfg)
1215
1255
  headers = {"Authorization": f"Bearer {token['access_token']}"}
@@ -3,17 +3,22 @@
3
3
  import json
4
4
  import os
5
5
  import random
6
+ import socket
6
7
  import sys
7
8
  import time
9
+ import urllib.parse
8
10
  import webbrowser
9
11
  from contextlib import contextmanager
10
12
  from dataclasses import dataclass, field
13
+ from getpass import getuser
14
+ from http.server import BaseHTTPRequestHandler, HTTPServer
11
15
  from pathlib import Path
16
+ from threading import Event, Thread
12
17
  from typing import (
13
18
  Any,
14
- Callable,
15
19
  Dict,
16
20
  Iterator,
21
+ List,
17
22
  Literal,
18
23
  Optional,
19
24
  TypedDict,
@@ -33,6 +38,7 @@ TOKEN_EXPIRY_BUFFER = 60 # seconds
33
38
  TOKEN_ENV_VAR = "FREVA_TOKEN_FILE"
34
39
 
35
40
 
41
+ REDIRECT_URI = "http://localhost:{port}/callback"
36
42
  BROWSER_MESSAGE = """Will attempt to open the auth url in your browser.
37
43
 
38
44
  If this doesn't work, try opening the following url:
@@ -80,11 +86,15 @@ class AuthError(Exception):
80
86
  """Athentication error."""
81
87
 
82
88
  def __init__(
83
- self, message: str, detail: Optional[Dict[str, str]] = None
89
+ self,
90
+ message: str,
91
+ detail: Optional[Dict[str, str]] = None,
92
+ status_code: int = 500,
84
93
  ) -> None:
85
94
  super().__init__(message)
86
95
  self.message = message
87
96
  self.detail = detail or {}
97
+ self.status_code = status_code
88
98
 
89
99
  def __str__(self) -> str:
90
100
  return f"{self.message}: {self.detail}" if self.detail else self.message
@@ -101,10 +111,165 @@ class Token(TypedDict, total=False):
101
111
  scope: str
102
112
 
103
113
 
114
+ class OAuthCallbackHandler(BaseHTTPRequestHandler):
115
+ def log_message(self, format: str, *args: object) -> None:
116
+ logger.debug(format, *args)
117
+
118
+ def do_GET(self) -> None:
119
+ query = urllib.parse.urlparse(self.path).query
120
+ params = urllib.parse.parse_qs(query)
121
+ if "code" in params:
122
+ setattr(self.server, "auth_code", params["code"][0])
123
+ self.send_response(200)
124
+ self.end_headers()
125
+ self.wfile.write(b"Login successful! You can close this tab.")
126
+ else:
127
+ self.send_response(400)
128
+ self.end_headers()
129
+ self.wfile.write(b"Authorization code not found.")
130
+
131
+
104
132
  @dataclass
105
- class DeviceAuthClient:
133
+ class CodeAuthClient:
134
+ """Minimal OIDC Code Authorization Grand client.
135
+
136
+ Parameters
137
+ ----------
138
+ device_endpoint : str
139
+ Device endpoint URL, e.g. ``{issuer}/protocol/openid-connect/auth/device``.
140
+ token_endpoint : str
141
+ Token endpoint URL, e.g. ``{issuer}/protocol/openid-connect/token``.
142
+ scope : str, optional
143
+ Space-separated scopes; include ``offline_access`` if you need offline RTs.
144
+ timeout : int | None, optional
145
+ Overall timeout (seconds) for user approval. ``None`` waits indefinitely.
146
+ session : requests.Session | None, optional
147
+ Reusable HTTP session. A new one is created if omitted.
148
+
106
149
  """
107
- Minimal OIDC Device Authorization Grant client.
150
+
151
+ login_endpoint: str
152
+ token_endpoint: str
153
+ port_endpoint: str
154
+ timeout: Optional[int] = 600
155
+ session: requests.Session = field(default_factory=requests.Session)
156
+
157
+ @staticmethod
158
+ def _start_local_server(port: int, event: Event) -> HTTPServer:
159
+ """Start local HTTP server to wait for a single callback."""
160
+ server = HTTPServer(("localhost", port), OAuthCallbackHandler)
161
+
162
+ def handle() -> None:
163
+ logger.info("Waiting for browser callback on port %s ...", port)
164
+ while not event.is_set():
165
+ server.handle_request()
166
+ if getattr(server, "auth_code", None):
167
+ event.set()
168
+
169
+ thread = Thread(target=handle, daemon=True)
170
+ thread.start()
171
+ return server
172
+
173
+ def _find_free_port(self) -> int:
174
+ """Get a free port where we can start the test server."""
175
+ ports: List[int] = (
176
+ requests.get(self.port_endpoint).json().get("valid_ports", [])
177
+ )
178
+ for port in ports:
179
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
180
+ try:
181
+ s.bind(("localhost", port))
182
+ return port
183
+ except (OSError, PermissionError):
184
+ pass
185
+ raise OSError("No free ports available for login flow")
186
+
187
+ @staticmethod
188
+ def _wait_for_port(host: str, port: int, timeout: float = 5.0) -> None:
189
+ """Wait until a TCP port starts accepting connections."""
190
+ deadline = time.time() + timeout
191
+ while time.time() < deadline:
192
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
193
+ sock.settimeout(0.5)
194
+ try:
195
+ if sock.connect_ex((host, port)) == 0:
196
+ return
197
+ except OSError:
198
+ pass
199
+ time.sleep(0.05)
200
+ raise TimeoutError(
201
+ f"Port {port} on {host} did not open within {timeout} seconds."
202
+ )
203
+
204
+ def login(
205
+ self,
206
+ ) -> Token:
207
+ """Start the login flow."""
208
+ port = self._find_free_port()
209
+ redirect_uri = REDIRECT_URI.format(port=port)
210
+ params = {
211
+ "redirect_uri": redirect_uri,
212
+ "offline_access": "true",
213
+ "prompt": "consent",
214
+ }
215
+ query = urllib.parse.urlencode(params)
216
+ login_url = f"{self.login_endpoint}?{query}"
217
+ logger.info("Opening browser for login:\n%s", login_url)
218
+ logger.info(
219
+ "If you are using this on a remote host, you might need to "
220
+ "increase the login timeout and forward port %d:\n"
221
+ " ssh -L %d:localhost:%d %s@%s",
222
+ port,
223
+ port,
224
+ port,
225
+ getuser(),
226
+ socket.gethostname(),
227
+ )
228
+ event = Event()
229
+ server = self._start_local_server(port, event)
230
+ code: Optional[str] = None
231
+ reason = "Login failed."
232
+ try:
233
+ self._wait_for_port("localhost", port)
234
+ webbrowser.open(login_url)
235
+ success = event.wait(timeout=self.timeout or None)
236
+ if not success:
237
+ raise TimeoutError(
238
+ f"Login did not complete within {self.timeout} seconds. "
239
+ "Possibly headless environment."
240
+ )
241
+ code = getattr(server, "auth_code", None)
242
+ except Exception as error:
243
+ logger.warning(
244
+ "Could not open browser automatically. %s"
245
+ "Please open the URL manually.",
246
+ error,
247
+ )
248
+ reason = str(error)
249
+
250
+ finally:
251
+ logger.debug("Cleaning up login state")
252
+ if hasattr(server, "server_close"):
253
+ try:
254
+ server.server_close()
255
+ except Exception as error:
256
+ logger.debug("Failed to close server cleanly: %s", error)
257
+ if not code:
258
+ raise AuthError(reason)
259
+ data = {
260
+ "code": code,
261
+ "redirect_uri": redirect_uri,
262
+ "grant_type": "authorization_code",
263
+ }
264
+
265
+ response = requests.post(self.token_endpoint, data=data)
266
+ response.raise_for_status()
267
+ return cast(Token, response.json())
268
+
269
+
270
+ @dataclass
271
+ class DeviceAuthClient:
272
+ """Minimal OIDC Device Authorization Grant client.
108
273
 
109
274
  Parameters
110
275
  ----------
@@ -137,7 +302,6 @@ class DeviceAuthClient:
137
302
  self,
138
303
  *,
139
304
  auto_open: bool = True,
140
- token_normalizer: Optional[Callable[[str, Dict[str, str]], Token]] = None,
141
305
  ) -> Token:
142
306
  """
143
307
  Run the full device flow and return an OIDC token payload.
@@ -146,10 +310,6 @@ class DeviceAuthClient:
146
310
  ----------
147
311
  auto_open : bool, optional
148
312
  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
313
 
154
314
  Returns
155
315
  -------
@@ -276,7 +436,11 @@ class DeviceAuthClient:
276
436
  "error": "http_error",
277
437
  "error_description": resp.text[:300],
278
438
  }
279
- raise AuthError(f"{url} -> {resp.status_code}", detail=payload)
439
+ raise AuthError(
440
+ f"{url} -> {resp.status_code}",
441
+ detail=payload,
442
+ status_code=resp.status_code,
443
+ )
280
444
  try:
281
445
  return cast(Dict[str, Any], resp.json())
282
446
  except Exception as error: