arraylake 1.0.1__tar.gz → 1.0.2__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 (64) hide show
  1. {arraylake-1.0.1 → arraylake-1.0.2}/PKG-INFO +3 -1
  2. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/_version.py +2 -2
  3. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/client.py +215 -38
  4. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/metastore/http_metastore.py +21 -10
  5. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/types.py +109 -1
  6. {arraylake-1.0.1 → arraylake-1.0.2}/pyproject.toml +7 -0
  7. {arraylake-1.0.1 → arraylake-1.0.2}/tests/test_client.py +123 -22
  8. {arraylake-1.0.1 → arraylake-1.0.2}/uv.lock +215 -154
  9. {arraylake-1.0.1 → arraylake-1.0.2}/.gitignore +0 -0
  10. {arraylake-1.0.1 → arraylake-1.0.2}/README.md +0 -0
  11. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/__init__.py +0 -0
  12. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/__main__.py +0 -0
  13. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/api_utils.py +0 -0
  14. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/asyn.py +0 -0
  15. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/cli/__init__.py +0 -0
  16. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/cli/auth.py +0 -0
  17. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/cli/compute.py +0 -0
  18. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/cli/config.py +0 -0
  19. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/cli/main.py +0 -0
  20. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/cli/repo.py +0 -0
  21. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/cli/utils.py +0 -0
  22. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/compute/doctor.py +0 -0
  23. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/compute/http_client.py +0 -0
  24. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/compute/services.py +0 -0
  25. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/compute/types.py +0 -0
  26. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/config.py +0 -0
  27. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/config.yaml +0 -0
  28. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/credentials.py +0 -0
  29. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/diagnostics.py +0 -0
  30. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/display/__init__.py +0 -0
  31. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/display/repo.py +0 -0
  32. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/display/repolist.py +0 -0
  33. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/exceptions.py +0 -0
  34. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/log_util.py +0 -0
  35. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/metastore/__init__.py +0 -0
  36. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/metastore/abc.py +0 -0
  37. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/py.typed +0 -0
  38. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/repos/__init__.py +0 -0
  39. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/repos/icechunk/__init__.py +0 -0
  40. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/repos/icechunk/storage.py +0 -0
  41. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/repos/icechunk/types.py +0 -0
  42. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/repos/icechunk/virtual.py +0 -0
  43. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/token.py +0 -0
  44. {arraylake-1.0.1 → arraylake-1.0.2}/arraylake/tuning.py +0 -0
  45. {arraylake-1.0.1 → arraylake-1.0.2}/tests/__init__.py +0 -0
  46. {arraylake-1.0.1 → arraylake-1.0.2}/tests/compute/test_client.py +0 -0
  47. {arraylake-1.0.1 → arraylake-1.0.2}/tests/compute/test_doctor.py +0 -0
  48. {arraylake-1.0.1 → arraylake-1.0.2}/tests/config.yaml +0 -0
  49. {arraylake-1.0.1 → arraylake-1.0.2}/tests/conftest.py +0 -0
  50. {arraylake-1.0.1 → arraylake-1.0.2}/tests/icechunk/__init__.py +0 -0
  51. {arraylake-1.0.1 → arraylake-1.0.2}/tests/icechunk/conftest.py +0 -0
  52. {arraylake-1.0.1 → arraylake-1.0.2}/tests/icechunk/test_storage.py +0 -0
  53. {arraylake-1.0.1 → arraylake-1.0.2}/tests/icechunk/test_virtual.py +0 -0
  54. {arraylake-1.0.1 → arraylake-1.0.2}/tests/test_api_utils.py +0 -0
  55. {arraylake-1.0.1 → arraylake-1.0.2}/tests/test_asyn.py +0 -0
  56. {arraylake-1.0.1 → arraylake-1.0.2}/tests/test_cli.py +0 -0
  57. {arraylake-1.0.1 → arraylake-1.0.2}/tests/test_config.py +0 -0
  58. {arraylake-1.0.1 → arraylake-1.0.2}/tests/test_credentials.py +0 -0
  59. {arraylake-1.0.1 → arraylake-1.0.2}/tests/test_diagnostics.py +0 -0
  60. {arraylake-1.0.1 → arraylake-1.0.2}/tests/test_performance.py +0 -0
  61. {arraylake-1.0.1 → arraylake-1.0.2}/tests/test_subscriptions.py +0 -0
  62. {arraylake-1.0.1 → arraylake-1.0.2}/tests/test_token_handler.py +0 -0
  63. {arraylake-1.0.1 → arraylake-1.0.2}/tests/test_types.py +0 -0
  64. {arraylake-1.0.1 → arraylake-1.0.2}/tests/test_virtual_chunks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arraylake
