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.
- {freva_client-2508.0.0 → freva_client-2509.1.0}/PKG-INFO +1 -1
- {freva_client-2508.0.0 → freva_client-2509.1.0}/assets/share/freva/freva.toml +5 -0
- {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/__init__.py +2 -1
- {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/auth.py +36 -129
- {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/cli/auth_cli.py +6 -0
- freva_client-2509.1.0/src/freva_client/cli/cli_utils.py +69 -0
- {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/cli/databrowser_cli.py +232 -32
- {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/query.py +255 -38
- freva_client-2509.1.0/src/freva_client/utils/auth_utils.py +516 -0
- {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/utils/databrowser_utils.py +118 -32
- freva_client-2508.0.0/src/freva_client/cli/cli_utils.py +0 -32
- freva_client-2508.0.0/src/freva_client/utils/auth_utils.py +0 -240
- {freva_client-2508.0.0 → freva_client-2509.1.0}/MANIFEST.in +0 -0
- {freva_client-2508.0.0 → freva_client-2509.1.0}/README.md +0 -0
- {freva_client-2508.0.0 → freva_client-2509.1.0}/pyproject.toml +0 -0
- {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/__main__.py +0 -0
- {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/cli/__init__.py +0 -0
- {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/cli/cli_app.py +0 -0
- {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/cli/cli_parser.py +0 -0
- {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/py.typed +0 -0
- {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/utils/__init__.py +0 -0
- {freva_client-2508.0.0 → freva_client-2509.1.0}/src/freva_client/utils/logger.py +0 -0
|
@@ -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__ = "
|
|
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
|
|
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
|
|
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
|
-
|
|
116
|
-
_timeout: int = 30,
|
|
71
|
+
_timeout: Optional[int] = 30,
|
|
117
72
|
) -> Token:
|
|
118
|
-
|
|
73
|
+
device_endpoint = f"{auth_url}/device"
|
|
119
74
|
token_endpoint = f"{auth_url}/token"
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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)
|