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 +1 -1
- freva_client/auth.py +234 -102
- freva_client/cli/auth_cli.py +13 -19
- freva_client/cli/cli_parser.py +2 -0
- freva_client/cli/databrowser_cli.py +304 -82
- freva_client/query.py +201 -47
- freva_client/utils/auth_utils.py +240 -0
- freva_client/utils/databrowser_utils.py +8 -3
- {freva_client-2502.0.0.dist-info → freva_client-2506.0.0.dist-info}/METADATA +1 -2
- freva_client-2506.0.0.dist-info/RECORD +20 -0
- {freva_client-2502.0.0.dist-info → freva_client-2506.0.0.dist-info}/WHEEL +1 -1
- freva_client-2502.0.0.dist-info/RECORD +0 -19
- {freva_client-2502.0.0.data → freva_client-2506.0.0.data}/data/share/freva/freva.toml +0 -0
- {freva_client-2502.0.0.dist-info → freva_client-2506.0.0.dist-info}/entry_points.txt +0 -0
freva_client/__init__.py
CHANGED
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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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,
|
|
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.
|
|
79
|
-
|
|
80
|
-
|
|
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",
|
|
88
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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,
|
|
331
|
+
host=host,
|
|
332
|
+
force=force,
|
|
333
|
+
_auto=False,
|
|
202
334
|
)
|
freva_client/cli/auth_cli.py
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
"""Command line interface for authentication."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
|
|
4
|
+
import os
|
|
5
5
|
from typing import Optional
|
|
6
6
|
|
|
7
7
|
import typer
|
|
8
8
|
|
|
9
|
-
from freva_client import
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
"--
|
|
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
|
|
43
|
-
"
|
|
44
|
-
"
|
|
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
|
-
|
|
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(
|
|
64
|
+
print(json.dumps(token, indent=3))
|