arraylake 0.26.0__tar.gz → 0.31.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 (66) hide show
  1. {arraylake-0.26.0 → arraylake-0.31.0}/PKG-INFO +1 -1
  2. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/__init__.py +2 -0
  3. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/_version.py +2 -2
  4. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/api_utils.py +31 -37
  5. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/asyn.py +6 -4
  6. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/cli/compute.py +17 -1
  7. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/cli/repo.py +41 -9
  8. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/cli/utils.py +1 -1
  9. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/client.py +559 -126
  10. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/compute/doctor.py +12 -8
  11. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/compute/services.py +5 -2
  12. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/compute/types.py +21 -6
  13. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/credentials.py +13 -8
  14. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/diagnostics.py +2 -2
  15. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/display/repolist.py +8 -2
  16. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/metastore/http_metastore.py +166 -9
  17. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/repos/icechunk/storage.py +56 -10
  18. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/repos/icechunk/types.py +65 -1
  19. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/repos/icechunk/virtual.py +30 -7
  20. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/token.py +35 -45
  21. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/tuning.py +8 -6
  22. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/types.py +379 -20
  23. {arraylake-0.26.0 → arraylake-0.31.0}/pyproject.toml +10 -4
  24. {arraylake-0.26.0 → arraylake-0.31.0}/tests/conftest.py +61 -8
  25. {arraylake-0.26.0 → arraylake-0.31.0}/tests/icechunk/test_virtual.py +1 -1
  26. {arraylake-0.26.0 → arraylake-0.31.0}/tests/test_cli.py +44 -5
  27. {arraylake-0.26.0 → arraylake-0.31.0}/tests/test_client.py +300 -40
  28. {arraylake-0.26.0 → arraylake-0.31.0}/tests/test_credentials.py +1 -1
  29. {arraylake-0.26.0 → arraylake-0.31.0}/tests/test_diagnostics.py +3 -0
  30. {arraylake-0.26.0 → arraylake-0.31.0}/tests/test_performance.py +4 -7
  31. arraylake-0.31.0/tests/test_subscriptions.py +94 -0
  32. {arraylake-0.26.0 → arraylake-0.31.0}/tests/test_token_handler.py +7 -8
  33. arraylake-0.31.0/tests/test_types.py +404 -0
  34. {arraylake-0.26.0 → arraylake-0.31.0}/tests/test_virtual_chunks.py +218 -7
  35. arraylake-0.31.0/uv.lock +3090 -0
  36. arraylake-0.26.0/tests/test_types.py +0 -76
  37. arraylake-0.26.0/uv.lock +0 -3079
  38. {arraylake-0.26.0 → arraylake-0.31.0}/.gitignore +0 -0
  39. {arraylake-0.26.0 → arraylake-0.31.0}/README.md +0 -0
  40. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/__main__.py +0 -0
  41. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/cli/__init__.py +0 -0
  42. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/cli/auth.py +0 -0
  43. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/cli/config.py +0 -0
  44. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/cli/main.py +0 -0
  45. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/compute/http_client.py +0 -0
  46. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/config.py +0 -0
  47. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/config.yaml +0 -0
  48. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/display/__init__.py +0 -0
  49. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/display/repo.py +0 -0
  50. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/exceptions.py +0 -0
  51. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/log_util.py +0 -0
  52. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/metastore/__init__.py +0 -0
  53. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/metastore/abc.py +0 -0
  54. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/py.typed +0 -0
  55. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/repos/__init__.py +0 -0
  56. {arraylake-0.26.0 → arraylake-0.31.0}/arraylake/repos/icechunk/__init__.py +0 -0
  57. {arraylake-0.26.0 → arraylake-0.31.0}/tests/__init__.py +0 -0
  58. {arraylake-0.26.0 → arraylake-0.31.0}/tests/compute/test_client.py +0 -0
  59. {arraylake-0.26.0 → arraylake-0.31.0}/tests/compute/test_doctor.py +0 -0
  60. {arraylake-0.26.0 → arraylake-0.31.0}/tests/config.yaml +0 -0
  61. {arraylake-0.26.0 → arraylake-0.31.0}/tests/icechunk/__init__.py +0 -0
  62. {arraylake-0.26.0 → arraylake-0.31.0}/tests/icechunk/conftest.py +0 -0
  63. {arraylake-0.26.0 → arraylake-0.31.0}/tests/icechunk/test_storage.py +0 -0
  64. {arraylake-0.26.0 → arraylake-0.31.0}/tests/test_api_utils.py +0 -0
  65. {arraylake-0.26.0 → arraylake-0.31.0}/tests/test_asyn.py +0 -0
  66. {arraylake-0.26.0 → arraylake-0.31.0}/tests/test_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arraylake
