arraylake 0.33.0__tar.gz → 1.0.1__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-0.33.0 → arraylake-1.0.1}/.gitignore +3 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/PKG-INFO +7 -9
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/_version.py +2 -2
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/api_utils.py +24 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/cli/auth.py +1 -8
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/client.py +21 -10
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/repos/icechunk/virtual.py +0 -22
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/types.py +128 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/pyproject.toml +18 -21
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_api_utils.py +49 -3
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_client.py +41 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_virtual_chunks.py +21 -6
- {arraylake-0.33.0 → arraylake-1.0.1}/uv.lock +799 -683
- {arraylake-0.33.0 → arraylake-1.0.1}/README.md +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/__init__.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/__main__.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/asyn.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/cli/__init__.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/cli/compute.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/cli/config.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/cli/main.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/cli/repo.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/cli/utils.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/compute/doctor.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/compute/http_client.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/compute/services.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/compute/types.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/config.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/config.yaml +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/credentials.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/diagnostics.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/display/__init__.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/display/repo.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/display/repolist.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/exceptions.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/log_util.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/metastore/__init__.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/metastore/abc.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/metastore/http_metastore.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/py.typed +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/repos/__init__.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/repos/icechunk/__init__.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/repos/icechunk/storage.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/repos/icechunk/types.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/token.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/tuning.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/__init__.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/compute/test_client.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/compute/test_doctor.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/config.yaml +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/conftest.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/icechunk/__init__.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/icechunk/conftest.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/icechunk/test_storage.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/icechunk/test_virtual.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_asyn.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_cli.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_config.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_credentials.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_diagnostics.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_performance.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_subscriptions.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_token_handler.py +0 -0
- {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_types.py +0 -0
|
@@ -1,33 +1,31 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arraylake
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.1
|
|
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
|
|
7
7
|
Author-email: Joseph Hamman <joe@earthmover.io>
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
Classifier: Programming Language :: Python
|
|
10
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
11
10
|
Classifier: Programming Language :: Python :: 3.12
|
|
12
11
|
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
-
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Python: <3.15,>=3.12
|
|
14
14
|
Requires-Dist: click<9.0,>=8.1.3
|
|
15
15
|
Requires-Dist: donfig<1.0,>=0.7
|
|
16
|
-
Requires-Dist: httpx<0.
|
|
17
|
-
Requires-Dist: icechunk<
|
|
16
|
+
Requires-Dist: httpx<0.29,>=0.23
|
|
17
|
+
Requires-Dist: icechunk<3.0.0,>=2.0.1
|
|
18
18
|
Requires-Dist: numpy<3.0,>=1.23
|
|
19
|
-
Requires-Dist: packaging<
|
|
19
|
+
Requires-Dist: packaging<27.0,>=23.0
|
|
20
20
|
Requires-Dist: pydantic[email]<3.0,>=2.9
|
|
21
21
|
Requires-Dist: python-dateutil<3.0,>=2.8
|
|
22
|
-
Requires-Dist: rich<
|
|
22
|
+
Requires-Dist: rich<16.0,>=12.6
|
|
23
23
|
Requires-Dist: ruamel-yaml<1.0,>=0.17
|
|
24
24
|
Requires-Dist: structlog<26.0,>=24.1
|
|
25
25
|
Requires-Dist: typer<1.0,>=0.12.0
|
|
26
26
|
Requires-Dist: zarr<4.0,>=3.1.0
|
|
27
27
|
Provides-Extra: maximal
|
|
28
28
|
Requires-Dist: cf-xarray>=0.10.4; extra == 'maximal'
|
|
29
|
-
Requires-Dist: numpy<2.3; extra == 'maximal'
|
|
30
|
-
Requires-Dist: pyarrow<22; extra == 'maximal'
|
|
31
29
|
Requires-Dist: xarray[accel,etc,io,parallel]>=2024.10.0; extra == 'maximal'
|
|
32
30
|
Provides-Extra: xarray
|
|
33
31
|
Requires-Dist: cf-xarray>=0.10.4; extra == 'xarray'
|
|
@@ -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 = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (
|
|
21
|
+
__version__ = version = '1.0.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 0, 1)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import functools
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
6
7
|
import random
|
|
8
|
+
import uuid
|
|
9
|
+
import warnings
|
|
7
10
|
import weakref
|
|
8
11
|
from collections.abc import AsyncGenerator, Callable, Generator, Mapping
|
|
9
12
|
from contextlib import _AsyncGeneratorContextManager
|
|
@@ -81,6 +84,19 @@ def _get_proxy_from_env() -> str | None:
|
|
|
81
84
|
return None
|
|
82
85
|
|
|
83
86
|
|
|
87
|
+
@functools.cache
|
|
88
|
+
def _get_session_id() -> str:
|
|
89
|
+
"""Session ID for this Python process.
|
|
90
|
+
|
|
91
|
+
Propagated as the ``arraylake-session-id`` header on every outbound request and
|
|
92
|
+
baked into ``ArraylakeHttpClient._default_headers`` at construction time, so it
|
|
93
|
+
rides along to dask workers via the existing pickle path on the client.
|
|
94
|
+
"""
|
|
95
|
+
session_id = str(uuid.uuid4())
|
|
96
|
+
logger.info("arraylake session id: %s", session_id)
|
|
97
|
+
return session_id
|
|
98
|
+
|
|
99
|
+
|
|
84
100
|
# this is a hashable key to use for the global client cache
|
|
85
101
|
# we only need one client for each key
|
|
86
102
|
@dataclass(eq=True, frozen=True)
|
|
@@ -301,6 +317,7 @@ class ArraylakeHttpClient:
|
|
|
301
317
|
# technically we don't enforce this, for now it's for debugging purposes
|
|
302
318
|
"client-name": "arraylake-python-client",
|
|
303
319
|
"client-version": arraylake.__version__,
|
|
320
|
+
"arraylake-session-id": _get_session_id(),
|
|
304
321
|
}
|
|
305
322
|
|
|
306
323
|
self._async_lock = asyncio.Lock()
|
|
@@ -430,8 +447,15 @@ def _exception_log_debug(request: httpx.Request, response: httpx.Response):
|
|
|
430
447
|
)
|
|
431
448
|
|
|
432
449
|
|
|
450
|
+
_emitted_deprecation_warnings: set[str] = set()
|
|
451
|
+
|
|
452
|
+
|
|
433
453
|
def handle_response(response: httpx.Response):
|
|
434
454
|
"""Convenience function to handle response status codes"""
|
|
455
|
+
deprecation_msg = response.headers.get("X-Deprecation-Warning")
|
|
456
|
+
if deprecation_msg and deprecation_msg not in _emitted_deprecation_warnings:
|
|
457
|
+
_emitted_deprecation_warnings.add(deprecation_msg)
|
|
458
|
+
warnings.warn(deprecation_msg, DeprecationWarning, stacklevel=2)
|
|
435
459
|
try:
|
|
436
460
|
response.raise_for_status()
|
|
437
461
|
except httpx.RequestError as exc:
|
|
@@ -101,14 +101,7 @@ def token():
|
|
|
101
101
|
raise typer.Exit(code=1)
|
|
102
102
|
try:
|
|
103
103
|
token_value = handler.tokens.id_token.get_secret_value()
|
|
104
|
-
|
|
105
|
-
Panel(
|
|
106
|
-
f"[dim]{token_value}[/dim]",
|
|
107
|
-
title="id_token",
|
|
108
|
-
subtitle=f"[link=https://jwt.io/#id_token={token_value}][blue]:link: Inspect with JWT.io[/blue][/link]",
|
|
109
|
-
subtitle_align="right",
|
|
110
|
-
)
|
|
111
|
-
)
|
|
104
|
+
typer.echo(token_value)
|
|
112
105
|
except (AuthException, AttributeError):
|
|
113
106
|
rich_console.print(Align(f"[yellow]{NOT_LOGGED_IN_MESSAGE}[/yellow]", align="center"))
|
|
114
107
|
raise typer.Exit(code=1)
|
|
@@ -46,7 +46,6 @@ from arraylake.repos.icechunk.storage import (
|
|
|
46
46
|
_get_icechunk_storage_obj,
|
|
47
47
|
)
|
|
48
48
|
from arraylake.repos.icechunk.virtual import (
|
|
49
|
-
create_vcc_store_config_for_platform,
|
|
50
49
|
create_virtual_chunk_container,
|
|
51
50
|
forbid_unsafe_virtual_bucket_configs,
|
|
52
51
|
forbid_unsafe_virtual_chunk_containers,
|
|
@@ -559,15 +558,17 @@ class AsyncClient:
|
|
|
559
558
|
if response.virtual_chunk_credentials:
|
|
560
559
|
credentials_map: dict[str, icechunk.AnyS3Credential | icechunk.AnyGcsCredential | icechunk.AnyAzureCredential | None] = {}
|
|
561
560
|
for vcc_prefix, vcc_creds in response.virtual_chunk_credentials.items():
|
|
562
|
-
#
|
|
561
|
+
# Create VCC objects for all server-provided VCCs, not just runtime ones,
|
|
562
|
+
# because icechunk's config merge replaces virtual_chunk_containers
|
|
563
|
+
# rather than extending them.
|
|
564
|
+
store_config = vcc_creds.to_vcc_store_config(user_id=UUID(response.principal_id))
|
|
565
|
+
container = icechunk.VirtualChunkContainer(
|
|
566
|
+
url_prefix=vcc_prefix,
|
|
567
|
+
store=store_config,
|
|
568
|
+
)
|
|
563
569
|
if vcc_creds.runtime_vcc_name is not None:
|
|
564
|
-
store_config = create_vcc_store_config_for_platform(vcc_creds.platform)
|
|
565
|
-
container = icechunk.VirtualChunkContainer(
|
|
566
|
-
url_prefix=vcc_prefix,
|
|
567
|
-
store=store_config,
|
|
568
|
-
)
|
|
569
570
|
container.name = vcc_creds.runtime_vcc_name
|
|
570
|
-
|
|
571
|
+
config.set_virtual_chunk_container(container)
|
|
571
572
|
|
|
572
573
|
# TODO: build refresh closures here using vcc_creds.org, vcc_creds.bucket_nickname,
|
|
573
574
|
# and vcc_creds.platform so that credentials can be refreshed when they expire. (See https://github.com/earth-mover/arraylake/issues/5122)
|
|
@@ -734,6 +735,7 @@ class AsyncClient:
|
|
|
734
735
|
config: icechunk.RepositoryConfig | None = None,
|
|
735
736
|
authorize_virtual_chunk_access: Mapping[BucketPrefix, BucketNickname] | None = None,
|
|
736
737
|
storage_options: StorageOptions | None = None,
|
|
738
|
+
spec_version: int = 2,
|
|
737
739
|
) -> IcechunkRepository:
|
|
738
740
|
"""Create a new repo.
|
|
739
741
|
|
|
@@ -751,6 +753,7 @@ class AsyncClient:
|
|
|
751
753
|
and used for the containers. Must be supplied as complete urls.
|
|
752
754
|
storage_options: Optional storage options for the underlying Icechunk storage.
|
|
753
755
|
Currently supports `network_stream_timeout_seconds` for S3, Tigris, and R2 storage.
|
|
756
|
+
spec_version: The Icechunk spec version to use for the repo. Defaults to 2.
|
|
754
757
|
|
|
755
758
|
Returns:
|
|
756
759
|
icechunk.Repository object for the repo.
|
|
@@ -808,10 +811,15 @@ class AsyncClient:
|
|
|
808
811
|
# TODO: make icechunk accept an empty dict instead of None
|
|
809
812
|
prefixes_credential_mapping = None
|
|
810
813
|
|
|
814
|
+
create_kwargs: dict[str, object] = {
|
|
815
|
+
"config": config,
|
|
816
|
+
"authorize_virtual_chunk_access": prefixes_credential_mapping,
|
|
817
|
+
"spec_version": spec_version,
|
|
818
|
+
}
|
|
819
|
+
|
|
811
820
|
ic_repo = IcechunkRepository.create(
|
|
812
821
|
icechunk_storage,
|
|
813
|
-
|
|
814
|
-
authorize_virtual_chunk_access=prefixes_credential_mapping,
|
|
822
|
+
**create_kwargs, # type: ignore[arg-type]
|
|
815
823
|
)
|
|
816
824
|
|
|
817
825
|
ic_repo.set_default_commit_metadata({"author_name": author.name, "author_email": author.email})
|
|
@@ -1667,6 +1675,7 @@ class Client:
|
|
|
1667
1675
|
config: icechunk.RepositoryConfig | None = None,
|
|
1668
1676
|
authorize_virtual_chunk_access: Mapping[BucketPrefix, BucketNickname] | None = None,
|
|
1669
1677
|
storage_options: StorageOptions | None = None,
|
|
1678
|
+
spec_version: int = 2,
|
|
1670
1679
|
) -> IcechunkRepository:
|
|
1671
1680
|
"""Create a new repo
|
|
1672
1681
|
|
|
@@ -1685,6 +1694,7 @@ class Client:
|
|
|
1685
1694
|
and used for the containers. Used for Icechunk repos only.
|
|
1686
1695
|
storage_options: Optional storage options for the underlying Icechunk storage.
|
|
1687
1696
|
Currently supports `network_stream_timeout_seconds` for S3, Tigris, and R2 storage.
|
|
1697
|
+
spec_version: The Icechunk spec version to use for the repo. Defaults to 2.
|
|
1688
1698
|
|
|
1689
1699
|
Returns:
|
|
1690
1700
|
icechunk.Repository: The created Icechunk repository.
|
|
@@ -1699,6 +1709,7 @@ class Client:
|
|
|
1699
1709
|
config=config,
|
|
1700
1710
|
authorize_virtual_chunk_access=authorize_virtual_chunk_access,
|
|
1701
1711
|
storage_options=storage_options,
|
|
1712
|
+
spec_version=spec_version,
|
|
1702
1713
|
)
|
|
1703
1714
|
|
|
1704
1715
|
def import_repo(
|
|
@@ -19,28 +19,6 @@ from arraylake.types import (
|
|
|
19
19
|
FORBIDDEN_VIRTUAL_CHUNK_PREFIXES = {"file://", "memory://", "http://"}
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
def create_vcc_store_config_for_platform(platform: str) -> icechunk.AnyObjectStoreConfig:
|
|
23
|
-
"""Create an Icechunk ObjectStoreConfig for a virtual chunk container based on platform.
|
|
24
|
-
|
|
25
|
-
This is used for runtime VCCs (e.g., filtered subscriptions) where we need to create
|
|
26
|
-
a VCC dynamically based on the server-provided platform type.
|
|
27
|
-
|
|
28
|
-
Args:
|
|
29
|
-
platform: The storage platform (s3, s3-compatible, minio, gs, azure)
|
|
30
|
-
|
|
31
|
-
Returns:
|
|
32
|
-
An icechunk ObjectStoreConfig for the given platform
|
|
33
|
-
"""
|
|
34
|
-
if platform in ("s3", "s3-compatible", "minio"):
|
|
35
|
-
return icechunk.ObjectStoreConfig.S3(icechunk.S3Options())
|
|
36
|
-
elif platform == "gs":
|
|
37
|
-
return icechunk.ObjectStoreConfig.Gcs({})
|
|
38
|
-
elif platform == "azure":
|
|
39
|
-
return icechunk.ObjectStoreConfig.Azure({})
|
|
40
|
-
else:
|
|
41
|
-
raise ValueError(f"Unsupported platform for VCC store config: {platform}")
|
|
42
|
-
|
|
43
|
-
|
|
44
22
|
def get_icechunk_container_credentials(
|
|
45
23
|
bucket_platform: str,
|
|
46
24
|
credentials: S3Credentials | GSCredentials | None,
|
|
@@ -289,11 +289,31 @@ class RepoSubscribeBody(RepoCreateBodyBase):
|
|
|
289
289
|
None, description="Subscriber-defined filter applied on top of the provider's source filter. None means no additional filtering."
|
|
290
290
|
)
|
|
291
291
|
subscription_type: SubscriptionType = Field(default=SubscriptionType.DIRECT, description="The type/tier of subscription.")
|
|
292
|
+
prefix: BucketPrefix | None = Field(
|
|
293
|
+
None, description="Custom storage prefix for filtered/private subscriptions. If not provided, one is auto-generated."
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
@field_validator("prefix")
|
|
297
|
+
@classmethod
|
|
298
|
+
def validate_prefix_length(cls, v: str | None) -> str | None:
|
|
299
|
+
if v is not None and len(v) > MAX_BUCKET_PREFIX_LENGTH:
|
|
300
|
+
raise ValueError(f"Prefix must be at most {MAX_BUCKET_PREFIX_LENGTH} characters long. Got {len(v)}.")
|
|
301
|
+
return v
|
|
292
302
|
|
|
293
303
|
@model_validator(mode="after")
|
|
294
304
|
def validate_subscription_type_with_filter(self) -> RepoSubscribeBody:
|
|
295
305
|
if self.target_filter is not None and self.subscription_type == SubscriptionType.DIRECT:
|
|
296
306
|
raise ValueError("subscription_type cannot be 'direct' when target_filter is specified. Use 'filtered' or 'private' instead.")
|
|
307
|
+
# Filtered/private subscriptions require explicit target_filter with explicit allowlist
|
|
308
|
+
if self.subscription_type in (SubscriptionType.FILTERED, SubscriptionType.PRIVATE):
|
|
309
|
+
if self.target_filter is None:
|
|
310
|
+
raise ValueError(f"target_filter is required for {self.subscription_type.value} subscriptions.")
|
|
311
|
+
if self.target_filter.nodes is None:
|
|
312
|
+
raise ValueError(f"target_filter.nodes cannot be None for {self.subscription_type.value} subscriptions.")
|
|
313
|
+
if not self.target_filter.nodes.include_paths:
|
|
314
|
+
raise ValueError(f"target_filter must have explicit include_paths for {self.subscription_type.value} subscriptions.")
|
|
315
|
+
if self.target_filter.nodes.include_paths == ["/"]:
|
|
316
|
+
raise ValueError(f"target_filter cannot be the root group for {self.subscription_type.value} subscriptions.")
|
|
297
317
|
return self
|
|
298
318
|
|
|
299
319
|
|
|
@@ -304,6 +324,16 @@ class RepoClaimBody(RepoCreateBodyBase):
|
|
|
304
324
|
"""
|
|
305
325
|
|
|
306
326
|
claim_code: str = Field(..., description="The claim code to redeem")
|
|
327
|
+
prefix: BucketPrefix | None = Field(
|
|
328
|
+
None, description="Custom storage prefix for filtered/private subscriptions. If not provided, one is auto-generated."
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
@field_validator("prefix")
|
|
332
|
+
@classmethod
|
|
333
|
+
def validate_prefix_length(cls, v: str | None) -> str | None:
|
|
334
|
+
if v is not None and len(v) > MAX_BUCKET_PREFIX_LENGTH:
|
|
335
|
+
raise ValueError(f"Prefix must be at most {MAX_BUCKET_PREFIX_LENGTH} characters long. Got {len(v)}.")
|
|
336
|
+
return v
|
|
307
337
|
|
|
308
338
|
|
|
309
339
|
class RepoModifyRequest(BaseModel):
|
|
@@ -471,6 +501,51 @@ class BucketModel(BaseModel):
|
|
|
471
501
|
region = self.extra_config.get("region_name")
|
|
472
502
|
return str(region) if region else None
|
|
473
503
|
|
|
504
|
+
def to_vcc_store_config(self, user_id: UUID | None = None) -> icechunk.AnyObjectStoreConfig:
|
|
505
|
+
"""Create an Icechunk ObjectStoreConfig for use as a virtual chunk container.
|
|
506
|
+
|
|
507
|
+
This extracts the necessary configuration from the bucket's extra_config
|
|
508
|
+
to create a properly configured store config for accessing virtual chunks.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
user_id: Optional user ID for logging purposes (used in GCS user_agent).
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
An icechunk ObjectStoreConfig for the bucket's platform.
|
|
515
|
+
"""
|
|
516
|
+
if self.platform in ("s3", "s3-compatible", "minio"):
|
|
517
|
+
endpoint_url = self.extra_config.get("endpoint_url")
|
|
518
|
+
if endpoint_url is not None:
|
|
519
|
+
endpoint_url = str(endpoint_url)
|
|
520
|
+
|
|
521
|
+
region = self.extra_config.get("region_name")
|
|
522
|
+
if region is not None:
|
|
523
|
+
region = str(region)
|
|
524
|
+
|
|
525
|
+
use_ssl = self.extra_config.get("use_ssl", True)
|
|
526
|
+
allow_http = not use_ssl
|
|
527
|
+
|
|
528
|
+
force_path_style_raw = self.extra_config.get("force_path_style", False if use_ssl else True)
|
|
529
|
+
force_path_style = bool(force_path_style_raw)
|
|
530
|
+
|
|
531
|
+
options = icechunk.S3Options(
|
|
532
|
+
region=region,
|
|
533
|
+
endpoint_url=endpoint_url,
|
|
534
|
+
allow_http=allow_http,
|
|
535
|
+
force_path_style=force_path_style,
|
|
536
|
+
)
|
|
537
|
+
return icechunk.ObjectStoreConfig.S3(options)
|
|
538
|
+
elif self.platform == "gs":
|
|
539
|
+
from arraylake import __version__ as arraylake_version
|
|
540
|
+
|
|
541
|
+
# Used for logging and debugging purposes in GCS access logs to help identify which client/version and optionally which user is accessing virtual chunks
|
|
542
|
+
config = {"user_agent": f"arraylake/{arraylake_version} (uuid={str(user_id)})" if user_id else f"arraylake/{arraylake_version}"}
|
|
543
|
+
return icechunk.ObjectStoreConfig.Gcs(config)
|
|
544
|
+
elif self.platform == "azure":
|
|
545
|
+
return icechunk.ObjectStoreConfig.Azure({})
|
|
546
|
+
else:
|
|
547
|
+
raise ValueError(f"Unsupported platform for VCC store config: {self.platform}")
|
|
548
|
+
|
|
474
549
|
|
|
475
550
|
class Bucket(BucketModel):
|
|
476
551
|
"""A bucket configuration which actually exists in arraylake."""
|
|
@@ -1157,6 +1232,10 @@ class VirtualChunkCredentials(BaseModel):
|
|
|
1157
1232
|
|
|
1158
1233
|
The org, bucket_nickname, and platform fields are needed so the client can
|
|
1159
1234
|
construct credential refresh closures for delegated-creds buckets.
|
|
1235
|
+
|
|
1236
|
+
The store config fields (region, endpoint_url, allow_http, force_path_style)
|
|
1237
|
+
allow the client to construct VirtualChunkContainer objects without extra
|
|
1238
|
+
API calls to fetch the full bucket config.
|
|
1160
1239
|
"""
|
|
1161
1240
|
|
|
1162
1241
|
# TODO: consider a dedicated server-side refresh endpoint to avoid exposing cross-org metadata to the client?
|
|
@@ -1168,6 +1247,55 @@ class VirtualChunkCredentials(BaseModel):
|
|
|
1168
1247
|
# where subsync writes placeholder refs like vcc://__al_source/CHUNK_ID)
|
|
1169
1248
|
# If None, assume the VCC already exists
|
|
1170
1249
|
runtime_vcc_name: str | None = None
|
|
1250
|
+
# Store config fields for constructing VCCs without extra get_bucket_config() calls.
|
|
1251
|
+
# Populated from the bucket's extra_config via from_bucket().
|
|
1252
|
+
region: str | None = None
|
|
1253
|
+
endpoint_url: str | None = None
|
|
1254
|
+
allow_http: bool = False
|
|
1255
|
+
force_path_style: bool = False
|
|
1256
|
+
|
|
1257
|
+
@classmethod
|
|
1258
|
+
def from_bucket(
|
|
1259
|
+
cls,
|
|
1260
|
+
org: OrgName,
|
|
1261
|
+
bucket: BucketModel,
|
|
1262
|
+
credentials: TempCredentials | None,
|
|
1263
|
+
) -> VirtualChunkCredentials:
|
|
1264
|
+
"""Build VirtualChunkCredentials from a bucket model, extracting store config fields."""
|
|
1265
|
+
extra = bucket.extra_config
|
|
1266
|
+
use_ssl = extra.get("use_ssl", True)
|
|
1267
|
+
region = extra.get("region_name")
|
|
1268
|
+
endpoint_url = extra.get("endpoint_url")
|
|
1269
|
+
return cls(
|
|
1270
|
+
credentials=credentials,
|
|
1271
|
+
org=org,
|
|
1272
|
+
bucket_nickname=bucket.nickname,
|
|
1273
|
+
platform=bucket.platform,
|
|
1274
|
+
region=str(region) if region is not None else None,
|
|
1275
|
+
endpoint_url=str(endpoint_url) if endpoint_url is not None else None,
|
|
1276
|
+
allow_http=not use_ssl,
|
|
1277
|
+
force_path_style=bool(extra.get("force_path_style", False if use_ssl else True)),
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
def to_vcc_store_config(self, user_id: UUID | None = None) -> icechunk.AnyObjectStoreConfig:
|
|
1281
|
+
"""Create an Icechunk ObjectStoreConfig from the embedded store config fields."""
|
|
1282
|
+
if self.platform in ("s3", "s3-compatible", "minio"):
|
|
1283
|
+
options = icechunk.S3Options(
|
|
1284
|
+
region=self.region,
|
|
1285
|
+
endpoint_url=self.endpoint_url,
|
|
1286
|
+
allow_http=self.allow_http,
|
|
1287
|
+
force_path_style=self.force_path_style,
|
|
1288
|
+
)
|
|
1289
|
+
return icechunk.ObjectStoreConfig.S3(options)
|
|
1290
|
+
elif self.platform == "gs":
|
|
1291
|
+
from arraylake import __version__ as arraylake_version
|
|
1292
|
+
|
|
1293
|
+
config = {"user_agent": f"arraylake/{arraylake_version} (uuid={str(user_id)})" if user_id else f"arraylake/{arraylake_version}"}
|
|
1294
|
+
return icechunk.ObjectStoreConfig.Gcs(config)
|
|
1295
|
+
elif self.platform == "azure":
|
|
1296
|
+
return icechunk.ObjectStoreConfig.Azure({})
|
|
1297
|
+
else:
|
|
1298
|
+
raise ValueError(f"Unsupported platform for VCC store config: {self.platform}")
|
|
1171
1299
|
|
|
1172
1300
|
|
|
1173
1301
|
class OpenRepoResponse(BaseModel):
|
|
@@ -21,26 +21,26 @@ readme = "README.md"
|
|
|
21
21
|
authors = [
|
|
22
22
|
{ name = "Joseph Hamman", email = "joe@earthmover.io" },
|
|
23
23
|
]
|
|
24
|
-
requires-python = ">=3.
|
|
24
|
+
requires-python = ">=3.12, <3.15"
|
|
25
25
|
dynamic = ["version"]
|
|
26
26
|
license = "MIT"
|
|
27
27
|
keywords = []
|
|
28
28
|
classifiers = [
|
|
29
29
|
"Programming Language :: Python",
|
|
30
|
-
"Programming Language :: Python :: 3.11",
|
|
31
30
|
"Programming Language :: Python :: 3.12",
|
|
32
31
|
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Programming Language :: Python :: 3.14",
|
|
33
33
|
]
|
|
34
34
|
dependencies = [
|
|
35
35
|
"click >= 8.1.3, < 9.0",
|
|
36
36
|
"donfig >= 0.7, < 1.0",
|
|
37
|
-
"httpx >= 0.23, < 0.
|
|
38
|
-
"icechunk >=
|
|
37
|
+
"httpx >= 0.23, < 0.29",
|
|
38
|
+
"icechunk >= 2.0.1, < 3.0.0",
|
|
39
39
|
"numpy >= 1.23, < 3.0",
|
|
40
|
-
"packaging
|
|
40
|
+
"packaging>=23.0,<27.0",
|
|
41
41
|
"pydantic[email] >= 2.9, < 3.0",
|
|
42
42
|
"python-dateutil >= 2.8, < 3.0",
|
|
43
|
-
"rich
|
|
43
|
+
"rich>=12.6,<16.0",
|
|
44
44
|
"ruamel-yaml >= 0.17, < 1.0",
|
|
45
45
|
"structlog >= 24.1, < 26.0",
|
|
46
46
|
"typer >= 0.12.0, < 1.0",
|
|
@@ -55,31 +55,28 @@ xarray = [
|
|
|
55
55
|
maximal = [
|
|
56
56
|
"xarray[accel,etc,io,parallel] >= 2024.10.0",
|
|
57
57
|
"cf_xarray >= 0.10.4",
|
|
58
|
-
"numpy<2.3", # until dask[complete] updates their constraints
|
|
59
|
-
"pyarrow<22", # restrict for better resolution. comes via dask[complete]
|
|
60
58
|
]
|
|
61
59
|
|
|
62
60
|
[dependency-groups]
|
|
63
61
|
dev = [
|
|
64
|
-
"cloudpickle
|
|
62
|
+
"cloudpickle>=3.0,<3.2",
|
|
65
63
|
"hatch>=1.14.2",
|
|
66
64
|
"hypothesis>=6.88.1",
|
|
67
|
-
"icechunk >=
|
|
65
|
+
"icechunk >= 2.0.1, < 3.0.0",
|
|
68
66
|
"mypy~=1.11",
|
|
69
|
-
"pytest-asyncio
|
|
67
|
+
"pytest-asyncio>=0.21.1,<1.4.0",
|
|
70
68
|
"pytest-click~=1.1.0",
|
|
71
|
-
"pytest-cov
|
|
69
|
+
"pytest-cov>=4.0,<7.2",
|
|
72
70
|
"pytest-incremental~=0.6.0",
|
|
73
|
-
"pytest-
|
|
74
|
-
"pytest-
|
|
75
|
-
"pytest-
|
|
76
|
-
"pytest-
|
|
77
|
-
"pytest
|
|
78
|
-
"pytest~=7.3",
|
|
71
|
+
"pytest-reportlog>=0.4,<1.1",
|
|
72
|
+
"pytest-retry>=1.6.2,<1.8.0",
|
|
73
|
+
"pytest-timeout>=2.1,<2.5",
|
|
74
|
+
"pytest-xdist>=3.5,<3.9",
|
|
75
|
+
"pytest>=7.3,<10.0",
|
|
79
76
|
"pyyaml~=6.0.1",
|
|
80
|
-
"respx
|
|
81
|
-
"types-cachetools
|
|
82
|
-
"types-python-dateutil
|
|
77
|
+
"respx>=0.22,<0.24",
|
|
78
|
+
"types-cachetools>=5.3,<7.0",
|
|
79
|
+
"types-python-dateutil>=2.8.19,<2.10.0",
|
|
83
80
|
"types-pyyaml~=6.0.12",
|
|
84
81
|
"types-urllib3~=1.26.25",
|
|
85
82
|
]
|
|
@@ -16,7 +16,9 @@ from arraylake.api_utils import (
|
|
|
16
16
|
AsyncRetryTransport,
|
|
17
17
|
TokenAuth,
|
|
18
18
|
UserAuth,
|
|
19
|
+
_emitted_deprecation_warnings,
|
|
19
20
|
_get_proxy_from_env,
|
|
21
|
+
_get_session_id,
|
|
20
22
|
calc_backoff,
|
|
21
23
|
handle_response,
|
|
22
24
|
retry_on_exception,
|
|
@@ -80,6 +82,24 @@ async def test_http_request_headers(respx_mock, token, test_token_file) -> None:
|
|
|
80
82
|
assert route.calls.last.request.headers["special"] == "header"
|
|
81
83
|
|
|
82
84
|
|
|
85
|
+
def test_session_id_stable_within_process() -> None:
|
|
86
|
+
"""Two clients in the same process share one session id (the point of the feature)."""
|
|
87
|
+
c1 = ArraylakeHttpClient("https://foo.com")
|
|
88
|
+
c2 = ArraylakeHttpClient("https://foo.com")
|
|
89
|
+
sid = _get_session_id()
|
|
90
|
+
assert c1._default_headers["arraylake-session-id"] == sid
|
|
91
|
+
assert c2._default_headers["arraylake-session-id"] == sid
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_session_id_survives_pickling() -> None:
|
|
95
|
+
"""Session id rides along when the client is pickled → one dask cluster, one session."""
|
|
96
|
+
client = ArraylakeHttpClient("https://foo.com")
|
|
97
|
+
sid = client._default_headers["arraylake-session-id"]
|
|
98
|
+
assert sid
|
|
99
|
+
restored = pickle.loads(pickle.dumps(client))
|
|
100
|
+
assert restored._default_headers["arraylake-session-id"] == sid
|
|
101
|
+
|
|
102
|
+
|
|
83
103
|
def test_calc_backoff() -> None:
|
|
84
104
|
assert calc_backoff(0, backoff_factor=0.5, jitter_ratio=0.1, max_backoff_wait=10) == 0
|
|
85
105
|
assert calc_backoff(1, backoff_factor=0.5, jitter_ratio=0.0, max_backoff_wait=10) == 0.5
|
|
@@ -167,7 +187,7 @@ def test_token_auth_does_not_mutate_request(test_token, method, headers, json) -
|
|
|
167
187
|
if k != "authorization": # keys are lower case
|
|
168
188
|
assert header == request.headers.get(k)
|
|
169
189
|
if json:
|
|
170
|
-
assert json_lib.
|
|
190
|
+
assert json_lib.loads(request.content) == json
|
|
171
191
|
|
|
172
192
|
|
|
173
193
|
@pytest.mark.asyncio
|
|
@@ -226,7 +246,7 @@ async def test_user_auth_does_not_mutate_request(test_token_file, method, header
|
|
|
226
246
|
if k != "authorization": # keys are lower case
|
|
227
247
|
assert header == request.headers.get(k)
|
|
228
248
|
if json:
|
|
229
|
-
assert json_lib.
|
|
249
|
+
assert json_lib.loads(request.content) == json
|
|
230
250
|
|
|
231
251
|
|
|
232
252
|
@pytest.mark.asyncio
|
|
@@ -396,7 +416,7 @@ def test_handle_response_error(log_output: LogCapture) -> None:
|
|
|
396
416
|
entries = log_output.entries[0]
|
|
397
417
|
assert entries["log_level"] == "debug"
|
|
398
418
|
assert entries["url"] == "https://earthmover.io/content/foo?a=b"
|
|
399
|
-
assert entries["request_content"] ==
|
|
419
|
+
assert json_lib.loads(entries["request_content"]) == {"foo": "bar"}
|
|
400
420
|
assert entries["request_headers"]["authorization"] == "[omitted]"
|
|
401
421
|
assert entries["response_headers"]["content-type"] == "application/json"
|
|
402
422
|
assert entries["response_headers"]["x-request-id"] == "7eea73c041524f268f804af813f7618f"
|
|
@@ -1011,6 +1031,32 @@ def test_arraylake_http_client_explicit_overrides_config() -> None:
|
|
|
1011
1031
|
config.clear()
|
|
1012
1032
|
|
|
1013
1033
|
|
|
1034
|
+
def _ok_response(headers: dict[str, str] | None = None) -> httpx.Response:
|
|
1035
|
+
return httpx.Response(200, request=httpx.Request("GET", "https://example.com/api"), headers=headers or {})
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def test_deprecation_warnings() -> None:
|
|
1039
|
+
import warnings
|
|
1040
|
+
|
|
1041
|
+
_emitted_deprecation_warnings.clear()
|
|
1042
|
+
|
|
1043
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
1044
|
+
warnings.simplefilter("always")
|
|
1045
|
+
# no header -> no warning
|
|
1046
|
+
handle_response(_ok_response())
|
|
1047
|
+
# header -> warning
|
|
1048
|
+
handle_response(_ok_response({"X-Deprecation-Warning": "A is deprecated"}))
|
|
1049
|
+
# same message again -> deduplicated
|
|
1050
|
+
handle_response(_ok_response({"X-Deprecation-Warning": "A is deprecated"}))
|
|
1051
|
+
# different message -> new warning
|
|
1052
|
+
handle_response(_ok_response({"X-Deprecation-Warning": "B is deprecated"}))
|
|
1053
|
+
|
|
1054
|
+
deprecation_warnings = [w for w in caught if issubclass(w.category, DeprecationWarning)]
|
|
1055
|
+
assert len(deprecation_warnings) == 2
|
|
1056
|
+
assert "A is deprecated" in str(deprecation_warnings[0].message)
|
|
1057
|
+
assert "B is deprecated" in str(deprecation_warnings[1].message)
|
|
1058
|
+
|
|
1059
|
+
|
|
1014
1060
|
@pytest.mark.asyncio
|
|
1015
1061
|
async def test_config_proxy_end_to_end_integration(respx_mock, monkeypatch) -> None:
|
|
1016
1062
|
"""Test end-to-end proxy configuration via arraylake config"""
|
|
@@ -201,6 +201,47 @@ async def test_create_repo_in_anon_bucket(isolated_org, minio_anon_bucket, token
|
|
|
201
201
|
await aclient.create_repo(repo_name)
|
|
202
202
|
|
|
203
203
|
|
|
204
|
+
@pytest.mark.asyncio
|
|
205
|
+
async def test_create_repo_default_creates_ic2_repo(isolated_org, default_bucket, token):
|
|
206
|
+
"""create_repo with default spec_version creates IC2 repos (spec_version=2)."""
|
|
207
|
+
async with isolated_org(default_bucket()) as (org_name, buckets):
|
|
208
|
+
client = AsyncClient(token=token)
|
|
209
|
+
repo = await client.create_repo(
|
|
210
|
+
f"{org_name}/test-default-repo",
|
|
211
|
+
bucket_config_nickname=buckets[0].nickname,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
assert repo.spec_version == 2, f"Default create_repo should create IC2 repo (spec_version=2), got {repo.spec_version}"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@pytest.mark.asyncio
|
|
218
|
+
async def test_create_repo_spec_version_1_creates_ic1_repo(isolated_org, default_bucket, token):
|
|
219
|
+
"""create_repo with spec_version=1 creates IC1 repos."""
|
|
220
|
+
async with isolated_org(default_bucket()) as (org_name, buckets):
|
|
221
|
+
client = AsyncClient(token=token)
|
|
222
|
+
repo = await client.create_repo(
|
|
223
|
+
f"{org_name}/test-ic1-explicit",
|
|
224
|
+
bucket_config_nickname=buckets[0].nickname,
|
|
225
|
+
spec_version=1,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
assert repo.spec_version == 1, f"spec_version=1 should create IC1 repo, got {repo.spec_version}"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@pytest.mark.asyncio
|
|
232
|
+
async def test_create_repo_spec_version_2_creates_ic2_repo(isolated_org, default_bucket, token):
|
|
233
|
+
"""create_repo with spec_version=2 creates IC2 repos."""
|
|
234
|
+
async with isolated_org(default_bucket()) as (org_name, buckets):
|
|
235
|
+
client = AsyncClient(token=token)
|
|
236
|
+
repo = await client.create_repo(
|
|
237
|
+
f"{org_name}/test-ic2-explicit",
|
|
238
|
+
bucket_config_nickname=buckets[0].nickname,
|
|
239
|
+
spec_version=2,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
assert repo.spec_version == 2, f"spec_version=2 should create IC2 repo, got {repo.spec_version}"
|
|
243
|
+
|
|
244
|
+
|
|
204
245
|
@pytest.mark.asyncio
|
|
205
246
|
async def test_modify_repo(isolated_org, default_bucket, token) -> None:
|
|
206
247
|
async with isolated_org(default_bucket()) as (org_name, buckets):
|