arraylake 1.5.0__tar.gz → 1.7.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 (76) hide show
  1. {arraylake-1.5.0 → arraylake-1.7.0}/PKG-INFO +4 -4
  2. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/_version.py +2 -2
  3. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/cli/compute.py +20 -5
  4. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/cli/config.py +3 -3
  5. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/cli/iceberg.py +16 -4
  6. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/cli/repo.py +12 -4
  7. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/client.py +20 -14
  8. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/compute/http_client.py +12 -1
  9. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/compute/services.py +23 -4
  10. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/compute/types.py +12 -2
  11. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/metastore/http_metastore.py +1 -1
  12. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/repos/icechunk/virtual.py +6 -6
  13. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/tuning.py +2 -2
  14. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/types.py +87 -17
  15. {arraylake-1.5.0 → arraylake-1.7.0}/pyproject.toml +10 -25
  16. {arraylake-1.5.0 → arraylake-1.7.0}/tests/compute/test_client.py +43 -0
  17. {arraylake-1.5.0 → arraylake-1.7.0}/tests/icechunk/test_virtual.py +20 -4
  18. {arraylake-1.5.0 → arraylake-1.7.0}/tests/test_cli.py +6 -1
  19. {arraylake-1.5.0 → arraylake-1.7.0}/tests/test_client.py +1 -2
  20. {arraylake-1.5.0 → arraylake-1.7.0}/tests/test_iceberg_client.py +6 -2
  21. {arraylake-1.5.0 → arraylake-1.7.0}/tests/test_subscriptions.py +11 -11
  22. {arraylake-1.5.0 → arraylake-1.7.0}/tests/test_types.py +28 -2
  23. {arraylake-1.5.0 → arraylake-1.7.0}/tests/test_virtual_chunks.py +3 -3
  24. {arraylake-1.5.0 → arraylake-1.7.0}/uv.lock +390 -310
  25. {arraylake-1.5.0 → arraylake-1.7.0}/.gitignore +0 -0
  26. {arraylake-1.5.0 → arraylake-1.7.0}/README.md +0 -0
  27. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/__init__.py +0 -0
  28. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/__main__.py +0 -0
  29. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/_credential_cache.py +0 -0
  30. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/api_utils.py +0 -0
  31. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/asyn.py +0 -0
  32. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/cli/__init__.py +0 -0
  33. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/cli/auth.py +0 -0
  34. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/cli/main.py +0 -0
  35. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/cli/org.py +0 -0
  36. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/cli/skills.py +0 -0
  37. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/cli/utils.py +0 -0
  38. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/config.py +0 -0
  39. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/config.yaml +0 -0
  40. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/credentials.py +0 -0
  41. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/diagnostics.py +0 -0
  42. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/display/__init__.py +0 -0
  43. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/display/repo.py +0 -0
  44. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/display/repolist.py +0 -0
  45. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/exceptions.py +0 -0
  46. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/log_util.py +0 -0
  47. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/metastore/__init__.py +0 -0
  48. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/metastore/abc.py +0 -0
  49. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/obstore.py +0 -0
  50. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/py.typed +0 -0
  51. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/repos/__init__.py +0 -0
  52. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/repos/iceberg/__init__.py +0 -0
  53. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/repos/iceberg/auth.py +0 -0
  54. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/repos/iceberg/catalog.py +0 -0
  55. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/repos/icechunk/__init__.py +0 -0
  56. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/repos/icechunk/storage.py +0 -0
  57. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/repos/icechunk/types.py +0 -0
  58. {arraylake-1.5.0 → arraylake-1.7.0}/arraylake/token.py +0 -0
  59. {arraylake-1.5.0 → arraylake-1.7.0}/tests/__init__.py +0 -0
  60. {arraylake-1.5.0 → arraylake-1.7.0}/tests/compute/test_cli_table.py +0 -0
  61. {arraylake-1.5.0 → arraylake-1.7.0}/tests/config.yaml +0 -0
  62. {arraylake-1.5.0 → arraylake-1.7.0}/tests/conftest.py +0 -0
  63. {arraylake-1.5.0 → arraylake-1.7.0}/tests/icechunk/__init__.py +0 -0
  64. {arraylake-1.5.0 → arraylake-1.7.0}/tests/icechunk/conftest.py +0 -0
  65. {arraylake-1.5.0 → arraylake-1.7.0}/tests/icechunk/test_storage.py +0 -0
  66. {arraylake-1.5.0 → arraylake-1.7.0}/tests/test_api_utils.py +0 -0
  67. {arraylake-1.5.0 → arraylake-1.7.0}/tests/test_asyn.py +0 -0
  68. {arraylake-1.5.0 → arraylake-1.7.0}/tests/test_config.py +0 -0
  69. {arraylake-1.5.0 → arraylake-1.7.0}/tests/test_credential_cache.py +0 -0
  70. {arraylake-1.5.0 → arraylake-1.7.0}/tests/test_credentials.py +0 -0
  71. {arraylake-1.5.0 → arraylake-1.7.0}/tests/test_diagnostics.py +0 -0
  72. {arraylake-1.5.0 → arraylake-1.7.0}/tests/test_http_metastore.py +0 -0
  73. {arraylake-1.5.0 → arraylake-1.7.0}/tests/test_obstore.py +0 -0
  74. {arraylake-1.5.0 → arraylake-1.7.0}/tests/test_performance.py +0 -0
  75. {arraylake-1.5.0 → arraylake-1.7.0}/tests/test_skills.py +0 -0
  76. {arraylake-1.5.0 → arraylake-1.7.0}/tests/test_token_handler.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arraylake