3
- Version: 1.0.1
3
+ Version: 1.0.2
4
4
  Summary: Python client for ArrayLake
5
5
  Project-URL: Documentation, https://docs.earthmover.io/
6
6
  Project-URL: Changelog, https://docs.earthmover.io/changelog
@@ -27,6 +27,8 @@ 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
29
  Requires-Dist: xarray[accel,etc,io,parallel]>=2024.10.0; extra == 'maximal'
30
+ Provides-Extra: obstore
31
+ Requires-Dist: obstore>=0.9.2; extra == 'obstore'
30
32
  Provides-Extra: xarray
31
33
  Requires-Dist: cf-xarray>=0.10.4; extra == 'xarray'
32
34
  Requires-Dist: xarray>=2024.10.0; 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 = '1.0.1'
22
- __version_tuple__ = version_tuple = (1, 0, 1)
21
+ __version__ = version = '1.0.2'
22
+ __version_tuple__ = version_tuple = (1, 0, 2)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -18,7 +18,7 @@ import warnings
18
18
  from collections.abc import AsyncGenerator, Callable, Generator, Mapping
19
19
  from datetime import UTC
20
20
  from functools import partial
21
- from typing import Literal
21
+ from typing import TYPE_CHECKING, Literal
22
22
  from urllib.parse import urlparse
23
23
  from uuid import UUID
24
24
 
@@ -83,6 +83,9 @@ from arraylake.types import (
83
83
  )
84
84
  from arraylake.types import Repo as RepoModel
85
85
 
86
+ if TYPE_CHECKING:
87
+ import obstore as obs
88
+
86
89
  logger = get_logger(__name__)
87
90
 
88
91
  _VALID_NAME = r"(\w[\w\.\-_]+)"
@@ -243,52 +246,67 @@ class AsyncClient:
243
246
  azure_creds = await mstore.get_azure_container_credentials_from_repo(repo_name)
244
247
  return azure_creds
245
248
 
246
- async def _get_s3_delegated_credentials_from_bucket(self, org: OrgName, nickname: BucketNickname) -> S3Credentials:
247
- """Get delegated credentials for a S3 bucket. These credentials are scoped
248
- to read-only.
249
+ async def _get_s3_delegated_credentials_from_bucket(
250
+ self, org: OrgName, nickname: BucketNickname, access: Literal["read", "write"] = "read"
251
+ ) -> S3Credentials:
252
+ """Get delegated credentials for an S3 bucket.
253
+
254
+ Defaults to read-only credentials. Pass ``access="write"`` to request read+write
255
+ credentials; the caller must have ``CAN_MANAGE_USERS`` on the org.
249
256
 
250
257
  Args:
251
258
  org: Name of the organization that the bucket belongs to.
252
259
  nickname: Nickname of the bucket.
260
+ access: ``"read"`` (default) or ``"write"``.
253
261
 
254
262
  Returns:
255
263
  S3Credentials: Temporary credentials for the S3 bucket.
256
264
  """
257
265
  mstore = self._metastore_for_org(org)
258
266
  bucket_id = await self._bucket_id_for_nickname(mstore, nickname)
259
- s3_creds = await mstore.get_s3_bucket_credentials_from_bucket(bucket_id)
267
+ s3_creds = await mstore.get_s3_bucket_credentials_from_bucket(bucket_id, access=access)
260
268
  return s3_creds
261
269
 
262
- async def _get_gcs_delegated_credentials_from_bucket(self, org: RepoName, nickname: BucketNickname) -> GSCredentials:
263
- """Get delegated credentials for a GCS bucket. These credentials are scoped
264
- to read-only.
270
+ async def _get_gcs_delegated_credentials_from_bucket(
271
+ self, org: RepoName, nickname: BucketNickname, access: Literal["read", "write"] = "read"
272
+ ) -> GSCredentials:
273
+ """Get delegated credentials for a GCS bucket.
274
+
275
+ Defaults to read-only credentials. Pass ``access="write"`` to request read+write
276
+ credentials; the caller must have ``CAN_MANAGE_USERS`` on the org.
265
277
 
266
278
  Args:
267
279
  org: Name of the organization that the bucket belongs to.
268
280
  nickname: Nickname of the bucket.
281
+ access: ``"read"`` (default) or ``"write"``.
269
282
 
270
283
  Returns:
271
284
  GSCredentials: Temporary credentials for the GCS bucket.
272
285
  """
273
286
  mstore = self._metastore_for_org(org)
274
287
  bucket_id = await self._bucket_id_for_nickname(mstore, nickname)
