freva-client 2505.0.0__tar.gz → 2506.0.1__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-2505.0.0 → freva_client-2506.0.1}/PKG-INFO +1 -2
  2. {freva_client-2505.0.0 → freva_client-2506.0.1}/pyproject.toml +0 -1
  3. {freva_client-2505.0.0 → freva_client-2506.0.1}/src/freva_client/__init__.py +1 -1
  4. freva_client-2506.0.1/src/freva_client/auth.py +334 -0
  5. {freva_client-2505.0.0 → freva_client-2506.0.1}/src/freva_client/cli/auth_cli.py +13 -19
  6. {freva_client-2505.0.0 → freva_client-2506.0.1}/src/freva_client/cli/databrowser_cli.py +60 -80
  7. {freva_client-2505.0.0 → freva_client-2506.0.1}/src/freva_client/query.py +38 -42
  8. freva_client-2506.0.1/src/freva_client/utils/auth_utils.py +240 -0
  9. {freva_client-2505.0.0 → freva_client-2506.0.1}/src/freva_client/utils/databrowser_utils.py +3 -3
  10. freva_client-2505.0.0/src/freva_client/auth.py +0 -202
  11. {freva_client-2505.0.0 → freva_client-2506.0.1}/MANIFEST.in +0 -0
  12. {freva_client-2505.0.0 → freva_client-2506.0.1}/README.md +0 -0
  13. {freva_client-2505.0.0 → freva_client-2506.0.1}/assets/share/freva/freva.toml +0 -0
  14. {freva_client-2505.0.0 → freva_client-2506.0.1}/src/freva_client/__main__.py +0 -0
  15. {freva_client-2505.0.0 → freva_client-2506.0.1}/src/freva_client/cli/__init__.py +0 -0
  16. {freva_client-2505.0.0 → freva_client-2506.0.1}/src/freva_client/cli/cli_app.py +0 -0
  17. {freva_client-2505.0.0 → freva_client-2506.0.1}/src/freva_client/cli/cli_parser.py +0 -0
  18. {freva_client-2505.0.0 → freva_client-2506.0.1}/src/freva_client/cli/cli_utils.py +0 -0
  19. {freva_client-2505.0.0 → freva_client-2506.0.1}/src/freva_client/py.typed +0 -0
  20. {freva_client-2505.0.0 → freva_client-2506.0.1}/src/freva_client/utils/__init__.py +0 -0
  21. {freva_client-2505.0.0 → freva_client-2506.0.1}/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: 2505.0.0
3
+ Version: 2506.0.1
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
@@ -19,7 +19,6 @@ Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: 3.13
20
20
  Requires-Dist: appdirs
21
21
  Requires-Dist: pyyaml
22
- Requires-Dist: authlib
23
22
  Requires-Dist: requests
24
23
  Requires-Dist: intake_esm
25
24
  Requires-Dist: rich
