arraylake 0.33.0__tar.gz → 1.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {arraylake-0.33.0 → arraylake-1.0.1}/.gitignore +3 -0
  2. {arraylake-0.33.0 → arraylake-1.0.1}/PKG-INFO +7 -9
  3. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/_version.py +2 -2
  4. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/api_utils.py +24 -0
  5. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/cli/auth.py +1 -8
  6. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/client.py +21 -10
  7. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/repos/icechunk/virtual.py +0 -22
  8. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/types.py +128 -0
  9. {arraylake-0.33.0 → arraylake-1.0.1}/pyproject.toml +18 -21
  10. {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_api_utils.py +49 -3
  11. {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_client.py +41 -0
  12. {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_virtual_chunks.py +21 -6
  13. {arraylake-0.33.0 → arraylake-1.0.1}/uv.lock +799 -683
  14. {arraylake-0.33.0 → arraylake-1.0.1}/README.md +0 -0
  15. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/__init__.py +0 -0
  16. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/__main__.py +0 -0
  17. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/asyn.py +0 -0
  18. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/cli/__init__.py +0 -0
  19. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/cli/compute.py +0 -0
  20. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/cli/config.py +0 -0
  21. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/cli/main.py +0 -0
  22. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/cli/repo.py +0 -0
  23. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/cli/utils.py +0 -0
  24. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/compute/doctor.py +0 -0
  25. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/compute/http_client.py +0 -0
  26. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/compute/services.py +0 -0
  27. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/compute/types.py +0 -0
  28. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/config.py +0 -0
  29. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/config.yaml +0 -0
  30. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/credentials.py +0 -0
  31. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/diagnostics.py +0 -0
  32. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/display/__init__.py +0 -0
  33. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/display/repo.py +0 -0
  34. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/display/repolist.py +0 -0
  35. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/exceptions.py +0 -0
  36. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/log_util.py +0 -0
  37. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/metastore/__init__.py +0 -0
  38. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/metastore/abc.py +0 -0
  39. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/metastore/http_metastore.py +0 -0
  40. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/py.typed +0 -0
  41. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/repos/__init__.py +0 -0
  42. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/repos/icechunk/__init__.py +0 -0
  43. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/repos/icechunk/storage.py +0 -0
  44. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/repos/icechunk/types.py +0 -0
  45. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/token.py +0 -0
  46. {arraylake-0.33.0 → arraylake-1.0.1}/arraylake/tuning.py +0 -0
  47. {arraylake-0.33.0 → arraylake-1.0.1}/tests/__init__.py +0 -0
  48. {arraylake-0.33.0 → arraylake-1.0.1}/tests/compute/test_client.py +0 -0
  49. {arraylake-0.33.0 → arraylake-1.0.1}/tests/compute/test_doctor.py +0 -0
  50. {arraylake-0.33.0 → arraylake-1.0.1}/tests/config.yaml +0 -0
  51. {arraylake-0.33.0 → arraylake-1.0.1}/tests/conftest.py +0 -0
  52. {arraylake-0.33.0 → arraylake-1.0.1}/tests/icechunk/__init__.py +0 -0
  53. {arraylake-0.33.0 → arraylake-1.0.1}/tests/icechunk/conftest.py +0 -0
  54. {arraylake-0.33.0 → arraylake-1.0.1}/tests/icechunk/test_storage.py +0 -0
  55. {arraylake-0.33.0 → arraylake-1.0.1}/tests/icechunk/test_virtual.py +0 -0
  56. {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_asyn.py +0 -0
  57. {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_cli.py +0 -0
  58. {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_config.py +0 -0
  59. {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_credentials.py +0 -0
  60. {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_diagnostics.py +0 -0
  61. {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_performance.py +0 -0
  62. {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_subscriptions.py +0 -0
  63. {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_token_handler.py +0 -0
  64. {arraylake-0.33.0 → arraylake-1.0.1}/tests/test_types.py +0 -0
@@ -146,3 +146,6 @@ client/arraylake/_version.py
146
146
 
147
147
  # claude
148
148
  **/.claude/
149
+ !/.claude/
150
+ /.claude/*
151
+ !/.claude/skills/
@@ -1,33 +1,31 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arraylake
3
- Version: 0.33.0
3
+ Version: 1.0.1
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
7
7
  Author-email: Joseph Hamman <joe@earthmover.io>
8
8
  License-Expression: MIT
9
9
  Classifier: Programming Language :: Python
10
- Classifier: Programming Language :: Python :: 3.11
11
10
  Classifier: Programming Language :: Python :: 3.12
12
11
  Classifier: Programming Language :: Python :: 3.13
13
- Requires-Python: <3.14,>=3.11
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ 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
- Requires-Dist: httpx<0.28,>=0.23
17
- Requires-Dist: icechunk<2.0.0,>=1.1.0
16
+ Requires-Dist: httpx<0.29,>=0.23
17
+ Requires-Dist: icechunk<3.0.0,>=2.0.1
18
18
  Requires-Dist: numpy<3.0,>=1.23
19
- Requires-Dist: packaging<26.0,>=23.0
19
+ Requires-Dist: packaging<27.0,>=23.0
20
20
  Requires-Dist: pydantic[email]<3.0,>=2.9
21
21
  Requires-Dist: python-dateutil<3.0,>=2.8
22
- Requires-Dist: rich<15.0,>=12.6
22
+ Requires-Dist: rich<16.0,>=12.6
23
23
  Requires-Dist: ruamel-yaml<1.0,>=0.17
24
24
  Requires-Dist: structlog<26.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: maximal
28
28
  Requires-Dist: cf-xarray>=0.10.4; extra == 'maximal'
29
- Requires-Dist: numpy<2.3; extra == 'maximal'
30
- Requires-Dist: pyarrow<22; extra == 'maximal'
31
29
  Requires-Dist: xarray[accel,etc,io,parallel]>=2024.10.0; extra == 'maximal'
32
30
  Provides-Extra: xarray
33
31
  Requires-Dist: cf-xarray>=0.10.4; 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 = '0.33.0'
22
- __version_tuple__ = version_tuple = (0, 33, 0)
21
+ __version__ = version = '1.0.1'
22
+ __version_tuple__ = version_tuple = (1, 0, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -1,9 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import functools
4
5
  import json
5
6
  import os
6
7
  import random
8
+ import uuid
9
+ import warnings
7
10
  import weakref
8
11
  from collections.abc import AsyncGenerator, Callable, Generator, Mapping
9
12
  from contextlib import _AsyncGeneratorContextManager
@@ -81,6 +84,19 @@ def _get_proxy_from_env() -> str | None:
81
84
  return None
82
85
 
83
86
 
87
+ @functools.cache
88
+ def _get_session_id() -> str:
89
+ """Session ID for this Python process.
90
+
91
+ Propagated as the ``arraylake-session-id`` header on every outbound request and
92
+ baked into ``ArraylakeHttpClient._default_headers`` at construction time, so it
93
+ rides along to dask workers via the existing pickle path on the client.
94
+ """
95
+ session_id = str(uuid.uuid4())
96
+ logger.info("arraylake session id: %s", session_id)
97
+ return session_id
98
+
99
+
84
100
  # this is a hashable key to use for the global client cache
85
101
  # we only need one client for each key
86
102
  @dataclass(eq=True, frozen=True)
@@ -301,6 +317,7 @@ class ArraylakeHttpClient:
301
317
  # technically we don't enforce this, for now it's for debugging purposes
302
318
  "client-name": "arraylake-python-client",
303
319
  "client-version": arraylake.__version__,
320
+ "arraylake-session-id": _get_session_id(),
304
321
  }
305
322
 
306
323
  self._async_lock = asyncio.Lock()
@@ -430,8 +447,15 @@ def _exception_log_debug(request: httpx.Request, response: httpx.Response):
430
447
  )
431
448
 
432
449
 
450
+ _emitted_deprecation_warnings: set[str] = set()
451
+
452
+
433
453
  def handle_response(response: httpx.Response):
434
454
  """Convenience function to handle response status codes"""
455
+ deprecation_msg = response.headers.get("X-Deprecation-Warning")
456
+ if deprecation_msg and deprecation_msg not in _emitted_deprecation_warnings:
457
+ _emitted_deprecation_warnings.add(deprecation_msg)
458
+ warnings.warn(deprecation_msg, DeprecationWarning, stacklevel=2)
435
459
  try:
436
460
  response.raise_for_status()
437
461
  except httpx.RequestError as exc:
@@ -101,14 +101,7 @@ def token():
101
101
  raise typer.Exit(code=1)
102
102
  try:
103
103
  token_value = handler.tokens.id_token.get_secret_value()
104
- rich_console.print(
105
- Panel(
106
- f"[dim]{token_value}[/dim]",
107
- title="id_token",
108
- subtitle=f"[link=https://jwt.io/#id_token={token_value}][blue]:link: Inspect with JWT.io[/blue][/link]",
109
- subtitle_align="right",
110
- )
111
- )
104
+ typer.echo(token_value)
112
105
  except (AuthException, AttributeError):
113
106
  rich_console.print(Align(f"[yellow]{NOT_LOGGED_IN_MESSAGE}[/yellow]", align="center"))
114
107
  raise typer.Exit(code=1)
@@ -46,7 +46,6 @@ from arraylake.repos.icechunk.storage import (
46
46
  _get_icechunk_storage_obj,
47
47
  )
48
48
  from arraylake.repos.icechunk.virtual import (
49
- create_vcc_store_config_for_platform,
50
49
  create_virtual_chunk_container,
51
50
  forbid_unsafe_virtual_bucket_configs,
52
51
  forbid_unsafe_virtual_chunk_containers,
@@ -559,15 +558,17 @@ class AsyncClient:
559
558
  if response.virtual_chunk_credentials:
560
559
  credentials_map: dict[str, icechunk.AnyS3Credential | icechunk.AnyGcsCredential | icechunk.AnyAzureCredential | None] = {}
561
560
  for vcc_prefix, vcc_creds in response.virtual_chunk_credentials.items():
562
- # Handle runtime VCCs (e.g., filtered subscriptions) - create VCC on the fly
561
+ # Create VCC objects for all server-provided VCCs, not just runtime ones,
562
+ # because icechunk's config merge replaces virtual_chunk_containers
563
+ # rather than extending them.
564
+ store_config = vcc_creds.to_vcc_store_config(user_id=UUID(response.principal_id))
565
+ container = icechunk.VirtualChunkContainer(
566
+ url_prefix=vcc_prefix,
567
+ store=store_config,
568
+ )
563
569
  if vcc_creds.runtime_vcc_name is not None:
564
- store_config = create_vcc_store_config_for_platform(vcc_creds.platform)
565
- container = icechunk.VirtualChunkContainer(
566
- url_prefix=vcc_prefix,
567
- store=store_config,
568
- )
569
570
  container.name = vcc_creds.runtime_vcc_name
570
- config.set_virtual_chunk_container(container)
571
+ config.set_virtual_chunk_container(container)
571
572
 
572
573
  # TODO: build refresh closures here using vcc_creds.org, vcc_creds.bucket_nickname,
573
574
  # and vcc_creds.platform so that credentials can be refreshed when they expire. (See https://github.com/earth-mover/arraylake/issues/5122)
@@ -734,6 +735,7 @@ class AsyncClient:
734
735
  config: icechunk.RepositoryConfig | None = None,
735
736
  authorize_virtual_chunk_access: Mapping[BucketPrefix, BucketNickname] | None = None,
736
737
  storage_options: StorageOptions | None = None,
738
+ spec_version: int = 2,
737
739
  ) -> IcechunkRepository:
738
740
  """Create a new repo.
739
741
 
@@ -751,6 +753,7 @@ class AsyncClient:
751
753
  and used for the containers. Must be supplied as complete urls.
752
754
  storage_options: Optional storage options for the underlying Icechunk storage.
753
755
  Currently supports `network_stream_timeout_seconds` for S3, Tigris, and R2 storage.
756
+ spec_version: The Icechunk spec version to use for the repo. Defaults to 2.
754
757
 
755
758
  Returns:
756
759
  icechunk.Repository object for the repo.
@@ -808,10 +811,15 @@ class AsyncClient:
808
811
  # TODO: make icechunk accept an empty dict instead of None
809
812
  prefixes_credential_mapping = None
810
813
 
814
+ create_kwargs: dict[str, object] = {
815
+ "config": config,
816
+ "authorize_virtual_chunk_access": prefixes_credential_mapping,
817
+ "spec_version": spec_version,
818
+ }
819
+
811
820
  ic_repo = IcechunkRepository.create(
812
821
  icechunk_storage,
813
- config=config,
814
- authorize_virtual_chunk_access=prefixes_credential_mapping,
822
+ **create_kwargs, # type: ignore[arg-type]
815
823
  )
816
824
 
817
825
  ic_repo.set_default_commit_metadata({"author_name": author.name, "author_email": author.email})
@@ -1667,6 +1675,7 @@ class Client:
1667
1675
  config: icechunk.RepositoryConfig | None = None,
1668
1676
  authorize_virtual_chunk_access: Mapping[BucketPrefix, BucketNickname] | None = None,
1669
1677
  storage_options: StorageOptions | None = None,
1678
+ spec_version: int = 2,
1670
1679
  ) -> IcechunkRepository:
1671
1680
  """Create a new repo
1672
1681
 
@@ -1685,6 +1694,7 @@ class Client:
1685
1694
  and used for the containers. Used for Icechunk repos only.
1686
1695
  storage_options: Optional storage options for the underlying Icechunk storage.
1687
1696
  Currently supports `network_stream_timeout_seconds` for S3, Tigris, and R2 storage.
1697
+ spec_version: The Icechunk spec version to use for the repo. Defaults to 2.
1688
1698
 
1689
1699
  Returns:
1690
1700
  icechunk.Repository: The created Icechunk repository.
@@ -1699,6 +1709,7 @@ class Client:
1699
1709
  config=config,
1700
1710
  authorize_virtual_chunk_access=authorize_virtual_chunk_access,
1701
1711
  storage_options=storage_options,
1712
+ spec_version=spec_version,
1702
1713
  )
1703
1714
 
1704
1715
  def import_repo(
@@ -19,28 +19,6 @@ from arraylake.types import (
19
19
  FORBIDDEN_VIRTUAL_CHUNK_PREFIXES = {"file://", "memory://", "http://"}
20
20
 
21
21
 
22
- def create_vcc_store_config_for_platform(platform: str) -> icechunk.AnyObjectStoreConfig:
23
- """Create an Icechunk ObjectStoreConfig for a virtual chunk container based on platform.
24
-
25
- This is used for runtime VCCs (e.g., filtered subscriptions) where we need to create
26
- a VCC dynamically based on the server-provided platform type.
27
-
28
- Args:
29
- platform: The storage platform (s3, s3-compatible, minio, gs, azure)
30
-
31
- Returns:
32
- An icechunk ObjectStoreConfig for the given platform
33
- """
34
- if platform in ("s3", "s3-compatible", "minio"):
35
- return icechunk.ObjectStoreConfig.S3(icechunk.S3Options())
36
- elif platform == "gs":
37
- return icechunk.ObjectStoreConfig.Gcs({})
38
- elif platform == "azure":
39
- return icechunk.ObjectStoreConfig.Azure({})
40
- else:
41
- raise ValueError(f"Unsupported platform for VCC store config: {platform}")
42
-
43
-
44
22
  def get_icechunk_container_credentials(
45
23
  bucket_platform: str,
46
24
  credentials: S3Credentials | GSCredentials | None,
@@ -289,11 +289,31 @@ class RepoSubscribeBody(RepoCreateBodyBase):
289
289
  None, description="Subscriber-defined filter applied on top of the provider's source filter. None means no additional filtering."
290
290
  )
291
291
  subscription_type: SubscriptionType = Field(default=SubscriptionType.DIRECT, description="The type/tier of subscription.")
292
+ prefix: BucketPrefix | None = Field(
293
+ None, description="Custom storage prefix for filtered/private subscriptions. If not provided, one is auto-generated."
294
+ )
295
+
296
+ @field_validator("prefix")
297
+ @classmethod
298
+ def validate_prefix_length(cls, v: str | None) -> str | None:
299
+ if v is not None and len(v) > MAX_BUCKET_PREFIX_LENGTH:
300
+ raise ValueError(f"Prefix must be at most {MAX_BUCKET_PREFIX_LENGTH} characters long. Got {len(v)}.")
301
+ return v
292
302
 
293
303
  @model_validator(mode="after")
294
304
  def validate_subscription_type_with_filter(self) -> RepoSubscribeBody:
295
305
  if self.target_filter is not None and self.subscription_type == SubscriptionType.DIRECT:
296
306
  raise ValueError("subscription_type cannot be 'direct' when target_filter is specified. Use 'filtered' or 'private' instead.")
307
+ # Filtered/private subscriptions require explicit target_filter with explicit allowlist
308
+ if self.subscription_type in (SubscriptionType.FILTERED, SubscriptionType.PRIVATE):
309
+ if self.target_filter is None:
310
+ raise ValueError(f"target_filter is required for {self.subscription_type.value} subscriptions.")
311
+ if self.target_filter.nodes is None:
312
+ raise ValueError(f"target_filter.nodes cannot be None for {self.subscription_type.value} subscriptions.")
313
+ if not self.target_filter.nodes.include_paths:
314
+ raise ValueError(f"target_filter must have explicit include_paths for {self.subscription_type.value} subscriptions.")
315
+ if self.target_filter.nodes.include_paths == ["/"]:
316
+ raise ValueError(f"target_filter cannot be the root group for {self.subscription_type.value} subscriptions.")
297
317
  return self
298
318
 
299
319
 
@@ -304,6 +324,16 @@ class RepoClaimBody(RepoCreateBodyBase):
304
324
  """
305
325
 
306
326
  claim_code: str = Field(..., description="The claim code to redeem")
327
+ prefix: BucketPrefix | None = Field(
328
+ None, description="Custom storage prefix for filtered/private subscriptions. If not provided, one is auto-generated."
329
+ )
330
+
331
+ @field_validator("prefix")
332
+ @classmethod
333
+ def validate_prefix_length(cls, v: str | None) -> str | None:
334
+ if v is not None and len(v) > MAX_BUCKET_PREFIX_LENGTH:
335
+ raise ValueError(f"Prefix must be at most {MAX_BUCKET_PREFIX_LENGTH} characters long. Got {len(v)}.")
336
+ return v
307
337
 
308
338
 
309
339
  class RepoModifyRequest(BaseModel):
@@ -471,6 +501,51 @@ class BucketModel(BaseModel):
471
501
  region = self.extra_config.get("region_name")
472
502
  return str(region) if region else None
473
503
 
504
+ def to_vcc_store_config(self, user_id: UUID | None = None) -> icechunk.AnyObjectStoreConfig:
505
+ """Create an Icechunk ObjectStoreConfig for use as a virtual chunk container.
506
+
507
+ This extracts the necessary configuration from the bucket's extra_config
508
+ to create a properly configured store config for accessing virtual chunks.
509
+
510
+ Args:
511
+ user_id: Optional user ID for logging purposes (used in GCS user_agent).
512
+
513
+ Returns:
514
+ An icechunk ObjectStoreConfig for the bucket's platform.
515
+ """
516
+ if self.platform in ("s3", "s3-compatible", "minio"):
517
+ endpoint_url = self.extra_config.get("endpoint_url")
518
+ if endpoint_url is not None:
519
+ endpoint_url = str(endpoint_url)
520
+
521
+ region = self.extra_config.get("region_name")
522
+ if region is not None:
523
+ region = str(region)
524
+
525
+ use_ssl = self.extra_config.get("use_ssl", True)
526
+ allow_http = not use_ssl
527
+
528
+ force_path_style_raw = self.extra_config.get("force_path_style", False if use_ssl else True)
529
+ force_path_style = bool(force_path_style_raw)
530
+
531
+ options = icechunk.S3Options(
532
+ region=region,
533
+ endpoint_url=endpoint_url,
534
+ allow_http=allow_http,
535
+ force_path_style=force_path_style,
536
+ )
537
+ return icechunk.ObjectStoreConfig.S3(options)
538
+ elif self.platform == "gs":
539
+ from arraylake import __version__ as arraylake_version
540
+
541
+ # Used for logging and debugging purposes in GCS access logs to help identify which client/version and optionally which user is accessing virtual chunks
542
+ config = {"user_agent": f"arraylake/{arraylake_version} (uuid={str(user_id)})" if user_id else f"arraylake/{arraylake_version}"}
543
+ return icechunk.ObjectStoreConfig.Gcs(config)
544
+ elif self.platform == "azure":
545
+ return icechunk.ObjectStoreConfig.Azure({})
546
+ else:
547
+ raise ValueError(f"Unsupported platform for VCC store config: {self.platform}")
548
+
474
549
 
475
550
  class Bucket(BucketModel):
476
551
  """A bucket configuration which actually exists in arraylake."""
@@ -1157,6 +1232,10 @@ class VirtualChunkCredentials(BaseModel):
1157
1232
 
1158
1233
  The org, bucket_nickname, and platform fields are needed so the client can
1159
1234
  construct credential refresh closures for delegated-creds buckets.
1235
+
1236
+ The store config fields (region, endpoint_url, allow_http, force_path_style)
1237
+ allow the client to construct VirtualChunkContainer objects without extra
1238
+ API calls to fetch the full bucket config.
1160
1239
  """
1161
1240
 
1162
1241
  # TODO: consider a dedicated server-side refresh endpoint to avoid exposing cross-org metadata to the client?
@@ -1168,6 +1247,55 @@ class VirtualChunkCredentials(BaseModel):
1168
1247
  # where subsync writes placeholder refs like vcc://__al_source/CHUNK_ID)
1169
1248
  # If None, assume the VCC already exists
1170
1249
  runtime_vcc_name: str | None = None
1250
+ # Store config fields for constructing VCCs without extra get_bucket_config() calls.
1251
+ # Populated from the bucket's extra_config via from_bucket().
1252
+ region: str | None = None
1253
+ endpoint_url: str | None = None
1254
+ allow_http: bool = False
1255
+ force_path_style: bool = False
1256
+
1257
+ @classmethod
1258
+ def from_bucket(
1259
+ cls,
1260
+ org: OrgName,
1261
+ bucket: BucketModel,
1262
+ credentials: TempCredentials | None,
1263
+ ) -> VirtualChunkCredentials:
1264
+ """Build VirtualChunkCredentials from a bucket model, extracting store config fields."""
1265
+ extra = bucket.extra_config
1266
+ use_ssl = extra.get("use_ssl", True)
1267
+ region = extra.get("region_name")
1268
+ endpoint_url = extra.get("endpoint_url")
1269
+ return cls(
1270
+ credentials=credentials,
1271
+ org=org,
1272
+ bucket_nickname=bucket.nickname,
1273
+ platform=bucket.platform,
1274
+ region=str(region) if region is not None else None,
1275
+ endpoint_url=str(endpoint_url) if endpoint_url is not None else None,
1276
+ allow_http=not use_ssl,
1277
+ force_path_style=bool(extra.get("force_path_style", False if use_ssl else True)),
1278
+ )
1279
+
1280
+ def to_vcc_store_config(self, user_id: UUID | None = None) -> icechunk.AnyObjectStoreConfig:
1281
+ """Create an Icechunk ObjectStoreConfig from the embedded store config fields."""
1282
+ if self.platform in ("s3", "s3-compatible", "minio"):
1283
+ options = icechunk.S3Options(
1284
+ region=self.region,
1285
+ endpoint_url=self.endpoint_url,
1286
+ allow_http=self.allow_http,
1287
+ force_path_style=self.force_path_style,
1288
+ )
1289
+ return icechunk.ObjectStoreConfig.S3(options)
1290
+ elif self.platform == "gs":
1291
+ from arraylake import __version__ as arraylake_version
1292
+
1293
+ config = {"user_agent": f"arraylake/{arraylake_version} (uuid={str(user_id)})" if user_id else f"arraylake/{arraylake_version}"}
1294
+ return icechunk.ObjectStoreConfig.Gcs(config)
1295
+ elif self.platform == "azure":
1296
+ return icechunk.ObjectStoreConfig.Azure({})
1297
+ else:
1298
+ raise ValueError(f"Unsupported platform for VCC store config: {self.platform}")
1171
1299
 
1172
1300
 
1173
1301
  class OpenRepoResponse(BaseModel):
@@ -21,26 +21,26 @@ readme = "README.md"
21
21
  authors = [
22
22
  { name = "Joseph Hamman", email = "joe@earthmover.io" },
23
23
  ]
24
- requires-python = ">=3.11, <3.14"
24
+ requires-python = ">=3.12, <3.15"
25
25
  dynamic = ["version"]
26
26
  license = "MIT"
27
27
  keywords = []
28
28
  classifiers = [
29
29
  "Programming Language :: Python",
30
- "Programming Language :: Python :: 3.11",
31
30
  "Programming Language :: Python :: 3.12",
32
31
  "Programming Language :: Python :: 3.13",
32
+ "Programming Language :: Python :: 3.14",
33
33
  ]
34
34
  dependencies = [
35
35
  "click >= 8.1.3, < 9.0",
36
36
  "donfig >= 0.7, < 1.0",
37
- "httpx >= 0.23, < 0.28",
38
- "icechunk >= 1.1.0, < 2.0.0",
37
+ "httpx >= 0.23, < 0.29",
38
+ "icechunk >= 2.0.1, < 3.0.0",
39
39
  "numpy >= 1.23, < 3.0",
40
- "packaging >= 23.0, < 26.0",
40
+ "packaging>=23.0,<27.0",
41
41
  "pydantic[email] >= 2.9, < 3.0",
42
42
  "python-dateutil >= 2.8, < 3.0",
43
- "rich >= 12.6, < 15.0",
43
+ "rich>=12.6,<16.0",
44
44
  "ruamel-yaml >= 0.17, < 1.0",
45
45
  "structlog >= 24.1, < 26.0",
46
46
  "typer >= 0.12.0, < 1.0",
@@ -55,31 +55,28 @@ xarray = [
55
55
  maximal = [
56
56
  "xarray[accel,etc,io,parallel] >= 2024.10.0",
57
57
  "cf_xarray >= 0.10.4",
58
- "numpy<2.3", # until dask[complete] updates their constraints
59
- "pyarrow<22", # restrict for better resolution. comes via dask[complete]
60
58
  ]
61
59
 
62
60
  [dependency-groups]
63
61
  dev = [
64
- "cloudpickle~=3.0.0",
62
+ "cloudpickle>=3.0,<3.2",
65
63
  "hatch>=1.14.2",
66
64
  "hypothesis>=6.88.1",
67
- "icechunk >= 1.1.9, < 2.0.0", # tests require higher min version than core package - see https://github.com/earth-mover/arraylake/pull/3404#discussion_r2490979373
65
+ "icechunk >= 2.0.1, < 3.0.0",
68
66
  "mypy~=1.11",
69
- "pytest-asyncio~=0.21.1",
67
+ "pytest-asyncio>=0.21.1,<1.4.0",
70
68
  "pytest-click~=1.1.0",
71
- "pytest-cov~=4.0.0",
69
+ "pytest-cov>=4.0,<7.2",
72
70
  "pytest-incremental~=0.6.0",
73
- "pytest-lazy-fixture~=0.6.3",
74
- "pytest-reportlog~=0.4.0",
75
- "pytest-retry~=1.6.2",
76
- "pytest-timeout~=2.1.0",
77
- "pytest-xdist~=3.5.0",
78
- "pytest~=7.3",
71
+ "pytest-reportlog>=0.4,<1.1",
72
+ "pytest-retry>=1.6.2,<1.8.0",
73
+ "pytest-timeout>=2.1,<2.5",
74
+ "pytest-xdist>=3.5,<3.9",
75
+ "pytest>=7.3,<10.0",
79
76
  "pyyaml~=6.0.1",
80
- "respx~=0.20.1",
81
- "types-cachetools~=5.3",
82
- "types-python-dateutil~=2.8.19",
77
+ "respx>=0.22,<0.24",
78
+ "types-cachetools>=5.3,<7.0",
79
+ "types-python-dateutil>=2.8.19,<2.10.0",
83
80
  "types-pyyaml~=6.0.12",
84
81
  "types-urllib3~=1.26.25",
85
82
  ]
@@ -16,7 +16,9 @@ from arraylake.api_utils import (
16
16
  AsyncRetryTransport,
17
17
  TokenAuth,
18
18
  UserAuth,
19
+ _emitted_deprecation_warnings,
19
20
  _get_proxy_from_env,
21
+ _get_session_id,
20
22
  calc_backoff,
21
23
  handle_response,
22
24
  retry_on_exception,
@@ -80,6 +82,24 @@ async def test_http_request_headers(respx_mock, token, test_token_file) -> None:
80
82
  assert route.calls.last.request.headers["special"] == "header"
81
83
 
82
84
 
85
+ def test_session_id_stable_within_process() -> None:
86
+ """Two clients in the same process share one session id (the point of the feature)."""
87
+ c1 = ArraylakeHttpClient("https://foo.com")
88
+ c2 = ArraylakeHttpClient("https://foo.com")
89
+ sid = _get_session_id()
90
+ assert c1._default_headers["arraylake-session-id"] == sid
91
+ assert c2._default_headers["arraylake-session-id"] == sid
92
+
93
+
94
+ def test_session_id_survives_pickling() -> None:
95
+ """Session id rides along when the client is pickled → one dask cluster, one session."""
96
+ client = ArraylakeHttpClient("https://foo.com")
97
+ sid = client._default_headers["arraylake-session-id"]
98
+ assert sid
99
+ restored = pickle.loads(pickle.dumps(client))
100
+ assert restored._default_headers["arraylake-session-id"] == sid
101
+
102
+
83
103
  def test_calc_backoff() -> None:
84
104
  assert calc_backoff(0, backoff_factor=0.5, jitter_ratio=0.1, max_backoff_wait=10) == 0
85
105
  assert calc_backoff(1, backoff_factor=0.5, jitter_ratio=0.0, max_backoff_wait=10) == 0.5
@@ -167,7 +187,7 @@ def test_token_auth_does_not_mutate_request(test_token, method, headers, json) -
167
187
  if k != "authorization": # keys are lower case
168
188
  assert header == request.headers.get(k)
169
189
  if json:
170
- assert json_lib.dumps(json).encode("utf-8") in request.content
190
+ assert json_lib.loads(request.content) == json
171
191
 
172
192
 
173
193
  @pytest.mark.asyncio
@@ -226,7 +246,7 @@ async def test_user_auth_does_not_mutate_request(test_token_file, method, header
226
246
  if k != "authorization": # keys are lower case
227
247
  assert header == request.headers.get(k)
228
248
  if json:
229
- assert json_lib.dumps(json).encode("utf-8") in request.content
249
+ assert json_lib.loads(request.content) == json
230
250
 
231
251
 
232
252
  @pytest.mark.asyncio
@@ -396,7 +416,7 @@ def test_handle_response_error(log_output: LogCapture) -> None:
396
416
  entries = log_output.entries[0]
397
417
  assert entries["log_level"] == "debug"
398
418
  assert entries["url"] == "https://earthmover.io/content/foo?a=b"
399
- assert entries["request_content"] == b'{"foo": "bar"}'
419
+ assert json_lib.loads(entries["request_content"]) == {"foo": "bar"}
400
420
  assert entries["request_headers"]["authorization"] == "[omitted]"
401
421
  assert entries["response_headers"]["content-type"] == "application/json"
402
422
  assert entries["response_headers"]["x-request-id"] == "7eea73c041524f268f804af813f7618f"
@@ -1011,6 +1031,32 @@ def test_arraylake_http_client_explicit_overrides_config() -> None:
1011
1031
  config.clear()
1012
1032
 
1013
1033
 
1034
+ def _ok_response(headers: dict[str, str] | None = None) -> httpx.Response:
1035
+ return httpx.Response(200, request=httpx.Request("GET", "https://example.com/api"), headers=headers or {})
1036
+
1037
+
1038
+ def test_deprecation_warnings() -> None:
1039
+ import warnings
1040
+
1041
+ _emitted_deprecation_warnings.clear()
1042
+
1043
+ with warnings.catch_warnings(record=True) as caught:
1044
+ warnings.simplefilter("always")
1045
+ # no header -> no warning
1046
+ handle_response(_ok_response())
1047
+ # header -> warning
1048
+ handle_response(_ok_response({"X-Deprecation-Warning": "A is deprecated"}))
1049
+ # same message again -> deduplicated
1050
+ handle_response(_ok_response({"X-Deprecation-Warning": "A is deprecated"}))
1051
+ # different message -> new warning
1052
+ handle_response(_ok_response({"X-Deprecation-Warning": "B is deprecated"}))
1053
+
1054
+ deprecation_warnings = [w for w in caught if issubclass(w.category, DeprecationWarning)]
1055
+ assert len(deprecation_warnings) == 2
1056
+ assert "A is deprecated" in str(deprecation_warnings[0].message)
1057
+ assert "B is deprecated" in str(deprecation_warnings[1].message)
1058
+
1059
+
1014
1060
  @pytest.mark.asyncio
1015
1061
  async def test_config_proxy_end_to_end_integration(respx_mock, monkeypatch) -> None:
1016
1062
  """Test end-to-end proxy configuration via arraylake config"""
@@ -201,6 +201,47 @@ async def test_create_repo_in_anon_bucket(isolated_org, minio_anon_bucket, token
201
201
  await aclient.create_repo(repo_name)
202
202
 
203
203
 
204
+ @pytest.mark.asyncio
205
+ async def test_create_repo_default_creates_ic2_repo(isolated_org, default_bucket, token):
206
+ """create_repo with default spec_version creates IC2 repos (spec_version=2)."""
207
+ async with isolated_org(default_bucket()) as (org_name, buckets):
208
+ client = AsyncClient(token=token)
209
+ repo = await client.create_repo(
210
+ f"{org_name}/test-default-repo",
211
+ bucket_config_nickname=buckets[0].nickname,
212
+ )
213
+
214
+ assert repo.spec_version == 2, f"Default create_repo should create IC2 repo (spec_version=2), got {repo.spec_version}"
215
+
216
+
217
+ @pytest.mark.asyncio
218
+ async def test_create_repo_spec_version_1_creates_ic1_repo(isolated_org, default_bucket, token):
219
+ """create_repo with spec_version=1 creates IC1 repos."""
220
+ async with isolated_org(default_bucket()) as (org_name, buckets):
221
+ client = AsyncClient(token=token)
222
+ repo = await client.create_repo(
223
+ f"{org_name}/test-ic1-explicit",
224
+ bucket_config_nickname=buckets[0].nickname,
225
+ spec_version=1,
226
+ )
227
+
228
+ assert repo.spec_version == 1, f"spec_version=1 should create IC1 repo, got {repo.spec_version}"
229
+
230
+
231
+ @pytest.mark.asyncio
232
+ async def test_create_repo_spec_version_2_creates_ic2_repo(isolated_org, default_bucket, token):
233
+ """create_repo with spec_version=2 creates IC2 repos."""
234
+ async with isolated_org(default_bucket()) as (org_name, buckets):
235
+ client = AsyncClient(token=token)
236
+ repo = await client.create_repo(
237
+ f"{org_name}/test-ic2-explicit",
238
+ bucket_config_nickname=buckets[0].nickname,
239
+ spec_version=2,
240
+ )
241
+
242
+ assert repo.spec_version == 2, f"spec_version=2 should create IC2 repo, got {repo.spec_version}"
243
+
244
+
204
245
  @pytest.mark.asyncio
205
246
  async def test_modify_repo(isolated_org, default_bucket, token) -> None:
206
247
  async with isolated_org(default_bucket()) as (org_name, buckets):