3
- Version: 1.5.0
3
+ Version: 1.7.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
@@ -15,23 +15,23 @@ 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
17
  Requires-Dist: icechunk<3.0.0,>=2.2.2
18
- Requires-Dist: numpy<3.0,>=1.23
19
18
  Requires-Dist: packaging<27.0,>=23.0
20
19
  Requires-Dist: pydantic[email]<3.0,>=2.9
21
20
  Requires-Dist: python-dateutil<3.0,>=2.8
21
+ Requires-Dist: pyyaml<7.0,>=6.0.1
22
22
  Requires-Dist: rich<16.0,>=12.6
23
23
  Requires-Dist: ruamel-yaml<1.0,>=0.17
24
24
  Requires-Dist: structlog<27.0,>=24.1
25
25
  Requires-Dist: typer<1.0,>=0.12.0
26
26
  Requires-Dist: zarr<4.0,>=3.1.0
27
27
  Provides-Extra: iceberg
28
- Requires-Dist: pyiceberg<0.13,>=0.11; extra == 'iceberg'
28
+ Requires-Dist: pyiceberg[pyarrow]<0.13,>=0.11; extra == 'iceberg'
29
29
  Provides-Extra: maximal
30
30
  Requires-Dist: xarray[accel,etc,io,parallel]>=2024.10.0; extra == 'maximal'
31
31
  Provides-Extra: obstore
32
32
  Requires-Dist: obstore>=0.9.2; extra == 'obstore'
33
33
  Provides-Extra: openeo
34
- Requires-Dist: openeo<0.52,>=0.35.0; extra == 'openeo'
34
+ Requires-Dist: openeo<0.53,>=0.35.0; extra == 'openeo'
35
35
  Provides-Extra: xarray
36
36
  Requires-Dist: xarray>=2024.10.0; extra == 'xarray'
37
37
  Description-Content-Type: text/markdown