275
- gcs_creds = await mstore.get_gs_bucket_credentials_from_bucket(bucket_id)
288
+ gcs_creds = await mstore.get_gs_bucket_credentials_from_bucket(bucket_id, access=access)
276
289
  return gcs_creds
277
290
 
278
- async def _get_azure_delegated_credentials_from_bucket(self, org: RepoName, nickname: BucketNickname) -> AzureCredentials:
279
- """Get delegated credentials for an Azure Blob Storage container. These credentials are scoped
280
- to read-only.
291
+ async def _get_azure_delegated_credentials_from_bucket(
292
+ self, org: RepoName, nickname: BucketNickname, access: Literal["read", "write"] = "read"
293
+ ) -> AzureCredentials:
294
+ """Get delegated credentials for an Azure Blob Storage container.
295
+
296
+ Defaults to read-only credentials. Pass ``access="write"`` to request read+write
297
+ credentials; the caller must have ``CAN_MANAGE_USERS`` on the org.
281
298
 
282
299
  Args:
283
300
  org: Name of the organization that the bucket belongs to.
284
301
  nickname: Nickname of the bucket.
302
+ access: ``"read"`` (default) or ``"write"``.
285
303
 
286
304
  Returns:
287
305
  AzureCredentials: Temporary credentials for the Azure Blob Storage container.
288
306
  """
289
307
  mstore = self._metastore_for_org(org)
290
308
  bucket_id = await self._bucket_id_for_nickname(mstore, nickname)
291
- azure_creds = await mstore.get_azure_container_credentials_from_bucket(bucket_id)
309
+ azure_creds = await mstore.get_azure_container_credentials_from_bucket(bucket_id, access=access)
292
310
  return azure_creds
293
311
 
294
312
  def _get_icechunk_s3_credentials_refresh_function_for_repo(self, org: OrgName, repo_name: RepoName) -> icechunk.S3StaticCredentials:
@@ -394,28 +412,32 @@ class AsyncClient:
394
412
  bucket: BucketResponse,
395
413
  org: OrgName,
396
414
  repo_name: RepoName | None,
415
+ bucket_access: Literal["read", "write"] = "read",
397
416
  ) -> TempCredentials | None:
398
417
  """Checks if the bucket is configured for delegated or HMAC credentials and gets the
399
418
  credentials if it is configured.
400
419
 
401
420
  Returns None if delegated or HMAC credentials are not configured for the bucket.
