freva-client 2509.0.0__tar.gz → 2509.2.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-2509.0.0 → freva_client-2509.2.0}/PKG-INFO +1 -1
- {freva_client-2509.0.0 → freva_client-2509.2.0}/src/freva_client/__init__.py +1 -1
- {freva_client-2509.0.0 → freva_client-2509.2.0}/src/freva_client/auth.py +23 -125
- freva_client-2509.2.0/src/freva_client/utils/auth_utils.py +516 -0
- {freva_client-2509.0.0 → freva_client-2509.2.0}/src/freva_client/utils/databrowser_utils.py +44 -33
- freva_client-2509.0.0/src/freva_client/utils/auth_utils.py +0 -278
- {freva_client-2509.0.0 → freva_client-2509.2.0}/MANIFEST.in +0 -0
- {freva_client-2509.0.0 → freva_client-2509.2.0}/README.md +0 -0
- {freva_client-2509.0.0 → freva_client-2509.2.0}/assets/share/freva/freva.toml +0 -0
- {freva_client-2509.0.0 → freva_client-2509.2.0}/pyproject.toml +0 -0
- {freva_client-2509.0.0 → freva_client-2509.2.0}/src/freva_client/__main__.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.2.0}/src/freva_client/cli/__init__.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.2.0}/src/freva_client/cli/auth_cli.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.2.0}/src/freva_client/cli/cli_app.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.2.0}/src/freva_client/cli/cli_parser.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.2.0}/src/freva_client/cli/cli_utils.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.2.0}/src/freva_client/cli/databrowser_cli.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.2.0}/src/freva_client/py.typed +0 -0
- {freva_client-2509.0.0 → freva_client-2509.2.0}/src/freva_client/query.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.2.0}/src/freva_client/utils/__init__.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.2.0}/src/freva_client/utils/logger.py +0 -0
|
@@ -2,28 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
import datetime
|
|
4
4
|
import json
|
|
5
|
-
import
|
|
6
|
-
import urllib.parse
|
|
7
|
-
import webbrowser
|
|
8
|
-
from getpass import getuser
|
|
9
|
-
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
5
|
+
import os
|
|
10
6
|
from pathlib import Path
|
|
11
|
-
from
|
|
12
|
-
from typing import Any, Dict, List, Optional, Union
|
|
7
|
+
from typing import Any, Dict, Optional, Union
|
|
13
8
|
|
|
14
9
|
import requests
|
|
15
10
|
|
|
16
11
|
from .utils import logger
|
|
17
12
|
from .utils.auth_utils import (
|
|
13
|
+
AuthError,
|
|
14
|
+
DeviceAuthClient,
|
|
18
15
|
Token,
|
|
19
16
|
choose_token_strategy,
|
|
20
17
|
get_default_token_file,
|
|
18
|
+
is_interactive_auth_possible,
|
|
21
19
|
load_token,
|
|
22
|
-
wait_for_port,
|
|
23
20
|
)
|
|
24
21
|
from .utils.databrowser_utils import Config
|
|
25
22
|
|
|
26
|
-
REDIRECT_URI = "http://localhost:{port}/callback"
|
|
27
23
|
AUTH_FAILED_MSG = """Login failed or no valid token found in this environment.
|
|
28
24
|
If you appear to be in a non-interactive or remote session create a new token
|
|
29
25
|
via:
|
|
@@ -35,52 +31,11 @@ Then pass the token file using:"
|
|
|
35
31
|
" {token_path} or set the $TOKEN_ENV_VAR env. variable."""
|
|
36
32
|
|
|
37
33
|
|
|
38
|
-
class AuthError(Exception):
|
|
39
|
-
"""Athentication error."""
|
|
40
|
-
|
|
41
|
-
pass
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
45
|
-
def log_message(self, format: str, *args: object) -> None:
|
|
46
|
-
logger.debug(format, *args)
|
|
47
|
-
|
|
48
|
-
def do_GET(self) -> None:
|
|
49
|
-
query = urllib.parse.urlparse(self.path).query
|
|
50
|
-
params = urllib.parse.parse_qs(query)
|
|
51
|
-
if "code" in params:
|
|
52
|
-
setattr(self.server, "auth_code", params["code"][0])
|
|
53
|
-
self.send_response(200)
|
|
54
|
-
self.end_headers()
|
|
55
|
-
self.wfile.write(b"Login successful! You can close this tab.")
|
|
56
|
-
else:
|
|
57
|
-
self.send_response(400)
|
|
58
|
-
self.end_headers()
|
|
59
|
-
self.wfile.write(b"Authorization code not found.")
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def start_local_server(port: int, event: Event) -> HTTPServer:
|
|
63
|
-
"""Start local HTTP server to wait for a single callback."""
|
|
64
|
-
server = HTTPServer(("localhost", port), OAuthCallbackHandler)
|
|
65
|
-
|
|
66
|
-
def handle() -> None:
|
|
67
|
-
logger.info("Waiting for browser callback on port %s ...", port)
|
|
68
|
-
while not event.is_set():
|
|
69
|
-
server.handle_request()
|
|
70
|
-
if getattr(server, "auth_code", None):
|
|
71
|
-
event.set()
|
|
72
|
-
|
|
73
|
-
thread = Thread(target=handle, daemon=True)
|
|
74
|
-
thread.start()
|
|
75
|
-
return server
|
|
76
|
-
|
|
77
|
-
|
|
78
34
|
class Auth:
|
|
79
35
|
"""Helper class for authentication."""
|
|
80
36
|
|
|
81
37
|
_instance: Optional["Auth"] = None
|
|
82
38
|
_auth_token: Optional[Token] = None
|
|
83
|
-
_thread_lock: Lock = Lock()
|
|
84
39
|
|
|
85
40
|
def __new__(cls, *args: Any, **kwargs: Any) -> "Auth":
|
|
86
41
|
if cls._instance is None:
|
|
@@ -113,86 +68,29 @@ class Auth:
|
|
|
113
68
|
def _login(
|
|
114
69
|
self,
|
|
115
70
|
auth_url: str,
|
|
116
|
-
force: bool = False,
|
|
117
71
|
_timeout: Optional[int] = 30,
|
|
118
72
|
) -> Token:
|
|
119
|
-
|
|
73
|
+
device_endpoint = f"{auth_url}/device"
|
|
120
74
|
token_endpoint = f"{auth_url}/token"
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
"offline_access": "true",
|
|
126
|
-
}
|
|
127
|
-
if force:
|
|
128
|
-
params["prompt"] = "login"
|
|
129
|
-
query = urllib.parse.urlencode(params)
|
|
130
|
-
login_url = f"{login_endpoint}?{query}"
|
|
131
|
-
logger.info("Opening browser for login:\n%s", login_url)
|
|
132
|
-
logger.info(
|
|
133
|
-
"If you are using this on a remote host, you might need to "
|
|
134
|
-
"increase the login timeout and forward port %d:\n"
|
|
135
|
-
" ssh -L %d:localhost:%d %s@%s",
|
|
136
|
-
port,
|
|
137
|
-
port,
|
|
138
|
-
port,
|
|
139
|
-
getuser(),
|
|
140
|
-
socket.gethostname(),
|
|
75
|
+
client = DeviceAuthClient(
|
|
76
|
+
device_endpoint=device_endpoint,
|
|
77
|
+
token_endpoint=token_endpoint,
|
|
78
|
+
timeout=_timeout,
|
|
141
79
|
)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
code: Optional[str] = None
|
|
145
|
-
reason = "Login failed."
|
|
146
|
-
try:
|
|
147
|
-
wait_for_port("localhost", port)
|
|
148
|
-
webbrowser.open(login_url)
|
|
149
|
-
success = event.wait(timeout=_timeout or None)
|
|
150
|
-
if not success:
|
|
151
|
-
raise TimeoutError(
|
|
152
|
-
f"Login did not complete within {_timeout} seconds. "
|
|
153
|
-
"Possibly headless environment."
|
|
154
|
-
)
|
|
155
|
-
code = getattr(server, "auth_code", None)
|
|
156
|
-
except Exception as error:
|
|
157
|
-
logger.warning(
|
|
158
|
-
"Could not open browser automatically. %s"
|
|
159
|
-
"Please open the URL manually.",
|
|
160
|
-
error,
|
|
161
|
-
)
|
|
162
|
-
reason = str(error)
|
|
163
|
-
|
|
164
|
-
finally:
|
|
165
|
-
logger.debug("Cleaning up login state")
|
|
166
|
-
if hasattr(server, "server_close"):
|
|
167
|
-
try:
|
|
168
|
-
server.server_close()
|
|
169
|
-
except Exception as error:
|
|
170
|
-
logger.debug("Failed to close server cleanly: %s", error)
|
|
171
|
-
if not code:
|
|
172
|
-
raise AuthError(reason)
|
|
173
|
-
return self.get_token(
|
|
174
|
-
token_endpoint,
|
|
175
|
-
data={
|
|
176
|
-
"code": code,
|
|
177
|
-
"redirect_uri": redirect_uri,
|
|
178
|
-
"grant_type": "authorization_code",
|
|
179
|
-
},
|
|
80
|
+
is_interactive_auth = int(
|
|
81
|
+
os.getenv("BROWSER_SESSION", str(int(is_interactive_auth_possible())))
|
|
180
82
|
)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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"],
|
|
187
93
|
)
|
|
188
|
-
for port in ports:
|
|
189
|
-
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
190
|
-
try:
|
|
191
|
-
s.bind(("localhost", port))
|
|
192
|
-
return port
|
|
193
|
-
except (OSError, PermissionError):
|
|
194
|
-
pass
|
|
195
|
-
raise OSError("No free ports available for login flow")
|
|
196
94
|
|
|
197
95
|
def set_token(
|
|
198
96
|
self,
|
|
@@ -265,7 +163,7 @@ class Auth:
|
|
|
265
163
|
cfg.auth_url, token["refresh_token"], timeout=timeout
|
|
266
164
|
)
|
|
267
165
|
if strategy == "browser_auth":
|
|
268
|
-
return self._login(cfg.auth_url,
|
|
166
|
+
return self._login(cfg.auth_url, _timeout=timeout)
|
|
269
167
|
except AuthError as error:
|
|
270
168
|
reason = str(error)
|
|
271
169
|
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""Helper functions for authentication."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import webbrowser
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import (
|
|
13
|
+
Any,
|
|
14
|
+
Callable,
|
|
15
|
+
Dict,
|
|
16
|
+
Iterator,
|
|
17
|
+
Literal,
|
|
18
|
+
Optional,
|
|
19
|
+
TypedDict,
|
|
20
|
+
Union,
|
|
21
|
+
cast,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
import requests
|
|
25
|
+
import rich.console
|
|
26
|
+
import rich.spinner
|
|
27
|
+
from appdirs import user_cache_dir
|
|
28
|
+
from rich.live import Live
|
|
29
|
+
|
|
30
|
+
from freva_client.utils import logger
|
|
31
|
+
|
|
32
|
+
TOKEN_EXPIRY_BUFFER = 60 # seconds
|
|
33
|
+
TOKEN_ENV_VAR = "FREVA_TOKEN_FILE"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
BROWSER_MESSAGE = """Will attempt to open the auth url in your browser.
|
|
37
|
+
|
|
38
|
+
If this doesn't work, try opening the following url:
|
|
39
|
+
|
|
40
|
+
{interactive}{uri}{interactive_end}
|
|
41
|
+
|
|
42
|
+
You might have to enter the this code manually {interactive}{user_code}{interactive_end}
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
NON_INTERACTIVE_MESSAGE = """
|
|
46
|
+
|
|
47
|
+
Visit the following url to authorise your session:
|
|
48
|
+
|
|
49
|
+
{interactive}{uri}{interactive_end}
|
|
50
|
+
|
|
51
|
+
You might have to enter the this code manually {interactive}{user_code}{interactive_end}
|
|
52
|
+
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@contextmanager
|
|
57
|
+
def _clock(timeout: Optional[int] = None) -> Iterator[None]:
|
|
58
|
+
Console = rich.console.Console(
|
|
59
|
+
force_terminal=is_interactive_shell(), stderr=True
|
|
60
|
+
)
|
|
61
|
+
txt = f"- Timeout: {timeout:>3,.0f}s " if timeout else ""
|
|
62
|
+
interactive = int(Console.is_terminal)
|
|
63
|
+
if int(os.getenv("INTERACIVE_SESSION", str(interactive))):
|
|
64
|
+
spinner = rich.spinner.Spinner(
|
|
65
|
+
"moon", text=f"[b]Waiting for code {txt}... [/]"
|
|
66
|
+
)
|
|
67
|
+
live = Live(
|
|
68
|
+
spinner, console=Console, refresh_per_second=2.5, transient=True
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
live.start()
|
|
72
|
+
yield
|
|
73
|
+
finally:
|
|
74
|
+
live.stop()
|
|
75
|
+
else:
|
|
76
|
+
yield
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AuthError(Exception):
|
|
80
|
+
"""Athentication error."""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self, message: str, detail: Optional[Dict[str, str]] = None
|
|
84
|
+
) -> None:
|
|
85
|
+
super().__init__(message)
|
|
86
|
+
self.message = message
|
|
87
|
+
self.detail = detail or {}
|
|
88
|
+
|
|
89
|
+
def __str__(self) -> str:
|
|
90
|
+
return f"{self.message}: {self.detail}" if self.detail else self.message
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Token(TypedDict, total=False):
|
|
94
|
+
"""Token information."""
|
|
95
|
+
|
|
96
|
+
access_token: str
|
|
97
|
+
token_type: str
|
|
98
|
+
expires: int
|
|
99
|
+
refresh_token: str
|
|
100
|
+
refresh_expires: int
|
|
101
|
+
scope: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class DeviceAuthClient:
|
|
106
|
+
"""
|
|
107
|
+
Minimal OIDC Device Authorization Grant client.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
device_endpoint : str
|
|
112
|
+
Device endpoint URL, e.g. ``{issuer}/protocol/openid-connect/auth/device``.
|
|
113
|
+
token_endpoint : str
|
|
114
|
+
Token endpoint URL, e.g. ``{issuer}/protocol/openid-connect/token``.
|
|
115
|
+
scope : str, optional
|
|
116
|
+
Space-separated scopes; include ``offline_access`` if you need offline RTs.
|
|
117
|
+
timeout : int | None, optional
|
|
118
|
+
Overall timeout (seconds) for user approval. ``None`` waits indefinitely.
|
|
119
|
+
session : requests.Session | None, optional
|
|
120
|
+
Reusable HTTP session. A new one is created if omitted.
|
|
121
|
+
|
|
122
|
+
Notes
|
|
123
|
+
-----
|
|
124
|
+
- For a CLI UX, show both ``verification_uri_complete`` (if present) and
|
|
125
|
+
``verification_uri`` + ``user_code`` as fallback.
|
|
126
|
+
- Polling respects ``interval`` and ``slow_down`` per RFC 8628.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
device_endpoint: str
|
|
130
|
+
token_endpoint: str
|
|
131
|
+
timeout: Optional[int] = 600
|
|
132
|
+
session: requests.Session = field(default_factory=requests.Session)
|
|
133
|
+
|
|
134
|
+
# ---------- Public API ----------
|
|
135
|
+
|
|
136
|
+
def login(
|
|
137
|
+
self,
|
|
138
|
+
*,
|
|
139
|
+
auto_open: bool = True,
|
|
140
|
+
token_normalizer: Optional[Callable[[str, Dict[str, str]], Token]] = None,
|
|
141
|
+
) -> Token:
|
|
142
|
+
"""
|
|
143
|
+
Run the full device flow and return an OIDC token payload.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
auto_open : bool, optional
|
|
148
|
+
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
|
+
|
|
154
|
+
Returns
|
|
155
|
+
-------
|
|
156
|
+
Token
|
|
157
|
+
The token payload. Includes a refresh token if granted and allowed.
|
|
158
|
+
|
|
159
|
+
Raises
|
|
160
|
+
------
|
|
161
|
+
AuthError
|
|
162
|
+
If the device flow fails or times out.
|
|
163
|
+
"""
|
|
164
|
+
init = self._authorize()
|
|
165
|
+
|
|
166
|
+
uri = init.get("verification_uri_complete") or init["verification_uri"]
|
|
167
|
+
user_code = init["user_code"]
|
|
168
|
+
|
|
169
|
+
Console = rich.console.Console(
|
|
170
|
+
force_terminal=is_interactive_shell(), stderr=True
|
|
171
|
+
)
|
|
172
|
+
pprint = Console.print if Console.is_terminal else print
|
|
173
|
+
interactive, interactive_end = (
|
|
174
|
+
("[b]", "[/b]") if Console.is_terminal else ("", "")
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if auto_open and init.get("verification_uri_complete"):
|
|
178
|
+
try:
|
|
179
|
+
pprint(
|
|
180
|
+
BROWSER_MESSAGE.format(
|
|
181
|
+
user_code=user_code,
|
|
182
|
+
uri=uri,
|
|
183
|
+
interactive=interactive,
|
|
184
|
+
interactive_end=interactive_end,
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
webbrowser.open(init["verification_uri_complete"])
|
|
188
|
+
except Exception as error:
|
|
189
|
+
logger.warning("Could not auto-open browser: %s", error)
|
|
190
|
+
else:
|
|
191
|
+
pprint(
|
|
192
|
+
NON_INTERACTIVE_MESSAGE.format(
|
|
193
|
+
user_code=user_code,
|
|
194
|
+
uri=uri,
|
|
195
|
+
interactive=interactive,
|
|
196
|
+
interactive_end=interactive_end,
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
return self._poll_for_token(
|
|
200
|
+
device_code=init["device_code"],
|
|
201
|
+
base_interval=int(init.get("interval", 5)),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# ---------- Internals ----------
|
|
205
|
+
|
|
206
|
+
def _authorize(self) -> Dict[str, Any]:
|
|
207
|
+
"""Start device authorization; return the raw init payload."""
|
|
208
|
+
payload = self._post_form(self.device_endpoint)
|
|
209
|
+
# Validate essentials
|
|
210
|
+
for k in ("device_code", "user_code", "verification_uri", "expires_in"):
|
|
211
|
+
if k not in payload:
|
|
212
|
+
raise AuthError(f"Device authorization missing '{k}'")
|
|
213
|
+
return payload
|
|
214
|
+
|
|
215
|
+
def _poll_for_token(self, *, device_code: str, base_interval: int) -> Token:
|
|
216
|
+
"""
|
|
217
|
+
Poll token endpoint until approved/denied/expired; return token JSON.
|
|
218
|
+
|
|
219
|
+
Raises
|
|
220
|
+
------
|
|
221
|
+
AuthError
|
|
222
|
+
On timeout, denial, or other OAuth errors.
|
|
223
|
+
"""
|
|
224
|
+
start = time.monotonic()
|
|
225
|
+
interval = max(1, base_interval)
|
|
226
|
+
with _clock(self.timeout):
|
|
227
|
+
while True:
|
|
228
|
+
sleep = interval + random.uniform(-0.2, 0.4)
|
|
229
|
+
if (
|
|
230
|
+
self.timeout is not None
|
|
231
|
+
and time.monotonic() - start > self.timeout
|
|
232
|
+
):
|
|
233
|
+
raise AuthError(
|
|
234
|
+
"Login did not complete within the allotted time; "
|
|
235
|
+
"approve the request in your browser and try again."
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
data = {"device-code": device_code}
|
|
239
|
+
try:
|
|
240
|
+
return cast(Token, self._post_form(self.token_endpoint, data))
|
|
241
|
+
except AuthError as error:
|
|
242
|
+
err = (
|
|
243
|
+
error.detail.get("error")
|
|
244
|
+
if isinstance(error.detail, dict)
|
|
245
|
+
else None
|
|
246
|
+
)
|
|
247
|
+
if err is None or "authorization_pending" in err:
|
|
248
|
+
time.sleep(sleep)
|
|
249
|
+
elif "slow_down" in err:
|
|
250
|
+
interval += 5
|
|
251
|
+
time.sleep(sleep)
|
|
252
|
+
elif "expired_token" in err or "access_denied" in err:
|
|
253
|
+
raise AuthError(f"Device flow failed: {err}")
|
|
254
|
+
else:
|
|
255
|
+
# Unknown OAuth error
|
|
256
|
+
raise # pragma: no cover
|
|
257
|
+
|
|
258
|
+
def _post_form(
|
|
259
|
+
self, url: str, data: Optional[Dict[str, str]] = None
|
|
260
|
+
) -> Dict[str, Any]:
|
|
261
|
+
"""POST x-www-form-urlencoded and return JSON or raise AuthError."""
|
|
262
|
+
resp = self.session.post(
|
|
263
|
+
url,
|
|
264
|
+
data=data,
|
|
265
|
+
headers={
|
|
266
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
267
|
+
"Connection": "close",
|
|
268
|
+
},
|
|
269
|
+
timeout=30,
|
|
270
|
+
)
|
|
271
|
+
if resp.status_code >= 400:
|
|
272
|
+
try:
|
|
273
|
+
payload = resp.json()
|
|
274
|
+
except Exception:
|
|
275
|
+
payload = {
|
|
276
|
+
"error": "http_error",
|
|
277
|
+
"error_description": resp.text[:300],
|
|
278
|
+
}
|
|
279
|
+
raise AuthError(f"{url} -> {resp.status_code}", detail=payload)
|
|
280
|
+
try:
|
|
281
|
+
return cast(Dict[str, Any], resp.json())
|
|
282
|
+
except Exception as error:
|
|
283
|
+
raise AuthError(f"Invalid JSON from {url}: {error}")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def get_default_token_file(token_file: Optional[Union[str, Path]] = None) -> Path:
|
|
287
|
+
"""Get the location of the default token file."""
|
|
288
|
+
path_str = token_file or os.getenv(TOKEN_ENV_VAR, "").strip()
|
|
289
|
+
|
|
290
|
+
path = Path(
|
|
291
|
+
path_str or os.path.join(user_cache_dir("freva"), "auth-token.json")
|
|
292
|
+
)
|
|
293
|
+
path.parent.mkdir(exist_ok=True, parents=True)
|
|
294
|
+
return path
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def is_job_env() -> bool:
|
|
298
|
+
"""Detect whether we are running in a batch or job-managed environment.
|
|
299
|
+
|
|
300
|
+
Returns
|
|
301
|
+
-------
|
|
302
|
+
bool
|
|
303
|
+
True if common batch or workload manager environment variables are present.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
job_env_vars = [
|
|
307
|
+
# Slurm, PBS, Moab
|
|
308
|
+
"SLURM_JOB_ID",
|
|
309
|
+
"SLURM_NODELIST",
|
|
310
|
+
"PBS_JOBID",
|
|
311
|
+
"PBS_ENVIRONMENT",
|
|
312
|
+
"PBS_NODEFILE",
|
|
313
|
+
# SGE
|
|
314
|
+
"JOB_ID",
|
|
315
|
+
"SGE_TASK_ID",
|
|
316
|
+
"PE_HOSTFILE",
|
|
317
|
+
# LSF
|
|
318
|
+
"LSB_JOBID",
|
|
319
|
+
"LSB_HOSTS",
|
|
320
|
+
# OAR
|
|
321
|
+
"OAR_JOB_ID",
|
|
322
|
+
"OAR_NODEFILE",
|
|
323
|
+
# MPI
|
|
324
|
+
"OMPI_COMM_WORLD_SIZE",
|
|
325
|
+
"PMI_RANK",
|
|
326
|
+
"MPI_LOCALRANKID",
|
|
327
|
+
# Kubernetes
|
|
328
|
+
"KUBERNETES_SERVICE_HOST",
|
|
329
|
+
"KUBERNETES_PORT",
|
|
330
|
+
# FREVA BATCH MODE
|
|
331
|
+
"FREVA_BATCH_JOB",
|
|
332
|
+
# JHUB SESSION
|
|
333
|
+
"JUPYTERHUB_USER",
|
|
334
|
+
]
|
|
335
|
+
return any(var in os.environ for var in job_env_vars)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def is_jupyter_notebook() -> bool:
|
|
339
|
+
"""Check if running in a Jupyter notebook.
|
|
340
|
+
|
|
341
|
+
Returns
|
|
342
|
+
-------
|
|
343
|
+
bool
|
|
344
|
+
True if inside a Jupyter notebook or Jupyter kernel.
|
|
345
|
+
"""
|
|
346
|
+
try:
|
|
347
|
+
from IPython import get_ipython # type: ignore[attr-defined]
|
|
348
|
+
|
|
349
|
+
return get_ipython() is not None # pragma: no cover
|
|
350
|
+
except Exception:
|
|
351
|
+
return False
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def is_interactive_shell() -> bool:
|
|
355
|
+
"""Check whether we are running in an interactive terminal.
|
|
356
|
+
|
|
357
|
+
Returns
|
|
358
|
+
-------
|
|
359
|
+
bool
|
|
360
|
+
True if stdin and stdout are TTYs.
|
|
361
|
+
"""
|
|
362
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def is_interactive_auth_possible() -> bool:
|
|
366
|
+
"""Decide if an interactive browser-based auth flow is possible.
|
|
367
|
+
|
|
368
|
+
Returns
|
|
369
|
+
-------
|
|
370
|
+
bool
|
|
371
|
+
True if not in a batch/job/JupyterHub context and either in a TTY or
|
|
372
|
+
local Jupyter.
|
|
373
|
+
"""
|
|
374
|
+
return (is_interactive_shell() or is_jupyter_notebook()) and not (
|
|
375
|
+
is_job_env()
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def resolve_token_path(custom_path: Optional[Union[str, Path]] = None) -> Path:
|
|
380
|
+
"""Resolve the path to the token file.
|
|
381
|
+
|
|
382
|
+
Parameters
|
|
383
|
+
----------
|
|
384
|
+
custom_path : str or None
|
|
385
|
+
Optional path override.
|
|
386
|
+
|
|
387
|
+
Returns
|
|
388
|
+
-------
|
|
389
|
+
Path
|
|
390
|
+
The resolved path to the token file.
|
|
391
|
+
"""
|
|
392
|
+
if custom_path:
|
|
393
|
+
return Path(custom_path).expanduser().absolute()
|
|
394
|
+
path = get_default_token_file()
|
|
395
|
+
return path.expanduser().absolute()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def load_token(path: Optional[Union[str, Path]]) -> Optional[Token]:
|
|
399
|
+
"""Load a token dictionary from the given file path.
|
|
400
|
+
|
|
401
|
+
Parameters
|
|
402
|
+
----------
|
|
403
|
+
path : Path or None
|
|
404
|
+
Path to the token file.
|
|
405
|
+
|
|
406
|
+
Returns
|
|
407
|
+
-------
|
|
408
|
+
dict or None
|
|
409
|
+
Parsed token dict or None if load fails.
|
|
410
|
+
"""
|
|
411
|
+
path = resolve_token_path(path)
|
|
412
|
+
try:
|
|
413
|
+
token: Token = json.loads(path.read_text())
|
|
414
|
+
return token
|
|
415
|
+
except Exception:
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def is_token_valid(
|
|
420
|
+
token: Optional[Token], token_type: Literal["access_token", "refresh_token"]
|
|
421
|
+
) -> bool:
|
|
422
|
+
"""Check if a refresh token is available.
|
|
423
|
+
|
|
424
|
+
Parameters
|
|
425
|
+
----------
|
|
426
|
+
token : dict
|
|
427
|
+
Token dictionary.
|
|
428
|
+
typken_type: str
|
|
429
|
+
What type of token to check for.
|
|
430
|
+
|
|
431
|
+
Returns
|
|
432
|
+
-------
|
|
433
|
+
bool
|
|
434
|
+
True if a refresh token is present.
|
|
435
|
+
"""
|
|
436
|
+
exp = cast(
|
|
437
|
+
Literal["refresh_expires", "expires"],
|
|
438
|
+
{
|
|
439
|
+
"refresh_token": "refresh_expires",
|
|
440
|
+
"access_token": "expires",
|
|
441
|
+
}[token_type],
|
|
442
|
+
)
|
|
443
|
+
return cast(
|
|
444
|
+
bool,
|
|
445
|
+
(
|
|
446
|
+
token
|
|
447
|
+
and token_type in token
|
|
448
|
+
and exp in token
|
|
449
|
+
and (time.time() + TOKEN_EXPIRY_BUFFER < token[exp])
|
|
450
|
+
),
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def choose_token_strategy(
|
|
455
|
+
token: Optional[Token] = None, token_file: Optional[Path] = None
|
|
456
|
+
) -> Literal["use_token", "refresh_token", "browser_auth", "fail"]:
|
|
457
|
+
"""Decide what action to take based on token state and environment.
|
|
458
|
+
|
|
459
|
+
Parameters
|
|
460
|
+
----------
|
|
461
|
+
token : dict|None, default: None
|
|
462
|
+
Token dictionary or None if no token file found.
|
|
463
|
+
token_file: Path|None, default: None
|
|
464
|
+
Path to the file holding token information.
|
|
465
|
+
|
|
466
|
+
Returns
|
|
467
|
+
-------
|
|
468
|
+
str
|
|
469
|
+
One of:
|
|
470
|
+
- "use_token" : Access token is valid and usable.
|
|
471
|
+
- "refresh_token" : Refresh token should be used to get new access token.
|
|
472
|
+
- "browser_auth" : Interactive login via browser is allowed.
|
|
473
|
+
- "fail" : No way to log in in current environment.
|
|
474
|
+
"""
|
|
475
|
+
if is_token_valid(token, "access_token"):
|
|
476
|
+
return "use_token"
|
|
477
|
+
if is_token_valid(token, "refresh_token"):
|
|
478
|
+
return "refresh_token"
|
|
479
|
+
if is_interactive_auth_possible():
|
|
480
|
+
return "browser_auth"
|
|
481
|
+
return "fail"
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def requires_authentication(
|
|
485
|
+
flavour: Optional[str],
|
|
486
|
+
zarr: bool = False,
|
|
487
|
+
databrowser_url: Optional[str] = None,
|
|
488
|
+
) -> bool:
|
|
489
|
+
"""Check if authentication is required.
|
|
490
|
+
|
|
491
|
+
Parameters
|
|
492
|
+
----------
|
|
493
|
+
flavour : str or None
|
|
494
|
+
The data flavour to check.
|
|
495
|
+
zarr : bool, default: False
|
|
496
|
+
Whether the request is for zarr data.
|
|
497
|
+
databrowser_url : str or None
|
|
498
|
+
The URL of the databrowser to query for available flavours.
|
|
499
|
+
If None, the function will skip querying and assume authentication
|
|
500
|
+
is required for non-default flavours.
|
|
501
|
+
"""
|
|
502
|
+
if zarr:
|
|
503
|
+
return True
|
|
504
|
+
if flavour in {"freva", "cmip6", "cmip5", "cordex", "user", None}:
|
|
505
|
+
return False
|
|
506
|
+
try:
|
|
507
|
+
response = requests.get(f"{databrowser_url}/flavours", timeout=30)
|
|
508
|
+
response.raise_for_status()
|
|
509
|
+
result = {"flavours": response.json().get("flavours", [])}
|
|
510
|
+
if "flavours" in result:
|
|
511
|
+
global_flavour_names = {f["flavour_name"] for f in result["flavours"]}
|
|
512
|
+
return flavour not in global_flavour_names
|
|
513
|
+
except Exception:
|
|
514
|
+
pass
|
|
515
|
+
|
|
516
|
+
return True
|
|
@@ -42,11 +42,18 @@ class Config:
|
|
|
42
42
|
uniq_key: Literal["file", "uri"] = "file",
|
|
43
43
|
flavour: Optional[str] = None,
|
|
44
44
|
) -> None:
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
self.
|
|
45
|
+
config_host = host or cast(str, self._get_databrowser_param_from_config("host"))
|
|
46
|
+
|
|
47
|
+
self.databrowser_url = f"{self.get_api_url(config_host)}/databrowser"
|
|
48
|
+
self.auth_url = f"{self.get_api_url(config_host)}/auth/v2"
|
|
49
|
+
self.get_api_main_url = self.get_api_url(config_host)
|
|
48
50
|
self.uniq_key = uniq_key
|
|
49
|
-
self.
|
|
51
|
+
self._flavour = (
|
|
52
|
+
flavour or self._get_databrowser_param_from_config(
|
|
53
|
+
"flavour",
|
|
54
|
+
optional=True
|
|
55
|
+
)
|
|
56
|
+
)
|
|
50
57
|
|
|
51
58
|
@cached_property
|
|
52
59
|
def validate_server(self) -> bool:
|
|
@@ -59,18 +66,17 @@ class Config:
|
|
|
59
66
|
f"Could not connect to {self.databrowser_url}: {e}"
|
|
60
67
|
) from None
|
|
61
68
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return "freva"
|
|
69
|
+
@property
|
|
70
|
+
def flavour(self) -> str:
|
|
71
|
+
"""Get the flavour, using server default if not configured."""
|
|
72
|
+
if self._flavour:
|
|
73
|
+
return self._flavour
|
|
74
|
+
try:
|
|
75
|
+
flavours = self.overview.get("flavours", [])
|
|
76
|
+
self._flavour = flavours[0] if flavours else "freva"
|
|
77
|
+
except (ValueError, IndexError, KeyError):
|
|
78
|
+
self._flavour = "freva"
|
|
79
|
+
return self._flavour
|
|
74
80
|
|
|
75
81
|
def _read_ini(self, path: Path) -> Dict[str, str]:
|
|
76
82
|
"""Read an ini file.
|
|
@@ -115,8 +121,11 @@ class Config:
|
|
|
115
121
|
"""
|
|
116
122
|
try:
|
|
117
123
|
config = tomli.loads(path.read_text()).get("freva", {})
|
|
118
|
-
|
|
124
|
+
raw_host = cast(str, config.get("host", ""))
|
|
119
125
|
flavour = config.get("default_flavour", "")
|
|
126
|
+
if not raw_host:
|
|
127
|
+
return {"host": "", "flavour": flavour}
|
|
128
|
+
scheme, host = self._split_url(raw_host)
|
|
120
129
|
except (tomli.TOMLDecodeError, KeyError):
|
|
121
130
|
return {}
|
|
122
131
|
host, _, port = host.partition(":")
|
|
@@ -186,9 +195,10 @@ class Config:
|
|
|
186
195
|
f"Could not connect to {self.databrowser_url}"
|
|
187
196
|
) from None
|
|
188
197
|
|
|
189
|
-
def
|
|
190
|
-
|
|
191
|
-
|
|
198
|
+
def _get_databrowser_param_from_config(
|
|
199
|
+
self, key: str, optional: bool = False
|
|
200
|
+
) -> Optional[str]:
|
|
201
|
+
"""Get a single config parameter following proper precedence."""
|
|
192
202
|
eval_conf = self.get_dirs(user=False) / "evaluation_system.conf"
|
|
193
203
|
freva_config = Path(
|
|
194
204
|
os.environ.get("FREVA_CONFIG")
|
|
@@ -202,20 +212,22 @@ class Config:
|
|
|
202
212
|
os.environ.get("EVALUATION_SYSTEM_CONFIG_FILE") or eval_conf
|
|
203
213
|
): "ini",
|
|
204
214
|
}
|
|
215
|
+
|
|
205
216
|
for config_path, config_type in paths.items():
|
|
206
217
|
if config_path.is_file():
|
|
207
218
|
config_data = self._read_config(config_path, config_type)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
219
|
+
value = config_data.get(key, "")
|
|
220
|
+
# we cannot igonore the empty string here, because
|
|
221
|
+
# it needs to check the next config file
|
|
222
|
+
if value:
|
|
223
|
+
return value if value else None
|
|
224
|
+
if optional:
|
|
225
|
+
return None
|
|
226
|
+
|
|
215
227
|
raise ValueError(
|
|
216
|
-
"No databrowser
|
|
217
|
-
" configuration defining a databrowser
|
|
218
|
-
" set a host name using the `
|
|
228
|
+
f"No databrowser {key} configured, please use a"
|
|
229
|
+
f" configuration defining a databrowser {key} or"
|
|
230
|
+
f" set a host name using the `{key}` key"
|
|
219
231
|
)
|
|
220
232
|
|
|
221
233
|
@property
|
|
@@ -257,9 +269,8 @@ class Config:
|
|
|
257
269
|
scheme = scheme or "http"
|
|
258
270
|
return scheme, hostname
|
|
259
271
|
|
|
260
|
-
def get_api_url(self, url:
|
|
272
|
+
def get_api_url(self, url: str) -> str:
|
|
261
273
|
"""Construct the databrowser url from a given hostname."""
|
|
262
|
-
url = url or self._get_databrowser_params_from_config().get("host", "")
|
|
263
274
|
scheme, hostname = self._split_url(url)
|
|
264
275
|
hostname, _, port = hostname.partition(":")
|
|
265
276
|
if port:
|
|
@@ -385,7 +396,7 @@ class UserDataHandler:
|
|
|
385
396
|
) -> None:
|
|
386
397
|
for data in validated_userdata:
|
|
387
398
|
metadata = self._get_metadata(data)
|
|
388
|
-
if
|
|
399
|
+
if metadata == {}:
|
|
389
400
|
logger.warning("Error getting metadata: %s", metadata)
|
|
390
401
|
else:
|
|
391
402
|
self.user_metadata.append(metadata)
|
|
@@ -1,278 +0,0 @@
|
|
|
1
|
-
"""Helper functions for authentication."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import os
|
|
5
|
-
import socket
|
|
6
|
-
import sys
|
|
7
|
-
import time
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from typing import Literal, Optional, TypedDict, Union, cast
|
|
10
|
-
|
|
11
|
-
import requests
|
|
12
|
-
from appdirs import user_cache_dir
|
|
13
|
-
|
|
14
|
-
TOKEN_EXPIRY_BUFFER = 60 # seconds
|
|
15
|
-
TOKEN_ENV_VAR = "FREVA_TOKEN_FILE"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class Token(TypedDict):
|
|
19
|
-
"""Token information."""
|
|
20
|
-
|
|
21
|
-
access_token: str
|
|
22
|
-
token_type: str
|
|
23
|
-
expires: int
|
|
24
|
-
refresh_token: str
|
|
25
|
-
refresh_expires: int
|
|
26
|
-
scope: str
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def get_default_token_file() -> Path:
|
|
30
|
-
"""Get the location of the default token file."""
|
|
31
|
-
path_str = os.getenv(TOKEN_ENV_VAR, "").strip()
|
|
32
|
-
|
|
33
|
-
path = Path(
|
|
34
|
-
path_str or os.path.join(user_cache_dir("freva"), "auth-token.json")
|
|
35
|
-
)
|
|
36
|
-
path.parent.mkdir(exist_ok=True, parents=True)
|
|
37
|
-
return path
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def is_job_env() -> bool:
|
|
41
|
-
"""Detect whether we are running in a batch or job-managed environment.
|
|
42
|
-
|
|
43
|
-
Returns
|
|
44
|
-
-------
|
|
45
|
-
bool
|
|
46
|
-
True if common batch or workload manager environment variables are present.
|
|
47
|
-
"""
|
|
48
|
-
|
|
49
|
-
job_env_vars = [
|
|
50
|
-
# Slurm, PBS, Moab
|
|
51
|
-
"SLURM_JOB_ID",
|
|
52
|
-
"SLURM_NODELIST",
|
|
53
|
-
"PBS_JOBID",
|
|
54
|
-
"PBS_ENVIRONMENT",
|
|
55
|
-
"PBS_NODEFILE",
|
|
56
|
-
# SGE
|
|
57
|
-
"JOB_ID",
|
|
58
|
-
"SGE_TASK_ID",
|
|
59
|
-
"PE_HOSTFILE",
|
|
60
|
-
# LSF
|
|
61
|
-
"LSB_JOBID",
|
|
62
|
-
"LSB_HOSTS",
|
|
63
|
-
# OAR
|
|
64
|
-
"OAR_JOB_ID",
|
|
65
|
-
"OAR_NODEFILE",
|
|
66
|
-
# MPI
|
|
67
|
-
"OMPI_COMM_WORLD_SIZE",
|
|
68
|
-
"PMI_RANK",
|
|
69
|
-
"MPI_LOCALRANKID",
|
|
70
|
-
# Kubernetes
|
|
71
|
-
"KUBERNETES_SERVICE_HOST",
|
|
72
|
-
"KUBERNETES_PORT",
|
|
73
|
-
# FREVA BATCH MODE
|
|
74
|
-
"FREVA_BATCH_JOB",
|
|
75
|
-
# JHUB SESSION
|
|
76
|
-
"JUPYTERHUB_USER",
|
|
77
|
-
]
|
|
78
|
-
return any(var in os.environ for var in job_env_vars)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def is_jupyter_notebook() -> bool:
|
|
82
|
-
"""Check if running in a Jupyter notebook.
|
|
83
|
-
|
|
84
|
-
Returns
|
|
85
|
-
-------
|
|
86
|
-
bool
|
|
87
|
-
True if inside a Jupyter notebook or Jupyter kernel.
|
|
88
|
-
"""
|
|
89
|
-
try:
|
|
90
|
-
from IPython import get_ipython # type: ignore[attr-defined]
|
|
91
|
-
|
|
92
|
-
return get_ipython() is not None # pragma: no cover
|
|
93
|
-
except Exception:
|
|
94
|
-
return False
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def is_interactive_shell() -> bool:
|
|
98
|
-
"""Check whether we are running in an interactive terminal.
|
|
99
|
-
|
|
100
|
-
Returns
|
|
101
|
-
-------
|
|
102
|
-
bool
|
|
103
|
-
True if stdin and stdout are TTYs.
|
|
104
|
-
"""
|
|
105
|
-
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def is_interactive_auth_possible() -> bool:
|
|
109
|
-
"""Decide if an interactive browser-based auth flow is possible.
|
|
110
|
-
|
|
111
|
-
Returns
|
|
112
|
-
-------
|
|
113
|
-
bool
|
|
114
|
-
True if not in a batch/job/JupyterHub context and either in a TTY or
|
|
115
|
-
local Jupyter.
|
|
116
|
-
"""
|
|
117
|
-
return (is_interactive_shell() or is_jupyter_notebook()) and not (
|
|
118
|
-
is_job_env()
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def resolve_token_path(custom_path: Optional[Union[str, Path]] = None) -> Path:
|
|
123
|
-
"""Resolve the path to the token file.
|
|
124
|
-
|
|
125
|
-
Parameters
|
|
126
|
-
----------
|
|
127
|
-
custom_path : str or None
|
|
128
|
-
Optional path override.
|
|
129
|
-
|
|
130
|
-
Returns
|
|
131
|
-
-------
|
|
132
|
-
Path
|
|
133
|
-
The resolved path to the token file.
|
|
134
|
-
"""
|
|
135
|
-
if custom_path:
|
|
136
|
-
return Path(custom_path).expanduser().absolute()
|
|
137
|
-
path = get_default_token_file()
|
|
138
|
-
return path.expanduser().absolute()
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def load_token(path: Optional[Union[str, Path]]) -> Optional[Token]:
|
|
142
|
-
"""Load a token dictionary from the given file path.
|
|
143
|
-
|
|
144
|
-
Parameters
|
|
145
|
-
----------
|
|
146
|
-
path : Path or None
|
|
147
|
-
Path to the token file.
|
|
148
|
-
|
|
149
|
-
Returns
|
|
150
|
-
-------
|
|
151
|
-
dict or None
|
|
152
|
-
Parsed token dict or None if load fails.
|
|
153
|
-
"""
|
|
154
|
-
path = resolve_token_path(path)
|
|
155
|
-
try:
|
|
156
|
-
token: Token = json.loads(path.read_text())
|
|
157
|
-
return token
|
|
158
|
-
except Exception:
|
|
159
|
-
return None
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def is_token_valid(
|
|
163
|
-
token: Optional[Token], token_type: Literal["access_token", "refresh_token"]
|
|
164
|
-
) -> bool:
|
|
165
|
-
"""Check if a refresh token is available.
|
|
166
|
-
|
|
167
|
-
Parameters
|
|
168
|
-
----------
|
|
169
|
-
token : dict
|
|
170
|
-
Token dictionary.
|
|
171
|
-
typken_type: str
|
|
172
|
-
What type of token to check for.
|
|
173
|
-
|
|
174
|
-
Returns
|
|
175
|
-
-------
|
|
176
|
-
bool
|
|
177
|
-
True if a refresh token is present.
|
|
178
|
-
"""
|
|
179
|
-
exp = cast(
|
|
180
|
-
Literal["refresh_expires", "expires"],
|
|
181
|
-
{
|
|
182
|
-
"refresh_token": "refresh_expires",
|
|
183
|
-
"access_token": "expires",
|
|
184
|
-
}[token_type],
|
|
185
|
-
)
|
|
186
|
-
return cast(
|
|
187
|
-
bool,
|
|
188
|
-
(
|
|
189
|
-
token
|
|
190
|
-
and token_type in token
|
|
191
|
-
and exp in token
|
|
192
|
-
and (time.time() + TOKEN_EXPIRY_BUFFER < token[exp])
|
|
193
|
-
),
|
|
194
|
-
)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def choose_token_strategy(
|
|
198
|
-
token: Optional[Token] = None, token_file: Optional[Path] = None
|
|
199
|
-
) -> Literal["use_token", "refresh_token", "browser_auth", "fail"]:
|
|
200
|
-
"""Decide what action to take based on token state and environment.
|
|
201
|
-
|
|
202
|
-
Parameters
|
|
203
|
-
----------
|
|
204
|
-
token : dict|None, default: None
|
|
205
|
-
Token dictionary or None if no token file found.
|
|
206
|
-
token_file: Path|None, default: None
|
|
207
|
-
Path to the file holding token information.
|
|
208
|
-
|
|
209
|
-
Returns
|
|
210
|
-
-------
|
|
211
|
-
str
|
|
212
|
-
One of:
|
|
213
|
-
- "use_token" : Access token is valid and usable.
|
|
214
|
-
- "refresh_token" : Refresh token should be used to get new access token.
|
|
215
|
-
- "browser_auth" : Interactive login via browser is allowed.
|
|
216
|
-
- "fail" : No way to log in in current environment.
|
|
217
|
-
"""
|
|
218
|
-
if is_token_valid(token, "access_token"):
|
|
219
|
-
return "use_token"
|
|
220
|
-
if is_token_valid(token, "refresh_token"):
|
|
221
|
-
return "refresh_token"
|
|
222
|
-
if is_interactive_auth_possible():
|
|
223
|
-
return "browser_auth"
|
|
224
|
-
return "fail"
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
def wait_for_port(host: str, port: int, timeout: float = 5.0) -> None:
|
|
228
|
-
"""Wait until a TCP port starts accepting connections."""
|
|
229
|
-
deadline = time.time() + timeout
|
|
230
|
-
while time.time() < deadline:
|
|
231
|
-
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
232
|
-
sock.settimeout(0.5)
|
|
233
|
-
try:
|
|
234
|
-
if sock.connect_ex((host, port)) == 0:
|
|
235
|
-
return
|
|
236
|
-
except OSError:
|
|
237
|
-
pass
|
|
238
|
-
time.sleep(0.05)
|
|
239
|
-
raise TimeoutError(
|
|
240
|
-
f"Port {port} on {host} did not open within {timeout} seconds."
|
|
241
|
-
)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
def requires_authentication(
|
|
245
|
-
flavour: Optional[str],
|
|
246
|
-
zarr: bool = False,
|
|
247
|
-
databrowser_url: Optional[str] = None
|
|
248
|
-
) -> bool:
|
|
249
|
-
"""Check if authentication is required.
|
|
250
|
-
|
|
251
|
-
Parameters
|
|
252
|
-
----------
|
|
253
|
-
flavour : str or None
|
|
254
|
-
The data flavour to check.
|
|
255
|
-
zarr : bool, default: False
|
|
256
|
-
Whether the request is for zarr data.
|
|
257
|
-
databrowser_url : str or None
|
|
258
|
-
The URL of the databrowser to query for available flavours.
|
|
259
|
-
If None, the function will skip querying and assume authentication
|
|
260
|
-
is required for non-default flavours.
|
|
261
|
-
"""
|
|
262
|
-
if zarr:
|
|
263
|
-
return True
|
|
264
|
-
if flavour in {"freva", "cmip6", "cmip5", "cordex", "user", None}:
|
|
265
|
-
return False
|
|
266
|
-
try:
|
|
267
|
-
response = requests.get(f"{databrowser_url}/flavours", timeout=30)
|
|
268
|
-
response.raise_for_status()
|
|
269
|
-
result = {"flavours": response.json().get("flavours", [])}
|
|
270
|
-
if "flavours" in result:
|
|
271
|
-
global_flavour_names = {
|
|
272
|
-
f["flavour_name"] for f in result["flavours"]
|
|
273
|
-
}
|
|
274
|
-
return flavour not in global_flavour_names
|
|
275
|
-
except Exception:
|
|
276
|
-
pass
|
|
277
|
-
|
|
278
|
-
return True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|