freva-client 2509.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-2509.0.0 → freva_client-2509.1.0}/PKG-INFO +1 -1
- {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/__init__.py +1 -1
- {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/auth.py +23 -125
- freva_client-2509.1.0/src/freva_client/utils/auth_utils.py +516 -0
- freva_client-2509.0.0/src/freva_client/utils/auth_utils.py +0 -278
- {freva_client-2509.0.0 → freva_client-2509.1.0}/MANIFEST.in +0 -0
- {freva_client-2509.0.0 → freva_client-2509.1.0}/README.md +0 -0
- {freva_client-2509.0.0 → freva_client-2509.1.0}/assets/share/freva/freva.toml +0 -0
- {freva_client-2509.0.0 → freva_client-2509.1.0}/pyproject.toml +0 -0
- {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/__main__.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/cli/__init__.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/cli/auth_cli.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/cli/cli_app.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/cli/cli_parser.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/cli/cli_utils.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/cli/databrowser_cli.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/py.typed +0 -0
- {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/query.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/utils/__init__.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.1.0}/src/freva_client/utils/databrowser_utils.py +0 -0
- {freva_client-2509.0.0 → freva_client-2509.1.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
|
|
@@ -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
|
|
File without changes
|