freva-client 2502.0.0__py3-none-any.whl → 2506.0.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
@@ -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__ = "2502.0.0"
20
+ __version__ = "2506.0.0"
21
21
  __all__ = ["authenticate", "databrowser", "__version__"]
freva_client/auth.py CHANGED
@@ -1,25 +1,77 @@
1
1
  """Module that handles the authentication at the rest service."""
2
2
 
3
3
  import datetime
4
- from getpass import getpass, getuser
5
- from typing import Optional, TypedDict, Union
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
6
12
 
7
- from authlib.integrations.requests_client import OAuth2Session
13
+ import requests
8
14
 
9
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
+ )
10
23
  from .utils.databrowser_utils import Config
11
24
 
12
- Token = TypedDict(
13
- "Token",
14
- {
15
- "access_token": str,
16
- "token_type": str,
17
- "expires": int,
18
- "refresh_token": str,
19
- "refresh_expires": int,
20
- "scope": str,
21
- },
22
- )
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
23
75
 
24
76
 
25
77
  class Auth:
@@ -27,23 +79,116 @@ class Auth:
27
79
 
28
80
  _instance: Optional["Auth"] = None
29
81
  _auth_token: Optional[Token] = None
82
+ _thread_lock: Lock = Lock()
30
83
 
31
- def __new__(cls) -> "Auth":
84
+ def __new__(cls, *args: Any, **kwargs: Any) -> "Auth":
32
85
  if cls._instance is None:
33
86
  cls._instance = super().__new__(cls)
34
87
  return cls._instance
35
88
 
36
- def __init__(self) -> None:
37
- self._auth_cls = OAuth2Session()
89
+ def __init__(self, token_file: Optional[Union[str, Path]] = None) -> None:
90
+ self.token_file = str(token_file or "").strip() or None
38
91
 
39
- @property
40
- def token_expiration_time(self) -> datetime.datetime:
41
- """Get the expiration time of an access token."""
42
- if self._auth_token is None:
43
- exp = 0.0
44
- else:
45
- exp = self._auth_token["expires"]
46
- return datetime.datetime.fromtimestamp(exp, datetime.timezone.utc)
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")
47
192
 
