arraylake 1.1.0__tar.gz → 1.2.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 (68) hide show
  1. {arraylake-1.1.0 → arraylake-1.2.0}/PKG-INFO +4 -2
  2. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/_version.py +2 -2
  3. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/api_utils.py +10 -6
  4. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/cli/repo.py +12 -1
  5. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/cli/skills.py +22 -13
  6. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/client.py +111 -11
  7. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/compute/types.py +42 -2
  8. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/diagnostics.py +9 -5
  9. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/metastore/abc.py +19 -3
  10. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/metastore/http_metastore.py +28 -2
  11. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/repos/icechunk/storage.py +39 -4
  12. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/repos/icechunk/types.py +20 -0
  13. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/token.py +57 -39
  14. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/types.py +69 -5
  15. {arraylake-1.1.0 → arraylake-1.2.0}/pyproject.toml +9 -3
  16. {arraylake-1.1.0 → arraylake-1.2.0}/tests/compute/test_client.py +1 -1
  17. {arraylake-1.1.0 → arraylake-1.2.0}/tests/conftest.py +29 -3
  18. {arraylake-1.1.0 → arraylake-1.2.0}/tests/icechunk/conftest.py +13 -0
  19. {arraylake-1.1.0 → arraylake-1.2.0}/tests/icechunk/test_storage.py +16 -0
  20. {arraylake-1.1.0 → arraylake-1.2.0}/tests/test_api_utils.py +35 -2
  21. {arraylake-1.1.0 → arraylake-1.2.0}/tests/test_cli.py +4 -3
  22. {arraylake-1.1.0 → arraylake-1.2.0}/tests/test_client.py +33 -18
  23. {arraylake-1.1.0 → arraylake-1.2.0}/tests/test_subscriptions.py +1 -1
  24. {arraylake-1.1.0 → arraylake-1.2.0}/tests/test_token_handler.py +82 -141
  25. {arraylake-1.1.0 → arraylake-1.2.0}/tests/test_types.py +12 -0
  26. {arraylake-1.1.0 → arraylake-1.2.0}/uv.lock +215 -49
  27. {arraylake-1.1.0 → arraylake-1.2.0}/.gitignore +0 -0
  28. {arraylake-1.1.0 → arraylake-1.2.0}/README.md +0 -0
  29. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/__init__.py +0 -0
  30. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/__main__.py +0 -0
  31. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/_credential_cache.py +0 -0
  32. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/asyn.py +0 -0
  33. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/cli/__init__.py +0 -0
  34. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/cli/auth.py +0 -0
  35. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/cli/compute.py +0 -0
  36. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/cli/config.py +0 -0
  37. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/cli/main.py +0 -0
  38. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/cli/utils.py +0 -0
  39. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/compute/doctor.py +0 -0
  40. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/compute/http_client.py +0 -0
  41. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/compute/services.py +0 -0
  42. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/config.py +0 -0
  43. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/config.yaml +0 -0
  44. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/credentials.py +0 -0
  45. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/display/__init__.py +0 -0
  46. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/display/repo.py +0 -0
  47. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/display/repolist.py +0 -0
  48. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/exceptions.py +0 -0
  49. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/log_util.py +0 -0
  50. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/metastore/__init__.py +0 -0
  51. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/py.typed +0 -0
  52. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/repos/__init__.py +0 -0
  53. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/repos/icechunk/__init__.py +0 -0
  54. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/repos/icechunk/virtual.py +0 -0
  55. {arraylake-1.1.0 → arraylake-1.2.0}/arraylake/tuning.py +0 -0
  56. {arraylake-1.1.0 → arraylake-1.2.0}/tests/__init__.py +0 -0
  57. {arraylake-1.1.0 → arraylake-1.2.0}/tests/compute/test_doctor.py +0 -0
  58. {arraylake-1.1.0 → arraylake-1.2.0}/tests/config.yaml +0 -0
  59. {arraylake-1.1.0 → arraylake-1.2.0}/tests/icechunk/__init__.py +0 -0
  60. {arraylake-1.1.0 → arraylake-1.2.0}/tests/icechunk/test_virtual.py +0 -0
  61. {arraylake-1.1.0 → arraylake-1.2.0}/tests/test_asyn.py +0 -0
  62. {arraylake-1.1.0 → arraylake-1.2.0}/tests/test_config.py +0 -0
  63. {arraylake-1.1.0 → arraylake-1.2.0}/tests/test_credential_cache.py +0 -0
  64. {arraylake-1.1.0 → arraylake-1.2.0}/tests/test_credentials.py +0 -0
  65. {arraylake-1.1.0 → arraylake-1.2.0}/tests/test_diagnostics.py +0 -0
  66. {arraylake-1.1.0 → arraylake-1.2.0}/tests/test_performance.py +0 -0
  67. {arraylake-1.1.0 → arraylake-1.2.0}/tests/test_skills.py +0 -0
  68. {arraylake-1.1.0 → arraylake-1.2.0}/tests/test_virtual_chunks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arraylake
