arraylake 1.7.0__tar.gz → 1.8.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.
- {arraylake-1.7.0 → arraylake-1.8.0}/PKG-INFO +5 -4
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/_version.py +2 -2
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/api_utils.py +20 -6
- arraylake-1.8.0/arraylake/cli/auth.py +195 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/cli/repo.py +84 -1
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/client.py +271 -19
- arraylake-1.8.0/arraylake/compute/openeo_connection.py +47 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/config.py +8 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/metastore/http_metastore.py +13 -1
- arraylake-1.8.0/arraylake/repos/iceberg/auth.py +19 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/token.py +103 -3
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/types.py +10 -2
- {arraylake-1.7.0 → arraylake-1.8.0}/pyproject.toml +10 -6
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/__init__.py +8 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/test_api_utils.py +55 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/test_cli.py +169 -7
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/test_client.py +82 -5
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/test_http_metastore.py +5 -5
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/test_iceberg_client.py +4 -5
- arraylake-1.8.0/tests/test_openeo_connection.py +110 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/test_subscriptions.py +4 -46
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/test_token_handler.py +90 -2
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/test_virtual_chunks.py +24 -21
- {arraylake-1.7.0 → arraylake-1.8.0}/uv.lock +490 -334
- arraylake-1.7.0/arraylake/cli/auth.py +0 -134
- arraylake-1.7.0/arraylake/repos/iceberg/auth.py +0 -54
- {arraylake-1.7.0 → arraylake-1.8.0}/.gitignore +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/README.md +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/__init__.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/__main__.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/_credential_cache.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/asyn.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/cli/__init__.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/cli/compute.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/cli/config.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/cli/iceberg.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/cli/main.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/cli/org.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/cli/skills.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/cli/utils.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/compute/http_client.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/compute/services.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/compute/types.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/config.yaml +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/credentials.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/diagnostics.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/display/__init__.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/display/repo.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/display/repolist.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/exceptions.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/log_util.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/metastore/__init__.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/metastore/abc.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/obstore.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/py.typed +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/repos/__init__.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/repos/iceberg/__init__.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/repos/iceberg/catalog.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/repos/icechunk/__init__.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/repos/icechunk/storage.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/repos/icechunk/types.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/repos/icechunk/virtual.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/arraylake/tuning.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/compute/test_cli_table.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/compute/test_client.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/config.yaml +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/conftest.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/icechunk/__init__.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/icechunk/conftest.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/icechunk/test_storage.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/icechunk/test_virtual.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/test_asyn.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/test_config.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/test_credential_cache.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/test_credentials.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/test_diagnostics.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/test_obstore.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/test_performance.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/test_skills.py +0 -0
- {arraylake-1.7.0 → arraylake-1.8.0}/tests/test_types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: arraylake
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.0
|
|
4
4
|
Summary: Python client for ArrayLake
|
|
5
5
|
Project-URL: Documentation, https://docs.earthmover.io/
|
|
6
6
|
Project-URL: Changelog, https://docs.earthmover.io/changelog
|
|
@@ -27,13 +27,14 @@ Requires-Dist: zarr<4.0,>=3.1.0
|
|
|
27
27
|
Provides-Extra: iceberg
|
|
28
28
|
Requires-Dist: pyiceberg[pyarrow]<0.13,>=0.11; extra == 'iceberg'
|
|
29
29
|
Provides-Extra: maximal
|
|
30
|
-
Requires-Dist: xarray[accel,etc,io,parallel]>=
|
|
30
|
+
Requires-Dist: xarray[accel,etc,io,parallel]>=2025.6.0; extra == 'maximal'
|
|
31
31
|
Provides-Extra: obstore
|
|
32
32
|
Requires-Dist: obstore>=0.9.2; extra == 'obstore'
|
|
33
33
|
Provides-Extra: openeo
|
|
34
|
-
Requires-Dist: openeo<0.
|
|
34
|
+
Requires-Dist: openeo<0.54,>=0.35.0; extra == 'openeo'
|
|
35
|
+
Requires-Dist: xarray>=2025.6.0; extra == 'openeo'
|
|
35
36
|
Provides-Extra: xarray
|
|
36
|
-
Requires-Dist: xarray>=
|
|
37
|
+
Requires-Dist: xarray>=2025.6.0; extra == 'xarray'
|
|
37
38
|
Description-Content-Type: text/markdown
|
|
38
39
|
|
|
39
40
|
<p align="center">
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '1.
|
|
22
|
-
__version_tuple__ = version_tuple = (1,
|
|
21
|
+
__version__ = version = '1.8.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 8, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -30,7 +30,7 @@ from arraylake.exceptions import (
|
|
|
30
30
|
ArraylakeValidationError,
|
|
31
31
|
)
|
|
32
32
|
from arraylake.log_util import get_logger
|
|
33
|
-
from arraylake.types import ApiTokenInfo,
|
|
33
|
+
from arraylake.types import ApiTokenInfo, UserInfo
|
|
34
34
|
|
|
35
35
|
logger = get_logger(__name__)
|
|
36
36
|
|
|
@@ -484,6 +484,9 @@ def _exception_log_debug(request: httpx.Request, response: httpx.Response):
|
|
|
484
484
|
)
|
|
485
485
|
|
|
486
486
|
|
|
487
|
+
# Request extension set by our auth flows on a 401, describing the credential that was rejected.
|
|
488
|
+
_CREDENTIAL_EXTENSION = "arraylake_credential"
|
|
489
|
+
|
|
487
490
|
_emitted_deprecation_warnings: set[str] = set()
|
|
488
491
|
_emitted_user_warnings: set[str] = set()
|
|
489
492
|
|
|
@@ -526,6 +529,8 @@ def handle_response(response: httpx.Response):
|
|
|
526
529
|
else:
|
|
527
530
|
# Handle other HTTP status errors
|
|
528
531
|
error_msg = f"Error response {exc.response.status_code} while requesting {exc.request.url!r}. {response}: {response.text}"
|
|
532
|
+
if credential := exc.request.extensions.get(_CREDENTIAL_EXTENSION):
|
|
533
|
+
error_msg = f"{error_msg} (Authenticated {credential}.)"
|
|
529
534
|
if 400 <= exc.response.status_code < 500:
|
|
530
535
|
raise ArraylakeClientError(error_msg, request_id=request_id, status_code=exc.response.status_code) from exc
|
|
531
536
|
else:
|
|
@@ -550,7 +555,12 @@ class TokenAuth(httpx.Auth):
|
|
|
550
555
|
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
|
551
556
|
# Send the request, with a bearer token header
|
|
552
557
|
request.headers["Authorization"] = f"Bearer {self.token}"
|
|
553
|
-
yield request
|
|
558
|
+
response = yield request
|
|
559
|
+
if response.status_code == httpx.codes.UNAUTHORIZED:
|
|
560
|
+
# Import locally to avoid circular imports
|
|
561
|
+
from arraylake.token import describe_token_source
|
|
562
|
+
|
|
563
|
+
request.extensions[_CREDENTIAL_EXTENSION] = describe_token_source(self.token)
|
|
554
564
|
|
|
555
565
|
|
|
556
566
|
class UserAuth(httpx.Auth):
|
|
@@ -579,11 +589,14 @@ class UserAuth(httpx.Auth):
|
|
|
579
589
|
# Import locally to avoid circular imports
|
|
580
590
|
from arraylake.token import AuthException
|
|
581
591
|
|
|
582
|
-
raise AuthException("Not logged in, please log in with `
|
|
592
|
+
raise AuthException("Not logged in, please log in with `al auth login`")
|
|
583
593
|
token = self._token_handler.tokens.id_token.get_secret_value()
|
|
584
594
|
return f"Bearer {token}"
|
|
585
595
|
|
|
586
596
|
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
|
597
|
+
# Import locally to avoid circular imports
|
|
598
|
+
from arraylake.token import describe_cached_login, parse_refreshed_tokens
|
|
599
|
+
|
|
587
600
|
token_at_send = self._bearer_token
|
|
588
601
|
request.headers["Authorization"] = token_at_send
|
|
589
602
|
response = yield request
|
|
@@ -597,11 +610,12 @@ class UserAuth(httpx.Auth):
|
|
|
597
610
|
refresh_response = yield await self._token_handler.build_refresh_request()
|
|
598
611
|
await refresh_response.aread()
|
|
599
612
|
handle_response(refresh_response)
|
|
600
|
-
|
|
601
|
-
self._token_handler.update(new_tokens)
|
|
613
|
+
self._token_handler.update(parse_refreshed_tokens(refresh_response.content))
|
|
602
614
|
|
|
603
615
|
request.headers["Authorization"] = self._bearer_token
|
|
604
|
-
yield request
|
|
616
|
+
response = yield request
|
|
617
|
+
if response.status_code == httpx.codes.UNAUTHORIZED:
|
|
618
|
+
request.extensions[_CREDENTIAL_EXTENSION] = describe_cached_login(self.api_endpoint)
|
|
605
619
|
|
|
606
620
|
def sync_auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
|
607
621
|
# we can implement this method if we end up needing the sync httpx.Client
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import typer
|
|
5
|
+
from rich.align import Align
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.markup import escape
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
|
|
10
|
+
from arraylake.cli.utils import (
|
|
11
|
+
coro,
|
|
12
|
+
error_console,
|
|
13
|
+
print_logo,
|
|
14
|
+
print_logo_mini,
|
|
15
|
+
rich_console,
|
|
16
|
+
)
|
|
17
|
+
from arraylake.config import default_service_uri, default_token
|
|
18
|
+
from arraylake.token import AuthException, TokenHandler, describe_token, get_auth_handler
|
|
19
|
+
|
|
20
|
+
auth = typer.Typer(help="Manage Arraylake authentication")
|
|
21
|
+
|
|
22
|
+
NOT_LOGGED_IN_MESSAGE = "⚠️ Not logged in, please log in with: `al auth login`"
|
|
23
|
+
|
|
24
|
+
# `al auth token` refreshes tokens that expire sooner than this, so the printed token stays usable for a while.
|
|
25
|
+
TOKEN_MIN_VALID_SECONDS = 300
|
|
26
|
+
|
|
27
|
+
# Uncolored notices on stderr, keeping stdout machine-readable.
|
|
28
|
+
notice_console = Console(stderr=True)
|
|
29
|
+
|
|
30
|
+
SERVICE_URI_OPTION = typer.Option(
|
|
31
|
+
None,
|
|
32
|
+
"--service-uri",
|
|
33
|
+
help=(
|
|
34
|
+
"Arraylake API endpoint to act on, e.g. **https://dev.api.earthmover.io**. Defaults to the configured "
|
|
35
|
+
"**service.uri**. Tokens are stored per endpoint, so this need not be persisted to config — unless "
|
|
36
|
+
"**service.token_path** is set, which makes all endpoints share one token file."
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_service_uri(service_uri: str | None) -> str:
|
|
42
|
+
return service_uri if service_uri else default_service_uri()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@auth.command()
|
|
46
|
+
@coro # type: ignore
|
|
47
|
+
async def login(
|
|
48
|
+
browser: bool = typer.Option(True, "--browser/--no-browser", " /--nobrowser", help="Whether to automatically open a browser window."),
|
|
49
|
+
service_uri: str | None = SERVICE_URI_OPTION,
|
|
50
|
+
):
|
|
51
|
+
"""**Log in** to Arraylake
|
|
52
|
+
|
|
53
|
+
This will automatically open a browser window. If **--no-browser** is specified, a link will be printed.
|
|
54
|
+
|
|
55
|
+
**Examples**
|
|
56
|
+
|
|
57
|
+
- Log in without automatically opening a browser window
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
$ al auth login --no-browser
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- Log in to a non-default Arraylake deployment
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
$ al auth login --service-uri https://dev.api.earthmover.io
|
|
67
|
+
```
|
|
68
|
+
"""
|
|
69
|
+
print_logo()
|
|
70
|
+
handler = get_auth_handler(api_endpoint=_resolve_service_uri(service_uri))
|
|
71
|
+
await handler.login(browser=browser)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@auth.command()
|
|
75
|
+
@coro # type: ignore
|
|
76
|
+
async def refresh(service_uri: str | None = SERVICE_URI_OPTION) -> None:
|
|
77
|
+
"""**Refresh** Arraylake's auth token"""
|
|
78
|
+
handler = get_auth_handler(api_endpoint=_resolve_service_uri(service_uri))
|
|
79
|
+
if handler.tokens is not None:
|
|
80
|
+
try:
|
|
81
|
+
await handler.refresh_token()
|
|
82
|
+
except Exception as e:
|
|
83
|
+
error_console.print(f"\n[bold red]❌ {e}[/bold red]")
|
|
84
|
+
raise typer.Exit(code=1)
|
|
85
|
+
else:
|
|
86
|
+
rich_console.print(Align(f"[yellow]{NOT_LOGGED_IN_MESSAGE}[/yellow]", align="center"))
|
|
87
|
+
raise typer.Exit(code=1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@auth.command()
|
|
91
|
+
@coro # type: ignore
|
|
92
|
+
async def logout(service_uri: str | None = SERVICE_URI_OPTION) -> None:
|
|
93
|
+
"""**Logout** of Arraylake
|
|
94
|
+
|
|
95
|
+
**Examples**
|
|
96
|
+
|
|
97
|
+
- Logout of arraylake
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
$ al auth logout
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- Log out of a non-default Arraylake deployment
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
$ al auth logout --service-uri https://dev.api.earthmover.io
|
|
107
|
+
```
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
handler = get_auth_handler(api_endpoint=_resolve_service_uri(service_uri))
|
|
111
|
+
await handler.logout()
|
|
112
|
+
except AuthException as e:
|
|
113
|
+
error_console.print(e)
|
|
114
|
+
raise typer.Exit(code=1)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _configured_token_name() -> str:
|
|
118
|
+
return "ARRAYLAKE_TOKEN" if os.environ.get("ARRAYLAKE_TOKEN", "").strip() else "The `token` arraylake config setting"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def _check_configured_token(handler: TokenHandler, token: str) -> bool:
|
|
122
|
+
"""Report on a configured token, which clients use instead of the cached login. Returns whether it is valid."""
|
|
123
|
+
name = _configured_token_name()
|
|
124
|
+
rich_console.print(
|
|
125
|
+
Align(f"[yellow]{name} is set ({describe_token(token)}) and takes precedence over your cached login.[/yellow]", align="center")
|
|
126
|
+
)
|
|
127
|
+
try:
|
|
128
|
+
async with handler._create_async_client() as client:
|
|
129
|
+
response = await client.get(
|
|
130
|
+
f"{handler.api_endpoint}/user", headers={"Authorization": f"Bearer {token}", **handler.arraylake_headers}
|
|
131
|
+
)
|
|
132
|
+
except httpx.HTTPError as e:
|
|
133
|
+
rich_console.print(Align(f"[yellow]⚠️ Could not verify {name}: {escape(str(e))}[/yellow]", align="center"))
|
|
134
|
+
return False
|
|
135
|
+
if response.status_code != httpx.codes.OK:
|
|
136
|
+
hint = "`unset ARRAYLAKE_TOKEN`" if name == "ARRAYLAKE_TOKEN" else "remove it"
|
|
137
|
+
rich_console.print(
|
|
138
|
+
Align(f"[red]❌ {name} was rejected ({response.status_code}); {hint} to use your cached login.[/red]", align="center")
|
|
139
|
+
)
|
|
140
|
+
return False
|
|
141
|
+
rich_console.print(Align(f"[green][bold]🔓 {name} is valid ({response.json().get('email', 'unknown')})[/bold][/green]", align="center"))
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@auth.command()
|
|
146
|
+
@coro # type: ignore
|
|
147
|
+
async def status(service_uri: str | None = SERVICE_URI_OPTION):
|
|
148
|
+
"""Verify and display information about your authentication **status**
|
|
149
|
+
|
|
150
|
+
Reports **ARRAYLAKE_TOKEN** (or the **token** config setting) first when set, since clients use it instead of
|
|
151
|
+
the cached login.
|
|
152
|
+
"""
|
|
153
|
+
endpoint = _resolve_service_uri(service_uri)
|
|
154
|
+
print_logo_mini()
|
|
155
|
+
rich_console.print(Align(Panel.fit(f"[bold]Arraylake API Endpoint[/bold]: {endpoint}"), align="center"))
|
|
156
|
+
|
|
157
|
+
handler = get_auth_handler(api_endpoint=endpoint)
|
|
158
|
+
configured_token = default_token()
|
|
159
|
+
configured_token_ok = await _check_configured_token(handler, configured_token) if configured_token else None
|
|
160
|
+
try:
|
|
161
|
+
user = await handler._get_user() # checks that the new tokens are valid
|
|
162
|
+
rich_console.print(Align(f"[green][bold]🔓 Logged in as {user.email}[/green][/bold]", align="center"))
|
|
163
|
+
except AuthException:
|
|
164
|
+
if configured_token_ok is None:
|
|
165
|
+
rich_console.print(Align(f"[yellow]{NOT_LOGGED_IN_MESSAGE}[/yellow]", align="center"))
|
|
166
|
+
raise typer.Exit(code=1)
|
|
167
|
+
rich_console.print(Align("[dim]No valid cached login (`al auth login`) for this endpoint.[/dim]", align="center"))
|
|
168
|
+
if configured_token_ok is False:
|
|
169
|
+
raise typer.Exit(code=1)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@auth.command()
|
|
173
|
+
@coro # type: ignore
|
|
174
|
+
async def token(service_uri: str | None = SERVICE_URI_OPTION):
|
|
175
|
+
"""Print your login's ID **token**, refreshing it first if it has expired or is about to
|
|
176
|
+
|
|
177
|
+
Only the token goes to stdout, so it can be captured, e.g. **PGPASSWORD="$(al auth token)"**. Make sure
|
|
178
|
+
**--service-uri** matches the deployment you will send the token to.
|
|
179
|
+
"""
|
|
180
|
+
endpoint = _resolve_service_uri(service_uri)
|
|
181
|
+
handler = get_auth_handler(api_endpoint=endpoint)
|
|
182
|
+
if handler.tokens is None:
|
|
183
|
+
error_console.print(NOT_LOGGED_IN_MESSAGE)
|
|
184
|
+
raise typer.Exit(code=1)
|
|
185
|
+
try:
|
|
186
|
+
token_value = await handler.get_fresh_id_token(min_valid_seconds=TOKEN_MIN_VALID_SECONDS)
|
|
187
|
+
except AuthException as e:
|
|
188
|
+
error_console.print(f"❌ {escape(str(e))}")
|
|
189
|
+
raise typer.Exit(code=1)
|
|
190
|
+
notice_console.print(f"[dim]ID token for {endpoint} ({describe_token(token_value)})[/dim]", highlight=False)
|
|
191
|
+
if default_token():
|
|
192
|
+
notice_console.print(
|
|
193
|
+
f"[yellow]Note: {_configured_token_name()} is set and takes precedence over this login in Arraylake clients.[/yellow]"
|
|
194
|
+
)
|
|
195
|
+
typer.echo(token_value)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
from datetime import datetime
|
|
2
3
|
from enum import Enum, StrEnum
|
|
3
4
|
from uuid import UUID
|
|
4
5
|
|
|
@@ -12,7 +13,7 @@ from rich.text import Text
|
|
|
12
13
|
from arraylake import AsyncClient
|
|
13
14
|
from arraylake.cli.utils import coro, rich_console, simple_progress
|
|
14
15
|
from arraylake.log_util import get_logger
|
|
15
|
-
from arraylake.types import RepoOperationMode
|
|
16
|
+
from arraylake.types import DatasetFilter, NodeFilter, RepoOperationMode, SubscriptionType
|
|
16
17
|
|
|
17
18
|
app = typer.Typer(help="Manage Arraylake repositories", no_args_is_help=True)
|
|
18
19
|
logger = get_logger(__name__)
|
|
@@ -85,6 +86,8 @@ def _make_json_safe(obj):
|
|
|
85
86
|
return "[REDACTED]"
|
|
86
87
|
elif isinstance(obj, UUID):
|
|
87
88
|
return str(obj)
|
|
89
|
+
elif isinstance(obj, datetime):
|
|
90
|
+
return obj.isoformat()
|
|
88
91
|
else:
|
|
89
92
|
return obj
|
|
90
93
|
|
|
@@ -186,6 +189,86 @@ async def import_repo(
|
|
|
186
189
|
)
|
|
187
190
|
|
|
188
191
|
|
|
192
|
+
@app.command()
|
|
193
|
+
@coro # type: ignore
|
|
194
|
+
async def subscribe(
|
|
195
|
+
repo_name: str = typer.Argument(..., help="Name of the subscriber repository {ORG}/{REPO_NAME}"),
|
|
196
|
+
listing_id: str = typer.Argument(..., help="ID of the exchange listing"),
|
|
197
|
+
subscription_type: SubscriptionType = typer.Option(SubscriptionType.DIRECT, help="Subscription type"),
|
|
198
|
+
include_path: list[str] | None = typer.Option(None, help="Group or array to copy (filtered/private). Repeat for more paths."),
|
|
199
|
+
exclude_path: list[str] | None = typer.Option(None, help="Group or array to leave out (filtered/private). Repeat for more paths."),
|
|
200
|
+
bucket_config_nickname: str | None = typer.Option(None, help="Bucket for the copied data (filtered/private)"),
|
|
201
|
+
prefix: str | None = typer.Option(None, help="Optional prefix in the bucket. If not provided, one is generated."),
|
|
202
|
+
description: str | None = typer.Option(None, help="Description of the repo"),
|
|
203
|
+
metadata: str | None = typer.Option(None, help="Optional metadata for the repo"),
|
|
204
|
+
):
|
|
205
|
+
"""**Subscribe** to an exchange listing
|
|
206
|
+
|
|
207
|
+
**Examples**
|
|
208
|
+
|
|
209
|
+
- Subscribe to a free listing
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
$ arraylake repo subscribe my-org/era5 6720f1c2a9e3b4d5e6f70812
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
- Copy two arrays from a paid listing into your bucket
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
$ arraylake repo subscribe my-org/era5-subset 6720f1c2a9e3b4d5e6f70812 --subscription-type filtered --include-path /t2m --include-path /u10 --bucket-config-nickname my-bucket
|
|
219
|
+
```
|
|
220
|
+
"""
|
|
221
|
+
target_filter = (
|
|
222
|
+
DatasetFilter(nodes=NodeFilter(include_paths=include_path or [], exclude_paths=exclude_path or []))
|
|
223
|
+
if include_path or exclude_path
|
|
224
|
+
else None
|
|
225
|
+
)
|
|
226
|
+
metadata_dict = json.loads(metadata) if metadata else None
|
|
227
|
+
with simple_progress(f"Subscribing [bold]{repo_name}[/bold]..."):
|
|
228
|
+
await AsyncClient().subscribe_to_listing(
|
|
229
|
+
repo_name,
|
|
230
|
+
listing_id,
|
|
231
|
+
subscription_type=subscription_type,
|
|
232
|
+
target_filter=target_filter,
|
|
233
|
+
bucket_config_nickname=bucket_config_nickname,
|
|
234
|
+
prefix=prefix,
|
|
235
|
+
description=description,
|
|
236
|
+
metadata=metadata_dict,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@app.command()
|
|
241
|
+
@coro # type: ignore
|
|
242
|
+
async def claim(
|
|
243
|
+
repo_name: str = typer.Argument(..., help="Name of the subscriber repository {ORG}/{REPO_NAME}"),
|
|
244
|
+
claim_code: str = typer.Argument(..., help="Claim code from the provider"),
|
|
245
|
+
bucket_config_nickname: str | None = typer.Option(None, help="Bucket for the copied data (filtered/private)"),
|
|
246
|
+
prefix: str | None = typer.Option(None, help="Optional prefix in the bucket. If not provided, one is generated."),
|
|
247
|
+
description: str | None = typer.Option(None, help="Description of the repo"),
|
|
248
|
+
metadata: str | None = typer.Option(None, help="Optional metadata for the repo"),
|
|
249
|
+
):
|
|
250
|
+
"""**Claim** a subscription with a claim code
|
|
251
|
+
|
|
252
|
+
**Examples**
|
|
253
|
+
|
|
254
|
+
- Claim a subscription
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
$ arraylake repo claim my-org/era5 ABCD-1234
|
|
258
|
+
```
|
|
259
|
+
"""
|
|
260
|
+
metadata_dict = json.loads(metadata) if metadata else None
|
|
261
|
+
with simple_progress(f"Claiming [bold]{repo_name}[/bold]..."):
|
|
262
|
+
await AsyncClient().claim_subscription(
|
|
263
|
+
repo_name,
|
|
264
|
+
claim_code,
|
|
265
|
+
bucket_config_nickname=bucket_config_nickname,
|
|
266
|
+
prefix=prefix,
|
|
267
|
+
description=description,
|
|
268
|
+
metadata=metadata_dict,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
189
272
|
@app.command()
|
|
190
273
|
@coro # type: ignore
|
|
191
274
|
async def modify(
|