@@ -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.5.0'
22
- __version_tuple__ = version_tuple = (1, 5, 0)
21
+ __version__ = version = '1.7.0'
22
+ __version_tuple__ = version_tuple = (1, 7, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -3,6 +3,7 @@ from enum import StrEnum
3
3
 
4
4
  import typer
5
5
  from rich import print_json
6
+ from rich.markup import escape
6
7
  from rich.table import Table
7
8
 
8
9
  from arraylake import Client
@@ -25,6 +26,7 @@ def _services_table(services, org):
25
26
  table.add_column("Service Type", justify="right", style="green", min_width=10)
26
27
  table.add_column("Org", justify="right", style="green", min_width=10)
27
28
  table.add_column("Public", justify="right", style="green", min_width=10)
29
+ table.add_column("Region", justify="right", style="green", min_width=10)
28
30
  table.add_column("URL", justify="right", style="green", min_width=10)
29
31
  table.add_column("Status", justify="right", style="green", min_width=10)
30
32
 
@@ -45,6 +47,7 @@ def _services_table(services, org):
45
47
  service.config.service_type,
46
48
  service.config.org,
47
49
  str(service.config.is_public),
50
+ service.region or "",
48
51
  service.url,
49
52
  service.status,
50
53
  # .get, because the API owns this vocabulary and adds to it: an older client must
@@ -67,6 +70,12 @@ def enable_service(
67
70
  "-n",
68
71
  help="Custom deployment name (defaults to protocol name). Must be lowercase alphanumeric with dashes, max 16 chars.",
69
72
  ),
73
+ region: str | None = typer.Option(
74
+ None,
75
+ "--region",
76
+ "-r",
77
+ help="The region to run the service in, e.g. eu-west-1. Defaults to the environment's default region.",
78
+ ),
70
79
  ):
71
80
  """**Enable** a new service
72
81
 
@@ -83,13 +92,19 @@ def enable_service(
83
92
  ```
84
93
  $ arraylake compute enable my-org tiles --sku-code s --deployment-name my-tiles
85
94
  ```
95
+
96
+ - Enable an EDR service in Europe
97
+
98
+ ```
99
+ $ arraylake compute enable my-org edr --sku-code xs --region eu-west-1
100
+ ```
86
101
  """
87
- kwargs = {}
102
+ kwargs: dict[str, str] = {}
88
103
  if deployment_name is not None:
89
104
  kwargs["deployment_name"] = deployment_name
90
105
 
91
106
  with simple_progress(f"Creating [bold]{protocol}[/bold] service for [bold]{org}[/bold]..."):
92
- status = Client().get_services(org).enable(protocol, is_public, sku_code=sku_code, **kwargs)
107
+ status = Client().get_services(org).enable(protocol, is_public, sku_code=sku_code, region=region, **kwargs)
93
108
  rich_console.print(status["message"])
94
109
 
95
110
 