48
193
  def set_token(
49
194
  self,
@@ -67,94 +212,82 @@ class Auth:
67
212
  refresh_expires=int(refresh_expires or now + refresh_expires_in),
68
213
  scope=scope,
69
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
+
70
223
  return self._auth_token
71
224
 
72
225
  def _refresh(
73
- self, url: str, refresh_token: str, username: Optional[str] = None
226
+ self,
227
+ url: str,
228
+ refresh_token: str,
74
229
  ) -> Token:
75
230
  """Refresh the access_token with a refresh token."""
76
- auth = self._auth_cls.refresh_token(f"{url}/token", refresh_token or " ")
77
231
  try:
78
- return self.set_token(
79
- access_token=auth["access_token"],
80
- token_type=auth["token_type"],
81
- expires=auth["expires"],
82
- refresh_token=auth["refresh_token"],
83
- refresh_expires=auth["refresh_expires"],
84
- scope=auth["scope"],
232
+ return self.get_token(
233
+ f"{url}/token",
234
+ data={"refresh-token": refresh_token or ""},
85
235
  )
86
- except KeyError:
87
- logger.warning("Failed to refresh token: %s", auth.get("detail", ""))
88
- if username:
89
- return self._login_with_password(url, username)
90
- raise ValueError("Could not use refresh token") from None
91
-
92
- def check_authentication(self, auth_url: Optional[str] = None) -> Token:
93
- """Check the status of the authentication.
94
-
95
- Raises
96
- ------
97
- ValueError: If user isn't or is no longer authenticated.
98
- """
99
- if not self._auth_token:
100
- raise ValueError("You must authenticate first.")
101
- now = datetime.datetime.now(datetime.timezone.utc).timestamp()
102
- if now > self._auth_token["refresh_expires"]:
103
- raise ValueError("Refresh token has expired.")
104
- if now > self._auth_token["expires"] and auth_url:
105
- self._refresh(auth_url, self._auth_token["refresh_token"])
106
- return self._auth_token
107
-
108
- def _login_with_password(self, auth_url: str, username: str) -> Token:
109
- """Create a new token."""
110
- pw_msg = "Give password for server authentication: "
111
- auth = self._auth_cls.fetch_token(
112
- f"{auth_url}/token", username=username, password=getpass(pw_msg)
113
- )
114
- try:
115
- return self.set_token(
116
- access_token=auth["access_token"],
117
- token_type=auth["token_type"],
118
- expires=auth["expires"],
119
- refresh_token=auth["refresh_token"],
120
- refresh_expires=auth["refresh_expires"],
121
- scope=auth["scope"],
122
- )
123
- except KeyError:
124
- logger.error("Failed to authenticate: %s", auth.get("detail", ""))
125
- raise ValueError("Token creation failed") from None
236
+ except (AuthError, KeyError) as error:
237
+ logger.warning("Failed to refresh token: %s", error)
238
+ return self._login(url)
126
239
 
127
240
  def authenticate(
128
241
  self,
129
242
  host: Optional[str] = None,
130
- refresh_token: Optional[str] = None,
131
- username: Optional[str] = None,
243
+ config: Optional[Config] = None,
244
+ *,
132
245
  force: bool = False,
246
+ _auto: bool = True,
247
+ _cli: bool = False,
133
248
  ) -> Token:
134
249
  """Authenticate the user to the host."""
135
- cfg = Config(host)
136
- if refresh_token:
137
- try:
138
- return self._refresh(cfg.auth_url, refresh_token)
139
- except ValueError:
140
- logger.warning(
141
- (
142
- "Could not use refresh token, falling back "
143
- "to username/password"
144
- )
145
- )
146
- username = username or getuser()
147
- if self._auth_token is None or force:
148
- return self._login_with_password(cfg.auth_url, username)
149
- if self.token_expiration_time < datetime.datetime.now(datetime.timezone.utc):
150
- self._refresh(cfg.auth_url, self._auth_token["refresh_token"], username)
151
- return self._auth_token
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)
152
286
 
153
287
 
154
288
  def authenticate(
155
289
  *,
156
- refresh_token: Optional[str] = None,
157
- username: Optional[str] = None,
290
+ token_file: Optional[Union[Path, str]] = None,
158
291
  host: Optional[str] = None,
159
292
  force: bool = False,
160
293
  ) -> Token:
@@ -167,9 +300,6 @@ def authenticate(
167
300
  refresh_token: str, optional
168
301
  Instead of setting a password, you can set a refresh token to refresh
169
302
  the access token. This is recommended for non-interactive environments.
170
- username: str, optional
171
- The username used for authentication. By default, the current
172
- system username is used.
173
303
  host: str, optional
174
304
  The hostname of the REST server.
175
305
  force: bool, default: False
@@ -186,7 +316,7 @@ def authenticate(
186
316
  .. code-block:: python
187
317
 
188
318
  from freva_client import authenticate
189
- token = authenticate(username="janedoe")
319
+ token = authenticate()
190
320
  print(token)
191
321
 
192
322
  Batch mode authentication with a refresh token:
@@ -194,9 +324,11 @@ def authenticate(
194
324
  .. code-block:: python
195
325
 
196
326
  from freva_client import authenticate
197
- token = authenticate(refresh_token="MYTOKEN")
327
+ token = authenticate(token_file="~/.freva-login-token.json")
198
328
  """
199
- auth = Auth()
329
+ auth = Auth(token_file=token_file or None)
200
330
  return auth.authenticate(
201
- host=host, username=username, refresh_token=refresh_token, force=force
331
+ host=host,
332
+ force=force,
333
+ _auto=False,
202
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))
@@ -69,6 +69,8 @@ class Completer:
69
69
  time=None,
70
70
  host=None,
71
71
  time_select="flexible",
72
+ bbox=None,
73
+ bbox_select="flexible",
72
74
  multiversion=False,
73
75
  extended_search=True,
74
76
  fail_on_error=False,