421
+
422
+ ``bucket_access`` only applies when ``repo_name`` is None (the bucket-scoped path);
423
+ repo-scoped credential fetches always inherit the caller's permissions on the repo.
402
424
  """
403
425
  if _use_delegated_credentials(bucket):
404
426
  if bucket.platform == "s3" or _is_r2_bucket(bucket):
405
427
  if repo_name:
406
428
  return await self._get_s3_delegated_credentials_from_repo(org, repo_name)
407
429
  else:
408
- return await self._get_s3_delegated_credentials_from_bucket(org, bucket.nickname)
430
+ return await self._get_s3_delegated_credentials_from_bucket(org, bucket.nickname, access=bucket_access)
409
431
  elif bucket.platform == "gs":
410
432
  if repo_name:
411
433
  return await self._get_gcs_delegated_credentials_from_repo(org, repo_name)
412
434
  else:
413
- return await self._get_gcs_delegated_credentials_from_bucket(org, bucket.nickname)
435
+ return await self._get_gcs_delegated_credentials_from_bucket(org, bucket.nickname, access=bucket_access)
414
436
  elif bucket.platform == "azure":
415
437
  if repo_name:
416
438
  return await self._get_azure_delegated_credentials_from_repo(org, repo_name)
417
439
  else:
418
- return await self._get_azure_delegated_credentials_from_bucket(org, bucket.nickname)
440
+ return await self._get_azure_delegated_credentials_from_bucket(org, bucket.nickname, access=bucket_access)
419
441
  else:
420
442
  raise ValueError(f"Unsupported platform for delegated credentials: {bucket.platform}")
421
443
  elif _use_hmac_credentials(bucket):
@@ -1265,13 +1287,42 @@ class AsyncClient:
1265
1287
  raise BucketNotFoundError(nickname)
1266
1288
  return bucket_id
1267
1289
 
1268
- def _make_bucket_config(self, *, nickname: BucketNickname, uri: str, extra_config: dict | None, auth_config: dict | None) -> dict:
1290
+ def _make_bucket_config(
1291
+ self,
1292
+ *,
1293
+ nickname: BucketNickname,
1294
+ uri: str,
1295
+ region_name: str | None = None,
1296
+ endpoint_url: str | None = None,
1297
+ use_ssl: bool | None = None,
1298
+ force_path_style: bool | None = None,
1299
+ extra_config: dict | None = None,
1300
+ auth_config: dict | None = None,
1301
+ ) -> dict:
1269
1302
  if not nickname:
1270
1303
  raise ValueError("nickname must be specified if uri is provided.")
1271
1304
 
1305
+ # Build extra_config from explicit parameters, with extra_config dict as fallback
1306
+ # for backwards compatibility. Explicit parameters take precedence.
1307
+ merged_extra_config: dict[str, str | bool] = {}
1308
+ if extra_config is not None:
1309
+ warnings.warn(
1310
+ "Passing 'extra_config' as a dict is deprecated. "
1311
+ "Use the explicit keyword arguments instead: region_name, endpoint_url, use_ssl, force_path_style.",
1312
+ DeprecationWarning,
1313
+ stacklevel=3,
1314
+ )
1315
+ merged_extra_config.update(extra_config)
1316
+ if region_name is not None:
1317
+ merged_extra_config["region_name"] = region_name
1318
+ if endpoint_url is not None:
1319
+ merged_extra_config["endpoint_url"] = endpoint_url
1320
+ if use_ssl is not None:
1321
+ merged_extra_config["use_ssl"] = use_ssl
1322
+ if force_path_style is not None:
1323
+ merged_extra_config["force_path_style"] = force_path_style
1324
+
1272
1325
  # unpack optionals
1273
- if extra_config is None:
1274
- extra_config = {}
1275
1326
  if auth_config is None:
1276
1327
  auth_config = {"method": "anonymous"}
1277
1328
 
@@ -1280,7 +1331,7 @@ class AsyncClient:
1280
1331
  platform: Literal["s3", "gs", "s3-compatible", "azure"] | None = (
1281
1332
  "s3" if res.scheme == "s3" else "gs" if res.scheme == "gs" else "azure" if res.scheme == "az" else None
1282
1333
  )
1283
- if platform == "s3" and extra_config.get("endpoint_url"):
1334
+ if platform == "s3" and merged_extra_config.get("endpoint_url"):
1284
1335
  platform = "s3-compatible"
1285
1336
  if platform not in ["s3", "gs", "s3-compatible", "azure"]:
1286
1337
  raise ValueError(f"Invalid platform {platform} for uri {uri}")
@@ -1314,26 +1365,62 @@ class AsyncClient:
1314
1365
  name=name,
1315
1366
  prefix=prefix,
1316
1367
  nickname=nickname,
1317
- extra_config=extra_config,
1368
+ extra_config=merged_extra_config,
1318
1369
  auth_config=auth_config,
1319
1370
  )
1320
1371
 
1321
1372
  async def create_bucket_config(
1322
- self, *, org: OrgName, nickname: BucketNickname, uri: URI, extra_config: dict | None = None, auth_config: dict | None = None
1373
+ self,
1374
+ *,
1375
+ org: OrgName,
1376
+ nickname: BucketNickname,
1377
+ uri: URI,
1378
+ region_name: str | None = None,
1379
+ endpoint_url: str | None = None,
1380
+ use_ssl: bool | None = None,
1381
+ force_path_style: bool | None = None,
1382
+ extra_config: dict | None = None,
1383
+ auth_config: dict | None = None,
1323
1384
  ) -> BucketResponse:
1324
- """Create a new bucket config entry
1385
+ """Create a new bucket config entry.
1325
1386
 
1326
1387
  NOTE: This does not create any actual buckets in the object store.
1327
1388
 
1328
1389
  Args:
1329
- org: Name of the org
1330
- nickname: bucket nickname (example: ours3-bucket`)
1390
+ org: Name of the org.
1391
+ nickname: Bucket nickname (e.g. ``"my-s3-bucket"``).
1331
1392
  uri: The URI of the object store, of the form
1332
- platform://bucket_name[/prefix].
1333
- extra_config: dictionary of additional config to set on bucket config
1334
- auth_config: dictionary of auth parameters, must include "method" key, default is `{"method": "anonymous"}`
1335
- """
1336
- validated = NewBucket(**self._make_bucket_config(nickname=nickname, uri=uri, extra_config=extra_config, auth_config=auth_config))
1393
+ ``platform://bucket_name[/prefix]`` (e.g. ``"s3://my-bucket/prefix"``).
1394
+ region_name: Cloud region for the bucket (e.g. ``"us-west-2"``).
1395
+ Required for S3 buckets.
1396
+ endpoint_url: Custom endpoint URL for S3-compatible storage
1397
+ (e.g. ``"https://account-id.r2.cloudflarestorage.com"``).
1398
+ Setting this automatically marks the bucket as S3-compatible.
1399
+ use_ssl: Whether to use HTTPS for storage connections.
1400
+ Defaults to ``True``. Set to ``False`` for local or insecure endpoints.
1401
+ Only applies to S3-type buckets.
1402
+ force_path_style: Use path-style URLs (``endpoint/bucket``) instead of
1403
+ virtual-hosted style (``bucket.endpoint``). Defaults to ``False`` when
1404
+ ``use_ssl=True``, ``True`` when ``use_ssl=False``. Often required for
1405
+ S3-compatible storage. Only applies to S3-type buckets.
1406
+ extra_config: Dictionary of additional config options. Prefer using the
1407
+ explicit parameters above when possible. Keys set here are overridden
1408
+ by any explicit parameters that are also provided.
1409
+ auth_config: Dictionary of auth parameters. Must include a ``"method"`` key.
1410
+ Defaults to ``{"method": "anonymous"}``.
1411
+ """
1412
+ validated = NewBucket(
1413
+ **self._make_bucket_config(
1414
+ nickname=nickname,
1415
+ uri=uri,
1416
+ region_name=region_name,
1417
+ endpoint_url=endpoint_url,
1418
+ use_ssl=use_ssl,
1419
+ force_path_style=force_path_style,
1420
+ extra_config=extra_config,
1421
+ auth_config=auth_config,
1422
+ )
1423
+ )
1337
1424
  mstore = self._metastore_for_org(org)
1338
1425
  bucket = await mstore.create_bucket_config(validated)
1339
1426
  return bucket
@@ -1360,6 +1447,42 @@ class AsyncClient:
1360
1447
  bucket = await mstore.get_bucket_config(bucket_id)
1361
1448
  return bucket
1362
1449
 
1450
+ async def get_obstore_for_bucket(
1451
+ self, *, org: OrgName, nickname: BucketNickname, access: Literal["read", "write"] = "read"
1452
+ ) -> obs.store.ObjectStore:
1453
+ """Get an obstore ObjectStore for the named bucket.
1454
+
1455
+ Resolves the bucket nickname, fetches scoped credentials, and returns an
1456
+ ``obstore.store.S3Store``, ``GCSStore``, or ``AzureStore`` ready for use.
1457
+
1458
+ Defaults to read-only credentials. Pass ``access="write"`` to request a
1459
+ read+write store; that variant requires ``CAN_MANAGE_USERS`` on the org.
1460
+
1461
+ Requires the optional ``obstore`` extra (``pip install arraylake[obstore]``).
1462
+
1463
+ Args:
1464
+ org: Name of the org that owns the bucket.
1465
+ nickname: Nickname of the bucket config.
1466
+ access: ``"read"`` (default) or ``"write"``.
1467
+
1468
+ Returns:
1469
+ An obstore ObjectStore scoped to the bucket and its configured prefix.
1470
+
1471
+ Raises:
1472
+ ImportError: If the optional ``obstore`` extra is not installed.
1473
+ """
1474
+ # Fail fast before making any API calls if obstore isn't available.
1475
+ try:
1476
+ import obstore # noqa: F401
1477
+ except ImportError as e:
1478
+ raise ImportError(
1479
+ "The 'obstore' package is required for this functionality but is not installed. "
1480
+ "Install it with: pip install 'arraylake[obstore]'"
1481
+ ) from e
1482
+ bucket = await self.get_bucket_config(org=org, nickname=nickname)
1483
+ creds = await self._maybe_get_credentials_for_icechunk(bucket, org, repo_name=None, bucket_access=access)
1484
+ return bucket.as_obstore_store(creds)
1485
+
1363
1486
  async def list_bucket_configs(self, org: OrgName) -> list[BucketResponse]:
1364
1487
  """List all bucket config entries
