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.
Files changed (65) hide show
  1. {arraylake-0.30.0 → arraylake-0.32.0}/PKG-INFO +1 -1
  2. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/_version.py +2 -2
  3. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/client.py +176 -56
  4. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/credentials.py +1 -1
  5. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/metastore/http_metastore.py +81 -0
  6. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/repos/icechunk/storage.py +2 -2
  7. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/repos/icechunk/virtual.py +1 -12
  8. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/token.py +9 -7
  9. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/types.py +145 -5
  10. {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_cli.py +20 -9
  11. {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_client.py +74 -30
  12. {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_credentials.py +1 -1
  13. {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_performance.py +28 -16
  14. {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_subscriptions.py +32 -0
  15. {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_token_handler.py +7 -8
  16. {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_types.py +27 -2
  17. arraylake-0.32.0/tests/test_virtual_chunks.py +1201 -0
  18. {arraylake-0.30.0 → arraylake-0.32.0}/uv.lock +146 -148
  19. arraylake-0.30.0/tests/test_virtual_chunks.py +0 -690
  20. {arraylake-0.30.0 → arraylake-0.32.0}/.gitignore +0 -0
  21. {arraylake-0.30.0 → arraylake-0.32.0}/README.md +0 -0
  22. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/__init__.py +0 -0
  23. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/__main__.py +0 -0
  24. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/api_utils.py +0 -0
  25. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/asyn.py +0 -0
  26. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/cli/__init__.py +0 -0
  27. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/cli/auth.py +0 -0
  28. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/cli/compute.py +0 -0
  29. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/cli/config.py +0 -0
  30. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/cli/main.py +0 -0
  31. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/cli/repo.py +0 -0
  32. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/cli/utils.py +0 -0
  33. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/compute/doctor.py +0 -0
  34. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/compute/http_client.py +0 -0
  35. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/compute/services.py +0 -0
  36. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/compute/types.py +0 -0
  37. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/config.py +0 -0
  38. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/config.yaml +0 -0
  39. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/diagnostics.py +0 -0
  40. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/display/__init__.py +0 -0
  41. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/display/repo.py +0 -0
  42. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/display/repolist.py +0 -0
  43. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/exceptions.py +0 -0
  44. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/log_util.py +0 -0
  45. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/metastore/__init__.py +0 -0
  46. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/metastore/abc.py +0 -0
  47. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/py.typed +0 -0
  48. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/repos/__init__.py +0 -0
  49. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/repos/icechunk/__init__.py +0 -0
  50. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/repos/icechunk/types.py +0 -0
  51. {arraylake-0.30.0 → arraylake-0.32.0}/arraylake/tuning.py +0 -0
  52. {arraylake-0.30.0 → arraylake-0.32.0}/pyproject.toml +0 -0
  53. {arraylake-0.30.0 → arraylake-0.32.0}/tests/__init__.py +0 -0
  54. {arraylake-0.30.0 → arraylake-0.32.0}/tests/compute/test_client.py +0 -0
  55. {arraylake-0.30.0 → arraylake-0.32.0}/tests/compute/test_doctor.py +0 -0
  56. {arraylake-0.30.0 → arraylake-0.32.0}/tests/config.yaml +0 -0
  57. {arraylake-0.30.0 → arraylake-0.32.0}/tests/conftest.py +0 -0
  58. {arraylake-0.30.0 → arraylake-0.32.0}/tests/icechunk/__init__.py +0 -0
  59. {arraylake-0.30.0 → arraylake-0.32.0}/tests/icechunk/conftest.py +0 -0
  60. {arraylake-0.30.0 → arraylake-0.32.0}/tests/icechunk/test_storage.py +0 -0
  61. {arraylake-0.30.0 → arraylake-0.32.0}/tests/icechunk/test_virtual.py +0 -0
  62. {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_api_utils.py +0 -0
  63. {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_asyn.py +0 -0
  64. {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_config.py +0 -0
  65. {arraylake-0.30.0 → arraylake-0.32.0}/tests/test_diagnostics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arraylake
3
- Version: 0.30.0
3
+ Version: 0.32.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
@@ -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.30.0'
32
- __version_tuple__ = version_tuple = (0, 30, 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 ArraylakeClientError, BucketNotFoundError
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
- async def _get_vccs_or_empty_on_missing_repo() -> dict[BucketPrefix, BucketNickname | None]:
507
- """Get VCCs, returning empty dict if repo doesn't exist (let get_database raise that error)."""
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
- # Get the icechunk storage for the repo
545
- user_id = UUID(str(user.id)) if not isinstance(user.id, UUID) else user.id
546
- icechunk_storage = await self._get_icechunk_storage_from_repo_model(repo_model, user_id=user_id, storage_options=storage_options)
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
- # fetch credentials for any buckets containing virtual chunks referred to by this repo
549
- prefixes_credential_mapping = await self._maybe_fetch_credentials_for_virtual_chunks(
550
- org=org,
551
- config=config,
552
- pre_authorized_vccs=preset_vccs,
553
- authorize_virtual_chunk_access=authorize_virtual_chunk_access,
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 not isinstance(config, RepositoryConfig) and config is not None:
557
- raise ValueError(f"config must be an icechunk.RepositoryConfig object or None: {config}.")
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, # The config passed here takes precedence over the stored config
563
- authorize_virtual_chunk_access=prefixes_credential_mapping,
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.cloudflarestorage.com" in endpoint_url:
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.cloudflarestorage.com" in endpoint_url:
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.cloudflarestorage.com" in endpoint_url:
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.GcsBearerCredential(bearer=credentials.access_token, expires_after=credentials.expiration)
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 Exception:
121
- raise AuthException("⚠️ There was an error getting the auth configuration!")
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("There was an error getting your user!\nPlease contact support at support@earthmover.io.")
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("There was an error getting your user!\nPlease contact support at support@earthmover.io.")
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 Exception:
325
- self.error_console.print("\n[bold red] There was an error fetching your user![/bold red]")
326
- self.error_console.print("[dim]Please contact support: support@earthmover.io[/dim]")
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: