arraylake 0.18.0__tar.gz → 0.19.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 (112) hide show
  1. {arraylake-0.18.0 → arraylake-0.19.0}/PKG-INFO +3 -3
  2. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/_version.py +2 -2
  3. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/cli/repo.py +15 -1
  4. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/client.py +11 -21
  5. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/compute/doctor.py +34 -18
  6. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/config.yaml +2 -0
  7. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/icechunk/storage.py +8 -0
  8. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/metastore/http_metastore.py +3 -9
  9. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/types.py +35 -13
  10. {arraylake-0.18.0 → arraylake-0.19.0}/pyproject.toml +2 -2
  11. {arraylake-0.18.0 → arraylake-0.19.0}/tests/config.yaml +2 -0
  12. {arraylake-0.18.0 → arraylake-0.19.0}/tests/icechunk/test_client.py +3 -1
  13. {arraylake-0.18.0 → arraylake-0.19.0}/tests/icechunk/test_storage.py +19 -2
  14. {arraylake-0.18.0 → arraylake-0.19.0}/tests/test_client.py +15 -11
  15. {arraylake-0.18.0 → arraylake-0.19.0}/tests/test_config.py +1 -0
  16. {arraylake-0.18.0 → arraylake-0.19.0}/.gitignore +0 -0
  17. {arraylake-0.18.0 → arraylake-0.19.0}/README.md +0 -0
  18. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/__init__.py +0 -0
  19. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/__main__.py +0 -0
  20. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/api_utils.py +0 -0
  21. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/asyn.py +0 -0
  22. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/cli/__init__.py +0 -0
  23. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/cli/auth.py +0 -0
  24. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/cli/compute.py +0 -0
  25. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/cli/config.py +0 -0
  26. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/cli/export.py +0 -0
  27. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/cli/main.py +0 -0
  28. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/cli/utils.py +0 -0
  29. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/compute/http_client.py +0 -0
  30. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/compute/services.py +0 -0
  31. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/compute/types.py +0 -0
  32. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/config.py +0 -0
  33. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/credentials.py +0 -0
  34. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/diagnostics.py +0 -0
  35. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/exceptions.py +0 -0
  36. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/experimental.py +0 -0
  37. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/log_util.py +0 -0
  38. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/py.typed +0 -0
  39. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/__init__.py +0 -0
  40. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/icechunk/__init__.py +0 -0
  41. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/icechunk/types.py +0 -0
  42. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/icechunk/utils.py +0 -0
  43. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/icechunk/virtual.py +0 -0
  44. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/__init__.py +0 -0
  45. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/chunkstore/__init__.py +0 -0
  46. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/chunkstore/abc.py +0 -0
  47. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/chunkstore/base_chunkstore.py +0 -0
  48. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/chunkstore/credential_provider.py +0 -0
  49. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/chunkstore/fsspec_chunkstore.py +0 -0
  50. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/chunkstore/fsspec_compat.py +0 -0
  51. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/chunkstore/s3chunkstore.py +0 -0
  52. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/chunkstore/utils.py +0 -0
  53. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/commits.py +0 -0
  54. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/metastore/__init__.py +0 -0
  55. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/metastore/abc.py +0 -0
  56. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/repo.py +0 -0
  57. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/types.py +0 -0
  58. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/virtual.py +0 -0
  59. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/zarr2_store.py +0 -0
  60. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/zarr3_store.py +0 -0
  61. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/repos/v1/zarr_util.py +0 -0
  62. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/retrier.py +0 -0
  63. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/strategies.py +0 -0
  64. {arraylake-0.18.0 → arraylake-0.19.0}/arraylake/token.py +0 -0
  65. {arraylake-0.18.0 → arraylake-0.19.0}/integration_tests/chunkstores.py +0 -0
  66. {arraylake-0.18.0 → arraylake-0.19.0}/integration_tests/data/small.nc +0 -0
  67. {arraylake-0.18.0 → arraylake-0.19.0}/integration_tests/intercompat.py +0 -0
  68. {arraylake-0.18.0 → arraylake-0.19.0}/integration_tests/run-compatibility-test.sh +0 -0
  69. {arraylake-0.18.0 → arraylake-0.19.0}/tests/__init__.py +0 -0
  70. {arraylake-0.18.0 → arraylake-0.19.0}/tests/compute/test_client.py +0 -0
  71. {arraylake-0.18.0 → arraylake-0.19.0}/tests/compute/test_doctor.py +0 -0
  72. {arraylake-0.18.0 → arraylake-0.19.0}/tests/conftest.py +0 -0
  73. {arraylake-0.18.0 → arraylake-0.19.0}/tests/icechunk/__init__.py +0 -0
  74. {arraylake-0.18.0 → arraylake-0.19.0}/tests/icechunk/conftest.py +0 -0
  75. {arraylake-0.18.0 → arraylake-0.19.0}/tests/icechunk/test_virtual.py +0 -0
  76. {arraylake-0.18.0 → arraylake-0.19.0}/tests/test_api_utils.py +0 -0
  77. {arraylake-0.18.0 → arraylake-0.19.0}/tests/test_asyn.py +0 -0
  78. {arraylake-0.18.0 → arraylake-0.19.0}/tests/test_diagnostics.py +0 -0
  79. {arraylake-0.18.0 → arraylake-0.19.0}/tests/test_retrier.py +0 -0
  80. {arraylake-0.18.0 → arraylake-0.19.0}/tests/test_token_handler.py +0 -0
  81. {arraylake-0.18.0 → arraylake-0.19.0}/tests/test_types.py +0 -0
  82. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/__init__.py +0 -0
  83. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/conftest.py +0 -0
  84. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/helpers/__init__.py +0 -0
  85. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/helpers/test_utils.py +0 -0
  86. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_chunkstore.py +0 -0
  87. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_cli.py +0 -0
  88. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_client.py +0 -0
  89. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_commits.py +0 -0
  90. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_credential_provider.py +0 -0
  91. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_dask.py +0 -0
  92. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_download_optimize_logic.py +0 -0
  93. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_expiration.py +0 -0
  94. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_export.py +0 -0
  95. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_gc.py +0 -0
  96. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_memory_leak.py +0 -0
  97. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_metastore.py +0 -0
  98. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_move.py +0 -0
  99. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_properties.py +0 -0
  100. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_repo.py +0 -0
  101. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_repo_experimental.py +0 -0
  102. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_sessions.py +0 -0
  103. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_stateful_chunkstore.py +0 -0
  104. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_stateful_hierarchy.py +0 -0
  105. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_stateful_history_rewrite.py +0 -0
  106. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_stateful_repo_ops.py +0 -0
  107. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_store.py +0 -0
  108. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_virtual.py +0 -0
  109. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_xarray.py +0 -0
  110. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_zarr.py +0 -0
  111. {arraylake-0.18.0 → arraylake-0.19.0}/tests/v1/test_zarr3_shim.py +0 -0
  112. {arraylake-0.18.0 → arraylake-0.19.0}/validate_export.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arraylake