3
- Version: 0.26.0
3
+ Version: 0.31.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
@@ -8,6 +8,7 @@ from arraylake.exceptions import (
8
8
  ArraylakeServerError,
9
9
  ArraylakeValidationError,
10
10
  )
11
+ from arraylake.types import StorageOptions
11
12
 
12
13
  __version__ = importlib.metadata.version("arraylake")
13
14
 
@@ -22,4 +23,5 @@ __all__ = [
22
23
  "ArraylakeClientError",
23
24
  "ArraylakeServerError",
24
25
  "ArraylakeValidationError",
26
+ "StorageOptions",
25
27
  ]
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.26.0'
32
- __version_tuple__ = version_tuple = (0, 26, 0)
31
+ __version__ = version = '0.31.0'
32
+ __version_tuple__ = version_tuple = (0, 31, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -5,11 +5,11 @@ import json
5
5
  import os
6
6
  import random
7
7
  import weakref
8
- from collections.abc import AsyncGenerator, Callable, Coroutine, Generator, Mapping
8
+ from collections.abc import AsyncGenerator, Callable, Generator, Mapping
9
9
  from contextlib import _AsyncGeneratorContextManager
10
10
  from dataclasses import dataclass, field
11
11
  from datetime import datetime
12
- from functools import partial, wraps
12
+ from functools import wraps
13
13
  from http import HTTPStatus
14
14
  from typing import Any
15
15
 
@@ -263,7 +263,7 @@ def retry_on_exception(exceptions: type[Exception] | tuple[type[Exception], ...]
263
263
  except exceptions as e:
264
264
  if i == n - 1:
265
265
  logger.debug(f"{type(e)} encountered with all retries. failing.")
266
- raise e
266
+ raise
267
267
  backoff = calc_backoff(i + 1, backoff_factor=0.5, jitter_ratio=0.1, max_backoff_wait=10)
268
268
  logger.debug(f"{type(e)} encountered, retrying time #{i} after {backoff} seconds")
269
269
  await asyncio.sleep(backoff) # try again after a pause
@@ -402,8 +402,7 @@ class ArraylakeHttpClient:
402
402
  if config.get("user.diagnostics", True):
403
403
  try:
404
404
  diagnostics = get_diagnostics()
405
- response = await self._request("POST", "user/diagnostics", content=diagnostics.model_dump_json())
406
- handle_response(response)
405
+ response = asyncio.create_task(self._request("POST", "user/diagnostics", content=diagnostics.model_dump_json()))
407
406
  except Exception as e:
408
407
  await logger.adebug("failed to send diagnostics", exception=str(e))
409
408
 
@@ -547,11 +546,14 @@ class AsyncRetryTransport(httpx.AsyncBaseTransport):
547
546
  backoff_factor: float = 0.1 # seconds, doubled every retry
548
547
  jitter_ratio: float = 0.1
549
548
  respect_retry_after_header: bool = True
550
- retryable_methods: frozenset[str] = field(default=frozenset(["HEAD", "GET", "PUT", "POST", "DELETE", "OPTIONS", "TRACE"]))
551
- retry_status_codes: frozenset[int] = field(
552
- default=frozenset(
553
- [HTTPStatus.TOO_MANY_REQUESTS, HTTPStatus.BAD_GATEWAY, HTTPStatus.SERVICE_UNAVAILABLE, HTTPStatus.GATEWAY_TIMEOUT]
554
- )
549
+ retryable_mapping: dict[HTTPStatus, list[str]] = field(
550
+ default_factory=lambda: {
551
+ HTTPStatus.INTERNAL_SERVER_ERROR: ["GET", "HEAD", "OPTIONS", "TRACE"],
552
+ HTTPStatus.TOO_MANY_REQUESTS: ["HEAD", "GET", "PUT", "PATCH", "POST", "DELETE", "OPTIONS", "TRACE"],
553
+ HTTPStatus.BAD_GATEWAY: ["HEAD", "GET", "PUT", "PATCH", "POST", "DELETE", "OPTIONS", "TRACE"],
554
+ HTTPStatus.SERVICE_UNAVAILABLE: ["HEAD", "GET", "PUT", "PATCH", "POST", "DELETE", "OPTIONS", "TRACE"],
555
+ HTTPStatus.GATEWAY_TIMEOUT: ["HEAD", "GET", "PUT", "PATCH", "POST", "DELETE", "OPTIONS", "TRACE"],
556
+ }
555
557
  )
556
558
  max_keepalive_connections: int = 100
557
559
  keepalive_expiry: int = 30
@@ -612,13 +614,24 @@ class AsyncRetryTransport(httpx.AsyncBaseTransport):
612
614
  The response.
613
615
 
614
616
  """
615
- transport: httpx.AsyncBaseTransport = self.wrapped_transport
616
- if request.method in self.retryable_methods:
617
- send_method = partial(transport.handle_async_request)
618
- response = await self._retry_operation_async(request, send_method)
619
- else:
620
- response = await transport.handle_async_request(request)
621
- return response
617
+ remaining_attempts = self.max_attempts
618
+ attempts_made = 0
619
+ headers: httpx.Headers = httpx.Headers()
620
+ while True:
621
+ if attempts_made > 0:
622
+ await asyncio.sleep(self._calculate_sleep(attempts_made, headers))
623
+ response = await self.wrapped_transport.handle_async_request(request)
624
+ headers = response.headers
625
+ if remaining_attempts < 1:
626
+ return response
627
+ if response.status_code not in self.retryable_mapping:
628
+ return response
629
+ status_code_http = HTTPStatus(response.status_code)
630
+ if request.method not in self.retryable_mapping[status_code_http]:
631
+ return response
632
+ await response.aclose()
633
+ attempts_made += 1
634
+ remaining_attempts -= 1
622
635
 
623
636
  async def aclose(self) -> None:
624
637
  """
@@ -630,7 +643,7 @@ class AsyncRetryTransport(httpx.AsyncBaseTransport):
630
643
  transport: httpx.AsyncBaseTransport = self.wrapped_transport
631
644
  await transport.aclose()
632
645
 
633
- def _calculate_sleep(self, attempts_made: int, headers: httpx.Headers | Mapping[str, str]) -> float:
646
+ def _calculate_sleep(self, attempts_made: int, headers: httpx.Headers) -> float:
634
647
  # Retry-After
635
648
  # The Retry-After response HTTP header indicates how long the user agent should wait before
636
649
  # making a follow-up request. There are three main cases this header is used:
@@ -657,22 +670,3 @@ class AsyncRetryTransport(httpx.AsyncBaseTransport):
657
670
  return calc_backoff(
658
671
  attempts_made, backoff_factor=self.backoff_factor, jitter_ratio=self.jitter_ratio, max_backoff_wait=self.max_backoff_wait
659
672
  )
660
-
661
- async def _retry_operation_async(
662
- self,
663
- request: httpx.Request,
664
- send_method: Callable[..., Coroutine[Any, Any, httpx.Response]],
665
- ) -> httpx.Response:
666
- remaining_attempts = self.max_attempts
667
- attempts_made = 0
668
- headers = {} # type: Union[httpx.Headers, Mapping[str, str]]
669
- while True:
670
- if attempts_made > 0:
671
- await asyncio.sleep(self._calculate_sleep(attempts_made, headers))
672
- response = await send_method(request)
673
- headers = response.headers
674
- if remaining_attempts < 1 or response.status_code not in self.retry_status_codes:
675
- return response
676
- await response.aclose()
677
- attempts_made += 1
678
- remaining_attempts -= 1
@@ -63,7 +63,9 @@ T = TypeVar("T")
63
63
  PageableFunction = Callable[..., Awaitable[tuple[Iterable[T], "PageableFunction"]]]
64
64
 
65
65
 
66
- async def async_gather_tasks(*coroutines: Awaitable[T]) -> list[T]:
66
+ # NOTE: Properly type hinting this is impossible at the moment
67
+ # https://discuss.python.org/t/pre-pep-considerations-and-feedback-type-transformations-on-variadic-generics/50605
68
+ async def async_gather_tasks(*coroutines: Coroutine[Any, Any, T]) -> list[T]:
67
69
  """
68
70
  Run coroutines concurrently using TaskGroup, returning ordered results.
69
71
 
@@ -74,7 +76,7 @@ async def async_gather_tasks(*coroutines: Awaitable[T]) -> list[T]:
74
76
  """
75
77
  try:
76
78
  async with asyncio.TaskGroup() as tg:
77
- tasks = [tg.create_task(coro) for coro in coroutines]
79
+ tasks: list[asyncio.Task[T]] = [tg.create_task(coro) for coro in coroutines]
78
80
 
79
81
  return [task.result() for task in tasks]
80
82
  except BaseException as e:
@@ -227,7 +229,7 @@ def get_background_loop() -> asyncio.AbstractEventLoop:
227
229
  return new_loop
228
230
 
229
231
 
230
- def asyncio_run(coro: Awaitable[T], timeout=30) -> T:
232
+ def asyncio_run(coro: Coroutine[Any, Any, T], timeout=30) -> T:
231
233
  """
232
234
  Runs the coroutine in an event loop running on a background thread,
233
235
  and blocks the current thread until it returns a result.
@@ -238,7 +240,7 @@ def asyncio_run(coro: Awaitable[T], timeout=30) -> T:
238
240
  Args:
239
241
  coro: A coroutine, typically an async method
240
242
  timeout: How many seconds we should wait for a result before raising an error
241
- """ # noqa: E501
243
+ """
242
244
  return asyncio.run_coroutine_threadsafe(coro, get_background_loop()).result(timeout=timeout)
243
245
 
244
246
 
@@ -49,6 +49,12 @@ def enable_service(
49
49
  org: str = typer.Argument(..., help="The organization to enable the service for"),
50
50
  protocol: str = typer.Argument(..., help="The protocol to enable. Must be one of: dap2, edr, wms, tiles, zarr"),
51
51
  is_public: bool = typer.Option(False, help="Whether the service is public"),
52
+ deployment_name: str | None = typer.Option(
53
+ None,
54
+ "--deployment-name",
55
+ "-n",
56
+ help="Custom deployment name (defaults to protocol name). Must be lowercase alphanumeric with dashes, max 16 chars.",
57
+ ),
52
58
  ):
53
59
  """**Enable** a new service
54
60
 
@@ -59,9 +65,19 @@ def enable_service(
59
65
  ```
60
66
  $ arraylake compute enable my-org dap2
61
67
  ```
68
+
69
+ - Enable a tiles service with a custom deployment name
70
+
71
+ ```
72
+ $ arraylake compute enable my-org tiles --deployment-name my-tiles
73
+ ```
62
74
  """
75
+ kwargs = {}
76
+ if deployment_name is not None:
77
+ kwargs["deployment_name"] = deployment_name
78
+
63
79
  with simple_progress(f"Creating [bold]{protocol}[/bold] service for [bold]{org}[/bold]..."):
64
- status = Client().get_services(org).enable(protocol, is_public)
80
+ status = Client().get_services(org).enable(protocol, is_public, **kwargs)
65
81
  rich_console.print(status["message"])
66
82
 
67
83
 
@@ -128,8 +128,8 @@ async def list_repos(
128
128
  repos = await AsyncClient().list_repos(org, filter_metadata=metadata_filter_dict)
129
129
 
130
130
  if output == "json":
131
- repos = [_make_json_safe(r._asdict()) for r in repos]
132
- print_json(data=repos)
131
+ repos_json = [_make_json_safe(r._asdict()) for r in repos]
132
+ print_json(data=repos_json)
133
133
  elif repos:
134
134
  rich_console.print(_repos_table(repos, org))
135
135
  else:
@@ -141,6 +141,7 @@ async def list_repos(
141
141
  async def create(
142
142
  repo_name: str = typer.Argument(..., help="Name of repository {ORG}/{REPO_NAME}"),
143
143
  bucket_config_nickname: str | None = typer.Option(None, help="Bucket config nickname"),
144
+ prefix: str = typer.Option(None, help="Optional prefix for Icechunk repo. If not provided, a random ID + the repo name will be used."),
144
145
  import_existing: bool = typer.Option(False, help="Import existing Icechunk data into the new repo"),
145
146
  description: str | None = typer.Option(None, help="Description of the repo"),
146
147
  metadata: str | None = typer.Option(None, help="Optional metadata for the repo"),
@@ -157,13 +158,44 @@ async def create(
157
158
  """
158
159
  metadata_dict = json.loads(metadata) if metadata else None
159
160
  with simple_progress(f"Creating repo [bold]{repo_name}[/bold]..."):
160
- kwargs = {
161
- "bucket_config_nickname": bucket_config_nickname,
162
- "import_existing": import_existing,
163
- "description": description,
164
- "metadata": metadata_dict,
165
- }
166
- await AsyncClient().create_repo(repo_name, **kwargs)
161
+ await AsyncClient().create_repo(
162
+ repo_name,
163
+ bucket_config_nickname=bucket_config_nickname,
164
+ prefix=prefix,
165
+ import_existing=import_existing,
166
+ description=description,
167
+ metadata=metadata_dict,
168
+ )
169
+
170
+
171
+ @app.command(name="import")
172
+ @coro # type: ignore
173
+ async def import_repo(
174
+ repo_name: str = typer.Argument(..., help="Name of repository {ORG}/{REPO_NAME}"),
175
+ bucket_config_nickname: str = typer.Argument(..., help="Bucket config nickname"),
176
+ prefix: str = typer.Option(None, help="Sub-prefix in which the Icechunk repo exists"),
177
+ description: str | None = typer.Option(None, help="Description of the repo"),
178
+ metadata: str | None = typer.Option(None, help="Optional metadata for the repo"),
179
+ ):
180
+ """**Import** an existing Icechunk repository
181
+
182
+ **Examples**
183
+
184
+ - Import existing Icechunk repository
185
+
186
+ ```
187
+ $ arraylake repo import my-org/example-repo --bucket-config-nickname icechunk-bucket --prefix my-icechunk-prefix --metadata '{"key": "value"}'
188
+ ```
189
+ """
190
+ metadata_dict = json.loads(metadata) if metadata else None
191
+ with simple_progress(f"Importing repo [bold]{repo_name}[/bold]..."):
192
+ await AsyncClient().import_repo(
193
+ repo_name,
194
+ bucket_config_nickname=bucket_config_nickname,
195
+ prefix=prefix,
196
+ description=description,
197
+ metadata=metadata_dict,
198
+ )
167
199
 
168
200
 
169
201
  @app.command()
@@ -162,7 +162,7 @@ def print_user_details(user: UserInfo):
162
162
  rich_console.print(
163
163
  Panel(
164
164
  f"Name: [dim]{user.first_name} {user.family_name}[/dim]\nEmail: [dim]{user.email}[/dim]\nId: [dim]{user.id}[/dim]",
165
- title="👤 User Details",
165
+ title="[bold]User Details[/bold]",
166
166
  )
167
167
  )
168
168