freva-client 2404.0.1__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.
Files changed (29) hide show
  1. {freva_client-2404.0.1 → freva_client-2510.1.0}/PKG-INFO +12 -9
  2. {freva_client-2404.0.1 → freva_client-2510.1.0}/README.md +3 -3
  3. {freva_client-2404.0.1 → freva_client-2510.1.0}/assets/share/freva/freva.toml +5 -0
  4. {freva_client-2404.0.1 → freva_client-2510.1.0}/pyproject.toml +8 -5
  5. {freva_client-2404.0.1 → freva_client-2510.1.0}/src/freva_client/__init__.py +4 -2
  6. freva_client-2510.1.0/src/freva_client/auth.py +248 -0
  7. freva_client-2510.1.0/src/freva_client/cli/auth_cli.py +69 -0
  8. freva_client-2510.1.0/src/freva_client/cli/cli_app.py +40 -0
  9. freva_client-2404.0.1/src/freva_client/cli/cli_utils.py → freva_client-2510.1.0/src/freva_client/cli/cli_parser.py +5 -16
  10. freva_client-2510.1.0/src/freva_client/cli/cli_utils.py +69 -0
  11. freva_client-2510.1.0/src/freva_client/cli/databrowser_cli.py +1215 -0
  12. freva_client-2510.1.0/src/freva_client/query.py +1362 -0
  13. {freva_client-2404.0.1 → freva_client-2510.1.0}/src/freva_client/utils/__init__.py +1 -1
  14. freva_client-2510.1.0/src/freva_client/utils/auth_utils.py +680 -0
  15. freva_client-2510.1.0/src/freva_client/utils/databrowser_utils.py +514 -0
  16. {freva_client-2404.0.1 → freva_client-2510.1.0}/src/freva_client/utils/logger.py +3 -3
  17. freva_client-2404.0.1/src/freva_client/cli/cli_app.py +0 -22
  18. freva_client-2404.0.1/src/freva_client/cli/databrowser_cli.py +0 -454
  19. freva_client-2404.0.1/src/freva_client/query.py +0 -616
  20. freva_client-2404.0.1/src/freva_client/utils/databrowser_utils.py +0 -162
  21. freva_client-2404.0.1/src/tests/__init__.py +0 -0
  22. freva_client-2404.0.1/src/tests/conftest.py +0 -90
  23. freva_client-2404.0.1/src/tests/test_cli.py +0 -136
  24. freva_client-2404.0.1/src/tests/test_databrowser.py +0 -116
  25. freva_client-2404.0.1/src/tests/test_url.py +0 -63
  26. {freva_client-2404.0.1 → freva_client-2510.1.0}/MANIFEST.in +0 -0
  27. {freva_client-2404.0.1 → freva_client-2510.1.0}/src/freva_client/__main__.py +0 -0
  28. {freva_client-2404.0.1 → freva_client-2510.1.0}/src/freva_client/cli/__init__.py +0 -0
  29. {freva_client-2404.0.1 → freva_client-2510.1.0}/src/freva_client/py.typed +0 -0
@@ -1,11 +1,11 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: freva-client
3
- Version: 2404.0.1
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
7
7
  Description-Content-Type: text/markdown
8
- Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Development Status :: 4 - Beta
9
9
  Classifier: Environment :: Console
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: Intended Audience :: Science/Research
@@ -16,25 +16,28 @@ Classifier: Programming Language :: Python :: 3.9
16
16
  Classifier: Programming Language :: Python :: 3.10
17
17
  Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
19
20
  Requires-Dist: appdirs
20
21
  Requires-Dist: pyyaml
21
22
  Requires-Dist: requests
23
+ Requires-Dist: intake_esm
22
24
  Requires-Dist: rich
25
+ Requires-Dist: setuptools
23
26
  Requires-Dist: tomli
24
27
  Requires-Dist: typer
25
28
  Requires-Dist: tox ; extra == "dev"
26
- Project-URL: Documentation, https://freva-clint.github.io/freva-nextgen
27
- Project-URL: Issues, https://github.com/FREVA-CLINT/freva-nextgen/issues
28
- Project-URL: Source, https://github.com/FREVA-CLINT/freva-nextgen/
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/
29
32
  Provides-Extra: dev
30
33
 
31
34
  # A REST API for the freva databrowser
32
35
 
