arraylake 1.1.1__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.1 → arraylake-1.2.0}/PKG-INFO +3 -1
  2. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/_version.py +2 -2
  3. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/api_utils.py +10 -6
  4. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/cli/repo.py +12 -1
  5. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/cli/skills.py +22 -13
  6. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/client.py +100 -9
  7. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/compute/types.py +22 -0
  8. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/diagnostics.py +9 -5
  9. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/metastore/abc.py +19 -3
  10. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/metastore/http_metastore.py +28 -2
  11. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/repos/icechunk/types.py +19 -0
  12. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/token.py +57 -39
  13. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/types.py +45 -5
  14. {arraylake-1.1.1 → arraylake-1.2.0}/pyproject.toml +7 -1
  15. {arraylake-1.1.1 → arraylake-1.2.0}/tests/compute/test_client.py +1 -1
  16. {arraylake-1.1.1 → arraylake-1.2.0}/tests/conftest.py +3 -3
  17. {arraylake-1.1.1 → arraylake-1.2.0}/tests/test_api_utils.py +35 -2
  18. {arraylake-1.1.1 → arraylake-1.2.0}/tests/test_cli.py +4 -3
  19. {arraylake-1.1.1 → arraylake-1.2.0}/tests/test_client.py +17 -18
  20. {arraylake-1.1.1 → arraylake-1.2.0}/tests/test_subscriptions.py +1 -1
  21. {arraylake-1.1.1 → arraylake-1.2.0}/tests/test_token_handler.py +82 -141
  22. {arraylake-1.1.1 → arraylake-1.2.0}/uv.lock +213 -47
  23. {arraylake-1.1.1 → arraylake-1.2.0}/.gitignore +0 -0
  24. {arraylake-1.1.1 → arraylake-1.2.0}/README.md +0 -0
  25. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/__init__.py +0 -0
  26. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/__main__.py +0 -0
  27. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/_credential_cache.py +0 -0
  28. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/asyn.py +0 -0
  29. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/cli/__init__.py +0 -0
  30. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/cli/auth.py +0 -0
  31. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/cli/compute.py +0 -0
  32. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/cli/config.py +0 -0
  33. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/cli/main.py +0 -0
  34. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/cli/utils.py +0 -0
  35. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/compute/doctor.py +0 -0
  36. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/compute/http_client.py +0 -0
  37. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/compute/services.py +0 -0
  38. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/config.py +0 -0
  39. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/config.yaml +0 -0
  40. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/credentials.py +0 -0
  41. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/display/__init__.py +0 -0
  42. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/display/repo.py +0 -0
  43. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/display/repolist.py +0 -0
  44. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/exceptions.py +0 -0
  45. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/log_util.py +0 -0
  46. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/metastore/__init__.py +0 -0
  47. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/py.typed +0 -0
  48. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/repos/__init__.py +0 -0
  49. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/repos/icechunk/__init__.py +0 -0
  50. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/repos/icechunk/storage.py +0 -0
  51. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/repos/icechunk/virtual.py +0 -0
  52. {arraylake-1.1.1 → arraylake-1.2.0}/arraylake/tuning.py +0 -0
  53. {arraylake-1.1.1 → arraylake-1.2.0}/tests/__init__.py +0 -0
  54. {arraylake-1.1.1 → arraylake-1.2.0}/tests/compute/test_doctor.py +0 -0
  55. {arraylake-1.1.1 → arraylake-1.2.0}/tests/config.yaml +0 -0
  56. {arraylake-1.1.1 → arraylake-1.2.0}/tests/icechunk/__init__.py +0 -0
  57. {arraylake-1.1.1 → arraylake-1.2.0}/tests/icechunk/conftest.py +0 -0
  58. {arraylake-1.1.1 → arraylake-1.2.0}/tests/icechunk/test_storage.py +0 -0
  59. {arraylake-1.1.1 → arraylake-1.2.0}/tests/icechunk/test_virtual.py +0 -0
  60. {arraylake-1.1.1 → arraylake-1.2.0}/tests/test_asyn.py +0 -0
  61. {arraylake-1.1.1 → arraylake-1.2.0}/tests/test_config.py +0 -0
  62. {arraylake-1.1.1 → arraylake-1.2.0}/tests/test_credential_cache.py +0 -0
  63. {arraylake-1.1.1 → arraylake-1.2.0}/tests/test_credentials.py +0 -0
  64. {arraylake-1.1.1 → arraylake-1.2.0}/tests/test_diagnostics.py +0 -0
  65. {arraylake-1.1.1 → arraylake-1.2.0}/tests/test_performance.py +0 -0
  66. {arraylake-1.1.1 → arraylake-1.2.0}/tests/test_skills.py +0 -0
  67. {arraylake-1.1.1 → arraylake-1.2.0}/tests/test_types.py +0 -0
  68. {arraylake-1.1.1 → 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.1
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
@@ -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.1'
22
- __version_tuple__ = version_tuple = (1, 1, 1)
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 (
@@ -90,6 +91,7 @@ from arraylake.types import Repo as RepoModel
90
91
 
91
92
  if TYPE_CHECKING:
92
93
  import obstore as obs
94
+ import openeo
93
95
 
94
96
  logger = get_logger(__name__)
95
97
 
@@ -1061,7 +1063,7 @@ class AsyncClient:
1061
1063
 
1062
1064
  except (IcechunkError, ValueError):
1063
1065
  # If the repo fails to create, we need to delete the repo model
1064
- 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)
1065
1067
  raise
1066
1068
 
1067
1069
  async def import_repo(
@@ -1160,7 +1162,7 @@ class AsyncClient:
1160
1162
 
1161
1163
  except (IcechunkError, ValueError):
1162
1164
  # If the repo fails to create, we need to delete the repo model
1163
- 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)
1164
1166
  raise
1165
1167
 
1166
1168
  async def _get_icechunk_storage_from_repo_model(
@@ -1466,16 +1468,38 @@ class AsyncClient:
1466
1468
  optimization_config=optimization_config,
1467
1469
  )
1468
1470
 
1469
- async def delete_repo(self, name: OrgAndRepoName, *, imsure: bool = False, imreallysure: bool = False) -> None:
1470
- """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.
1471
1486
 
1472
1487
  Args:
1473
1488
  name: Full name of the repo to delete (of the form [ORG]/[REPO])
1474
- 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
1475
1493
  """
1476
1494
  org, repo_name = _parse_org_and_repo(name)
1477
1495
  mstore = self._metastore_for_org(org)
1478
- 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
+ )
1479
1503
 