3
- Version: 0.18.0
3
+ Version: 0.19.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
@@ -39,7 +39,7 @@ Requires-Dist: cfgrib~=0.9; extra == 'grib'
39
39
  Requires-Dist: eccodes>=2.37; extra == 'grib'
40
40
  Requires-Dist: kerchunk<0.3,>=0.2.8; extra == 'grib'
41
41
  Provides-Extra: icechunk
42
- Requires-Dist: icechunk<0.3.0,>=0.2.11; extra == 'icechunk'
42
+ Requires-Dist: icechunk<0.3.0,>=0.2.15; extra == 'icechunk'
43
43
  Provides-Extra: maximal
44
44
  Requires-Dist: dask>=2022.10.2; extra == 'maximal'
45
45
  Requires-Dist: distributed>=2022.10.2; extra == 'maximal'
@@ -73,7 +73,7 @@ Requires-Dist: tenacity==8.4.1; extra == 'min-reqs'
73
73
  Requires-Dist: typer==0.12.0; extra == 'min-reqs'
74
74
  Requires-Dist: zarr==2.18.0; extra == 'min-reqs'
75
75
  Provides-Extra: minicechunk
76
- Requires-Dist: icechunk==0.2.11; extra == 'minicechunk'
76
+ Requires-Dist: icechunk==0.2.15; extra == 'minicechunk'
77
77
  Requires-Dist: numpy==1.25; extra == 'minicechunk'
78
78
  Requires-Dist: zarr==3.0.0; extra == 'minicechunk'
79
79
  Provides-Extra: virtual
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.18.0'
21
- __version_tuple__ = version_tuple = (0, 18, 0)
20
+ __version__ = version = '0.19.0'
21
+ __version_tuple__ = version_tuple = (0, 19, 0)
@@ -84,6 +84,20 @@ def _repos_table(repos, org):
84
84
  return table
85
85
 
86
86
 
87
+ def _make_json_safe(obj):
88
+ """Convert an object to a JSON-safe format."""
89
+ if isinstance(obj, dict):
90
+ return {k: _make_json_safe(v) for k, v in obj.items()}
91
+ elif isinstance(obj, list):
92
+ return [_make_json_safe(i) for i in obj]
93
+ elif isinstance(obj, set):
94
+ return _make_json_safe(list(obj))
95
+ elif isinstance(obj, Enum):
96
+ return obj.value
97
+ else:
98
+ return obj
99
+
100
+
87
101
  @app.command(name="list")
88
102
  @coro # type: ignore