1365
1488
 
@@ -1907,22 +2030,56 @@ class Client:
1907
2030
  return sync(self.aclient.delete_repo, name, imsure=imsure, imreallysure=imreallysure)
1908
2031
 
1909
2032
  def create_bucket_config(
1910
- self, *, org: OrgName, nickname: BucketNickname, uri: URI, extra_config: dict | None = None, auth_config: dict | None = None
2033
+ self,
2034
+ *,
2035
+ org: OrgName,
2036
+ nickname: BucketNickname,
2037
+ uri: URI,
2038
+ region_name: str | None = None,
2039
+ endpoint_url: str | None = None,
2040
+ use_ssl: bool | None = None,
2041
+ force_path_style: bool | None = None,
2042
+ extra_config: dict | None = None,
2043
+ auth_config: dict | None = None,
1911
2044
  ) -> BucketResponse:
1912
- """Create a new bucket config entry
2045
+ """Create a new bucket config entry.
1913
2046
 
1914
2047
  NOTE: This does not create any actual buckets in the object store.
1915
2048
 
1916
2049
  Args:
1917
- org: Name of the org
1918
- nickname: bucket nickname (example: our-s3-bucket)
2050
+ org: Name of the org.
2051
+ nickname: Bucket nickname (e.g. ``"my-s3-bucket"``).
1919
2052
  uri: The URI of the object store, of the form
