freva-client 2404.0.0__tar.gz → 2408.0.0.dev1__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.

Files changed (25) hide show
  1. {freva_client-2404.0.0 → freva_client-2408.0.0.dev1}/PKG-INFO +4 -2
  2. {freva_client-2404.0.0 → freva_client-2408.0.0.dev1}/README.md +1 -1
  3. {freva_client-2404.0.0 → freva_client-2408.0.0.dev1}/pyproject.toml +2 -0
  4. {freva_client-2404.0.0 → freva_client-2408.0.0.dev1}/src/freva_client/__init__.py +3 -2
  5. freva_client-2408.0.0.dev1/src/freva_client/auth.py +197 -0
  6. freva_client-2408.0.0.dev1/src/freva_client/cli/auth_cli.py +71 -0
  7. freva_client-2408.0.0.dev1/src/freva_client/cli/cli_app.py +35 -0
  8. freva_client-2404.0.0/src/freva_client/cli/cli_utils.py → freva_client-2408.0.0.dev1/src/freva_client/cli/cli_parser.py +3 -16
  9. freva_client-2408.0.0.dev1/src/freva_client/cli/cli_utils.py +31 -0
  10. {freva_client-2404.0.0 → freva_client-2408.0.0.dev1}/src/freva_client/cli/databrowser_cli.py +177 -9
  11. {freva_client-2404.0.0 → freva_client-2408.0.0.dev1}/src/freva_client/query.py +166 -23
  12. {freva_client-2404.0.0 → freva_client-2408.0.0.dev1}/src/freva_client/utils/__init__.py +1 -1
  13. {freva_client-2404.0.0 → freva_client-2408.0.0.dev1}/src/freva_client/utils/databrowser_utils.py +26 -9
  14. {freva_client-2404.0.0 → freva_client-2408.0.0.dev1}/src/freva_client/utils/logger.py +2 -2
  15. freva_client-2404.0.0/src/freva_client/cli/cli_app.py +0 -22
  16. freva_client-2404.0.0/src/tests/__init__.py +0 -0
  17. freva_client-2404.0.0/src/tests/conftest.py +0 -90
  18. freva_client-2404.0.0/src/tests/test_cli.py +0 -136
  19. freva_client-2404.0.0/src/tests/test_databrowser.py +0 -116
  20. freva_client-2404.0.0/src/tests/test_url.py +0 -63
  21. {freva_client-2404.0.0 → freva_client-2408.0.0.dev1}/MANIFEST.in +0 -0
  22. {freva_client-2404.0.0 → freva_client-2408.0.0.dev1}/assets/share/freva/freva.toml +0 -0
  23. {freva_client-2404.0.0 → freva_client-2408.0.0.dev1}/src/freva_client/__main__.py +0 -0
  24. {freva_client-2404.0.0 → freva_client-2408.0.0.dev1}/src/freva_client/cli/__init__.py +0 -0
  25. {freva_client-2404.0.0 → freva_client-2408.0.0.dev1}/src/freva_client/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: freva-client
3
- Version: 2404.0.0
3
+ Version: 2408.0.0.dev1
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
@@ -18,7 +18,9 @@ Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Requires-Dist: appdirs
20
20
  Requires-Dist: pyyaml
21
+ Requires-Dist: authlib
21
22
  Requires-Dist: requests
23
+ Requires-Dist: intake_esm
22
24
  Requires-Dist: rich
23
25
  Requires-Dist: tomli
24
26
  Requires-Dist: typer
@@ -31,7 +33,7 @@ Provides-Extra: dev
31
33
  # A REST API for the freva databrowser
32
34
 