89
103
  async def list_repos(
@@ -111,7 +125,7 @@ async def list_repos(
111
125
  repos = await AsyncClient().list_repos(org, filter_metadata=metadata_filter_dict)
112
126
 
113
127
  if output == "json":
114
- repos = [r._asdict() for r in repos]
128
+ repos = [_make_json_safe(r._asdict()) for r in repos]
115
129
  print_json(data=repos)
116
130
  elif repos:
117
131
  rich_console.print(_repos_table(repos, org))
@@ -54,12 +54,10 @@ from arraylake.types import (
54
54
  ApiClientResponse,
55
55
  Author,
56
56
  BucketResponse,
57
- ExpirationConfig,
58
- GCConfig,
59
57
  GSCredentials,
60
58
  HmacAuth,
61
- MaintenanceWindow,
62
59
  NewBucket,
60
+ OptimizationConfig,
63
61
  OrgActions,
64
62
  )
65
63
  from arraylake.types import Repo as RepoModel
@@ -702,12 +700,16 @@ class AsyncClient:
702
700
  else:
703
701
  credentials = None
704
702
 
703
+ # If config is not set, set scatter_initial_credentials to True by default
704
+ scatter_initial_credentials = config.get("icechunk.scatter_initial_credentials", True)
705
+
705
706
  return _get_icechunk_storage_obj(
706
707
  bucket_config=repo_model.bucket,
707
708
  prefix=repo_model.prefix,
708
709
  credential_type=_get_credential_type(credentials, credential_refresh_func),
709
710
  credentials=credentials,
710
711
  credential_refresh_func=credential_refresh_func,
712
+ scatter_initial_credentials=scatter_initial_credentials,
711
713
  )
712
714
 
713
715
  async def get_icechunk_storage(self, name: str, credentials_override=None): # icechunk.AnyCredential | None # -> icechunk.Storage
@@ -784,9 +786,7 @@ class AsyncClient:
784
786
  add_metadata: Optional[RepoMetadataT] = None,
785
787
  remove_metadata: Optional[list[str]] = None,
786
788
  update_metadata: Optional[RepoMetadataT] = None,
787
- maintenance_window: Optional[MaintenanceWindow] = None,
788
- gc_config: Optional[GCConfig] = None,
789
- expiration_config: Optional[ExpirationConfig] = None,
789
+ optimization_config: Optional[OptimizationConfig] = None,
790
790
  ) -> None:
791
791
  """Modify a repo's metadata or description.
792
792
 
@@ -799,9 +799,7 @@ class AsyncClient:
799
799
  remove_metadata: List of metadata keys to remove from the repo.
800
800
  update_metadata: Optional dictionary of metadata to update on the repo.
801
801
  Dictionary values can be a scalar (string, int, float, bool, or None) or a list of scalars.
802
- maintenance_window: Optional maintenance window to set for the repo.
803
- gc_config: Optional garbage collection configuration for the repo.
804
- expiration_config: Optional expiration configuration for the repo.
802
+ optimization_config: Optional optimization configuration for the repo.
805
803
  """
806
804
  org, repo_name = _parse_org_and_repo(name)
807
805
  mstore = self._metastore_for_org(org)
@@ -811,9 +809,7 @@ class AsyncClient:
811
809
  add_metadata=add_metadata,
812
810
  remove_metadata=remove_metadata,
813
811
  update_metadata=update_metadata,
814
- maintenance_window=maintenance_window,
815
- gc_config=gc_config,
816
- expiration_config=expiration_config,
812
+ optimization_config=optimization_config,
817
813
  )
818
814
 
819
815
  async def delete_repo(self, name: str, *, imsure: bool = False, imreallysure: bool = False) -> None:
@@ -1276,9 +1272,7 @@ class Client:
1276
1272
  add_metadata: Optional[RepoMetadataT] = None,
1277
1273
  remove_metadata: Optional[list[str]] = None,
1278
1274
  update_metadata: Optional[RepoMetadataT] = None,
1279
- maintenance_window: Optional[MaintenanceWindow] = None,
1280
- gc_config: Optional[GCConfig] = None,
1281
- expiration_config: Optional[ExpirationConfig] = None,
1275
+ optimization_config: Optional[OptimizationConfig] = None,
1282
1276
  ) -> None:
1283
1277
  """Modify a repo's metadata or description.
1284
1278
 
@@ -1291,9 +1285,7 @@ class Client:
1291
1285
  remove_metadata: List of metadata keys to remove from the repo.
1292
1286
  update_metadata: Optional dictionary of metadata to update on the repo.
1293
1287
  Dictionary values can be a scalar (string, int, float, bool, or None) or a list of scalars.
1294
- maintenance_window: Optional maintenance window to set for the repo.
1295
- gc_config: Optional garbage collection configuration for the repo.
1296
- expiration_config: Optional expiration configuration for the repo.
1288
+ optimization_config: Optional optimization configurations for the repo.
1297
1289
  """
1298
1290
  return sync(
1299
1291
  self.aclient.modify_repo,
@@ -1302,9 +1294,7 @@ class Client:
1302
1294
  add_metadata=add_metadata,
1303
1295
  remove_metadata=remove_metadata,
1304
1296
  update_metadata=update_metadata,
1305
- maintenance_window=maintenance_window,
1306
- gc_config=gc_config,
1307
- expiration_config=expiration_config,
1297
+ optimization_config=optimization_config,
1308
1298
  )
1309
1299
 
1310
1300
  def delete_repo(self, name: str, *, imsure: bool = False, imreallysure: bool = False) -> None:
@@ -1,8 +1,8 @@
1
- from dataclasses import dataclass
2
1
  from typing import TYPE_CHECKING, Literal, TypeAlias
3
2
 
4
3
  import numpy as np
5
4
  import zarr
5
+ from pydantic import BaseModel
6
6
 
7
7
  from arraylake.log_util import get_logger
8
8
 
@@ -24,8 +24,7 @@ WMS_REQUIRED_CF_KEYS: list[CFAttributeKey] = ["latitude", "longitude"]
24
24
  REQUIRED_CF_KEYS: list[CFAttributeKey] = EDR_REQUIRED_CF_KEYS + WMS_REQUIRED_CF_KEYS
