freva-client 2505.0.0__tar.gz → 2510.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.
- {freva_client-2505.0.0 → freva_client-2510.1.0}/PKG-INFO +7 -8
- {freva_client-2505.0.0 → freva_client-2510.1.0}/README.md +3 -3
- {freva_client-2505.0.0 → freva_client-2510.1.0}/assets/share/freva/freva.toml +5 -0
- {freva_client-2505.0.0 → freva_client-2510.1.0}/pyproject.toml +3 -4
- {freva_client-2505.0.0 → freva_client-2510.1.0}/src/freva_client/__init__.py +2 -1
- freva_client-2510.1.0/src/freva_client/auth.py +248 -0
- {freva_client-2505.0.0 → freva_client-2510.1.0}/src/freva_client/cli/auth_cli.py +18 -19
- freva_client-2510.1.0/src/freva_client/cli/cli_utils.py +69 -0
- {freva_client-2505.0.0 → freva_client-2510.1.0}/src/freva_client/cli/databrowser_cli.py +356 -105
- {freva_client-2505.0.0 → freva_client-2510.1.0}/src/freva_client/query.py +368 -77
- freva_client-2510.1.0/src/freva_client/utils/auth_utils.py +680 -0
- {freva_client-2505.0.0 → freva_client-2510.1.0}/src/freva_client/utils/databrowser_utils.py +166 -56
- {freva_client-2505.0.0 → freva_client-2510.1.0}/src/freva_client/utils/logger.py +1 -1
- freva_client-2505.0.0/src/freva_client/auth.py +0 -202
- freva_client-2505.0.0/src/freva_client/cli/cli_utils.py +0 -32
- {freva_client-2505.0.0 → freva_client-2510.1.0}/MANIFEST.in +0 -0
- {freva_client-2505.0.0 → freva_client-2510.1.0}/src/freva_client/__main__.py +0 -0
- {freva_client-2505.0.0 → freva_client-2510.1.0}/src/freva_client/cli/__init__.py +0 -0
- {freva_client-2505.0.0 → freva_client-2510.1.0}/src/freva_client/cli/cli_app.py +0 -0
- {freva_client-2505.0.0 → freva_client-2510.1.0}/src/freva_client/cli/cli_parser.py +0 -0
- {freva_client-2505.0.0 → freva_client-2510.1.0}/src/freva_client/py.typed +0 -0
- {freva_client-2505.0.0 → freva_client-2510.1.0}/src/freva_client/utils/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: freva-client
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2510.1.0
|
|
4
4
|
Summary: Search for climate data based on key-value pairs
|
|
5
5
|
Author-email: "DKRZ, Clint" <freva@dkrz.de>
|
|
6
6
|
Requires-Python: >=3.8
|
|
@@ -19,7 +19,6 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.13
|
|
20
20
|
Requires-Dist: appdirs
|
|
21
21
|
Requires-Dist: pyyaml
|
|
22
|
-
Requires-Dist: authlib
|
|
23
22
|
Requires-Dist: requests
|
|
24
23
|
Requires-Dist: intake_esm
|
|
25
24
|
Requires-Dist: rich
|
|
@@ -27,18 +26,18 @@ Requires-Dist: setuptools
|
|
|
27
26
|
Requires-Dist: tomli
|
|
28
27
|
Requires-Dist: typer
|
|
29
28
|
Requires-Dist: tox ; extra == "dev"
|
|
30
|
-
Project-URL: Documentation, https://freva-
|
|
31
|
-
Project-URL: Issues, https://github.com/
|
|
32
|
-
Project-URL: Source, https://github.com/
|
|
29
|
+
Project-URL: Documentation, https://freva-org.github.io/freva-nextgen
|
|
30
|
+
Project-URL: Issues, https://github.com/freva-org/freva-nextgen/issues
|
|
31
|
+
Project-URL: Source, https://github.com/freva-org/freva-nextgen/
|
|
33
32
|
Provides-Extra: dev
|
|
34
33
|
|
|
35
34
|
# A REST API for the freva databrowser
|
|
36
35
|
|
|
37
36
|
[](LICENSE)
|
|
38
37
|
[](https://pypi.org/project/freva-client/)
|
|
39
|
-
[](https://freva-
|
|
40
|
-
[](https://freva-org.github.io/freva-nextgen)
|
|
39
|
+
[](https://github.com/freva-org/freva-nextgen/actions)
|
|
40
|
+
[](https://codecov.io/github/freva-org/freva-nextgen)
|
|
42
41
|
|
|
43
42
|
The freva-client library is a small library that makes connections to the freva
|
|
44
43
|
server. The client library currently supports the following services:
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[](LICENSE)
|
|
4
4
|
[](https://pypi.org/project/freva-client/)
|
|
5
|
-
[](https://freva-
|
|
6
|
-
[](https://freva-org.github.io/freva-nextgen)
|
|
6
|
+
[](https://github.com/freva-org/freva-nextgen/actions)
|
|
7
|
+
[](https://codecov.io/github/freva-org/freva-nextgen)
|
|
8
8
|
|
|
9
9
|
The freva-client library is a small library that makes connections to the freva
|
|
10
10
|
server. The client library currently supports the following services:
|
|
@@ -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"
|
|
@@ -25,7 +25,6 @@ requires-python = ">=3.8"
|
|
|
25
25
|
dependencies = [
|
|
26
26
|
"appdirs",
|
|
27
27
|
"pyyaml",
|
|
28
|
-
"authlib",
|
|
29
28
|
"requests",
|
|
30
29
|
"intake_esm",
|
|
31
30
|
"rich",
|
|
@@ -36,9 +35,9 @@ dependencies = [
|
|
|
36
35
|
[project.scripts]
|
|
37
36
|
freva-client = "freva_client:cli.app"
|
|
38
37
|
[project.urls]
|
|
39
|
-
Documentation = "https://freva-
|
|
40
|
-
Issues = "https://github.com/
|
|
41
|
-
Source = "https://github.com/
|
|
38
|
+
Documentation = "https://freva-org.github.io/freva-nextgen"
|
|
39
|
+
Issues = "https://github.com/freva-org/freva-nextgen/issues"
|
|
40
|
+
Source = "https://github.com/freva-org/freva-nextgen/"
|
|
42
41
|
|
|
43
42
|
[project.optional-dependencies]
|
|
44
43
|
dev = ["tox"]
|
|
@@ -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__ = "2510.1.0"
|
|
21
22
|
__all__ = ["authenticate", "databrowser", "__version__"]
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Module that handles the authentication at the rest service."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, Optional, Union
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .utils import logger
|
|
12
|
+
from .utils.auth_utils import (
|
|
13
|
+
AuthError,
|
|
14
|
+
CodeAuthClient,
|
|
15
|
+
DeviceAuthClient,
|
|
16
|
+
Token,
|
|
17
|
+
choose_token_strategy,
|
|
18
|
+
get_default_token_file,
|
|
19
|
+
is_interactive_auth_possible,
|
|
20
|
+
load_token,
|
|
21
|
+
)
|
|
22
|
+
from .utils.databrowser_utils import Config
|
|
23
|
+
|
|
24
|
+
AUTH_FAILED_MSG = """Login failed or no valid token found in this environment.
|
|
25
|
+
If you appear to be in a non-interactive or remote session create a new token
|
|
26
|
+
via:
|
|
27
|
+
|
|
28
|
+
{command}
|
|
29
|
+
|
|
30
|
+
Then pass the token file using:"
|
|
31
|
+
|
|
32
|
+
" {token_path} or set the $TOKEN_ENV_VAR env. variable."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Auth:
|
|
36
|
+
"""Helper class for authentication."""
|
|
37
|
+
|
|
38
|
+
_instance: Optional["Auth"] = None
|
|
39
|
+
_auth_token: Optional[Token] = None
|
|
40
|
+
|
|
41
|
+
def __new__(cls, *args: Any, **kwargs: Any) -> "Auth":
|
|
42
|
+
if cls._instance is None:
|
|
43
|
+
cls._instance = super().__new__(cls)
|
|
44
|
+
return cls._instance
|
|
45
|
+
|
|
46
|
+
def __init__(self, token_file: Optional[Union[str, Path]] = None) -> None:
|
|
47
|
+
self.token_file = str(token_file or "").strip() or None
|
|
48
|
+
|
|
49
|
+
def get_token(
|
|
50
|
+
self,
|
|
51
|
+
token_url: str,
|
|
52
|
+
data: Dict[str, str],
|
|
53
|
+
) -> Token:
|
|
54
|
+
try:
|
|
55
|
+
response = requests.post(token_url, data=data)
|
|
56
|
+
response.raise_for_status()
|
|
57
|
+
except requests.exceptions.RequestException as error:
|
|
58
|
+
raise AuthError(f"Fetching token failed: {error}")
|
|
59
|
+
auth = response.json()
|
|
60
|
+
return self.set_token(
|
|
61
|
+
access_token=auth["access_token"],
|
|
62
|
+
token_type=auth["token_type"],
|
|
63
|
+
expires=auth["expires"],
|
|
64
|
+
refresh_token=auth["refresh_token"],
|
|
65
|
+
refresh_expires=auth["refresh_expires"],
|
|
66
|
+
scope=auth["scope"],
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def _login(
|
|
70
|
+
self,
|
|
71
|
+
auth_url: str,
|
|
72
|
+
_timeout: Optional[int] = 30,
|
|
73
|
+
) -> Token:
|
|
74
|
+
device_endpoint = f"{auth_url}/device"
|
|
75
|
+
token_endpoint = f"{auth_url}/token"
|
|
76
|
+
device_client = DeviceAuthClient(
|
|
77
|
+
device_endpoint=device_endpoint,
|
|
78
|
+
token_endpoint=token_endpoint,
|
|
79
|
+
timeout=_timeout,
|
|
80
|
+
)
|
|
81
|
+
code_client = CodeAuthClient(
|
|
82
|
+
login_endpoint=f"{auth_url}/login",
|
|
83
|
+
token_endpoint=f"{auth_url}/token",
|
|
84
|
+
port_endpoint=f"{auth_url}/auth-ports",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
is_interactive_auth = int(
|
|
88
|
+
os.getenv("BROWSER_SESSION", str(int(is_interactive_auth_possible())))
|
|
89
|
+
)
|
|
90
|
+
try:
|
|
91
|
+
response = device_client.login(auto_open=bool(is_interactive_auth))
|
|
92
|
+
except AuthError as error:
|
|
93
|
+
if error.status_code == 503:
|
|
94
|
+
response = code_client.login()
|
|
95
|
+
else:
|
|
96
|
+
raise
|
|
97
|
+
return self.set_token(
|
|
98
|
+
access_token=response["access_token"],
|
|
99
|
+
token_type=response["token_type"],
|
|
100
|
+
expires=response["expires"],
|
|
101
|
+
refresh_token=response["refresh_token"],
|
|
102
|
+
refresh_expires=response["refresh_expires"],
|
|
103
|
+
scope=response["scope"],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def set_token(
|
|
107
|
+
self,
|
|
108
|
+
access_token: str,
|
|
109
|
+
refresh_token: Optional[str] = None,
|
|
110
|
+
expires_in: int = 10,
|
|
111
|
+
refresh_expires_in: int = 10,
|
|
112
|
+
expires: Optional[Union[float, int]] = None,
|
|
113
|
+
refresh_expires: Optional[Union[float, int]] = None,
|
|
114
|
+
token_type: str = "Bearer",
|
|
115
|
+
scope: str = "profile email address",
|
|
116
|
+
) -> Token:
|
|
117
|
+
"""Override the existing auth token."""
|
|
118
|
+
now = datetime.datetime.now(datetime.timezone.utc).timestamp()
|
|
119
|
+
|
|
120
|
+
self._auth_token = Token(
|
|
121
|
+
access_token=access_token or "",
|
|
122
|
+
refresh_token=refresh_token or "",
|
|
123
|
+
token_type=token_type,
|
|
124
|
+
expires=int(expires or now + expires_in),
|
|
125
|
+
refresh_expires=int(refresh_expires or now + refresh_expires_in),
|
|
126
|
+
scope=scope,
|
|
127
|
+
)
|
|
128
|
+
default_token_file = get_default_token_file()
|
|
129
|
+
for _file in map(
|
|
130
|
+
Path, set((self.token_file or default_token_file, default_token_file))
|
|
131
|
+
):
|
|
132
|
+
_file.parent.mkdir(exist_ok=True, parents=True)
|
|
133
|
+
_file.write_text(json.dumps(self._auth_token))
|
|
134
|
+
_file.chmod(0o600)
|
|
135
|
+
|
|
136
|
+
return self._auth_token
|
|
137
|
+
|
|
138
|
+
def _refresh(
|
|
139
|
+
self, url: str, refresh_token: str, timeout: Optional[int] = 30
|
|
140
|
+
) -> Token:
|
|
141
|
+
"""Refresh the access_token with a refresh token."""
|
|
142
|
+
try:
|
|
143
|
+
return self.get_token(
|
|
144
|
+
f"{url}/token",
|
|
145
|
+
data={"refresh-token": refresh_token or ""},
|
|
146
|
+
)
|
|
147
|
+
except (AuthError, KeyError) as error:
|
|
148
|
+
logger.warning("Failed to refresh token: %s", error)
|
|
149
|
+
return self._login(url, _timeout=timeout)
|
|
150
|
+
|
|
151
|
+
def authenticate(
|
|
152
|
+
self,
|
|
153
|
+
host: Optional[str] = None,
|
|
154
|
+
config: Optional[Config] = None,
|
|
155
|
+
*,
|
|
156
|
+
force: bool = False,
|
|
157
|
+
timeout: Optional[int] = 30,
|
|
158
|
+
_cli: bool = False,
|
|
159
|
+
) -> Token:
|
|
160
|
+
"""Authenticate the user to the host."""
|
|
161
|
+
cfg = config or Config(host)
|
|
162
|
+
token = self._auth_token or load_token(self.token_file)
|
|
163
|
+
reason: Optional[str] = None
|
|
164
|
+
if force:
|
|
165
|
+
strategy = "browser_auth"
|
|
166
|
+
else:
|
|
167
|
+
strategy = choose_token_strategy(token)
|
|
168
|
+
if strategy == "use_token" and token:
|
|
169
|
+
self._auth_token = token
|
|
170
|
+
return self._auth_token
|
|
171
|
+
try:
|
|
172
|
+
if strategy == "refresh_token" and token:
|
|
173
|
+
return self._refresh(
|
|
174
|
+
cfg.auth_url, token["refresh_token"], timeout=timeout
|
|
175
|
+
)
|
|
176
|
+
if strategy == "browser_auth":
|
|
177
|
+
return self._login(cfg.auth_url, _timeout=timeout)
|
|
178
|
+
except AuthError as error:
|
|
179
|
+
reason = str(error)
|
|
180
|
+
|
|
181
|
+
command, token_path = {
|
|
182
|
+
True: ("freva-client auth", "--token-file /path/to/token.json"),
|
|
183
|
+
False: (
|
|
184
|
+
"freva_client.auth",
|
|
185
|
+
"`token_file='/path/to/token.json'`",
|
|
186
|
+
),
|
|
187
|
+
}[_cli]
|
|
188
|
+
|
|
189
|
+
reason = reason or AUTH_FAILED_MSG.format(
|
|
190
|
+
command=command, token_path=token_path
|
|
191
|
+
)
|
|
192
|
+
if _cli:
|
|
193
|
+
logger.critical(reason)
|
|
194
|
+
raise SystemExit(1)
|
|
195
|
+
else:
|
|
196
|
+
raise AuthError(reason)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def authenticate(
|
|
200
|
+
*,
|
|
201
|
+
token_file: Optional[Union[Path, str]] = None,
|
|
202
|
+
host: Optional[str] = None,
|
|
203
|
+
force: bool = False,
|
|
204
|
+
timeout: Optional[int] = 30,
|
|
205
|
+
) -> Token:
|
|
206
|
+
"""Authenticate to the host.
|
|
207
|
+
|
|
208
|
+
This method generates a new access token that should be used for restricted methods.
|
|
209
|
+
|
|
210
|
+
Parameters
|
|
211
|
+
----------
|
|
212
|
+
token_file: str, optional
|
|
213
|
+
Instead of setting a password, you can set a refresh token to refresh
|
|
214
|
+
the access token. This is recommended for non-interactive environments.
|
|
215
|
+
host: str, optional
|
|
216
|
+
The hostname of the REST server.
|
|
217
|
+
force: bool, default: False
|
|
218
|
+
Force token recreation, even if current token is still valid.
|
|
219
|
+
timeout: int, default: 30
|
|
220
|
+
Set the timeout, None for indefinate.
|
|
221
|
+
|
|
222
|
+
Returns
|
|
223
|
+
-------
|
|
224
|
+
Token: The authentication token.
|
|
225
|
+
|
|
226
|
+
Examples
|
|
227
|
+
--------
|
|
228
|
+
Interactive authentication:
|
|
229
|
+
|
|
230
|
+
.. code-block:: python
|
|
231
|
+
|
|
232
|
+
from freva_client import authenticate
|
|
233
|
+
token = authenticate(timeout=120)
|
|
234
|
+
print(token)
|
|
235
|
+
|
|
236
|
+
Batch mode authentication with a refresh token:
|
|
237
|
+
|
|
238
|
+
.. code-block:: python
|
|
239
|
+
|
|
240
|
+
from freva_client import authenticate
|
|
241
|
+
token = authenticate(token_file="~/.freva-login-token.json")
|
|
242
|
+
"""
|
|
243
|
+
auth = Auth(token_file=token_file or None)
|
|
244
|
+
return auth.authenticate(
|
|
245
|
+
host=host,
|
|
246
|
+
force=force,
|
|
247
|
+
timeout=timeout,
|
|
248
|
+
)
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
"""Command line interface for authentication."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
|
|
4
|
+
import os
|
|
5
5
|
from typing import Optional
|
|
6
6
|
|
|
7
7
|
import typer
|
|
8
8
|
|
|
9
|
-
from freva_client import
|
|
9
|
+
from freva_client.auth import Auth
|
|
10
10
|
from freva_client.utils import exception_handler, logger
|
|
11
|
+
from freva_client.utils.auth_utils import TOKEN_ENV_VAR, get_default_token_file
|
|
11
12
|
|
|
12
13
|
from .cli_utils import version_callback
|
|
13
14
|
|
|
@@ -28,20 +29,13 @@ def authenticate_cli(
|
|
|
28
29
|
"the hostname is read from a config file"
|
|
29
30
|
),
|
|
30
31
|
),
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"--
|
|
34
|
-
"-u",
|
|
35
|
-
help="The username used for authentication.",
|
|
36
|
-
),
|
|
37
|
-
refresh_token: Optional[str] = typer.Option(
|
|
38
|
-
None,
|
|
39
|
-
"--refresh-token",
|
|
40
|
-
"-r",
|
|
32
|
+
token_file: str = typer.Option(
|
|
33
|
+
os.getenv(TOKEN_ENV_VAR, "").strip(),
|
|
34
|
+
"--token-file",
|
|
41
35
|
help=(
|
|
42
|
-
"Instead of
|
|
43
|
-
"
|
|
44
|
-
"
|
|
36
|
+
"Instead of authenticating via code based authentication flow "
|
|
37
|
+
"you can set the path to the json file that contains a "
|
|
38
|
+
"`refresh token` containing a refresh_token key."
|
|
45
39
|
),
|
|
46
40
|
),
|
|
47
41
|
force: bool = typer.Option(
|
|
@@ -50,6 +44,11 @@ def authenticate_cli(
|
|
|
50
44
|
"-f",
|
|
51
45
|
help="Force token recreation, even if current token is still valid.",
|
|
52
46
|
),
|
|
47
|
+
timeout: int = typer.Option(
|
|
48
|
+
30,
|
|
49
|
+
"--timeout",
|
|
50
|
+
help="Set the timeout for login in secdonds, 0 for indefinate",
|
|
51
|
+
),
|
|
53
52
|
verbose: int = typer.Option(0, "-v", help="Increase verbosity", count=True),
|
|
54
53
|
version: Optional[bool] = typer.Option(
|
|
55
54
|
False,
|
|
@@ -61,10 +60,10 @@ def authenticate_cli(
|
|
|
61
60
|
) -> None:
|
|
62
61
|
"""Create OAuth2 access and refresh token."""
|
|
63
62
|
logger.set_verbosity(verbose)
|
|
64
|
-
|
|
63
|
+
token = Auth(token_file=token_file or get_default_token_file()).authenticate(
|
|
65
64
|
host=host,
|
|
66
|
-
username=username,
|
|
67
|
-
refresh_token=refresh_token,
|
|
68
65
|
force=force,
|
|
66
|
+
_cli=True,
|
|
67
|
+
timeout=timeout,
|
|
69
68
|
)
|
|
70
|
-
print(json.dumps(
|
|
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)
|