3
- Version: 1.1.0
3
+ Version: 1.2.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
@@ -14,7 +14,7 @@ Requires-Python: <3.15,>=3.12
14
14
  Requires-Dist: click<9.0,>=8.1.3
15
15
  Requires-Dist: donfig<1.0,>=0.7
16
16
  Requires-Dist: httpx<0.29,>=0.23
17
- Requires-Dist: icechunk<3.0.0,>=2.0.1
17
+ Requires-Dist: icechunk<3.0.0,>=2.0.6
18
18
  Requires-Dist: numpy<3.0,>=1.23
19
19
  Requires-Dist: packaging<27.0,>=23.0
20
20
  Requires-Dist: pydantic[email]<3.0,>=2.9
@@ -29,6 +29,8 @@ Requires-Dist: cf-xarray>=0.10.4; extra == 'maximal'
29
29
  Requires-Dist: xarray[accel,etc,io,parallel]>=2024.10.0; extra == 'maximal'
30
30
  Provides-Extra: obstore
31
31
  Requires-Dist: obstore>=0.9.2; extra == 'obstore'
32
+ Provides-Extra: openeo
33
+ Requires-Dist: openeo<0.40,>=0.35.0; extra == 'openeo'
32
34
  Provides-Extra: xarray
33
35
  Requires-Dist: cf-xarray>=0.10.4; extra == 'xarray'
34
36
  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.1.0'