@@ -25,7 +25,6 @@ requires-python = ">=3.8"
25
25
  dependencies = [
26
26
  "appdirs",
27
27
  "pyyaml",
28
- "authlib",
29
28
  "requests",
30
29
  "intake_esm",
31
30
  "rich",
@@ -17,5 +17,5 @@ need to apply data analysis plugins, please visit the
17
17
  from .auth import authenticate
18
18
  from .query import databrowser
19
19
 
20
- __version__ = "2505.0.0"
20
+ __version__ = "2506.0.1"
21
21
  __all__ = ["authenticate", "databrowser", "__version__"]
@@ -0,0 +1,334 @@
1
+ """Module that handles the authentication at the rest service."""
2
+
3
+ import datetime
4
+ import json
5
+ import socket
6
+ import urllib.parse
7
+ import webbrowser
8
+ from http.server import BaseHTTPRequestHandler, HTTPServer
9
+ from pathlib import Path
10
+ from threading import Event, Lock, Thread
11
+ from typing import Any, Dict, List, Optional, Union
12
+
13
+ import requests
14
+
15
+ from .utils import logger
16
+ from .utils.auth_utils import (
17
+ Token,
18
+ choose_token_strategy,
19
+ get_default_token_file,
20
+ load_token,
21
+ wait_for_port,
22
+ )
23
+ from .utils.databrowser_utils import Config
24
+
25
+ REDIRECT_URI = "http://localhost:{port}/callback"
26
+ AUTH_FAILED_MSG = """Login failed or no valid token found in this environment.
27
+ If you appear to be in a non-interactive or remote session create a new token
28
+ via:
29
+
30
+ {command}
31
+
32
+ Then pass the token file using:"
33
+
34
+ " {token_path} or set the $TOKEN_ENV_VAR env. variable."""
35
+
36
+
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
+ class Auth:
78
+ """Helper class for authentication."""
79
+
80
+ _instance: Optional["Auth"] = None
81
+ _auth_token: Optional[Token] = None
82
+ _thread_lock: Lock = Lock()
83
+
84
+ def __new__(cls, *args: Any, **kwargs: Any) -> "Auth":
85
+ if cls._instance is None:
86
+ cls._instance = super().__new__(cls)
87
+ return cls._instance
88
+
89
+ def __init__(self, token_file: Optional[Union[str, Path]] = None) -> None:
90
+ self.token_file = str(token_file or "").strip() or None
91
+
92
+ def get_token(
93
+ self,
94
+ token_url: str,
95
+ data: Dict[str, str],
96
+ ) -> Token:
97
+ try:
98
+ response = requests.post(token_url, data=data)
99
+ response.raise_for_status()
100
+ except requests.exceptions.RequestException as error:
101
+ raise AuthError(f"Fetching token failed: {error}")
102
+ auth = response.json()
103
+ return self.set_token(
104
+ access_token=auth["access_token"],
105
+ token_type=auth["token_type"],
106
+ expires=auth["expires"],
107
+ refresh_token=auth["refresh_token"],
108
+ refresh_expires=auth["refresh_expires"],
109
+ scope=auth["scope"],
110
+ )
111
+
112
+ def _login(
113
+ self,
114
+ auth_url: str,
115
+ force: bool = False,
116
+ _timeout: int = 30,
117
+ ) -> Token:
118
+ login_endpoint = f"{auth_url}/login"
119
+ 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,
137
+ )
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
+ },
176
+ )
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", [])
183
+ )
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
+
193
+ def set_token(
194
+ self,
195
+ access_token: str,
196
+ refresh_token: Optional[str] = None,
197
+ expires_in: int = 10,
198
+ refresh_expires_in: int = 10,
199
+ expires: Optional[Union[float, int]] = None,
200
+ refresh_expires: Optional[Union[float, int]] = None,
201
+ token_type: str = "Bearer",
202
+ scope: str = "profile email address",
203
+ ) -> Token:
204
+ """Override the existing auth token."""
205
+ now = datetime.datetime.now(datetime.timezone.utc).timestamp()
206
+
207
+ self._auth_token = Token(
208
+ access_token=access_token or "",
209
+ refresh_token=refresh_token or "",
210
+ token_type=token_type,
211
+ expires=int(expires or now + expires_in),
212
+ refresh_expires=int(refresh_expires or now + refresh_expires_in),
213
+ scope=scope,
214
+ )
215
+ default_token_file = get_default_token_file()
216
+ for _file in map(
217
+ Path, set((self.token_file or default_token_file, default_token_file))
218
+ ):
219
+ _file.parent.mkdir(exist_ok=True, parents=True)
220
+ _file.write_text(json.dumps(self._auth_token))
221
+ _file.chmod(0o600)
222
+
223
+ return self._auth_token
224
+
225
+ def _refresh(
226
+ self,
227
+ url: str,
228
+ refresh_token: str,
229
+ ) -> Token:
230
+ """Refresh the access_token with a refresh token."""
231
+ try:
232
+ return self.get_token(
233
+ f"{url}/token",
234
+ data={"refresh-token": refresh_token or ""},
235
+ )
236
+ except (AuthError, KeyError) as error:
237
+ logger.warning("Failed to refresh token: %s", error)
238
+ return self._login(url)
239
+
240
+ def authenticate(
241
+ self,
242
+ host: Optional[str] = None,
243
+ config: Optional[Config] = None,
244
+ *,
245
+ force: bool = False,
246
+ _auto: bool = True,
247
+ _cli: bool = False,
248
+ ) -> Token:
249
+ """Authenticate the user to the host."""
250
+ cfg = config or Config(host)
251
+ token = self._auth_token or load_token(self.token_file)
252
+ reason: Optional[str] = None
253
+ if _auto:
254
+ strategy = choose_token_strategy(token)
255
+ else:
256
+ strategy = "browser_auth"
257
+ if strategy == "use_token" and token:
258
+ self._auth_token = token
259
+ return self._auth_token
260
+ try:
261
+ if strategy == "refresh_token" and token:
262
+ return self._refresh(cfg.auth_url, token["refresh_token"])
263
+ if strategy == "browser_auth":
264
+ if _auto:
265
+ logger.warning("Automatically launching browser-based login")
266
+ return self._login(cfg.auth_url, force=force)
267
+ except AuthError as error:
268
+ reason = str(error)
269
+
270
+ command, token_path = {
271
+ True: ("freva-client auth", "--token-file /path/to/token.json"),
272
+ False: (
273
+ "freva_client.auth",
274
+ "`token_file='/path/to/token.json'`",
275
+ ),
276
+ }[_cli]
277
+
278
+ reason = reason or AUTH_FAILED_MSG.format(
279
+ command=command, token_path=token_path
280
+ )
281
+ if _cli:
282
+ logger.critical(reason)
283
+ raise SystemExit(1)
284
+ else:
285
+ raise AuthError(reason)
286
+
287
+
288
+ def authenticate(
289
+ *,
290
+ token_file: Optional[Union[Path, str]] = None,
291
+ host: Optional[str] = None,
292
+ force: bool = False,
293
+ ) -> Token:
294
+ """Authenticate to the host.
295
+
296
+ This method generates a new access token that should be used for restricted methods.
297
+
298
+ Parameters
299
+ ----------
300
+ refresh_token: str, optional
301
+ Instead of setting a password, you can set a refresh token to refresh
302
+ the access token. This is recommended for non-interactive environments.
303
+ host: str, optional
304
+ The hostname of the REST server.
305
+ force: bool, default: False
306
+ Force token recreation, even if current token is still valid.
307
+
308
+ Returns
309
+ -------
310
+ Token: The authentication token.
311
+
312
+ Examples
313
+ --------
314
+ Interactive authentication:
315
+
316
+ .. code-block:: python
317
+
318
+ from freva_client import authenticate
319
+ token = authenticate()
320
+ print(token)
321
+
322
+ Batch mode authentication with a refresh token:
323
+
324
+ .. code-block:: python
325
+
326
+ from freva_client import authenticate
327
+ token = authenticate(token_file="~/.freva-login-token.json")
328
+ """
329
+ auth = Auth(token_file=token_file or None)
330
+ return auth.authenticate(
331
+ host=host,
332
+ force=force,
333
+ _auto=False,
334
+ )
@@ -1,13 +1,14 @@
1
1
  """Command line interface for authentication."""