@@ -190,8 +205,8 @@ async def get_service_logs(
190
205
 
191
206
  async for log in log_stream:
192
207
  rich_console.print(log)
193
- except RuntimeError:
194
- rich_console.print(f"[bold red]No logs found for {org}/{service_id}. Please try again later.[/bold red]")
208
+ except RuntimeError as err:
209
+ rich_console.print(f"[bold red]{escape(str(err))}[/bold red]")
195
210
  raise typer.Exit(code=1)
196
211
 
197
212
 
@@ -210,7 +225,7 @@ def update_service(
210
225
  - Resize a service with ID edr-1234567 to a different compute shape
211
226
 
212
227
  ```
213
- $ arraylake compute update my-org edr-1234567 --sku medium
228
+ $ arraylake compute update my-org edr-1234567 --sku m
214
229
  ```
215
230
 
216
231
  - Change the scaling bounds of a service
@@ -162,7 +162,7 @@ def unset(
162
162
  @app.command()
163
163
  def get(
164
164
  key: str = typer.Argument(..., help="key to get in config file"),
165
- path: Path = typer.Option(user_config_file, help="Config file to update"),
165
+ path: Path | None = typer.Option(None, help="Config file to read. Defaults to the active config."),
166
166
  output: ListOutputType = typer.Option("yaml", help="Output formatting"),
167
167
  ):
168
168
  """**Get** Arraylake config value
@@ -191,11 +191,11 @@ def get(
191
191
  def init(path: Path = typer.Option(user_config_file, help="Config file to initialize")):
192
192
  """**Initialize** Arraylake config
193
193
 
194
- This command will walk you through the steps to configure Arraylake.
194
+ Creates the config file if it does not exist.
195
195
  """
196
196
 
197
197
  c = get_user_config(path=path)
198
198
 
199
199
  # write back to file
200
200
  write_user_config(c, path=path)
201
- rich_console.print(f"✅ Config file updated at {path}")
201
+ rich_console.print(f"Config file: {path}")
@@ -109,7 +109,7 @@ def list_namespaces(
109
109
  filter_metadata: str | None = typer.Option(None, help="JSON metadata filter"),
110
110
  sort: NamespaceSort | None = None,
111
111
  direction: Direction = Direction.desc,
112
- include_ghosts: bool = False,
112
+ include_ghosts: bool = typer.Option(False, help="Also list deleted namespaces that you can still restore"),
113
113
  page_size: int = typer.Option(50, min=1, max=100),
114
114
  output: Output = Output.rich,
115
115
  ):
@@ -195,10 +195,22 @@ def delete_namespace(
195
195
  ):
196
196
  """Delete an empty namespace. A soft-delete by default: restore it during its recovery period."""
197
197
  if not confirm:
198
- verb = "Permanently delete" if immediate else "Delete"
199
- typer.confirm(f"{verb} namespace {org}/{name}?", abort=True)
198
+ typer.confirm(
199
+ f"{_namespace_delete_outcome(org, name, immediate=immediate, retain_data=retain_data, done=False)} Continue?", abort=True
200
+ )
200
201
  Client().delete_iceberg_namespace(org, name, imsure=True, immediate=immediate, retain_data=retain_data)
201
- typer.echo(f"Deleted {org}/{name}")
202
+ typer.echo(_namespace_delete_outcome(org, name, immediate=immediate, retain_data=retain_data, done=True))
203
+
204
+
205
+ def _namespace_delete_outcome(org: str, name: str, *, immediate: bool, retain_data: bool, done: bool) -> str:
206
+ verb = "Deleted namespace" if done else "This will delete namespace"
207
+ if not immediate:
208
+ return (
209
+ f"{verb} {org}/{name}. Until the grace period ends, you can restore it with `arraylake iceberg namespace restore {org} {name}`."
210
+ )
211
+ if retain_data:
212
+ return f"{verb} {org}/{name} from Arraylake. Its data stays in your storage bucket."
213
+ return f"{verb} {org}/{name} and its data. You cannot restore it."
202
214
 
203
215
 
204
216
  @namespace_app.command("restore")
@@ -253,7 +253,7 @@ async def delete(
253
253
  """
254
254
  if not confirm:
255
255
  confirm = typer.confirm(
256
- f"This will permanently remove the {repo_name} repo. Are you sure you want to continue?",
256
+ f"{_delete_outcome(repo_name, immediate=immediate, retain_data=retain_data, done=False)} Continue?",
257
257
  abort=True,
258
258
  )
259
259
 
@@ -262,8 +262,16 @@ async def delete(
262
262
  with simple_progress(f"Deleting repo [bold]{repo_name}[/bold]..."):
263
263
  await client.delete_repo(repo_name, imsure=confirm, imreallysure=confirm, immediate=immediate, retain_data=retain_data)
264
264
 
265
- # If the repo is a icechunk repo, print message that the bucket must be deleted manually
266
- rich_console.print(f"Repo [bold]{repo_name}[/bold] removed from Arraylake. \nThe underlying Icechunk bucket must be deleted manually.")
265
+ rich_console.print(_delete_outcome(repo_name, immediate=immediate, retain_data=retain_data, done=True))
266
+
267
+
268
+ def _delete_outcome(repo_name: str, *, immediate: bool, retain_data: bool, done: bool) -> str:
269
+ verb = "Deleted" if done else "This will delete"
270
+ if not immediate:
271
+ return f"{verb} {repo_name}. You can restore it from the web app until the grace period ends."
272
+ if retain_data:
273
+ return f"{verb} {repo_name} from Arraylake. Its data stays in your storage bucket."
274
+ return f"{verb} {repo_name} and its data. You cannot restore it."
267
275
 
268
276
 
269
277
  @app.command()
@@ -339,7 +347,7 @@ async def set_status(
339
347
  @coro # type: ignore
340
348
  async def tune(
341
349
  org_name: str = typer.Argument(..., help="Name of organization"),
342
- bucket_config_nickname: str | None = typer.Option(None, help="Chunkstore bucket config nickname"),
350
+ bucket_config_nickname: str | None = typer.Option(None, help="Bucket config to benchmark. Defaults to the org's default bucket."),
343
351
  ):
344
352
  """Diagnose I/O configuration for optimal performance
345
353
 
@@ -119,6 +119,12 @@ def _parse_org_and_repo(org_and_repo: OrgAndRepoName) -> tuple[OrgName, RepoName
119
119
  return (parts[0], parts[1])
120
120
 
121
121
 
122
+ def _repo_status_text(headline: str, status: RepoOperationStatusResponse) -> str:
123
+ until = f" until {status.estimated_end_time}" if status.estimated_end_time else ""
124
+ reason = f" {status.message}" if status.message else ""
125
+ return f"{headline}{until}.{reason}"
126
+
127
+
122
128
  def _validate_service_uri(service_uri: str) -> None:
123
129
  if not service_uri.startswith("http"):
124
130
  raise ValueError("service uri must start with http")
@@ -732,11 +738,9 @@ class AsyncClient:
732
738
  response = await mstore.open_repo(repo_name)
733
739
 
734
740
  if response.status.mode == RepoOperationMode.OFFLINE:
735
- raise ValueError(f"Repository is offline, check back after {response.status.estimated_end_time}")
741
+ raise ValueError(_repo_status_text(f"Repo {name} is offline", response.status))
736
742
  elif response.status.mode == RepoOperationMode.MAINTENANCE:
737
- warnings.warn(
738
- f"Repository is in maintenance mode until {response.status.estimated_end_time}, do not attempt to write to the store"
739
- )
743
+ warnings.warn(_repo_status_text(f"Repo {name} is read-only for maintenance", response.status))
740
744
 
741
745
  return org, repo_name, response
742
746
 
@@ -1293,9 +1297,9 @@ class AsyncClient:
1293
1297
  # Raise a warning about token scoping for Azure delegated credentials
1294
1298
  if repo_model.bucket and repo_model.bucket.auth_config and repo_model.bucket.auth_config.method == "azure_credential_delegation":
1295
1299
  warnings.warn(
1296
- "The bucket is configured to use Azure Customer Managed Role authentication. "
1297
- "Credentials will be scoped to the container level only for this repo. "
1298
- "Ensure that no other sensitive data is stored in the same container, as the credentials will have read access to all data in the container.",
1300
+ "This bucket uses Azure credential delegation, so credentials cover the whole container, not only this repo. "
1301
+ "Users who can write to this repo can also write to every other blob in the container. "
1302
+ "Do not keep other data in this container.",
1299
1303
  UserWarning,
1300
1304
  )
1301
1305
 
@@ -1397,9 +1401,9 @@ class AsyncClient:
1397
1401
  # Raise a warning about token scoping for Azure delegated credentials
1398
1402
  if repo_model.bucket and repo_model.bucket.auth_config and repo_model.bucket.auth_config.method == "azure_credential_delegation":
1399
1403
  warnings.warn(
1400
- "The bucket is configured to use Azure Customer Managed Role authentication. "
1401
- "Credentials will be scoped to the container level only for this repo. "
1402
- "Ensure that no other sensitive data is stored in the same container, as the credentials will have read access to all data in the container.",
1404
+ "This bucket uses Azure credential delegation, so credentials cover the whole container, not only this repo. "
1405
+ "Users who can write to this repo can also write to every other blob in the container. "
1406
+ "Do not keep other data in this container.",
1403
1407
  UserWarning,
1404
1408
  )
1405
1409
 
@@ -1471,7 +1475,7 @@ class AsyncClient:
1471
1475
  from arraylake import __version__ as arraylake_version
1472
1476
 
1473
1477
  if not repo_model.subscription and not repo_model.bucket:
1474
- raise ValueError("The bucket on the catalog object cannot be None for Icechunk V2 repos!")
1478
+ raise ValueError(f"Repo {repo_model.org}/{repo_model.name} has no storage bucket.")
1475
1479
 
1476
1480
  effective_bucket = repo_model.bucket
1477
1481
  effective_prefix = repo_model.prefix
@@ -1481,7 +1485,7 @@ class AsyncClient:
1481
1485
  effective_prefix = repo_model.subscription.parent_repo.prefix
1482
1486
 
1483
1487
  if effective_bucket is None:
1484
- raise ValueError("Repo has no bucket configured")
1488
+ raise ValueError(f"Repo {repo_model.org}/{repo_model.name} has no storage bucket.")
1485
1489
 
1486
1490
  credential_refresh_func = self._maybe_get_credential_refresh_func_for_icechunk(
1487
1491
  bucket=effective_bucket, org=repo_model.org, repo_name=repo_model.name
@@ -3129,7 +3133,7 @@ class Client:
3129
3133
  Resolves the URL of the org's enabled openEO compute service and returns
3130
3134
  an authenticated ``openeo.Connection``. Requires the optional ``openeo``
3131
3135
  dependency (``pip install 'arraylake[openeo]'``) and an enabled openEO
3132
- service (``Client().get_services(org).enable("openeo")``).
3136
+ service (``Client().get_services(org).enable("openeo", sku_code="s")``).
3133
3137
 
3134
3138
  Args:
3135
3139
  org: Name of the org.
@@ -3146,9 +3150,11 @@ class Client:
3146
3150
  deployments = [d for d in self.get_services(org).list_enabled() if d.config.service_type == ServiceType.openeo]
3147
3151
  if deployment_name is not None:
3148
3152
  deployments = [d for d in deployments if (d.config.deployment_name or ServiceType.openeo.value) == deployment_name]
3153
+ if not deployments and deployment_name is not None:
3154
+ raise ValueError(f"Org {org!r} has no openEO service named {deployment_name!r}.")
3149
3155
  if not deployments:
3150
3156
  raise ValueError(
3151
- f"No openEO service is enabled for org {org!r}. Enable one with Client().get_services({org!r}).enable('openeo')."
3157
+ f"Org {org!r} has no openEO service. Enable one with Client().get_services({org!r}).enable('openeo', sku_code='s')."
3152
3158
  )
3153
3159
 
3154
3160
  token = self.aclient.token
@@ -11,6 +11,7 @@ from pydantic import TypeAdapter
11
11
  from arraylake.api_utils import ArraylakeHttpClient, handle_response
12
12
  from arraylake.compute.types import (
13
13
  ComputeConfig,
14
+ ComputeRegion,
14
15
  DeploymentInfo,
15
16
  LoadResults,
16
17
  ServiceConfig,
@@ -18,6 +19,7 @@ from arraylake.compute.types import (
18
19
 
19
20
  # type adapters
20
21
  LIST_SERVICES_ADAPTER = TypeAdapter(list[DeploymentInfo])
22
+ LIST_REGIONS_ADAPTER = TypeAdapter(list[ComputeRegion])
21
23
  DUMP_CONFIGS_ADAPTER = TypeAdapter(dict[str, list[ServiceConfig]])
22
24
 
23
25
 
@@ -90,6 +92,12 @@ class ComputeHttpClient(ArraylakeHttpClient):
90
92
  handle_response(response)
91
93
  return response.json()
92
94
 
95
+ async def list_compute_regions(self) -> list[ComputeRegion]:
96
+ """Lists the regions where this organization can run compute services."""
97
+ response = await self._request("GET", f"/compute/orgs/{self._config.org}/regions")
98
+ handle_response(response)
99
+ return LIST_REGIONS_ADAPTER.validate_json(response.content)
100
+
93
101
  async def list_compute_services(self) -> list[DeploymentInfo]:
94
102
  """Lists all of the Arraylake compute services for a given organization."""
95
103
  response = await self._request("GET", f"/compute/orgs/{self._config.org}/services")
@@ -130,10 +138,13 @@ class ComputeHttpClient(ArraylakeHttpClient):
130
138
  url = url + "?" + urlencode(params)
131
139
 
132
140
  async with await self._stream_request("GET", url) as response:
141
+ if response.is_error:
142
+ await response.aread()
143
+ handle_response(response)
133
144
  async for line in response.aiter_lines():
134
145
  yield line
135
146
  except Exception as e:
136
- raise RuntimeError(f"Error getting logs for {service_id}: {e}") from e
147
+ raise RuntimeError(f"Could not read logs for {service_id}: {e}") from e
137
148
 
138
149
  async def update_compute_service(self, service_id: str, changes: dict[str, Any]) -> DeploymentInfo:
139
150
  """Changes a running Arraylake compute service's shape or scaling.
@@ -20,7 +20,7 @@ from typing import Any
20
20
 
21
21
  from arraylake.asyn import sync
22
22
  from arraylake.compute.http_client import ComputeHttpClient, HttpComputeConfig
23
- from arraylake.compute.types import DeploymentInfo
23
+ from arraylake.compute.types import ComputeRegion, DeploymentInfo
24
24
 
25
25
  _VALID_NAME = r"(\w[\w\.\-_]+)"
26
26
 
@@ -61,10 +61,18 @@ class AsyncComputeClient:
61
61
  """Get a new HTTP client for the organization."""
62
62
  return ComputeHttpClient(HttpComputeConfig(self.service_uri, self.org, self.token))
63
63
 
64
- async def enable(self, protocol: str, is_public: bool = False, *, sku_code: str, **kwargs) -> dict[str, Any]:
64
+ async def enable(
65
+ self, protocol: str, is_public: bool = False, *, sku_code: str, region: str | None = None, **kwargs: Any
66
+ ) -> dict[str, Any]:
65
67
  """Enables a new Arraylake compute service."""
68
+ if region is not None:
69
+ kwargs["region"] = region
66
70
  return await self.http_client.create_compute_service(protocol, is_public, sku_code=sku_code, **kwargs)
67
71
 
72
+ async def regions(self) -> list[ComputeRegion]:
73
+ """Lists the regions where the organization can run compute services."""
74
+ return await self.http_client.list_compute_regions()
75
+
68
76
  async def list_enabled(self) -> list[DeploymentInfo]:
69
77
  """Lists all of the Arraylake compute services for the organization."""
70
78
  return await self.http_client.list_compute_services()
@@ -144,7 +152,7 @@ class ComputeClient:
144
152
 
145
153
  return wrap(*args, **kwargs)
146
154
 
147
- def enable(self, protocol: str, is_public: bool = False, *, sku_code: str, **kwargs) -> dict[str, Any]:
155
+ def enable(self, protocol: str, is_public: bool = False, *, sku_code: str, region: str | None = None, **kwargs: Any) -> dict[str, Any]:
148
156
  """Enables a new Arraylake compute service.
149
157
 
150
158
  Args:
@@ -152,12 +160,23 @@ class ComputeClient:
152
160
  is_public: Whether the service should be public or not. Defaults to False.
153
161
  sku_code: The compute shape to run the service on. Every service runs on a priced
154
162
  shape, so this is required.
163
+ region: The region to run the service in, such as "eu-west-1". `regions()` lists the
164
+ choices. Defaults to the environment's default region. The region cannot change
165
+ after creation.
155
166
  **kwargs: Additional arguments to pass to the service.
156
167
 
157
168
  Returns:
158
169
  Confirmation of the service being enabled.
159
170
  """
160
- return self._synchronize(self._aclient.enable, protocol, is_public, sku_code=sku_code, **kwargs)
171
+ return self._synchronize(self._aclient.enable, protocol, is_public, sku_code=sku_code, region=region, **kwargs)
172
+
173
+ def regions(self) -> list[ComputeRegion]:
174
+ """Lists the regions where the organization can run compute services.
175
+
176
+ Returns:
177
+ The available regions. The one marked `default` is used when `enable` gets no region.
178
+ """
179
+ return self._synchronize(self._aclient.regions)
161
180
 
162
181
  def list_enabled(self) -> list[DeploymentInfo]:
163
182
  """Lists all of the Arraylake compute services for the organization.
@@ -141,6 +141,9 @@ class ServiceConfig(BaseModel):
141
141
  memory_request: str | None = None
142
142
  memory_limit: str | None = None
143
143
 
144
+ # Fixed at creation. To move a service, disable it and enable it again in the other region.
145
+ region: str | None = None
146
+
144
147
  @field_validator("deployment_name")
145
148
  @classmethod
146
149
  def validate_deployment_name(cls, v: str | None) -> str | None:
@@ -170,6 +173,14 @@ class DeploymentInfo(BaseModel):
170
173
  # Pods serving right now. A service that scales to zero sits at 0 while still being available,
171
174
  # so this is what separates "ready to answer" from "the next request has to start it".
172
175
  ready_replicas: int = 0
176
+ # An older server sends no region.
177
+ region: str | None = None
178
+
179
+
180
+ class ComputeRegion(BaseModel):
181
+ name: str
182
+ location: str | None = None
183
+ default: bool = False
173
184
 
174
185
 
175
186
  class LoadResults(BaseModel):
@@ -184,9 +195,8 @@ class ComputeConfig(RedactedReprModel):
184
195
  container_repository: str
185
196
  kube_config: dict
186
197
  legacy_domain: str | None = None
187
- openmeter_api_key: str | None = None
188
198
 
189
- __redacted_repr_fields__ = frozenset({"kube_config", "openmeter_api_key"})
199
+ __redacted_repr_fields__ = frozenset({"kube_config"})
190
200
 
191
201
 
192
202
  class LogMessage(BaseModel):
@@ -269,7 +269,7 @@ class HttpMetastore(ArraylakeHttpClient, Metastore):
269
269
  retain_data: bool = False,
270
270
  ) -> None:
271
271
  if not (imsure and imreallysure):
272
- raise ValueError("Don't do this unless you're really sure. Once the database has been deleted, it's gone forever.")
272
+ raise ValueError("Pass imsure=True and imreallysure=True to confirm that you want to delete this repo.")
273
273
 
274
274
  # Default behavior is a soft-delete: the repo enters a recoverable
275
275
  # "ghost" state for the grace period and the daily cleanup job reclaims
@@ -25,9 +25,12 @@ FORBIDDEN_VIRTUAL_CHUNK_PREFIXES = {"file://", "memory://", "http://", "https://
25
25
 
26
26
  def _ensure_utc(expiration: datetime | None) -> datetime | None:
27
27
  """icechunk's Rust layer rejects a tz-naive expiration; treat naive as UTC."""
28
- if expiration is not None and expiration.tzinfo is None:
28
+ if expiration is None:
29
+ return None
30
+ if expiration.tzinfo is None:
29
31
  return expiration.replace(tzinfo=UTC)
30
- return expiration
32
+ # icechunk also rejects botocore's dateutil tzutc() on fresh STS expirations.
33
+ return expiration.astimezone(UTC)
31
34
 
32
35
 
33
36
  def get_icechunk_container_credentials(
@@ -174,10 +177,7 @@ def forbid_unsafe_virtual_bucket_configs(bucket: BucketResponse, bucket_nickname
174
177
 
175
178
  # exclude HMAC buckets specifically as these will never be safe
176
179
  if isinstance(auth_method, HmacAuth):
177
- raise ValueError(
178
- "Cannot use virtual chunk references that refer to a bucket which uses HMAC credentials, "
179
- f"But bucket {bucket_nickname} uses HMAC credentials."
180
- )
180
+ raise ValueError(f"Bucket {bucket_nickname} uses HMAC credentials, so virtual chunk references cannot point to it.")
181
181
 
182
182
 
183
183
  def forbid_unsafe_virtual_chunk_containers(
@@ -241,8 +241,8 @@ async def tune_org(org: str, bucket_config_nickname: str | None = None) -> Optim
241
241
  results = await tune_repo(repo)
242
242
  optimal_config = OptimalIOConfig(read=results.best_read(), write=results.best_write())
243
243
  finally:
244
- with CONSOLE.status("Cleaning up temporary repo"):
245
- client.delete_repo(repo_name, imsure=True, imreallysure=True)
244
+ with CONSOLE.status(f"Deleting temporary repo [yellow]{repo_name}"):
245
+ client.delete_repo(repo_name, imsure=True, imreallysure=True, immediate=True)
246
246
  CONSOLE.print("Optimal I/O configuration found:")
247
247
  CONSOLE.print(optimal_config)
248
248
  return optimal_config