22
- __version_tuple__ = version_tuple = (1, 1, 0)
21
+ __version__ = version = '1.2.0'
22
+ __version_tuple__ = version_tuple = (1, 2, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -542,17 +542,21 @@ class UserAuth(httpx.Auth):
542
542
  return f"Bearer {token}"
543
543
 
544
544
  async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
545
- request.headers["Authorization"] = self._bearer_token
545
+ token_at_send = self._bearer_token
546
+ request.headers["Authorization"] = token_at_send
546
547
  response = yield request
547
548
  if response.status_code == httpx.codes.UNAUTHORIZED:
548
549
  # If the server issues a 401 response, then issue a request to
549
550
  # refresh tokens, and resend the request.
550
551
  async with self._async_lock:
551
- refresh_response = yield self._token_handler.refresh_request
552
- await refresh_response.aread()
553
- handle_response(refresh_response)
554
- new_tokens = OauthTokensResponse.model_validate_json(refresh_response.content)
555
- self._token_handler.update(new_tokens)
552
+ # A concurrently-401'd request may have refreshed while we waited for
553
+ # the lock; refreshing again would burn a redundant Auth0 rotation.
554
+ if self._bearer_token == token_at_send:
555
+ refresh_response = yield await self._token_handler.build_refresh_request()
556
+ await refresh_response.aread()
557
+ handle_response(refresh_response)
558
+ new_tokens = OauthTokensResponse.model_validate_json(refresh_response.content)
559
+ self._token_handler.update(new_tokens)
556
560
 
557
561
  request.headers["Authorization"] = self._bearer_token
558
562
  yield request
@@ -239,9 +239,14 @@ async def modify(
239
239
  async def delete(
240
240
  repo_name: str = typer.Argument(..., help="Name of repository {ORG}/{REPO_NAME}"),
241
241
  confirm: bool = typer.Option(False, help="confirm deletion without prompting"),
242
+ immediate: bool = typer.Option(False, help="skip the recovery grace period and move straight to cleanup (cannot be restored)"),
243
+ retain_data: bool = typer.Option(False, help="leave the bucket bytes in place; only remove Arraylake metadata"),
242
244
  ):
243
245
  """**Delete** a repository
244
246
 
247
+ By default the repo enters a recoverable state for a grace period before its
248
+ data is cleaned up. Pass --immediate to skip the grace period.
249
+
245
250
  **Examples**
246
251
 
247
252
  - Delete repository without confirmation prompt
@@ -249,6 +254,12 @@ async def delete(
249
254
  ```
250
255
  $ arraylake repo delete my-org/example-repo --confirm
251
256
  ```
257
+
258
+ - Delete immediately, freeing the name right away
259
+
260
+ ```
261
+ $ arraylake repo delete my-org/example-repo --confirm --immediate
262
+ ```
252
263
  """
253
264
  if not confirm:
254
265
  confirm = typer.confirm(
@@ -259,7 +270,7 @@ async def delete(
259
270
  client = AsyncClient()
260
271
 
261
272
  with simple_progress(f"Deleting repo [bold]{repo_name}[/bold]..."):
262
- await client.delete_repo(repo_name, imsure=confirm, imreallysure=confirm)
273
+ await client.delete_repo(repo_name, imsure=confirm, imreallysure=confirm, immediate=immediate, retain_data=retain_data)
263
274
 
264
275
  # If the repo is a icechunk repo, print message that the bucket must be deleted manually
265
276
  rich_console.print(f"Repo [bold]{repo_name}[/bold] removed from Arraylake. \nThe underlying Icechunk bucket must be deleted manually.")
@@ -18,6 +18,7 @@ required.
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ import asyncio
21
22
  import shutil
22
23
  import tempfile
23
24
  from dataclasses import dataclass
@@ -81,14 +82,18 @@ def _parse_manifest(payload: dict) -> list[SkillEntry]:
81
82
  return entries
82
83
 
83
84
 
84
- def _fetch_manifest(uri: str) -> list[SkillEntry]:
85
+ async def _async_fetch_manifest(uri: str) -> list[SkillEntry]:
85
86
  url = f"{uri}/manifest.json"
86
- with httpx.Client(timeout=20.0, follow_redirects=True) as client:
87
- resp = client.get(url)
87
+ async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
88
+ resp = await client.get(url)
88
89
  resp.raise_for_status()
89
90
  return _parse_manifest(resp.json())
90
91
 
91
92
 
93
+ def _fetch_manifest(uri: str) -> list[SkillEntry]:
94
+ return asyncio.run(_async_fetch_manifest(uri))
95
+
96
+
92
97
  def _empty_temporary_sibling(dest: Path, prefix: str) -> Path:
93
98
  path = Path(tempfile.mkdtemp(prefix=prefix, dir=dest))
94
99
  path.rmdir()
@@ -102,6 +107,19 @@ def _remove_path(path: Path) -> None:
102
107
  shutil.rmtree(path)
103
108
 
104
109
 
110
+ async def _download_skill_files(uri: str, entry: SkillEntry, tmp_root: Path) -> None:
111
+ async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
112
+ for rel in entry.files:
113
+ if ".." in rel.split("/") or rel.startswith("/"):
114
+ raise RuntimeError(f"Refusing to write file outside skill root: {rel}")
115
+ file_url = f"{uri}/{entry.name}/{rel}"
116
+ resp = await client.get(file_url)
117
+ resp.raise_for_status()
118
+ target = tmp_root / rel
119
+ target.parent.mkdir(parents=True, exist_ok=True)
120
+ target.write_bytes(resp.content)
121
+
122
+
105
123
  def _install_skill(uri: str, entry: SkillEntry, dest: Path) -> str:
106
124
  """Download all of `entry.files` into `dest/<name>/...`.
107
125
 
@@ -115,16 +133,7 @@ def _install_skill(uri: str, entry: SkillEntry, dest: Path) -> str:
115
133
  backup_root: Path | None = None
116
134
 
117
135
  try:
118
- with httpx.Client(timeout=30.0, follow_redirects=True) as client:
119
- for rel in entry.files:
120
- if ".." in rel.split("/") or rel.startswith("/"):
121
- raise RuntimeError(f"Refusing to write file outside skill root: {rel}")
122
- file_url = f"{uri}/{entry.name}/{rel}"
123
- resp = client.get(file_url)
124
- resp.raise_for_status()
125
- target = tmp_root / rel
126
- target.parent.mkdir(parents=True, exist_ok=True)
127
- target.write_bytes(resp.content)
136
+ asyncio.run(_download_skill_files(uri, entry, tmp_root))
128
137
 
129
138
  if skill_root.exists() or skill_root.is_symlink():
130
139
  # Pre-existing install, including legacy wheel-bundled symlinks.
@@ -29,6 +29,7 @@ from icechunk import Repository as IcechunkRepository
29
29
  from arraylake._credential_cache import CredentialCacheKey, get_or_refresh, populate
30
30
  from arraylake.asyn import async_gather_tasks, asyncio_run, sync
31
31
  from arraylake.compute.services import AsyncComputeClient, ComputeClient
32
+ from arraylake.compute.types import ServiceType
32
33
  from arraylake.config import config as arraylake_config
33
34
  from arraylake.config import default_service_uri
34
35
  from arraylake.credentials import (
@@ -45,6 +46,7 @@ from arraylake.metastore import HttpMetastore, HttpMetastoreConfig
45
46
  from arraylake.repos.icechunk.storage import (
46
47
  _get_credential_type,
47
48
  _get_icechunk_storage_obj,
49
+ apply_bucket_repo_config,
48
50
  )
49
51
  from arraylake.repos.icechunk.virtual import (
50
52
  create_virtual_chunk_container,
@@ -89,6 +91,7 @@ from arraylake.types import Repo as RepoModel
89
91
 
90
92
  if TYPE_CHECKING:
91
93
  import obstore as obs
94
+ import openeo
92
95
 
93
96
  logger = get_logger(__name__)
94
97
 
@@ -1027,6 +1030,9 @@ class AsyncClient:
1027
1030
  repo_model, user_id=user.id, storage_options=storage_options
1028
1031
  )
1029
1032
 
1033
+ if repo_model.bucket is not None:
1034
+ config = apply_bucket_repo_config(repo_model.bucket, config)
1035
+
1030
1036
  if authorize_virtual_chunk_access is not None:
1031
1037
  config = await self._add_virtual_chunk_containers(
1032
1038
  org=org, user=user, config=config, authorize_virtual_chunk_access=authorize_virtual_chunk_access
@@ -1057,7 +1063,7 @@ class AsyncClient:
1057
1063
 
1058
1064
  except (IcechunkError, ValueError):
1059
1065
  # If the repo fails to create, we need to delete the repo model
1060
- await mstore.delete_database(repo_name, imsure=True, imreallysure=True)
1066
+ await mstore.delete_database(repo_name, imsure=True, imreallysure=True, immediate=True, retain_data=True)
1061
1067
  raise
1062
1068
 
1063
1069
  async def import_repo(
@@ -1141,6 +1147,9 @@ class AsyncClient:
1141
1147
  config = None
1142
1148
  prefixes_credential_mapping = None
1143
1149
 
1150
+ if repo_model.bucket is not None:
1151
+ config = apply_bucket_repo_config(repo_model.bucket, config)
1152
+
1144
1153
  ic_repo = IcechunkRepository.open(
1145
1154
  icechunk_storage,
1146
1155
  config=config,
@@ -1153,7 +1162,7 @@ class AsyncClient:
1153
1162
 
1154
1163
  except (IcechunkError, ValueError):
1155
1164
  # If the repo fails to create, we need to delete the repo model
1156
- await mstore.delete_database(repo_name, imsure=True, imreallysure=True)
1165
+ await mstore.delete_database(repo_name, imsure=True, imreallysure=True, immediate=True, retain_data=True)
1157
1166
  raise
1158
1167
 
1159
1168
  async def _get_icechunk_storage_from_repo_model(
@@ -1459,16 +1468,38 @@ class AsyncClient:
1459
1468
  optimization_config=optimization_config,
1460
1469
  )
1461
1470
 
1462
- async def delete_repo(self, name: OrgAndRepoName, *, imsure: bool = False, imreallysure: bool = False) -> None:
1463
- """Delete a repo
1471
+ async def delete_repo(
1472
+ self,
1473
+ name: OrgAndRepoName,
1474
+ *,
1475
+ imsure: bool = False,
1476
+ imreallysure: bool = False,
1477
+ immediate: bool = False,
1478
+ retain_data: bool = False,
1479
+ ) -> None:
1480
+ """Delete a repo.
1481
+
1482
+ By default the repo enters a recoverable "ghost" state for a grace
1483
+ period (currently 7 days) and the bucket bytes are reclaimed by the
1484
+ daily cleanup job after that. A repo in the ghost state can be brought
1485
+ back through the web app.
1464
1486
 
1465
1487
  Args:
1466
1488
  name: Full name of the repo to delete (of the form [ORG]/[REPO])
1467
- imsure, imreallysure: confirm you intend to delete this bucket config
1489
+ imsure, imreallysure: confirm you intend to delete this repo
1490
+ immediate: skip the grace period and move straight to cleanup
1491
+ retain_data: leave the bucket bytes alone; only remove Arraylake
1492
+ metadata
1468
1493
  """
1469
1494
  org, repo_name = _parse_org_and_repo(name)
1470
1495
  mstore = self._metastore_for_org(org)
1471
- await mstore.delete_database(repo_name, imsure=imsure, imreallysure=imreallysure)
1496
+ await mstore.delete_database(
1497
+ repo_name,
1498
+ imsure=imsure,
1499
+ imreallysure=imreallysure,
1500
+ immediate=immediate,
1501
+ retain_data=retain_data,
1502
+ )
1472
1503
 
1473
1504
  async def _set_repo_status(
1474
1505
  self, qualified_repo_name: OrgAndRepoName, mode: RepoOperationMode, message: str | None = None
@@ -1614,7 +1645,8 @@ class AsyncClient:
1614
1645
  explicit parameters above when possible. Keys set here are overridden
1615
1646
  by any explicit parameters that are also provided.
1616
1647
  auth_config: Dictionary of auth parameters. Must include a ``"method"`` key.
1617
- Defaults to ``{"method": "anonymous"}``.
1648
+ Defaults to ``{"method": "anonymous"}``. Anonymous (public) Azure
1649
+ containers must also set ``storage_account`` in this dict.
1618
1650
  """
1619
1651
  validated = NewBucket(
1620
1652
  **self._make_bucket_config(
@@ -2231,14 +2263,38 @@ class Client:
2231
2263
  optimization_config=optimization_config,
2232
2264
  )
2233
2265
 
2234
- def delete_repo(self, name: OrgAndRepoName, *, imsure: bool = False, imreallysure: bool = False) -> None:
2235
- """Delete a repo
2266
+ def delete_repo(
2267
+ self,
2268
+ name: OrgAndRepoName,
2269
+ *,
2270
+ imsure: bool = False,
2271
+ imreallysure: bool = False,
2272
+ immediate: bool = False,
2273
+ retain_data: bool = False,
2274
+ ) -> None:
2275
+ """Delete a repo.
2276
+
2277
+ By default the repo enters a recoverable "ghost" state for a grace
2278
+ period (currently 7 days) and the bucket bytes are reclaimed by the
2279
+ daily cleanup job after that. A repo in the ghost state can be brought
2280
+ back through the web app.
2236
2281
 
2237
2282
  Args:
2238
2283
  name: Full name of the repo to delete (of the form [ORG]/[REPO])
2284
+ imsure, imreallysure: confirm you intend to delete this repo
2285
+ immediate: skip the grace period and move straight to cleanup
2286
+ retain_data: leave the bucket bytes alone; only remove Arraylake
2287
+ metadata
2239
2288
  """
2240
2289
 
2241
- return sync(self.aclient.delete_repo, name, imsure=imsure, imreallysure=imreallysure)
2290
+ return sync(
2291
+ self.aclient.delete_repo,
2292
+ name,
2293
+ imsure=imsure,
2294
+ imreallysure=imreallysure,
2295
+ immediate=immediate,
2296
+ retain_data=retain_data,
2297
+ )
2242
2298
 
2243
2299
  def create_bucket_config(
2244
2300
  self,
@@ -2278,7 +2334,8 @@ class Client:
2278
2334
  explicit parameters above when possible. Keys set here are overridden
2279
2335
  by any explicit parameters that are also provided.
2280
2336
  auth_config: Dictionary of auth parameters. Must include a ``"method"`` key.
2281
- Defaults to ``{"method": "anonymous"}``.
2337
+ Defaults to ``{"method": "anonymous"}``. Anonymous (public) Azure
2338
+ containers must also set ``storage_account`` in this dict.
2282
2339
  """
2283
2340
  return sync(
2284
2341
  self.aclient.create_bucket_config,
@@ -2426,3 +2483,46 @@ class Client:
2426
2483
  org: Name of the org
2427
2484
  """
2428
2485
  return self.aclient.get_services(org).to_sync_client()
2486
+
2487
+ def get_openeo_connection(self, org: OrgName, deployment_name: str | None = None) -> openeo.Connection:
2488
+ """Connect to this org's deployed openEO service with the openeo client.
2489
+
2490
+ Resolves the URL of the org's enabled openEO compute service and returns
2491
+ an authenticated ``openeo.Connection``. Requires the optional ``openeo``
2492
+ dependency (``pip install 'arraylake[openeo]'``) and an enabled openEO
2493
+ service (``Client().get_services(org).enable("openeo")``).
2494
+
2495
+ Args:
2496
+ org: Name of the org.
2497
+ deployment_name: Select a specific openEO deployment by name when the
2498
+ org has more than one. Defaults to the only/first openEO service.
2499
+ """
2500
+ try:
2501
+ import openeo
2502
+ except ImportError as e:
2503
+ raise ImportError(
2504
+ "The 'openeo' package is required for get_openeo_connection. Install it with `pip install 'arraylake[openeo]'`."
2505
+ ) from e
2506
+
2507
+ deployments = [d for d in self.get_services(org).list_enabled() if d.config.service_type == ServiceType.openeo]
2508
+ if deployment_name is not None:
2509
+ deployments = [d for d in deployments if (d.config.deployment_name or ServiceType.openeo.value) == deployment_name]
2510
+ if not deployments:
2511
+ raise ValueError(
2512
+ f"No openEO service is enabled for org {org!r}. Enable one with Client().get_services({org!r}).enable('openeo')."
2513
+ )
2514
+
2515
+ token = self.aclient.token
2516
+ if not token:
2517
+ handler = get_auth_handler(self.aclient.service_uri)
2518
+ if handler.tokens is not None:
2519
+ token = handler.tokens.id_token.get_secret_value()
2520
+ if not token:
2521
+ raise ValueError(
2522
+ "An Arraylake API token is required to authenticate to openEO. "
2523
+ "Provide one via Client(token=...) or log in with `arraylake auth login`."
2524
+ )
2525
+
2526
+ connection = openeo.connect(deployments[0].url)
2527
+ connection.authenticate_bearer_token(token)
2528
+ return connection
@@ -11,6 +11,7 @@ class ServiceType(StrEnum):
11
11
  wms = "wms"
12
12
  tiles = "tiles"
13
13
  zarr = "zarr"
14
+ openeo = "openeo"
14
15
 
15
16
 
16
17
  class ServiceStatus(StrEnum):
@@ -28,6 +29,23 @@ class LogLevel(StrEnum):
28
29
  critical = "CRITICAL"
29
30
 
30
31
 
32
+ class InstanceSize(StrEnum):
33
+ xsmall = "xsmall"
34
+ small = "small"
35
+ medium = "medium"
36
+ large = "large"
37
+
38
+
39
+ # Mapping of instance sizes to resource specifications
40
+ # Based on AWS c7g instance family
41
+ INSTANCE_SIZE_SPECS: dict[InstanceSize, dict[str, int]] = {
42
+ InstanceSize.xsmall: {"vcpus": 1, "memory_gib": 2}, # c7g.medium
43
+ InstanceSize.small: {"vcpus": 4, "memory_gib": 8}, # c7g.xlarge
44
+ InstanceSize.medium: {"vcpus": 16, "memory_gib": 32}, # c7g.4xlarge
45
+ InstanceSize.large: {"vcpus": 64, "memory_gib": 128}, # c7g.16xlarge
46
+ }
47
+
48
+
31
49
  class ServiceConfig(BaseModel):
32
50
  service_type: ServiceType
33
51
  org: str
@@ -56,14 +74,21 @@ class ServiceConfig(BaseModel):
56
74
  # If not set, the default is 0, which means functionally no caching, but requests are revalidated.
57
75
  cache_max_age: int | None = 0
58
76
 
59
- # Optional control over icechunk chunk cache size in bytes
77
+ # Optional control over icechunk chunk cache size in bytes. Override for services whose
78
+ # single-request working set (e.g. all shards touched by an EDR query) exceeds the default,
79
+ # otherwise chunks are evicted and re-fetched from object storage on every request.
60
80
  icechunk_cache_chunk_bytes: int | None = 1024 * 1024 * 1024
61
81
 
62
82
  # Optional control over the number of chunk refs icechunk cache
63
83
  icechunk_cache_chunk_ref_count: int | None = 10_000_000
64
84
 
65
85
  # Optional zarr concurrency configuration, the number of concurrent reads zarr will use fulfilling requests
66
- zarr_concurrency: int | None = 128
86
+ zarr_concurrency: int | None = 1000
87
+
88
+ # Optional zarr codec pipeline batch size, the number of chunks zarr encodes/decodes per
89
+ # asyncio task. Zarr's own default of 1 spawns a task per inner chunk when decoding
90
+ # sharded arrays, which is significantly slower.
91
+ zarr_codec_pipeline_batch_size: int | None = 256
67
92
 
68
93
  # Optional zarr threading configuration, the max number of worker threads zarr uses for codec
69
94
  # encode/decode. If unset, zarr uses its default of min(32, os.cpu_count() + 4).
@@ -76,6 +101,21 @@ class ServiceConfig(BaseModel):
76
101
  # other than DAP2. DAP2 always uses Dask regardless of this setting.
77
102
  use_dask: bool = False
78
103
 
104
+ # Optional instance size selection. When set, this maps to predefined
105
+ # cpu_request, cpu_limit, memory_request, and memory_limit values.
106
+ instance_size: InstanceSize | None = None
107
+
108
+ # Optional container resource overrides. When unset, the per-service-type
109
+ # defaults from the server's ImageConfig are used. Setting any of these
110
+ # requires the caller to have the CAN_MANAGE_ALL_ORGS service permission;
111
+ # non-admin callers will be rejected. Values are Kubernetes resource
112
+ # quantities (e.g. "500m", "4", "8Gi"). Requests drive node selection and
113
+ # packing; limits cap burst. Set request == limit for Guaranteed QoS.
114
+ cpu_request: str | None = None
115
+ cpu_limit: str | None = None
116
+ memory_request: str | None = None
117
+ memory_limit: str | None = None
118
+
79
119
  @field_validator("deployment_name")
80
120
  @classmethod
81
121
  def validate_deployment_name(cls, v: str | None) -> str | None:
@@ -17,6 +17,7 @@
17
17
  # limitations under the License.
18
18
  """Utility functions for printing version information."""
19
19
 
20
+ import asyncio
20
21
  import importlib
21
22
  import locale
22
23
  import os
@@ -107,12 +108,15 @@ def get_versions() -> dict[str, str]:
107
108
  return versions
108
109
 
109
110
 
110
- def get_service_info(service_uri: str) -> dict[str, str]:
111
- with httpx.Client() as client:
112
- response = client.get(service_uri)
111
+ async def _async_get_service_info(service_uri: str) -> dict[str, str]:
112
+ async with httpx.AsyncClient() as client:
113
+ response = await client.get(service_uri)
113
114
  data = response.json()
114
- info = {"service_uri": service_uri, "service_version": data["arraylake_api_version"]}
115
- return info
115
+ return {"service_uri": service_uri, "service_version": data["arraylake_api_version"]}
116
+
117
+
118
+ def get_service_info(service_uri: str) -> dict[str, str]:
119
+ return asyncio.run(_async_get_service_info(service_uri))
116
120
 
117
121
 
118
122
  def get_config_info() -> dict[str, str]:
@@ -32,14 +32,30 @@ class Metastore(ABC): # pragma: no cover
32
32
  ...
33
33
 
34
34
  @abstractmethod
35
- async def delete_database(self, name: str, *, imsure: bool = False, imreallysure: bool = False) -> None:
35
+ async def delete_database(
36
+ self,
37
+ name: str,
38
+ *,
39
+ imsure: bool = False,
40
+ imreallysure: bool = False,
41
+ immediate: bool = False,
42
+ retain_data: bool = False,
43
+ ) -> None:
36
44
  """Delete an existing metastore database.
37
45
 
38
46
  Parameters
39
47
  ----------
40
48
  name : str
41
49
  Name of repo
42
- imsure, imreallsure : bool
43
- Confirm permanent deletion.
50
+ imsure, imreallysure : bool
51
+ Confirm intent to delete.
52
+ immediate : bool, default False
53
+ Skip the soft-delete grace period and move straight to cleanup.
54
+ With the default ``immediate=False`` the repo enters a recoverable
55
+ "ghost" state for the grace period before the daily cleanup job
56
+ takes it.
57
+ retain_data : bool, default False
58
+ Leave the bucket bytes alone when the cleanup runs; only the
59
+ Arraylake metadata is removed.
44
60
  """
45
61
  ...
@@ -234,11 +234,37 @@ class HttpMetastore(ArraylakeHttpClient, Metastore):
234
234
  response = await self._request("PATCH", f"/orgs/{self._config.org}/{name}", content=repo_modify_request.model_dump_json())
235
235
  handle_response(response)
236
236
 
237
- async def delete_database(self, name: str, *, imsure: bool = False, imreallysure: bool = False) -> None:
237
+ async def delete_database(
238
+ self,
239
+ name: str,
240
+ *,
241
+ imsure: bool = False,
242
+ imreallysure: bool = False,
243
+ immediate: bool = False,
244
+ retain_data: bool = False,
245
+ ) -> None:
238
246
  if not (imsure and imreallysure):
239
247
  raise ValueError("Don't do this unless you're really sure. Once the database has been deleted, it's gone forever.")
240
248
 
241
- response = await self._request("DELETE", f"/orgs/{self._config.org}/{name}")
249
+ # Default behavior is a soft-delete: the repo enters a recoverable
250
+ # "ghost" state for the grace period and the daily cleanup job reclaims
251
+ # the bucket bytes afterward. Callers can opt out:
252
+ # immediate=True - skip the grace period; ghost vanishes immediately
253
+ # and the subscription cascade runs now
254
+ # retain_data=True - leave bucket bytes alone when the cleanup job
255
+ # finalizes (only the Arraylake metadata is removed)
256
+ # Setting both together makes the server drop the row inline so the
257
+ # (org, name) slot is immediately reusable.
258
+ params: dict[str, str] = {}
259
+ if immediate:
260
+ params["immediate"] = "true"
261
+ if retain_data:
262
+ params["retain_data"] = "true"
263
+ response = await self._request(
264
+ "DELETE",
265
+ f"/orgs/{self._config.org}/{name}",
266
+ params=params,
267
+ )
242
268
  handle_response(response)
243
269
 
244
270
  async def create_bucket_config(self, bucket_config: NewBucket) -> BucketResponse:
@@ -17,7 +17,6 @@ import icechunk
17
17
  from arraylake.log_util import get_logger
18
18
  from arraylake.types import (
19
19
  AzureCredentials,
20
- AzureDelegatedCredentialsAuth,
21
20
  BucketResponse,
22
21
  GSCredentials,
23
22
  S3Credentials,
@@ -232,14 +231,22 @@ def _get_icechunk_storage_obj(
232
231
 
233
232
  assert credentials is None or isinstance(credentials, AzureCredentials)
234
233
 
235
- if not isinstance(bucket_config.auth_config, AzureDelegatedCredentialsAuth):
236
- raise ValueError(f"Invalid auth config for Azure bucket: {bucket_config.auth_config}")
234
+ # The Azure storage account name lives on the auth config for both delegated and
235
+ # anonymous Azure buckets (it's needed to resolve the blob endpoint host). Fall
236
+ # back to the vended credentials if they carry it.
237
+ storage_account: str | None = getattr(bucket_config.auth_config, "storage_account", None)
238
+ if not storage_account and credentials is not None:
239
+ storage_account = credentials.storage_account
240
+
241
+ if not storage_account:
242
+ raise ValueError(f"Could not determine the Azure storage account for bucket {bucket_config.nickname}.")
237
243
 
238
244
  return icechunk.azure_storage(
239
- account=bucket_config.auth_config.storage_account,
245
+ account=storage_account,
240
246
  container=bucket_config.name,
241
247
  prefix=prefix or "",
242
248
  sas_token=credentials.sas_token if credentials else None,
249
+ anonymous=credential_type == CredentialType.ANONYMOUS,
243
250
  from_env=False,
244
251
  )
245
252
 
@@ -329,3 +336,31 @@ def create_icechunk_store_config(
329
336
 
330
337
  else:
331
338
  raise ValueError(f"Unsupported bucket platform: {bucket_config.platform}")
339
+
340
+
341
+ def apply_bucket_repo_config(
342
+ bucket_config: BucketResponse,
343
+ user_config: icechunk.RepositoryConfig | None,
344
+ ) -> icechunk.RepositoryConfig | None:
345
+ """Merge bucket-level icechunk RepositoryConfig flags into a user-supplied config.
346
+
347
+ Reads ``unsafe_use_conditional_update`` and ``unsafe_use_conditional_create`` from
348
+ ``bucket_config.extra_config`` and folds them into a ``RepositoryConfig``. Caller-provided
349
+ ``user_config`` wins on overlap (icechunk's ``RepositoryConfig.merge`` semantics: fields set on
350
+ the argument override fields set on the receiver).
351
+ """
352
+ extra = bucket_config.extra_config or {}
353
+ unsafe_update = extra.get("unsafe_use_conditional_update")
354
+ unsafe_create = extra.get("unsafe_use_conditional_create")
355
+
356
+ if unsafe_update is None and unsafe_create is None:
357
+ return user_config
358
+
359
+ bucket_storage = icechunk.StorageSettings(
360
+ unsafe_use_conditional_update=None if unsafe_update is None else bool(unsafe_update),
361
+ unsafe_use_conditional_create=None if unsafe_create is None else bool(unsafe_create),
362
+ )
363
+ bucket_derived = icechunk.RepositoryConfig(storage=bucket_storage)
364
+ if user_config is None:
365
+ return bucket_derived
366
+ return bucket_derived.merge(user_config)
@@ -98,6 +98,25 @@ class CreateBranchRequest(BaseModel):
98
98
  commit_id: CommitId
99
99
 
100
100
 
101
+ class AttributeEdit(BaseModel):
102
+ """A merge patch for one node's attributes (path "" targets the root group)."""
103
+
104
+ path: str = ""
105
+ attributes: dict[str, Any]
106
+
107
+
108
+ class UpdateNodeAttributesRequest(BaseModel):
109
+ """Merge attributes into one or more nodes (groups or arrays), applying every
110
+ edit to a branch as a single commit."""
111
+
112
+ branch: BranchName
113
+ edits: list[AttributeEdit]
114
+ message: str | None = None
115
+ # Optimistic concurrency: the commit the caller expects the new commit to be
116
+ # parented on (i.e. the current branch tip). Reject if the branch has moved.
117
+ expected_parent_commit_id: CommitId | None = None
118
+
119
+
101
120
  class Tag(BaseModel):
102
121
  id: TagName
103
122
  commit_id: CommitId
@@ -178,6 +197,7 @@ class ClassifiedArray(BaseModel):
178
197
  dimension_names: list[str]
179
198
  attributes: dict[str, Any]
180
199
  chunk_shape: list[int]
200
+ shard_shape: list[int] | None = None # Outer shard shape; None for unsharded arrays
181
201
  fill_value: Any = None
182
202
  codecs: list[dict[str, Any]] = [] # List of codec configurations
183
203
  classification: ArrayClassification