2
2
 
3
3
  import json
4
- from getpass import getuser
4
+ import os
5
5
  from typing import Optional
6
6
 
7
7
  import typer
8
8
 
9
- from freva_client import authenticate
9
+ from freva_client.auth import Auth
10
10
  from freva_client.utils import exception_handler, logger
11
+ from freva_client.utils.auth_utils import TOKEN_ENV_VAR, get_default_token_file
11
12
 
12
13
  from .cli_utils import version_callback
13
14
 
@@ -28,20 +29,13 @@ def authenticate_cli(
28
29
  "the hostname is read from a config file"
29
30
  ),
30
31
  ),
31
- username: str = typer.Option(
32
- getuser(),
33
- "--username",
34
- "-u",
35
- help="The username used for authentication.",
36
- ),
37
- refresh_token: Optional[str] = typer.Option(
38
- None,
39
- "--refresh-token",
40
- "-r",
32
+ token_file: str = typer.Option(
33
+ os.getenv(TOKEN_ENV_VAR, "").strip(),
34
+ "--token-file",
41
35
  help=(
42
- "Instead of using a password, you can use a refresh token. "
43
- "refresh the access token. This is recommended for non-interactive"
44
- " environments."
36
+ "Instead of authenticating via code based authentication flow "
37
+ "you can set the path to the json file that contains a "
38
+ "`refresh token` containing a refresh_token key."
45
39
  ),
46
40
  ),
47
41
  force: bool = typer.Option(
@@ -61,10 +55,10 @@ def authenticate_cli(
61
55
  ) -> None:
62
56
  """Create OAuth2 access and refresh token."""
63
57
  logger.set_verbosity(verbose)
64
- token_data = authenticate(
58
+ token = Auth(token_file=token_file or get_default_token_file()).authenticate(
65
59
  host=host,
66
- username=username,
67
- refresh_token=refresh_token,
68
60
  force=force,
61
+ _cli=True,
62
+ _auto=False,
69
63
  )
70
- print(json.dumps(token_data, indent=3))
64
+ print(json.dumps(token, indent=3))