1920
- platform://bucket_name[/prefix].
1921
- extra_config: dictionary of additional config to set on bucket config
1922
- auth_config: dictionary of auth parameters, must include "method" key, default is `{"method": "anonymous"}`
2053
+ ``platform://bucket_name[/prefix]`` (e.g. ``"s3://my-bucket/prefix"``).
2054
+ region_name: Cloud region for the bucket (e.g. ``"us-west-2"``).
2055
+ Required for S3 buckets.
2056
+ endpoint_url: Custom endpoint URL for S3-compatible storage
2057
+ (e.g. ``"https://account-id.r2.cloudflarestorage.com"``).
2058
+ Setting this automatically marks the bucket as S3-compatible.
2059
+ use_ssl: Whether to use HTTPS for storage connections.
2060
+ Defaults to ``True``. Set to ``False`` for local or insecure endpoints.
2061
+ Only applies to S3-type buckets.
2062
+ force_path_style: Use path-style URLs (``endpoint/bucket``) instead of
2063
+ virtual-hosted style (``bucket.endpoint``). Defaults to ``False`` when
2064
+ ``use_ssl=True``, ``True`` when ``use_ssl=False``. Often required for
2065
+ S3-compatible storage. Only applies to S3-type buckets.
2066
+ extra_config: Dictionary of additional config options. Prefer using the
2067
+ explicit parameters above when possible. Keys set here are overridden
2068
+ by any explicit parameters that are also provided.
2069
+ auth_config: Dictionary of auth parameters. Must include a ``"method"`` key.
2070
+ Defaults to ``{"method": "anonymous"}``.
1923
2071
  """
1924
2072
  return sync(
1925
- self.aclient.create_bucket_config, org=org, nickname=nickname, uri=uri, extra_config=extra_config, auth_config=auth_config
2073
+ self.aclient.create_bucket_config,
2074
+ org=org,
2075
+ nickname=nickname,
2076
+ uri=uri,
2077
+ region_name=region_name,
2078
+ endpoint_url=endpoint_url,
2079
+ use_ssl=use_ssl,
2080
+ force_path_style=force_path_style,
2081
+ extra_config=extra_config,
2082
+ auth_config=auth_config,
1926
2083
  )
1927
2084
 
1928
2085
  def set_default_bucket_config(self, *, org: OrgName, nickname: BucketNickname) -> None:
@@ -1943,6 +2100,26 @@ class Client:
1943
2100
  """
1944
2101
  return sync(self.aclient.get_bucket_config, org=org, nickname=nickname)
1945
2102
 
2103
+ def get_obstore_for_bucket(
2104
+ self, *, org: OrgName, nickname: BucketNickname, access: Literal["read", "write"] = "read"
2105
+ ) -> obs.store.ObjectStore:
2106
+ """Get an obstore ObjectStore for the named bucket.
2107
+
2108
+ Defaults to read-only credentials. Pass ``access="write"`` to request a
2109
+ read+write store; that variant requires ``CAN_MANAGE_USERS`` on the org.
2110
+
2111
+ Requires the optional ``obstore`` extra (``pip install arraylake[obstore]``).
2112
+
2113
+ Args:
2114
+ org: Name of the org that owns the bucket.
2115
+ nickname: Nickname of the bucket config.
2116
+ access: ``"read"`` (default) or ``"write"``.
2117
+
2118
+ Returns:
2119
+ An obstore ObjectStore scoped to the bucket and its configured prefix.
2120
+ """
2121
+ return sync(self.aclient.get_obstore_for_bucket, org=org, nickname=nickname, access=access)
2122
+
1946
2123
  def list_bucket_configs(self, org: OrgName) -> list[BucketResponse]:
1947
2124
  """List all buckets for the specified org
1948
2125
 
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  from dataclasses import dataclass, field
5
- from typing import Any
5
+ from typing import Any, Literal
6
6
  from uuid import UUID
7
7
 
8
8
  from pydantic import TypeAdapter
@@ -300,21 +300,32 @@ class HttpMetastore(ArraylakeHttpClient, Metastore):
300
300
  handle_response(response)
301
301
  return AzureCredentials.model_validate_json(response.content)
302
302
 
303
- async def get_s3_bucket_credentials_from_bucket(self, bucket_id: UUID) -> S3Credentials:
304
- """Gets the S3 credentials for a bucket. Credentials will be scoped to read-only."""
305
- response = await self._request("GET", f"/orgs/{self._config.org}/buckets/{bucket_id}/credentials")
303
+ async def get_s3_bucket_credentials_from_bucket(self, bucket_id: UUID, access: Literal["read", "write"] = "read") -> S3Credentials:
304
+ """Gets the S3 credentials for a bucket.
305
+
306
+ Defaults to read-only credentials; pass ``access="write"`` for read+write.
307
+ """
308
+ response = await self._request("GET", f"/orgs/{self._config.org}/buckets/{bucket_id}/credentials", params={"access": access})
306
309
  handle_response(response)
307
310
  return S3Credentials.model_validate_json(response.content)
308
311
 
309
- async def get_gs_bucket_credentials_from_bucket(self, bucket_id: UUID) -> GSCredentials:
310
- """Gets the GCS credentials for a bucket. Credentials will be scoped to read-only."""
311
- response = await self._request("GET", f"/orgs/{self._config.org}/buckets/{bucket_id}/credentials")
312
+ async def get_gs_bucket_credentials_from_bucket(self, bucket_id: UUID, access: Literal["read", "write"] = "read") -> GSCredentials:
313
+ """Gets the GCS credentials for a bucket.
314
+
315
+ Defaults to read-only credentials; pass ``access="write"`` for read+write.
316
+ """
317
+ response = await self._request("GET", f"/orgs/{self._config.org}/buckets/{bucket_id}/credentials", params={"access": access})
312
318
  handle_response(response)
313
319
  return GSCredentials.model_validate_json(response.content)
314
320
 
315
- async def get_azure_container_credentials_from_bucket(self, bucket_id: UUID) -> AzureCredentials:
316
- """Gets the Azure credentials for a bucket. Credentials will be scoped to read-only."""
317
- response = await self._request("GET", f"/orgs/{self._config.org}/buckets/{bucket_id}/credentials")
321
+ async def get_azure_container_credentials_from_bucket(
322
+ self, bucket_id: UUID, access: Literal["read", "write"] = "read"
323
+ ) -> AzureCredentials:
324
+ """Gets the Azure credentials for a bucket.
325
+
326
+ Defaults to read-only credentials; pass ``access="write"`` for read+write.
327
+ """
328
+ response = await self._request("GET", f"/orgs/{self._config.org}/buckets/{bucket_id}/credentials", params={"access": access})
318
329
  handle_response(response)
319
330
  return AzureCredentials.model_validate_json(response.content)
320
331
 
@@ -5,10 +5,14 @@ from collections.abc import Mapping
5
5
  from datetime import UTC, datetime, time, timedelta
6
6
  from enum import Enum, StrEnum
7
7
  from pathlib import PurePosixPath
8
- from typing import Annotated, Any, Generic, Literal, TypeAlias, TypedDict, TypeVar
8
+ from typing import TYPE_CHECKING, Annotated, Any, Generic, Literal, TypeAlias, TypedDict, TypeVar
9
9
  from uuid import UUID
10
10
 
11
11
  import icechunk
12
+
13
+ if TYPE_CHECKING:
14
+ import obstore as obs
15
+ from obstore.store import AzureConfig, ClientConfig, GCSCredential, S3Config
12
16
  from pydantic import (
13
17
  BaseModel,
14
18
  ConfigDict,
@@ -546,6 +550,109 @@ class BucketModel(BaseModel):
546
550
  else:
547
551
  raise ValueError(f"Unsupported platform for VCC store config: {self.platform}")
548
552
 
553
+ def as_obstore_store(
554
+ self,
555
+ credentials: TempCredentials | None,
556
+ prefix: str | None = None,
557
+ ) -> obs.store.ObjectStore:
558
+ """Build an obstore ObjectStore for this bucket using the given credentials.
559
+
560
+ Requires the optional ``obstore`` extra (``pip install arraylake[obstore]``).
561
+
562
+ Args:
563
+ credentials: Temporary credentials for the bucket. Pass ``None`` for
564
+ anonymous access (the resulting store will use ``skip_signature``).
565
+ prefix: Object key prefix to scope the store to. Defaults to ``self.prefix``
566
+ if not provided.
567
+
568
+ Returns:
569
+ An ``obstore.store.S3Store``, ``GCSStore``, or ``AzureStore``
570
+ depending on this bucket's platform.
571
+
572
+ Raises:
573
+ ImportError: If the optional ``obstore`` extra is not installed.
574
+ ValueError: If the bucket's platform or credential type is unsupported.
575
+ """
576
+ try:
577
+ import obstore as obs
578
+ except ImportError as e:
579
+ raise ImportError(
580
+ "The 'obstore' package is required for this functionality but is not installed. "
581
+ "Install it with: pip install 'arraylake[obstore]'"
582
+ ) from e
583
+
584
+ effective_prefix = prefix if prefix is not None else self.prefix
585
+
586
+ endpoint_url = self.extra_config.get("endpoint_url")
587
+ s3_client_options: ClientConfig = {}
588
+ if endpoint_url and str(endpoint_url).startswith("http://"):
589
+ s3_client_options["allow_http"] = True
590
+
591
+ if self.platform in ("s3", "s3-compatible", "minio"):
592
+ if credentials is None:
593
+ s3_config: S3Config = {"skip_signature": True}
594
+ elif isinstance(credentials, S3Credentials):
595
+ s3_config = {
596
+ "access_key_id": credentials.aws_access_key_id,
597
+ "secret_access_key": credentials.aws_secret_access_key,
598
+ }
599
+ if credentials.aws_session_token:
600
+ s3_config["session_token"] = credentials.aws_session_token
601
+ else:
602
+ raise ValueError(f"Expected S3Credentials or None for platform {self.platform!r}, got {type(credentials).__name__}")
603
+ if self.region_name:
604
+ s3_config["region"] = self.region_name
605
+ if endpoint_url:
606
+ s3_config["endpoint"] = str(endpoint_url)
607
+ return obs.store.S3Store(
608
+ bucket=self.name,
609
+ prefix=effective_prefix,
610
+ config=s3_config,
611
+ client_options=s3_client_options or None,
612
+ )
613
+ elif self.platform == "gs":
614
+ if credentials is None:
615
+ return obs.store.GCSStore(
616
+ bucket=self.name,
617
+ prefix=effective_prefix,
618
+ config={"skip_signature": True},
619
+ )
620
+ elif isinstance(credentials, GSCredentials):
621
+ # google-auth returns naive datetimes (utcnow with tzinfo stripped) but
622
+ # obstore requires timezone-aware datetimes; normalize in the provider.
623
+ gcs_creds = credentials
624
+
625
+ def gcs_credential_provider() -> GCSCredential:
626
+ expires_at = gcs_creds.expiration
627
+ if expires_at is not None and expires_at.tzinfo is None:
628
+ expires_at = expires_at.replace(tzinfo=UTC)
629
+ return {"token": gcs_creds.access_token, "expires_at": expires_at}
630
+
631
+ return obs.store.GCSStore(
632
+ bucket=self.name,
633
+ prefix=effective_prefix,
634
+ credential_provider=gcs_credential_provider,
635
+ )
636
+ else:
637
+ raise ValueError(f"Expected GSCredentials or None for platform 'gs', got {type(credentials).__name__}")
638
+ elif self.platform == "azure":
639
+ if credentials is None:
640
+ azure_config: AzureConfig = {"skip_signature": True}
641
+ elif isinstance(credentials, AzureCredentials):
642
+ azure_config = {
643
+ "sas_key": credentials.sas_token,
644
+ "account_name": credentials.storage_account,
645
+ }
646
+ else:
647
+ raise ValueError(f"Expected AzureCredentials or None for platform 'azure', got {type(credentials).__name__}")
648
+ return obs.store.AzureStore(
649
+ container_name=self.name,
650
+ prefix=effective_prefix,
651
+ config=azure_config,
652
+ )
653
+ else:
654
+ raise ValueError(f"Unsupported platform: {self.platform}")
655
+
549
656
 
550
657
  class Bucket(BucketModel):
551
658
  """A bucket configuration which actually exists in arraylake."""
@@ -965,6 +1072,7 @@ class MarketplaceListing(BaseModel):
965
1072
  id: str
966
1073
  repo_id: str | None
967
1074
  org: str
1075
+ org_display_name: str | None = None
968
1076
  org_description: str | None = None
969
1077
  org_avatar: str | None = None
970
1078
  org_email: str | None = None
@@ -56,6 +56,9 @@ maximal = [
56
56
  "xarray[accel,etc,io,parallel] >= 2024.10.0",
57
57
  "cf_xarray >= 0.10.4",
58
58
  ]
59
+ obstore = [
60
+ "obstore >= 0.9.2",
61
+ ]
59
62
 
60
63
  [dependency-groups]
61
64
  dev = [
@@ -160,3 +163,7 @@ ignore = [
160
163
  "TRY004",
161
164
  ]
162
165
  exclude = ['**/__init__.py', '**/tests/*']
166
+
167
+ [tool.uv]
168
+ exclude-newer = "2 weeks"
169
+ exclude-newer-package = { icechunk = false }