25
25
 
26
26
 
27
- @dataclass
28
- class CFDiagnosisMissing:
27
+ class CFDiagnosisMissing(BaseModel):
29
28
  name: CFAttributeKey
30
29
  attr_name: CFAttributeName
31
30
  required: bool
@@ -42,8 +41,7 @@ class CFDiagnosisMissing:
42
41
  return False
43
42
 
44
43
 
45
- @dataclass
46
- class CFDiagnosisInvalid:
44
+ class CFDiagnosisInvalid(BaseModel):
47
45
  name: CFAttributeKey
48
46
  attr_name: CFAttributeName
49
47
  issue: str
@@ -52,8 +50,7 @@ class CFDiagnosisInvalid:
52
50
  return hash((self.name, self.issue))
53
51
 
54
52
 
55
- @dataclass
56
- class CFDiagnosisSummary:
53
+ class CFDiagnosisSummary(BaseModel):
57
54
  found: set[CFAttributeKey] # All of the correct CF keys found
58
55
  missing: set[CFDiagnosisMissing]
59
56
  invalid: set[CFDiagnosisInvalid]
@@ -93,35 +90,53 @@ class CFDiagnosisSummary:
93
90
  return incompatible
94
91
 
95
92
 
96
- def _guess_cf_axes_and_coords(ds: "Dataset") -> "Dataset":
93
+ def _guess_cf_axes_and_coords(ds: "Dataset", interactive: bool = True) -> "Dataset":
97
94
  import cf_xarray # noqa
98
95
 
99
96
  new_ds = ds.cf.guess_coord_axis()
100
97
  cf_rep = new_ds.cf
101
98
  coords = cf_rep.coordinates
102
99
  axes = cf_rep.axes
100
+
101
+ # In non-interactive mode (server context), make reasonable assumptions
103
102
  if "longitude" not in coords and "X" in axes:
104
103
  if len(axes["X"]) == 1 and any(key not in new_ds[axes["X"][0]].attrs for key in ["standard_name", "units"]):
105
- message = f"Does the X axis ({axes['X'][0]!r}) represent longitude? (y/n): "
106
- modify_x = input(message).lower() == "y"
104
+ if interactive:
105
+ message = f"Does the X axis ({axes['X'][0]!r}) represent longitude? (y/n): "
106
+ modify_x = input(message).lower() == "y"
107
+ else:
108
+ # In server mode, assume X axis represents longitude if it's missing standard_name
109
+ modify_x = "standard_name" not in new_ds[axes["X"][0]].attrs
107
110
  if modify_x:
108
111
  new_ds[axes["X"][0]].attrs["standard_name"] = "longitude"
109
112
  if "latitude" not in coords and "Y" in axes:
110
113
  if len(axes["Y"]) == 1 and any(key not in new_ds[axes["Y"][0]].attrs for key in ["standard_name", "units"]):
111
- message = f"Does the Y axis ({axes['Y'][0]!r}) represent latitude? (y/n): "
112
- modify_y = input(message).lower() == "y"
114
+ if interactive:
115
+ message = f"Does the Y axis ({axes['Y'][0]!r}) represent latitude? (y/n): "
116
+ modify_y = input(message).lower() == "y"
117
+ else:
118
+ # In server mode, assume Y axis represents latitude if it's missing standard_name
119
+ modify_y = "standard_name" not in new_ds[axes["Y"][0]].attrs
113
120
  if modify_y:
114
121
  new_ds[axes["Y"][0]].attrs["standard_name"] = "latitude"
115
122
  if "vertical" not in coords and "Z" in axes:
116
123
  if len(axes["Z"]) == 1 and any(key not in new_ds[axes["Z"][0]].attrs for key in ["standard_name", "units"]):
117
- message = f"Does the Z axis ({axes['Z'][0]!r}) represent vertical? (y/n): "
118
- modify_z = input(message).lower() == "y"
124
+ if interactive:
125
+ message = f"Does the Z axis ({axes['Z'][0]!r}) represent vertical? (y/n): "
126
+ modify_z = input(message).lower() == "y"
127
+ else:
128
+ # In server mode, assume Z axis represents vertical if it's missing standard_name
129
+ modify_z = "standard_name" not in new_ds[axes["Z"][0]].attrs
119
130
  if modify_z:
120
131
  new_ds[axes["Z"][0]].attrs["standard_name"] = "vertical"
121
132
  if "time" not in coords and "T" in axes:
122
133
  if len(axes["T"]) == 1 and any(key not in new_ds[axes["T"][0]].attrs for key in ["standard_name", "units"]):
123
- message = f"Does the T axis ({axes['T'][0]!r}) represent time? (y/n): "
124
- modify_t = input(message).lower() == "y"
134
+ if interactive:
135
+ message = f"Does the T axis ({axes['T'][0]!r}) represent time? (y/n): "
136
+ modify_t = input(message).lower() == "y"
137
+ else:
138
+ # In server mode, assume T axis represents time if it's missing standard_name
139
+ modify_t = "standard_name" not in new_ds[axes["T"][0]].attrs
125
140
  if modify_t:
126
141
  new_ds[axes["T"][0]].attrs["standard_name"] = "time"
127
142
 
@@ -136,11 +151,12 @@ def _is_wms_compatible(keys: set[CFAttributeKey]) -> bool:
136
151
  return all(k in keys for k in WMS_REQUIRED_CF_KEYS)
137
152
 
138
153
 
139
- def check_cf_attribute_completeness(ds: "Dataset") -> CFDiagnosisSummary:
154
+ def check_cf_attribute_completeness(ds: "Dataset", interactive: bool = True) -> CFDiagnosisSummary:
140
155
  """Returns a dictionary of the CF compliance errors in the provided dataset.
141
156
 
142
157
  Args:
143
158
  ds: The xarray dataset to check.
159
+ interactive: Whether to prompt user for input when guessing CF attributes.
144
160
 
145
161
  Returns:
146
162
  dict: A diagnostic dictionary containing the found, missing, and invalid CF compliance errors.
@@ -151,7 +167,7 @@ def check_cf_attribute_completeness(ds: "Dataset") -> CFDiagnosisSummary:
151
167
  found, missing, invalid = set(), set(), set()
152
168
 
153
169
  # Guess the axes, to use for fixing missing attributes
154
- proposed_fix_ds = _guess_cf_axes_and_coords(ds)
170
+ proposed_fix_ds = _guess_cf_axes_and_coords(ds, interactive=interactive)
155
171
 
156
172
  for k in SPATIAL_CF_KEYS:
157
173
  if k in cf_keys:
@@ -4,6 +4,8 @@ service:
4
4
  ssl:
5
5
  verify: True
6
6
  cafile:
7
+ icechunk:
8
+ scatter_initial_credentials: True
7
9
  chunkstore:
8
10
  uri:
9
11
  hash_method: hashlib.sha256
@@ -48,6 +48,7 @@ def _get_icechunk_storage_obj(
48
48
  credential_type: CredentialType,
49
49
  credentials: TempCredentials | None,
50
50
  credential_refresh_func: Callable | None,
51
+ scatter_initial_credentials: bool,
51
52
  ): # Removed output type so icechunk import is not required
52
53
  """Gets the Icechunk storage object.
53
54
 