33
35
  [![License](https://img.shields.io/badge/License-BSD-purple.svg)](LICENSE)
34
- [![Python](https://img.shields.io/badge/python-3.12-red.svg)](https://www.python.org/downloads/release/python-312/)
36
+ [![PyPI](https://img.shields.io/pypi/pyversions/freva-client.svg)](https://pypi.org/project/freva-client/)
35
37
  [![Docs](https://img.shields.io/badge/API-Doc-green.svg)](https://freva-clint.github.io/freva-nextgen)
36
38
  [![Tests](https://github.com/FREVA-CLINT/freva-nextgen/actions/workflows/ci_job.yml/badge.svg)](https://github.com/FREVA-CLINT/freva-nextgen/actions)
37
39
  [![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)
@@ -1,7 +1,7 @@
1
1
  # A REST API for the freva databrowser
2
2
 
3
3
  [![License](https://img.shields.io/badge/License-BSD-purple.svg)](LICENSE)
4
- [![Python](https://img.shields.io/badge/python-3.12-red.svg)](https://www.python.org/downloads/release/python-312/)
4
+ [![PyPI](https://img.shields.io/pypi/pyversions/freva-client.svg)](https://pypi.org/project/freva-client/)
5
5
  [![Docs](https://img.shields.io/badge/API-Doc-green.svg)](https://freva-clint.github.io/freva-nextgen)
6
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
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)
@@ -24,7 +24,9 @@ requires-python = ">=3.8"
24
24
  dependencies = [
25
25
  "appdirs",
26
26
  "pyyaml",
27
+ "authlib",
27
28
  "requests",
29
+ "intake_esm",
28
30
  "rich",
29
31
  "tomli",
30
32
  "typer",
@@ -14,7 +14,8 @@ library described in the documentation only support searching for data. If you
14
14
  need to apply data analysis plugins, please visit the
15
15
  """
16
16
 
17
+ from .auth import authenticate
17
18
  from .query import databrowser
18
19
 
19
- __version__ = "2404.0.0"
20
- __all__ = ["databrowser", "__version__"]
20
+ __version__ = "2408.0.0.dev1"
21
+ __all__ = ["authenticate", "databrowser", "__version__"]
@@ -0,0 +1,197 @@
1
+ """Module that handles the authentication at the rest service."""
2
+
3
+ from datetime import datetime
4
+ from getpass import getpass, getuser
5
+ from typing import Optional, TypedDict
6
+
7
+ from authlib.integrations.requests_client import OAuth2Session
8
+
9
+ from .utils import logger
10
+ from .utils.databrowser_utils import Config
11
+
12
+ Token = TypedDict(
13
+ "Token",
14
+ {
15
+ "access_token": str,
16
+ "token_type": str,
17
+ "expires": float,
18
+ "refresh_token": str,
19
+ "refresh_expires": float,
20
+ },
21
+ )
22
+
23
+
24
+ class Auth:
25
+ """Helper class for authentication."""
26
+
27
+ _instance: Optional["Auth"] = None
28
+ _auth_token: Optional[Token] = None
29
+
30
+ def __new__(cls) -> "Auth":
31
+ if cls._instance is None:
32
+ cls._instance = super().__new__(cls)
33
+ return cls._instance
34
+
35
+ def __init__(self) -> None:
36
+ self._auth_cls = OAuth2Session()
37
+
38
+ @property
39
+ def token_expiration_time(self) -> datetime:
40
+ """Get the expiration time of an access token."""
41
+ if self._auth_token is None:
42
+ exp = 0.0
43
+ else:
44
+ exp = self._auth_token["expires"]
45
+ return datetime.fromtimestamp(exp)
46
+
47
+ def set_token(
48
+ self,
49
+ access_token: str,
50
+ refresh_token: Optional[str] = None,
51
+ expires_in: int = 10,
52
+ refresh_expires_in: int = 10,
53
+ expires: Optional[float] = None,
54
+ refresh_expires: Optional[float] = None,
55
+ token_type: str = "Bearer",
56
+ ) -> Token:
57
+ """Override the existing auth token."""
58
+ now = datetime.now().timestamp()
59
+
60
+ self._auth_token = Token(
61
+ access_token=access_token or "",
62
+ refresh_token=refresh_token or "",
63
+ token_type=token_type,
64
+ expires=expires or now + expires_in,
65
+ refresh_expires=refresh_expires or now + refresh_expires_in,
66
+ )
67
+ return self._auth_token
68
+
69
+ def _refresh(
70
+ self, url: str, refresh_token: str, username: Optional[str] = None
71
+ ) -> Token:
72
+ """Refresh the access_token with a refresh token."""
73
+ auth = self._auth_cls.refresh_token(f"{url}/token", refresh_token or " ")
74
+ try:
75
+ return self.set_token(
76
+ access_token=auth["access_token"],
77
+ token_type=auth["token_type"],
78
+ expires_in=auth["expires_in"],
79
+ refresh_token=auth["refresh_token"],
80
+ refresh_expires_in=auth["refresh_expires_in"],
81
+ )
82
+ except KeyError:
83
+ logger.warning("Failed to refresh token: %s", auth.get("detail", ""))
84
+ if username:
85
+ return self._login_with_password(url, username)
86
+ raise ValueError("Could not use refresh token") from None
87
+
88
+ def check_authentication(self, auth_url: Optional[str] = None) -> Token:
89
+ """Check the status of the authentication.
90
+
91
+ Raises
92
+ ------
93
+ ValueError: If user isn't or is no longer authenticated.
94
+ """
95
+ if not self._auth_token:
96
+ raise ValueError("You must authenticate first.")
97
+ now = datetime.now().timestamp()
98
+ if now > self._auth_token["refresh_expires"]:
99
+ raise ValueError("Refresh token has expired.")
100
+ if now > self._auth_token["expires"] and auth_url:
101
+ self._refresh(auth_url, self._auth_token["refresh_token"])
102
+ return self._auth_token
103
+
104
+ def _login_with_password(self, auth_url: str, username: str) -> Token:
105
+ """Create a new token."""
106
+ pw_msg = "Give password for server authentication: "
107
+ auth = self._auth_cls.fetch_token(
108
+ f"{auth_url}/token", username=username, password=getpass(pw_msg)
109
+ )
110
+ try:
111
+ return self.set_token(
112
+ access_token=auth["access_token"],
113
+ token_type=auth["token_type"],
114
+ expires_in=auth["expires_in"],
115
+ refresh_token=auth["refresh_token"],
116
+ refresh_expires_in=auth["refresh_expires_in"],
117
+ )
118
+ except KeyError:
119
+ logger.error("Failed to authenticate: %s", auth.get("detail", ""))
120
+ raise ValueError("Token creation failed") from None
121
+
122
+ def authenticate(
123
+ self,
124
+ host: Optional[str] = None,
125
+ refresh_token: Optional[str] = None,
126
+ username: Optional[str] = None,
127
+ force: bool = False,
128
+ ) -> Token:
129
+ """Authenticate the user to the host."""
130
+ cfg = Config(host)
131
+ if refresh_token:
132
+ try:
133
+ return self._refresh(cfg.auth_url, refresh_token)
134
+ except ValueError:
135
+ logger.warning(
136
+ (
137
+ "Could not use refresh token, falling back "
138
+ "to username/password"
139
+ )
140
+ )
141
+ username = username or getuser()
142
+ if self._auth_token is None or force:
143
+ return self._login_with_password(cfg.auth_url, username)
144
+ if self.token_expiration_time < datetime.now():
145
+ self._refresh(cfg.auth_url, self._auth_token["refresh_token"], username)
146
+ return self._auth_token
147
+
148
+
149
+ def authenticate(
150
+ *,
151
+ refresh_token: Optional[str] = None,
152
+ username: Optional[str] = None,
153
+ host: Optional[str] = None,
154
+ force: bool = False,
155
+ ) -> Token:
156
+ """Authenticate to the host.
157
+
158
+ This method generates a new access token that should be used for restricted methods.
159
+
160
+ Parameters
161
+ ----------
162
+ refresh_token: str, optional
163
+ Instead of setting a password, you can set a refresh token to refresh
164
+ the access token. This is recommended for non-interactive environments.
165
+ username: str, optional
166
+ The username used for authentication. By default, the current
167
+ system username is used.
168
+ host: str, optional
169
+ The hostname of the REST server.
170
+ force: bool, default: False
171
+ Force token recreation, even if current token is still valid.
172
+
173
+ Returns
174
+ -------
175
+ Token: The authentication token.
176
+
177
+ Examples
178
+ --------
179
+ Interactive authentication:
180
+
181
+ .. code-block:: python
182
+
183
+ from freva_client import authenticate
184
+ token = authenticate(username="janedoe")
185
+ print(token)
186
+
187
+ Batch mode authentication with a refresh token:
188
+
189
+ .. code-block:: python
190
+
191
+ from freva_client import authenticate
192
+ token = authenticate(refresh_token="MYTOKEN")
193
+ """
194
+ auth = Auth()
195
+ return auth.authenticate(
196
+ host=host, username=username, refresh_token=refresh_token, force=force
197
+ )
@@ -0,0 +1,71 @@
1
+ """Command line interface for authentication."""
2
+
3
+ import json
4
+ from getpass import getuser
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from freva_client import authenticate
9
+ from freva_client.utils import exception_handler, logger
10
+
11
+ from .cli_utils import version_callback
12
+
13
+ auth_app = typer.Typer(
14
+ name="auth",
15
+ help="Create OAuth2 access and refresh token.",
16
+ pretty_exceptions_short=False,
17
+ )
18
+
19
+
20
+ @exception_handler
21
+ def authenticate_cli(
22
+ host: Optional[str] = typer.Option(
23
+ None,
24
+ "--host",
25
+ help=(
26
+ "Set the hostname of the databrowser, if not set (default) "
27
+ "the hostname is read from a config file"
28
+ ),
29
+ ),
30
+ username: str = typer.Option(
31
+ getuser(),
32
+ "--username",
33
+ "-u",
34
+ help="The username used for authentication.",
35
+ ),
36
+ refresh_token: Optional[str] = typer.Option(
37
+ None,
38
+ "--refresh-token",
39
+ "-r",
40
+ help=(
41
+ "Instead of using a password, you can use a refresh token. "
42
+ "refresh the access token. This is recommended for non-interactive"
43
+ " environments."
44
+ ),
45
+ ),
46
+ force: bool = typer.Option(
47
+ False,
48
+ "--force",
49
+ "-f",
50
+ help="Force token recreation, even if current token is still valid.",
51
+ ),
52
+ verbose: int = typer.Option(
53
+ 0, "-v", help="Increase verbosity", count=True
54
+ ),
55
+ version: Optional[bool] = typer.Option(
56
+ False,
57
+ "-V",
58
+ "--version",
59
+ help="Show version an exit",
60
+ callback=version_callback,
61
+ ),
62
+ ) -> None:
63
+ """Create OAuth2 access and refresh token."""
64
+ logger.set_verbosity(verbose)
65
+ token_data = authenticate(
66
+ host=host,
67
+ username=username,
68
+ refresh_token=refresh_token,
69
+ force=force,
70
+ )
71
+ print(json.dumps(token_data, indent=3))
@@ -0,0 +1,35 @@
1
+ """Freva the Free Evaluation System command line interface."""
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+ from freva_client.utils import logger
7
+
8
+ from .auth_cli import authenticate_cli
9
+ from .cli_utils import APP_NAME, version_callback
10
+ from .databrowser_cli import databrowser_app
11
+
12
+ app = typer.Typer(
13
+ name=APP_NAME,
14
+ help=__doc__,
15
+ add_completion=True,
16
+ callback=logger.set_cli,
17
+ )
18
+
19
+
20
+ @app.callback()
21
+ def main(
22
+ version: Optional[bool] = typer.Option(
23
+ None,
24
+ "--version",
25
+ "-V",
26
+ help="Show version and exit",
27
+ callback=version_callback,
28
+ is_eager=True,
29
+ ),
30
+ ) -> None:
31
+ """The main cli app."""
32
+
33
+
34
+ app.add_typer(databrowser_app, name="databrowser")
35
+ 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:
@@ -103,7 +90,7 @@ class Completer:
103
90
  choices = self._get_choices()
104
91
  return {**self.choices, **choices}
105
92
 
106
- def formated_print(self) -> None:
93
+ def formatted_print(self) -> None:
107
94
  """Print all choices to be processed by the shell completion function."""
108
95
 
109
96
  out = self.get_print(self.command_choices)
@@ -0,0 +1,31 @@
1
+ """Utilities for the command line interface."""
2
+
3
+ from typing import Dict, List
4
+
5
+ import typer
6
+ from freva_client import __version__
7
+ from freva_client.utils import logger
8
+ from rich import print as pprint
9
+
10
+ APP_NAME: str = "freva-client"
11
+
12
+
13
+ def version_callback(version: bool) -> None:
14
+ """Print the version and exit."""
15
+ if version:
16
+ pprint(f"{APP_NAME}: {__version__}")
17
+ raise typer.Exit()
18
+
19
+
20
+ def parse_cli_args(cli_args: List[str]) -> Dict[str, List[str]]:
21
+ """Convert the cli arguments to a dictionary."""
22
+ logger.debug("parsing command line arguments.")
23
+ kwargs = {}
24
+ for entry in cli_args:
25
+ key, _, value = entry.partition("=")
26
+ if value and key not in kwargs:
27
+ kwargs[key] = [value]
28
+ elif value:
29
+ kwargs[key].append(value)
30
+ logger.debug(kwargs)
31
+ return kwargs