1480
1504
  async def _set_repo_status(
1481
1505
  self, qualified_repo_name: OrgAndRepoName, mode: RepoOperationMode, message: str | None = None
@@ -2239,14 +2263,38 @@ class Client:
2239
2263
  optimization_config=optimization_config,
2240
2264
  )
2241
2265
 
2242
- def delete_repo(self, name: OrgAndRepoName, *, imsure: bool = False, imreallysure: bool = False) -> None:
2243
- """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.
2244
2281
 
2245
2282
  Args:
2246
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
2247
2288
  """
2248
2289
 
2249
- 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
+ )
2250
2298
 
2251
2299
  def create_bucket_config(
2252
2300
  self,
@@ -2435,3 +2483,46 @@ class Client:
2435
2483
  org: Name of the org
2436
2484
  """
2437
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
@@ -83,6 +101,10 @@ class ServiceConfig(BaseModel):
83
101
  # other than DAP2. DAP2 always uses Dask regardless of this setting.
84
102
  use_dask: bool = False
85
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
+
86
108
  # Optional container resource overrides. When unset, the per-service-type
87
109
  # defaults from the server's ImageConfig are used. Setting any of these
88
110
  # requires the caller to have the CAN_MANAGE_ALL_ORGS service permission;
@@ -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:
@@ -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