@@ -64,6 +65,10 @@ def _get_icechunk_storage_obj(
64
65
  Optional function to refresh credentials. This function must
65
66
  be synchronous, cannot take in any args, and return a
66
67
  icechunk.S3StaticCredentials or icechunk.GcsBearerCredential object.
68
+ scatter_initial_credentials:
69
+ Whether to immediately call and store the value returned by get_credentials.
70
+ Ensures that the same set of credentials are available for all workers in a
71
+ distributed setup.
67
72
 
68
73
  Returns:
69
74
  Icechunk Storage object
@@ -112,6 +117,7 @@ def _get_icechunk_storage_obj(
112
117
  anonymous=credential_type == CredentialType.ANONYMOUS,
113
118
  from_env=credential_type == CredentialType.ENV,
114
119
  get_credentials=credential_refresh_func,
120
+ scatter_initial_credentials=scatter_initial_credentials,
115
121
  )
116
122
  # Use s3_storage to create the storage object for s3 or s3-compatible buckets
117
123
  return icechunk.s3_storage(
@@ -128,6 +134,7 @@ def _get_icechunk_storage_obj(
128
134
  from_env=credential_type == CredentialType.ENV,
129
135
  get_credentials=credential_refresh_func,
130
136
  force_path_style=bool(force_path_style), # mypy thinks force_path_style could be a str
137
+ scatter_initial_credentials=scatter_initial_credentials,
131
138
  )
132
139
  # Otherwise, check if the bucket is a GCS bucket
133
140
  elif bucket_config.platform in ("gs"):
@@ -145,6 +152,7 @@ def _get_icechunk_storage_obj(
145
152
  from_env=credential_type == CredentialType.ENV,
146
153
  config=None,
147
154
  get_credentials=credential_refresh_func,
155
+ scatter_initial_credentials=scatter_initial_credentials,
148
156
  )
149
157
  else:
150
158
  raise ValueError(f"Unsupported bucket platform: {bucket_config.platform}")
@@ -39,12 +39,10 @@ from arraylake.types import (
39
39
  ApiClientResponse,
40
40
  BucketModifyRequest,
41
41
  BucketResponse,
42
- ExpirationConfig,
43
- GCConfig,
44
42
  GSCredentials,
45
- MaintenanceWindow,
46
43
  NewBucket,
47
44
  NewRepoOperationStatus,
45
+ OptimizationConfig,
48
46
  OrgActions,
49
47
  PermissionBody,
50
48
  PermissionCheckResponse,
@@ -204,18 +202,14 @@ class HttpMetastore(ArraylakeHttpClient, Metastore):
204
202
  add_metadata: RepoMetadataT | None = None,
205
203
  remove_metadata: list[str] | None = None,
206
204
  update_metadata: RepoMetadataT | None = None,
207
- maintenance_window: MaintenanceWindow | None = None,
208
- gc_config: GCConfig | None = None,
209
- expiration_config: ExpirationConfig | None = None,
205
+ optimization_config: OptimizationConfig | None = None,
210
206
  ) -> None:
211
207
  repo_modify_request = RepoModifyRequest(
212
208
  description=description,
213
209
  add_metadata=add_metadata,
214
210
  remove_metadata=remove_metadata,
215
211
  update_metadata=update_metadata,
216
- maintenance_window=maintenance_window,
217
- gc_config=gc_config,
218
- expiration_config=expiration_config,
212
+ optimization_config=optimization_config,
219
213
  )
220
214
  response = await self._request("PATCH", f"/orgs/{self._config.org}/{name}", content=repo_modify_request.model_dump_json())
221
215
  handle_response(response)
@@ -100,17 +100,13 @@ class RepoModifyRequest(BaseModel):
100
100
  add_metadata: RepoMetadataT | None = None
101
101
  remove_metadata: list[str] | None = None
102
102
  update_metadata: RepoMetadataT | None = None
103
- gc_config: GCConfig | None = None
104
- expiration_config: ExpirationConfig | None = None
105
- maintenance_window: MaintenanceWindow | None = None
103
+ optimization_config: OptimizationConfig | None = None
106
104
 
107
105
 
108
106
  class RepoModifyBody(BaseModel):
109
107
  description: str | None = None
110
108
  metadata: RepoMetadataT | None = None
111
- gc_config: GCConfig | None = None
112
- expiration_config: ExpirationConfig | None = None
113
- maintenance_window: MaintenanceWindow | None = None
109
+ optimization_config: Optional[OptimizationConfig] = None
114
110
 
115
111
 
116
112
  # Used for `/orgs/{org}/{repo}/visibility` endpoint
@@ -392,13 +388,27 @@ class GCConfig(BaseModel):
392
388
 
393
389
  @field_serializer("dangling_chunks", "dangling_manifests", "dangling_attributes", "dangling_transaction_logs", "dangling_snapshots")
394
390
  @classmethod
395
- def serialize_duration(self, value: GCAction) -> float | None:
391
+ def serialize_dangling(self, value: GCAction) -> float | None:
396
392
  if isinstance(value, GCKeep):
397
393
  return None
398
394
  else:
399
395
  assert isinstance(value, GCDeleteOlderThan)
400
396
  return value.date.total_seconds()
401
397
 
398
+ @field_validator("gc_every", mode="before")
399
+ @classmethod
400
+ def parse_gc_every(cls, v: Any) -> timedelta | None:
401
+ if v is not None:
402
+ return timedelta_from_seconds(v)
403
+ return None
404
+
405
+ @field_serializer("gc_every")
406
+ @classmethod
407
+ def serialize_gc_every(self, value: timedelta | None) -> float | None:
408
+ if value is not None:
409
+ return value.total_seconds()
410
+ return None
411
+
402
412
 
403
413
  class ExpirationConfig(BaseModel):
404
414
  """Configuration for expiration"""
@@ -416,14 +426,14 @@ class ExpirationConfig(BaseModel):
416
426
 
417
427
  @field_serializer("expire_every", "expire_versions_older_than")
418
428
  @classmethod
419
- def serialize_duration(self, value: timedelta | None) -> float | None:
429
+ def serialize_expire_every(self, value: timedelta | None) -> float | None:
420
430
  if value is not None:
421
431
  return value.total_seconds()
422
432
  return None
423
433
 
424
434
 
425
- class MaintenanceWindow(BaseModel):
426
- """The preferred time to run maintenance tasks on a repo"""
435
+ class OptimizationWindow(BaseModel):
436
+ """The preferred time to run optimization tasks on a repo"""
427
437
 
428
438
  duration: timedelta
429
439
  start_time: time # Time of day in UTC
@@ -440,6 +450,14 @@ class MaintenanceWindow(BaseModel):
440
450
  return duration.total_seconds()
441
451
 
442
452
 
453
+ class OptimizationConfig(BaseModel):
454
+ """High-level config for repo optimizations"""
455
+
456
+ window: Optional[OptimizationWindow] = None # If None, uses the org default
457
+ gc_config: Optional[GCConfig] = None
458
+ expiration_config: Optional[ExpirationConfig] = None
459
+
460
+
443
461
  class Repo(ModelWithID):
444
462
  org: str
445
463
  name: str
@@ -453,9 +471,13 @@ class Repo(ModelWithID):
453
471
  status: RepoOperationStatusResponse
454
472
  kind: RepoKind = RepoKind.V1
455
473
  prefix: Optional[str] = None
456
- maintenance_window: Optional[MaintenanceWindow] = None # If None, uses the org default
457
- gc_config: Optional[GCConfig] = None
458
- expiration_config: Optional[ExpirationConfig] = None
474
+ optimization_config: OptimizationConfig = Field(
475
+ default_factory=lambda: OptimizationConfig(
476
+ window=None,
477
+ gc_config=None,
478
+ expiration_config=None,
479
+ )
480
+ )
459
481
  permissions: list[Permission[RepoActions]] = Field(default_factory=list)
460
482
 
461
483
  def _asdict(self):
@@ -87,11 +87,11 @@ maximal = [
87
87
  "xarray >= 2024.10.0",
88
88
  ]
89
89
  icechunk = [
90
- "icechunk >= 0.2.11, < 0.3.0"
90
+ "icechunk >= 0.2.15, < 0.3.0"
91
91
  ]
92
92
  # TODO: move to dependency group once pip supports it
93
93
  minicechunk = [
94
- "icechunk==0.2.11",
94
+ "icechunk==0.2.15",
95
95
  "numpy==1.25", # min zarr 3 numpy
96
96
  "zarr==3.0.0",
97
97
  ]
@@ -5,3 +5,5 @@ service:
5
5
  uri: http://localhost:8000
6
6
  s3:
7
7
  endpoint_url: http://localhost:9000
8
+ icechunk:
9
+ scatter_initial_credentials: False
@@ -7,6 +7,7 @@ import zarr
7
7
  from icechunk import IcechunkError, Repository, S3Credentials, Storage
8
8
 
9
9
  from arraylake import AsyncClient, Client
10
+ from arraylake.config import config
10
11
  from arraylake.types import DBID
11
12
  from arraylake.types import Repo as RepoModel
12
13
  from arraylake.types import RepoKind, RepoOperationMode, RepoOperationStatusResponse
@@ -197,7 +198,8 @@ async def test_get_icechunk_storage_from_repo_model(isolated_org_with_bucket, to
197
198
  status=RepoOperationStatusResponse(mode=RepoOperationMode.ONLINE, initiated_by={}),
198
199
  bucket=bucket_config,
199
200
  )
200
- storage = await aclient._get_icechunk_storage_from_repo_model(repo_model=repo_model)
201
+ with config.set({"icechunk.scatter_initial_credentials": False}):
202
+ storage = await aclient._get_icechunk_storage_from_repo_model(repo_model=repo_model)
201
203
  assert isinstance(storage, Storage)
202
204
 
203
205
 
@@ -31,6 +31,7 @@ def test_get_icechunk_storage_obj_s3_credentials(
31
31
  credentials=s3_credentials,
32
32
  credential_type=CredentialType.PROVIDED,
33
33
  credential_refresh_func=None,
34
+ scatter_initial_credentials=False,
34
35
  )
35
36
  assert isinstance(storage, icechunk.Storage)
36
37
 
@@ -45,6 +46,7 @@ def test_get_icechunk_storage_obj_gcs_credentials(
45
46
  credentials=gs_credentials,
46
47
  credential_type=CredentialType.PROVIDED,
47
48
  credential_refresh_func=None,
49
+ scatter_initial_credentials=False,
48
50
  )
49
51
  assert isinstance(storage, icechunk.Storage)
50
52
 
@@ -54,7 +56,12 @@ def test_get_icechunk_storage_obj_from_env(s3_bucket_config: BucketResponse, mon
54
56
  monkeypatch.setenv(var, "test")
55
57
 
56
58
  storage = _get_icechunk_storage_obj(
57
- bucket_config=s3_bucket_config, prefix=None, credentials=None, credential_type=CredentialType.ENV, credential_refresh_func=None
59
+ bucket_config=s3_bucket_config,
60
+ prefix=None,
61
+ credentials=None,
62
+ credential_type=CredentialType.ENV,
63
+ credential_refresh_func=None,
64
+ scatter_initial_credentials=False,
58
65
  )
59
66
  assert isinstance(storage, icechunk.Storage)
60
67
 
@@ -70,6 +77,7 @@ def test_get_icechunk_storage_obj_s3_no_credentials(s3_bucket_config: BucketResp
70
77
  credentials=None,
71
78
  credential_type=CredentialType.ANONYMOUS,
72
79
  credential_refresh_func=None,
80
+ scatter_initial_credentials=False,
73
81
  )
74
82
  assert isinstance(storage, icechunk.Storage)
75
83
 
@@ -81,6 +89,7 @@ def test_get_icechunk_storage_obj_tigris_no_credentials(tigris_bucket_config: Bu
81
89
  credentials=None,
82
90
  credential_type=CredentialType.ANONYMOUS,
83
91
  credential_refresh_func=None,
92
+ scatter_initial_credentials=False,
84
93
  )
85
94
  assert isinstance(storage, icechunk.Storage)
86
95
 
@@ -95,6 +104,7 @@ def test_get_icechunk_storage_obj_tigris_no_region(tigris_bucket_config: BucketR
95
104
  credentials=None,
96
105
  credential_type=CredentialType.ENV,
97
106
  credential_refresh_func=None,
107
+ scatter_initial_credentials=False,
98
108
  )
99
109
  assert "Region cannot be None for Tigris buckets" in str(e.value)
100
110
 
@@ -109,13 +119,19 @@ def test_get_icechunk_storage_obj_tigris_auto_region(tigris_bucket_config: Bucke
109
119
  credentials=None,
110
120
  credential_type=CredentialType.ENV,
111
121
  credential_refresh_func=None,
122
+ scatter_initial_credentials=False,
112
123
  )
113
124
  assert "Region cannot be auto for Tigris buckets" in str(e.value)
114
125
 
115
126
 
116
127
  def test_get_icechunk_storage_obj_gcs_no_credentials(gcs_bucket_config: BucketResponse):
117
128
  storage = _get_icechunk_storage_obj(
118
- bucket_config=gcs_bucket_config, prefix=None, credentials=None, credential_type=CredentialType.ENV, credential_refresh_func=None
129
+ bucket_config=gcs_bucket_config,
130
+ prefix=None,
131
+ credentials=None,
132
+ credential_type=CredentialType.ENV,
133
+ credential_refresh_func=None,
134
+ scatter_initial_credentials=False,
119
135
  )
120
136
  assert isinstance(storage, icechunk.Storage)
121
137
 
@@ -130,5 +146,6 @@ def test_get_icechunk_storage_obj_invalid_credential_input(
130
146
  credentials=s3_credentials,
131
147
  credential_type=CredentialType.PROVIDED,
132
148
  credential_refresh_func=credential_refresh_func,
149
+ scatter_initial_credentials=True,
133
150
  )
134
151
  assert "Cannot provide both static credentials and a credential refresh function" in str(e.value)
@@ -25,7 +25,8 @@ from arraylake.types import (
25
25
  GCConfig,
26
26
  GCDeleteOlderThan,
27
27
  GCKeep,
28
- MaintenanceWindow,
28
+ OptimizationConfig,
29
+ OptimizationWindow,
29
30
  RepoKind,
30
31
  UserInfo,
31
32
  )
@@ -187,7 +188,7 @@ async def test_modify_repo(token, isolated_org_with_bucket, repo_kind_to_test: R
187
188
 
188
189
  # Modify metadata
189
190
  new_description = "This is a modified test repo"
190
- maintenance_window = MaintenanceWindow(
191
+ optimization_window = OptimizationWindow(
191
192
  duration=3600, # 1 hour
192
193
  start_time=dt_time(2, 0), # 2:00 AM UTC
193
194
  day_of_week=1, # Monday
@@ -207,6 +208,11 @@ async def test_modify_repo(token, isolated_org_with_bucket, repo_kind_to_test: R
207
208
  expire_every=None,
208
209
  enabled=True,
209
210
  )
211
+ optimization_config = OptimizationConfig(
212
+ expiration_config=expiration_config,
213
+ gc_config=gc_config,
214
+ window=optimization_window,
215
+ )
210
216
  add_metadata = {"legume": "soybean", "fats": "butter"}
211
217
  remove_metadata = ["dairy"]
212
218
  update_metadata = {"fruits": ["pear", "kiwi"], "vegetable": ["broccoli", "carrot"], "healthy": False}
@@ -225,9 +231,7 @@ async def test_modify_repo(token, isolated_org_with_bucket, repo_kind_to_test: R
225
231
  add_metadata=add_metadata,
226
232
  remove_metadata=remove_metadata,
227
233
  update_metadata=update_metadata,
228
- maintenance_window=maintenance_window,
229
- gc_config=gc_config,
230
- expiration_config=expiration_config,
234
+ optimization_config=optimization_config,
231
235
  )
232
236
  updated_repo_obj = await aclient.get_repo_object(repo_name)
233
237
  assert updated_repo_obj.metadata.keys() == expected_metadata.keys()
@@ -238,9 +242,9 @@ async def test_modify_repo(token, isolated_org_with_bucket, repo_kind_to_test: R
238
242
  assert updated_repo_obj.metadata[key] == value
239
243
  assert updated_repo_obj.description == new_description
240
244
  assert updated_repo_obj.updated > current_repo_obj.updated
241
- assert updated_repo_obj.maintenance_window == maintenance_window
242
- assert updated_repo_obj.gc_config == gc_config
243
- assert updated_repo_obj.expiration_config == expiration_config
245
+ assert updated_repo_obj.optimization_config.window == optimization_window
246
+ assert updated_repo_obj.optimization_config.gc_config == gc_config
247
+ assert updated_repo_obj.optimization_config.expiration_config == expiration_config
244
248
 
245
249
 
246
250
  def test_modify_repo_raises(token, isolated_org_with_bucket, repo_kind_to_test: RepoKind) -> None:
@@ -284,9 +288,9 @@ async def test_modify_repo_no_change(token, isolated_org_with_bucket, repo_kind_
284
288
  assert updated_repo_obj.metadata == metadata
285
289
  assert updated_repo_obj.description is None
286
290
  assert updated_repo_obj.updated == current_repo_obj.updated
287
- assert updated_repo_obj.maintenance_window is None
288
- assert updated_repo_obj.gc_config is None
289
- assert updated_repo_obj.expiration_config is None
291
+ assert updated_repo_obj.optimization_config.window is None
292
+ assert updated_repo_obj.optimization_config.gc_config is None
293
+ assert updated_repo_obj.optimization_config.expiration_config is None
290
294
 
291
295
 
292
296
  def test_list_repos_filter_metadata(token, isolated_org_with_bucket, repo_kind_to_test: RepoKind):
@@ -19,3 +19,4 @@ def test_config_defaults_set():
19
19
  assert "verify" in arraylake.config.defaults[0]["service"]["ssl"]
20
20
  assert "hash_method" in arraylake.config.defaults[0]["chunkstore"]
21
21
  assert "inline_threshold_bytes" in arraylake.config.defaults[0]["chunkstore"]
22
+ assert "scatter_initial_credentials" in arraylake.config.defaults[0]["icechunk"]
File without changes
File without changes
File without changes
File without changes
File without changes