arraylake 0.30.0__tar.gz → 0.32.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-0.30.0 → arraylake-0.32.0}/PKG-INFO +1 -1
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/_version.py +2 -2
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/client.py +176 -56
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/credentials.py +1 -1
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/metastore/http_metastore.py +81 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/repos/icechunk/storage.py +2 -2
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/repos/icechunk/virtual.py +1 -12
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/token.py +9 -7
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/types.py +145 -5
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_cli.py +20 -9
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_client.py +74 -30
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_credentials.py +1 -1
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_performance.py +28 -16
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_subscriptions.py +32 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_token_handler.py +7 -8
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_types.py +27 -2
- arraylake-0.32.0/tests/test_virtual_chunks.py +1201 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/uv.lock +146 -148
- arraylake-0.30.0/tests/test_virtual_chunks.py +0 -690
- {arraylake-0.30.0 → arraylake-0.32.0}/.gitignore +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/README.md +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/__init__.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/__main__.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/api_utils.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/asyn.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/cli/__init__.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/cli/auth.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/cli/compute.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/cli/config.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/cli/main.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/cli/repo.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/cli/utils.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/compute/doctor.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/compute/http_client.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/compute/services.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/compute/types.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/config.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/config.yaml +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/diagnostics.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/display/__init__.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/display/repo.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/display/repolist.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/exceptions.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/log_util.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/metastore/__init__.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/metastore/abc.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/py.typed +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/repos/__init__.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/repos/icechunk/__init__.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/repos/icechunk/types.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/tuning.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/pyproject.toml +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/__init__.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/compute/test_client.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/compute/test_doctor.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/config.yaml +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/conftest.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/icechunk/__init__.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/icechunk/conftest.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/icechunk/test_storage.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/icechunk/test_virtual.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_api_utils.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_asyn.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_config.py +0 -0
- {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_diagnostics.py +0 -0
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.32.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 32, 0)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -38,7 +38,7 @@ from arraylake.credentials import (
|
|
|
38
38
|
_use_hmac_credentials,
|
|
39
39
|
)
|
|
40
40
|
from arraylake.display.repolist import RepoList
|
|
41
|
-
from arraylake.exceptions import
|
|
41
|
+
from arraylake.exceptions import BucketNotFoundError
|
|
42
42
|
from arraylake.log_util import get_logger
|
|
43
43
|
from arraylake.metastore import HttpMetastore, HttpMetastoreConfig
|
|
44
44
|
from arraylake.repos.icechunk.storage import (
|
|
@@ -57,12 +57,12 @@ from arraylake.types import (
|
|
|
57
57
|
ICECHUNK_ANY_CREDENTIAL,
|
|
58
58
|
URI,
|
|
59
59
|
ApiClientResponse,
|
|
60
|
-
ApiTokenInfo,
|
|
61
60
|
Author,
|
|
62
61
|
AzureCredentials,
|
|
63
62
|
BucketNickname,
|
|
64
63
|
BucketPrefix,
|
|
65
64
|
BucketResponse,
|
|
65
|
+
ExplicitVirtualChunkAccessPolicyResponse,
|
|
66
66
|
GSCredentials,
|
|
67
67
|
NewBucket,
|
|
68
68
|
OptimizationConfig,
|
|
@@ -78,7 +78,6 @@ from arraylake.types import (
|
|
|
78
78
|
S3Credentials,
|
|
79
79
|
StorageOptions,
|
|
80
80
|
TempCredentials,
|
|
81
|
-
UserInfo,
|
|
82
81
|
validate_name,
|
|
83
82
|
validate_org_and_repo_name,
|
|
84
83
|
)
|
|
@@ -491,6 +490,8 @@ class AsyncClient:
|
|
|
491
490
|
Returns:
|
|
492
491
|
A icechunk.Repository object.
|
|
493
492
|
"""
|
|
493
|
+
from arraylake import __version__ as arraylake_version
|
|
494
|
+
|
|
494
495
|
if isinstance(config, RepositoryConfig):
|
|
495
496
|
pass
|
|
496
497
|
elif config is None:
|
|
@@ -500,70 +501,84 @@ class AsyncClient:
|
|
|
500
501
|
raise ValueError(f"config must be an icechunk.RepositoryConfig object or None: {config}.")
|
|
501
502
|
|
|
502
503
|
org, repo_name = _parse_org_and_repo(name)
|
|
503
|
-
|
|
504
504
|
mstore = self._metastore_for_org(org)
|
|
505
505
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
try:
|
|
509
|
-
return await mstore.get_virtual_chunk_containers(repo_name)
|
|
510
|
-
except ArraylakeClientError as e:
|
|
511
|
-
if e.status_code == 404:
|
|
512
|
-
return {} # Repo doesn't exist - get_database will raise the proper error
|
|
513
|
-
raise
|
|
514
|
-
|
|
515
|
-
# optimization: fetch everything we might need from the metastore concurrently
|
|
516
|
-
if authorize_virtual_chunk_access is None or authorize_virtual_chunk_access:
|
|
517
|
-
# TODO these 3 concurrent API requests could instead be handled by a single new endpoint
|
|
518
|
-
repo_model_raw, user_raw, preset_vccs = await async_gather_tasks(
|
|
519
|
-
mstore.get_database(repo_name),
|
|
520
|
-
mstore.get_user(),
|
|
521
|
-
# This call is slower because it requires the server to also fetch the repo config from object storage
|
|
522
|
-
_get_vccs_or_empty_on_missing_repo(),
|
|
523
|
-
)
|
|
524
|
-
else:
|
|
525
|
-
# authorize_virtual_chunk_access is an empty dict, so no VCCs to authorize, and no need to fetch them from repo config
|
|
526
|
-
repo_model_raw, user_raw = await async_gather_tasks(
|
|
527
|
-
mstore.get_database(repo_name),
|
|
528
|
-
mstore.get_user(),
|
|
529
|
-
)
|
|
530
|
-
preset_vccs = {}
|
|
531
|
-
|
|
532
|
-
# TODO this "_raw" business is just for mypy? Surely it's not necessary...
|
|
533
|
-
if not isinstance(repo_model_raw, RepoModel):
|
|
534
|
-
raise ValueError("Expected Repo object from metastore")
|
|
535
|
-
if not isinstance(user_raw, (ApiTokenInfo, UserInfo)):
|
|
536
|
-
raise ValueError("Expected UserInfo or ApiTokenInfo from metastore")
|
|
537
|
-
if not isinstance(preset_vccs, dict):
|
|
538
|
-
raise ValueError("Expected dict from metastore")
|
|
539
|
-
|
|
540
|
-
repo_model = repo_model_raw
|
|
541
|
-
user = user_raw
|
|
542
|
-
author: Author = user.as_author()
|
|
506
|
+
# Single API call to get everything needed to open the repo
|
|
507
|
+
response = await mstore.open_repo(repo_name)
|
|
543
508
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
509
|
+
if response.status.mode == RepoOperationMode.OFFLINE:
|
|
510
|
+
raise ValueError(f"Repository is offline, check back after {response.status.estimated_end_time}")
|
|
511
|
+
elif response.status.mode == RepoOperationMode.MAINTENANCE:
|
|
512
|
+
warnings.warn(
|
|
513
|
+
f"Repository is in maintenance mode until {response.status.estimated_end_time}, do not attempt to write to the store"
|
|
514
|
+
)
|
|
547
515
|
|
|
548
|
-
#
|
|
549
|
-
|
|
550
|
-
org=org,
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
516
|
+
# Build icechunk storage from the response
|
|
517
|
+
credential_refresh_func = self._maybe_get_credential_refresh_func_for_icechunk(
|
|
518
|
+
bucket=response.repo_bucket, org=org, repo_name=repo_name
|
|
519
|
+
)
|
|
520
|
+
credentials = response.repo_credentials if credential_refresh_func is None else None
|
|
521
|
+
icechunk_storage = _get_icechunk_storage_obj(
|
|
522
|
+
bucket_config=response.repo_bucket,
|
|
523
|
+
prefix=response.repo_prefix,
|
|
524
|
+
credential_type=_get_credential_type(credentials, credential_refresh_func),
|
|
525
|
+
credentials=credentials,
|
|
526
|
+
credential_refresh_func=credential_refresh_func,
|
|
527
|
+
scatter_initial_credentials=arraylake_config.get("icechunk.scatter_initial_credentials", True),
|
|
528
|
+
arraylake_version=arraylake_version,
|
|
529
|
+
user_id=UUID(response.principal_id),
|
|
530
|
+
storage_options=storage_options,
|
|
554
531
|
)
|
|
555
532
|
|
|
556
|
-
if
|
|
557
|
-
|
|
533
|
+
# Check if the user passed a custom config with VCCs that weren't authorized by the repo writer
|
|
534
|
+
server_authorized_vcc_prefixes = set(response.virtual_chunk_credentials.keys())
|
|
535
|
+
config_vccs = config.virtual_chunk_containers if config.virtual_chunk_containers is not None else {}
|
|
536
|
+
if config_vccs:
|
|
537
|
+
unauthorized_vccs = set(config_vccs.keys()) - server_authorized_vcc_prefixes
|
|
538
|
+
if unauthorized_vccs:
|
|
539
|
+
# TODO this is likely uneccessarily restrictive
|
|
540
|
+
raise ValueError(
|
|
541
|
+
f"All virtual chunk access must be pre-authorized by a user with repo writer permissions, not just by the user reading the repo. "
|
|
542
|
+
f"The following virtual chunk container prefixes in your custom config were not authorized by a repo writer: {unauthorized_vccs}"
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
# Resolve VCC credentials
|
|
546
|
+
vcc_credentials: dict[BucketPrefix, icechunk.AnyCredential | None]
|
|
547
|
+
if authorize_virtual_chunk_access is not None:
|
|
548
|
+
# User explicitly provided VCC overrides — fetch credentials for those buckets
|
|
549
|
+
# This incurs an extra roundtrip to the DB but it's not the happy-path so that's okay.
|
|
550
|
+
vcc_credentials = await self._containers_credentials_for_buckets(
|
|
551
|
+
org,
|
|
552
|
+
containers_to_buckets_map=dict(authorize_virtual_chunk_access),
|
|
553
|
+
forbid_configs_unsafe_for_virtual_chunks=True,
|
|
554
|
+
)
|
|
555
|
+
else:
|
|
556
|
+
# Use server-provided VCC credentials
|
|
557
|
+
vcc_credentials = {}
|
|
558
|
+
if response.virtual_chunk_credentials:
|
|
559
|
+
credentials_map: dict[str, icechunk.AnyS3Credential | icechunk.AnyGcsCredential | icechunk.AnyAzureCredential | None] = {}
|
|
560
|
+
for vcc_prefix, vcc_creds in response.virtual_chunk_credentials.items():
|
|
561
|
+
# TODO: build refresh closures here using vcc_creds.org, vcc_creds.bucket_nickname,
|
|
562
|
+
# and vcc_creds.platform so that credentials can be refreshed when they expire. (See https://github.com/earth-mover/arraylake/issues/5122)
|
|
563
|
+
# TODO: support Azure virtual chunk credentials
|
|
564
|
+
if isinstance(vcc_creds.credentials, AzureCredentials):
|
|
565
|
+
raise NotImplementedError(f"Azure virtual chunk credentials are not yet supported. (prefix: {vcc_prefix}).")
|
|
566
|
+
# TODO: fix return type of get_icechunk_container_credentials (returns GcsBearerCredential
|
|
567
|
+
# which is not in icechunk's AnyGcsCredential union — icechunk typing gap)
|
|
568
|
+
credentials_map[vcc_prefix] = get_icechunk_container_credentials( # type: ignore[assignment]
|
|
569
|
+
bucket_platform=vcc_creds.platform,
|
|
570
|
+
credentials=vcc_creds.credentials,
|
|
571
|
+
credential_refresh_func=None,
|
|
572
|
+
)
|
|
573
|
+
vcc_credentials = icechunk.containers_credentials(credentials_map)
|
|
558
574
|
|
|
559
|
-
# TODO skip fetching the config again in the case that we have already fetched it previously? See https://github.com/earth-mover/arraylake/pull/3590#discussion_r2397969758
|
|
560
575
|
ic_repo: IcechunkRepository = await IcechunkRepository.open_async(
|
|
561
576
|
icechunk_storage,
|
|
562
|
-
config=config,
|
|
563
|
-
authorize_virtual_chunk_access=
|
|
577
|
+
config=config,
|
|
578
|
+
authorize_virtual_chunk_access=vcc_credentials,
|
|
564
579
|
)
|
|
565
580
|
|
|
566
|
-
ic_repo.set_default_commit_metadata({"author_name": author.name, "author_email": author.email})
|
|
581
|
+
ic_repo.set_default_commit_metadata({"author_name": response.author.name, "author_email": response.author.email})
|
|
567
582
|
|
|
568
583
|
return ic_repo
|
|
569
584
|
|
|
@@ -1366,6 +1381,65 @@ class AsyncClient:
|
|
|
1366
1381
|
bucket_id = await self._bucket_id_for_nickname(mstore, nickname)
|
|
1367
1382
|
await mstore.delete_bucket_config(bucket_id)
|
|
1368
1383
|
|
|
1384
|
+
async def set_virtual_chunk_access_policy(
|
|
1385
|
+
self,
|
|
1386
|
+
org: OrgName,
|
|
1387
|
+
*,
|
|
1388
|
+
bucket_nickname: BucketNickname,
|
|
1389
|
+
subprefix: str = "",
|
|
1390
|
+
public: bool = False,
|
|
1391
|
+
) -> ExplicitVirtualChunkAccessPolicyResponse:
|
|
1392
|
+
"""Set an explicit virtual chunk access policy on this org.
|
|
1393
|
+
|
|
1394
|
+
Args:
|
|
1395
|
+
org: Name of the organization.
|
|
1396
|
+
bucket_nickname: Nickname of the bucket config to associate with this VCAP.
|
|
1397
|
+
subprefix: Subprefix within the bucket for this VCAP.
|
|
1398
|
+
public: Whether this VCAP allows public access.
|
|
1399
|
+
"""
|
|
1400
|
+
mstore = self._metastore_for_org(org)
|
|
1401
|
+
bucket_id = await self._bucket_id_for_nickname(mstore, bucket_nickname)
|
|
1402
|
+
vcap = await mstore.set_virtual_chunk_access_policy(
|
|
1403
|
+
bucket_id=str(bucket_id),
|
|
1404
|
+
subprefix=subprefix.strip("/"),
|
|
1405
|
+
public=public,
|
|
1406
|
+
)
|
|
1407
|
+
return ExplicitVirtualChunkAccessPolicyResponse.model_validate(vcap.model_dump())
|
|
1408
|
+
|
|
1409
|
+
async def list_virtual_chunk_access_policies(self, org: OrgName) -> list[ExplicitVirtualChunkAccessPolicyResponse]:
|
|
1410
|
+
"""List explicit virtual chunk access policies on this org.
|
|
1411
|
+
|
|
1412
|
+
Args:
|
|
1413
|
+
org: Name of the organization.
|
|
1414
|
+
"""
|
|
1415
|
+
mstore = self._metastore_for_org(org)
|
|
1416
|
+
vcaps = await mstore.list_virtual_chunk_access_policies()
|
|
1417
|
+
return [ExplicitVirtualChunkAccessPolicyResponse.model_validate(v.model_dump()) for v in vcaps if v.use_case == "explicit"]
|
|
1418
|
+
|
|
1419
|
+
async def delete_virtual_chunk_access_policy(
|
|
1420
|
+
self,
|
|
1421
|
+
org: OrgName,
|
|
1422
|
+
*,
|
|
1423
|
+
bucket_nickname: BucketNickname,
|
|
1424
|
+
subprefix: str,
|
|
1425
|
+
public: bool,
|
|
1426
|
+
) -> None:
|
|
1427
|
+
"""Delete a virtual chunk access policy from this org (idempotent).
|
|
1428
|
+
|
|
1429
|
+
Args:
|
|
1430
|
+
org: Name of the organization.
|
|
1431
|
+
bucket_nickname: Nickname of the bucket config for the VCAP.
|
|
1432
|
+
subprefix: Subprefix of the VCAP to delete.
|
|
1433
|
+
public: Whether this VCAP allows public access.
|
|
1434
|
+
"""
|
|
1435
|
+
mstore = self._metastore_for_org(org)
|
|
1436
|
+
bucket_id = await self._bucket_id_for_nickname(mstore, bucket_nickname)
|
|
1437
|
+
await mstore.delete_virtual_chunk_access_policy(
|
|
1438
|
+
bucket_id=str(bucket_id),
|
|
1439
|
+
subprefix=subprefix.strip("/"),
|
|
1440
|
+
public=public,
|
|
1441
|
+
)
|
|
1442
|
+
|
|
1369
1443
|
async def login(self, *, browser: bool = False) -> None:
|
|
1370
1444
|
"""Login to ArrayLake.
|
|
1371
1445
|
|
|
@@ -1878,6 +1952,52 @@ class Client:
|
|
|
1878
1952
|
"""
|
|
1879
1953
|
return sync(self.aclient.delete_bucket_config, org=org, nickname=nickname, imsure=imsure, imreallysure=imreallysure)
|
|
1880
1954
|
|
|
1955
|
+
def set_virtual_chunk_access_policy(
|
|
1956
|
+
self,
|
|
1957
|
+
org: OrgName,
|
|
1958
|
+
*,
|
|
1959
|
+
bucket_nickname: BucketNickname,
|
|
1960
|
+
subprefix: str,
|
|
1961
|
+
public: bool,
|
|
1962
|
+
) -> ExplicitVirtualChunkAccessPolicyResponse:
|
|
1963
|
+
"""Set an explicit virtual chunk access policy on this org.
|
|
1964
|
+
|
|
1965
|
+
Args:
|
|
1966
|
+
org: Name of the organization.
|
|
1967
|
+
bucket_nickname: Nickname of the bucket config to associate with this VCAP.
|
|
1968
|
+
subprefix: Subprefix within the bucket for this VCAP.
|
|
1969
|
+
public: Whether this VCAP allows public access.
|
|
1970
|
+
"""
|
|
1971
|
+
return sync(self.aclient.set_virtual_chunk_access_policy, org, bucket_nickname=bucket_nickname, subprefix=subprefix, public=public)
|
|
1972
|
+
|
|
1973
|
+
def list_virtual_chunk_access_policies(self, org: OrgName) -> list[ExplicitVirtualChunkAccessPolicyResponse]:
|
|
1974
|
+
"""List explicit virtual chunk access policies on this org.
|
|
1975
|
+
|
|
1976
|
+
Args:
|
|
1977
|
+
org: Name of the organization.
|
|
1978
|
+
"""
|
|
1979
|
+
return sync(self.aclient.list_virtual_chunk_access_policies, org)
|
|
1980
|
+
|
|
1981
|
+
def delete_virtual_chunk_access_policy(
|
|
1982
|
+
self,
|
|
1983
|
+
org: OrgName,
|
|
1984
|
+
*,
|
|
1985
|
+
bucket_nickname: BucketNickname,
|
|
1986
|
+
subprefix: str,
|
|
1987
|
+
public: bool,
|
|
1988
|
+
) -> None:
|
|
1989
|
+
"""Delete a virtual chunk access policy from this org.
|
|
1990
|
+
|
|
1991
|
+
Args:
|
|
1992
|
+
org: Name of the organization.
|
|
1993
|
+
bucket_nickname: Nickname of the bucket config for the VCAP.
|
|
1994
|
+
subprefix: Subprefix of the VCAP to delete.
|
|
1995
|
+
public: Whether this VCAP allows public access.
|
|
1996
|
+
"""
|
|
1997
|
+
return sync(
|
|
1998
|
+
self.aclient.delete_virtual_chunk_access_policy, org, bucket_nickname=bucket_nickname, subprefix=subprefix, public=public
|
|
1999
|
+
)
|
|
2000
|
+
|
|
1881
2001
|
def login(self, *, browser: bool = False) -> None:
|
|
1882
2002
|
"""Login to ArrayLake.
|
|
1883
2003
|
|
|
@@ -8,7 +8,7 @@ def _is_r2_bucket(bucket: BucketResponse | None) -> bool:
|
|
|
8
8
|
return False
|
|
9
9
|
|
|
10
10
|
endpoint_url = bucket.extra_config.get("endpoint_url")
|
|
11
|
-
if endpoint_url is not None and isinstance(endpoint_url, str) and "r2.
|
|
11
|
+
if endpoint_url is not None and isinstance(endpoint_url, str) and ".r2." in endpoint_url:
|
|
12
12
|
return True
|
|
13
13
|
return False
|
|
14
14
|
|
|
@@ -17,6 +17,7 @@ from arraylake.types import (
|
|
|
17
17
|
GSCredentials,
|
|
18
18
|
NewBucket,
|
|
19
19
|
NewRepoOperationStatus,
|
|
20
|
+
OpenRepoResponse,
|
|
20
21
|
OptimizationConfig,
|
|
21
22
|
OrgActions,
|
|
22
23
|
PaginatedResponse,
|
|
@@ -32,6 +33,7 @@ from arraylake.types import (
|
|
|
32
33
|
RepoOperationStatusResponse,
|
|
33
34
|
S3Credentials,
|
|
34
35
|
TokenAuthenticateBody,
|
|
36
|
+
VirtualChunkAccessPolicyResponse,
|
|
35
37
|
)
|
|
36
38
|
|
|
37
39
|
# type adapters
|
|
@@ -39,6 +41,7 @@ LIST_DATABASES_ADAPTER = TypeAdapter(list[Repo])
|
|
|
39
41
|
LIST_DATABASES_PAGINATED_ADAPTER = TypeAdapter(PaginatedResponse[Repo])
|
|
40
42
|
LIST_BUCKETS_ADAPTER = TypeAdapter(list[BucketResponse])
|
|
41
43
|
LIST_REPOS_FOR_BUCKET_ADAPTER = TypeAdapter(list[Repo])
|
|
44
|
+
LIST_VCAPS_PAGINATED_ADAPTER = TypeAdapter(PaginatedResponse[VirtualChunkAccessPolicyResponse])
|
|
42
45
|
|
|
43
46
|
|
|
44
47
|
@dataclass
|
|
@@ -82,6 +85,19 @@ class HttpMetastore(ArraylakeHttpClient, Metastore):
|
|
|
82
85
|
handle_response(response)
|
|
83
86
|
return Repo.model_validate_json(response.content)
|
|
84
87
|
|
|
88
|
+
async def open_repo(self, name: str) -> OpenRepoResponse:
|
|
89
|
+
"""Fetch everything needed to open an icechunk repo in a single request.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
name: Name of the repository (without org prefix).
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
OpenRepoResponse with storage config, credentials, VCC credentials, and author.
|
|
96
|
+
"""
|
|
97
|
+
response = await self._request("GET", f"/repos/{self._config.org}/{name}/open")
|
|
98
|
+
handle_response(response)
|
|
99
|
+
return OpenRepoResponse.model_validate_json(response.content)
|
|
100
|
+
|
|
85
101
|
async def list_databases(
|
|
86
102
|
self,
|
|
87
103
|
filter_metadata: RepoMetadataT | None = None,
|
|
@@ -311,3 +327,68 @@ class HttpMetastore(ArraylakeHttpClient, Metastore):
|
|
|
311
327
|
content=json.dumps(payload),
|
|
312
328
|
)
|
|
313
329
|
handle_response(response)
|
|
330
|
+
|
|
331
|
+
async def set_virtual_chunk_access_policy(
|
|
332
|
+
self,
|
|
333
|
+
*,
|
|
334
|
+
bucket_id: str,
|
|
335
|
+
subprefix: str,
|
|
336
|
+
public: bool,
|
|
337
|
+
) -> VirtualChunkAccessPolicyResponse:
|
|
338
|
+
"""Set an explicit virtual chunk access policy on this org (idempotent).
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
bucket_id: Bucket ID for this VCAP.
|
|
342
|
+
subprefix: Subprefix within the bucket for this VCAP.
|
|
343
|
+
public: Whether this VCAP allows public access.
|
|
344
|
+
"""
|
|
345
|
+
payload: dict[str, str | bool] = {
|
|
346
|
+
"use_case": "explicit",
|
|
347
|
+
"bucket_id": bucket_id,
|
|
348
|
+
"subprefix": subprefix,
|
|
349
|
+
"public": public,
|
|
350
|
+
}
|
|
351
|
+
response = await self._request(
|
|
352
|
+
"PUT",
|
|
353
|
+
f"/orgs/{self._config.org}/settings/virtual-chunk-access-policies",
|
|
354
|
+
content=json.dumps(payload),
|
|
355
|
+
)
|
|
356
|
+
handle_response(response)
|
|
357
|
+
return VirtualChunkAccessPolicyResponse.model_validate_json(response.content)
|
|
358
|
+
|
|
359
|
+
async def list_virtual_chunk_access_policies(self) -> list[VirtualChunkAccessPolicyResponse]:
|
|
360
|
+
"""List all virtual chunk access policies on this org."""
|
|
361
|
+
response = await self._request(
|
|
362
|
+
"GET",
|
|
363
|
+
f"/orgs/{self._config.org}/settings/virtual-chunk-access-policies",
|
|
364
|
+
params={"size": 100},
|
|
365
|
+
)
|
|
366
|
+
handle_response(response)
|
|
367
|
+
return LIST_VCAPS_PAGINATED_ADAPTER.validate_json(response.content).items
|
|
368
|
+
|
|
369
|
+
async def delete_virtual_chunk_access_policy(
|
|
370
|
+
self,
|
|
371
|
+
*,
|
|
372
|
+
bucket_id: str,
|
|
373
|
+
subprefix: str,
|
|
374
|
+
public: bool,
|
|
375
|
+
) -> None:
|
|
376
|
+
"""Delete an explicit virtual chunk access policy from this org (idempotent).
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
bucket_id: Bucket ID of the VCAP to delete.
|
|
380
|
+
subprefix: Subprefix of the VCAP to delete.
|
|
381
|
+
public: Whether this VCAP allows public access.
|
|
382
|
+
"""
|
|
383
|
+
payload: dict[str, str | bool] = {
|
|
384
|
+
"use_case": "explicit",
|
|
385
|
+
"bucket_id": bucket_id,
|
|
386
|
+
"subprefix": subprefix,
|
|
387
|
+
"public": public,
|
|
388
|
+
}
|
|
389
|
+
response = await self._request(
|
|
390
|
+
"DELETE",
|
|
391
|
+
f"/orgs/{self._config.org}/settings/virtual-chunk-access-policies",
|
|
392
|
+
content=json.dumps(payload),
|
|
393
|
+
)
|
|
394
|
+
handle_response(response)
|
|
@@ -143,7 +143,7 @@ def _get_icechunk_storage_obj(
|
|
|
143
143
|
tigris_kwargs["network_stream_timeout_seconds"] = network_stream_timeout_seconds
|
|
144
144
|
return icechunk.tigris_storage(**tigris_kwargs)
|
|
145
145
|
# Use r2_storage to create the storage object for R2 buckets
|
|
146
|
-
elif endpoint_url and "r2.
|
|
146
|
+
elif endpoint_url and ".r2." in endpoint_url:
|
|
147
147
|
r2_kwargs: dict = dict(
|
|
148
148
|
bucket=bucket_config.name,
|
|
149
149
|
prefix=prefix,
|
|
@@ -297,7 +297,7 @@ def create_icechunk_store_config(
|
|
|
297
297
|
)
|
|
298
298
|
return icechunk.ObjectStoreConfig.S3Compatible(options)
|
|
299
299
|
|
|
300
|
-
elif endpoint_url and "r2.
|
|
300
|
+
elif endpoint_url and ".r2." in endpoint_url:
|
|
301
301
|
options = icechunk.S3Options(
|
|
302
302
|
region=region,
|
|
303
303
|
endpoint_url=endpoint_url,
|
|
@@ -8,7 +8,6 @@ from arraylake.types import (
|
|
|
8
8
|
ICECHUNK_ANY_CREDENTIAL,
|
|
9
9
|
URI,
|
|
10
10
|
UUID,
|
|
11
|
-
AnonymousAuth,
|
|
12
11
|
BucketNickname,
|
|
13
12
|
BucketPrefix,
|
|
14
13
|
BucketResponse,
|
|
@@ -62,7 +61,7 @@ def get_icechunk_container_credentials(
|
|
|
62
61
|
# https://github.com/earth-mover/icechunk/pull/776
|
|
63
62
|
if credentials:
|
|
64
63
|
assert isinstance(credentials, GSCredentials)
|
|
65
|
-
return icechunk.
|
|
64
|
+
return icechunk.GcsCredentials.Static(icechunk.GcsStaticCredentials.BearerToken(credentials.access_token))
|
|
66
65
|
else:
|
|
67
66
|
return icechunk.GcsCredentials.Anonymous()
|
|
68
67
|
else:
|
|
@@ -164,13 +163,6 @@ def forbid_unsafe_virtual_bucket_configs(bucket: BucketResponse, bucket_nickname
|
|
|
164
163
|
f"But bucket {bucket_nickname} uses HMAC credentials."
|
|
165
164
|
)
|
|
166
165
|
|
|
167
|
-
# TODO: in future we could relax this to allow buckets with delegated credentials
|
|
168
|
-
elif not isinstance(auth_method, AnonymousAuth):
|
|
169
|
-
raise ValueError(
|
|
170
|
-
"Cannot use virtual chunk references that refer to a bucket which does not support anonymous public access, "
|
|
171
|
-
f"But bucket {bucket_nickname} uses {auth_method.method if auth_method else 'unknown'} credentials."
|
|
172
|
-
)
|
|
173
|
-
|
|
174
166
|
|
|
175
167
|
def forbid_unsafe_virtual_chunk_containers(
|
|
176
168
|
container_prefixes: Iterable[BucketPrefix],
|
|
@@ -178,9 +170,6 @@ def forbid_unsafe_virtual_chunk_containers(
|
|
|
178
170
|
"""
|
|
179
171
|
Raises if any virtual chunk container prefix is somehow unsafe.
|
|
180
172
|
"""
|
|
181
|
-
# TODO Once we allow auto-authorization of existing VCCs (needed for Flux), this fn will need to be changed.
|
|
182
|
-
# TODO It will need to actually fetch the existing VCCs, check they are all safe, and only then open the repo with authorization.
|
|
183
|
-
|
|
184
173
|
for url_prefix in container_prefixes:
|
|
185
174
|
if any(url_prefix.startswith(forbidden_prefix) for forbidden_prefix in FORBIDDEN_VIRTUAL_CHUNK_PREFIXES):
|
|
186
175
|
raise ValueError(
|
|
@@ -117,8 +117,10 @@ class TokenHandler:
|
|
|
117
117
|
response = client.get(f"{self.api_endpoint}/auth/config?target=client")
|
|
118
118
|
response.raise_for_status()
|
|
119
119
|
return AuthProviderConfig.model_validate_json(response.text)
|
|
120
|
-
except
|
|
121
|
-
raise AuthException("⚠️
|
|
120
|
+
except httpx.HTTPStatusError as e:
|
|
121
|
+
raise AuthException(f"⚠️ Error getting auth configuration: {e.response.status_code} from {e.request.url!r}")
|
|
122
|
+
except httpx.ConnectError:
|
|
123
|
+
raise AuthException(f"⚠️ Could not connect to {self.api_endpoint}. Check your network connection and service URI.")
|
|
122
124
|
|
|
123
125
|
async def get_authorize_info(self) -> Auth0UrlCode:
|
|
124
126
|
"""
|
|
@@ -263,7 +265,7 @@ class TokenHandler:
|
|
|
263
265
|
async with self._create_async_client() as client:
|
|
264
266
|
response = await client.get(f"{self.api_endpoint}/user", headers=headers)
|
|
265
267
|
if response.status_code != httpx.codes.OK:
|
|
266
|
-
raise AuthException("
|
|
268
|
+
raise AuthException(f"Error fetching user: {response.status_code} {response.text}")
|
|
267
269
|
return UserInfo.model_validate_json(response.content)
|
|
268
270
|
|
|
269
271
|
async def _update_user_profile(self) -> UserInfo:
|
|
@@ -273,7 +275,7 @@ class TokenHandler:
|
|
|
273
275
|
async with self._create_async_client() as client:
|
|
274
276
|
response = await client.post(f"{self.api_endpoint}/auth/update-user", headers=headers)
|
|
275
277
|
if response.status_code != httpx.codes.OK:
|
|
276
|
-
raise AuthException("
|
|
278
|
+
raise AuthException(f"Error updating user profile: {response.status_code} {response.text}")
|
|
277
279
|
return UserInfo.model_validate_json(response.content)
|
|
278
280
|
|
|
279
281
|
async def login(self, *, browser: bool = False) -> None:
|
|
@@ -321,9 +323,9 @@ class TokenHandler:
|
|
|
321
323
|
self.console.print(f"[green][bold]Successfully logged in![/bold][/green] [dim]Token stored at {self.token_path}[/dim]")
|
|
322
324
|
print_user_details(user)
|
|
323
325
|
|
|
324
|
-
except
|
|
325
|
-
self.
|
|
326
|
-
self.error_console.print("[
|
|
326
|
+
except AuthException as e:
|
|
327
|
+
self.console.print(f"[green][bold]Successfully logged in![/bold][/green] [dim]Token stored at {self.token_path}[/dim]")
|
|
328
|
+
self.error_console.print(f"\n[bold red]⚠️ Could not update user profile: {e}[/bold red]")
|
|
327
329
|
|
|
328
330
|
async def _logout(self) -> None:
|
|
329
331
|
try:
|