33
36
  [![License](https://img.shields.io/badge/License-BSD-purple.svg)](LICENSE)
34
37
  [![PyPI](https://img.shields.io/pypi/pyversions/freva-client.svg)](https://pypi.org/project/freva-client/)
35
- [![Docs](https://img.shields.io/badge/API-Doc-green.svg)](https://freva-clint.github.io/freva-nextgen)
36
- [![Tests](https://github.com/FREVA-CLINT/freva-nextgen/actions/workflows/ci_job.yml/badge.svg)](https://github.com/FREVA-CLINT/freva-nextgen/actions)
37
- [![Test-Coverage](https://codecov.io/github/FREVA-CLINT/freva-nextgen/branch/init/graph/badge.svg?token=dGhXxh7uP3)](https://codecov.io/github/FREVA-CLINT/freva-nextgen)
38
+ [![Docs](https://img.shields.io/badge/API-Doc-green.svg)](https://freva-org.github.io/freva-nextgen)
39
+ [![Tests](https://github.com/freva-org/freva-nextgen/actions/workflows/ci_job.yml/badge.svg)](https://github.com/freva-org/freva-nextgen/actions)
40
+ [![Test-Coverage](https://codecov.io/github/freva-org/freva-nextgen/branch/init/graph/badge.svg?token=dGhXxh7uP3)](https://codecov.io/github/freva-org/freva-nextgen)
38
41
 
39
42
  The freva-client library is a small library that makes connections to the freva
40
43
  server. The client library currently supports the following services:
@@ -2,9 +2,9 @@
2
2
 
3
3
  [![License](https://img.shields.io/badge/License-BSD-purple.svg)](LICENSE)
4
4
  [![PyPI](https://img.shields.io/pypi/pyversions/freva-client.svg)](https://pypi.org/project/freva-client/)
5
- [![Docs](https://img.shields.io/badge/API-Doc-green.svg)](https://freva-clint.github.io/freva-nextgen)
6
- [![Tests](https://github.com/FREVA-CLINT/freva-nextgen/actions/workflows/ci_job.yml/badge.svg)](https://github.com/FREVA-CLINT/freva-nextgen/actions)
7
- [![Test-Coverage](https://codecov.io/github/FREVA-CLINT/freva-nextgen/branch/init/graph/badge.svg?token=dGhXxh7uP3)](https://codecov.io/github/FREVA-CLINT/freva-nextgen)
5
+ [![Docs](https://img.shields.io/badge/API-Doc-green.svg)](https://freva-org.github.io/freva-nextgen)
6
+ [![Tests](https://github.com/freva-org/freva-nextgen/actions/workflows/ci_job.yml/badge.svg)](https://github.com/freva-org/freva-nextgen/actions)
7
+ [![Test-Coverage](https://codecov.io/github/freva-org/freva-nextgen/branch/init/graph/badge.svg?token=dGhXxh7uP3)](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"
@@ -8,7 +8,7 @@ description = "Search for climate data based on key-value pairs"
8
8
  authors = [{name = "DKRZ, Clint", email = "freva@dkrz.de"}]
9
9
  readme = "README.md"
10
10
  classifiers = [
11
- "Development Status :: 3 - Alpha",
11
+ "Development Status :: 4 - Beta",
12
12
  "Environment :: Console",
13
13
  "Intended Audience :: Developers",
14
14
  "Intended Audience :: Science/Research",
@@ -19,22 +19,25 @@ classifiers = [
19
19
  "Programming Language :: Python :: 3.10",
20
20
  "Programming Language :: Python :: 3.11",
21
21
  "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
22
23
  ]
23
24
  requires-python = ">=3.8"
24
25
  dependencies = [
25
26
  "appdirs",
26
27
  "pyyaml",
27
28
  "requests",
29
+ "intake_esm",
28
30
  "rich",
31
+ "setuptools",
29
32
  "tomli",
30
33
  "typer",
31
34
  ]
32
35
  [project.scripts]
33
36
  freva-client = "freva_client:cli.app"
34
37
  [project.urls]
35
- Documentation = "https://freva-clint.github.io/freva-nextgen"
36
- Issues = "https://github.com/FREVA-CLINT/freva-nextgen/issues"
37
- Source = "https://github.com/FREVA-CLINT/freva-nextgen/"
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/"
38
41
 
39
42
  [project.optional-dependencies]
40
43
  dev = ["tox"]
@@ -45,4 +48,4 @@ include = ["assets/*"]
45
48
  [tool.flit.external-data]
46
49
  directory = "assets"
47
50
  [package-data]
48
- freva_deployment = ["py.typed"]
51
+ freva_client = ["py.typed"]
@@ -12,9 +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
 
18
+ from .auth import authenticate
17
19
  from .query import databrowser
18
20
 
19
- __version__ = "2404.0.1"
20
- __all__ = ["databrowser", "__version__"]
21
+ __version__ = "2510.1.0"
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
+ )
@@ -0,0 +1,69 @@
1
+ """Command line interface for authentication."""
2
+
3
+ import json
4
+ import os
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from freva_client.auth import Auth
10
+ from freva_client.utils import exception_handler, logger
11
+ from freva_client.utils.auth_utils import TOKEN_ENV_VAR, get_default_token_file
12
+
13
+ from .cli_utils import version_callback
14
+
15
+ auth_app = typer.Typer(
16
+ name="auth",
17
+ help="Create OAuth2 access and refresh token.",
18
+ pretty_exceptions_short=False,
19
+ )
20
+
21
+
22
+ @exception_handler
23
+ def authenticate_cli(
24
+ host: Optional[str] = typer.Option(
25
+ None,
26
+ "--host",
27
+ help=(
28
+ "Set the hostname of the databrowser, if not set (default) "
29
+ "the hostname is read from a config file"
30
+ ),
31
+ ),
32
+ token_file: str = typer.Option(
33
+ os.getenv(TOKEN_ENV_VAR, "").strip(),
34
+ "--token-file",
35
+ help=(
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."
39
+ ),
40
+ ),
41
+ force: bool = typer.Option(
42
+ False,
43
+ "--force",
44
+ "-f",
45
+ help="Force token recreation, even if current token is still valid.",
46
+ ),
47
+ timeout: int = typer.Option(
48
+ 30,
49
+ "--timeout",
50
+ help="Set the timeout for login in secdonds, 0 for indefinate",
51
+ ),
52
+ verbose: int = typer.Option(0, "-v", help="Increase verbosity", count=True),
53
+ version: Optional[bool] = typer.Option(
54
+ False,
55
+ "-V",
56
+ "--version",
57
+ help="Show version an exit",
58
+ callback=version_callback,
59
+ ),
60
+ ) -> None:
61
+ """Create OAuth2 access and refresh token."""
62
+ logger.set_verbosity(verbose)
63
+ token = Auth(token_file=token_file or get_default_token_file()).authenticate(
64
+ host=host,
65
+ force=force,
66
+ _cli=True,
67
+ timeout=timeout,
68
+ )
69
+ print(json.dumps(token, indent=3))
@@ -0,0 +1,40 @@
1
+ """Freva the Free Evaluation System command line interface."""
2
+
3
+ import os
4
+ from typing import Optional
5
+
6
+ import typer
7
+
8
+ from freva_client.utils import logger
9
+
10
+ from .auth_cli import authenticate_cli
11
+ from .cli_utils import APP_NAME, version_callback
12
+ from .databrowser_cli import databrowser_app
13
+
14
+ if os.getenv("FREVA_NO_RICH_PANELS", "0") == "1":
15
+ typer.core.rich = None # type: ignore
16
+
17
+ app = typer.Typer(
18
+ name=APP_NAME,
19
+ help=__doc__,
20
+ add_completion=True,
21
+ callback=logger.set_cli,
22
+ )
23
+
24
+
25
+ @app.callback()
26
+ def main(
27
+ version: Optional[bool] = typer.Option(
28
+ None,
29
+ "--version",
30
+ "-V",
31
+ help="Show version and exit",
32
+ callback=version_callback,
33
+ is_eager=True,
34
+ ),
35
+ ) -> None:
36
+ """The main cli app."""
37
+
38
+
39
+ app.add_typer(databrowser_app, name="databrowser")
40
+ app.command(name="auth", help=authenticate_cli.__doc__)(authenticate_cli)
@@ -1,4 +1,4 @@
1
- """Utilities for the command line interface."""
1
+ """Command line argument completion definitions."""
2
2
 
3
3
  import argparse
4
4
  import inspect
@@ -6,21 +6,8 @@ from typing import Any, Callable, Dict, List, Optional, Tuple, cast
6
6
 
7
7
  from freva_client import databrowser
8
8
  from freva_client.cli.cli_app import app
9
- from freva_client.utils import logger
10
9
 
11
-
12
- def parse_cli_args(cli_args: List[str]) -> Dict[str, List[str]]:
13
- """Convert the cli arguments to a dictionary."""
14
- logger.debug("parsing command line arguments.")
15
- kwargs = {}
16
- for entry in cli_args:
17
- key, _, value = entry.partition("=")
18
- if value and key not in kwargs:
19
- kwargs[key] = [value]
20
- elif value:
21
- kwargs[key].append(value)
22
- logger.debug(kwargs)
23
- return kwargs
10
+ from .cli_utils import parse_cli_args
24
11
 
25
12
 
26
13
  class Completer:
@@ -82,6 +69,8 @@ class Completer:
82
69
  time=None,
83
70
  host=None,
84
71
  time_select="flexible",
72
+ bbox=None,
73
+ bbox_select="flexible",
85
74
  multiversion=False,
86
75
  extended_search=True,
87
76
  fail_on_error=False,
@@ -103,7 +92,7 @@ class Completer:
103
92
  choices = self._get_choices()
104
93
  return {**self.choices, **choices}
105
94
 
106
- def formated_print(self) -> None:
95
+ def formatted_print(self) -> None:
107
96
  """Print all choices to be processed by the shell completion function."""
108
97
 
109
98
  out = self.get_print(self.command_choices)
@@ -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)