From d12b1a54d960ff28d5a8aaca68c6f717b9ee533c Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Fri, 27 Sep 2024 13:15:56 -0400 Subject: [PATCH 001/167] Rename Storage to StorageConfig in python api (#118) --- icechunk-python/README.md | 4 +-- icechunk-python/examples/smoke-test.py | 10 +++---- .../notebooks/demo-dummy-data.ipynb | 4 +-- icechunk-python/notebooks/memorystore.ipynb | 2 +- .../notebooks/version-control.ipynb | 4 +-- icechunk-python/python/icechunk/__init__.py | 8 ++--- .../python/icechunk/_icechunk_python.pyi | 26 ++++++++--------- icechunk-python/src/lib.rs | 10 +++---- icechunk-python/src/storage.rs | 29 +++++++++++-------- icechunk-python/tests/conftest.py | 6 ++-- icechunk-python/tests/test_timetravel.py | 2 +- icechunk-python/tests/test_virtual_ref.py | 4 +-- .../test_store/test_icechunk_store.py | 4 +-- 13 files changed, 59 insertions(+), 54 deletions(-) diff --git a/icechunk-python/README.md b/icechunk-python/README.md index a7f2e1fd..33c4b5f1 100644 --- a/icechunk-python/README.md +++ b/icechunk-python/README.md @@ -34,10 +34,10 @@ pip install -e icechunk@. Now you can create or open an icechunk store for use with `zarr-python`: ```python -from icechunk import IcechunkStore, Storage +from icechunk import IcechunkStore, StorageConfig from zarr import Array, Group -storage = Storage.memory("test") +storage = StorageConfig.memory("test") store = await IcechunkStore.open(storage=storage, mode='r+') root = Group.from_store(store=store, zarr_format=zarr_format) diff --git a/icechunk-python/examples/smoke-test.py b/icechunk-python/examples/smoke-test.py index c9bae991..f2805c55 100644 --- a/icechunk-python/examples/smoke-test.py +++ b/icechunk-python/examples/smoke-test.py @@ -9,7 +9,7 @@ from zarr.abc.store import Store -from icechunk import IcechunkStore, Storage, S3Credentials, StoreConfig +from icechunk import IcechunkStore, StorageConfig, S3Credentials, StoreConfig import random import string @@ -156,7 +156,7 @@ async def run(store: Store) -> None: print(f"Read done in {time.time() - read_start} secs") -async def create_icechunk_store(*, storage: Storage) -> IcechunkStore: +async def create_icechunk_store(*, storage: StorageConfig) -> IcechunkStore: return await IcechunkStore.create( storage=storage, mode="r+", config=StoreConfig(inline_chunk_threshold=1) ) @@ -176,8 +176,8 @@ async def create_zarr_store(*, store: Literal["memory", "local", "s3"]) -> Store if __name__ == "__main__": - MEMORY = Storage.memory("new") - MINIO = Storage.s3_from_credentials( + MEMORY = StorageConfig.memory("new") + MINIO = StorageConfig.s3_from_credentials( bucket="testbucket", prefix="root-icechunk", credentials=S3Credentials( @@ -187,7 +187,7 @@ async def create_zarr_store(*, store: Literal["memory", "local", "s3"]) -> Store ), endpoint_url="http://localhost:9000", ) - S3 = Storage.s3_from_env( + S3 = StorageConfig.s3_from_env( bucket="icechunk-test", prefix="demo-repository", ) diff --git a/icechunk-python/notebooks/demo-dummy-data.ipynb b/icechunk-python/notebooks/demo-dummy-data.ipynb index a81147e2..21e7b6b7 100644 --- a/icechunk-python/notebooks/demo-dummy-data.ipynb +++ b/icechunk-python/notebooks/demo-dummy-data.ipynb @@ -22,7 +22,7 @@ "import numpy as np\n", "import zarr\n", "\n", - "from icechunk import IcechunkStore, Storage" + "from icechunk import IcechunkStore, StorageConfig" ] }, { @@ -54,7 +54,7 @@ ], "source": [ "store = await IcechunkStore.create(\n", - " storage=Storage.memory(\"icechunk-demo\"),\n", + " storage=StorageConfig.memory(\"icechunk-demo\"),\n", " mode=\"w\",\n", ")\n", "store" diff --git a/icechunk-python/notebooks/memorystore.ipynb b/icechunk-python/notebooks/memorystore.ipynb index 98708ec5..6eb64c3f 100644 --- a/icechunk-python/notebooks/memorystore.ipynb +++ b/icechunk-python/notebooks/memorystore.ipynb @@ -34,7 +34,7 @@ } ], "source": [ - "store = await icechunk.IcechunkStore.create(storage=icechunk.Storage.memory(\"\"), mode=\"w\")\n", + "store = await icechunk.IcechunkStore.create(storage=icechunk.StorageConfig.memory(\"\"), mode=\"w\")\n", "store" ] }, diff --git a/icechunk-python/notebooks/version-control.ipynb b/icechunk-python/notebooks/version-control.ipynb index 3cfe1169..1b7160f2 100644 --- a/icechunk-python/notebooks/version-control.ipynb +++ b/icechunk-python/notebooks/version-control.ipynb @@ -18,7 +18,7 @@ "\n", "import zarr\n", "\n", - "from icechunk import IcechunkStore, Storage" + "from icechunk import IcechunkStore, StorageConfig" ] }, { @@ -51,7 +51,7 @@ "source": [ "\n", "store = await IcechunkStore.create(\n", - " storage=Storage.memory(\"test\"),\n", + " storage=StorageConfig.memory(\"test\"),\n", " mode=\"w\",\n", ")\n", "store" diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index a00643df..af25c3d6 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -7,7 +7,7 @@ pyicechunk_store_create, pyicechunk_store_from_json_config, SnapshotMetadata, - Storage, + StorageConfig, StoreConfig, pyicechunk_store_open_existing, pyicechunk_store_exists, @@ -18,7 +18,7 @@ from zarr.core.common import AccessModeLiteral, BytesLike from zarr.core.sync import SyncMixin -__all__ = ["IcechunkStore", "Storage", "S3Credentials", "StoreConfig"] +__all__ = ["IcechunkStore", "StorageConfig", "S3Credentials", "StoreConfig"] class IcechunkStore(Store, SyncMixin): @@ -140,7 +140,7 @@ async def from_config( @classmethod async def open_existing( cls, - storage: Storage, + storage: StorageConfig, mode: AccessModeLiteral = "r", *args: Any, **kwargs: Any, @@ -162,7 +162,7 @@ async def open_existing( @classmethod async def create( cls, - storage: Storage, + storage: StorageConfig, mode: AccessModeLiteral = "w", *args: Any, **kwargs: Any, diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index 105245a7..7d479daf 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -67,18 +67,18 @@ class PyAsyncSnapshotGenerator(AsyncGenerator[SnapshotMetadata, None], metaclass async def __anext__(self) -> SnapshotMetadata: ... -class Storage: +class StorageConfig: """Storage configuration for an IcechunkStore Currently supports memory, filesystem, and S3 storage backends. - Use the class methods to create a Storage object with the desired backend. + Use the class methods to create a StorageConfig object with the desired backend. Ex: ``` - storage = Storage.memory("prefix") - storage = Storage.filesystem("/path/to/root") - storage = Storage.s3_from_env("bucket", "prefix") - storage = Storage.s3_from_credentials("bucket", "prefix", + storage_config = StorageConfig.memory("prefix") + storage_config = StorageConfig.filesystem("/path/to/root") + storage_config = StorageConfig.s3_from_env("bucket", "prefix") + storage_config = StorageConfig.s3_from_credentials("bucket", "prefix", ``` """ class Memory: @@ -99,16 +99,16 @@ class Storage: def __init__(self, storage: Memory | Filesystem | S3): ... @classmethod - def memory(cls, prefix: str) -> Storage: ... + def memory(cls, prefix: str) -> StorageConfig: ... @classmethod - def filesystem(cls, root: str) -> Storage: ... + def filesystem(cls, root: str) -> StorageConfig: ... @classmethod - def s3_from_env(cls, bucket: str, prefix: str, endpoint_url: str | None = None) -> Storage: ... + def s3_from_env(cls, bucket: str, prefix: str, endpoint_url: str | None = None) -> StorageConfig: ... @classmethod - def s3_from_credentials(cls, bucket: str, prefix: str, credentials: S3Credentials, endpoint_url: str | None) -> Storage: ... + def s3_from_credentials(cls, bucket: str, prefix: str, credentials: S3Credentials, endpoint_url: str | None) -> StorageConfig: ... class S3Credentials: @@ -132,7 +132,7 @@ class StoreConfig: ): ... -async def pyicechunk_store_exists(storage: Storage) -> bool: ... -async def pyicechunk_store_create(storage: Storage, config: StoreConfig = StoreConfig()) -> PyIcechunkStore: ... -async def pyicechunk_store_open_existing(storage: Storage, read_only: bool, config: StoreConfig = StoreConfig()) -> PyIcechunkStore: ... +async def pyicechunk_store_exists(storage: StorageConfig) -> bool: ... +async def pyicechunk_store_create(storage: StorageConfig, config: StoreConfig = StoreConfig()) -> PyIcechunkStore: ... +async def pyicechunk_store_open_existing(storage: StorageConfig, read_only: bool, config: StoreConfig = StoreConfig()) -> PyIcechunkStore: ... async def pyicechunk_store_from_json_config(config: str, read_only: bool) -> PyIcechunkStore: ... diff --git a/icechunk-python/src/lib.rs b/icechunk-python/src/lib.rs index 4ecdc4cb..fe29ebda 100644 --- a/icechunk-python/src/lib.rs +++ b/icechunk-python/src/lib.rs @@ -20,7 +20,7 @@ use icechunk::{ Repository, SnapshotMetadata, }; use pyo3::{exceptions::PyValueError, prelude::*, types::PyBytes}; -use storage::{PyS3Credentials, PyStorage}; +use storage::{PyS3Credentials, PyStorageConfig}; use streams::PyAsyncGenerator; use tokio::sync::{Mutex, RwLock}; @@ -184,7 +184,7 @@ fn pyicechunk_store_from_json_config( #[pyo3(signature = (storage, read_only, config=PyStoreConfig::default()))] fn pyicechunk_store_open_existing<'py>( py: Python<'py>, - storage: &'py PyStorage, + storage: &'py PyStorageConfig, read_only: bool, config: PyStoreConfig, ) -> PyResult> { @@ -206,7 +206,7 @@ fn pyicechunk_store_open_existing<'py>( #[pyfunction] fn pyicechunk_store_exists<'py>( py: Python<'py>, - storage: &'py PyStorage, + storage: &'py PyStorageConfig, ) -> PyResult> { let storage = storage.into(); pyo3_asyncio_0_21::tokio::future_into_py(py, async move { @@ -218,7 +218,7 @@ fn pyicechunk_store_exists<'py>( #[pyo3(signature = (storage, config=PyStoreConfig::default()))] fn pyicechunk_store_create<'py>( py: Python<'py>, - storage: &'py PyStorage, + storage: &'py PyStorageConfig, config: PyStoreConfig, ) -> PyResult> { let storage = storage.into(); @@ -656,7 +656,7 @@ impl PyIcechunkStore { /// The icechunk Python module implemented in Rust. #[pymodule] fn _icechunk_python(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/icechunk-python/src/storage.rs b/icechunk-python/src/storage.rs index 4c0a6f1c..7fe6003a 100644 --- a/icechunk-python/src/storage.rs +++ b/icechunk-python/src/storage.rs @@ -36,8 +36,8 @@ impl PyS3Credentials { } } -#[pyclass(name = "Storage")] -pub enum PyStorage { +#[pyclass(name = "StorageConfig")] +pub enum PyStorageConfig { Memory { prefix: Option, }, @@ -53,15 +53,15 @@ pub enum PyStorage { } #[pymethods] -impl PyStorage { +impl PyStorageConfig { #[classmethod] fn memory(_cls: &Bound<'_, PyType>, prefix: Option) -> Self { - PyStorage::Memory { prefix } + PyStorageConfig::Memory { prefix } } #[classmethod] fn filesystem(_cls: &Bound<'_, PyType>, root: String) -> Self { - PyStorage::Filesystem { root } + PyStorageConfig::Filesystem { root } } #[classmethod] @@ -71,7 +71,7 @@ impl PyStorage { prefix: String, endpoint_url: Option, ) -> Self { - PyStorage::S3 { bucket, prefix, credentials: None, endpoint_url } + PyStorageConfig::S3 { bucket, prefix, credentials: None, endpoint_url } } #[classmethod] @@ -82,20 +82,25 @@ impl PyStorage { credentials: PyS3Credentials, endpoint_url: Option, ) -> Self { - PyStorage::S3 { bucket, prefix, credentials: Some(credentials), endpoint_url } + PyStorageConfig::S3 { + bucket, + prefix, + credentials: Some(credentials), + endpoint_url, + } } } -impl From<&PyStorage> for StorageConfig { - fn from(storage: &PyStorage) -> Self { +impl From<&PyStorageConfig> for StorageConfig { + fn from(storage: &PyStorageConfig) -> Self { match storage { - PyStorage::Memory { prefix } => { + PyStorageConfig::Memory { prefix } => { StorageConfig::InMemory { prefix: prefix.clone() } } - PyStorage::Filesystem { root } => { + PyStorageConfig::Filesystem { root } => { StorageConfig::LocalFileSystem { root: PathBuf::from(root.clone()) } } - PyStorage::S3 { bucket, prefix, credentials, endpoint_url } => { + PyStorageConfig::S3 { bucket, prefix, credentials, endpoint_url } => { StorageConfig::S3ObjectStore { bucket: bucket.clone(), prefix: prefix.clone(), diff --git a/icechunk-python/tests/conftest.py b/icechunk-python/tests/conftest.py index 414ef509..bf670b19 100644 --- a/icechunk-python/tests/conftest.py +++ b/icechunk-python/tests/conftest.py @@ -1,17 +1,17 @@ from typing import Literal -from icechunk import IcechunkStore, Storage +from icechunk import IcechunkStore, StorageConfig import pytest async def parse_store(store: Literal["local", "memory"], path: str) -> IcechunkStore: if store == "local": return await IcechunkStore.create( - storage=Storage.filesystem(path), + storage=StorageConfig.filesystem(path), ) if store == "memory": return await IcechunkStore.create( - storage=Storage.memory(path), + storage=StorageConfig.memory(path), ) diff --git a/icechunk-python/tests/test_timetravel.py b/icechunk-python/tests/test_timetravel.py index fc43b670..d12a011b 100644 --- a/icechunk-python/tests/test_timetravel.py +++ b/icechunk-python/tests/test_timetravel.py @@ -5,7 +5,7 @@ async def test_timetravel(): store = await icechunk.IcechunkStore.create( - storage=icechunk.Storage.memory("test"), + storage=icechunk.StorageConfig.memory("test"), config=icechunk.StoreConfig(inline_chunk_threshold=1), ) diff --git a/icechunk-python/tests/test_virtual_ref.py b/icechunk-python/tests/test_virtual_ref.py index 88819718..31b97651 100644 --- a/icechunk-python/tests/test_virtual_ref.py +++ b/icechunk-python/tests/test_virtual_ref.py @@ -1,5 +1,5 @@ from object_store import ClientOptions, ObjectStore -from icechunk import IcechunkStore, Storage, S3Credentials +from icechunk import IcechunkStore, StorageConfig, S3Credentials import zarr import zarr.core import zarr.core.buffer @@ -34,7 +34,7 @@ async def test_write_virtual_refs(): # Open the store, the S3 credentials must be set in environment vars for this to work for now store = await IcechunkStore.open( - storage=Storage.s3_from_credentials( + storage=StorageConfig.s3_from_credentials( bucket="testbucket", prefix="python-virtual-ref", credentials=S3Credentials( diff --git a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py index 5a2ed85d..c762a19c 100644 --- a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py +++ b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py @@ -6,7 +6,7 @@ from zarr.core.buffer import Buffer, cpu, default_buffer_prototype from zarr.testing.store import StoreTests -from icechunk import IcechunkStore, Storage +from icechunk import IcechunkStore, StorageConfig DEFAULT_GROUP_METADATA = b'{"zarr_format":3,"node_type":"group","attributes":null}' @@ -50,7 +50,7 @@ def store_kwargs( self, request: pytest.FixtureRequest ) -> dict[str, str | None | dict[str, Buffer]]: kwargs = { - "storage": Storage.memory(""), + "storage": StorageConfig.memory(""), "mode": "r+", } return kwargs From 9bd8e48408587dfd153f87ad88e31a009ea64e2c Mon Sep 17 00:00:00 2001 From: Tom Nicholas Date: Sun, 29 Sep 2024 14:28:15 -0600 Subject: [PATCH 002/167] update top-level README to match #118 (#121) --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 48260b05..dbd5163e 100644 --- a/README.md +++ b/README.md @@ -104,20 +104,20 @@ pip install -e icechunk@. Once you have everything installed, here's an example of how to use Icechunk. ```python -from icechunk import IcechunkStore, Storage +from icechunk import IcechunkStore, StorageConfig from zarr import Array, Group # Example using memory store -storage = Storage.memory("test") +storage = StorageConfig.memory("test") store = await IcechunkStore.open(storage=storage, mode='r+') # Example using file store -storage = Storage.filesystem("/path/to/root") +storage = StorageConfig.filesystem("/path/to/root") store = await IcechunkStore.open(storage=storage, mode='r+') # Example using S3 -s3_storage = Storage.s3_from_env(bucket="icechunk-test", prefix="oscar-demo-repository") +s3_storage = StorageConfig.s3_from_env(bucket="icechunk-test", prefix="oscar-demo-repository") store = await IcechunkStore.open(storage=storage, mode='r+') ``` From 34429632bc1ff36a70621151e4ea2e51bfadabf7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 12:23:22 +0000 Subject: [PATCH 003/167] Bump the rust-dependencies group with 2 updates Bumps the rust-dependencies group with 2 updates: [async-trait](https://github.com/dtolnay/async-trait) and [tempfile](https://github.com/Stebalien/tempfile). Updates `async-trait` from 0.1.82 to 0.1.83 - [Release notes](https://github.com/dtolnay/async-trait/releases) - [Commits](https://github.com/dtolnay/async-trait/compare/0.1.82...0.1.83) Updates `tempfile` from 3.12.0 to 3.13.0 - [Changelog](https://github.com/Stebalien/tempfile/blob/master/CHANGELOG.md) - [Commits](https://github.com/Stebalien/tempfile/compare/v3.12.0...v3.13.0) --- updated-dependencies: - dependency-name: async-trait dependency-type: direct:production update-type: version-update:semver-patch dependency-group: rust-dependencies - dependency-name: tempfile dependency-type: direct:production update-type: version-update:semver-minor dependency-group: rust-dependencies ... Signed-off-by: dependabot[bot] --- Cargo.lock | 20 ++++++++++---------- icechunk/Cargo.toml | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fcdc1bdf..a11e6248 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,9 +80,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.82" +version = "0.1.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a27b8a3a6e1a44fa4c8baf1f653e4172e81486d4941f2237e20dc2d0cf4ddff1" +checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", @@ -317,9 +317,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" +checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" [[package]] name = "fnv" @@ -761,9 +761,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.155" +version = "0.2.159" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "libm" @@ -1360,9 +1360,9 @@ checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" [[package]] name = "rustix" -version = "0.38.34" +version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ "bitflags", "errno", @@ -1676,9 +1676,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tempfile" -version = "3.12.0" +version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" +checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" dependencies = [ "cfg-if", "fastrand", diff --git a/icechunk/Cargo.toml b/icechunk/Cargo.toml index 29a42f5d..9f609201 100644 --- a/icechunk/Cargo.toml +++ b/icechunk/Cargo.toml @@ -6,7 +6,7 @@ description = "Icechunk client" publish = false [dependencies] -async-trait = "0.1.82" +async-trait = "0.1.83" bytes = { version = "1.7.2", features = ["serde"] } base64 = "0.22.1" futures = "0.3.30" @@ -31,7 +31,7 @@ async-stream = "0.3.5" [dev-dependencies] pretty_assertions = "1.4.1" proptest-state-machine = "0.3.0" -tempfile = "3.12.0" +tempfile = "3.13.0" [lints] workspace = true From 90c35f385e8a4daefec253e22c08d12cee8287fb Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Mon, 30 Sep 2024 20:20:12 -0400 Subject: [PATCH 004/167] Support zarr python `3.0.0a6` [EAR-1358] (#122) * Add set_if_not_exists and with_mode to rust and python zarr impls * fmt * Cleanup before focus on python impl * Fix stupid typo * Update __init__.py Co-authored-by: Joe Hamman * Update types, tests for python integration * Update python job asset upload * Update readme #120 * Cleanup * lint * fix typo Co-authored-by: Joe Hamman --------- Co-authored-by: Joe Hamman --- .github/workflows/python-ci.yaml | 14 +-- README.md | 19 ++-- icechunk-python/pyproject.toml | 2 +- icechunk-python/python/icechunk/__init__.py | 71 +++++++++++--- .../python/icechunk/_icechunk_python.pyi | 59 +++++++----- icechunk-python/src/lib.rs | 32 +++++++ icechunk-python/tests/test_zarr/test_api.py | 11 ++- icechunk-python/tests/test_zarr/test_array.py | 51 +++++++++- icechunk-python/tests/test_zarr/test_group.py | 63 ++++++++++-- .../test_store/test_icechunk_store.py | 96 ++++++++++++++++++- icechunk/src/zarr.rs | 69 ++++++++++++- 11 files changed, 416 insertions(+), 71 deletions(-) diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml index e981ea59..dbc03ea6 100644 --- a/.github/workflows/python-ci.yaml +++ b/.github/workflows/python-ci.yaml @@ -65,12 +65,12 @@ jobs: args: --release --out dist --find-interpreter sccache: 'true' manylinux: auto - - name: Upload wheels - uses: actions/upload-artifact@v4 - with: - working-directory: icechunk-python - name: wheels-linux-${{ matrix.platform.target }} - path: dist + # - name: Upload wheels + # uses: actions/upload-artifact@v4 + # with: + # working-directory: icechunk-python + # name: wheels-linux-${{ matrix.platform.target }} + # path: icechunk-python/dist - name: mypy shell: bash working-directory: icechunk-python @@ -263,7 +263,7 @@ jobs: with: working-directory: icechunk-python name: wheels-sdist - path: dist + path: icechunk-python/dist # release: # name: Release diff --git a/README.md b/README.md index dbd5163e..786befca 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Governance of the project will be managed by Earthmover PBC. We recommend using Icechunk from Python, together with the Zarr-Python library -> [!WARNING] +> [!WARNING] > Icechunk is a very new project. > It is not recommended for production use at this time. > These instructions are aimed at Icechunk developers and curious early adopters. @@ -86,17 +86,19 @@ pip install maturin Build the project in dev mode: ```bash +cd icechunk-python/ maturin develop ``` or build the project in editable mode: ```bash +cd icechunk-python/ pip install -e icechunk@. ``` -> [!WARNING] -> This only makes the python source code editable, the rust will need to +> [!WARNING] +> This only makes the python source code editable, the rust will need to > be recompiled when it changes ### Basic Usage @@ -123,7 +125,7 @@ store = await IcechunkStore.open(storage=storage, mode='r+') ## Running Tests -You will need [`docker compose`](https://docs.docker.com/compose/install/) and (optionally) [`just`](https://just.systems/). +You will need [`docker compose`](https://docs.docker.com/compose/install/) and (optionally) [`just`](https://just.systems/). Once those are installed, first switch to the icechunk root directory, then start up a local minio server: ``` docker compose up -d @@ -134,13 +136,13 @@ Use `just` to conveniently run a test just test ``` -This is just an alias for +This is just an alias for ``` AWS_ALLOW_HTTP=1 AWS_ENDPOINT_URL=http://localhost:9000 AWS_ACCESS_KEY_ID=minio123 AWS_SECRET_ACCESS_KEY=minio123 cargo test ``` -> [!TIP] +> [!TIP] > For other aliases see [Justfile](./Justfile). ## Snapshots, Branches, and Tags @@ -166,7 +168,7 @@ Tags are appropriate for publishing specific releases of a repository or for any ## How Does It Work? -> [!NOTE] +> [!NOTE] > For more detailed explanation, have a look at the [Icechunk spec](spec/icechunk_spec.md) Zarr itself works by storing both metadata and chunk data into a abstract store according to a specified system of "keys". @@ -204,7 +206,7 @@ flowchart TD Iceberg and all other "table formats" (Delta, Hudi, LanceDB, etc.) are based on tabular data model. This data model cannot accommodate large, multidimensional arrays (tensors) in a general, scalable way. - + 1. Is Icechunk part of Zarr? Formally, no. @@ -222,4 +224,3 @@ Icechunk's was inspired by several existing projects and formats, most notably - [LanceDB](https://lancedb.github.io/lance/format.html) - [TileDB](https://docs.tiledb.com/main/background/key-concepts-and-data-format) - [OCDBT](https://google.github.io/tensorstore/kvstore/ocdbt/index.html) - diff --git a/icechunk-python/pyproject.toml b/icechunk-python/pyproject.toml index b4307948..8a4d1487 100644 --- a/icechunk-python/pyproject.toml +++ b/icechunk-python/pyproject.toml @@ -13,7 +13,7 @@ classifiers = [ dynamic = ["version"] dependencies = [ - "zarr==3.0.0a5" + "zarr==3.0.0a6" ] [project.optional-dependencies] diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index af25c3d6..4ae13a65 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -1,23 +1,25 @@ # module import json -from typing import Any, AsyncGenerator, Self +from collections.abc import AsyncGenerator, Iterable +from typing import Any, Self + +from zarr.abc.store import AccessMode, ByteRangeRequest, Store +from zarr.core.buffer import Buffer, BufferPrototype +from zarr.core.common import AccessModeLiteral, BytesLike +from zarr.core.sync import SyncMixin + from ._icechunk_python import ( PyIcechunkStore, S3Credentials, - pyicechunk_store_create, - pyicechunk_store_from_json_config, SnapshotMetadata, StorageConfig, StoreConfig, - pyicechunk_store_open_existing, + pyicechunk_store_create, pyicechunk_store_exists, + pyicechunk_store_from_json_config, + pyicechunk_store_open_existing, ) -from zarr.abc.store import AccessMode, Store -from zarr.core.buffer import Buffer, BufferPrototype -from zarr.core.common import AccessModeLiteral, BytesLike -from zarr.core.sync import SyncMixin - __all__ = ["IcechunkStore", "StorageConfig", "S3Credentials", "StoreConfig"] @@ -178,6 +180,32 @@ async def create( store = await pyicechunk_store_create(storage) return cls(store=store, mode=mode, args=args, kwargs=kwargs) + def with_mode(self, mode: AccessModeLiteral) -> Self: + """ + Return a new store of the same type pointing to the same location with a new mode. + + The returned Store is not automatically opened. Call :meth:`Store.open` before + using. + + Parameters + ---------- + mode: AccessModeLiteral + The new mode to use. + + Returns + ------- + store: + A new store of the same type with the new mode. + + Examples + -------- + >>> writer = zarr.store.MemoryStore(mode="w") + >>> reader = writer.with_mode("r") + """ + read_only = mode == "r" + new_store = self._store.with_mode(read_only) + return self.__class__(new_store, mode=mode) + @property def snapshot_id(self) -> str: """Return the current snapshot id.""" @@ -285,20 +313,22 @@ async def get( async def get_partial_values( self, prototype: BufferPrototype, - key_ranges: list[tuple[str, tuple[int | None, int | None]]], + key_ranges: Iterable[tuple[str, ByteRangeRequest]], ) -> list[Buffer | None]: """Retrieve possibly partial values from given key_ranges. Parameters ---------- - key_ranges : list[tuple[str, tuple[int, int]]] + key_ranges : Iterable[tuple[str, tuple[int | None, int | None]]] Ordered set of key, range pairs, a key may occur multiple times with different ranges Returns ------- list of values, in the order of the key_ranges, may contain null/none for missing keys """ - result = await self._store.get_partial_values(key_ranges) + # NOTE: pyo3 has not implicit conversion from an Iterable to a rust iterable. So we convert it + # to a list here first. Possible opportunity for optimization. + result = await self._store.get_partial_values(list(key_ranges)) return [ prototype.buffer.from_bytes(r) if r is not None else None for r in result ] @@ -331,6 +361,17 @@ async def set(self, key: str, value: Buffer) -> None: """ return await self._store.set(key, value.to_bytes()) + async def set_if_not_exists(self, key: str, value: Buffer) -> None: + """ + Store a key to ``value`` if the key is not already present. + + Parameters + ----------- + key : str + value : Buffer + """ + return await self._store.set_if_not_exists(key, value.to_bytes()) + async def set_virtual_ref( self, key: str, location: str, *, offset: int, length: int ) -> None: @@ -364,7 +405,7 @@ def supports_partial_writes(self) -> bool: return self._store.supports_partial_writes async def set_partial_values( - self, key_start_values: list[tuple[str, int, BytesLike]] + self, key_start_values: Iterable[tuple[str, int, BytesLike]] ) -> None: """Store values at a given key, starting at byte range_start. @@ -375,7 +416,9 @@ async def set_partial_values( range_starts, range_starts (considering the length of the respective values) must not specify overlapping ranges for the same key """ - return await self._store.set_partial_values(key_start_values) + # NOTE: pyo3 does not implicit conversion from an Iterable to a rust iterable. So we convert it + # to a list here first. Possible opportunity for optimization. + return await self._store.set_partial_values(list(key_start_values)) # type: ignore @property def supports_listing(self) -> bool: diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index 7d479daf..e2ceb086 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -1,8 +1,9 @@ import abc import datetime -from typing import AsyncGenerator +from collections.abc import AsyncGenerator class PyIcechunkStore: + def with_mode(self, read_only: bool) -> PyIcechunkStore: ... @property def snapshot_id(self) -> str: ... @property @@ -31,7 +32,10 @@ class PyIcechunkStore: @property def supports_deletes(self) -> bool: ... async def set(self, key: str, value: bytes) -> None: ... - async def set_virtual_ref(self, key: str, location: str, offset: int, length: int) -> None: ... + async def set_if_not_exists(self, key: str, value: bytes) -> None: ... + async def set_virtual_ref( + self, key: str, location: str, offset: int, length: int + ) -> None: ... async def delete(self, key: str) -> None: ... @property def supports_partial_writes(self) -> bool: ... @@ -45,28 +49,24 @@ class PyIcechunkStore: def list_dir(self, prefix: str) -> PyAsyncStringGenerator: ... def __eq__(self, other) -> bool: ... - class PyAsyncStringGenerator(AsyncGenerator[str, None], metaclass=abc.ABCMeta): def __aiter__(self) -> PyAsyncStringGenerator: ... async def __anext__(self) -> str: ... - class SnapshotMetadata: @property def id(self) -> str: ... - @property def written_at(self) -> datetime.datetime: ... - @property def message(self) -> str: ... - -class PyAsyncSnapshotGenerator(AsyncGenerator[SnapshotMetadata, None], metaclass=abc.ABCMeta): +class PyAsyncSnapshotGenerator( + AsyncGenerator[SnapshotMetadata, None], metaclass=abc.ABCMeta +): def __aiter__(self) -> PyAsyncSnapshotGenerator: ... async def __anext__(self) -> SnapshotMetadata: ... - class StorageConfig: """Storage configuration for an IcechunkStore @@ -83,41 +83,51 @@ class StorageConfig: """ class Memory: """An in-memory storage backend""" + prefix: str class Filesystem: """A local filesystem storage backend""" + root: str class S3: """An S3 Object Storage compatible storage backend""" + bucket: str prefix: str credentials: S3Credentials endpoint_url: str | None def __init__(self, storage: Memory | Filesystem | S3): ... - @classmethod def memory(cls, prefix: str) -> StorageConfig: ... - @classmethod def filesystem(cls, root: str) -> StorageConfig: ... - @classmethod - def s3_from_env(cls, bucket: str, prefix: str, endpoint_url: str | None = None) -> StorageConfig: ... - + def s3_from_env( + cls, bucket: str, prefix: str, endpoint_url: str | None = None + ) -> StorageConfig: ... @classmethod - def s3_from_credentials(cls, bucket: str, prefix: str, credentials: S3Credentials, endpoint_url: str | None) -> StorageConfig: ... - + def s3_from_credentials( + cls, + bucket: str, + prefix: str, + credentials: S3Credentials, + endpoint_url: str | None, + ) -> StorageConfig: ... class S3Credentials: access_key_id: str secret_access_key: str session_token: str | None - def __init__(self, access_key_id: str, secret_access_key: str, session_token: str | None = None): ... - + def __init__( + self, + access_key_id: str, + secret_access_key: str, + session_token: str | None = None, + ): ... class StoreConfig: get_partial_values_concurrency: int | None @@ -131,8 +141,13 @@ class StoreConfig: unsafe_overwrite_refs: bool | None = None, ): ... - async def pyicechunk_store_exists(storage: StorageConfig) -> bool: ... -async def pyicechunk_store_create(storage: StorageConfig, config: StoreConfig = StoreConfig()) -> PyIcechunkStore: ... -async def pyicechunk_store_open_existing(storage: StorageConfig, read_only: bool, config: StoreConfig = StoreConfig()) -> PyIcechunkStore: ... -async def pyicechunk_store_from_json_config(config: str, read_only: bool) -> PyIcechunkStore: ... +async def pyicechunk_store_create( + storage: StorageConfig, config: StoreConfig = StoreConfig() +) -> PyIcechunkStore: ... +async def pyicechunk_store_open_existing( + storage: StorageConfig, read_only: bool, config: StoreConfig = StoreConfig() +) -> PyIcechunkStore: ... +async def pyicechunk_store_from_json_config( + config: str, read_only: bool +) -> PyIcechunkStore: ... diff --git a/icechunk-python/src/lib.rs b/icechunk-python/src/lib.rs index fe29ebda..ad01247a 100644 --- a/icechunk-python/src/lib.rs +++ b/icechunk-python/src/lib.rs @@ -233,6 +233,20 @@ fn pyicechunk_store_create<'py>( #[pymethods] impl PyIcechunkStore { + fn with_mode(&self, read_only: bool) -> PyResult { + let access_mode = if read_only { + icechunk::zarr::AccessMode::ReadOnly + } else { + icechunk::zarr::AccessMode::ReadWrite + }; + let store = self.store.blocking_read().with_access_mode(access_mode); + let store = Arc::new(RwLock::new(store)); + let rt = tokio::runtime::Runtime::new() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + + Ok(PyIcechunkStore { store, rt }) + } + fn checkout_snapshot<'py>( &'py self, py: Python<'py>, @@ -520,6 +534,24 @@ impl PyIcechunkStore { }) } + fn set_if_not_exists<'py>( + &'py self, + py: Python<'py>, + key: String, + value: Vec, + ) -> PyResult> { + let store = Arc::clone(&self.store); + + pyo3_asyncio_0_21::tokio::future_into_py(py, async move { + let store = store.read().await; + store + .set_if_not_exists(&key, Bytes::from(value)) + .await + .map_err(PyIcechunkStoreError::from)?; + Ok(()) + }) + } + fn set_virtual_ref<'py>( &'py self, py: Python<'py>, diff --git a/icechunk-python/tests/test_zarr/test_api.py b/icechunk-python/tests/test_zarr/test_api.py index 2bdd193c..8defe386 100644 --- a/icechunk-python/tests/test_zarr/test_api.py +++ b/icechunk-python/tests/test_zarr/test_api.py @@ -125,14 +125,21 @@ def test_open_with_mode_r_plus(tmp_path: pathlib.Path) -> None: # 'r+' means read/write (must exist) with pytest.raises(FileNotFoundError): zarr.open(store=tmp_path, mode="r+") - zarr.ones(store=tmp_path, shape=(3, 3)) + z1 = zarr.ones(store=tmp_path, shape=(3, 3)) + assert z1.fill_value == 1 z2 = zarr.open(store=tmp_path, mode="r+") assert isinstance(z2, Array) + assert z2.fill_value == 1 assert (z2[:] == 1).all() z2[:] = 3 -def test_open_with_mode_a(tmp_path: pathlib.Path) -> None: +async def test_open_with_mode_a(tmp_path: pathlib.Path) -> None: + # Open without shape argument should default to group + g = zarr.open(store=tmp_path, mode="a") + assert isinstance(g, Group) + await g.store_path.delete() + # 'a' means read/write (create if doesn't exist) arr = zarr.open(store=tmp_path, mode="a", shape=(3, 3)) assert isinstance(arr, Array) diff --git a/icechunk-python/tests/test_zarr/test_array.py b/icechunk-python/tests/test_zarr/test_array.py index 3cf10c72..15cdde25 100644 --- a/icechunk-python/tests/test_zarr/test_array.py +++ b/icechunk-python/tests/test_zarr/test_array.py @@ -4,7 +4,10 @@ import numpy as np import pytest -from zarr import Array, Group +from zarr import Array, AsyncGroup, Group +import zarr +import zarr.api +import zarr.api.asynchronous from zarr.core.common import ZarrFormat from zarr.errors import ContainsArrayError, ContainsGroupError from zarr.store.common import StorePath @@ -60,6 +63,46 @@ def test_array_creation_existing_node( zarr_format=zarr_format, ) +@pytest.mark.parametrize("store", ["memory"], indirect=["store"]) +@pytest.mark.parametrize("zarr_format", [3]) +async def test_create_creates_parents( + store: IcechunkStore, zarr_format: ZarrFormat +) -> None: + # prepare a root node, with some data set + await zarr.api.asynchronous.open_group( + store=store, path="a", zarr_format=zarr_format, attributes={"key": "value"} + ) + + # create a child node with a couple intermediates + await zarr.api.asynchronous.create( + shape=(2, 2), store=store, path="a/b/c/d", zarr_format=zarr_format + ) + parts = ["a", "a/b", "a/b/c"] + + if zarr_format == 2: + files = [".zattrs", ".zgroup"] + else: + files = ["zarr.json"] + + expected = [f"{part}/{file}" for file in files for part in parts] + + if zarr_format == 2: + expected.append("a/b/c/d/.zarray") + expected.append("a/b/c/d/.zattrs") + else: + expected.append("a/b/c/d/zarr.json") + + expected = sorted(expected) + + result = sorted([x async for x in store.list_prefix("")]) + + assert result == expected + + paths = ["a", "a/b", "a/b/c"] + for path in paths: + g = await zarr.api.asynchronous.open_group(store=store, path=path) + assert isinstance(g, AsyncGroup) + @pytest.mark.parametrize("store", ["memory"], indirect=["store"]) @pytest.mark.parametrize("zarr_format", [3]) @@ -122,8 +165,10 @@ def test_array_v3_fill_value_default( @pytest.mark.parametrize("store", ["memory"], indirect=True) -@pytest.mark.parametrize("fill_value", [False, 0.0, 1, 2.3]) -@pytest.mark.parametrize("dtype_str", ["bool", "uint8", "float32", "complex64"]) +@pytest.mark.parametrize( + ("dtype_str", "fill_value"), + [("bool", True), ("uint8", 99), ("float32", -99.9), ("complex64", 3 + 4j)], +) def test_array_v3_fill_value(store: IcechunkStore, fill_value: int, dtype_str: str) -> None: shape = (10,) arr = Array.create( diff --git a/icechunk-python/tests/test_zarr/test_group.py b/icechunk-python/tests/test_zarr/test_group.py index 8e0e545e..7e556fb5 100644 --- a/icechunk-python/tests/test_zarr/test_group.py +++ b/icechunk-python/tests/test_zarr/test_group.py @@ -7,8 +7,11 @@ import pytest from zarr import Array, AsyncArray, AsyncGroup, Group +import zarr +import zarr.api +import zarr.api.asynchronous from zarr.core.buffer import default_buffer_prototype -from zarr.core.common import ZarrFormat +from zarr.core.common import JSON, ZarrFormat from zarr.core.group import GroupMetadata from zarr.core.sync import sync from zarr.errors import ContainsArrayError, ContainsGroupError @@ -54,6 +57,46 @@ def test_group_init(store: IcechunkStore, zarr_format: ZarrFormat) -> None: assert group._async_group == agroup +async def test_create_creates_parents(store: IcechunkStore, zarr_format: ZarrFormat) -> None: + # prepare a root node, with some data set + await zarr.api.asynchronous.open_group( + store=store, path="a", zarr_format=zarr_format, attributes={"key": "value"} + ) + # create a child node with a couple intermediates + await zarr.api.asynchronous.open_group(store=store, path="a/b/c/d", zarr_format=zarr_format) + parts = ["a", "a/b", "a/b/c"] + + if zarr_format == 2: + files = [".zattrs", ".zgroup"] + else: + files = ["zarr.json"] + + expected = [f"{part}/{file}" for file in files for part in parts] + + if zarr_format == 2: + expected.append("a/b/c/d/.zgroup") + expected.append("a/b/c/d/.zattrs") + else: + expected.append("a/b/c/d/zarr.json") + + expected = sorted(expected) + + result = sorted([x async for x in store.list_prefix("")]) + + assert result == expected + + paths = ["a", "a/b", "a/b/c"] + for path in paths: + g = await zarr.api.asynchronous.open_group(store=store, path=path) + assert isinstance(g, AsyncGroup) + + if path == "a": + # ensure we didn't overwrite the root attributes + assert g.attrs == {"key": "value"} + else: + assert g.attrs == {} + + def test_group_name_properties(store: IcechunkStore, zarr_format: ZarrFormat) -> None: """ Test basic properties of groups @@ -91,8 +134,8 @@ def test_group_members(store: IcechunkStore, zarr_format: ZarrFormat) -> None: members_expected["subgroup"] = group.create_group("subgroup") # make a sub-sub-subgroup, to ensure that the children calculation doesn't go # too deep in the hierarchy - subsubgroup = members_expected["subgroup"].create_group("subsubgroup") # type: ignore - subsubsubgroup = subsubgroup.create_group("subsubsubgroup") # type: ignore + subsubgroup = members_expected["subgroup"].create_group("subsubgroup") + subsubsubgroup = subsubgroup.create_group("subsubsubgroup") members_expected["subarray"] = group.create_array( "subarray", shape=(100,), dtype="uint8", chunk_shape=(10,), exists_ok=True @@ -284,7 +327,7 @@ def test_group_len(store: IcechunkStore, zarr_format: ZarrFormat) -> None: group = Group.from_store(store, zarr_format=zarr_format) with pytest.raises(NotImplementedError): - len(group) # type: ignore + len(group) def test_group_setitem(store: IcechunkStore, zarr_format: ZarrFormat) -> None: @@ -412,8 +455,8 @@ def test_group_creation_existing_node( """ spath = StorePath(store) group = Group.from_store(spath, zarr_format=zarr_format) - expected_exception: type[ContainsArrayError] | type[ContainsGroupError] - attributes = {"old": True} + expected_exception: type[ContainsArrayError | ContainsGroupError] + attributes: dict[str, JSON] = {"old": True} if extant_node == "array": expected_exception = ContainsArrayError @@ -546,9 +589,9 @@ async def test_asyncgroup_open_wrong_format( # should this be async? @pytest.mark.parametrize( "data", - ( + [ {"zarr_format": 3, "node_type": "group", "attributes": {"foo": 100}}, - ), + ], ) def test_asyncgroup_from_dict(store: IcechunkStore, data: dict[str, Any]) -> None: """ @@ -650,7 +693,7 @@ async def test_asyncgroup_create_array( shape = (10,) dtype = "uint8" chunk_shape = (4,) - attributes = {"foo": 100} + attributes: dict[str, JSON] = {"foo": 100} sub_node_path = "sub_array" subnode = await agroup.create_array( @@ -669,7 +712,7 @@ async def test_asyncgroup_create_array( assert subnode.dtype == dtype # todo: fix the type annotation of array.metadata.chunk_grid so that we get some autocomplete # here. - assert subnode.metadata.chunk_grid.chunk_shape == chunk_shape + assert subnode.metadata.chunk_grid.chunk_shape == chunk_shape # type: ignore assert subnode.metadata.zarr_format == zarr_format diff --git a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py index c762a19c..c3981a8e 100644 --- a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py +++ b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py @@ -1,9 +1,12 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast import pytest +from zarr.abc.store import AccessMode from zarr.core.buffer import Buffer, cpu, default_buffer_prototype +from zarr.core.common import AccessModeLiteral +from zarr.core.sync import collect_aiterator from zarr.testing.store import StoreTests from icechunk import IcechunkStore, StorageConfig @@ -65,6 +68,10 @@ async def store( def test_store_repr(self, store: IcechunkStore) -> None: super().test_store_repr(store) + @pytest.mark.xfail(reason="Not implemented") + def test_serializable_store(self, store: IcechunkStore) -> None: + super().test_serializable_store(store) + async def test_not_writable_store_raises( self, store_kwargs: dict[str, Any] ) -> None: @@ -226,3 +233,90 @@ async def test_get(self, store: IcechunkStore) -> None: result = await store.get("zarr.json", default_buffer_prototype()) assert result is not None assert result.to_bytes() == DEFAULT_GROUP_METADATA + + async def test_get_many(self, store: IcechunkStore) -> None: + """ + Ensure that multiple keys can be retrieved at once with the _get_many method. + """ + await store.set("zarr.json", self.buffer_cls.from_bytes(ARRAY_METADATA)) + + keys = [ + "c/0/0/0", + "c/0/0/1", + "c/0/1/0", + "c/0/1/1", + "c/1/0/0", + "c/1/0/1", + "c/1/1/0", + "c/1/1/1", + ] + values = [bytes(i) for i, _ in enumerate(keys)] + for k, v in zip(keys, values, strict=False): + await self.set(store, k, self.buffer_cls.from_bytes(v)) + observed_buffers = collect_aiterator( + store._get_many( + zip( + keys, + (default_buffer_prototype(),) * len(keys), + (None,) * len(keys), + strict=False, + ) + ) + ) + observed_kvs = sorted(((k, b.to_bytes()) for k, b in observed_buffers)) # type: ignore[union-attr] + expected_kvs = sorted(((k, b) for k, b in zip(keys, values, strict=False))) + assert observed_kvs == expected_kvs + + async def test_with_mode(self, store: IcechunkStore) -> None: + data = b"0000" + await self.set(store, "zarr.json", self.buffer_cls.from_bytes(ARRAY_METADATA)) + await self.set(store, "c/0/0/0", self.buffer_cls.from_bytes(data)) + assert (await self.get(store, "c/0/0/0")).to_bytes() == data + + for mode in ["r", "a"]: + mode = cast(AccessModeLiteral, mode) + clone = store.with_mode(mode) + # await store.close() + await clone._ensure_open() + assert clone.mode == AccessMode.from_literal(mode) + assert isinstance(clone, type(store)) + + # earlier writes are visible + result = await clone.get("c/0/0/0", default_buffer_prototype()) + assert result is not None + assert result.to_bytes() == data + + # writes to original after with_mode is visible + await self.set(store, "c/0/0/1", self.buffer_cls.from_bytes(data)) + result = await clone.get("c/0/0/1", default_buffer_prototype()) + assert result is not None + assert result.to_bytes() == data + + if mode == "a": + # writes to clone is visible in the original + await clone.set("c/0/1/0", self.buffer_cls.from_bytes(data)) + result = await clone.get("c/0/1/0", default_buffer_prototype()) + assert result is not None + assert result.to_bytes() == data + + else: + with pytest.raises(ValueError, match="store error: cannot write"): + await clone.set("c/0/1/0", self.buffer_cls.from_bytes(data)) + + async def test_set_if_not_exists(self, store: IcechunkStore) -> None: + key = "zarr.json" + data_buf = self.buffer_cls.from_bytes(ARRAY_METADATA) + await self.set(store, key, data_buf) + + new = self.buffer_cls.from_bytes(b"1111") + + # no error even though the data is invalid and the metadata exists + await store.set_if_not_exists(key, new) + + result = await store.get(key, default_buffer_prototype()) + assert result == data_buf + + await store.set_if_not_exists("c/0/0/0", new) # no error + + result = await store.get("c/0/0/0", default_buffer_prototype()) + assert result == new diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index 31a86229..21dd1040 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -290,6 +290,20 @@ impl Store { } } + /// Creates a new clone of the store with the given access mode. + pub fn with_access_mode(&self, mode: AccessMode) -> Self { + Store { + repository: self.repository.clone(), + mode, + current_branch: self.current_branch.clone(), + config: self.config.clone(), + } + } + + pub fn access_mode(&self) -> &AccessMode { + &self.mode + } + pub fn current_branch(&self) -> &Option { &self.current_branch } @@ -540,6 +554,16 @@ impl Store { } } + pub async fn set_if_not_exists(&self, key: &str, value: Bytes) -> StoreResult<()> { + // TODO: Make sure this is correctly threadsafe. Technically a third API call + // may be able to slip into the gap between the exists and the set. + if self.exists(key).await? { + Ok(()) + } else { + self.set(key, value).await + } + } + // alternate API would take array path, and a mapping from string coord to ChunkPayload pub async fn set_virtual_ref( &mut self, @@ -1930,12 +1954,18 @@ mod tests { store.set("array/zarr.json", zarr_meta.clone()).await.unwrap(); let data = Bytes::copy_from_slice(b"hello"); - store.set("array/c/0/1/0", data.clone()).await.unwrap(); + store.set_if_not_exists("array/c/0/1/0", data.clone()).await.unwrap(); + assert_eq!(store.get("array/c/0/1/0", &ByteRange::ALL).await.unwrap(), data); let snapshot_id = store.commit("initial commit").await.unwrap(); let new_data = Bytes::copy_from_slice(b"world"); + store.set_if_not_exists("array/c/0/1/0", new_data.clone()).await.unwrap(); + assert_eq!(store.get("array/c/0/1/0", &ByteRange::ALL).await.unwrap(), data); + store.set("array/c/0/1/0", new_data.clone()).await.unwrap(); + assert_eq!(store.get("array/c/0/1/0", &ByteRange::ALL).await.unwrap(), new_data); + let new_snapshot_id = store.commit("update").await.unwrap(); store.checkout(VersionInfo::SnapshotId(snapshot_id.clone())).await.unwrap(); @@ -1954,7 +1984,7 @@ mod tests { store.reset().await?; assert_eq!(store.get("array/c/0/1/0", &ByteRange::ALL).await.unwrap(), new_data); - // TODO: Create a new branch and do stuff with it + // Create a new branch and do stuff with it store.new_branch("dev").await?; store.set("array/c/0/1/0", new_data.clone()).await?; let dev_snapshot_id = store.commit("update dev branch").await?; @@ -1975,6 +2005,41 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_access_mode() { + let storage: Arc = + Arc::new(ObjectStorage::new_in_memory_store(Some("prefix".into()))); + + let writeable_store = + Store::new_from_storage(Arc::clone(&storage)).await.unwrap(); + assert_eq!(writeable_store.access_mode(), &AccessMode::ReadWrite); + + writeable_store + .set( + "zarr.json", + Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group"}"#), + ) + .await + .unwrap(); + + let readable_store = writeable_store.with_access_mode(AccessMode::ReadOnly); + assert_eq!(readable_store.access_mode(), &AccessMode::ReadOnly); + + let result = readable_store + .set( + "zarr.json", + Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group"}"#), + ) + .await; + let correct_error = match result { + Err(StoreError::ReadOnly { .. }) => true, + _ => false, + }; + assert!(correct_error); + + readable_store.get("zarr.json", &ByteRange::ALL).await.unwrap(); + } + #[test] fn test_store_config_deserialization() -> Result<(), Box> { let expected = ConsolidatedStore { From c91d0bab494a28ed3e1dfeaf9380feb4fc90af71 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Tue, 1 Oct 2024 09:25:45 -0400 Subject: [PATCH 005/167] Fix inline chunks support in python [EAR-1359] (#124) * Fix inline chunks support in python add tests * fix python formatting * Fix tests --- icechunk-python/examples/smoke-test.py | 2 +- icechunk-python/notebooks/reference.ipynb | 28 ++++++- icechunk-python/python/icechunk/__init__.py | 18 +++-- .../python/icechunk/_icechunk_python.pyi | 50 +++++++++--- icechunk-python/src/lib.rs | 10 +-- icechunk-python/tests/test_config.py | 76 +++++++++++++++++++ icechunk-python/tests/test_timetravel.py | 2 +- icechunk/src/repository.rs | 8 +- 8 files changed, 162 insertions(+), 32 deletions(-) create mode 100644 icechunk-python/tests/test_config.py diff --git a/icechunk-python/examples/smoke-test.py b/icechunk-python/examples/smoke-test.py index f2805c55..83d848cb 100644 --- a/icechunk-python/examples/smoke-test.py +++ b/icechunk-python/examples/smoke-test.py @@ -158,7 +158,7 @@ async def run(store: Store) -> None: async def create_icechunk_store(*, storage: StorageConfig) -> IcechunkStore: return await IcechunkStore.create( - storage=storage, mode="r+", config=StoreConfig(inline_chunk_threshold=1) + storage=storage, mode="r+", config=StoreConfig(inline_chunk_threshold_bytes=1) ) diff --git a/icechunk-python/notebooks/reference.ipynb b/icechunk-python/notebooks/reference.ipynb index 094e721f..ebdc0e52 100644 --- a/icechunk-python/notebooks/reference.ipynb +++ b/icechunk-python/notebooks/reference.ipynb @@ -2,11 +2,33 @@ "cells": [ { "cell_type": "code", - "execution_count": 1, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ - "import zarr" + "import zarr\n", + "from zarr.store import LocalStore" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "LocalStore('file:///tmp/test.zarr')" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "store = LocalStore('/tmp/test.zarr')\n", + "store" ] }, { @@ -33,7 +55,7 @@ } ], "source": [ - "group = zarr.group(overwrite=True)\n", + "group = zarr.group(store=store, overwrite=True)\n", "store = group.store_path.store\n", "group" ] diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index 4ae13a65..f254f68a 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -101,9 +101,11 @@ async def from_config( "version": { "branch": "main", }, - // The threshold at which chunks are stored inline and not written to chunk storage - inline_chunk_threshold_bytes: 512, }, + "config": { + // The threshold at which chunks are stored inline and not written to chunk storage + inline_chunk_threshold_bytes: 512 + } } The following storage types are supported: @@ -144,6 +146,7 @@ async def open_existing( cls, storage: StorageConfig, mode: AccessModeLiteral = "r", + config: StoreConfig = StoreConfig(), *args: Any, **kwargs: Any, ) -> Self: @@ -158,7 +161,9 @@ async def open_existing( If opened with AccessModeLiteral "r", the store will be read-only. Otherwise the store will be writable. """ read_only = mode == "r" - store = await pyicechunk_store_open_existing(storage, read_only=read_only) + store = await pyicechunk_store_open_existing( + storage, read_only=read_only, config=config + ) return cls(store=store, mode=mode, args=args, kwargs=kwargs) @classmethod @@ -166,6 +171,7 @@ async def create( cls, storage: StorageConfig, mode: AccessModeLiteral = "w", + config: StoreConfig = StoreConfig(), *args: Any, **kwargs: Any, ) -> Self: @@ -177,7 +183,7 @@ async def create( this will be configured automatically with the provided storage_config as the underlying storage backend. """ - store = await pyicechunk_store_create(storage) + store = await pyicechunk_store_create(storage, config=config) return cls(store=store, mode=mode, args=args, kwargs=kwargs) def with_mode(self, mode: AccessModeLiteral) -> Self: @@ -326,7 +332,7 @@ async def get_partial_values( ------- list of values, in the order of the key_ranges, may contain null/none for missing keys """ - # NOTE: pyo3 has not implicit conversion from an Iterable to a rust iterable. So we convert it + # NOTE: pyo3 has not implicit conversion from an Iterable to a rust iterable. So we convert it # to a list here first. Possible opportunity for optimization. result = await self._store.get_partial_values(list(key_ranges)) return [ @@ -418,7 +424,7 @@ async def set_partial_values( """ # NOTE: pyo3 does not implicit conversion from an Iterable to a rust iterable. So we convert it # to a list here first. Possible opportunity for optimization. - return await self._store.set_partial_values(list(key_start_values)) # type: ignore + return await self._store.set_partial_values(list(key_start_values)) # type: ignore @property def supports_listing(self) -> bool: diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index e2ceb086..30e7861d 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -82,17 +82,17 @@ class StorageConfig: ``` """ class Memory: - """An in-memory storage backend""" + """Config for an in-memory storage backend""" prefix: str class Filesystem: - """A local filesystem storage backend""" + """Config for a local filesystem storage backend""" root: str class S3: - """An S3 Object Storage compatible storage backend""" + """Config for an S3 Object Storage compatible storage backend""" bucket: str prefix: str @@ -101,13 +101,29 @@ class StorageConfig: def __init__(self, storage: Memory | Filesystem | S3): ... @classmethod - def memory(cls, prefix: str) -> StorageConfig: ... + def memory(cls, prefix: str) -> StorageConfig: + """Create a StorageConfig object for an in-memory storage backend with the given prefix""" + ... + @classmethod - def filesystem(cls, root: str) -> StorageConfig: ... + def filesystem(cls, root: str) -> StorageConfig: + """Create a StorageConfig object for a local filesystem storage backend with the given root directory""" + ... + @classmethod def s3_from_env( cls, bucket: str, prefix: str, endpoint_url: str | None = None - ) -> StorageConfig: ... + ) -> StorageConfig: + """Create a StorageConfig object for an S3 Object Storage compatible storage backend + with the given bucket and prefix + + This assumes that the necessary credentials are available in the environment: + AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY, + AWS_SESSION_TOKEN (optional) + """ + ... + @classmethod def s3_from_credentials( cls, @@ -115,7 +131,14 @@ class StorageConfig: prefix: str, credentials: S3Credentials, endpoint_url: str | None, - ) -> StorageConfig: ... + ) -> StorageConfig: + """Create a StorageConfig object for an S3 Object Storage compatible storage + backend with the given bucket, prefix, and credentials + + This method will directly use the provided credentials to authenticate with the S3 service, + ignoring any environment variables. + """ + ... class S3Credentials: access_key_id: str @@ -130,23 +153,28 @@ class S3Credentials: ): ... class StoreConfig: + # The number of concurrent requests to make when fetching partial values get_partial_values_concurrency: int | None - inline_chunk_threshold: int | None + # The threshold at which to inline chunks in the store in bytes. When set, + # chunks smaller than this threshold will be inlined in the store. Default is + # 512 bytes. + inline_chunk_threshold_bytes: int | None + # Whether to allow overwriting refs in the store. Default is False. Experimental. unsafe_overwrite_refs: bool | None def __init__( self, get_partial_values_concurrency: int | None = None, - inline_chunk_threshold: int | None = None, + inline_chunk_threshold_bytes: int | None = None, unsafe_overwrite_refs: bool | None = None, ): ... async def pyicechunk_store_exists(storage: StorageConfig) -> bool: ... async def pyicechunk_store_create( - storage: StorageConfig, config: StoreConfig = StoreConfig() + storage: StorageConfig, config: StoreConfig ) -> PyIcechunkStore: ... async def pyicechunk_store_open_existing( - storage: StorageConfig, read_only: bool, config: StoreConfig = StoreConfig() + storage: StorageConfig, read_only: bool, config: StoreConfig ) -> PyIcechunkStore: ... async def pyicechunk_store_from_json_config( config: str, read_only: bool diff --git a/icechunk-python/src/lib.rs b/icechunk-python/src/lib.rs index ad01247a..7bd3b495 100644 --- a/icechunk-python/src/lib.rs +++ b/icechunk-python/src/lib.rs @@ -36,7 +36,7 @@ struct PyStoreConfig { #[pyo3(get, set)] pub get_partial_values_concurrency: Option, #[pyo3(get, set)] - pub inline_chunk_threshold: Option, + pub inline_chunk_threshold_bytes: Option, #[pyo3(get, set)] pub unsafe_overwrite_refs: Option, } @@ -45,7 +45,7 @@ impl From<&PyStoreConfig> for RepositoryConfig { fn from(config: &PyStoreConfig) -> Self { RepositoryConfig { version: None, - inline_chunk_threshold_bytes: config.inline_chunk_threshold, + inline_chunk_threshold_bytes: config.inline_chunk_threshold_bytes, unsafe_overwrite_refs: config.unsafe_overwrite_refs, } } @@ -68,12 +68,12 @@ impl PyStoreConfig { #[new] fn new( get_partial_values_concurrency: Option, - inline_chunk_threshold: Option, + inline_chunk_threshold_bytes: Option, unsafe_overwrite_refs: Option, ) -> Self { PyStoreConfig { get_partial_values_concurrency, - inline_chunk_threshold, + inline_chunk_threshold_bytes, unsafe_overwrite_refs, } } @@ -181,7 +181,6 @@ fn pyicechunk_store_from_json_config( } #[pyfunction] -#[pyo3(signature = (storage, read_only, config=PyStoreConfig::default()))] fn pyicechunk_store_open_existing<'py>( py: Python<'py>, storage: &'py PyStorageConfig, @@ -215,7 +214,6 @@ fn pyicechunk_store_exists<'py>( } #[pyfunction] -#[pyo3(signature = (storage, config=PyStoreConfig::default()))] fn pyicechunk_store_create<'py>( py: Python<'py>, storage: &'py PyStorageConfig, diff --git a/icechunk-python/tests/test_config.py b/icechunk-python/tests/test_config.py new file mode 100644 index 00000000..2980dfb8 --- /dev/null +++ b/icechunk-python/tests/test_config.py @@ -0,0 +1,76 @@ +import os +import shutil + +import icechunk +import pytest +import zarr + +STORE_PATH = "/tmp/icechunk_config_test" + +@pytest.fixture +async def store(): + store = await icechunk.IcechunkStore.open( + storage=icechunk.StorageConfig.filesystem(STORE_PATH), + mode="a", + config=icechunk.StoreConfig(inline_chunk_threshold_bytes=5), + ) + + yield store + + store.close() + shutil.rmtree(STORE_PATH) + + +async def test_no_inline_chunks(store): + array = zarr.open_array( + store=store, + mode="a", + shape=(10), + dtype="int64", + zarr_format=3, + chunk_shape=(1), + fill_value=-1, + ) + array[:] = 42 + + # Check that the chunks directory was created, since each chunk is 4 bytes and the + # inline_chunk_threshold is 1, we should have 10 chunks in the chunks directory + assert os.path.isdir(f"{STORE_PATH}/chunks") + assert len(os.listdir(f"{STORE_PATH}/chunks")) == 10 + + +async def test_inline_chunks(store): + inline_array = zarr.open_array( + store=store, + mode="a", + path="inline", + shape=(10), + dtype="int32", + zarr_format=3, + chunk_shape=(1), + fill_value=-1, + ) + + inline_array[:] = 9 + + # Check that the chunks directory was not created, since each chunk is 4 bytes and the + # inline_chunk_threshold is 40, we should have no chunks directory + assert not os.path.isdir(f"{STORE_PATH}/chunks") + + written_array = zarr.open_array( + store=store, + mode="a", + path="not_inline", + shape=(10), + dtype="int64", + zarr_format=3, + chunk_shape=(1), + fill_value=-1, + ) + + written_array[:] = 3 + + # Check that the chunks directory was not created, since each chunk is 8 bytes and the + # inline_chunk_threshold is 40, we should have 10 chunks in the chunks directory + assert os.path.isdir(f"{STORE_PATH}/chunks") + assert len(os.listdir(f"/{STORE_PATH}/chunks")) == 10 diff --git a/icechunk-python/tests/test_timetravel.py b/icechunk-python/tests/test_timetravel.py index d12a011b..84e49bba 100644 --- a/icechunk-python/tests/test_timetravel.py +++ b/icechunk-python/tests/test_timetravel.py @@ -6,7 +6,7 @@ async def test_timetravel(): store = await icechunk.IcechunkStore.create( storage=icechunk.StorageConfig.memory("test"), - config=icechunk.StoreConfig(inline_chunk_threshold=1), + config=icechunk.StoreConfig(inline_chunk_threshold_bytes=1), ) group = zarr.group(store=store, overwrite=True) diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index 86d55428..f48f5e5a 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -51,7 +51,7 @@ use crate::{ #[derive(Clone, Debug)] pub struct RepositoryConfig { // Chunks smaller than this will be stored inline in the manifst - pub inline_threshold_bytes: u16, + pub inline_chunk_threshold_bytes: u16, // Unsafely overwrite refs on write. This is not recommended, users should only use it at their // own risk in object stores for which we don't support write-object-if-not-exists. There is // teh posibility of race conditions if this variable is set to true and there are concurrent @@ -61,7 +61,7 @@ pub struct RepositoryConfig { impl Default for RepositoryConfig { fn default() -> Self { - Self { inline_threshold_bytes: 512, unsafe_overwrite_refs: false } + Self { inline_chunk_threshold_bytes: 512, unsafe_overwrite_refs: false } } } @@ -223,7 +223,7 @@ impl RepositoryBuilder { } pub fn with_inline_threshold_bytes(&mut self, threshold: u16) -> &mut Self { - self.config.inline_threshold_bytes = threshold; + self.config.inline_chunk_threshold_bytes = threshold; self } @@ -735,7 +735,7 @@ impl Repository { ) -> Pin< Box> + Send>, > { - let threshold = self.config.inline_threshold_bytes as usize; + let threshold = self.config.inline_chunk_threshold_bytes as usize; let storage = Arc::clone(&self.storage); move |data: Bytes| { async move { From 52a15b04dfdb9e316ce3877603d7f35c788d9542 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Tue, 1 Oct 2024 11:57:17 -0300 Subject: [PATCH 006/167] Enable put-if-not-exists for s3 --- icechunk/src/storage/object_store.rs | 12 ++++++------ icechunk/src/storage/virtual_ref.rs | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/icechunk/src/storage/object_store.rs b/icechunk/src/storage/object_store.rs index 6fb53645..c48b597e 100644 --- a/icechunk/src/storage/object_store.rs +++ b/icechunk/src/storage/object_store.rs @@ -7,8 +7,9 @@ use bytes::Bytes; use core::fmt; use futures::{stream::BoxStream, StreamExt, TryStreamExt}; use object_store::{ - local::LocalFileSystem, memory::InMemory, path::Path as ObjectPath, GetOptions, - GetRange, ObjectStore, PutMode, PutOptions, PutPayload, + aws::S3ConditionalPut, local::LocalFileSystem, memory::InMemory, + path::Path as ObjectPath, GetOptions, GetRange, ObjectStore, PutMode, PutOptions, + PutPayload, }; use serde::{Deserialize, Serialize}; use std::{ @@ -70,8 +71,6 @@ pub struct ObjectStorage { // implementation is used only for tests, it's OK to sort in memory. artificially_sort_refs_in_mem: bool, - // We need this because object_store's hasn't implemented support for create-if-not-exists in - // S3 yet. We'll delete this after they do. supports_create_if_not_exists: bool, } @@ -134,13 +133,14 @@ impl ObjectStorage { builder }; + let builder = builder.with_conditional_put(S3ConditionalPut::ETagMatch); + let store = builder.with_bucket_name(bucket_name.into()).build()?; Ok(ObjectStorage { store: Arc::new(store), prefix: prefix.into(), artificially_sort_refs_in_mem: false, - // FIXME: this will go away once object_store supports create-if-not-exist on S3 - supports_create_if_not_exists: false, + supports_create_if_not_exists: true, }) } diff --git a/icechunk/src/storage/virtual_ref.rs b/icechunk/src/storage/virtual_ref.rs index 4e5f95a8..70ad8b9c 100644 --- a/icechunk/src/storage/virtual_ref.rs +++ b/icechunk/src/storage/virtual_ref.rs @@ -86,7 +86,7 @@ impl VirtualChunkResolver for ObjectStoreVirtualChunkResolver { let store = { let stores = self.stores.read().await; #[allow(clippy::expect_used)] - stores.get(&cache_key).map(Arc::clone) + stores.get(&cache_key).cloned() }; let store = match store { Some(store) => store, From 671f52caa4a65edcbfcadea6d2ba96ae18a1caa5 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Tue, 1 Oct 2024 18:40:43 -0300 Subject: [PATCH 007/167] Add the bare minimum of on-disk format evolution - We introduce a version number (u16) for manifest and snapshot formats (independently because versioning could change from one repo version to the next one) - We introduce a set of format flags for manifests and snapshots in the form of a mapping from strings to msgpack values - Snapshots and manifests store their version and format flags. - Snapshots now store a list of all their manifest files and attribute files, together with their format version - Simplified snapshot construction by eliminating most constructors - The old `Flags` datastructure is no longer used - Remove file extensions from snapshots and manifests in object store (this is to support different formats in the future, we don't necessarily want to keep track of the extension) - Store content-type for manifests and snapshots, as object store metadata. - Store format version for manifests and snapshots, as object store metadata. --- Cargo.lock | 22 ++++++ icechunk/Cargo.toml | 1 + icechunk/src/format/manifest.rs | 26 +++++-- icechunk/src/format/mod.rs | 17 ++++- icechunk/src/format/snapshot.rs | 102 ++++++++++++++++----------- icechunk/src/repository.rs | 40 +++++++---- icechunk/src/storage/object_store.rs | 68 ++++++++++++++---- 7 files changed, 197 insertions(+), 79 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a11e6248..b01abd83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -654,6 +654,7 @@ dependencies = [ "quick_cache", "rand", "rmp-serde", + "rmpv", "serde", "serde_json", "serde_with", @@ -1346,6 +1347,18 @@ dependencies = [ "serde", ] +[[package]] +name = "rmpv" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58450723cd9ee93273ce44a20b6ec4efe17f8ed2e3631474387bfdecf18bb2a9" +dependencies = [ + "num-traits", + "rmp", + "serde", + "serde_bytes", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -1499,6 +1512,15 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "387cc504cb06bb40a96c8e04e951fe01854cf6bc921053c954e4a606d9675c6a" +dependencies = [ + "serde", +] + [[package]] name = "serde_derive" version = "1.0.210" diff --git a/icechunk/Cargo.toml b/icechunk/Cargo.toml index 9f609201..6c3fc497 100644 --- a/icechunk/Cargo.toml +++ b/icechunk/Cargo.toml @@ -27,6 +27,7 @@ async-recursion = "1.1.1" rmp-serde = "1.3.0" url = "2.5.2" async-stream = "0.3.5" +rmpv = { version = "1.3.0", features = ["serde", "with-serde"] } [dev-dependencies] pretty_assertions = "1.4.1" diff --git a/icechunk/src/format/manifest.rs b/icechunk/src/format/manifest.rs index ef7862a1..3cfdcda8 100644 --- a/icechunk/src/format/manifest.rs +++ b/icechunk/src/format/manifest.rs @@ -6,8 +6,8 @@ use bytes::Bytes; use serde::{Deserialize, Serialize}; use super::{ - ChunkId, ChunkIndices, ChunkLength, ChunkOffset, Flags, IcechunkFormatError, - IcechunkResult, ManifestId, NodeId, + format_constants, ChunkId, ChunkIndices, ChunkLength, ChunkOffset, + IcechunkFormatError, IcechunkFormatVersion, IcechunkResult, ManifestId, NodeId, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -16,7 +16,6 @@ pub struct ManifestExtents(pub Vec); #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ManifestRef { pub object_id: ManifestId, - pub flags: Flags, pub extents: ManifestExtents, } @@ -84,7 +83,7 @@ pub struct ChunkRef { #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum ChunkPayload { - Inline(Bytes), // FIXME: optimize copies + Inline(Bytes), Virtual(VirtualChunkRef), Ref(ChunkRef), } @@ -98,7 +97,9 @@ pub struct ChunkInfo { #[derive(Debug, PartialEq, Serialize, Deserialize, Default)] pub struct Manifest { - pub chunks: BTreeMap<(NodeId, ChunkIndices), ChunkPayload>, + pub icechunk_manifest_format_version: IcechunkFormatVersion, + pub icechunk_manifest_format_flags: BTreeMap, + chunks: BTreeMap<(NodeId, ChunkIndices), ChunkPayload>, } impl Manifest { @@ -119,6 +120,19 @@ impl Manifest { ) -> impl Iterator { PayloadIterator { manifest: self, for_node: *node, last_key: None } } + + pub fn new(chunks: BTreeMap<(NodeId, ChunkIndices), ChunkPayload>) -> Self { + Self { + chunks, + icechunk_manifest_format_version: + format_constants::LATEST_ICECHUNK_MANIFEST_FORMAT, + icechunk_manifest_format_flags: Default::default(), + } + } + + pub fn chunks(&self) -> &BTreeMap<(NodeId, ChunkIndices), ChunkPayload> { + &self.chunks + } } impl FromIterator for Manifest { @@ -127,7 +141,7 @@ impl FromIterator for Manifest { .into_iter() .map(|chunk| ((chunk.node, chunk.coord), chunk.payload)) .collect(); - Manifest { chunks } + Self::new(chunks) } } diff --git a/icechunk/src/format/mod.rs b/icechunk/src/format/mod.rs index 6e53d840..d7168756 100644 --- a/icechunk/src/format/mod.rs +++ b/icechunk/src/format/mod.rs @@ -204,9 +204,6 @@ impl From<(Option, Option)> for ByteRange { pub type TableOffset = u32; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Flags(); // FIXME: implement - #[derive(Debug, Clone, Error, PartialEq, Eq)] pub enum IcechunkFormatError { #[error("error decoding fill_value from array")] @@ -221,6 +218,20 @@ pub enum IcechunkFormatError { pub type IcechunkResult = Result; +type IcechunkFormatVersion = u16; + +pub mod format_constants { + use super::IcechunkFormatVersion; + + pub const LATEST_ICECHUNK_MANIFEST_FORMAT: IcechunkFormatVersion = 0; + pub const LATEST_ICECHUNK_MANIFEST_CONTENT_TYPE: &str = "application/msgpack"; + pub const LATEST_ICECHUNK_MANIFEST_VERSION_METADATA_KEY: &str = "ic-man-fmt-ver"; + + pub const LATEST_ICECHUNK_SNAPSHOT_FORMAT: IcechunkFormatVersion = 0; + pub const LATEST_ICECHUNK_SNAPSHOT_CONTENT_TYPE: &str = "application/msgpack"; + pub const LATEST_ICECHUNK_SNAPSHOT_VERSION_METADATA_KEY: &str = "ic-sna-fmt-ver"; +} + #[cfg(test)] #[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] mod tests { diff --git a/icechunk/src/format/snapshot.rs b/icechunk/src/format/snapshot.rs index 4f386be2..ac76ed8c 100644 --- a/icechunk/src/format/snapshot.rs +++ b/icechunk/src/format/snapshot.rs @@ -14,15 +14,15 @@ use crate::metadata::{ }; use super::{ - manifest::ManifestRef, AttributesId, Flags, IcechunkFormatError, IcechunkResult, - NodeId, ObjectId, Path, SnapshotId, TableOffset, + format_constants, manifest::ManifestRef, AttributesId, IcechunkFormatError, + IcechunkFormatVersion, IcechunkResult, ManifestId, NodeId, ObjectId, Path, + SnapshotId, TableOffset, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct UserAttributesRef { pub object_id: AttributesId, pub location: TableOffset, - pub flags: Flags, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -81,8 +81,26 @@ pub struct SnapshotMetadata { pub type SnapshotProperties = HashMap; +#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] +pub struct ManifestFileInfo { + pub id: ManifestId, + pub format_version: IcechunkFormatVersion, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] +pub struct AttributeFileInfo { + pub id: AttributesId, + pub format_version: IcechunkFormatVersion, +} + #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct Snapshot { + pub icechunk_snapshot_format_version: IcechunkFormatVersion, + pub icechunk_snapshot_format_flags: BTreeMap, + + pub manifest_files: Vec, + pub attribute_files: Vec, + pub total_parents: u32, // we denormalize this field to have it easily available in the serialized file pub short_term_parents: u16, @@ -91,7 +109,7 @@ pub struct Snapshot { pub metadata: SnapshotMetadata, pub started_at: DateTime, pub properties: SnapshotProperties, - pub nodes: BTreeMap, + nodes: BTreeMap, } impl Default for SnapshotMetadata { @@ -113,17 +131,24 @@ impl SnapshotMetadata { impl Snapshot { pub const INITIAL_COMMIT_MESSAGE: &'static str = "Repository initialized"; - pub fn new( + fn new( short_term_history: VecDeque, total_parents: u32, properties: Option, + nodes: BTreeMap, + manifest_files: Vec, + attribute_files: Vec, ) -> Self { let metadata = SnapshotMetadata::default(); let short_term_parents = short_term_history.len() as u16; let started_at = Utc::now(); let properties = properties.unwrap_or_default(); - let nodes = BTreeMap::new(); Self { + icechunk_snapshot_format_version: + format_constants::LATEST_ICECHUNK_SNAPSHOT_FORMAT, + icechunk_snapshot_format_flags: Default::default(), + manifest_files, + attribute_files, total_parents, short_term_parents, short_term_history, @@ -135,50 +160,33 @@ impl Snapshot { } pub fn from_iter>( - short_term_history: VecDeque, - total_parents: u32, + parent: &Snapshot, properties: Option, + manifest_files: Vec, + attribute_files: Vec, iter: T, ) -> Self { let nodes = iter.into_iter().map(|node| (node.path.clone(), node)).collect(); - Self { nodes, ..Self::new(short_term_history, total_parents, properties) } - } - - pub fn first(properties: Option) -> Self { - Self::new(VecDeque::new(), 0, properties) - } - - pub fn first_from_iter>( - properties: Option, - iter: T, - ) -> Self { - Self::from_iter(VecDeque::new(), 0, properties, iter) - } - - pub fn from_parent( - parent: &Snapshot, - properties: Option, - ) -> Self { let mut history = parent.short_term_history.clone(); history.push_front(parent.metadata.clone()); - Self::new(history, parent.total_parents + 1, properties) - } - pub fn child_from_iter>( - parent: &Snapshot, - properties: Option, - iter: T, - ) -> Self { - let mut res = Self::from_parent(parent, properties); - let with_nodes = Self::first_from_iter(None, iter); - res.nodes = with_nodes.nodes; - res + Self::new( + history, + parent.total_parents + 1, + properties, + nodes, + manifest_files, + attribute_files, + ) } pub fn empty() -> Self { let metadata = SnapshotMetadata::with_message(Self::INITIAL_COMMIT_MESSAGE.to_string()); - Self { metadata, ..Self::first(None) } + Self { + metadata, + ..Self::new(VecDeque::new(), 0, None, Default::default(), vec![], vec![]) + } } pub fn get_node(&self, path: &Path) -> IcechunkResult<&NodeSnapshot> { @@ -295,12 +303,10 @@ mod tests { ZarrArrayMetadata { dimension_names: None, ..zarr_meta2.clone() }; let man_ref1 = ManifestRef { object_id: ObjectId::random(), - flags: Flags(), extents: ManifestExtents(vec![]), }; let man_ref2 = ManifestRef { object_id: ObjectId::random(), - flags: Flags(), extents: ManifestExtents(vec![]), }; @@ -338,7 +344,6 @@ mod tests { user_attributes: Some(UserAttributesSnapshot::Ref(UserAttributesRef { object_id: oid.clone(), location: 42, - flags: Flags(), })), node_data: NodeData::Array( zarr_meta1.clone(), @@ -358,7 +363,19 @@ mod tests { node_data: NodeData::Array(zarr_meta3.clone(), vec![]), }, ]; - let st = Snapshot::first_from_iter(None, nodes); + let initial = Snapshot::empty(); + let manifests = vec![ + ManifestFileInfo { + id: man_ref1.object_id.clone(), + format_version: format_constants::LATEST_ICECHUNK_MANIFEST_FORMAT, + }, + ManifestFileInfo { + id: man_ref2.object_id.clone(), + format_version: format_constants::LATEST_ICECHUNK_MANIFEST_FORMAT, + }, + ]; + let st = Snapshot::from_iter(&initial, None, manifests, vec![], nodes); + assert_eq!( st.get_node(&"/nonexistent".into()), Err(IcechunkFormatError::NodeNotFound { path: "/nonexistent".into() }) @@ -395,7 +412,6 @@ mod tests { user_attributes: Some(UserAttributesSnapshot::Ref(UserAttributesRef { object_id: oid, location: 42, - flags: Flags(), })), node_data: NodeData::Array(zarr_meta1.clone(), vec![man_ref1, man_ref2]), }), diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index f48f5e5a..5c50b265 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -8,7 +8,10 @@ use std::{ }; use crate::{ - format::{manifest::VirtualReferenceError, ManifestId, SnapshotId}, + format::{ + manifest::VirtualReferenceError, snapshot::ManifestFileInfo, ManifestId, + SnapshotId, + }, storage::virtual_ref::{construct_valid_byte_range, VirtualChunkResolver}, }; pub use crate::{ @@ -38,7 +41,7 @@ use crate::{ NodeData, NodeSnapshot, NodeType, Snapshot, SnapshotProperties, UserAttributesSnapshot, }, - ByteRange, Flags, IcechunkFormatError, NodeId, ObjectId, + ByteRange, IcechunkFormatError, NodeId, ObjectId, }, refs::{ create_tag, fetch_branch_tip, fetch_tag, update_branch, BranchVersion, Ref, @@ -888,7 +891,6 @@ impl Repository { //FIXME: it could be none for empty arrays Some(vec![ManifestRef { object_id: manifest_id.clone(), - flags: Flags(), extents: ManifestExtents(vec![]), }]) } else { @@ -915,7 +917,6 @@ impl Repository { NodeData::Array(meta, _no_manifests_yet) => { let new_manifests = vec![ManifestRef { object_id: manifest_id.clone(), - flags: Flags(), extents: ManifestExtents(vec![]), }]; NodeSnapshot { @@ -1037,7 +1038,7 @@ impl Repository { //FIXME: avoid clone, this one is extremely expensive en memory //it's currently needed because we don't want to destroy the manifest in case of later //failure - let mut new_chunks = old_manifest.as_ref().chunks.clone(); + let mut new_chunks = old_manifest.as_ref().chunks().clone(); update_manifest(&mut new_chunks, &chunk_changes_c); (new_chunks, chunk_changes) }); @@ -1053,17 +1054,22 @@ impl Repository { Arc::into_inner(chunk_changes).expect("Bug in flush task join"); } - let new_manifest = Arc::new(Manifest { chunks: new_chunks }); + let new_manifest = Arc::new(Manifest::new(new_chunks)); let new_manifest_id = ObjectId::random(); self.storage - .write_manifests(new_manifest_id.clone(), new_manifest) + .write_manifests(new_manifest_id.clone(), Arc::clone(&new_manifest)) .await?; let all_nodes = self.updated_nodes(&new_manifest_id).await?; - let mut new_snapshot = Snapshot::child_from_iter( + let mut new_snapshot = Snapshot::from_iter( old_snapshot.as_ref(), Some(properties), + vec![ManifestFileInfo { + id: new_manifest_id.clone(), + format_version: new_manifest.icechunk_manifest_format_version, + }], + vec![], all_nodes, ); new_snapshot.metadata.message = message.to_string(); @@ -1372,7 +1378,7 @@ mod tests { let manifest = Arc::new(vec![chunk1.clone(), chunk2.clone()].into_iter().collect()); let manifest_id = ObjectId::random(); - storage.write_manifests(manifest_id.clone(), manifest).await?; + storage.write_manifests(manifest_id.clone(), Arc::clone(&manifest)).await?; let zarr_meta1 = ZarrArrayMetadata { shape: vec![2, 2, 2], @@ -1396,8 +1402,7 @@ mod tests { ]), }; let manifest_ref = ManifestRef { - object_id: manifest_id, - flags: Flags(), + object_id: manifest_id.clone(), extents: ManifestExtents(vec![]), }; let array1_path: PathBuf = "/array1".to_string().into(); @@ -1418,7 +1423,18 @@ mod tests { }, ]; - let snapshot = Arc::new(Snapshot::first_from_iter(None, nodes.iter().cloned())); + let initial = Snapshot::empty(); + let manifests = vec![ManifestFileInfo { + id: manifest_id.clone(), + format_version: manifest.icechunk_manifest_format_version, + }]; + let snapshot = Arc::new(Snapshot::from_iter( + &initial, + None, + manifests, + vec![], + nodes.iter().cloned(), + )); let snapshot_id = ObjectId::random(); storage.write_snapshot(snapshot_id.clone(), snapshot).await?; let mut ds = Repository::update(Arc::new(storage), snapshot_id) diff --git a/icechunk/src/storage/object_store.rs b/icechunk/src/storage/object_store.rs index c48b597e..c2fc5198 100644 --- a/icechunk/src/storage/object_store.rs +++ b/icechunk/src/storage/object_store.rs @@ -1,6 +1,7 @@ use crate::format::{ - attributes::AttributesTable, manifest::Manifest, snapshot::Snapshot, AttributesId, - ByteRange, ChunkId, FileTypeTag, ManifestId, ObjectId, SnapshotId, + attributes::AttributesTable, format_constants, manifest::Manifest, + snapshot::Snapshot, AttributesId, ByteRange, ChunkId, FileTypeTag, ManifestId, + ObjectId, SnapshotId, }; use async_trait::async_trait; use bytes::Bytes; @@ -8,8 +9,8 @@ use core::fmt; use futures::{stream::BoxStream, StreamExt, TryStreamExt}; use object_store::{ aws::S3ConditionalPut, local::LocalFileSystem, memory::InMemory, - path::Path as ObjectPath, GetOptions, GetRange, ObjectStore, PutMode, PutOptions, - PutPayload, + path::Path as ObjectPath, Attribute, AttributeValue, Attributes, GetOptions, + GetRange, ObjectStore, PutMode, PutOptions, PutPayload, }; use serde::{Deserialize, Serialize}; use std::{ @@ -147,25 +148,24 @@ impl ObjectStorage { fn get_path( &self, file_prefix: &str, - extension: &str, id: &ObjectId, ) -> ObjectPath { // TODO: be careful about allocation here // we serialize the url using crockford - let path = format!("{}/{}/{}{}", self.prefix, file_prefix, id, extension); + let path = format!("{}/{}/{}", self.prefix, file_prefix, id); ObjectPath::from(path) } fn get_snapshot_path(&self, id: &SnapshotId) -> ObjectPath { - self.get_path(SNAPSHOT_PREFIX, ".msgpack", id) + self.get_path(SNAPSHOT_PREFIX, id) } fn get_manifest_path(&self, id: &ManifestId) -> ObjectPath { - self.get_path(MANIFEST_PREFIX, ".msgpack", id) + self.get_path(MANIFEST_PREFIX, id) } fn get_chunk_path(&self, id: &ChunkId) -> ObjectPath { - self.get_path(CHUNK_PREFIX, "", id) + self.get_path(CHUNK_PREFIX, id) } fn drop_prefix(&self, prefix: &ObjectPath, path: &ObjectPath) -> Option { @@ -234,12 +234,31 @@ impl Storage for ObjectStorage { async fn write_snapshot( &self, id: SnapshotId, - table: Arc, + snapshot: Arc, ) -> Result<(), StorageError> { let path = self.get_snapshot_path(&id); - let bytes = rmp_serde::to_vec(table.as_ref())?; + let bytes = rmp_serde::to_vec(snapshot.as_ref())?; + let options = PutOptions { + attributes: Attributes::from_iter(vec![ + ( + Attribute::ContentType, + AttributeValue::from( + format_constants::LATEST_ICECHUNK_SNAPSHOT_CONTENT_TYPE, + ), + ), + ( + Attribute::Metadata(std::borrow::Cow::Borrowed( + format_constants::LATEST_ICECHUNK_SNAPSHOT_VERSION_METADATA_KEY, + )), + AttributeValue::from( + snapshot.icechunk_snapshot_format_version.to_string(), + ), + ), + ]), + ..PutOptions::default() + }; // FIXME: use multipart - self.store.put(&path, bytes.into()).await?; + self.store.put_opts(&path, bytes.into(), options).await?; Ok(()) } @@ -254,12 +273,31 @@ impl Storage for ObjectStorage { async fn write_manifests( &self, id: ManifestId, - table: Arc, + manifest: Arc, ) -> Result<(), StorageError> { let path = self.get_manifest_path(&id); - let bytes = rmp_serde::to_vec(table.as_ref())?; + let bytes = rmp_serde::to_vec(manifest.as_ref())?; + let options = PutOptions { + attributes: Attributes::from_iter(vec![ + ( + Attribute::ContentType, + AttributeValue::from( + format_constants::LATEST_ICECHUNK_MANIFEST_CONTENT_TYPE, + ), + ), + ( + Attribute::Metadata(std::borrow::Cow::Borrowed( + format_constants::LATEST_ICECHUNK_MANIFEST_VERSION_METADATA_KEY, + )), + AttributeValue::from( + manifest.icechunk_manifest_format_version.to_string(), + ), + ), + ]), + ..PutOptions::default() + }; // FIXME: use multipart - self.store.put(&path, bytes.into()).await?; + self.store.put_opts(&path, bytes.into(), options).await?; Ok(()) } From 73bb82beb520e541ba9810bc9ae6c64be3e4cace Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Tue, 1 Oct 2024 18:20:05 -0400 Subject: [PATCH 008/167] Virtual Ref config support [EAR-1345] (#126) --- .github/workflows/python-ci.yaml | 4 +- icechunk-python/examples/smoke-test.py | 2 +- .../notebooks/demo-dummy-data.ipynb | 8 +- icechunk-python/notebooks/demo-s3.ipynb | 16 +- icechunk-python/notebooks/memorystore.ipynb | 8 +- icechunk-python/notebooks/reference.ipynb | 154 -------------- .../notebooks/version-control.ipynb | 123 +++++------ icechunk-python/python/icechunk/__init__.py | 10 +- .../python/icechunk/_icechunk_python.pyi | 62 +++++- icechunk-python/src/lib.rs | 12 +- icechunk-python/src/storage.rs | 101 ++++++++- icechunk-python/tests/test_concurrency.py | 42 ++-- icechunk-python/tests/test_config.py | 1 + icechunk-python/tests/test_virtual_ref.py | 23 ++- icechunk-python/tests/test_zarr/test_api.py | 19 +- icechunk-python/tests/test_zarr/test_array.py | 9 +- icechunk-python/tests/test_zarr/test_group.py | 76 +++++-- icechunk/src/format/manifest.rs | 21 +- icechunk/src/repository.rs | 28 ++- icechunk/src/storage/object_store.rs | 81 +++++--- icechunk/src/storage/virtual_ref.rs | 73 +++++-- icechunk/src/zarr.rs | 67 ++++-- icechunk/tests/test_virtual_refs.rs | 191 +++++++++++++++--- 23 files changed, 722 insertions(+), 409 deletions(-) delete mode 100644 icechunk-python/notebooks/reference.ipynb diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml index dbc03ea6..cc6d5e11 100644 --- a/.github/workflows/python-ci.yaml +++ b/.github/workflows/python-ci.yaml @@ -98,7 +98,7 @@ jobs: python3 -m venv .venv source .venv/bin/activate pip install icechunk['test'] --find-links dist --force-reinstall - AWS_ALLOW_HTTP=1 AWS_ENDPOINT_URL=http://localhost:9000 AWS_ACCESS_KEY_ID=minio123 AWS_SECRET_ACCESS_KEY=minio123 pytest + pytest - name: pytest if: ${{ !startsWith(matrix.platform.target, 'x86') && matrix.platform.target != 'ppc64' }} uses: uraimo/run-on-arch-action@v2 @@ -114,7 +114,7 @@ jobs: run: | set -e pip3 install icechunk['test'] --find-links dist --force-reinstall - AWS_ALLOW_HTTP=1 AWS_ENDPOINT_URL=http://localhost:9000 AWS_ACCESS_KEY_ID=minio123 AWS_SECRET_ACCESS_KEY=minio123 pytest + pytest # musllinux: # runs-on: ${{ matrix.platform.runner }} diff --git a/icechunk-python/examples/smoke-test.py b/icechunk-python/examples/smoke-test.py index 83d848cb..fd64ddeb 100644 --- a/icechunk-python/examples/smoke-test.py +++ b/icechunk-python/examples/smoke-test.py @@ -177,7 +177,7 @@ async def create_zarr_store(*, store: Literal["memory", "local", "s3"]) -> Store if __name__ == "__main__": MEMORY = StorageConfig.memory("new") - MINIO = StorageConfig.s3_from_credentials( + MINIO = StorageConfig.s3_from_config( bucket="testbucket", prefix="root-icechunk", credentials=S3Credentials( diff --git a/icechunk-python/notebooks/demo-dummy-data.ipynb b/icechunk-python/notebooks/demo-dummy-data.ipynb index 21e7b6b7..5b98d361 100644 --- a/icechunk-python/notebooks/demo-dummy-data.ipynb +++ b/icechunk-python/notebooks/demo-dummy-data.ipynb @@ -1155,11 +1155,11 @@ "array = root_group[\"group2/foo3\"]\n", "print(array)\n", "\n", - "array = array.resize((array.shape[0]*2, *array.shape[1:]))\n", + "array = array.resize((array.shape[0] * 2, *array.shape[1:]))\n", "print(array)\n", - "array[array.shape[0]//2:, ...] = expected[\"group2/foo3\"]\n", - "print(array[2:, 0,0,0])\n", - "expected[\"group2/foo3\"] = np.concatenate([expected[\"group2/foo3\"]]*2, axis=0)\n", + "array[array.shape[0] // 2 :, ...] = expected[\"group2/foo3\"]\n", + "print(array[2:, 0, 0, 0])\n", + "expected[\"group2/foo3\"] = np.concatenate([expected[\"group2/foo3\"]] * 2, axis=0)\n", "\n", "await store.commit(\"appended to group2/foo3\")" ] diff --git a/icechunk-python/notebooks/demo-s3.ipynb b/icechunk-python/notebooks/demo-s3.ipynb index 21ce86b7..41f5872e 100644 --- a/icechunk-python/notebooks/demo-s3.ipynb +++ b/icechunk-python/notebooks/demo-s3.ipynb @@ -19,7 +19,7 @@ "source": [ "import zarr\n", "\n", - "from icechunk import IcechunkStore, Storage" + "from icechunk import IcechunkStore, StorageConfig" ] }, { @@ -39,7 +39,7 @@ "metadata": {}, "outputs": [], "source": [ - "s3_storage = Storage.s3_from_env(bucket=\"icechunk-test\", prefix=\"oscar-demo-repository\")" + "s3_storage = StorageConfig.s3_from_env(bucket=\"icechunk-test\", prefix=\"oscar-demo-repository\")" ] }, { @@ -1085,7 +1085,7 @@ " group.create_array(\n", " name=var,\n", " shape=oscar[var].shape,\n", - " chunk_shape = (1, 1, 481, 1201),\n", + " chunk_shape=(1, 1, 481, 1201),\n", " fill_value=-1234567,\n", " dtype=oscar[var].dtype,\n", " data=oscar[var],\n", @@ -1147,10 +1147,10 @@ "source": [ "import zarr\n", "\n", - "from icechunk import IcechunkStore, Storage\n", + "from icechunk import IcechunkStore, StorageConfig\n", "\n", "# TODO: catalog will handle this\n", - "s3_storage = Storage.s3_from_env(bucket=\"icechunk-test\", prefix=\"oscar-demo-repository\")" + "s3_storage = StorageConfig.s3_from_env(bucket=\"icechunk-test\", prefix=\"oscar-demo-repository\")" ] }, { @@ -1339,9 +1339,9 @@ ], "metadata": { "kernelspec": { - "display_name": "Python [conda env:icechunk]", + "display_name": ".venv", "language": "python", - "name": "conda-env-icechunk-py" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -1353,7 +1353,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.6" + "version": "3.11.9" } }, "nbformat": 4, diff --git a/icechunk-python/notebooks/memorystore.ipynb b/icechunk-python/notebooks/memorystore.ipynb index 6eb64c3f..22573d15 100644 --- a/icechunk-python/notebooks/memorystore.ipynb +++ b/icechunk-python/notebooks/memorystore.ipynb @@ -34,7 +34,9 @@ } ], "source": [ - "store = await icechunk.IcechunkStore.create(storage=icechunk.StorageConfig.memory(\"\"), mode=\"w\")\n", + "store = await icechunk.IcechunkStore.create(\n", + " storage=icechunk.StorageConfig.memory(\"\"), mode=\"w\"\n", + ")\n", "store" ] }, @@ -83,7 +85,9 @@ } ], "source": [ - "air_temp = group.create_array(\"air_temp\", shape=(1000, 1000), chunk_shape=(100, 100), dtype=\"i4\")\n", + "air_temp = group.create_array(\n", + " \"air_temp\", shape=(1000, 1000), chunk_shape=(100, 100), dtype=\"i4\"\n", + ")\n", "air_temp" ] }, diff --git a/icechunk-python/notebooks/reference.ipynb b/icechunk-python/notebooks/reference.ipynb deleted file mode 100644 index ebdc0e52..00000000 --- a/icechunk-python/notebooks/reference.ipynb +++ /dev/null @@ -1,154 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [], - "source": [ - "import zarr\n", - "from zarr.store import LocalStore" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "LocalStore('file:///tmp/test.zarr')" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "store = LocalStore('/tmp/test.zarr')\n", - "store" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Ok! Lets create some data!" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "Group(_async_group=)" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "group = zarr.group(store=store, overwrite=True)\n", - "store = group.store_path.store\n", - "group" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "air_temp = group.create_array(\"air_temp\", shape=(1000, 1000), chunk_shape=(100, 100), dtype=\"i4\")\n", - "air_temp" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "zarr.json\n", - "air_temp/zarr.json\n" - ] - } - ], - "source": [ - "async for key in store.list():\n", - " print(key)" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [], - "source": [ - "air_temp[:, :] = 42" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "array(42, dtype=int32)" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "air_temp[200, 6]" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.9" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/icechunk-python/notebooks/version-control.ipynb b/icechunk-python/notebooks/version-control.ipynb index 1b7160f2..6b94072c 100644 --- a/icechunk-python/notebooks/version-control.ipynb +++ b/icechunk-python/notebooks/version-control.ipynb @@ -10,12 +10,11 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 1, "id": "020e322c-4323-4064-b17e-a1e95f710d21", "metadata": {}, "outputs": [], "source": [ - "\n", "import zarr\n", "\n", "from icechunk import IcechunkStore, StorageConfig" @@ -33,23 +32,22 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 2, "id": "dd35041c-7981-446a-8981-d1eae02f4fff", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 3, + "execution_count": 2, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "\n", "store = await IcechunkStore.create(\n", " storage=StorageConfig.memory(\"test\"),\n", " mode=\"w\",\n", @@ -90,7 +88,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 3, "id": "51654a0d-58b2-43a9-acd9-0214f22c3dc5", "metadata": {}, "outputs": [], @@ -100,7 +98,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 4, "id": "d8bf3160-2a39-48be-82ea-e800fd3164b3", "metadata": {}, "outputs": [], @@ -110,25 +108,17 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 5, "id": "3a33f69c-9949-458a-9d3a-1f0d7f451553", "metadata": {}, "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "[icechunk/src/storage/caching.rs:190:9] \"inserting\" = \"inserting\"\n", - "[icechunk/src/storage/caching.rs:190:9] &id = 78447f5713395150be2281b3254cded2\n" - ] - }, { "data": { "text/plain": [ - "'AYHJX8N6C308R5J57CC8CX6N20'" + "'51MXCR5RTNGPC54Z7WJG'" ] }, - "execution_count": 6, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } @@ -140,7 +130,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 6, "id": "f41f701e-a513-4fe6-b23e-82200f5ab221", "metadata": {}, "outputs": [ @@ -150,7 +140,7 @@ "{'attr': 'first_attr'}" ] }, - "execution_count": 7, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } @@ -161,25 +151,17 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 7, "id": "10e40f91-7f90-4feb-91ba-b51b709d508d", "metadata": {}, "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "[icechunk/src/storage/caching.rs:190:9] \"inserting\" = \"inserting\"\n", - "[icechunk/src/storage/caching.rs:190:9] &id = 0e808d75aab5b5cf1bf33732244ef431\n" - ] - }, { "data": { "text/plain": [ - "'Y3YFWYFMW3RRMZP04M9STWKANR'" + "'45AE3AT46RHZCZ50HWEG'" ] }, - "execution_count": 8, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -200,17 +182,17 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 8, "id": "34fff29b-2bec-490c-89ef-51e14fb4527f", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'Y3YFWYFMW3RRMZP04M9STWKANR'" + "'45AE3AT46RHZCZ50HWEG'" ] }, - "execution_count": 9, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -237,17 +219,17 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 9, "id": "ff66cc99-84ca-4371-b63d-12efa6e98dc3", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "('Y3YFWYFMW3RRMZP04M9STWKANR', {'attr': 'second_attr'})" + "('45AE3AT46RHZCZ50HWEG', {'attr': 'second_attr'})" ] }, - "execution_count": 10, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -258,7 +240,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 10, "id": "e785d9a1-36ec-4207-b334-20e0a68e3ac8", "metadata": {}, "outputs": [ @@ -268,7 +250,7 @@ "{'attr': 'first_attr'}" ] }, - "execution_count": 11, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } @@ -291,7 +273,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 11, "id": "257215f2-fa09-4730-a1da-07a4d3d12b0c", "metadata": {}, "outputs": [ @@ -302,8 +284,8 @@ "traceback": [ "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", "\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[0;32mIn[12], line 2\u001b[0m\n\u001b[1;32m 1\u001b[0m root_group\u001b[38;5;241m.\u001b[39mattrs[\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mattr\u001b[39m\u001b[38;5;124m\"\u001b[39m] \u001b[38;5;241m=\u001b[39m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mwill_fail\u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[0;32m----> 2\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m store\u001b[38;5;241m.\u001b[39mcommit(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mthis should fail\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n", - "File \u001b[0;32m~/repos/icechunk/icechunk-python/python/icechunk/__init__.py:60\u001b[0m, in \u001b[0;36mIcechunkStore.commit\u001b[0;34m(self, message)\u001b[0m\n\u001b[1;32m 59\u001b[0m \u001b[38;5;28;01masync\u001b[39;00m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mcommit\u001b[39m(\u001b[38;5;28mself\u001b[39m, message: \u001b[38;5;28mstr\u001b[39m) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m \u001b[38;5;28mstr\u001b[39m:\n\u001b[0;32m---> 60\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_store\u001b[38;5;241m.\u001b[39mcommit(message)\n", + "Cell \u001b[0;32mIn[11], line 2\u001b[0m\n\u001b[1;32m 1\u001b[0m root_group\u001b[38;5;241m.\u001b[39mattrs[\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mattr\u001b[39m\u001b[38;5;124m\"\u001b[39m] \u001b[38;5;241m=\u001b[39m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mwill_fail\u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[0;32m----> 2\u001b[0m \u001b[38;5;28;01mawait\u001b[39;00m store\u001b[38;5;241m.\u001b[39mcommit(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mthis should fail\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n", + "File \u001b[0;32m~/Developer/icechunk/icechunk-python/python/icechunk/__init__.py:261\u001b[0m, in \u001b[0;36mIcechunkStore.commit\u001b[0;34m(self, message)\u001b[0m\n\u001b[1;32m 255\u001b[0m \u001b[38;5;28;01masync\u001b[39;00m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mcommit\u001b[39m(\u001b[38;5;28mself\u001b[39m, message: \u001b[38;5;28mstr\u001b[39m) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m \u001b[38;5;28mstr\u001b[39m:\n\u001b[1;32m 256\u001b[0m \u001b[38;5;250m \u001b[39m\u001b[38;5;124;03m\"\"\"Commit any uncommitted changes to the store.\u001b[39;00m\n\u001b[1;32m 257\u001b[0m \n\u001b[1;32m 258\u001b[0m \u001b[38;5;124;03m This will create a new snapshot on the current branch and return\u001b[39;00m\n\u001b[1;32m 259\u001b[0m \u001b[38;5;124;03m the snapshot id.\u001b[39;00m\n\u001b[1;32m 260\u001b[0m \u001b[38;5;124;03m \"\"\"\u001b[39;00m\n\u001b[0;32m--> 261\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_store\u001b[38;5;241m.\u001b[39mcommit(message)\n", "\u001b[0;31mValueError\u001b[0m: store error: all commits must be made on a branch" ] } @@ -334,7 +316,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 12, "id": "4ccb3c84-787a-43c4-b9af-606e6b8212ed", "metadata": {}, "outputs": [], @@ -354,7 +336,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 13, "id": "81f676de-48f7-4dd1-bbf9-300f97700f32", "metadata": {}, "outputs": [], @@ -364,7 +346,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 14, "id": "94b4e4d8-767a-45d0-9f4f-b0a473e9520a", "metadata": {}, "outputs": [], @@ -374,17 +356,17 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 15, "id": "b35947c7-e634-41e7-a78e-89447a5f4f8e", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'AYHJX8N6C308R5J57CC8CX6N20'" + "'51MXCR5RTNGPC54Z7WJG'" ] }, - "execution_count": 16, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } @@ -395,7 +377,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 16, "id": "121d2b55-9311-4f1a-813a-c6c49bbc4a4f", "metadata": {}, "outputs": [ @@ -405,7 +387,7 @@ "'new-branch'" ] }, - "execution_count": 17, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } @@ -416,7 +398,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 17, "id": "799cfce7-7385-4ae6-8868-77d4789c5cdb", "metadata": {}, "outputs": [ @@ -426,7 +408,7 @@ "{'attr': 'first_attr'}" ] }, - "execution_count": 18, + "execution_count": 17, "metadata": {}, "output_type": "execute_result" } @@ -438,19 +420,10 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 18, "id": "c3484aba-d25b-4d26-aa59-714e1f236d24", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "[icechunk/src/storage/caching.rs:190:9] \"inserting\" = \"inserting\"\n", - "[icechunk/src/storage/caching.rs:190:9] &id = af76e505cac3a24c91e6ac817335ee40\n" - ] - } - ], + "outputs": [], "source": [ "root_group.attrs[\"attr\"] = \"new_branch_attr\"\n", "new_branch_commit = await store.commit(\"commit on new branch\")" @@ -468,7 +441,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 19, "id": "5ed7e6ed-47db-4542-b773-6ab128a10395", "metadata": {}, "outputs": [], @@ -478,7 +451,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 20, "id": "b495cfbf-3f82-4f8c-9943-119e6a69dafb", "metadata": {}, "outputs": [ @@ -488,7 +461,7 @@ "True" ] }, - "execution_count": 22, + "execution_count": 20, "metadata": {}, "output_type": "execute_result" } @@ -499,7 +472,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 21, "id": "c80da99a-a78f-419f-a92d-bcf770c0db53", "metadata": {}, "outputs": [ @@ -509,7 +482,7 @@ "True" ] }, - "execution_count": 24, + "execution_count": 21, "metadata": {}, "output_type": "execute_result" } @@ -537,7 +510,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 22, "id": "47a4a3a2-0ae2-4e93-a1a6-a2d4706339db", "metadata": {}, "outputs": [], @@ -555,7 +528,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 23, "id": "6366cb69-cb02-4b61-9607-dc4b0ba08517", "metadata": {}, "outputs": [], @@ -565,7 +538,7 @@ }, { "cell_type": "code", - "execution_count": 29, + "execution_count": 24, "id": "81da441c-ccab-43c2-b50e-0588fb4c91bc", "metadata": {}, "outputs": [], @@ -585,7 +558,7 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": 25, "id": "dc509bde-2510-48f1-90b0-69a065393ced", "metadata": {}, "outputs": [ @@ -595,7 +568,7 @@ "True" ] }, - "execution_count": 31, + "execution_count": 25, "metadata": {}, "output_type": "execute_result" } @@ -607,7 +580,7 @@ }, { "cell_type": "code", - "execution_count": 33, + "execution_count": 26, "id": "b8b221c1-d94d-4971-97b9-ffa1948ce93d", "metadata": {}, "outputs": [], diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index f254f68a..09d8c23f 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -14,13 +14,20 @@ SnapshotMetadata, StorageConfig, StoreConfig, + VirtualRefConfig, pyicechunk_store_create, pyicechunk_store_exists, pyicechunk_store_from_json_config, pyicechunk_store_open_existing, ) -__all__ = ["IcechunkStore", "StorageConfig", "S3Credentials", "StoreConfig"] +__all__ = [ + "IcechunkStore", + "StorageConfig", + "S3Credentials", + "StoreConfig", + "VirtualRefConfig", +] class IcechunkStore(Store, SyncMixin): @@ -120,7 +127,6 @@ async def from_config( - s3: { "bucket": "bucket-name", "prefix": "optional-prefix", - "endpoint": "optional-end "access_key_id": "optional-access-key-id", "secret_access_key": "optional", "session_token": "optional", diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index 30e7861d..6b5d5564 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -78,7 +78,7 @@ class StorageConfig: storage_config = StorageConfig.memory("prefix") storage_config = StorageConfig.filesystem("/path/to/root") storage_config = StorageConfig.s3_from_env("bucket", "prefix") - storage_config = StorageConfig.s3_from_credentials("bucket", "prefix", + storage_config = StorageConfig.s3_from_config("bucket", "prefix", ...) ``` """ class Memory: @@ -96,8 +96,10 @@ class StorageConfig: bucket: str prefix: str - credentials: S3Credentials + credentials: S3Credentials | None endpoint_url: str | None + allow_http: bool | None + region: str | None def __init__(self, storage: Memory | Filesystem | S3): ... @classmethod @@ -111,9 +113,7 @@ class StorageConfig: ... @classmethod - def s3_from_env( - cls, bucket: str, prefix: str, endpoint_url: str | None = None - ) -> StorageConfig: + def s3_from_env(cls, bucket: str, prefix: str) -> StorageConfig: """Create a StorageConfig object for an S3 Object Storage compatible storage backend with the given bucket and prefix @@ -121,19 +121,24 @@ class StorageConfig: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN (optional) + AWS_REGION (optional) + AWS_ENDPOINT_URL (optional) + AWS_ALLOW_HTTP (optional) """ ... @classmethod - def s3_from_credentials( + def s3_from_config( cls, bucket: str, prefix: str, credentials: S3Credentials, endpoint_url: str | None, + allow_http: bool | None = None, + region: str | None = None, ) -> StorageConfig: """Create a StorageConfig object for an S3 Object Storage compatible storage - backend with the given bucket, prefix, and credentials + backend with the given bucket, prefix, and configuration This method will directly use the provided credentials to authenticate with the S3 service, ignoring any environment variables. @@ -152,6 +157,46 @@ class S3Credentials: session_token: str | None = None, ): ... +class VirtualRefConfig: + class S3: + """Config for an S3 Object Storage compatible storage backend""" + + credentials: S3Credentials | None + endpoint_url: str | None + allow_http: bool | None + region: str | None + + @classmethod + def s3_from_env(cls) -> StorageConfig: + """Create a VirtualReferenceConfig object for an S3 Object Storage compatible storage backend + with the given bucket and prefix + + This assumes that the necessary credentials are available in the environment: + AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY, + AWS_SESSION_TOKEN (optional) + AWS_REGION (optional) + AWS_ENDPOINT_URL (optional) + AWS_ALLOW_HTTP (optional) + """ + ... + + @classmethod + def s3_from_config( + cls, + credentials: S3Credentials, + endpoint_url: str | None, + allow_http: bool | None = None, + region: str | None = None, + ) -> StorageConfig: + """Create a VirtualReferenceConfig object for an S3 Object Storage compatible storage + backend with the given bucket, prefix, and configuration + + This method will directly use the provided credentials to authenticate with the S3 service, + ignoring any environment variables. + """ + ... + class StoreConfig: # The number of concurrent requests to make when fetching partial values get_partial_values_concurrency: int | None @@ -161,12 +206,15 @@ class StoreConfig: inline_chunk_threshold_bytes: int | None # Whether to allow overwriting refs in the store. Default is False. Experimental. unsafe_overwrite_refs: bool | None + # Configurations for virtual references such as credentials and endpoints + virtual_ref_config: VirtualRefConfig | None def __init__( self, get_partial_values_concurrency: int | None = None, inline_chunk_threshold_bytes: int | None = None, unsafe_overwrite_refs: bool | None = None, + virtual_ref_config: VirtualRefConfig | None = None, ): ... async def pyicechunk_store_exists(storage: StorageConfig) -> bool: ... diff --git a/icechunk-python/src/lib.rs b/icechunk-python/src/lib.rs index 7bd3b495..41b835bc 100644 --- a/icechunk-python/src/lib.rs +++ b/icechunk-python/src/lib.rs @@ -13,6 +13,7 @@ use icechunk::{ format::{manifest::VirtualChunkRef, ChunkLength}, refs::Ref, repository::VirtualChunkLocation, + storage::virtual_ref::ObjectStoreVirtualChunkResolverConfig, zarr::{ ConsolidatedStore, ObjectId, RepositoryConfig, StorageConfig, StoreOptions, VersionInfo, @@ -20,7 +21,7 @@ use icechunk::{ Repository, SnapshotMetadata, }; use pyo3::{exceptions::PyValueError, prelude::*, types::PyBytes}; -use storage::{PyS3Credentials, PyStorageConfig}; +use storage::{PyS3Credentials, PyStorageConfig, PyVirtualRefConfig}; use streams::PyAsyncGenerator; use tokio::sync::{Mutex, RwLock}; @@ -39,6 +40,8 @@ struct PyStoreConfig { pub inline_chunk_threshold_bytes: Option, #[pyo3(get, set)] pub unsafe_overwrite_refs: Option, + #[pyo3(get, set)] + pub virtual_ref_config: Option, } impl From<&PyStoreConfig> for RepositoryConfig { @@ -47,6 +50,10 @@ impl From<&PyStoreConfig> for RepositoryConfig { version: None, inline_chunk_threshold_bytes: config.inline_chunk_threshold_bytes, unsafe_overwrite_refs: config.unsafe_overwrite_refs, + virtual_ref_config: config + .virtual_ref_config + .as_ref() + .map(ObjectStoreVirtualChunkResolverConfig::from), } } } @@ -70,11 +77,13 @@ impl PyStoreConfig { get_partial_values_concurrency: Option, inline_chunk_threshold_bytes: Option, unsafe_overwrite_refs: Option, + virtual_ref_config: Option, ) -> Self { PyStoreConfig { get_partial_values_concurrency, inline_chunk_threshold_bytes, unsafe_overwrite_refs, + virtual_ref_config, } } } @@ -691,6 +700,7 @@ fn _icechunk_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_function(wrap_pyfunction!(pyicechunk_store_from_json_config, m)?)?; m.add_function(wrap_pyfunction!(pyicechunk_store_exists, m)?)?; m.add_function(wrap_pyfunction!(pyicechunk_store_create, m)?)?; diff --git a/icechunk-python/src/storage.rs b/icechunk-python/src/storage.rs index 7fe6003a..22b1ea18 100644 --- a/icechunk-python/src/storage.rs +++ b/icechunk-python/src/storage.rs @@ -1,6 +1,12 @@ use std::path::PathBuf; -use icechunk::{storage::object_store::S3Credentials, zarr::StorageConfig}; +use icechunk::{ + storage::{ + object_store::{S3Config, S3Credentials}, + virtual_ref::ObjectStoreVirtualChunkResolverConfig, + }, + zarr::StorageConfig, +}; use pyo3::{prelude::*, types::PyType}; #[pyclass(name = "S3Credentials")] @@ -49,6 +55,8 @@ pub enum PyStorageConfig { prefix: String, credentials: Option, endpoint_url: Option, + allow_http: Option, + region: Option, }, } @@ -70,23 +78,36 @@ impl PyStorageConfig { bucket: String, prefix: String, endpoint_url: Option, + allow_http: Option, + region: Option, ) -> Self { - PyStorageConfig::S3 { bucket, prefix, credentials: None, endpoint_url } + PyStorageConfig::S3 { + bucket, + prefix, + credentials: None, + endpoint_url, + allow_http, + region, + } } #[classmethod] - fn s3_from_credentials( + fn s3_from_config( _cls: &Bound<'_, PyType>, bucket: String, prefix: String, credentials: PyS3Credentials, endpoint_url: Option, + allow_http: Option, + region: Option, ) -> Self { PyStorageConfig::S3 { bucket, prefix, credentials: Some(credentials), endpoint_url, + allow_http, + region, } } } @@ -100,13 +121,77 @@ impl From<&PyStorageConfig> for StorageConfig { PyStorageConfig::Filesystem { root } => { StorageConfig::LocalFileSystem { root: PathBuf::from(root.clone()) } } - PyStorageConfig::S3 { bucket, prefix, credentials, endpoint_url } => { - StorageConfig::S3ObjectStore { - bucket: bucket.clone(), - prefix: prefix.clone(), + PyStorageConfig::S3 { + bucket, + prefix, + credentials, + endpoint_url, + allow_http, + region, + } => StorageConfig::S3ObjectStore { + bucket: bucket.clone(), + prefix: prefix.clone(), + config: Some(S3Config { + region: region.clone(), credentials: credentials.as_ref().map(S3Credentials::from), endpoint: endpoint_url.clone(), - } + allow_http: *allow_http, + }), + }, + } + } +} + +#[pyclass(name = "VirtualRefConfig")] +#[derive(Clone, Debug)] +pub enum PyVirtualRefConfig { + S3 { + credentials: Option, + endpoint_url: Option, + allow_http: Option, + region: Option, + }, +} + +#[pymethods] +impl PyVirtualRefConfig { + #[classmethod] + fn s3_from_env(_cls: &Bound<'_, PyType>) -> Self { + PyVirtualRefConfig::S3 { + credentials: None, + endpoint_url: None, + allow_http: None, + region: None, + } + } + + #[classmethod] + fn s3_from_config( + _cls: &Bound<'_, PyType>, + credentials: PyS3Credentials, + endpoint_url: Option, + allow_http: Option, + region: Option, + ) -> Self { + PyVirtualRefConfig::S3 { + credentials: Some(credentials), + endpoint_url, + allow_http, + region, + } + } +} + +impl From<&PyVirtualRefConfig> for ObjectStoreVirtualChunkResolverConfig { + fn from(config: &PyVirtualRefConfig) -> Self { + match config { + PyVirtualRefConfig::S3 { credentials, endpoint_url, allow_http, region } => { + ObjectStoreVirtualChunkResolverConfig::S3(S3Config { + region: region.clone(), + endpoint: endpoint_url.clone(), + credentials: credentials.as_ref().map(S3Credentials::from), + allow_http: *allow_http, + }) } } } diff --git a/icechunk-python/tests/test_concurrency.py b/icechunk-python/tests/test_concurrency.py index 65af93e3..20089024 100644 --- a/icechunk-python/tests/test_concurrency.py +++ b/icechunk-python/tests/test_concurrency.py @@ -6,23 +6,29 @@ N = 15 + async def write_to_store(array, x, y, barrier): await barrier.wait() - await asyncio.sleep(random.uniform(0,0.5)) - array[x,y] = x*y - #await asyncio.sleep(0) + await asyncio.sleep(random.uniform(0, 0.5)) + array[x, y] = x * y + # await asyncio.sleep(0) + async def read_store(array, x, y, barrier): await barrier.wait() while True: - #print(f"reading {x},{y}") - value = array[x,y] - if value == x*y: + # print(f"reading {x},{y}") + value = array[x, y] + if value == x * y: break - await asyncio.sleep(random.uniform(0,0.1)) + await asyncio.sleep(random.uniform(0, 0.1)) + async def list_store(store, barrier): - expected = set(['zarr.json', 'array/zarr.json'] + [f"array/c/{x}/{y}" for x in range(N) for y in range(N)]) + expected = set( + ["zarr.json", "array/zarr.json"] + + [f"array/c/{x}/{y}" for x in range(N) for y in range(N)] + ) await barrier.wait() while True: current = set([k async for k in store.list_prefix("")]) @@ -31,36 +37,42 @@ async def list_store(store, barrier): current = None await asyncio.sleep(0.1) + async def test_concurrency(): store = await icechunk.IcechunkStore.from_config( config={"storage": {"type": "in_memory"}, "repository": {}}, mode="w" ) group = zarr.group(store=store, overwrite=True) - array = group.create_array("array", shape=(N, N), chunk_shape=(1, 1), dtype="f8", fill_value=1e23) + array = group.create_array( + "array", shape=(N, N), chunk_shape=(1, 1), dtype="f8", fill_value=1e23 + ) - barrier = asyncio.Barrier(2*N*N + 1) + barrier = asyncio.Barrier(2 * N * N + 1) async with asyncio.TaskGroup() as tg: _task1 = tg.create_task(list_store(store, barrier), name="listing") for x in range(N): for y in range(N): - _write_task = tg.create_task(read_store(array, x, y, barrier), name=f"read {x},{y}") + _write_task = tg.create_task( + read_store(array, x, y, barrier), name=f"read {x},{y}" + ) for x in range(N): for y in range(N): - _write_task = tg.create_task(write_to_store(array, x, y, barrier), name=f"write {x},{y}") + _write_task = tg.create_task( + write_to_store(array, x, y, barrier), name=f"write {x},{y}" + ) - _res=await store.commit("commit") + _res = await store.commit("commit") array = group["array"] assert isinstance(array, zarr.Array) for x in range(N): for y in range(N): - assert array[x,y] == x*y - + assert array[x, y] == x * y # FIXME: add assertions print("done") diff --git a/icechunk-python/tests/test_config.py b/icechunk-python/tests/test_config.py index 2980dfb8..b9d23aad 100644 --- a/icechunk-python/tests/test_config.py +++ b/icechunk-python/tests/test_config.py @@ -7,6 +7,7 @@ STORE_PATH = "/tmp/icechunk_config_test" + @pytest.fixture async def store(): store = await icechunk.IcechunkStore.open( diff --git a/icechunk-python/tests/test_virtual_ref.py b/icechunk-python/tests/test_virtual_ref.py index 31b97651..1264d517 100644 --- a/icechunk-python/tests/test_virtual_ref.py +++ b/icechunk-python/tests/test_virtual_ref.py @@ -1,5 +1,11 @@ from object_store import ClientOptions, ObjectStore -from icechunk import IcechunkStore, StorageConfig, S3Credentials +from icechunk import ( + IcechunkStore, + StorageConfig, + StoreConfig, + S3Credentials, + VirtualRefConfig, +) import zarr import zarr.core import zarr.core.buffer @@ -24,7 +30,7 @@ def write_chunks_to_minio(chunks: list[tuple[str, bytes]]): store.put(key, data) -async def test_write_virtual_refs(): +async def test_write_minino_virtual_refs(): write_chunks_to_minio( [ ("path/to/python/chunk-1", b"first"), @@ -34,7 +40,7 @@ async def test_write_virtual_refs(): # Open the store, the S3 credentials must be set in environment vars for this to work for now store = await IcechunkStore.open( - storage=StorageConfig.s3_from_credentials( + storage=StorageConfig.s3_from_config( bucket="testbucket", prefix="python-virtual-ref", credentials=S3Credentials( @@ -42,8 +48,19 @@ async def test_write_virtual_refs(): secret_access_key="minio123", ), endpoint_url="http://localhost:9000", + allow_http=True, ), mode="r+", + config=StoreConfig( + virtual_ref_config=VirtualRefConfig.s3_from_config( + credentials=S3Credentials( + access_key_id="minio123", + secret_access_key="minio123", + ), + endpoint_url="http://localhost:9000", + allow_http=True, + ) + ), ) array = zarr.Array.create(store, shape=(1, 1, 2), chunk_shape=(1, 1, 1), dtype="i4") diff --git a/icechunk-python/tests/test_zarr/test_api.py b/icechunk-python/tests/test_zarr/test_api.py index 8defe386..7a1fafb3 100644 --- a/icechunk-python/tests/test_zarr/test_api.py +++ b/icechunk-python/tests/test_zarr/test_api.py @@ -8,10 +8,19 @@ import zarr from zarr import Array, Group from zarr.abc.store import Store -from zarr.api.synchronous import create, load, open, open_group, save, save_array, save_group +from zarr.api.synchronous import ( + create, + load, + open, + open_group, + save, + save_array, + save_group, +) from ..conftest import parse_store + @pytest.fixture(scope="function") async def memory_store() -> IcechunkStore: return await parse_store("memory", "") @@ -48,7 +57,7 @@ async def test_open_array(memory_store: IcechunkStore) -> None: # open array, overwrite # _store_dict wont currently work with IcechunkStore - # TODO: Should it? + # TODO: Should it? pytest.xfail("IcechunkStore does not support _store_dict") store._store_dict = {} z = open(store=store, shape=200, mode="w") # mode="w" @@ -59,7 +68,7 @@ async def test_open_array(memory_store: IcechunkStore) -> None: store_cls = type(store) # _store_dict wont currently work with IcechunkStore - # TODO: Should it? + # TODO: Should it? ro_store = await store_cls.open(store_dict=store._store_dict, mode="r") z = open(store=ro_store) @@ -89,7 +98,7 @@ async def test_open_group(memory_store: IcechunkStore) -> None: # open group, read-only store_cls = type(store) # _store_dict wont currently work with IcechunkStore - # TODO: Should it? + # TODO: Should it? pytest.xfail("IcechunkStore does not support _store_dict") ro_store = await store_cls.open(store_dict=store._store_dict, mode="r") g = open_group(store=ro_store) @@ -139,7 +148,7 @@ async def test_open_with_mode_a(tmp_path: pathlib.Path) -> None: g = zarr.open(store=tmp_path, mode="a") assert isinstance(g, Group) await g.store_path.delete() - + # 'a' means read/write (create if doesn't exist) arr = zarr.open(store=tmp_path, mode="a", shape=(3, 3)) assert isinstance(arr, Array) diff --git a/icechunk-python/tests/test_zarr/test_array.py b/icechunk-python/tests/test_zarr/test_array.py index 15cdde25..e7dee8b4 100644 --- a/icechunk-python/tests/test_zarr/test_array.py +++ b/icechunk-python/tests/test_zarr/test_array.py @@ -63,6 +63,7 @@ def test_array_creation_existing_node( zarr_format=zarr_format, ) + @pytest.mark.parametrize("store", ["memory"], indirect=["store"]) @pytest.mark.parametrize("zarr_format", [3]) async def test_create_creates_parents( @@ -109,7 +110,9 @@ async def test_create_creates_parents( def test_array_name_properties_no_group( store: IcechunkStore, zarr_format: ZarrFormat ) -> None: - arr = Array.create(store=store, shape=(100,), chunks=(10,), zarr_format=zarr_format, dtype="i4") + arr = Array.create( + store=store, shape=(100,), chunks=(10,), zarr_format=zarr_format, dtype="i4" + ) assert arr.path == "" assert arr.name is None assert arr.basename is None @@ -169,7 +172,9 @@ def test_array_v3_fill_value_default( ("dtype_str", "fill_value"), [("bool", True), ("uint8", 99), ("float32", -99.9), ("complex64", 3 + 4j)], ) -def test_array_v3_fill_value(store: IcechunkStore, fill_value: int, dtype_str: str) -> None: +def test_array_v3_fill_value( + store: IcechunkStore, fill_value: int, dtype_str: str +) -> None: shape = (10,) arr = Array.create( store=store, diff --git a/icechunk-python/tests/test_zarr/test_group.py b/icechunk-python/tests/test_zarr/test_group.py index 7e556fb5..90118728 100644 --- a/icechunk-python/tests/test_zarr/test_group.py +++ b/icechunk-python/tests/test_zarr/test_group.py @@ -28,7 +28,9 @@ async def store(request: pytest.FixtureRequest, tmpdir: LEGACY_PATH) -> IcechunkStore: result = await parse_store(request.param, str(tmpdir)) if not isinstance(result, IcechunkStore): - raise TypeError("Wrong store class returned by test fixture! got " + result + " instead") + raise TypeError( + "Wrong store class returned by test fixture! got " + result + " instead" + ) return result @@ -57,13 +59,17 @@ def test_group_init(store: IcechunkStore, zarr_format: ZarrFormat) -> None: assert group._async_group == agroup -async def test_create_creates_parents(store: IcechunkStore, zarr_format: ZarrFormat) -> None: +async def test_create_creates_parents( + store: IcechunkStore, zarr_format: ZarrFormat +) -> None: # prepare a root node, with some data set await zarr.api.asynchronous.open_group( store=store, path="a", zarr_format=zarr_format, attributes={"key": "value"} ) # create a child node with a couple intermediates - await zarr.api.asynchronous.open_group(store=store, path="a/b/c/d", zarr_format=zarr_format) + await zarr.api.asynchronous.open_group( + store=store, path="a/b/c/d", zarr_format=zarr_format + ) parts = ["a", "a/b", "a/b/c"] if zarr_format == 2: @@ -146,9 +152,12 @@ def test_group_members(store: IcechunkStore, zarr_format: ZarrFormat) -> None: # the list of children should ignore this object. with pytest.raises(ValueError): sync( - store.set(f"{path}/extra_object-1", default_buffer_prototype().buffer.from_bytes(b"000000")) + store.set( + f"{path}/extra_object-1", + default_buffer_prototype().buffer.from_bytes(b"000000"), + ) ) - + # This is not supported by Icechunk, so we expect an error # zarr-python: add an extra object under a directory-like prefix in the domain of the group. # this creates a directory with a random key in it @@ -185,7 +194,9 @@ def test_group(store: IcechunkStore, zarr_format: ZarrFormat) -> None: Test basic Group routines. """ store_path = StorePath(store) - agroup = AsyncGroup(metadata=GroupMetadata(zarr_format=zarr_format), store_path=store_path) + agroup = AsyncGroup( + metadata=GroupMetadata(zarr_format=zarr_format), store_path=store_path + ) group = Group(agroup) assert agroup.metadata is group.metadata assert agroup.store_path == group.store_path == store_path @@ -229,14 +240,19 @@ def test_group_create( Test that `Group.create` works as expected. """ attributes = {"foo": 100} - group = Group.from_store(store, attributes=attributes, zarr_format=zarr_format, exists_ok=exists_ok) + group = Group.from_store( + store, attributes=attributes, zarr_format=zarr_format, exists_ok=exists_ok + ) assert group.attrs == attributes if not exists_ok: with pytest.raises(ContainsGroupError): group = Group.from_store( - store, attributes=attributes, exists_ok=exists_ok, zarr_format=zarr_format + store, + attributes=attributes, + exists_ok=exists_ok, + zarr_format=zarr_format, ) @@ -264,7 +280,9 @@ def test_group_open( new_attrs = {"path": "bar"} if not exists_ok: with pytest.raises(ContainsGroupError): - Group.from_store(store, attributes=attrs, zarr_format=zarr_format, exists_ok=exists_ok) + Group.from_store( + store, attributes=attrs, zarr_format=zarr_format, exists_ok=exists_ok + ) else: group_created_again = Group.from_store( store, attributes=new_attrs, zarr_format=zarr_format, exists_ok=exists_ok @@ -460,7 +478,9 @@ def test_group_creation_existing_node( if extant_node == "array": expected_exception = ContainsArrayError - _ = group.create_array("extant", shape=(10,), dtype="uint8", attributes=attributes) + _ = group.create_array( + "extant", shape=(10,), dtype="uint8", attributes=attributes + ) elif extant_node == "group": expected_exception = ContainsGroupError _ = group.create_group("extant", attributes=attributes) @@ -505,7 +525,9 @@ async def test_asyncgroup_create( zarr_format=zarr_format, ) - assert agroup.metadata == GroupMetadata(zarr_format=zarr_format, attributes=attributes) + assert agroup.metadata == GroupMetadata( + zarr_format=zarr_format, attributes=attributes + ) assert agroup.store_path == await make_store_path(store) if not exists_ok: @@ -532,7 +554,9 @@ async def test_asyncgroup_create( async def test_asyncgroup_attrs(store: IcechunkStore, zarr_format: ZarrFormat) -> None: attributes = {"foo": 100} - agroup = await AsyncGroup.from_store(store, zarr_format=zarr_format, attributes=attributes) + agroup = await AsyncGroup.from_store( + store, zarr_format=zarr_format, attributes=attributes + ) assert agroup.attrs == agroup.metadata.attributes == attributes @@ -571,7 +595,9 @@ async def test_asyncgroup_open_wrong_format( store: IcechunkStore, zarr_format: ZarrFormat, ) -> None: - _ = await AsyncGroup.from_store(store=store, exists_ok=False, zarr_format=zarr_format) + _ = await AsyncGroup.from_store( + store=store, exists_ok=False, zarr_format=zarr_format + ) zarr_format_wrong: ZarrFormat # try opening with the wrong zarr format if zarr_format == 3: @@ -608,7 +634,9 @@ def test_asyncgroup_from_dict(store: IcechunkStore, data: dict[str, Any]) -> Non # todo: replace this with a declarative API where we model a full hierarchy -async def test_asyncgroup_getitem(store: IcechunkStore, zarr_format: ZarrFormat) -> None: +async def test_asyncgroup_getitem( + store: IcechunkStore, zarr_format: ZarrFormat +) -> None: """ Create an `AsyncGroup`, then create members of that group, and ensure that we can access those members via the `AsyncGroup.getitem` method. @@ -630,11 +658,17 @@ async def test_asyncgroup_getitem(store: IcechunkStore, zarr_format: ZarrFormat) await agroup.getitem("foo") -async def test_asyncgroup_delitem(store: IcechunkStore, zarr_format: ZarrFormat) -> None: +async def test_asyncgroup_delitem( + store: IcechunkStore, zarr_format: ZarrFormat +) -> None: agroup = await AsyncGroup.from_store(store=store, zarr_format=zarr_format) array_name = "sub_array" _ = await agroup.create_array( - name=array_name, shape=(10,), dtype="uint8", chunk_shape=(2,), attributes={"foo": 100} + name=array_name, + shape=(10,), + dtype="uint8", + chunk_shape=(2,), + attributes={"foo": 100}, ) await agroup.delitem(array_name) @@ -712,7 +746,7 @@ async def test_asyncgroup_create_array( assert subnode.dtype == dtype # todo: fix the type annotation of array.metadata.chunk_grid so that we get some autocomplete # here. - assert subnode.metadata.chunk_grid.chunk_shape == chunk_shape # type: ignore + assert subnode.metadata.chunk_grid.chunk_shape == chunk_shape # type: ignore assert subnode.metadata.zarr_format == zarr_format @@ -767,7 +801,9 @@ async def test_group_members_async(store: IcechunkStore) -> None: assert nmembers == 4 # all children - all_children = sorted([x async for x in group.members(max_depth=None)], key=lambda x: x[0]) + all_children = sorted( + [x async for x in group.members(max_depth=None)], key=lambda x: x[0] + ) expected = [ ("a0", a0), ("g0", g0), @@ -850,7 +886,9 @@ async def test_create_dataset(store: IcechunkStore, zarr_format: ZarrFormat) -> async def test_require_array(store: IcechunkStore, zarr_format: ZarrFormat) -> None: root = await AsyncGroup.from_store(store=store, zarr_format=zarr_format) - foo1 = await root.require_array("foo", shape=(10,), dtype="i8", attributes={"foo": 101}) + foo1 = await root.require_array( + "foo", shape=(10,), dtype="i8", attributes={"foo": 101} + ) assert foo1.attrs == {"foo": 101} foo2 = await root.require_array("foo", shape=(10,), dtype="i8") assert foo2.attrs == {"foo": 101} diff --git a/icechunk/src/format/manifest.rs b/icechunk/src/format/manifest.rs index ef7862a1..0f0bdcc1 100644 --- a/icechunk/src/format/manifest.rs +++ b/icechunk/src/format/manifest.rs @@ -49,6 +49,7 @@ impl VirtualChunkLocation { // make sure we can parse the provided URL before creating the enum // TODO: consider other validation here. let url = url::Url::parse(path)?; + let scheme = url.scheme(); let new_path: String = url .path_segments() .ok_or(VirtualReferenceError::NoPathSegments(path.into()))? @@ -56,15 +57,17 @@ impl VirtualChunkLocation { .filter(|x| !x.is_empty()) .join("/"); - let host = url - .host() - .ok_or_else(|| VirtualReferenceError::CannotParseBucketName(path.into()))?; - Ok(VirtualChunkLocation::Absolute(format!( - "{}://{}/{}", - url.scheme(), - host, - new_path, - ))) + let host = if let Some(host) = url.host() { + host.to_string() + } else if scheme == "file" { + "".to_string() + } else { + return Err(VirtualReferenceError::CannotParseBucketName(path.into())); + }; + + let location = format!("{}://{}/{}", scheme, host, new_path,); + + Ok(VirtualChunkLocation::Absolute(location)) } } diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index f48f5e5a..f0db228d 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -9,7 +9,10 @@ use std::{ use crate::{ format::{manifest::VirtualReferenceError, ManifestId, SnapshotId}, - storage::virtual_ref::{construct_valid_byte_range, VirtualChunkResolver}, + storage::virtual_ref::{ + construct_valid_byte_range, ObjectStoreVirtualChunkResolverConfig, + VirtualChunkResolver, + }, }; pub use crate::{ format::{ @@ -215,11 +218,17 @@ pub struct RepositoryBuilder { config: RepositoryConfig, storage: Arc, snapshot_id: SnapshotId, + virtual_ref_config: Option, } impl RepositoryBuilder { fn new(storage: Arc, snapshot_id: SnapshotId) -> Self { - Self { config: RepositoryConfig::default(), snapshot_id, storage } + Self { + config: RepositoryConfig::default(), + snapshot_id, + storage, + virtual_ref_config: None, + } } pub fn with_inline_threshold_bytes(&mut self, threshold: u16) -> &mut Self { @@ -237,11 +246,20 @@ impl RepositoryBuilder { self } + pub fn with_virtual_ref_config( + &mut self, + config: ObjectStoreVirtualChunkResolverConfig, + ) -> &mut Self { + self.virtual_ref_config = Some(config); + self + } + pub fn build(&self) -> Repository { Repository::new( self.config.clone(), self.storage.clone(), self.snapshot_id.clone(), + self.virtual_ref_config.clone(), ) } } @@ -353,6 +371,7 @@ impl Repository { config: RepositoryConfig, storage: Arc, snapshot_id: SnapshotId, + virtual_ref_config: Option, ) -> Self { Repository { snapshot_id, @@ -360,7 +379,9 @@ impl Repository { storage, last_node_id: None, change_set: ChangeSet::default(), - virtual_resolver: Arc::new(ObjectStoreVirtualChunkResolver::default()), + virtual_resolver: Arc::new(ObjectStoreVirtualChunkResolver::new( + virtual_ref_config, + )), } } @@ -637,6 +658,7 @@ impl Repository { // TODO: I hate rust forces me to clone to search in a hashmap. How to do better? let session_chunk = self.change_set.get_chunk_ref(node.id, coords).cloned(); + // If session_chunk is not None we have to return it, because is the update the // user made in the current session // If session_chunk == None, user hasn't modified the chunk in this session and we diff --git a/icechunk/src/storage/object_store.rs b/icechunk/src/storage/object_store.rs index c48b597e..829d47c8 100644 --- a/icechunk/src/storage/object_store.rs +++ b/icechunk/src/storage/object_store.rs @@ -7,9 +7,11 @@ use bytes::Bytes; use core::fmt; use futures::{stream::BoxStream, StreamExt, TryStreamExt}; use object_store::{ - aws::S3ConditionalPut, local::LocalFileSystem, memory::InMemory, - path::Path as ObjectPath, GetOptions, GetRange, ObjectStore, PutMode, PutOptions, - PutPayload, + aws::{AmazonS3Builder, S3ConditionalPut}, + local::LocalFileSystem, + memory::InMemory, + path::Path as ObjectPath, + GetOptions, GetRange, ObjectStore, PutMode, PutOptions, PutPayload, }; use serde::{Deserialize, Serialize}; use std::{ @@ -64,6 +66,51 @@ pub struct S3Credentials { pub session_token: Option, } +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct S3Config { + pub region: Option, + pub endpoint: Option, + pub credentials: Option, + pub allow_http: Option, +} + +// TODO: Hide this behind a feature flag? +impl S3Config { + pub fn to_builder(&self) -> AmazonS3Builder { + let builder = if let Some(credentials) = &self.credentials { + let builder = AmazonS3Builder::new() + .with_access_key_id(credentials.access_key_id.clone()) + .with_secret_access_key(credentials.secret_access_key.clone()); + + if let Some(token) = &credentials.session_token { + builder.with_token(token.clone()) + } else { + builder + } + } else { + AmazonS3Builder::from_env() + }; + + let builder = if let Some(region) = &self.region { + builder.with_region(region.clone()) + } else { + builder + }; + + let builder = if let Some(endpoint) = &self.endpoint { + builder.with_endpoint(endpoint.clone()) + } else { + builder + }; + + if let Some(allow_http) = self.allow_http { + builder.with_allow_http(allow_http) + } else { + builder + } + } +} + pub struct ObjectStorage { store: Arc, prefix: String, @@ -108,33 +155,11 @@ impl ObjectStorage { pub fn new_s3_store( bucket_name: impl Into, prefix: impl Into, - credentials: Option, - endpoint: Option>, + config: Option, ) -> Result { - use object_store::aws::AmazonS3Builder; - - let builder = if let Some(credentials) = credentials { - let builder = AmazonS3Builder::new() - .with_access_key_id(credentials.access_key_id) - .with_secret_access_key(credentials.secret_access_key); - - if let Some(token) = credentials.session_token { - builder.with_token(token) - } else { - builder - } - } else { - AmazonS3Builder::from_env() - }; - - let builder = if let Some(endpoint) = endpoint { - builder.with_endpoint(endpoint).with_allow_http(true) - } else { - builder - }; - + let config = config.unwrap_or_default(); + let builder = config.to_builder(); let builder = builder.with_conditional_put(S3ConditionalPut::ETagMatch); - let store = builder.with_bucket_name(bucket_name.into()).build()?; Ok(ObjectStorage { store: Arc::new(store), diff --git a/icechunk/src/storage/virtual_ref.rs b/icechunk/src/storage/virtual_ref.rs index 70ad8b9c..e4b378bf 100644 --- a/icechunk/src/storage/virtual_ref.rs +++ b/icechunk/src/storage/virtual_ref.rs @@ -2,9 +2,9 @@ use crate::format::manifest::{VirtualChunkLocation, VirtualReferenceError}; use crate::format::ByteRange; use async_trait::async_trait; use bytes::Bytes; -use object_store::{ - aws::AmazonS3Builder, path::Path as ObjectPath, GetOptions, GetRange, ObjectStore, -}; +use object_store::local::LocalFileSystem; +use object_store::{path::Path as ObjectPath, GetOptions, GetRange, ObjectStore}; +use serde::{Deserialize, Serialize}; use std::cmp::{max, min}; use std::collections::HashMap; use std::fmt::Debug; @@ -13,6 +13,8 @@ use std::sync::Arc; use tokio::sync::RwLock; use url; +use super::object_store::S3Config; + #[async_trait] pub trait VirtualChunkResolver: Debug { async fn fetch_chunk( @@ -25,9 +27,21 @@ pub trait VirtualChunkResolver: Debug { #[derive(PartialEq, Eq, Hash, Clone, Debug)] struct StoreCacheKey(String, String); +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum ObjectStoreVirtualChunkResolverConfig { + S3(S3Config), +} + #[derive(Debug, Default)] pub struct ObjectStoreVirtualChunkResolver { stores: RwLock>>, + config: Option, +} + +impl ObjectStoreVirtualChunkResolver { + pub fn new(config: Option) -> Self { + Self { stores: RwLock::new(HashMap::new()), config } + } } // Converts the requested ByteRange to a valid ByteRange appropriate @@ -70,40 +84,63 @@ impl VirtualChunkResolver for ObjectStoreVirtualChunkResolver { let VirtualChunkLocation::Absolute(location) = location; let parsed = url::Url::parse(location).map_err(VirtualReferenceError::CannotParseUrl)?; - let bucket_name = parsed - .host_str() - .ok_or(VirtualReferenceError::CannotParseBucketName( - "error parsing bucket name".into(), - ))? - .to_string(); let path = ObjectPath::parse(parsed.path()) .map_err(|e| VirtualReferenceError::OtherError(Box::new(e)))?; let scheme = parsed.scheme(); + + let bucket_name = if let Some(host) = parsed.host_str() { + host.to_string() + } else if scheme == "file" { + // Host is not required for file scheme, if it is not there, + // we can assume the bucket name is empty and it is a local file + "".to_string() + } else { + Err(VirtualReferenceError::CannotParseBucketName( + "No bucket name found".to_string(), + ))? + }; + let cache_key = StoreCacheKey(scheme.into(), bucket_name); let options = GetOptions { range: Option::::from(range), ..Default::default() }; let store = { let stores = self.stores.read().await; - #[allow(clippy::expect_used)] stores.get(&cache_key).cloned() }; let store = match store { Some(store) => store, None => { - let builder = match scheme { + let new_store: Arc = match scheme { + "file" => { + let fs = LocalFileSystem::new(); + Arc::new(fs) + } // FIXME: allow configuring auth for virtual references - "s3" => AmazonS3Builder::from_env(), + "s3" => { + let config = if let Some( + ObjectStoreVirtualChunkResolverConfig::S3(config), + ) = &self.config + { + config.clone() + } else { + S3Config::default() + }; + + let s3 = config + .to_builder() + .with_bucket_name(&cache_key.1) + .build() + .map_err(|e| { + VirtualReferenceError::FetchError(Box::new(e)) + })?; + + Arc::new(s3) + } _ => { Err(VirtualReferenceError::UnsupportedScheme(scheme.to_string()))? } }; - let new_store: Arc = Arc::new( - builder - .with_bucket_name(&cache_key.1) - .build() - .map_err(|e| VirtualReferenceError::FetchError(Box::new(e)))?, - ); { self.stores .write() diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index 21dd1040..2509c666 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -31,7 +31,9 @@ use crate::{ Codec, DataType, DimensionNames, FillValue, Path, RepositoryError, StorageTransformer, UserAttributes, ZarrArrayMetadata, }, - storage::object_store::S3Credentials, + storage::{ + object_store::S3Config, virtual_ref::ObjectStoreVirtualChunkResolverConfig, + }, ObjectStorage, Repository, RepositoryBuilder, SnapshotMetadata, Storage, }; @@ -50,8 +52,8 @@ pub enum StorageConfig { S3ObjectStore { bucket: String, prefix: String, - credentials: Option, - endpoint: Option, + #[serde(flatten)] + config: Option, }, } @@ -66,14 +68,9 @@ impl StorageConfig { .map_err(|e| format!("Error creating storage: {e}"))?; Ok(Arc::new(storage)) } - StorageConfig::S3ObjectStore { bucket, prefix, credentials, endpoint } => { - let storage = ObjectStorage::new_s3_store( - bucket, - prefix, - credentials.clone(), - endpoint.clone(), - ) - .map_err(|e| format!("Error creating storage: {e}"))?; + StorageConfig::S3ObjectStore { bucket, prefix, config } => { + let storage = ObjectStorage::new_s3_store(bucket, prefix, config.clone()) + .map_err(|e| format!("Error creating storage: {e}"))?; Ok(Arc::new(storage)) } } @@ -102,6 +99,7 @@ pub struct RepositoryConfig { pub version: Option, pub inline_chunk_threshold_bytes: Option, pub unsafe_overwrite_refs: Option, + pub virtual_ref_config: Option, } impl RepositoryConfig { @@ -128,6 +126,14 @@ impl RepositoryConfig { self } + pub fn with_virtual_ref_credentials( + mut self, + config: ObjectStoreVirtualChunkResolverConfig, + ) -> Self { + self.virtual_ref_config = Some(config); + self + } + pub async fn make_repository( &self, storage: Arc, @@ -167,6 +173,9 @@ impl RepositoryConfig { if let Some(value) = self.unsafe_overwrite_refs { builder.with_unsafe_overwrite_refs(value); } + if let Some(config) = &self.virtual_ref_config { + builder.with_virtual_ref_config(config.clone()); + } // TODO: add error checking, does the previous version exist? Ok((builder.build(), branch)) @@ -1212,6 +1221,8 @@ mod tests { use std::borrow::BorrowMut; + use crate::storage::object_store::S3Credentials; + use super::*; use pretty_assertions::assert_eq; @@ -2050,6 +2061,7 @@ mod tests { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, ]))), unsafe_overwrite_refs: Some(true), + virtual_ref_config: None, }, config: Some(StoreOptions { get_partial_values_concurrency: 100 }), }; @@ -2083,6 +2095,7 @@ mod tests { version: None, inline_chunk_threshold_bytes: None, unsafe_overwrite_refs: None, + virtual_ref_config: None, }, config: None, ..expected.clone() @@ -2102,6 +2115,7 @@ mod tests { version: None, inline_chunk_threshold_bytes: None, unsafe_overwrite_refs: None, + virtual_ref_config: None, }, config: None, ..expected.clone() @@ -2120,6 +2134,7 @@ mod tests { version: None, inline_chunk_threshold_bytes: None, unsafe_overwrite_refs: None, + virtual_ref_config: None, }, storage: StorageConfig::InMemory { prefix: Some("prefix".to_string()) }, config: None, @@ -2138,6 +2153,7 @@ mod tests { version: None, inline_chunk_threshold_bytes: None, unsafe_overwrite_refs: None, + virtual_ref_config: None, }, storage: StorageConfig::InMemory { prefix: None }, config: None, @@ -2156,12 +2172,17 @@ mod tests { version: None, inline_chunk_threshold_bytes: None, unsafe_overwrite_refs: None, + virtual_ref_config: None, }, storage: StorageConfig::S3ObjectStore { bucket: String::from("test"), prefix: String::from("root"), - credentials: None, - endpoint: None + config: Some(S3Config { + endpoint: None, + credentials: None, + allow_http: None, + region: None + }), }, config: None, }, @@ -2177,7 +2198,8 @@ mod tests { "access_key_id":"my-key", "secret_access_key":"my-secret-key" }, - "endpoint":"http://localhost:9000" + "endpoint":"http://localhost:9000", + "allow_http": true }, "repository": {} } @@ -2188,16 +2210,21 @@ mod tests { version: None, inline_chunk_threshold_bytes: None, unsafe_overwrite_refs: None, + virtual_ref_config: None, }, storage: StorageConfig::S3ObjectStore { bucket: String::from("test"), prefix: String::from("root"), - credentials: Some(S3Credentials { - access_key_id: String::from("my-key"), - secret_access_key: String::from("my-secret-key"), - session_token: None, - }), - endpoint: Some(String::from("http://localhost:9000")) + config: Some(S3Config { + region: None, + endpoint: Some(String::from("http://localhost:9000")), + credentials: Some(S3Credentials { + access_key_id: String::from("my-key"), + secret_access_key: String::from("my-secret-key"), + session_token: None, + }), + allow_http: Some(true), + }) }, config: None, }, diff --git a/icechunk/tests/test_virtual_refs.rs b/icechunk/tests/test_virtual_refs.rs index 6d18d0e2..e5ff2e3b 100644 --- a/icechunk/tests/test_virtual_refs.rs +++ b/icechunk/tests/test_virtual_refs.rs @@ -8,42 +8,84 @@ mod tests { }, metadata::{ChunkKeyEncoding, ChunkShape, DataType, FillValue}, repository::{get_chunk, ChunkPayload, ZarrArrayMetadata}, - storage::{object_store::S3Credentials, ObjectStorage}, + storage::{ + object_store::{S3Config, S3Credentials}, + ObjectStorage, + }, zarr::AccessMode, Repository, Storage, Store, }; - use std::sync::Arc; use std::{error::Error, num::NonZeroU64, path::PathBuf}; + use std::{path::Path, sync::Arc}; + use tempfile::TempDir; use bytes::Bytes; - use object_store::{ObjectStore, PutMode, PutOptions, PutPayload}; + use object_store::{ + local::LocalFileSystem, ObjectStore, PutMode, PutOptions, PutPayload, + }; use pretty_assertions::assert_eq; + async fn create_repository(storage: Arc) -> Repository { + Repository::init(storage, true).await.expect("building repository failed").build() + } + + async fn write_chunks_to_store( + store: impl ObjectStore, + chunks: impl Iterator, + ) { + // TODO: Switch to PutMode::Create when object_store supports that + let opts = PutOptions { mode: PutMode::Overwrite, ..PutOptions::default() }; + + for (path, bytes) in chunks { + store + .put_opts( + &path.clone().into(), + PutPayload::from_bytes(bytes.clone()), + opts.clone(), + ) + .await + .expect(&format!("putting chunk to {} failed", &path)); + } + } + async fn create_local_repository(path: &Path) -> Repository { + let storage: Arc = Arc::new( + ObjectStorage::new_local_store(path).expect("Creating local storage failed"), + ); + + create_repository(storage).await + } + async fn create_minio_repository() -> Repository { let storage: Arc = Arc::new( ObjectStorage::new_s3_store( "testbucket".to_string(), format!("{:?}", ChunkId::random()), - Some(S3Credentials { - access_key_id: "minio123".into(), - secret_access_key: "minio123".into(), - session_token: None, + Some(S3Config { + region: None, + endpoint: Some("http://localhost:9000".to_string()), + credentials: Some(S3Credentials { + access_key_id: "minio123".into(), + secret_access_key: "minio123".into(), + session_token: None, + }), + allow_http: Some(true), }), - Some("http://localhost:9000"), ) .expect("Creating minio storage failed"), ); - Repository::init(Arc::clone(&storage), true) - .await - .expect("building repository failed") - .build() + + create_repository(storage).await + } + + async fn write_chunks_to_local_fs(chunks: impl Iterator) { + let store = + LocalFileSystem::new_with_prefix("/").expect("Failed to create local store"); + write_chunks_to_store(store, chunks).await; } async fn write_chunks_to_minio(chunks: impl Iterator) { use object_store::aws::AmazonS3Builder; let bucket_name = "testbucket".to_string(); - // TODO: Switch to PutMode::Create when object_store supports that - let opts = PutOptions { mode: PutMode::Overwrite, ..PutOptions::default() }; let store = AmazonS3Builder::new() .with_access_key_id("minio123") @@ -54,20 +96,123 @@ mod tests { .build() .expect("building S3 store failed"); - for (path, bytes) in chunks { - store - .put_opts( - &path.clone().into(), - PutPayload::from_bytes(bytes.clone()), - opts.clone(), + write_chunks_to_store(store, chunks).await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_repository_with_local_virtual_refs() -> Result<(), Box> { + let chunk_dir = TempDir::new()?; + let chunk_1 = chunk_dir.path().join("chunk-1").to_str().unwrap().to_owned(); + let chunk_2 = chunk_dir.path().join("chunk-2").to_str().unwrap().to_owned(); + + let bytes1 = Bytes::copy_from_slice(b"first"); + let bytes2 = Bytes::copy_from_slice(b"second0000"); + let chunks = [(chunk_1, bytes1.clone()), (chunk_2, bytes2.clone())]; + write_chunks_to_local_fs(chunks.iter().cloned()).await; + + let repo_dir = TempDir::new()?; + let mut ds = create_local_repository(&repo_dir.path()).await; + + let zarr_meta = ZarrArrayMetadata { + shape: vec![1, 1, 2], + data_type: DataType::Int32, + chunk_shape: ChunkShape(vec![NonZeroU64::new(2).unwrap()]), + chunk_key_encoding: ChunkKeyEncoding::Slash, + fill_value: FillValue::Int32(0), + codecs: vec![], + storage_transformers: None, + dimension_names: None, + }; + let payload1 = ChunkPayload::Virtual(VirtualChunkRef { + location: VirtualChunkLocation::from_absolute_path(&format!( + // intentional extra '/' + "file://{}", + chunks[0].0 + ))?, + offset: 0, + length: 5, + }); + let payload2 = ChunkPayload::Virtual(VirtualChunkRef { + location: VirtualChunkLocation::from_absolute_path(&format!( + "file://{}", + chunks[1].0, + ))?, + offset: 1, + length: 5, + }); + + let new_array_path: PathBuf = "/array".to_string().into(); + ds.add_array(new_array_path.clone(), zarr_meta.clone()).await.unwrap(); + + ds.set_chunk_ref( + new_array_path.clone(), + ChunkIndices(vec![0, 0, 0]), + Some(payload1), + ) + .await + .unwrap(); + ds.set_chunk_ref( + new_array_path.clone(), + ChunkIndices(vec![0, 0, 1]), + Some(payload2), + ) + .await + .unwrap(); + + assert_eq!( + get_chunk( + ds.get_chunk_reader( + &new_array_path, + &ChunkIndices(vec![0, 0, 0]), + &ByteRange::ALL ) .await - .expect(&format!("putting chunk to {} failed", &path)); + .unwrap() + ) + .await + .unwrap(), + Some(bytes1.clone()), + ); + assert_eq!( + get_chunk( + ds.get_chunk_reader( + &new_array_path, + &ChunkIndices(vec![0, 0, 1]), + &ByteRange::ALL + ) + .await + .unwrap() + ) + .await + .unwrap(), + Some(Bytes::copy_from_slice(&bytes2[1..6])), + ); + + for range in [ + ByteRange::bounded(0u64, 3u64), + ByteRange::from_offset(2u64), + ByteRange::to_offset(4u64), + ] { + assert_eq!( + get_chunk( + ds.get_chunk_reader( + &new_array_path, + &ChunkIndices(vec![0, 0, 0]), + &range + ) + .await + .unwrap() + ) + .await + .unwrap(), + Some(range.slice(bytes1.clone())) + ); } + Ok(()) } #[tokio::test(flavor = "multi_thread")] - async fn test_repository_with_virtual_refs() -> Result<(), Box> { + async fn test_repository_with_minio_virtual_refs() -> Result<(), Box> { let bytes1 = Bytes::copy_from_slice(b"first"); let bytes2 = Bytes::copy_from_slice(b"second0000"); let chunks = [ @@ -177,7 +322,7 @@ mod tests { } #[tokio::test] - async fn test_zarr_store_virtual_refs_set_and_get( + async fn test_zarr_store_virtual_refs_minio_set_and_get( ) -> Result<(), Box> { let bytes1 = Bytes::copy_from_slice(b"first"); let bytes2 = Bytes::copy_from_slice(b"second0000"); From 50e1220ed62a8088a003bbb554dc9da166b45cd0 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Tue, 1 Oct 2024 22:05:56 -0300 Subject: [PATCH 009/167] No metadata for Local file storage --- icechunk/src/storage/object_store.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/icechunk/src/storage/object_store.rs b/icechunk/src/storage/object_store.rs index ba607886..7374e9df 100644 --- a/icechunk/src/storage/object_store.rs +++ b/icechunk/src/storage/object_store.rs @@ -121,6 +121,7 @@ pub struct ObjectStorage { artificially_sort_refs_in_mem: bool, supports_create_if_not_exists: bool, + supports_metadata: bool, } impl ObjectStorage { @@ -136,6 +137,7 @@ impl ObjectStorage { prefix, artificially_sort_refs_in_mem: false, supports_create_if_not_exists: true, + supports_metadata: true, } } @@ -151,6 +153,7 @@ impl ObjectStorage { prefix: "".to_string(), artificially_sort_refs_in_mem: true, supports_create_if_not_exists: true, + supports_metadata: false, }) } @@ -168,6 +171,7 @@ impl ObjectStorage { prefix: prefix.into(), artificially_sort_refs_in_mem: false, supports_create_if_not_exists: true, + supports_metadata: true, }) } @@ -264,8 +268,8 @@ impl Storage for ObjectStorage { ) -> Result<(), StorageError> { let path = self.get_snapshot_path(&id); let bytes = rmp_serde::to_vec(snapshot.as_ref())?; - let options = PutOptions { - attributes: Attributes::from_iter(vec![ + let attributes = if self.supports_metadata { + Attributes::from_iter(vec![ ( Attribute::ContentType, AttributeValue::from( @@ -280,9 +284,11 @@ impl Storage for ObjectStorage { snapshot.icechunk_snapshot_format_version.to_string(), ), ), - ]), - ..PutOptions::default() + ]) + } else { + Attributes::new() }; + let options = PutOptions { attributes, ..PutOptions::default() }; // FIXME: use multipart self.store.put_opts(&path, bytes.into(), options).await?; Ok(()) @@ -303,8 +309,8 @@ impl Storage for ObjectStorage { ) -> Result<(), StorageError> { let path = self.get_manifest_path(&id); let bytes = rmp_serde::to_vec(manifest.as_ref())?; - let options = PutOptions { - attributes: Attributes::from_iter(vec![ + let attributes = if self.supports_metadata { + Attributes::from_iter(vec![ ( Attribute::ContentType, AttributeValue::from( @@ -319,9 +325,11 @@ impl Storage for ObjectStorage { manifest.icechunk_manifest_format_version.to_string(), ), ), - ]), - ..PutOptions::default() + ]) + } else { + Attributes::new() }; + let options = PutOptions { attributes, ..PutOptions::default() }; // FIXME: use multipart self.store.put_opts(&path, bytes.into(), options).await?; Ok(()) From f405c59ab91cdf501ea8211fc5c9c5cf7df53394 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Wed, 2 Oct 2024 09:17:20 -0400 Subject: [PATCH 010/167] Fix config test to use tmp dir correctly (#128) * Fix config test to use tmp dir correctly * Add full object store error string to output, lint --- icechunk-python/tests/test_config.py | 32 +++++++++++++++------------- icechunk/src/repository.rs | 2 +- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/icechunk-python/tests/test_config.py b/icechunk-python/tests/test_config.py index b9d23aad..b9d42f48 100644 --- a/icechunk-python/tests/test_config.py +++ b/icechunk-python/tests/test_config.py @@ -1,28 +1,27 @@ import os -import shutil import icechunk import pytest import zarr -STORE_PATH = "/tmp/icechunk_config_test" - -@pytest.fixture -async def store(): +@pytest.fixture(scope="function") +async def tmp_store(tmpdir): + store_path = f"{tmpdir}" store = await icechunk.IcechunkStore.open( - storage=icechunk.StorageConfig.filesystem(STORE_PATH), + storage=icechunk.StorageConfig.filesystem(store_path), mode="a", config=icechunk.StoreConfig(inline_chunk_threshold_bytes=5), ) - yield store + yield store, store_path store.close() - shutil.rmtree(STORE_PATH) -async def test_no_inline_chunks(store): +async def test_no_inline_chunks(tmp_store): + store = tmp_store[0] + store_path = tmp_store[1] array = zarr.open_array( store=store, mode="a", @@ -36,11 +35,14 @@ async def test_no_inline_chunks(store): # Check that the chunks directory was created, since each chunk is 4 bytes and the # inline_chunk_threshold is 1, we should have 10 chunks in the chunks directory - assert os.path.isdir(f"{STORE_PATH}/chunks") - assert len(os.listdir(f"{STORE_PATH}/chunks")) == 10 + assert os.path.isdir(f"{store_path}/chunks") + assert len(os.listdir(f"{store_path}/chunks")) == 10 + +async def test_inline_chunks(tmp_store): + store = tmp_store[0] + store_path = tmp_store[1] -async def test_inline_chunks(store): inline_array = zarr.open_array( store=store, mode="a", @@ -56,7 +58,7 @@ async def test_inline_chunks(store): # Check that the chunks directory was not created, since each chunk is 4 bytes and the # inline_chunk_threshold is 40, we should have no chunks directory - assert not os.path.isdir(f"{STORE_PATH}/chunks") + assert not os.path.isdir(f"{store_path}/chunks") written_array = zarr.open_array( store=store, @@ -73,5 +75,5 @@ async def test_inline_chunks(store): # Check that the chunks directory was not created, since each chunk is 8 bytes and the # inline_chunk_threshold is 40, we should have 10 chunks in the chunks directory - assert os.path.isdir(f"{STORE_PATH}/chunks") - assert len(os.listdir(f"/{STORE_PATH}/chunks")) == 10 + assert os.path.isdir(f"{store_path}/chunks") + assert len(os.listdir(f"/{store_path}/chunks")) == 10 diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index c1947487..c81c7b5d 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -269,7 +269,7 @@ impl RepositoryBuilder { #[derive(Debug, Error)] pub enum RepositoryError { - #[error("error contacting storage")] + #[error("error contacting storage {0}")] StorageError(#[from] StorageError), #[error("error in icechunk file")] FormatError(#[from] IcechunkFormatError), From 728e3c56708c971c667cb7b6e666a0f1226b7e82 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Wed, 2 Oct 2024 12:00:50 -0300 Subject: [PATCH 011/167] Move ChangeSet to its own module Just moving the code + a few new public methods to avoid breaking encapsulation. --- icechunk/src/change_set.rs | 184 +++++++++++++++++++++++++++++++++++++ icechunk/src/lib.rs | 1 + icechunk/src/repository.rs | 173 ++++------------------------------ 3 files changed, 202 insertions(+), 156 deletions(-) create mode 100644 icechunk/src/change_set.rs diff --git a/icechunk/src/change_set.rs b/icechunk/src/change_set.rs new file mode 100644 index 00000000..055d0694 --- /dev/null +++ b/icechunk/src/change_set.rs @@ -0,0 +1,184 @@ +use std::{ + collections::{HashMap, HashSet}, + iter, + mem::take, +}; + +use itertools::Either; + +use crate::{ + format::{manifest::ChunkInfo, NodeId}, + metadata::UserAttributes, + repository::{ChunkIndices, ChunkPayload, Path, ZarrArrayMetadata}, +}; + +#[derive(Clone, Debug, PartialEq, Default)] +pub struct ChangeSet { + new_groups: HashMap, + new_arrays: HashMap, + updated_arrays: HashMap, + // These paths may point to Arrays or Groups, + // since both Groups and Arrays support UserAttributes + updated_attributes: HashMap>, + // FIXME: issue with too many inline chunks kept in mem + set_chunks: HashMap>>, + deleted_groups: HashSet, + deleted_arrays: HashSet, +} + +impl ChangeSet { + pub fn is_empty(&self) -> bool { + self == &ChangeSet::default() + } + + pub fn add_group(&mut self, path: Path, node_id: NodeId) { + self.new_groups.insert(path, node_id); + } + + pub fn get_group(&self, path: &Path) -> Option<&NodeId> { + self.new_groups.get(path) + } + + pub fn get_array(&self, path: &Path) -> Option<&(NodeId, ZarrArrayMetadata)> { + self.new_arrays.get(path) + } + + pub fn delete_group(&mut self, path: &Path, node_id: NodeId) { + let new_node_id = self.new_groups.remove(path); + let is_new_group = new_node_id.is_some(); + debug_assert!(!is_new_group || new_node_id == Some(node_id)); + + self.updated_attributes.remove(&node_id); + if !is_new_group { + self.deleted_groups.insert(node_id); + } + } + + pub fn add_array( + &mut self, + path: Path, + node_id: NodeId, + metadata: ZarrArrayMetadata, + ) { + self.new_arrays.insert(path, (node_id, metadata)); + } + + pub fn update_array(&mut self, node_id: NodeId, metadata: ZarrArrayMetadata) { + self.updated_arrays.insert(node_id, metadata); + } + + pub fn delete_array(&mut self, path: &Path, node_id: NodeId) { + // if deleting a new array created in this session, just remove the entry + // from new_arrays + let node_and_meta = self.new_arrays.remove(path); + let is_new_array = node_and_meta.is_some(); + debug_assert!(!is_new_array || node_and_meta.map(|n| n.0) == Some(node_id)); + + self.updated_arrays.remove(&node_id); + self.updated_attributes.remove(&node_id); + self.set_chunks.remove(&node_id); + if !is_new_array { + self.deleted_arrays.insert(node_id); + } + } + + pub fn is_deleted(&self, node_id: &NodeId) -> bool { + self.deleted_groups.contains(node_id) || self.deleted_arrays.contains(node_id) + } + + pub fn has_updated_attributes(&self, node_id: &NodeId) -> bool { + self.updated_attributes.contains_key(node_id) + } + + pub fn get_updated_zarr_metadata( + &self, + node_id: NodeId, + ) -> Option<&ZarrArrayMetadata> { + self.updated_arrays.get(&node_id) + } + + pub fn update_user_attributes( + &mut self, + node_id: NodeId, + atts: Option, + ) { + self.updated_attributes.insert(node_id, atts); + } + + pub fn get_user_attributes( + &self, + node_id: NodeId, + ) -> Option<&Option> { + self.updated_attributes.get(&node_id) + } + + pub fn set_chunk_ref( + &mut self, + node_id: NodeId, + coord: ChunkIndices, + data: Option, + ) { + // this implementation makes delete idempotent + // it allows deleting a deleted chunk by repeatedly setting None. + self.set_chunks + .entry(node_id) + .and_modify(|h| { + h.insert(coord.clone(), data.clone()); + }) + .or_insert(HashMap::from([(coord, data)])); + } + + pub fn get_chunk_ref( + &self, + node_id: NodeId, + coords: &ChunkIndices, + ) -> Option<&Option> { + self.set_chunks.get(&node_id).and_then(|h| h.get(coords)) + } + + pub fn array_chunks_iterator( + &self, + node_id: NodeId, + ) -> impl Iterator)> { + match self.set_chunks.get(&node_id) { + None => Either::Left(iter::empty()), + Some(h) => Either::Right(h.iter()), + } + } + + pub fn new_arrays_chunk_iterator( + &self, + ) -> impl Iterator + '_ { + self.new_arrays.iter().flat_map(|(path, (node_id, _))| { + self.array_chunks_iterator(*node_id).filter_map(|(coords, payload)| { + payload.as_ref().map(|p| { + ( + path.clone(), + ChunkInfo { + node: *node_id, + coord: coords.clone(), + payload: p.clone(), + }, + ) + }) + }) + }) + } + + pub fn new_nodes(&self) -> impl Iterator { + self.new_groups.keys().chain(self.new_arrays.keys()) + } + + pub fn take_chunks( + &mut self, + ) -> HashMap>> { + take(&mut self.set_chunks) + } + + pub fn set_chunks( + &mut self, + chunks: HashMap>>, + ) { + self.set_chunks = chunks + } +} diff --git a/icechunk/src/lib.rs b/icechunk/src/lib.rs index 2ec87fb7..b09ef491 100644 --- a/icechunk/src/lib.rs +++ b/icechunk/src/lib.rs @@ -17,6 +17,7 @@ //! - a caching wrapper implementation //! - The datastructures are represented by concrete types in the [`mod@format`] modules. //! These datastructures use Arrow RecordBatches for representation. +pub mod change_set; pub mod format; pub mod metadata; pub mod refs; diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index c81c7b5d..c83d0a4b 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -1,23 +1,13 @@ use std::{ collections::{BTreeMap, HashMap, HashSet}, iter::{self}, - mem::take, path::PathBuf, pin::Pin, sync::Arc, }; -use crate::{ - format::{ - manifest::VirtualReferenceError, snapshot::ManifestFileInfo, ManifestId, - SnapshotId, - }, - storage::virtual_ref::{ - construct_valid_byte_range, ObjectStoreVirtualChunkResolverConfig, - VirtualChunkResolver, - }, -}; pub use crate::{ + change_set::ChangeSet, format::{ manifest::{ChunkPayload, VirtualChunkLocation}, snapshot::{SnapshotMetadata, ZarrArrayMetadata}, @@ -28,6 +18,16 @@ pub use crate::{ DimensionNames, FillValue, StorageTransformer, UserAttributes, }, }; +use crate::{ + format::{ + manifest::VirtualReferenceError, snapshot::ManifestFileInfo, ManifestId, + SnapshotId, + }, + storage::virtual_ref::{ + construct_valid_byte_range, ObjectStoreVirtualChunkResolverConfig, + VirtualChunkResolver, + }, +}; use bytes::Bytes; use chrono::Utc; use futures::{future::ready, Future, FutureExt, Stream, StreamExt, TryStreamExt}; @@ -81,141 +81,6 @@ pub struct Repository { virtual_resolver: Arc, } -#[derive(Clone, Debug, PartialEq, Default)] -struct ChangeSet { - new_groups: HashMap, - new_arrays: HashMap, - updated_arrays: HashMap, - // These paths may point to Arrays or Groups, - // since both Groups and Arrays support UserAttributes - updated_attributes: HashMap>, - // FIXME: issue with too many inline chunks kept in mem - set_chunks: HashMap>>, - deleted_groups: HashSet, - deleted_arrays: HashSet, -} - -impl ChangeSet { - fn is_empty(&self) -> bool { - self == &ChangeSet::default() - } - - fn add_group(&mut self, path: Path, node_id: NodeId) { - self.new_groups.insert(path, node_id); - } - - fn get_group(&self, path: &Path) -> Option<&NodeId> { - self.new_groups.get(path) - } - - fn get_array(&self, path: &Path) -> Option<&(NodeId, ZarrArrayMetadata)> { - self.new_arrays.get(path) - } - - fn delete_group(&mut self, path: &Path, node_id: NodeId) { - let new_node_id = self.new_groups.remove(path); - let is_new_group = new_node_id.is_some(); - debug_assert!(!is_new_group || new_node_id == Some(node_id)); - - self.updated_attributes.remove(&node_id); - if !is_new_group { - self.deleted_groups.insert(node_id); - } - } - - fn add_array(&mut self, path: Path, node_id: NodeId, metadata: ZarrArrayMetadata) { - self.new_arrays.insert(path, (node_id, metadata)); - } - - fn update_array(&mut self, node_id: NodeId, metadata: ZarrArrayMetadata) { - self.updated_arrays.insert(node_id, metadata); - } - - fn delete_array(&mut self, path: &Path, node_id: NodeId) { - // if deleting a new array created in this session, just remove the entry - // from new_arrays - let node_and_meta = self.new_arrays.remove(path); - let is_new_array = node_and_meta.is_some(); - debug_assert!(!is_new_array || node_and_meta.map(|n| n.0) == Some(node_id)); - - self.updated_arrays.remove(&node_id); - self.updated_attributes.remove(&node_id); - self.set_chunks.remove(&node_id); - if !is_new_array { - self.deleted_arrays.insert(node_id); - } - } - - fn get_updated_zarr_metadata(&self, node_id: NodeId) -> Option<&ZarrArrayMetadata> { - self.updated_arrays.get(&node_id) - } - - fn update_user_attributes(&mut self, node_id: NodeId, atts: Option) { - self.updated_attributes.insert(node_id, atts); - } - - fn get_user_attributes(&self, node_id: NodeId) -> Option<&Option> { - self.updated_attributes.get(&node_id) - } - - fn set_chunk_ref( - &mut self, - node_id: NodeId, - coord: ChunkIndices, - data: Option, - ) { - // this implementation makes delete idempotent - // it allows deleting a deleted chunk by repeatedly setting None. - self.set_chunks - .entry(node_id) - .and_modify(|h| { - h.insert(coord.clone(), data.clone()); - }) - .or_insert(HashMap::from([(coord, data)])); - } - - fn get_chunk_ref( - &self, - node_id: NodeId, - coords: &ChunkIndices, - ) -> Option<&Option> { - self.set_chunks.get(&node_id).and_then(|h| h.get(coords)) - } - - fn array_chunks_iterator( - &self, - node_id: NodeId, - ) -> impl Iterator)> { - match self.set_chunks.get(&node_id) { - None => Either::Left(iter::empty()), - Some(h) => Either::Right(h.iter()), - } - } - - fn new_arrays_chunk_iterator( - &self, - ) -> impl Iterator + '_ { - self.new_arrays.iter().flat_map(|(path, (node_id, _))| { - self.array_chunks_iterator(*node_id).filter_map(|(coords, payload)| { - payload.as_ref().map(|p| { - ( - path.clone(), - ChunkInfo { - node: *node_id, - coord: coords.clone(), - payload: p.clone(), - }, - ) - }) - }) - }) - } - - fn new_nodes(&self) -> impl Iterator { - self.new_groups.keys().chain(self.new_arrays.keys()) - } -} - #[derive(Debug, Clone)] pub struct RepositoryBuilder { config: RepositoryConfig, @@ -536,9 +401,7 @@ impl Repository { Some(node) => Ok(node), None => { let node = self.get_existing_node(path).await?; - if self.change_set.deleted_groups.contains(&node.id) - || self.change_set.deleted_arrays.contains(&node.id) - { + if self.change_set.is_deleted(&node.id) { Err(RepositoryError::NodeNotFound { path: path.clone(), message: "getting node".to_string(), @@ -1053,7 +916,7 @@ impl Repository { // and at the end we put the map back to where it was, in case there is some later failure. // We always want to leave things in the previous state if there was a failure. - let chunk_changes = Arc::new(take(&mut self.change_set.set_chunks)); + let chunk_changes = Arc::new(self.change_set.take_chunks()); let chunk_changes_c = Arc::clone(&chunk_changes); let update_task = task::spawn_blocking(move || { @@ -1072,8 +935,9 @@ impl Repository { { // It's OK to call into_inner here because we created the Arc locally and never // shared it with other code - self.change_set.set_chunks = + let chunks = Arc::into_inner(chunk_changes).expect("Bug in flush task join"); + self.change_set.set_chunks(chunks); } let new_manifest = Arc::new(Manifest::new(new_chunks)); @@ -1590,12 +1454,9 @@ mod tests { let path: Path = "/group/array2".into(); let node = ds.get_node(&path).await; - assert!(ds - .change_set - .updated_attributes - .contains_key(&node.as_ref().unwrap().id)); + assert!(ds.change_set.has_updated_attributes(&node.as_ref().unwrap().id)); assert!(ds.delete_array(path.clone()).await.is_ok()); - assert!(!ds.change_set.updated_attributes.contains_key(&node?.id)); + assert!(!ds.change_set.has_updated_attributes(&node?.id)); Ok(()) } From a2695db7404752bb3559f2a7e1776bcd08bd00cf Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Wed, 2 Oct 2024 13:49:17 -0300 Subject: [PATCH 012/167] Implement Rust distributed writes We provide a wait to commit from multiple writers. Each writer does its writes, and then it serializes its `ChangeSet`` to bytes (using `ChangeSet::export_to_bytes`). The writer then ships those bytes to the commit coordinator. The coordinator can recreate the distributed change sets, merge them, and do a single commit. Added a simple functional test that writes chunks from multiple threads. --- icechunk/src/change_set.rs | 160 ++++++++++++- icechunk/src/repository.rs | 450 +++++++++++++++++-------------------- 2 files changed, 368 insertions(+), 242 deletions(-) diff --git a/icechunk/src/change_set.rs b/icechunk/src/change_set.rs index 055d0694..6306b618 100644 --- a/icechunk/src/change_set.rs +++ b/icechunk/src/change_set.rs @@ -5,14 +5,19 @@ use std::{ }; use itertools::Either; +use serde::{Deserialize, Serialize}; use crate::{ - format::{manifest::ChunkInfo, NodeId}, + format::{ + manifest::{ChunkInfo, ManifestExtents, ManifestRef}, + snapshot::{NodeData, NodeSnapshot, UserAttributesSnapshot}, + ManifestId, NodeId, + }, metadata::UserAttributes, - repository::{ChunkIndices, ChunkPayload, Path, ZarrArrayMetadata}, + repository::{ChunkIndices, ChunkPayload, Path, RepositoryResult, ZarrArrayMetadata}, }; -#[derive(Clone, Debug, PartialEq, Default)] +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] pub struct ChangeSet { new_groups: HashMap, new_arrays: HashMap, @@ -181,4 +186,153 @@ impl ChangeSet { ) { self.set_chunks = chunks } + + /// Merge this ChangeSet with `other`. + /// + /// Results of the merge are applied to `self`. Changes present in `other` take precedence over + /// `self` changes. + pub fn merge(&mut self, other: ChangeSet) { + // FIXME: this should detect conflict, for example, if different writers added on the same + // path, different objects, or if the same path is added and deleted, etc. + // TODO: optimize + self.new_groups.extend(other.new_groups); + self.new_arrays.extend(other.new_arrays); + self.updated_arrays.extend(other.updated_arrays); + self.updated_attributes.extend(other.updated_attributes); + self.deleted_groups.extend(other.deleted_groups); + self.deleted_arrays.extend(other.deleted_arrays); + + for (node, other_chunks) in other.set_chunks.into_iter() { + match self.set_chunks.remove(&node) { + Some(mut old_value) => { + old_value.extend(other_chunks); + self.set_chunks.insert(node, old_value); + } + None => { + self.set_chunks.insert(node, other_chunks); + } + } + } + } + + pub fn merge_many>(&mut self, others: T) { + others.into_iter().fold(self, |res, change_set| { + res.merge(change_set); + res + }); + } + + /// Serialize this ChangeSet + /// + /// This is intended to help with marshalling distributed writers back to the coordinator + pub fn export_to_bytes(&self) -> RepositoryResult> { + Ok(rmp_serde::to_vec(self)?) + } + + /// Deserialize a ChangeSet + /// + /// This is intended to help with marshalling distributed writers back to the coordinator + pub fn import_from_bytes(bytes: &[u8]) -> RepositoryResult { + Ok(rmp_serde::from_slice(bytes)?) + } + + pub fn update_existing_chunks<'a>( + &'a self, + node: NodeId, + chunks: impl Iterator + 'a, + ) -> impl Iterator + 'a { + chunks.filter_map(move |chunk| match self.get_chunk_ref(node, &chunk.coord) { + None => Some(chunk), + Some(new_payload) => { + new_payload.clone().map(|pl| ChunkInfo { payload: pl, ..chunk }) + } + }) + } + + pub fn get_new_node(&self, path: &Path) -> Option { + self.get_new_array(path).or(self.get_new_group(path)) + } + + pub fn get_new_array(&self, path: &Path) -> Option { + self.get_array(path).map(|(id, meta)| { + let meta = self.get_updated_zarr_metadata(*id).unwrap_or(meta).clone(); + let atts = self.get_user_attributes(*id).cloned(); + NodeSnapshot { + id: *id, + path: path.clone(), + user_attributes: atts.flatten().map(UserAttributesSnapshot::Inline), + // We put no manifests in new arrays, see get_chunk_ref to understand how chunks get + // fetched for those arrays + node_data: NodeData::Array(meta.clone(), vec![]), + } + }) + } + + pub fn get_new_group(&self, path: &Path) -> Option { + self.get_group(path).map(|id| { + let atts = self.get_user_attributes(*id).cloned(); + NodeSnapshot { + id: *id, + path: path.clone(), + user_attributes: atts.flatten().map(UserAttributesSnapshot::Inline), + node_data: NodeData::Group, + } + }) + } + + pub fn new_nodes_iterator<'a>( + &'a self, + manifest_id: &'a ManifestId, + ) -> impl Iterator + 'a { + self.new_nodes().map(move |path| { + // we should be able to create the full node because we + // know it's a new node + #[allow(clippy::expect_used)] + let node = self.get_new_node(path).expect("Bug in new_nodes implementation"); + match node.node_data { + NodeData::Group => node, + NodeData::Array(meta, _no_manifests_yet) => { + let new_manifests = vec![ManifestRef { + object_id: manifest_id.clone(), + extents: ManifestExtents(vec![]), + }]; + NodeSnapshot { + node_data: NodeData::Array(meta, new_manifests), + ..node + } + } + } + }) + } + + pub fn update_existing_node( + &self, + node: NodeSnapshot, + new_manifests: Option>, + ) -> NodeSnapshot { + let session_atts = self + .get_user_attributes(node.id) + .cloned() + .map(|a| a.map(UserAttributesSnapshot::Inline)); + let new_atts = session_atts.unwrap_or(node.user_attributes); + match node.node_data { + NodeData::Group => NodeSnapshot { user_attributes: new_atts, ..node }, + NodeData::Array(old_zarr_meta, _) => { + let new_zarr_meta = self + .get_updated_zarr_metadata(node.id) + .cloned() + .unwrap_or(old_zarr_meta); + + NodeSnapshot { + // FIXME: bad option type, change + node_data: NodeData::Array( + new_zarr_meta, + new_manifests.unwrap_or_default(), + ), + user_attributes: new_atts, + ..node + } + } + } + } } diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index c83d0a4b..4a56bbc3 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -160,9 +160,13 @@ pub enum RepositoryError { AlreadyInitialized, #[error("error when handling virtual reference {0}")] VirtualReferenceError(#[from] VirtualReferenceError), + #[error("error in repository serialization `{0}`")] + SerializationError(#[from] rmp_serde::encode::Error), + #[error("error in repository deserialization `{0}`")] + DeserializationError(#[from] rmp_serde::decode::Error), } -type RepositoryResult = Result; +pub type RepositoryResult = Result; /// FIXME: what do we want to do with implicit groups? /// @@ -397,7 +401,7 @@ impl Repository { pub async fn get_node(&self, path: &Path) -> RepositoryResult { // We need to look for nodes in self.change_set and the snapshot file - match self.get_new_node(path) { + match self.change_set.get_new_node(path) { Some(node) => Ok(node), None => { let node = self.get_existing_node(path).await?; @@ -474,38 +478,6 @@ impl Repository { } } - fn get_new_node(&self, path: &Path) -> Option { - self.get_new_array(path).or(self.get_new_group(path)) - } - - fn get_new_array(&self, path: &Path) -> Option { - self.change_set.get_array(path).map(|(id, meta)| { - let meta = - self.change_set.get_updated_zarr_metadata(*id).unwrap_or(meta).clone(); - let atts = self.change_set.get_user_attributes(*id).cloned(); - NodeSnapshot { - id: *id, - path: path.clone(), - user_attributes: atts.flatten().map(UserAttributesSnapshot::Inline), - // We put no manifests in new arrays, see get_chunk_ref to understand how chunks get - // fetched for those arrays - node_data: NodeData::Array(meta.clone(), vec![]), - } - }) - } - - fn get_new_group(&self, path: &Path) -> Option { - self.change_set.get_group(path).map(|id| { - let atts = self.change_set.get_user_attributes(*id).cloned(); - NodeSnapshot { - id: *id, - path: path.clone(), - user_attributes: atts.flatten().map(UserAttributesSnapshot::Inline), - node_data: NodeData::Group, - } - }) - } - pub async fn get_chunk_ref( &self, path: &Path, @@ -724,9 +696,10 @@ impl Repository { payload, }); - let old_chunks = self.update_existing_chunks( - node.id, old_chunks, - ); + let old_chunks = + self.change_set.update_existing_chunks( + node.id, old_chunks, + ); futures::future::Either::Left( futures::stream::iter(old_chunks.map(Ok)), ) @@ -748,118 +721,16 @@ impl Repository { } } - fn update_existing_chunks<'a>( - &'a self, - node: NodeId, - chunks: impl Iterator + 'a, - ) -> impl Iterator + 'a { - chunks.filter_map(move |chunk| { - match self.change_set.get_chunk_ref(node, &chunk.coord) { - None => Some(chunk), - Some(new_payload) => { - new_payload.clone().map(|pl| ChunkInfo { payload: pl, ..chunk }) - } - } - }) - } - - async fn updated_existing_nodes<'a>( - &'a self, - manifest_id: &'a ManifestId, - ) -> RepositoryResult + 'a> { - // TODO: solve this duplication, there is always the possibility of this being the first - // version - let updated_nodes = - self.storage.fetch_snapshot(&self.snapshot_id).await?.iter_arc().map( - move |node| { - let new_manifests = if node.node_type() == NodeType::Array { - //FIXME: it could be none for empty arrays - Some(vec![ManifestRef { - object_id: manifest_id.clone(), - extents: ManifestExtents(vec![]), - }]) - } else { - None - }; - self.update_existing_node(node, new_manifests) - }, - ); - - Ok(updated_nodes) - } - - fn new_nodes<'a>( - &'a self, - manifest_id: &'a ManifestId, - ) -> impl Iterator + 'a { - self.change_set.new_nodes().map(move |path| { - // we should be able to create the full node because we - // know it's a new node - #[allow(clippy::expect_used)] - let node = self.get_new_node(path).expect("Bug in new_nodes implementation"); - match node.node_data { - NodeData::Group => node, - NodeData::Array(meta, _no_manifests_yet) => { - let new_manifests = vec![ManifestRef { - object_id: manifest_id.clone(), - extents: ManifestExtents(vec![]), - }]; - NodeSnapshot { - node_data: NodeData::Array(meta, new_manifests), - ..node - } - } - } - }) - } - - async fn updated_nodes<'a>( - &'a self, - manifest_id: &'a ManifestId, - ) -> RepositoryResult + 'a> { - Ok(self - .updated_existing_nodes(manifest_id) - .await? - .chain(self.new_nodes(manifest_id))) - } - - fn update_existing_node( - &self, - node: NodeSnapshot, - new_manifests: Option>, - ) -> NodeSnapshot { - let session_atts = self - .change_set - .get_user_attributes(node.id) - .cloned() - .map(|a| a.map(UserAttributesSnapshot::Inline)); - let new_atts = session_atts.unwrap_or(node.user_attributes); - match node.node_data { - NodeData::Group => NodeSnapshot { user_attributes: new_atts, ..node }, - NodeData::Array(old_zarr_meta, _) => { - let new_zarr_meta = self - .change_set - .get_updated_zarr_metadata(node.id) - .cloned() - .unwrap_or(old_zarr_meta); - - NodeSnapshot { - // FIXME: bad option type, change - node_data: NodeData::Array( - new_zarr_meta, - new_manifests.unwrap_or_default(), - ), - user_attributes: new_atts, - ..node - } - } - } - } - pub async fn list_nodes( &self, ) -> RepositoryResult + '_> { - self.updated_nodes(&ObjectId::FAKE).await + updated_nodes( + self.storage.as_ref(), + &self.change_set, + &self.snapshot_id, + &ObjectId::FAKE, + ) + .await } pub async fn all_chunks( @@ -872,6 +743,28 @@ impl Repository { Ok(existing_array_chunks.chain(new_array_chunks)) } + pub async fn distributed_flush>( + &mut self, + other_change_sets: I, + message: &str, + properties: SnapshotProperties, + ) -> RepositoryResult { + // FIXME: this clone can be avoided + let change_sets = iter::once(self.change_set.clone()).chain(other_change_sets); + let new_snapshot_id = distributed_flush( + self.storage.as_ref(), + self.snapshot_id(), + change_sets, + message, + properties, + ) + .await?; + + self.snapshot_id = new_snapshot_id.clone(); + self.change_set = ChangeSet::default(); + Ok(new_snapshot_id) + } + /// After changes to the repository have been made, this generates and writes to `Storage` the updated datastructures. /// /// After calling this, changes are reset and the [`Repository`] can continue to be used for further @@ -884,95 +777,7 @@ impl Repository { message: &str, properties: SnapshotProperties, ) -> RepositoryResult { - if !self.has_uncommitted_changes() { - return Err(RepositoryError::NoChangesToCommit); - } - // We search for the current manifest. We are assumming a single one for now - let old_snapshot = self.storage().fetch_snapshot(&self.snapshot_id).await?; - let old_snapshot_c = Arc::clone(&old_snapshot); - let manifest_id = old_snapshot_c.iter_arc().find_map(|node| { - match node.node_data { - NodeData::Array(_, man) => { - // TODO: can we avoid clone - man.first().map(|manifest| manifest.object_id.clone()) - } - NodeData::Group => None, - } - }); - - let old_manifest = match manifest_id { - Some(ref manifest_id) => self.storage.fetch_manifests(manifest_id).await?, - // If there is no previous manifest we create an empty one - None => Arc::new(Manifest::default()), - }; - - // The manifest update process is CPU intensive, so we want to executed it on a worker - // thread. Currently it's also destructive of the manifest, so we are also cloning the - // old manifest data - // - // The update process requires reference access to the set_chunks map, since we are running - // it on blocking task, it wants that reference to be 'static, which we cannot provide. - // As a solution, we temporarily `take` the map, replacing it an empty one, run the thread, - // and at the end we put the map back to where it was, in case there is some later failure. - // We always want to leave things in the previous state if there was a failure. - - let chunk_changes = Arc::new(self.change_set.take_chunks()); - let chunk_changes_c = Arc::clone(&chunk_changes); - - let update_task = task::spawn_blocking(move || { - //FIXME: avoid clone, this one is extremely expensive en memory - //it's currently needed because we don't want to destroy the manifest in case of later - //failure - let mut new_chunks = old_manifest.as_ref().chunks().clone(); - update_manifest(&mut new_chunks, &chunk_changes_c); - (new_chunks, chunk_changes) - }); - - match update_task.await { - Ok((new_chunks, chunk_changes)) => { - // reset the set_chunks map to it's previous value - #[allow(clippy::expect_used)] - { - // It's OK to call into_inner here because we created the Arc locally and never - // shared it with other code - let chunks = - Arc::into_inner(chunk_changes).expect("Bug in flush task join"); - self.change_set.set_chunks(chunks); - } - - let new_manifest = Arc::new(Manifest::new(new_chunks)); - let new_manifest_id = ObjectId::random(); - self.storage - .write_manifests(new_manifest_id.clone(), Arc::clone(&new_manifest)) - .await?; - - let all_nodes = self.updated_nodes(&new_manifest_id).await?; - - let mut new_snapshot = Snapshot::from_iter( - old_snapshot.as_ref(), - Some(properties), - vec![ManifestFileInfo { - id: new_manifest_id.clone(), - format_version: new_manifest.icechunk_manifest_format_version, - }], - vec![], - all_nodes, - ); - new_snapshot.metadata.message = message.to_string(); - new_snapshot.metadata.written_at = Utc::now(); - - let new_snapshot = Arc::new(new_snapshot); - let new_snapshot_id = &new_snapshot.metadata.id; - self.storage - .write_snapshot(new_snapshot_id.clone(), Arc::clone(&new_snapshot)) - .await?; - - self.snapshot_id = new_snapshot_id.clone(); - self.change_set = ChangeSet::default(); - Ok(new_snapshot_id.clone()) - } - Err(_) => Err(RepositoryError::OtherFlushError), - } + self.distributed_flush(iter::empty(), message, properties).await } pub async fn commit( @@ -980,11 +785,28 @@ impl Repository { update_branch_name: &str, message: &str, properties: Option, + ) -> RepositoryResult { + self.distributed_commit(update_branch_name, iter::empty(), message, properties) + .await + } + + pub async fn distributed_commit>( + &mut self, + update_branch_name: &str, + other_change_sets: I, + message: &str, + properties: Option, ) -> RepositoryResult { let current = fetch_branch_tip(self.storage.as_ref(), update_branch_name).await; match current { Err(RefError::RefNotFound(_)) => { - self.do_commit(update_branch_name, message, properties).await + self.do_distributed_commit( + update_branch_name, + other_change_sets, + message, + properties, + ) + .await } Err(err) => Err(err.into()), Ok(ref_data) => { @@ -995,21 +817,29 @@ impl Repository { actual_parent: Some(ref_data.snapshot.clone()), }) } else { - self.do_commit(update_branch_name, message, properties).await + self.do_distributed_commit( + update_branch_name, + other_change_sets, + message, + properties, + ) + .await } } } } - async fn do_commit( + async fn do_distributed_commit>( &mut self, update_branch_name: &str, + other_change_sets: I, message: &str, properties: Option, ) -> RepositoryResult { let parent_snapshot = self.snapshot_id.clone(); let properties = properties.unwrap_or_default(); - let new_snapshot = self.flush(message, properties).await?; + let new_snapshot = + self.distributed_flush(other_change_sets, message, properties).await?; match update_branch( self.storage.as_ref(), @@ -1065,6 +895,12 @@ impl Repository { } } +impl From for ChangeSet { + fn from(val: Repository) -> Self { + val.change_set + } +} + async fn new_materialized_chunk( storage: &(dyn Storage + Send + Sync), data: Bytes, @@ -1107,6 +943,142 @@ pub async fn get_chunk( } } +async fn updated_existing_nodes<'a>( + storage: &(dyn Storage + Send + Sync), + change_set: &'a ChangeSet, + parent_id: &SnapshotId, + manifest_id: &'a ManifestId, +) -> RepositoryResult + 'a> { + // TODO: solve this duplication, there is always the possibility of this being the first + // version + let updated_nodes = + storage.fetch_snapshot(parent_id).await?.iter_arc().map(move |node| { + let new_manifests = if node.node_type() == NodeType::Array { + //FIXME: it could be none for empty arrays + Some(vec![ManifestRef { + object_id: manifest_id.clone(), + extents: ManifestExtents(vec![]), + }]) + } else { + None + }; + change_set.update_existing_node(node, new_manifests) + }); + + Ok(updated_nodes) +} + +async fn updated_nodes<'a>( + storage: &(dyn Storage + Send + Sync), + change_set: &'a ChangeSet, + parent_id: &SnapshotId, + manifest_id: &'a ManifestId, +) -> RepositoryResult + 'a> { + Ok(updated_existing_nodes(storage, change_set, parent_id, manifest_id) + .await? + .chain(change_set.new_nodes_iterator(manifest_id))) +} + +async fn distributed_flush>( + storage: &(dyn Storage + Send + Sync), + parent_id: &SnapshotId, + change_sets: I, + message: &str, + properties: SnapshotProperties, +) -> RepositoryResult { + let mut change_set = ChangeSet::default(); + change_set.merge_many(change_sets); + if change_set.is_empty() { + return Err(RepositoryError::NoChangesToCommit); + } + + // We search for the current manifest. We are assumming a single one for now + let old_snapshot = storage.fetch_snapshot(parent_id).await?; + let old_snapshot_c = Arc::clone(&old_snapshot); + let manifest_id = old_snapshot_c.iter_arc().find_map(|node| { + match node.node_data { + NodeData::Array(_, man) => { + // TODO: can we avoid clone + man.first().map(|manifest| manifest.object_id.clone()) + } + NodeData::Group => None, + } + }); + + let old_manifest = match manifest_id { + Some(ref manifest_id) => storage.fetch_manifests(manifest_id).await?, + // If there is no previous manifest we create an empty one + None => Arc::new(Manifest::default()), + }; + + // The manifest update process is CPU intensive, so we want to executed it on a worker + // thread. Currently it's also destructive of the manifest, so we are also cloning the + // old manifest data + // + // The update process requires reference access to the set_chunks map, since we are running + // it on blocking task, it wants that reference to be 'static, which we cannot provide. + // As a solution, we temporarily `take` the map, replacing it an empty one, run the thread, + // and at the end we put the map back to where it was, in case there is some later failure. + // We always want to leave things in the previous state if there was a failure. + + let chunk_changes = Arc::new(change_set.take_chunks()); + let chunk_changes_c = Arc::clone(&chunk_changes); + + let update_task = task::spawn_blocking(move || { + //FIXME: avoid clone, this one is extremely expensive en memory + //it's currently needed because we don't want to destroy the manifest in case of later + //failure + let mut new_chunks = old_manifest.as_ref().chunks().clone(); + update_manifest(&mut new_chunks, &chunk_changes_c); + (new_chunks, chunk_changes) + }); + + match update_task.await { + Ok((new_chunks, chunk_changes)) => { + // reset the set_chunks map to it's previous value + #[allow(clippy::expect_used)] + { + // It's OK to call into_inner here because we created the Arc locally and never + // shared it with other code + let chunks = + Arc::into_inner(chunk_changes).expect("Bug in flush task join"); + change_set.set_chunks(chunks); + } + + let new_manifest = Arc::new(Manifest::new(new_chunks)); + let new_manifest_id = ObjectId::random(); + storage + .write_manifests(new_manifest_id.clone(), Arc::clone(&new_manifest)) + .await?; + + let all_nodes = + updated_nodes(storage, &change_set, parent_id, &new_manifest_id).await?; + + let mut new_snapshot = Snapshot::from_iter( + old_snapshot.as_ref(), + Some(properties), + vec![ManifestFileInfo { + id: new_manifest_id.clone(), + format_version: new_manifest.icechunk_manifest_format_version, + }], + vec![], + all_nodes, + ); + new_snapshot.metadata.message = message.to_string(); + new_snapshot.metadata.written_at = Utc::now(); + + let new_snapshot = Arc::new(new_snapshot); + let new_snapshot_id = &new_snapshot.metadata.id; + storage + .write_snapshot(new_snapshot_id.clone(), Arc::clone(&new_snapshot)) + .await?; + + Ok(new_snapshot_id.clone()) + } + Err(_) => Err(RepositoryError::OtherFlushError), + } +} + #[cfg(test)] #[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] mod tests { From 85d73c97022b021f7f1e3666327cdff83de36878 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Wed, 2 Oct 2024 16:55:40 -0300 Subject: [PATCH 013/167] Added test file --- icechunk/tests/test_distributed_writes.rs | 188 ++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 icechunk/tests/test_distributed_writes.rs diff --git a/icechunk/tests/test_distributed_writes.rs b/icechunk/tests/test_distributed_writes.rs new file mode 100644 index 00000000..cd00ea91 --- /dev/null +++ b/icechunk/tests/test_distributed_writes.rs @@ -0,0 +1,188 @@ +use pretty_assertions::assert_eq; +use std::{num::NonZeroU64, ops::Range, sync::Arc}; + +use bytes::Bytes; +use icechunk::{ + format::{ByteRange, ChunkIndices, Path, SnapshotId}, + metadata::{ChunkKeyEncoding, ChunkShape, DataType, FillValue}, + repository::{get_chunk, ChangeSet, ZarrArrayMetadata}, + storage::object_store::{S3Config, S3Credentials}, + ObjectStorage, Repository, Storage, +}; +use tokio::task::JoinSet; + +const SIZE: usize = 10; + +fn mk_storage( + prefix: &str, +) -> Result, Box> { + let storage: Arc = Arc::new(ObjectStorage::new_s3_store( + "testbucket", + prefix, + Some(S3Config { + region: None, + endpoint: Some("http://localhost:9000".to_string()), + credentials: Some(S3Credentials { + access_key_id: "minio123".into(), + secret_access_key: "minio123".into(), + session_token: None, + }), + allow_http: Some(true), + }), + )?); + Ok(Repository::add_in_mem_asset_caching(storage)) +} + +async fn mk_repo( + storage: Arc, + init: bool, +) -> Result> { + if init { + Ok(Repository::init(storage, false).await?.with_inline_threshold_bytes(1).build()) + } else { + Ok(Repository::from_branch_tip(storage, "main") + .await? + .with_inline_threshold_bytes(1) + .build()) + } +} + +async fn write_chunks( + mut repo: Repository, + xs: Range, + ys: Range, +) -> Result> { + for x in xs { + for y in ys.clone() { + let fx = x as f64; + let fy = y as f64; + let bytes: Vec = fx + .to_le_bytes() + .into_iter() + .chain(fy.to_le_bytes().into_iter()) + .collect(); + let payload = + repo.get_chunk_writer()(Bytes::copy_from_slice(bytes.as_slice())).await?; + repo.set_chunk_ref("/array".into(), ChunkIndices(vec![x, y]), Some(payload)) + .await?; + } + } + Ok(repo) +} + +#[allow(clippy::unwrap_used)] +async fn verify( + repo: Repository, +) -> Result<(), Box> { + for x in 0..(SIZE / 2) as u32 { + for y in 0..(SIZE / 2) as u32 { + let bytes = get_chunk( + repo.get_chunk_reader( + &"/array".into(), + &ChunkIndices(vec![x, y]), + &ByteRange::ALL, + ) + .await?, + ) + .await?; + assert!(bytes.is_some()); + let bytes = bytes.unwrap(); + let written_x = f64::from_le_bytes(bytes[0..8].try_into().unwrap()); + let written_y = f64::from_le_bytes(bytes[8..16].try_into().unwrap()); + assert_eq!(x as f64, written_x); + assert_eq!(y as f64, written_y); + } + } + Ok(()) +} + +#[tokio::test] +#[allow(clippy::unwrap_used)] +/// This test does a distributed write from 4 different [`Repository`] instances, and then commits. +/// +/// - We create a repo, and write an empty array to it. +/// - We commit to it +/// - We initialize 3 other Repos pointing to the same place +/// - We do concurrent writes from the 4 repo instances +/// - When done, we do a distributed commit using a random repo +/// - The changes from the other repos are serialized via [`ChangeSet::export_to_bytes`] +async fn test_distributed_writes() -> Result<(), Box> +{ + let prefix = format!("test_distributed_writes_{}", SnapshotId::random()); + let storage1 = mk_storage(prefix.as_str())?; + let storage2 = mk_storage(prefix.as_str())?; + let storage3 = mk_storage(prefix.as_str())?; + let storage4 = mk_storage(prefix.as_str())?; + let mut repo1 = mk_repo(storage1, true).await?; + + let zarr_meta = ZarrArrayMetadata { + shape: vec![SIZE as u64, SIZE as u64], + data_type: DataType::Float64, + chunk_shape: ChunkShape(vec![ + NonZeroU64::new(2).unwrap(), + NonZeroU64::new(2).unwrap(), + ]), + chunk_key_encoding: ChunkKeyEncoding::Slash, + fill_value: FillValue::Float64(f64::NAN), + codecs: vec![], + storage_transformers: None, + dimension_names: None, + }; + + let new_array_path: Path = "/array".to_string().into(); + repo1.add_array(new_array_path.clone(), zarr_meta.clone()).await?; + repo1.commit("main", "create array", None).await?; + + let repo2 = mk_repo(storage2, false).await?; + let repo3 = mk_repo(storage3, false).await?; + let repo4 = mk_repo(storage4, false).await?; + + let mut set = JoinSet::new(); + #[allow(clippy::erasing_op, clippy::identity_op)] + { + let size2 = SIZE as u32; + let size24 = size2 / 4; + let xrange1 = size24 * 0..size24 * 1; + let xrange2 = size24 * 1..size24 * 2; + let xrange3 = size24 * 2..size24 * 3; + let xrange4 = size24 * 3..size24 * 4; + set.spawn(async move { write_chunks(repo1, xrange1, 0..size2).await }); + set.spawn(async move { write_chunks(repo2, xrange2, 0..size2).await }); + set.spawn(async move { write_chunks(repo3, xrange3, 0..size2).await }); + set.spawn(async move { write_chunks(repo4, xrange4, 0..size2).await }); + } + + let mut write_results = set.join_all().await; + + // We have completed all the chunk writes + assert!(write_results.len() == 4); + assert!(write_results.iter().all(|r| r.is_ok())); + + // We recover our repo instances (the may be numbered in a different order, doesn't matter) + let mut repo1 = write_results.pop().unwrap().unwrap(); + let repo2 = write_results.pop().unwrap().unwrap(); + let repo3 = write_results.pop().unwrap().unwrap(); + let repo4 = write_results.pop().unwrap().unwrap(); + + // We get the ChangeSet from repos 2, 3 and 4, by converting them into bytes. + // This simulates a marshalling operation from a remote writer. + let change_sets: Vec = vec![repo2.into(), repo3.into(), repo4.into()]; + let change_sets_bytes = change_sets.iter().map(|cs| cs.export_to_bytes().unwrap()); + let change_sets = change_sets_bytes + .map(|bytes| ChangeSet::import_from_bytes(bytes.as_slice()).unwrap()); + + // Distributed commit now, using arbitrarily one of the repos as base and the others as extra + // changesets + let _new_snapshot = + repo1.distributed_commit("main", change_sets, "distributed commit", None).await?; + + // We check we can read all chunks correctly + verify(repo1).await?; + + // To be safe, we create a new instance of the storage and repo, and verify again + let storage = mk_storage(prefix.as_str())?; + let repo = mk_repo(storage, false).await?; + verify(repo).await?; + + Ok(()) +} From eac0b24470d498be06c32d86e83f32cdd30a0073 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Wed, 2 Oct 2024 20:31:17 -0300 Subject: [PATCH 014/167] Python can do distributed writes It's quite hacky, we need better usability, but it works. There is a new python test that uses Dask to do concurrent writers on the same array. Serialization works like this: coordinator -> worker: we serialize a config dict, workers use it to instantiate the Store done worker -> coordinator: we serialize the bytes of the change set, the coordinator uses that to do a distributed commit Both paths can (and should) be improved, making objects picklable would help, it's not trivial in pyo3 but doable. --- icechunk-python/pyproject.toml | 26 ++-- icechunk-python/python/icechunk/__init__.py | 7 + .../python/icechunk/_icechunk_python.pyi | 3 + icechunk-python/src/lib.rs | 29 ++++ .../tests/test_distributed_writers.py | 124 ++++++++++++++++++ icechunk/src/repository.rs | 4 + icechunk/src/zarr.rs | 24 +++- 7 files changed, 199 insertions(+), 18 deletions(-) create mode 100644 icechunk-python/tests/test_distributed_writers.py diff --git a/icechunk-python/pyproject.toml b/icechunk-python/pyproject.toml index 8a4d1487..cf193702 100644 --- a/icechunk-python/pyproject.toml +++ b/icechunk-python/pyproject.toml @@ -6,25 +6,25 @@ build-backend = "maturin" name = "icechunk" requires-python = ">=3.10" classifiers = [ - "Programming Language :: Rust", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", ] dynamic = ["version"] -dependencies = [ - "zarr==3.0.0a6" -] +dependencies = ["zarr==3.0.0a6"] [project.optional-dependencies] test = [ - "coverage", - "mypy", - "object-store-python", - "pytest", - "pytest-cov", - "pytest-asyncio", - "ruff", + "coverage", + "mypy", + "object-store-python", + "pytest", + "pytest-cov", + "pytest-asyncio", + "ruff", + "dask", + "distributed", ] [tool.maturin] diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index 09d8c23f..158120a6 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -223,6 +223,10 @@ def snapshot_id(self) -> str: """Return the current snapshot id.""" return self._store.snapshot_id + + def change_set_bytes(self) -> bytes: + return self._store.change_set_bytes() + @property def branch(self) -> str | None: """Return the current branch name.""" @@ -260,6 +264,9 @@ async def commit(self, message: str) -> str: """ return await self._store.commit(message) + async def distributed_commit(self, message: str, other_change_set_bytes: Iterable[bytes]) -> str: + return await self._store.distributed_commit(message, list(other_change_set_bytes)) + @property def has_uncommitted_changes(self) -> bool: """Return True if there are uncommitted changes to the store""" diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index 6b5d5564..ea5cd457 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -1,16 +1,19 @@ import abc import datetime from collections.abc import AsyncGenerator +from typing import Iterable class PyIcechunkStore: def with_mode(self, read_only: bool) -> PyIcechunkStore: ... @property def snapshot_id(self) -> str: ... + def change_set_bytes(self) -> bytes: ... @property def branch(self) -> str | None: ... async def checkout_snapshot(self, snapshot_id: str) -> None: ... async def checkout_branch(self, branch: str) -> None: ... async def checkout_tag(self, tag: str) -> None: ... + async def distributed_commit(self, message: str, other_change_set_bytes: Iterable[bytes]) -> str: ... async def commit(self, message: str) -> str: ... @property def has_uncommitted_changes(self) -> bool: ... diff --git a/icechunk-python/src/lib.rs b/icechunk-python/src/lib.rs index 41b835bc..c1f2bce1 100644 --- a/icechunk-python/src/lib.rs +++ b/icechunk-python/src/lib.rs @@ -334,6 +334,35 @@ impl PyIcechunkStore { }) } + fn distributed_commit<'py>( + &'py self, + py: Python<'py>, + message: String, + other_change_set_bytes: Vec>, + ) -> PyResult> { + let store = Arc::clone(&self.store); + + // The commit mechanism is async and calls tokio::spawn so we need to use the + // pyo3_asyncio_0_21::tokio helper to run the async function in the tokio runtime + pyo3_asyncio_0_21::tokio::future_into_py(py, async move { + let mut writeable_store = store.write().await; + let oid = writeable_store + .distributed_commit(&message, other_change_set_bytes) + .await + .map_err(PyIcechunkStoreError::from)?; + Ok(String::from(&oid)) + }) + } + + fn change_set_bytes(&self) -> PyIcechunkStoreResult> { + let store = self.store.blocking_read(); + let res = self + .rt + .block_on(store.change_set_bytes()) + .map_err(PyIcechunkStoreError::from)?; + Ok(res) + } + #[getter] fn branch(&self) -> PyIcechunkStoreResult> { let store = self.store.blocking_read(); diff --git a/icechunk-python/tests/test_distributed_writers.py b/icechunk-python/tests/test_distributed_writers.py new file mode 100644 index 00000000..2eee5b2f --- /dev/null +++ b/icechunk-python/tests/test_distributed_writers.py @@ -0,0 +1,124 @@ +from dataclasses import dataclass +import time +import asyncio +import random +import zarr +from dask.distributed import Client +import numpy as np + +import icechunk + +@dataclass +class Task: + # fixme: useee StorageConfig and StoreConfig once those are pickable + storage_config: dict + store_config: dict + area: slice + seed: int + +# We create a 2-d array with this many chunks along each direction +CHUNKS_PER_DIM = 10 + +# Each chunk is CHUNK_DIM_SIZE x CHUNK_DIM_SIZE floats +CHUNK_DIM_SIZE = 10 + +# We split the writes in tasks, each task does this many chunks +CHUNKS_PER_TASK = 2 + +async def mk_store(mode: str, task: Task): + storage_config = icechunk.StorageConfig.s3_from_config( + **task.storage_config, + credentials=icechunk.S3Credentials( + access_key_id="minio123", + secret_access_key="minio123", + ) + ) + store_config = icechunk.StoreConfig(**task.store_config) + + store = await icechunk.IcechunkStore.open( + storage=storage_config, + mode="a", + config=store_config, + ) + + return store + +def generate_task_array(task: Task): + np.random.seed(task.seed) + nx = len(range(*task.area[0].indices(1000))) + ny = len(range(*task.area[1].indices(1000))) + return np.random.rand(nx, ny) + + +async def execute_task(task: Task): + store = await mk_store("w", task) + + group = zarr.group(store=store, overwrite=False) + array = group["array"] + array[task.area] = generate_task_array(task) + return store.change_set_bytes() + + +def run_task(task: Task): + return asyncio.run(execute_task(task)) + +async def test_distributed_writers(): + """Write to an array using uncoordinated writers, distributed via Dask. + + We create a big array, and then we split into workers, each worker gets + an area, where it writes random data with a known seed. Each worker + returns the bytes for its ChangeSet, then the coordinator (main thread) + does a distributed commit. When done, we open the store again and verify + we can write everything we have written. + """ + + client = Client(n_workers=8) + storage_config = { + "bucket":"testbucket", + "prefix":"python-distributed-writers-test__" + str(time.time()), + "endpoint_url":"http://localhost:9000", + "allow_http":True, + } + store_config = {"inline_chunk_threshold_bytes":5} + + + ranges = [ + (slice(x, min(x+CHUNKS_PER_TASK*CHUNK_DIM_SIZE, CHUNK_DIM_SIZE*CHUNKS_PER_DIM)), + slice(y, min(y+CHUNKS_PER_TASK*CHUNK_DIM_SIZE, CHUNK_DIM_SIZE*CHUNKS_PER_DIM)) + ) + for x in range(0, CHUNK_DIM_SIZE*CHUNKS_PER_DIM, CHUNKS_PER_TASK*CHUNK_DIM_SIZE) + for y in range(0, CHUNK_DIM_SIZE*CHUNKS_PER_DIM, CHUNKS_PER_TASK*CHUNK_DIM_SIZE) + ] + tasks = [ + Task(storage_config=storage_config, store_config=store_config, area=area, seed=idx) + for idx, area in enumerate(ranges) + ] + store = await mk_store("r+", tasks[0]) + group = zarr.group(store=store, overwrite=True) + + n = CHUNKS_PER_DIM * CHUNK_DIM_SIZE + array = group.create_array( + "array", shape=(n, n), chunk_shape=(CHUNK_DIM_SIZE, CHUNK_DIM_SIZE), dtype="f8", fill_value=float('nan') + ) + first_snap = await store.commit("array created") + + map_result = client.map(run_task, tasks) + change_sets_bytes = client.gather(map_result) + + # we can use the current store as the commit coordinator, because it doesn't have any pending changes, + # all changes come from the tasks, Icechunk doesn't care about where the changes come from, the only + # important thing is to not count changes twice + commit_res = await store.distributed_commit("distributed commit", change_sets_bytes) + assert(commit_res) + + # Lets open a new store to verify the results + store = await mk_store("r", tasks[0]) + all_keys = [key async for key in store.list_prefix("/")] + assert len(all_keys) == 1 + 1 + CHUNKS_PER_DIM*CHUNKS_PER_DIM # group meta + array meta + each chunk + + group = zarr.group(store=store, overwrite=False) + + for task in tasks: + actual = array[task.area] + expected = generate_task_array(task) + np.testing.assert_array_equal(actual, expected) diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index 4a56bbc3..fca69bb7 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -858,6 +858,10 @@ impl Repository { } } + pub fn change_set_bytes(&self) -> RepositoryResult> { + self.change_set.export_to_bytes() + } + pub async fn new_branch(&self, branch_name: &str) -> RepositoryResult { // TODO: The parent snapshot should exist? let version = match update_branch( diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index 2509c666..9c73209f 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -20,6 +20,7 @@ use thiserror::Error; use tokio::sync::RwLock; use crate::{ + change_set::ChangeSet, format::{ manifest::VirtualChunkRef, snapshot::{NodeData, UserAttributesSnapshot}, @@ -398,13 +399,25 @@ impl Store { /// Commit the current changes to the current branch. If the store is not currently /// on a branch, this will return an error. pub async fn commit(&mut self, message: &str) -> StoreResult { + self.distributed_commit(message, vec![]).await + } + + pub async fn distributed_commit<'a, I: IntoIterator>>( + &mut self, + message: &str, + other_changesets_bytes: I, + ) -> StoreResult { if let Some(branch) = &self.current_branch { + let other_change_sets: Vec = other_changesets_bytes + .into_iter() + .map(|v| ChangeSet::import_from_bytes(v.as_slice())) + .try_collect()?; let result = self .repository .write() .await .deref_mut() - .commit(branch, message, None) + .distributed_commit(branch, other_change_sets, message, None) .await?; Ok(result) } else { @@ -428,6 +441,10 @@ impl Store { Ok(futures::stream::iter(all)) } + pub async fn change_set_bytes(&self) -> StoreResult> { + Ok(self.repository.read().await.change_set_bytes()?) + } + pub async fn empty(&self) -> StoreResult { let res = self.repository.read().await.list_nodes().await?.next().is_none(); Ok(res) @@ -2042,10 +2059,7 @@ mod tests { Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group"}"#), ) .await; - let correct_error = match result { - Err(StoreError::ReadOnly { .. }) => true, - _ => false, - }; + let correct_error = matches!(result, Err(StoreError::ReadOnly { .. })); assert!(correct_error); readable_store.get("zarr.json", &ByteRange::ALL).await.unwrap(); From 12231421e17bcfdfc1629d0aec1882baa762870a Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Wed, 2 Oct 2024 20:41:54 -0300 Subject: [PATCH 015/167] Ruff --- icechunk-python/notebooks/demo-s3.ipynb | 8 ++- icechunk-python/python/icechunk/__init__.py | 9 ++- .../python/icechunk/_icechunk_python.pyi | 4 +- .../tests/test_distributed_writers.py | 71 +++++++++++++------ 4 files changed, 66 insertions(+), 26 deletions(-) diff --git a/icechunk-python/notebooks/demo-s3.ipynb b/icechunk-python/notebooks/demo-s3.ipynb index 41f5872e..c43df6d4 100644 --- a/icechunk-python/notebooks/demo-s3.ipynb +++ b/icechunk-python/notebooks/demo-s3.ipynb @@ -39,7 +39,9 @@ "metadata": {}, "outputs": [], "source": [ - "s3_storage = StorageConfig.s3_from_env(bucket=\"icechunk-test\", prefix=\"oscar-demo-repository\")" + "s3_storage = StorageConfig.s3_from_env(\n", + " bucket=\"icechunk-test\", prefix=\"oscar-demo-repository\"\n", + ")" ] }, { @@ -1150,7 +1152,9 @@ "from icechunk import IcechunkStore, StorageConfig\n", "\n", "# TODO: catalog will handle this\n", - "s3_storage = StorageConfig.s3_from_env(bucket=\"icechunk-test\", prefix=\"oscar-demo-repository\")" + "s3_storage = StorageConfig.s3_from_env(\n", + " bucket=\"icechunk-test\", prefix=\"oscar-demo-repository\"\n", + ")" ] }, { diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index 158120a6..dfa09bd6 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -223,7 +223,6 @@ def snapshot_id(self) -> str: """Return the current snapshot id.""" return self._store.snapshot_id - def change_set_bytes(self) -> bytes: return self._store.change_set_bytes() @@ -264,8 +263,12 @@ async def commit(self, message: str) -> str: """ return await self._store.commit(message) - async def distributed_commit(self, message: str, other_change_set_bytes: Iterable[bytes]) -> str: - return await self._store.distributed_commit(message, list(other_change_set_bytes)) + async def distributed_commit( + self, message: str, other_change_set_bytes: Iterable[bytes] + ) -> str: + return await self._store.distributed_commit( + message, list(other_change_set_bytes) + ) @property def has_uncommitted_changes(self) -> bool: diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index ea5cd457..71f1c715 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -13,7 +13,9 @@ class PyIcechunkStore: async def checkout_snapshot(self, snapshot_id: str) -> None: ... async def checkout_branch(self, branch: str) -> None: ... async def checkout_tag(self, tag: str) -> None: ... - async def distributed_commit(self, message: str, other_change_set_bytes: Iterable[bytes]) -> str: ... + async def distributed_commit( + self, message: str, other_change_set_bytes: Iterable[bytes] + ) -> str: ... async def commit(self, message: str) -> str: ... @property def has_uncommitted_changes(self) -> bool: ... diff --git a/icechunk-python/tests/test_distributed_writers.py b/icechunk-python/tests/test_distributed_writers.py index 2eee5b2f..81abb828 100644 --- a/icechunk-python/tests/test_distributed_writers.py +++ b/icechunk-python/tests/test_distributed_writers.py @@ -1,13 +1,13 @@ from dataclasses import dataclass import time import asyncio -import random import zarr from dask.distributed import Client import numpy as np import icechunk + @dataclass class Task: # fixme: useee StorageConfig and StoreConfig once those are pickable @@ -16,6 +16,7 @@ class Task: area: slice seed: int + # We create a 2-d array with this many chunks along each direction CHUNKS_PER_DIM = 10 @@ -25,13 +26,14 @@ class Task: # We split the writes in tasks, each task does this many chunks CHUNKS_PER_TASK = 2 + async def mk_store(mode: str, task: Task): storage_config = icechunk.StorageConfig.s3_from_config( **task.storage_config, credentials=icechunk.S3Credentials( access_key_id="minio123", secret_access_key="minio123", - ) + ), ) store_config = icechunk.StoreConfig(**task.store_config) @@ -43,6 +45,7 @@ async def mk_store(mode: str, task: Task): return store + def generate_task_array(task: Task): np.random.seed(task.seed) nx = len(range(*task.area[0].indices(1000))) @@ -62,6 +65,7 @@ async def execute_task(task: Task): def run_task(task: Task): return asyncio.run(execute_task(task)) + async def test_distributed_writers(): """Write to an array using uncoordinated writers, distributed via Dask. @@ -74,23 +78,44 @@ async def test_distributed_writers(): client = Client(n_workers=8) storage_config = { - "bucket":"testbucket", - "prefix":"python-distributed-writers-test__" + str(time.time()), - "endpoint_url":"http://localhost:9000", - "allow_http":True, + "bucket": "testbucket", + "prefix": "python-distributed-writers-test__" + str(time.time()), + "endpoint_url": "http://localhost:9000", + "allow_http": True, } - store_config = {"inline_chunk_threshold_bytes":5} - + store_config = {"inline_chunk_threshold_bytes": 5} ranges = [ - (slice(x, min(x+CHUNKS_PER_TASK*CHUNK_DIM_SIZE, CHUNK_DIM_SIZE*CHUNKS_PER_DIM)), - slice(y, min(y+CHUNKS_PER_TASK*CHUNK_DIM_SIZE, CHUNK_DIM_SIZE*CHUNKS_PER_DIM)) - ) - for x in range(0, CHUNK_DIM_SIZE*CHUNKS_PER_DIM, CHUNKS_PER_TASK*CHUNK_DIM_SIZE) - for y in range(0, CHUNK_DIM_SIZE*CHUNKS_PER_DIM, CHUNKS_PER_TASK*CHUNK_DIM_SIZE) - ] + ( + slice( + x, + min( + x + CHUNKS_PER_TASK * CHUNK_DIM_SIZE, + CHUNK_DIM_SIZE * CHUNKS_PER_DIM, + ), + ), + slice( + y, + min( + y + CHUNKS_PER_TASK * CHUNK_DIM_SIZE, + CHUNK_DIM_SIZE * CHUNKS_PER_DIM, + ), + ), + ) + for x in range( + 0, CHUNK_DIM_SIZE * CHUNKS_PER_DIM, CHUNKS_PER_TASK * CHUNK_DIM_SIZE + ) + for y in range( + 0, CHUNK_DIM_SIZE * CHUNKS_PER_DIM, CHUNKS_PER_TASK * CHUNK_DIM_SIZE + ) + ] tasks = [ - Task(storage_config=storage_config, store_config=store_config, area=area, seed=idx) + Task( + storage_config=storage_config, + store_config=store_config, + area=area, + seed=idx, + ) for idx, area in enumerate(ranges) ] store = await mk_store("r+", tasks[0]) @@ -98,9 +123,13 @@ async def test_distributed_writers(): n = CHUNKS_PER_DIM * CHUNK_DIM_SIZE array = group.create_array( - "array", shape=(n, n), chunk_shape=(CHUNK_DIM_SIZE, CHUNK_DIM_SIZE), dtype="f8", fill_value=float('nan') + "array", + shape=(n, n), + chunk_shape=(CHUNK_DIM_SIZE, CHUNK_DIM_SIZE), + dtype="f8", + fill_value=float("nan"), ) - first_snap = await store.commit("array created") + _first_snap = await store.commit("array created") map_result = client.map(run_task, tasks) change_sets_bytes = client.gather(map_result) @@ -109,12 +138,14 @@ async def test_distributed_writers(): # all changes come from the tasks, Icechunk doesn't care about where the changes come from, the only # important thing is to not count changes twice commit_res = await store.distributed_commit("distributed commit", change_sets_bytes) - assert(commit_res) + assert commit_res # Lets open a new store to verify the results store = await mk_store("r", tasks[0]) - all_keys = [key async for key in store.list_prefix("/")] - assert len(all_keys) == 1 + 1 + CHUNKS_PER_DIM*CHUNKS_PER_DIM # group meta + array meta + each chunk + all_keys = [key async for key in store.list_prefix("/")] + assert ( + len(all_keys) == 1 + 1 + CHUNKS_PER_DIM * CHUNKS_PER_DIM + ) # group meta + array meta + each chunk group = zarr.group(store=store, overwrite=False) From 4a70ba466f52fb6395ccd582c8892496ea61d43d Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Wed, 2 Oct 2024 21:41:16 -0300 Subject: [PATCH 016/167] Python's distributed_commit takes list instead of Iterable --- icechunk-python/python/icechunk/__init__.py | 6 ++---- icechunk-python/python/icechunk/_icechunk_python.pyi | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index dfa09bd6..17aab712 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -264,11 +264,9 @@ async def commit(self, message: str) -> str: return await self._store.commit(message) async def distributed_commit( - self, message: str, other_change_set_bytes: Iterable[bytes] + self, message: str, other_change_set_bytes: list[bytes] ) -> str: - return await self._store.distributed_commit( - message, list(other_change_set_bytes) - ) + return await self._store.distributed_commit(message, other_change_set_bytes) @property def has_uncommitted_changes(self) -> bool: diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index 71f1c715..b5fc847e 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -1,7 +1,6 @@ import abc import datetime from collections.abc import AsyncGenerator -from typing import Iterable class PyIcechunkStore: def with_mode(self, read_only: bool) -> PyIcechunkStore: ... @@ -14,7 +13,7 @@ class PyIcechunkStore: async def checkout_branch(self, branch: str) -> None: ... async def checkout_tag(self, tag: str) -> None: ... async def distributed_commit( - self, message: str, other_change_set_bytes: Iterable[bytes] + self, message: str, other_change_set_bytes: list[bytes] ) -> str: ... async def commit(self, message: str) -> str: ... @property From 2f1537a6c6eed4598f3ca0fa728b2c46a556248a Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Thu, 3 Oct 2024 14:25:49 -0300 Subject: [PATCH 017/167] small dask write example --- icechunk-python/examples/dask_write.py | 238 +++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 icechunk-python/examples/dask_write.py diff --git a/icechunk-python/examples/dask_write.py b/icechunk-python/examples/dask_write.py new file mode 100644 index 00000000..eb7df85e --- /dev/null +++ b/icechunk-python/examples/dask_write.py @@ -0,0 +1,238 @@ +""" +This scripts uses dask to write a big array to S3. + +Example usage: + +``` +python ./examples/dask_write.py create --name test --t-chunks 100000 +python ./examples/dask_write.py update --name test --t-from 0 --t-to 1500 --workers 16 --max-sleep 0.1 --min-sleep 0 --sleep-tasks 300 +python ./examples/dask_write.py verify --name test --t-from 0 --t-to 1500 --workers 16 +``` +""" +import argparse +from dataclasses import dataclass +import time +import asyncio +import zarr +from dask.distributed import Client +import numpy as np + +import icechunk + + +@dataclass +class Task: + # fixme: useee StorageConfig and StoreConfig once those are pickable + storage_config: dict + store_config: dict + time: int + seed: int + sleep: float + + +async def mk_store(mode: str, task: Task): + storage_config = icechunk.StorageConfig.s3_from_env(**task.storage_config) + store_config = icechunk.StoreConfig(**task.store_config) + + store = await icechunk.IcechunkStore.open( + storage=storage_config, + mode="a", + config=store_config, + ) + + return store + + +def generate_task_array(task: Task, shape): + np.random.seed(task.seed) + return np.random.rand(*shape) + + +async def execute_write_task(task: Task): + from zarr import config + config.set({"async.concurrency": 10}) + + store = await mk_store("w", task) + + group = zarr.group(store=store, overwrite=False) + array = group["array"] + print(f"Writing at t={task.time}") + data = generate_task_array(task, array.shape[0:2]) + array[:,:,task.time] = data + print(f"Writing at t={task.time} done") + if task.sleep != 0: + print(f"Sleeping for {task.sleep} secs") + time.sleep(task.sleep) + return store.change_set_bytes() + + +async def execute_read_task(task: Task): + print(f"Reading t={task.time}") + store = await mk_store("r", task) + group = zarr.group(store=store, overwrite=False) + array = group["array"] + + actual = array[:,:,task.time] + expected = generate_task_array(task, array.shape[0:2]) + np.testing.assert_array_equal(actual, expected) + +def run_write_task(task: Task): + return asyncio.run(execute_write_task(task)) + +def run_read_task(task: Task): + return asyncio.run(execute_read_task(task)) + +def storage_config(args): + prefix = f"seba-tests/icechunk/{args.name}" + return { + "bucket":"arraylake-test", + "prefix": prefix, + } + +def store_config(args): + return {"inline_chunk_threshold_bytes": 1} + +async def create(args): + store = await icechunk.IcechunkStore.open( + storage=icechunk.StorageConfig.s3_from_env(**storage_config(args)), + mode="w", + config=icechunk.StoreConfig(**store_config(args)), + ) + + group = zarr.group(store=store, overwrite=True) + shape = (args.x_chunks * args.chunk_x_size, args.y_chunks * args.chunk_y_size, args.t_chunks * 1) + chunk_shape = (args.chunk_x_size, args.chunk_y_size, 1) + + array = group.create_array( + "array", + shape=shape, + chunk_shape=chunk_shape, + dtype="f8", + fill_value=float("nan"), + ) + _first_snap = await store.commit("array created") + print("Array initialized") + +async def update(args): + storage_conf = storage_config(args) + store_conf = store_config(args) + + store = await icechunk.IcechunkStore.open( + storage=icechunk.StorageConfig.s3_from_env(**storage_conf), + mode="r+", + config=icechunk.StoreConfig(**store_conf), + ) + + group = zarr.group(store=store, overwrite=False) + array = group["array"] + print(f"Found an array of shape: {array.shape}") + + tasks = [ + Task( + storage_config=storage_conf, + store_config=store_conf, + time=time, + seed=time, + sleep=max(0, args.max_sleep - ((args.max_sleep - args.min_sleep)/args.sleep_tasks * time)) + ) + for time in range(args.t_from, args.t_to, 1) + ] + + client = Client(n_workers=args.workers, threads_per_worker=1) + + map_result = client.map(run_write_task, tasks) + change_sets_bytes = client.gather(map_result) + + print("Starting distributed commit") + # we can use the current store as the commit coordinator, because it doesn't have any pending changes, + # all changes come from the tasks, Icechunk doesn't care about where the changes come from, the only + # important thing is to not count changes twice + commit_res = await store.distributed_commit("distributed commit", change_sets_bytes) + assert commit_res + print("Distributed commit done") + +async def verify(args): + storage_conf = storage_config(args) + store_conf = store_config(args) + + store = await icechunk.IcechunkStore.open( + storage=icechunk.StorageConfig.s3_from_env(**storage_conf), + mode="r+", + config=icechunk.StoreConfig(**store_conf), + ) + + group = zarr.group(store=store, overwrite=False) + array = group["array"] + print(f"Found an array of shape: {array.shape}") + + tasks = [ + Task( + storage_config=storage_conf, + store_config=store_conf, + time=time, + seed=time, + sleep=0 + ) + for time in range(args.t_from, args.t_to, 1) + ] + + client = Client(n_workers=args.workers, threads_per_worker=1) + + map_result = client.map(run_read_task, tasks) + client.gather(map_result) + print(f"done, all good") + + + +async def distributed_write(): + """Write to an array using uncoordinated writers, distributed via Dask. + + We create a big array, and then we split into workers, each worker gets + an area, where it writes random data with a known seed. Each worker + returns the bytes for its ChangeSet, then the coordinator (main thread) + does a distributed commit. When done, we open the store again and verify + we can write everything we have written. + """ + + global_parser = argparse.ArgumentParser(prog="dask_write") + subparsers = global_parser.add_subparsers(title="subcommands", required=True) + + create_parser = subparsers.add_parser("create", help="create repo and array") + create_parser.add_argument("--x-chunks", type=int, help="number of chunks in the x dimension", default=4) + create_parser.add_argument("--y-chunks", type=int, help="number of chunks in the y dimension", default=4) + create_parser.add_argument("--t-chunks", type=int, help="number of chunks in the t dimension", default=1000) + create_parser.add_argument("--chunk-x-size", type=int, help="size of chunks in the x dimension", default=112) + create_parser.add_argument("--chunk-y-size", type=int, help="size of chunks in the y dimension", default=112) + create_parser.add_argument("--name", type=str, help="repository name", required=True) + create_parser.set_defaults(command="create") + + + update_parser = subparsers.add_parser("update", help="add chunks to the array") + update_parser.add_argument("--t-from", type=int, help="time position where to start adding chunks (included)", required=True) + update_parser.add_argument("--t-to", type=int, help="time position where to stop adding chunks (not included)", required=True) + update_parser.add_argument("--workers", type=int, help="number of workers to use", required=True) + update_parser.add_argument("--name", type=str, help="repository name", required=True) + update_parser.add_argument("--max-sleep", type=float, help="initial tasks sleep by these many seconds", default=0.3) + update_parser.add_argument("--min-sleep", type=float, help="last task that sleeps does it by these many seconds, a ramp from --max-sleep", default=0) + update_parser.add_argument("--sleep-tasks", type=int, help="this many tasks sleep", default=0.3) + update_parser.set_defaults(command="update") + + update_parser = subparsers.add_parser("verify", help="verify array chunks") + update_parser.add_argument("--t-from", type=int, help="time position where to start adding chunks (included)", required=True) + update_parser.add_argument("--t-to", type=int, help="time position where to stop adding chunks (not included)", required=True) + update_parser.add_argument("--workers", type=int, help="number of workers to use", required=True) + update_parser.add_argument("--name", type=str, help="repository name", required=True) + update_parser.set_defaults(command="verify") + + args = global_parser.parse_args() + match args.command: + case "create": + await create(args) + case "update": + await update(args) + case "verify": + await verify(args) + +if __name__ == "__main__": + asyncio.run(distributed_write()) + From cd097b55da618262c84432e8fade00754999c274 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Thu, 3 Oct 2024 23:12:42 -0300 Subject: [PATCH 018/167] ruff --- icechunk-python/examples/dask_write.py | 129 +++++++++++++++++++------ 1 file changed, 101 insertions(+), 28 deletions(-) diff --git a/icechunk-python/examples/dask_write.py b/icechunk-python/examples/dask_write.py index eb7df85e..e469d946 100644 --- a/icechunk-python/examples/dask_write.py +++ b/icechunk-python/examples/dask_write.py @@ -9,6 +9,7 @@ python ./examples/dask_write.py verify --name test --t-from 0 --t-to 1500 --workers 16 ``` """ + import argparse from dataclasses import dataclass import time @@ -50,6 +51,7 @@ def generate_task_array(task: Task, shape): async def execute_write_task(task: Task): from zarr import config + config.set({"async.concurrency": 10}) store = await mk_store("w", task) @@ -58,7 +60,7 @@ async def execute_write_task(task: Task): array = group["array"] print(f"Writing at t={task.time}") data = generate_task_array(task, array.shape[0:2]) - array[:,:,task.time] = data + array[:, :, task.time] = data print(f"Writing at t={task.time} done") if task.sleep != 0: print(f"Sleeping for {task.sleep} secs") @@ -72,26 +74,31 @@ async def execute_read_task(task: Task): group = zarr.group(store=store, overwrite=False) array = group["array"] - actual = array[:,:,task.time] + actual = array[:, :, task.time] expected = generate_task_array(task, array.shape[0:2]) np.testing.assert_array_equal(actual, expected) + def run_write_task(task: Task): return asyncio.run(execute_write_task(task)) + def run_read_task(task: Task): return asyncio.run(execute_read_task(task)) + def storage_config(args): prefix = f"seba-tests/icechunk/{args.name}" return { - "bucket":"arraylake-test", + "bucket": "arraylake-test", "prefix": prefix, } + def store_config(args): return {"inline_chunk_threshold_bytes": 1} + async def create(args): store = await icechunk.IcechunkStore.open( storage=icechunk.StorageConfig.s3_from_env(**storage_config(args)), @@ -100,10 +107,14 @@ async def create(args): ) group = zarr.group(store=store, overwrite=True) - shape = (args.x_chunks * args.chunk_x_size, args.y_chunks * args.chunk_y_size, args.t_chunks * 1) + shape = ( + args.x_chunks * args.chunk_x_size, + args.y_chunks * args.chunk_y_size, + args.t_chunks * 1, + ) chunk_shape = (args.chunk_x_size, args.chunk_y_size, 1) - array = group.create_array( + group.create_array( "array", shape=shape, chunk_shape=chunk_shape, @@ -113,6 +124,7 @@ async def create(args): _first_snap = await store.commit("array created") print("Array initialized") + async def update(args): storage_conf = storage_config(args) store_conf = store_config(args) @@ -133,7 +145,11 @@ async def update(args): store_config=store_conf, time=time, seed=time, - sleep=max(0, args.max_sleep - ((args.max_sleep - args.min_sleep)/args.sleep_tasks * time)) + sleep=max( + 0, + args.max_sleep + - ((args.max_sleep - args.min_sleep) / args.sleep_tasks * time), + ), ) for time in range(args.t_from, args.t_to, 1) ] @@ -151,6 +167,7 @@ async def update(args): assert commit_res print("Distributed commit done") + async def verify(args): storage_conf = storage_config(args) store_conf = store_config(args) @@ -171,7 +188,7 @@ async def verify(args): store_config=store_conf, time=time, seed=time, - sleep=0 + sleep=0, ) for time in range(args.t_from, args.t_to, 1) ] @@ -180,8 +197,7 @@ async def verify(args): map_result = client.map(run_read_task, tasks) client.gather(map_result) - print(f"done, all good") - + print("done, all good") async def distributed_write(): @@ -198,30 +214,87 @@ async def distributed_write(): subparsers = global_parser.add_subparsers(title="subcommands", required=True) create_parser = subparsers.add_parser("create", help="create repo and array") - create_parser.add_argument("--x-chunks", type=int, help="number of chunks in the x dimension", default=4) - create_parser.add_argument("--y-chunks", type=int, help="number of chunks in the y dimension", default=4) - create_parser.add_argument("--t-chunks", type=int, help="number of chunks in the t dimension", default=1000) - create_parser.add_argument("--chunk-x-size", type=int, help="size of chunks in the x dimension", default=112) - create_parser.add_argument("--chunk-y-size", type=int, help="size of chunks in the y dimension", default=112) - create_parser.add_argument("--name", type=str, help="repository name", required=True) + create_parser.add_argument( + "--x-chunks", type=int, help="number of chunks in the x dimension", default=4 + ) + create_parser.add_argument( + "--y-chunks", type=int, help="number of chunks in the y dimension", default=4 + ) + create_parser.add_argument( + "--t-chunks", type=int, help="number of chunks in the t dimension", default=1000 + ) + create_parser.add_argument( + "--chunk-x-size", + type=int, + help="size of chunks in the x dimension", + default=112, + ) + create_parser.add_argument( + "--chunk-y-size", + type=int, + help="size of chunks in the y dimension", + default=112, + ) + create_parser.add_argument( + "--name", type=str, help="repository name", required=True + ) create_parser.set_defaults(command="create") - update_parser = subparsers.add_parser("update", help="add chunks to the array") - update_parser.add_argument("--t-from", type=int, help="time position where to start adding chunks (included)", required=True) - update_parser.add_argument("--t-to", type=int, help="time position where to stop adding chunks (not included)", required=True) - update_parser.add_argument("--workers", type=int, help="number of workers to use", required=True) - update_parser.add_argument("--name", type=str, help="repository name", required=True) - update_parser.add_argument("--max-sleep", type=float, help="initial tasks sleep by these many seconds", default=0.3) - update_parser.add_argument("--min-sleep", type=float, help="last task that sleeps does it by these many seconds, a ramp from --max-sleep", default=0) - update_parser.add_argument("--sleep-tasks", type=int, help="this many tasks sleep", default=0.3) + update_parser.add_argument( + "--t-from", + type=int, + help="time position where to start adding chunks (included)", + required=True, + ) + update_parser.add_argument( + "--t-to", + type=int, + help="time position where to stop adding chunks (not included)", + required=True, + ) + update_parser.add_argument( + "--workers", type=int, help="number of workers to use", required=True + ) + update_parser.add_argument( + "--name", type=str, help="repository name", required=True + ) + update_parser.add_argument( + "--max-sleep", + type=float, + help="initial tasks sleep by these many seconds", + default=0.3, + ) + update_parser.add_argument( + "--min-sleep", + type=float, + help="last task that sleeps does it by these many seconds, a ramp from --max-sleep", + default=0, + ) + update_parser.add_argument( + "--sleep-tasks", type=int, help="this many tasks sleep", default=0.3 + ) update_parser.set_defaults(command="update") update_parser = subparsers.add_parser("verify", help="verify array chunks") - update_parser.add_argument("--t-from", type=int, help="time position where to start adding chunks (included)", required=True) - update_parser.add_argument("--t-to", type=int, help="time position where to stop adding chunks (not included)", required=True) - update_parser.add_argument("--workers", type=int, help="number of workers to use", required=True) - update_parser.add_argument("--name", type=str, help="repository name", required=True) + update_parser.add_argument( + "--t-from", + type=int, + help="time position where to start adding chunks (included)", + required=True, + ) + update_parser.add_argument( + "--t-to", + type=int, + help="time position where to stop adding chunks (not included)", + required=True, + ) + update_parser.add_argument( + "--workers", type=int, help="number of workers to use", required=True + ) + update_parser.add_argument( + "--name", type=str, help="repository name", required=True + ) update_parser.set_defaults(command="verify") args = global_parser.parse_args() @@ -233,6 +306,6 @@ async def distributed_write(): case "verify": await verify(args) + if __name__ == "__main__": asyncio.run(distributed_write()) - From 906bbe6b76848a91906be217a7286da503bbc5a6 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Thu, 3 Oct 2024 20:32:29 -0300 Subject: [PATCH 019/167] Use AWS's S3 client instead of object_store We are now using aws-sdk-s3 crate. object_store showed many issues and performance problems while trying to use it with high concurrency. We are still using object_store, but only for the in-memory and local-filesystem `Storage` implementations, S3 (and minio) go through aws-sdk-s3. So the dependency no longer includes the `aws` feature. We did some changes to the virtual ref resolver. Now, we can use a single instance of an S3 client, because the S3 client allows that, unlike object_store that needs one client per bucket. This is a big simplification of the code, at least while we have a single set of credentials. We are no longer caching the object_store instances for local filesystem refs. --- Cargo.lock | 1177 ++++++++++++++------- compose.yaml | 2 +- icechunk-python/src/lib.rs | 6 +- icechunk-python/src/storage.rs | 2 +- icechunk/Cargo.toml | 5 +- icechunk/src/refs.rs | 4 +- icechunk/src/storage/caching.rs | 5 +- icechunk/src/storage/logging.rs | 5 +- icechunk/src/storage/mod.rs | 35 +- icechunk/src/storage/object_store.rs | 96 +- icechunk/src/storage/s3.rs | 419 ++++++++ icechunk/src/storage/virtual_ref.rs | 161 ++- icechunk/src/zarr.rs | 18 +- icechunk/tests/test_distributed_writes.rs | 45 +- icechunk/tests/test_s3_storage.rs | 190 ++++ icechunk/tests/test_virtual_refs.rs | 64 +- 16 files changed, 1617 insertions(+), 617 deletions(-) create mode 100644 icechunk/src/storage/s3.rs create mode 100644 icechunk/tests/test_s3_storage.rs diff --git a/Cargo.lock b/Cargo.lock index b01abd83..408a4be8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,6 +30,12 @@ dependencies = [ "zerocopy 0.7.35", ] +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -89,18 +95,387 @@ dependencies = [ "syn", ] -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "autocfg" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +[[package]] +name = "aws-config" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8191fb3091fa0561d1379ef80333c3c7191c6f0435d986e85821bcf7acbd1126" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 0.2.12", + "ring", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60e8f6b615cb5fc60a98132268508ad104310f0cfb25a1c22eee76efdf9154da" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-runtime" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a10d5c055aa540164d9561a0e2e74ad30f0dcf7393c3a92f6733ddf9c5762468" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http-body 0.4.6", + "once_cell", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fad71130014e11f42fadbdcce5df12ee61866f8ab9bad773b138d4b3c11087" +dependencies = [ + "ahash", + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 0.2.12", + "http-body 0.4.6", + "lru", + "once_cell", + "percent-encoding", + "regex-lite", + "sha2", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b90cfe6504115e13c41d3ea90286ede5aa14da294f3fe077027a6e83850843c" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "http 0.2.12", + "once_cell", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167c0fad1f212952084137308359e8e4c4724d1c643038ce163f06de9662c1d0" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "http 0.2.12", + "once_cell", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cb5f98188ec1435b68097daa2a37d74b9d17c9caa799466338a8d1544e71b9d" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "http 0.2.12", + "once_cell", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc8db6904450bafe7473c6ca9123f88cc11089e41a025408f992db4e22d3be68" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint 0.5.5", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.1.0", + "once_cell", + "p256", + "percent-encoding", + "ring", + "sha2", + "subtle", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62220bc6e97f946ddd51b5f1361f78996e704677afc518a4ff66b7a72ea1378c" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.60.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598b1689d001c4d4dc3cb386adb07d37786783aee3ac4b324bcadac116bf3d23" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc32c", + "crc32fast", + "hex", + "http 0.2.12", + "http-body 0.4.6", + "md-5", + "pin-project-lite", + "sha1", + "sha2", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cef7d0a272725f87e51ba2bf89f8c21e4df61b9e49ae1ac367a6d69916ef7c90" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.60.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8bc3e8fdc6b8d07d976e301c02fe553f72a39b7a9fea820e023268467d7ab6" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http-body 0.4.6", + "once_cell", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.60.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4683df9469ef09468dad3473d129960119a0d3593617542b7d52086c8486f2d6" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fbd61ceb3fe8a1cb7352e42689cec5335833cd9f94103a61e98f9bb61c64bb" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce695746394772e7000b39fe073095db6d45a862d0767dd5ad0ac0d7f8eb87" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "http-body 1.0.1", + "httparse", + "hyper", + "hyper-rustls", + "once_cell", + "pin-project-lite", + "pin-utils", + "rustls", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e086682a53d3aa241192aa110fa8dfce98f2f5ac2ead0de84d41582c7e8fdb96" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.1.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147100a7bea70fa20ef224a6bad700358305f5dc0f84649c53769761395b355b" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.1.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab0b0166827aa700d3dc519f72f8b3a91c35d0b8d042dc5d643a91e6f80648fc" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5221b91b3e441e6675310829fd8984801b772cb1546ef6c0e54dec9f1ac13fef" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.73" @@ -116,18 +491,46 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + [[package]] name = "base32" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "bit-set" version = "0.5.3" @@ -179,6 +582,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cc" version = "1.1.7" @@ -203,9 +616,15 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-targets 0.52.6", + "windows-targets", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation" version = "0.9.4" @@ -222,6 +641,55 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "cpufeatures" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -267,6 +735,16 @@ dependencies = [ "syn", ] +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.3.11" @@ -291,6 +769,19 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der", + "elliptic-curve", + "rfc6979", + "signature", ] [[package]] @@ -299,6 +790,26 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct", + "crypto-bigint 0.4.9", + "der", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -321,6 +832,16 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "fnv" version = "1.0.7" @@ -452,18 +973,29 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "h2" -version = "0.4.5" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa82e28a107a8cc405f0839610bdc9b15f1e25ec7d696aa5cf173edbcb1486ab" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" dependencies = [ - "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http", + "futures-util", + "http 0.2.12", "indexmap 2.2.6", "slab", "tokio", @@ -482,6 +1014,10 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] [[package]] name = "heck" @@ -507,6 +1043,26 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.1.0" @@ -518,6 +1074,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -525,7 +1092,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.1.0", ] [[package]] @@ -536,8 +1103,8 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http", - "http-body", + "http 1.1.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -547,6 +1114,12 @@ version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "humantime" version = "2.1.0" @@ -555,60 +1128,42 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" -version = "1.4.1" +version = "0.14.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" +checksum = "a152ddd61dfaec7273fe8419ab357f33aee0d914c5f4efbf0d96fa749eea5ec9" dependencies = [ "bytes", "futures-channel", + "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 0.2.12", + "http-body 0.4.6", "httparse", + "httpdate", "itoa", "pin-project-lite", - "smallvec", + "socket2", "tokio", + "tower-service", + "tracing", "want", ] [[package]] name = "hyper-rustls" -version = "0.27.2" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" dependencies = [ "futures-util", - "http", + "http 0.2.12", "hyper", - "hyper-util", + "log", "rustls", "rustls-native-certs", - "rustls-pki-types", "tokio", "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "pin-project-lite", - "socket2", - "tokio", - "tower", - "tower-service", - "tracing", ] [[package]] @@ -641,8 +1196,11 @@ dependencies = [ "async-recursion", "async-stream", "async-trait", + "aws-config", + "aws-credential-types", + "aws-sdk-s3", "base32", - "base64", + "base64 0.22.1", "bytes", "chrono", "futures", @@ -724,12 +1282,6 @@ version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" -[[package]] -name = "ipnet" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" - [[package]] name = "itertools" version = "0.13.0" @@ -794,6 +1346,15 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "lru" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ee39891760e7d94734f6f63fedc29a2e4a152f836120753a72503f09fcf904" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "md-5" version = "0.10.6" @@ -819,12 +1380,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - [[package]] name = "miniz_oxide" version = "0.7.4" @@ -852,6 +1407,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -878,22 +1442,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25a0c4b3a0e31f8b66f71ad8064521efa773910196e2cde791436f13409f3b45" dependencies = [ "async-trait", - "base64", "bytes", "chrono", "futures", "humantime", - "hyper", "itertools", - "md-5", "parking_lot", "percent-encoding", - "quick-xml", - "rand", - "reqwest", - "ring", - "serde", - "serde_json", "snafu", "tokio", "tracing", @@ -913,6 +1468,23 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +[[package]] +name = "outref" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4030760ffd992bef45b0ae3f10ce1aba99e33464c90d14dd7c039884963ddc7a" + +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa", + "elliptic-curve", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.3" @@ -933,7 +1505,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -942,31 +1514,11 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "percent-encoding" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" - -[[package]] -name = "pin-project" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project-lite" @@ -980,6 +1532,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der", + "spki", +] + [[package]] name = "portable-atomic" version = "1.7.0" @@ -1132,16 +1694,6 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" -[[package]] -name = "quick-xml" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96a05e2e8efddfa51a84ca47cec303fac86c8541b686d37cac5efc0e094417bc" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quick_cache" version = "0.6.9" @@ -1154,54 +1706,6 @@ dependencies = [ "parking_lot", ] -[[package]] -name = "quinn" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b22d8e7369034b9a7132bc2008cac12f2013c8132b45e0554e6e20e2617f2156" -dependencies = [ - "bytes", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror", - "tokio", - "tracing", -] - -[[package]] -name = "quinn-proto" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fadfaed2cd7f389d0161bb73eeb07b7b78f8691047a6f3e73caaeae55310a4a6" -dependencies = [ - "bytes", - "rand", - "ring", - "rustc-hash", - "rustls", - "slab", - "thiserror", - "tinyvec", - "tracing", -] - -[[package]] -name = "quinn-udp" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bffec3605b73c6f1754535084a85229fa8a30f86014e6c81aeec4abb68b0285" -dependencies = [ - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.52.0", -] - [[package]] name = "quote" version = "1.0.36" @@ -1259,6 +1763,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-lite" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" + [[package]] name = "regex-syntax" version = "0.8.4" @@ -1266,48 +1776,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" [[package]] -name = "reqwest" -version = "0.12.5" +name = "rfc6979" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" dependencies = [ - "base64", - "bytes", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "ipnet", - "js-sys", - "log", - "mime", - "once_cell", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-native-certs", - "rustls-pemfile", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "winreg", + "crypto-bigint 0.4.9", + "hmac", + "zeroize", ] [[package]] @@ -1366,10 +1842,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] -name = "rustc-hash" -version = "2.0.0" +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] [[package]] name = "rustix" @@ -1386,55 +1865,44 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.12" +version = "0.21.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ - "once_cell", + "log", "ring", - "rustls-pki-types", "rustls-webpki", - "subtle", - "zeroize", + "sct", ] [[package]] name = "rustls-native-certs" -version = "0.7.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88d6d420651b496bdd98684116959239430022a115c1240e6c3993be0b15fba" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" dependencies = [ "openssl-probe", "rustls-pemfile", - "rustls-pki-types", "schannel", "security-framework", ] [[package]] name = "rustls-pemfile" -version = "2.1.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "196fe16b00e106300d3e45ecfcb764fa292a535d7326a29a5875c579c7417425" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64", - "rustls-pki-types", + "base64 0.21.7", ] -[[package]] -name = "rustls-pki-types" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" - [[package]] name = "rustls-webpki" -version = "0.102.6" +version = "0.101.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e6b52d4fda176fd835fdc55a835d4a89b8499cad995885a21149d5ad62f852e" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ "ring", - "rustls-pki-types", "untrusted", ] @@ -1480,6 +1948,30 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -1503,6 +1995,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + [[package]] name = "serde" version = "1.0.210" @@ -1544,25 +2042,13 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - [[package]] name = "serde_with" version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cecfa94848272156ea67b2b1a53f20fc7bc638c4a46d2f8abde08f05f4b857" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", @@ -1586,6 +2072,47 @@ dependencies = [ "syn", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "slab" version = "0.4.9" @@ -1638,6 +2165,16 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1684,12 +2221,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" - [[package]] name = "target-lexicon" version = "0.12.16" @@ -1798,6 +2329,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.52.0", @@ -1816,12 +2348,11 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ "rustls", - "rustls-pki-types", "tokio", ] @@ -1838,27 +2369,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "pin-project", - "pin-project-lite", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - [[package]] name = "tower-service" version = "0.3.3" @@ -1958,12 +2468,30 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "uuid" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.0" @@ -2023,18 +2551,6 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "wasm-bindgen-macro" version = "0.2.92" @@ -2064,29 +2580,6 @@ version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" -[[package]] -name = "wasm-streams" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "web-sys" -version = "0.3.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "winapi-util" version = "0.1.9" @@ -2102,16 +2595,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", + "windows-targets", ] [[package]] @@ -2120,7 +2604,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -2129,22 +2613,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", + "windows-targets", ] [[package]] @@ -2153,46 +2622,28 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2205,48 +2656,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2254,14 +2681,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "winreg" -version = "0.52.0" +name = "xmlparser" +version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" [[package]] name = "yansi" diff --git a/compose.yaml b/compose.yaml index 6034aac4..5f3ee303 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,7 +4,7 @@ volumes: services: minio: container_name: icechunk_minio - image: quay.io/minio/minio + image: minio/minio entrypoint: | /bin/sh -c ' for bucket in testbucket externalbucket arraylake-repo-bucket diff --git a/icechunk-python/src/lib.rs b/icechunk-python/src/lib.rs index c1f2bce1..0be1b6b1 100644 --- a/icechunk-python/src/lib.rs +++ b/icechunk-python/src/lib.rs @@ -112,8 +112,10 @@ type KeyRanges = Vec<(String, (Option, Option))>; impl PyIcechunkStore { async fn store_exists(storage: StorageConfig) -> PyIcechunkStoreResult { - let storage = - storage.make_cached_storage().map_err(PyIcechunkStoreError::UnkownError)?; + let storage = storage + .make_cached_storage() + .await + .map_err(PyIcechunkStoreError::UnkownError)?; let exists = Repository::exists(storage.as_ref()).await?; Ok(exists) } diff --git a/icechunk-python/src/storage.rs b/icechunk-python/src/storage.rs index 22b1ea18..5217572f 100644 --- a/icechunk-python/src/storage.rs +++ b/icechunk-python/src/storage.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use icechunk::{ storage::{ - object_store::{S3Config, S3Credentials}, + s3::{S3Config, S3Credentials}, virtual_ref::ObjectStoreVirtualChunkResolverConfig, }, zarr::StorageConfig, diff --git a/icechunk/Cargo.toml b/icechunk/Cargo.toml index 6c3fc497..52713ee2 100644 --- a/icechunk/Cargo.toml +++ b/icechunk/Cargo.toml @@ -11,7 +11,7 @@ bytes = { version = "1.7.2", features = ["serde"] } base64 = "0.22.1" futures = "0.3.30" itertools = "0.13.0" -object_store = { version = "0.11.0", features = ["aws"] } +object_store = { version = "0.11.0" } rand = "0.8.5" thiserror = "1.0.64" serde_json = "1.0.128" @@ -28,6 +28,9 @@ rmp-serde = "1.3.0" url = "2.5.2" async-stream = "0.3.5" rmpv = { version = "1.3.0", features = ["serde", "with-serde"] } +aws-sdk-s3 = "1.53.0" +aws-config = "1.5.7" +aws-credential-types = "1.2.1" [dev-dependencies] pretty_assertions = "1.4.1" diff --git a/icechunk/src/refs.rs b/icechunk/src/refs.rs index 68d8016f..e5ba906c 100644 --- a/icechunk/src/refs.rs +++ b/icechunk/src/refs.rs @@ -46,7 +46,7 @@ pub enum RefError { pub type RefResult = Result; -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Hash)] pub enum Ref { Tag(String), Branch(String), @@ -196,7 +196,7 @@ async fn branch_history<'a, 'b>( branch: &'b str, ) -> RefResult> + 'a> { let key = branch_root(branch)?; - let all = storage.ref_versions(key.as_str()).await; + let all = storage.ref_versions(key.as_str()).await?; Ok(all.map_err(|e| e.into()).and_then(move |version_id| async move { let version = version_id .strip_suffix(".json") diff --git a/icechunk/src/storage/caching.rs b/icechunk/src/storage/caching.rs index 1800ec45..e36f08b9 100644 --- a/icechunk/src/storage/caching.rs +++ b/icechunk/src/storage/caching.rs @@ -152,7 +152,10 @@ impl Storage for MemCachingStorage { self.backend.write_ref(ref_key, overwrite_refs, bytes).await } - async fn ref_versions(&self, ref_name: &str) -> BoxStream> { + async fn ref_versions( + &self, + ref_name: &str, + ) -> StorageResult>> { self.backend.ref_versions(ref_name).await } } diff --git a/icechunk/src/storage/logging.rs b/icechunk/src/storage/logging.rs index 7e5334b1..38ec82f3 100644 --- a/icechunk/src/storage/logging.rs +++ b/icechunk/src/storage/logging.rs @@ -121,7 +121,10 @@ impl Storage for LoggingStorage { self.backend.write_ref(ref_key, overwrite_refs, bytes).await } - async fn ref_versions(&self, ref_name: &str) -> BoxStream> { + async fn ref_versions( + &self, + ref_name: &str, + ) -> StorageResult>> { self.backend.ref_versions(ref_name).await } } diff --git a/icechunk/src/storage/mod.rs b/icechunk/src/storage/mod.rs index f8a9f10d..716602dc 100644 --- a/icechunk/src/storage/mod.rs +++ b/icechunk/src/storage/mod.rs @@ -1,6 +1,15 @@ +use aws_sdk_s3::{ + config::http::HttpResponse, + error::SdkError, + operation::{ + get_object::GetObjectError, list_objects_v2::ListObjectsV2Error, + put_object::PutObjectError, + }, + primitives::ByteStreamError, +}; use core::fmt; use futures::stream::BoxStream; -use std::sync::Arc; +use std::{ffi::OsString, sync::Arc}; use async_trait::async_trait; use bytes::Bytes; @@ -12,6 +21,7 @@ pub mod caching; pub mod logging; pub mod object_store; +pub mod s3; pub mod virtual_ref; pub use caching::MemCachingStorage; @@ -19,30 +29,36 @@ pub use object_store::ObjectStorage; use crate::format::{ attributes::AttributesTable, manifest::Manifest, snapshot::Snapshot, AttributesId, - ByteRange, ChunkId, ManifestId, Path, SnapshotId, + ByteRange, ChunkId, ManifestId, SnapshotId, }; #[derive(Debug, Error)] pub enum StorageError { #[error("error contacting object store {0}")] ObjectStore(#[from] ::object_store::Error), + #[error("bad object store prefix {0:?}")] + BadPrefix(OsString), + #[error("error getting object from object store {0}")] + S3GetObjectError(#[from] SdkError), + #[error("error writing object to object store {0}")] + S3PutObjectError(#[from] SdkError), + #[error("error listing objects in object store {0}")] + S3ListObjectError(#[from] SdkError), + #[error("error streaming bytes from object store {0}")] + S3StreamError(#[from] ByteStreamError), #[error("messagepack decode error: {0}")] MsgPackDecodeError(#[from] rmp_serde::decode::Error), #[error("messagepack encode error: {0}")] MsgPackEncodeError(#[from] rmp_serde::encode::Error), - #[error("error parsing RecordBatch from parquet file {0}.")] - BadRecordBatchRead(Path), #[error("cannot overwrite ref: {0}")] RefAlreadyExists(String), #[error("ref not found: {0}")] RefNotFound(String), - #[error("generic storage error: {0}")] - OtherError(#[from] Arc), #[error("unknown storage error: {0}")] Other(String), } -type StorageResult = Result; +pub type StorageResult = Result; /// Fetch and write the parquet files that represent the repository in object store /// @@ -77,7 +93,10 @@ pub trait Storage: fmt::Debug { async fn get_ref(&self, ref_key: &str) -> StorageResult; async fn ref_names(&self) -> StorageResult>; - async fn ref_versions(&self, ref_name: &str) -> BoxStream>; + async fn ref_versions( + &self, + ref_name: &str, + ) -> StorageResult>>; async fn write_ref( &self, ref_key: &str, diff --git a/icechunk/src/storage/object_store.rs b/icechunk/src/storage/object_store.rs index 7374e9df..56742e70 100644 --- a/icechunk/src/storage/object_store.rs +++ b/icechunk/src/storage/object_store.rs @@ -5,17 +5,12 @@ use crate::format::{ }; use async_trait::async_trait; use bytes::Bytes; -use core::fmt; use futures::{stream::BoxStream, StreamExt, TryStreamExt}; use object_store::{ - aws::{AmazonS3Builder, S3ConditionalPut}, - local::LocalFileSystem, - memory::InMemory, - path::Path as ObjectPath, - Attribute, AttributeValue, Attributes, GetOptions, GetRange, ObjectStore, PutMode, - PutOptions, PutPayload, + local::LocalFileSystem, memory::InMemory, path::Path as ObjectPath, Attribute, + AttributeValue, Attributes, GetOptions, GetRange, ObjectStore, PutMode, PutOptions, + PutPayload, }; -use serde::{Deserialize, Serialize}; use std::{ fs::create_dir_all, future::ready, ops::Bound, path::Path as StdPath, sync::Arc, }; @@ -61,58 +56,7 @@ const MANIFEST_PREFIX: &str = "manifests/"; const CHUNK_PREFIX: &str = "chunks/"; const REF_PREFIX: &str = "refs"; -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub struct S3Credentials { - pub access_key_id: String, - pub secret_access_key: String, - pub session_token: Option, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] -pub struct S3Config { - pub region: Option, - pub endpoint: Option, - pub credentials: Option, - pub allow_http: Option, -} - -// TODO: Hide this behind a feature flag? -impl S3Config { - pub fn to_builder(&self) -> AmazonS3Builder { - let builder = if let Some(credentials) = &self.credentials { - let builder = AmazonS3Builder::new() - .with_access_key_id(credentials.access_key_id.clone()) - .with_secret_access_key(credentials.secret_access_key.clone()); - - if let Some(token) = &credentials.session_token { - builder.with_token(token.clone()) - } else { - builder - } - } else { - AmazonS3Builder::from_env() - }; - - let builder = if let Some(region) = &self.region { - builder.with_region(region.clone()) - } else { - builder - }; - - let builder = if let Some(endpoint) = &self.endpoint { - builder.with_endpoint(endpoint.clone()) - } else { - builder - }; - - if let Some(allow_http) = self.allow_http { - builder.with_allow_http(allow_http) - } else { - builder - } - } -} - +#[derive(Debug)] pub struct ObjectStorage { store: Arc, prefix: String, @@ -157,24 +101,6 @@ impl ObjectStorage { }) } - pub fn new_s3_store( - bucket_name: impl Into, - prefix: impl Into, - config: Option, - ) -> Result { - let config = config.unwrap_or_default(); - let builder = config.to_builder(); - let builder = builder.with_conditional_put(S3ConditionalPut::ETagMatch); - let store = builder.with_bucket_name(bucket_name.into()).build()?; - Ok(ObjectStorage { - store: Arc::new(store), - prefix: prefix.into(), - artificially_sort_refs_in_mem: false, - supports_create_if_not_exists: true, - supports_metadata: true, - }) - } - fn get_path( &self, file_prefix: &str, @@ -225,11 +151,6 @@ impl ObjectStorage { } } -impl fmt::Debug for ObjectStorage { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!(f, "ObjectStorage, prefix={}, store={}", self.prefix, self.store) - } -} #[async_trait] impl Storage for ObjectStorage { async fn fetch_snapshot( @@ -391,7 +312,10 @@ impl Storage for ObjectStorage { .collect()) } - async fn ref_versions(&self, ref_name: &str) -> BoxStream> { + async fn ref_versions( + &self, + ref_name: &str, + ) -> StorageResult>> { let res = self.do_ref_versions(ref_name).await; if self.artificially_sort_refs_in_mem { #[allow(clippy::expect_used)] @@ -401,9 +325,9 @@ impl Storage for ObjectStorage { let mut all = res.try_collect::>().await.expect("Error fetching ref versions"); all.sort(); - futures::stream::iter(all.into_iter().map(Ok)).boxed() + Ok(futures::stream::iter(all.into_iter().map(Ok)).boxed()) } else { - res + Ok(res) } } diff --git a/icechunk/src/storage/s3.rs b/icechunk/src/storage/s3.rs new file mode 100644 index 00000000..d4955a1c --- /dev/null +++ b/icechunk/src/storage/s3.rs @@ -0,0 +1,419 @@ +use std::{ops::Bound, path::PathBuf, sync::Arc}; + +use async_stream::try_stream; +use async_trait::async_trait; +use aws_config::{meta::region::RegionProviderChain, AppName, BehaviorVersion}; +use aws_credential_types::Credentials; +use aws_sdk_s3::{ + config::{Builder, Region}, + error::ProvideErrorMetadata, + primitives::ByteStream, + Client, +}; +use bytes::Bytes; +use futures::StreamExt; +use serde::{Deserialize, Serialize}; + +use crate::{ + format::{ + attributes::AttributesTable, format_constants, manifest::Manifest, + snapshot::Snapshot, AttributesId, ByteRange, ChunkId, FileTypeTag, ManifestId, + SnapshotId, + }, + zarr::ObjectId, + Storage, StorageError, +}; + +use super::StorageResult; + +#[derive(Debug)] +pub struct S3Storage { + client: Arc, + prefix: String, + bucket: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct S3Credentials { + pub access_key_id: String, + pub secret_access_key: String, + pub session_token: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct S3Config { + pub region: Option, + pub endpoint: Option, + pub credentials: Option, + pub allow_http: Option, +} + +pub async fn mk_client(config: Option<&S3Config>) -> Client { + let region = config + .and_then(|c| c.region.as_ref()) + .map(|r| RegionProviderChain::first_try(Some(Region::new(r.clone())))) + .unwrap_or_else(RegionProviderChain::default_provider); + + let endpoint = config.and_then(|c| c.endpoint.clone()); + let allow_http = config.and_then(|c| c.allow_http).unwrap_or(false); + let credentials = config.and_then(|c| c.credentials.clone()); + #[allow(clippy::unwrap_used)] + let app_name = AppName::new("icechunk").unwrap(); + let mut aws_config = aws_config::defaults(BehaviorVersion::v2024_03_28()) + .region(region) + .app_name(app_name); + + if let Some(endpoint) = endpoint { + aws_config = aws_config.endpoint_url(endpoint) + } + + if let Some(credentials) = credentials { + aws_config = aws_config.credentials_provider(Credentials::new( + credentials.access_key_id, + credentials.secret_access_key, + credentials.session_token, + None, + "user", + )); + } + + let mut s3_builder = Builder::from(&aws_config.load().await); + + if allow_http { + s3_builder = s3_builder.force_path_style(true); + } + + let config = s3_builder.build(); + + Client::from_conf(config) +} + +const SNAPSHOT_PREFIX: &str = "snapshots/"; +const MANIFEST_PREFIX: &str = "manifests/"; +// const ATTRIBUTES_PREFIX: &str = "attributes/"; +const CHUNK_PREFIX: &str = "chunks/"; +const REF_PREFIX: &str = "refs"; + +impl S3Storage { + pub async fn new_s3_store( + bucket_name: impl Into, + prefix: impl Into, + config: Option<&S3Config>, + ) -> Result { + let client = Arc::new(mk_client(config).await); + Ok(S3Storage { client, prefix: prefix.into(), bucket: bucket_name.into() }) + } + + fn get_path( + &self, + file_prefix: &str, + id: &ObjectId, + ) -> StorageResult { + // we serialize the url using crockford + let path = PathBuf::from_iter([ + self.prefix.as_str(), + file_prefix, + id.to_string().as_str(), + ]); + path.into_os_string().into_string().map_err(StorageError::BadPrefix) + } + + fn get_snapshot_path(&self, id: &SnapshotId) -> StorageResult { + self.get_path(SNAPSHOT_PREFIX, id) + } + + fn get_manifest_path(&self, id: &ManifestId) -> StorageResult { + self.get_path(MANIFEST_PREFIX, id) + } + + fn get_chunk_path(&self, id: &ChunkId) -> StorageResult { + self.get_path(CHUNK_PREFIX, id) + } + + fn ref_key(&self, ref_key: &str) -> StorageResult { + let path = PathBuf::from_iter([self.prefix.as_str(), REF_PREFIX, ref_key]); + path.into_os_string().into_string().map_err(StorageError::BadPrefix) + } + + async fn get_object(&self, key: &str) -> StorageResult { + Ok(self + .client + .get_object() + .bucket(self.bucket.clone()) + .key(key) + .send() + .await? + .body + .collect() + .await? + .into_bytes()) + } + + async fn get_object_range( + &self, + key: &str, + range: &ByteRange, + ) -> StorageResult { + let mut b = self.client.get_object().bucket(self.bucket.clone()).key(key); + + if let Some(header) = range_to_header(range) { + b = b.range(header) + }; + + Ok(b.send().await?.body.collect().await?.into_bytes()) + } + + async fn put_object< + I: IntoIterator, impl Into)>, + >( + &self, + key: &str, + content_type: Option>, + metadata: I, + bytes: impl Into, + ) -> StorageResult<()> { + let mut b = self.client.put_object().bucket(self.bucket.clone()).key(key); + + if let Some(ct) = content_type { + b = b.content_type(ct) + }; + + for (k, v) in metadata { + b = b.metadata(k, v); + } + + b.body(bytes.into()).send().await?; + Ok(()) + } +} + +pub fn range_to_header(range: &ByteRange) -> Option { + match range { + ByteRange(Bound::Unbounded, Bound::Unbounded) => None, + ByteRange(Bound::Included(start), Bound::Excluded(end)) => { + Some(format!("bytes={}-{}", start, end - 1)) + } + ByteRange(Bound::Included(start), Bound::Unbounded) => { + Some(format!("bytes={}-", start)) + } + ByteRange(Bound::Included(start), Bound::Included(end)) => { + Some(format!("bytes={}-{}", start, end)) + } + ByteRange(Bound::Excluded(start), Bound::Excluded(end)) => { + Some(format!("bytes={}-{}", start + 1, end - 1)) + } + ByteRange(Bound::Excluded(start), Bound::Unbounded) => { + Some(format!("bytes={}-", start + 1)) + } + ByteRange(Bound::Excluded(start), Bound::Included(end)) => { + Some(format!("bytes={}-{}", start + 1, end)) + } + ByteRange(Bound::Unbounded, Bound::Excluded(end)) => { + Some(format!("bytes=0-{}", end - 1)) + } + ByteRange(Bound::Unbounded, Bound::Included(end)) => { + Some(format!("bytes=0-{}", end)) + } + } +} + +#[async_trait] +impl Storage for S3Storage { + async fn fetch_snapshot(&self, id: &SnapshotId) -> StorageResult> { + let key = self.get_snapshot_path(id)?; + let bytes = self.get_object(key.as_str()).await?; + // TODO: optimize using from_read + let res = rmp_serde::from_slice(bytes.as_ref())?; + Ok(Arc::new(res)) + } + + async fn fetch_attributes( + &self, + _id: &AttributesId, + ) -> StorageResult> { + todo!() + } + + async fn fetch_manifests(&self, id: &ManifestId) -> StorageResult> { + let key = self.get_manifest_path(id)?; + let bytes = self.get_object(key.as_str()).await?; + // TODO: optimize using from_read + let res = rmp_serde::from_slice(bytes.as_ref())?; + Ok(Arc::new(res)) + } + + async fn fetch_chunk(&self, id: &ChunkId, range: &ByteRange) -> StorageResult { + let key = self.get_chunk_path(id)?; + let bytes = self.get_object_range(key.as_str(), range).await?; + Ok(bytes) + } + + async fn write_snapshot( + &self, + id: SnapshotId, + snapshot: Arc, + ) -> StorageResult<()> { + let key = self.get_snapshot_path(&id)?; + let bytes = rmp_serde::to_vec(snapshot.as_ref())?; + let metadata = [( + format_constants::LATEST_ICECHUNK_SNAPSHOT_VERSION_METADATA_KEY, + snapshot.icechunk_snapshot_format_version.to_string(), + )]; + self.put_object( + key.as_str(), + Some(format_constants::LATEST_ICECHUNK_SNAPSHOT_CONTENT_TYPE), + metadata, + bytes, + ) + .await + } + + async fn write_attributes( + &self, + _id: AttributesId, + _table: Arc, + ) -> StorageResult<()> { + todo!() + } + + async fn write_manifests( + &self, + id: ManifestId, + manifest: Arc, + ) -> Result<(), StorageError> { + let key = self.get_manifest_path(&id)?; + let bytes = rmp_serde::to_vec(manifest.as_ref())?; + let metadata = [( + format_constants::LATEST_ICECHUNK_MANIFEST_VERSION_METADATA_KEY, + manifest.icechunk_manifest_format_version.to_string(), + )]; + self.put_object( + key.as_str(), + Some(format_constants::LATEST_ICECHUNK_MANIFEST_CONTENT_TYPE), + metadata, + bytes, + ) + .await + } + + async fn write_chunk( + &self, + id: ChunkId, + bytes: bytes::Bytes, + ) -> Result<(), StorageError> { + let key = self.get_chunk_path(&id)?; + //FIXME: use multipart upload + let metadata: [(String, String); 0] = []; + self.put_object(key.as_str(), None::, metadata, bytes).await + } + + async fn get_ref(&self, ref_key: &str) -> StorageResult { + let key = self.ref_key(ref_key)?; + let res = self + .client + .get_object() + .bucket(self.bucket.clone()) + .key(key.clone()) + .send() + .await; + + match res { + Ok(res) => Ok(res.body.collect().await?.into_bytes()), + Err(err) + if err + .as_service_error() + .map(|e| e.is_no_such_key()) + .unwrap_or(false) => + { + Err(StorageError::RefNotFound(key.to_string())) + } + Err(err) => Err(err.into()), + } + } + + async fn ref_names(&self) -> StorageResult> { + let prefix = self.ref_key("")?; + let mut paginator = self + .client + .list_objects_v2() + .bucket(self.bucket.clone()) + .prefix(prefix.clone()) + .delimiter("/") + .into_paginator() + .send(); + + let mut res = Vec::new(); + + while let Some(page) = paginator.try_next().await? { + for common_prefix in page.common_prefixes() { + if let Some(key) = common_prefix + .prefix() + .as_ref() + .and_then(|key| key.strip_prefix(prefix.as_str())) + .and_then(|key| key.strip_suffix('/')) + { + res.push(key.to_string()); + } + } + } + + Ok(res) + } + + async fn ref_versions( + &self, + ref_name: &str, + ) -> StorageResult>> { + let prefix = self.ref_key(ref_name)?; + let mut paginator = self + .client + .list_objects_v2() + .bucket(self.bucket.clone()) + .prefix(prefix.clone()) + .into_paginator() + .send(); + + let prefix = prefix + "/"; + let stream = try_stream! { + while let Some(page) = paginator.try_next().await? { + for object in page.contents() { + if let Some(key) = object.key.as_ref().and_then(|key| key.strip_prefix(prefix.as_str())) { + yield key.to_string() + } + } + } + }; + Ok(stream.boxed()) + } + + async fn write_ref( + &self, + ref_key: &str, + overwrite_refs: bool, + bytes: Bytes, + ) -> StorageResult<()> { + let key = self.ref_key(ref_key)?; + let mut builder = + self.client.put_object().bucket(self.bucket.clone()).key(key.clone()); + + if !overwrite_refs { + builder = builder.if_none_match("*") + } + + let res = builder.body(bytes.into()).send().await; + + match res { + Ok(_) => Ok(()), + Err(err) => { + let code = err.as_service_error().and_then(|e| e.code()).unwrap_or(""); + if code.contains("PreconditionFailed") + || code.contains("ConditionalRequestConflict") + { + Err(StorageError::RefAlreadyExists(key)) + } else { + Err(err.into()) + } + } + } + } +} diff --git a/icechunk/src/storage/virtual_ref.rs b/icechunk/src/storage/virtual_ref.rs index e4b378bf..8e60257a 100644 --- a/icechunk/src/storage/virtual_ref.rs +++ b/icechunk/src/storage/virtual_ref.rs @@ -1,19 +1,18 @@ use crate::format::manifest::{VirtualChunkLocation, VirtualReferenceError}; use crate::format::ByteRange; use async_trait::async_trait; +use aws_sdk_s3::Client; use bytes::Bytes; use object_store::local::LocalFileSystem; use object_store::{path::Path as ObjectPath, GetOptions, GetRange, ObjectStore}; use serde::{Deserialize, Serialize}; use std::cmp::{max, min}; -use std::collections::HashMap; use std::fmt::Debug; use std::ops::Bound; -use std::sync::Arc; -use tokio::sync::RwLock; -use url; +use tokio::sync::OnceCell; +use url::{self, Url}; -use super::object_store::S3Config; +use super::s3::{mk_client, range_to_header, S3Config}; #[async_trait] pub trait VirtualChunkResolver: Debug { @@ -24,23 +23,85 @@ pub trait VirtualChunkResolver: Debug { ) -> Result; } -#[derive(PartialEq, Eq, Hash, Clone, Debug)] -struct StoreCacheKey(String, String); - #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum ObjectStoreVirtualChunkResolverConfig { S3(S3Config), } -#[derive(Debug, Default)] +#[derive(Debug)] pub struct ObjectStoreVirtualChunkResolver { - stores: RwLock>>, - config: Option, + s3: OnceCell, + config: Box>, } impl ObjectStoreVirtualChunkResolver { pub fn new(config: Option) -> Self { - Self { stores: RwLock::new(HashMap::new()), config } + Self { s3: Default::default(), config: Box::new(config) } + } + + async fn s3(&self) -> &Client { + let config = self.config.clone(); + self.s3 + .get_or_init(|| async move { + match config.as_ref() { + Some(ObjectStoreVirtualChunkResolverConfig::S3(config)) => { + mk_client(Some(config)).await + } + None => mk_client(None).await, + } + }) + .await + } + + async fn fetch_file( + &self, + url: &Url, + range: &ByteRange, + ) -> Result { + let store = LocalFileSystem::new(); + let options = + GetOptions { range: Option::::from(range), ..Default::default() }; + let path = ObjectPath::parse(url.path()) + .map_err(|e| VirtualReferenceError::OtherError(Box::new(e)))?; + + store + .get_opts(&path, options) + .await + .map_err(|e| VirtualReferenceError::FetchError(Box::new(e)))? + .bytes() + .await + .map_err(|e| VirtualReferenceError::FetchError(Box::new(e))) + } + + async fn fetch_s3( + &self, + url: &Url, + range: &ByteRange, + ) -> Result { + let bucket_name = if let Some(host) = url.host_str() { + host.to_string() + } else { + Err(VirtualReferenceError::CannotParseBucketName( + "No bucket name found".to_string(), + ))? + }; + + let key = url.path(); + let key = key.strip_prefix('/').unwrap_or(key); + let mut b = self.s3().await.get_object().bucket(bucket_name).key(key); + + if let Some(header) = range_to_header(range) { + b = b.range(header) + }; + + Ok(b.send() + .await + .map_err(|e| VirtualReferenceError::FetchError(Box::new(e)))? + .body + .collect() + .await + .map_err(|e| VirtualReferenceError::FetchError(Box::new(e)))? + .into_bytes()) } } @@ -84,79 +145,13 @@ impl VirtualChunkResolver for ObjectStoreVirtualChunkResolver { let VirtualChunkLocation::Absolute(location) = location; let parsed = url::Url::parse(location).map_err(VirtualReferenceError::CannotParseUrl)?; - let path = ObjectPath::parse(parsed.path()) - .map_err(|e| VirtualReferenceError::OtherError(Box::new(e)))?; let scheme = parsed.scheme(); - let bucket_name = if let Some(host) = parsed.host_str() { - host.to_string() - } else if scheme == "file" { - // Host is not required for file scheme, if it is not there, - // we can assume the bucket name is empty and it is a local file - "".to_string() - } else { - Err(VirtualReferenceError::CannotParseBucketName( - "No bucket name found".to_string(), - ))? - }; - - let cache_key = StoreCacheKey(scheme.into(), bucket_name); - - let options = - GetOptions { range: Option::::from(range), ..Default::default() }; - let store = { - let stores = self.stores.read().await; - stores.get(&cache_key).cloned() - }; - let store = match store { - Some(store) => store, - None => { - let new_store: Arc = match scheme { - "file" => { - let fs = LocalFileSystem::new(); - Arc::new(fs) - } - // FIXME: allow configuring auth for virtual references - "s3" => { - let config = if let Some( - ObjectStoreVirtualChunkResolverConfig::S3(config), - ) = &self.config - { - config.clone() - } else { - S3Config::default() - }; - - let s3 = config - .to_builder() - .with_bucket_name(&cache_key.1) - .build() - .map_err(|e| { - VirtualReferenceError::FetchError(Box::new(e)) - })?; - - Arc::new(s3) - } - _ => { - Err(VirtualReferenceError::UnsupportedScheme(scheme.to_string()))? - } - }; - { - self.stores - .write() - .await - .insert(cache_key.clone(), Arc::clone(&new_store)); - } - new_store - } - }; - Ok(store - .get_opts(&path, options) - .await - .map_err(|e| VirtualReferenceError::FetchError(Box::new(e)))? - .bytes() - .await - .map_err(|e| VirtualReferenceError::FetchError(Box::new(e)))?) + match scheme { + "file" => self.fetch_file(&parsed, range).await, + "s3" => self.fetch_s3(&parsed, range).await, + _ => Err(VirtualReferenceError::UnsupportedScheme(scheme.to_string())), + } } } diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index 9c73209f..fda8aef8 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -33,7 +33,8 @@ use crate::{ StorageTransformer, UserAttributes, ZarrArrayMetadata, }, storage::{ - object_store::S3Config, virtual_ref::ObjectStoreVirtualChunkResolverConfig, + s3::{S3Config, S3Storage}, + virtual_ref::ObjectStoreVirtualChunkResolverConfig, }, ObjectStorage, Repository, RepositoryBuilder, SnapshotMetadata, Storage, }; @@ -59,7 +60,7 @@ pub enum StorageConfig { } impl StorageConfig { - pub fn make_storage(&self) -> Result, String> { + pub async fn make_storage(&self) -> Result, String> { match self { StorageConfig::InMemory { prefix } => { Ok(Arc::new(ObjectStorage::new_in_memory_store(prefix.clone()))) @@ -70,15 +71,18 @@ impl StorageConfig { Ok(Arc::new(storage)) } StorageConfig::S3ObjectStore { bucket, prefix, config } => { - let storage = ObjectStorage::new_s3_store(bucket, prefix, config.clone()) + let storage = S3Storage::new_s3_store(bucket, prefix, config.as_ref()) + .await .map_err(|e| format!("Error creating storage: {e}"))?; Ok(Arc::new(storage)) } } } - pub fn make_cached_storage(&self) -> Result, String> { - let storage = self.make_storage()?; + pub async fn make_cached_storage( + &self, + ) -> Result, String> { + let storage = self.make_storage().await?; let cached_storage = Repository::add_in_mem_asset_caching(storage); Ok(cached_storage) } @@ -266,7 +270,7 @@ impl Store { consolidated: &ConsolidatedStore, mode: AccessMode, ) -> Result { - let storage = consolidated.storage.make_cached_storage()?; + let storage = consolidated.storage.make_cached_storage().await?; let (repository, branch) = consolidated.repository.make_repository(storage).await?; Ok(Self::from_repository(repository, mode, branch, consolidated.config.clone())) @@ -1238,7 +1242,7 @@ mod tests { use std::borrow::BorrowMut; - use crate::storage::object_store::S3Credentials; + use crate::storage::s3::S3Credentials; use super::*; use pretty_assertions::assert_eq; diff --git a/icechunk/tests/test_distributed_writes.rs b/icechunk/tests/test_distributed_writes.rs index cd00ea91..c9ead245 100644 --- a/icechunk/tests/test_distributed_writes.rs +++ b/icechunk/tests/test_distributed_writes.rs @@ -6,30 +6,33 @@ use icechunk::{ format::{ByteRange, ChunkIndices, Path, SnapshotId}, metadata::{ChunkKeyEncoding, ChunkShape, DataType, FillValue}, repository::{get_chunk, ChangeSet, ZarrArrayMetadata}, - storage::object_store::{S3Config, S3Credentials}, - ObjectStorage, Repository, Storage, + storage::s3::{S3Config, S3Credentials, S3Storage}, + Repository, Storage, }; use tokio::task::JoinSet; const SIZE: usize = 10; -fn mk_storage( +async fn mk_storage( prefix: &str, ) -> Result, Box> { - let storage: Arc = Arc::new(ObjectStorage::new_s3_store( - "testbucket", - prefix, - Some(S3Config { - region: None, - endpoint: Some("http://localhost:9000".to_string()), - credentials: Some(S3Credentials { - access_key_id: "minio123".into(), - secret_access_key: "minio123".into(), - session_token: None, + let storage: Arc = Arc::new( + S3Storage::new_s3_store( + "testbucket", + prefix, + Some(&S3Config { + region: None, + endpoint: Some("http://localhost:9000".to_string()), + credentials: Some(S3Credentials { + access_key_id: "minio123".into(), + secret_access_key: "minio123".into(), + session_token: None, + }), + allow_http: Some(true), }), - allow_http: Some(true), - }), - )?); + ) + .await?, + ); Ok(Repository::add_in_mem_asset_caching(storage)) } @@ -109,10 +112,10 @@ async fn verify( async fn test_distributed_writes() -> Result<(), Box> { let prefix = format!("test_distributed_writes_{}", SnapshotId::random()); - let storage1 = mk_storage(prefix.as_str())?; - let storage2 = mk_storage(prefix.as_str())?; - let storage3 = mk_storage(prefix.as_str())?; - let storage4 = mk_storage(prefix.as_str())?; + let storage1 = mk_storage(prefix.as_str()).await?; + let storage2 = mk_storage(prefix.as_str()).await?; + let storage3 = mk_storage(prefix.as_str()).await?; + let storage4 = mk_storage(prefix.as_str()).await?; let mut repo1 = mk_repo(storage1, true).await?; let zarr_meta = ZarrArrayMetadata { @@ -180,7 +183,7 @@ async fn test_distributed_writes() -> Result<(), Box StorageResult { + S3Storage::new_s3_store( + "testbucket", + "test_s3_storage__".to_string() + Utc::now().to_rfc3339().as_str(), + Some(&S3Config { + region: None, + endpoint: Some("http://localhost:9000".to_string()), + credentials: Some(S3Credentials { + access_key_id: "minio123".into(), + secret_access_key: "minio123".into(), + session_token: None, + }), + allow_http: Some(true), + }), + ) + .await +} + +#[tokio::test] +pub async fn test_snapshot_write_read() -> Result<(), Box> { + let storage = mk_storage().await?; + let id = SnapshotId::random(); + let snapshot = Arc::new(Snapshot::empty()); + storage.write_snapshot(id.clone(), snapshot.clone()).await?; + let back = storage.fetch_snapshot(&id).await?; + assert_eq!(snapshot, back); + Ok(()) +} + +#[tokio::test] +pub async fn test_manifest_write_read() -> Result<(), Box> { + let storage = mk_storage().await?; + let id = ManifestId::random(); + let manifest = Arc::new(Manifest::default()); + storage.write_manifests(id.clone(), manifest.clone()).await?; + let back = storage.fetch_manifests(&id).await?; + assert_eq!(manifest, back); + Ok(()) +} + +#[tokio::test] +pub async fn test_chunk_write_read() -> Result<(), Box> { + let storage = mk_storage().await?; + let id = ChunkId::random(); + let bytes = Bytes::from_static(b"hello"); + storage.write_chunk(id.clone(), bytes.clone()).await?; + let back = storage.fetch_chunk(&id, &ByteRange::ALL).await?; + assert_eq!(bytes, back); + + let back = + storage.fetch_chunk(&id, &ByteRange::from_offset_with_length(1, 2)).await?; + assert_eq!(Bytes::from_static(b"el"), back); + + let back = storage.fetch_chunk(&id, &ByteRange::from_offset(1)).await?; + assert_eq!(Bytes::from_static(b"ello"), back); + + let back = storage.fetch_chunk(&id, &ByteRange::to_offset(3)).await?; + assert_eq!(Bytes::from_static(b"hel"), back); + + let back = storage.fetch_chunk(&id, &ByteRange::bounded(1, 4)).await?; + assert_eq!(Bytes::from_static(b"ell"), back); + Ok(()) +} + +#[tokio::test] +pub async fn test_tag_write_get() -> Result<(), Box> { + let storage = mk_storage().await?; + let id = SnapshotId::random(); + create_tag(&storage, "mytag", id.clone(), false).await?; + let back = fetch_tag(&storage, "mytag").await?; + assert_eq!(id, back.snapshot); + Ok(()) +} + +#[tokio::test] +pub async fn test_fetch_non_existing_tag() -> Result<(), Box> { + let storage = mk_storage().await?; + let id = SnapshotId::random(); + create_tag(&storage, "mytag", id.clone(), false).await?; + + let back = fetch_tag(&storage, "non-existing-tag").await; + assert!(matches!(back, Err(RefError::RefNotFound(r)) if r == "non-existing-tag")); + Ok(()) +} + +#[tokio::test] +pub async fn test_create_existing_tag() -> Result<(), Box> { + let storage = mk_storage().await?; + let id = SnapshotId::random(); + create_tag(&storage, "mytag", id.clone(), false).await?; + + let res = create_tag(&storage, "mytag", id.clone(), false).await; + assert!(matches!(res, Err(RefError::TagAlreadyExists(r)) if r == "mytag")); + Ok(()) +} + +#[tokio::test] +pub async fn test_branch_initialization() -> Result<(), Box> { + let storage = mk_storage().await?; + let id = SnapshotId::random(); + + let res = update_branch(&storage, "some-branch", id.clone(), None, false).await?; + assert_eq!(res.0, 0); + + let res = fetch_branch_tip(&storage, "some-branch").await?; + assert_eq!(res.snapshot, id); + + Ok(()) +} + +#[tokio::test] +pub async fn test_fetch_non_existing_branch() -> Result<(), Box> { + let storage = mk_storage().await?; + let id = SnapshotId::random(); + update_branch(&storage, "some-branch", id.clone(), None, false).await?; + + let back = fetch_branch_tip(&storage, "non-existing-branch").await; + assert!(matches!(back, Err(RefError::RefNotFound(r)) if r == "non-existing-branch")); + Ok(()) +} + +#[tokio::test] +pub async fn test_branch_update() -> Result<(), Box> { + let storage = mk_storage().await?; + let id1 = SnapshotId::random(); + let id2 = SnapshotId::random(); + let id3 = SnapshotId::random(); + + let res = update_branch(&storage, "some-branch", id1.clone(), None, false).await?; + assert_eq!(res.0, 0); + + let res = + update_branch(&storage, "some-branch", id2.clone(), Some(&id1), false).await?; + assert_eq!(res.0, 1); + + let res = + update_branch(&storage, "some-branch", id3.clone(), Some(&id2), false).await?; + assert_eq!(res.0, 2); + + let res = fetch_branch_tip(&storage, "some-branch").await?; + assert_eq!(res.snapshot, id3); + + Ok(()) +} + +#[tokio::test] +pub async fn test_ref_names() -> Result<(), Box> { + let storage = mk_storage().await?; + let id1 = SnapshotId::random(); + let id2 = SnapshotId::random(); + update_branch(&storage, "main", id1.clone(), None, false).await?; + update_branch(&storage, "main", id2.clone(), Some(&id1), false).await?; + update_branch(&storage, "foo", id1.clone(), None, false).await?; + update_branch(&storage, "bar", id1.clone(), None, false).await?; + create_tag(&storage, "my-tag", id1.clone(), false).await?; + create_tag(&storage, "my-other-tag", id1.clone(), false).await?; + + let res: HashSet<_> = HashSet::from_iter(list_refs(&storage).await?); + assert_eq!( + res, + HashSet::from_iter([ + Ref::Tag("my-tag".to_string()), + Ref::Tag("my-other-tag".to_string()), + Ref::Branch("main".to_string()), + Ref::Branch("foo".to_string()), + Ref::Branch("bar".to_string()), + ]) + ); + Ok(()) +} diff --git a/icechunk/tests/test_virtual_refs.rs b/icechunk/tests/test_virtual_refs.rs index e5ff2e3b..83257489 100644 --- a/icechunk/tests/test_virtual_refs.rs +++ b/icechunk/tests/test_virtual_refs.rs @@ -9,7 +9,8 @@ mod tests { metadata::{ChunkKeyEncoding, ChunkShape, DataType, FillValue}, repository::{get_chunk, ChunkPayload, ZarrArrayMetadata}, storage::{ - object_store::{S3Config, S3Credentials}, + s3::{mk_client, S3Config, S3Credentials, S3Storage}, + virtual_ref::ObjectStoreVirtualChunkResolverConfig, ObjectStorage, }, zarr::AccessMode, @@ -25,8 +26,27 @@ mod tests { }; use pretty_assertions::assert_eq; + fn s3_config() -> S3Config { + S3Config { + region: None, + endpoint: Some("http://localhost:9000".to_string()), + credentials: Some(S3Credentials { + access_key_id: "minio123".into(), + secret_access_key: "minio123".into(), + session_token: None, + }), + allow_http: Some(true), + } + } + async fn create_repository(storage: Arc) -> Repository { - Repository::init(storage, true).await.expect("building repository failed").build() + Repository::init(storage, true) + .await + .expect("building repository failed") + .with_virtual_ref_config(ObjectStoreVirtualChunkResolverConfig::S3( + s3_config(), + )) + .build() } async fn write_chunks_to_store( @@ -57,20 +77,12 @@ mod tests { async fn create_minio_repository() -> Repository { let storage: Arc = Arc::new( - ObjectStorage::new_s3_store( + S3Storage::new_s3_store( "testbucket".to_string(), format!("{:?}", ChunkId::random()), - Some(S3Config { - region: None, - endpoint: Some("http://localhost:9000".to_string()), - credentials: Some(S3Credentials { - access_key_id: "minio123".into(), - secret_access_key: "minio123".into(), - session_token: None, - }), - allow_http: Some(true), - }), + Some(&s3_config()), ) + .await .expect("Creating minio storage failed"), ); @@ -84,19 +96,19 @@ mod tests { } async fn write_chunks_to_minio(chunks: impl Iterator) { - use object_store::aws::AmazonS3Builder; - let bucket_name = "testbucket".to_string(); + let client = mk_client(Some(&s3_config())).await; - let store = AmazonS3Builder::new() - .with_access_key_id("minio123") - .with_secret_access_key("minio123") - .with_endpoint("http://localhost:9000") - .with_allow_http(true) - .with_bucket_name(bucket_name) - .build() - .expect("building S3 store failed"); - - write_chunks_to_store(store, chunks).await; + let bucket_name = "testbucket".to_string(); + for (key, bytes) in chunks { + client + .put_object() + .bucket(bucket_name.clone()) + .key(key) + .body(bytes.into()) + .send() + .await + .unwrap(); + } } #[tokio::test(flavor = "multi_thread")] @@ -111,7 +123,7 @@ mod tests { write_chunks_to_local_fs(chunks.iter().cloned()).await; let repo_dir = TempDir::new()?; - let mut ds = create_local_repository(&repo_dir.path()).await; + let mut ds = create_local_repository(repo_dir.path()).await; let zarr_meta = ZarrArrayMetadata { shape: vec![1, 1, 2], From 937767f0380bb26aef8b3a22662e17470b734d4f Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Fri, 4 Oct 2024 12:00:17 -0300 Subject: [PATCH 020/167] Fix env variables requirements --- Justfile | 2 +- README.md | 2 +- icechunk-python/tests/test_distributed_writers.py | 1 + icechunk/tests/test_distributed_writes.rs | 2 +- icechunk/tests/test_s3_storage.rs | 2 +- icechunk/tests/test_virtual_refs.rs | 2 +- 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Justfile b/Justfile index 0fdfb0fc..b120b1cf 100644 --- a/Justfile +++ b/Justfile @@ -3,7 +3,7 @@ alias pre := pre-commit # run all tests test *args='': - AWS_ALLOW_HTTP=1 AWS_ENDPOINT_URL=http://localhost:9000 AWS_ACCESS_KEY_ID=minio123 AWS_SECRET_ACCESS_KEY=minio123 cargo test {{args}} + cargo test --all {{args}} # compile but don't run all tests compile-tests *args='': diff --git a/README.md b/README.md index 786befca..99f1af2e 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ just test This is just an alias for ``` -AWS_ALLOW_HTTP=1 AWS_ENDPOINT_URL=http://localhost:9000 AWS_ACCESS_KEY_ID=minio123 AWS_SECRET_ACCESS_KEY=minio123 cargo test +cargo test --all ``` > [!TIP] diff --git a/icechunk-python/tests/test_distributed_writers.py b/icechunk-python/tests/test_distributed_writers.py index 81abb828..fb464e41 100644 --- a/icechunk-python/tests/test_distributed_writers.py +++ b/icechunk-python/tests/test_distributed_writers.py @@ -81,6 +81,7 @@ async def test_distributed_writers(): "bucket": "testbucket", "prefix": "python-distributed-writers-test__" + str(time.time()), "endpoint_url": "http://localhost:9000", + "region": "us-east-1", "allow_http": True, } store_config = {"inline_chunk_threshold_bytes": 5} diff --git a/icechunk/tests/test_distributed_writes.rs b/icechunk/tests/test_distributed_writes.rs index c9ead245..f602b2c4 100644 --- a/icechunk/tests/test_distributed_writes.rs +++ b/icechunk/tests/test_distributed_writes.rs @@ -21,7 +21,7 @@ async fn mk_storage( "testbucket", prefix, Some(&S3Config { - region: None, + region: Some("us-east-1".to_string()), endpoint: Some("http://localhost:9000".to_string()), credentials: Some(S3Credentials { access_key_id: "minio123".into(), diff --git a/icechunk/tests/test_s3_storage.rs b/icechunk/tests/test_s3_storage.rs index 30965a5c..bc53ab55 100644 --- a/icechunk/tests/test_s3_storage.rs +++ b/icechunk/tests/test_s3_storage.rs @@ -23,7 +23,7 @@ async fn mk_storage() -> StorageResult { "testbucket", "test_s3_storage__".to_string() + Utc::now().to_rfc3339().as_str(), Some(&S3Config { - region: None, + region: Some("us-east-1".to_string()), endpoint: Some("http://localhost:9000".to_string()), credentials: Some(S3Credentials { access_key_id: "minio123".into(), diff --git a/icechunk/tests/test_virtual_refs.rs b/icechunk/tests/test_virtual_refs.rs index 83257489..dc4e6c50 100644 --- a/icechunk/tests/test_virtual_refs.rs +++ b/icechunk/tests/test_virtual_refs.rs @@ -28,7 +28,7 @@ mod tests { fn s3_config() -> S3Config { S3Config { - region: None, + region: Some("us-east-1".to_string()), endpoint: Some("http://localhost:9000".to_string()), credentials: Some(S3Credentials { access_key_id: "minio123".into(), From dc8cc121f6f0589f6f2f4ae1cbeef4672aec55ec Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Fri, 4 Oct 2024 12:08:19 -0300 Subject: [PATCH 021/167] Set minio region in test --- icechunk-python/tests/test_virtual_ref.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/icechunk-python/tests/test_virtual_ref.py b/icechunk-python/tests/test_virtual_ref.py index 1264d517..dbcb63cb 100644 --- a/icechunk-python/tests/test_virtual_ref.py +++ b/icechunk-python/tests/test_virtual_ref.py @@ -49,6 +49,7 @@ async def test_write_minino_virtual_refs(): ), endpoint_url="http://localhost:9000", allow_http=True, + region="us-east-1", ), mode="r+", config=StoreConfig( @@ -59,6 +60,7 @@ async def test_write_minino_virtual_refs(): ), endpoint_url="http://localhost:9000", allow_http=True, + region="us-east-1", ) ), ) From 54493a138b4596b6c792fffc26efb6ead76a5ed0 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Fri, 4 Oct 2024 11:54:26 -0400 Subject: [PATCH 022/167] Pickle Support for python IcechunkStore [EAR-1326] (#134) * Start adding consolidated store tracking for serialization * Tests passing, add new pickle ops without testing * Add failing test * linter * Switch to cow, doesnt help * filesysmte store works * Workign pickling! * lint * Clean out deprecated methods * Get rid of rmp serde for now * Simplify equality, tests * Fix lint * Add fixme comments --- Cargo.lock | 1 + icechunk-python/Cargo.toml | 1 + icechunk-python/python/icechunk/__init__.py | 83 +++----------- .../python/icechunk/_icechunk_python.pyi | 8 +- icechunk-python/src/lib.rs | 107 +++++++++++------- icechunk-python/tests/test_concurrency.py | 5 +- icechunk-python/tests/test_pickle.py | 64 +++++++++++ icechunk/src/repository.rs | 11 +- icechunk/src/zarr.rs | 43 ++++++- 9 files changed, 212 insertions(+), 111 deletions(-) create mode 100644 icechunk-python/tests/test_pickle.py diff --git a/Cargo.lock b/Cargo.lock index 408a4be8..596e608e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1234,6 +1234,7 @@ dependencies = [ "icechunk", "pyo3", "pyo3-asyncio-0-21", + "serde_json", "thiserror", "tokio", ] diff --git a/icechunk-python/Cargo.toml b/icechunk-python/Cargo.toml index febaa271..16a288c8 100644 --- a/icechunk-python/Cargo.toml +++ b/icechunk-python/Cargo.toml @@ -23,6 +23,7 @@ pyo3-asyncio-0-21 = { version = "0.21.0", features = ["tokio-runtime"] } async-stream = "0.3.5" thiserror = "1.0.64" tokio = "1.40" +serde_json = "1.0.128" [lints] workspace = true diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index 17aab712..96bf5b7e 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -1,5 +1,4 @@ # module -import json from collections.abc import AsyncGenerator, Iterable from typing import Any, Self @@ -17,14 +16,15 @@ VirtualRefConfig, pyicechunk_store_create, pyicechunk_store_exists, - pyicechunk_store_from_json_config, pyicechunk_store_open_existing, + pyicechunk_store_from_bytes, ) __all__ = [ "IcechunkStore", "StorageConfig", "S3Credentials", + "SnapshotMetadata", "StoreConfig", "VirtualRefConfig", ] @@ -89,64 +89,6 @@ def __init__( ) self._store = store - @classmethod - async def from_config( - cls, config: dict, mode: AccessModeLiteral = "r", *args: Any, **kwargs: Any - ) -> Self: - """Create an IcechunkStore from a given configuration. - - NOTE: This is deprecated and will be removed in a future release. Use the open_existing or create methods instead. - - The configuration should be a dictionary in the following format: - { - "storage": { - "type": "s3, // one of "in_memory", "local_filesystem", "s3", "cached" - "...": "additional storage configuration" - }, - "repository": { - // Optional, only required if you want to open an existing repository - "version": { - "branch": "main", - }, - }, - "config": { - // The threshold at which chunks are stored inline and not written to chunk storage - inline_chunk_threshold_bytes: 512 - } - } - - The following storage types are supported: - - in_memory: store data in memory - - local_filesystem: store data on the local filesystem - - s3: store data on S3 compatible storage - - cached: store data in memory with a backing storage - - The following additional configuration options are supported for each storage type: - - in_memory: {} - - local_filesystem: {"root": "path/to/root/directory"} - - s3: { - "bucket": "bucket-name", - "prefix": "optional-prefix", - "access_key_id": "optional-access-key-id", - "secret_access_key": "optional", - "session_token": "optional", - "endpoint": "optional" - } - - cached: { - "approx_max_memory_bytes": 1_000_000, - "backend": { - "type": "s3", - "...": "additional storage configuration" - } - } - - If opened with AccessModeLiteral "r", the store will be read-only. Otherwise the store will be writable. - """ - config_str = json.dumps(config) - read_only = mode == "r" - store = await pyicechunk_store_from_json_config(config_str, read_only=read_only) - return cls(store=store, mode=mode, args=args, kwargs=kwargs) - @classmethod async def open_existing( cls, @@ -218,6 +160,22 @@ def with_mode(self, mode: AccessModeLiteral) -> Self: new_store = self._store.with_mode(read_only) return self.__class__(new_store, mode=mode) + def __eq__(self, value: object) -> bool: + if not isinstance(value, self.__class__): + return False + return self._store == value._store + + def __getstate__(self) -> object: + store_repr = self._store.as_bytes() + return {"store": store_repr, "mode": self.mode} + + def __setstate__(self, state: Any) -> None: + store_repr = state["store"] + mode = state['mode'] + is_read_only = (mode == "r") + self._store = pyicechunk_store_from_bytes(store_repr, is_read_only) + self._is_open = True + @property def snapshot_id(self) -> str: """Return the current snapshot id.""" @@ -494,8 +452,3 @@ def list_dir(self, prefix: str) -> AsyncGenerator[str, None]: # listing methods should not be async, so we need to # wrap the async method in a sync method. return self._store.list_dir(prefix) - - def __eq__(self, other) -> bool: - if other is self: - return True - raise NotImplementedError diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index b5fc847e..e5cb4f8e 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -3,6 +3,7 @@ import datetime from collections.abc import AsyncGenerator class PyIcechunkStore: + def as_bytes(self) -> bytes: ... def with_mode(self, read_only: bool) -> PyIcechunkStore: ... @property def snapshot_id(self) -> str: ... @@ -228,6 +229,9 @@ async def pyicechunk_store_create( async def pyicechunk_store_open_existing( storage: StorageConfig, read_only: bool, config: StoreConfig ) -> PyIcechunkStore: ... -async def pyicechunk_store_from_json_config( - config: str, read_only: bool +# async def pyicechunk_store_from_json_config( +# config: str, read_only: bool +# ) -> PyIcechunkStore: ... +def pyicechunk_store_from_bytes( + bytes: bytes, read_only: bool ) -> PyIcechunkStore: ... diff --git a/icechunk-python/src/lib.rs b/icechunk-python/src/lib.rs index 0be1b6b1..4edf0e45 100644 --- a/icechunk-python/src/lib.rs +++ b/icechunk-python/src/lib.rs @@ -2,7 +2,7 @@ mod errors; mod storage; mod streams; -use std::sync::Arc; +use std::{borrow::Cow, sync::Arc}; use ::icechunk::{format::ChunkOffset, Store}; use bytes::Bytes; @@ -27,6 +27,7 @@ use tokio::sync::{Mutex, RwLock}; #[pyclass] struct PyIcechunkStore { + consolidated: ConsolidatedStore, store: Arc>, rt: tokio::runtime::Runtime, } @@ -50,6 +51,7 @@ impl From<&PyStoreConfig> for RepositoryConfig { version: None, inline_chunk_threshold_bytes: config.inline_chunk_threshold_bytes, unsafe_overwrite_refs: config.unsafe_overwrite_refs, + change_set_bytes: None, virtual_ref_config: config .virtual_ref_config .as_ref() @@ -111,6 +113,10 @@ impl From for PySnapshotMetadata { type KeyRanges = Vec<(String, (Option, Option))>; impl PyIcechunkStore { + pub(crate) fn consolidated(&self) -> &ConsolidatedStore { + &self.consolidated + } + async fn store_exists(storage: StorageConfig) -> PyIcechunkStoreResult { let storage = storage .make_cached_storage() @@ -126,20 +132,12 @@ impl PyIcechunkStore { repository_config: RepositoryConfig, store_config: StoreOptions, ) -> Result { - let access_mode = if read_only { - icechunk::zarr::AccessMode::ReadOnly - } else { - icechunk::zarr::AccessMode::ReadWrite - }; let repository = repository_config .with_version(VersionInfo::BranchTipRef(Ref::DEFAULT_BRANCH.to_string())); - let config = + let consolidated = ConsolidatedStore { storage, repository, config: Some(store_config) }; - let store = Store::from_consolidated(&config, access_mode).await?; - let store = Arc::new(RwLock::new(store)); - let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; - Ok(Self { store, rt }) + PyIcechunkStore::from_consolidated(consolidated, read_only).await } async fn create( @@ -147,48 +145,42 @@ impl PyIcechunkStore { repository_config: RepositoryConfig, store_config: StoreOptions, ) -> Result { - let config = ConsolidatedStore { + let consolidated = ConsolidatedStore { storage, repository: repository_config, config: Some(store_config), }; - let store = - Store::from_consolidated(&config, icechunk::zarr::AccessMode::ReadWrite) - .await?; - let store = Arc::new(RwLock::new(store)); - let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; - Ok(Self { store, rt }) + PyIcechunkStore::from_consolidated(consolidated, false).await } - async fn from_json_config(json: &[u8], read_only: bool) -> Result { + async fn from_consolidated( + consolidated: ConsolidatedStore, + read_only: bool, + ) -> Result { let access_mode = if read_only { icechunk::zarr::AccessMode::ReadOnly } else { icechunk::zarr::AccessMode::ReadWrite }; - let store = Store::from_json(json, access_mode).await?; + + let store = Store::from_consolidated(&consolidated, access_mode).await?; let store = Arc::new(RwLock::new(store)); let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; - Ok(Self { store, rt }) + Ok(Self { consolidated, store, rt }) } -} -#[pyfunction] -fn pyicechunk_store_from_json_config( - py: Python<'_>, - json: String, - read_only: bool, -) -> PyResult> { - let json = json.as_bytes().to_owned(); + async fn as_consolidated(&self) -> PyIcechunkStoreResult { + let consolidated = self.consolidated.clone(); - // The commit mechanism is async and calls tokio::spawn so we need to use the - // pyo3_asyncio_0_21::tokio helper to run the async function in the tokio runtime - pyo3_asyncio_0_21::tokio::future_into_py(py, async move { - PyIcechunkStore::from_json_config(&json, read_only) - .await - .map_err(PyValueError::new_err) - }) + let store = self.store.read().await; + let version = store.current_version().await; + let change_set = store.change_set_bytes().await?; + + let consolidated = + consolidated.with_version(version).with_change_set_bytes(change_set)?; + Ok(consolidated) + } } #[pyfunction] @@ -240,20 +232,55 @@ fn pyicechunk_store_create<'py>( }) } +#[pyfunction] +fn pyicechunk_store_from_bytes( + bytes: Cow<[u8]>, + read_only: bool, +) -> PyResult { + // FIXME: Use rmp_serde instead of serde_json to optimize performance + let consolidated: ConsolidatedStore = serde_json::from_slice(&bytes) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + + let rt = tokio::runtime::Runtime::new() + .map_err(|e| PyIcechunkStoreError::UnkownError(e.to_string()))?; + let store = rt.block_on(async move { + PyIcechunkStore::from_consolidated(consolidated, read_only) + .await + .map_err(PyValueError::new_err) + })?; + + Ok(store) +} + #[pymethods] impl PyIcechunkStore { + fn __eq__(&self, other: &Self) -> bool { + self.consolidated.storage == other.consolidated().storage + } + + fn as_bytes(&self) -> PyResult> { + let consolidated = self.rt.block_on(self.as_consolidated())?; + + // FIXME: Use rmp_serde instead of serde_json to optimize performance + let serialized = serde_json::to_vec(&consolidated) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + Ok(Cow::Owned(serialized)) + } + fn with_mode(&self, read_only: bool) -> PyResult { let access_mode = if read_only { icechunk::zarr::AccessMode::ReadOnly } else { icechunk::zarr::AccessMode::ReadWrite }; - let store = self.store.blocking_read().with_access_mode(access_mode); - let store = Arc::new(RwLock::new(store)); + + let readable_store = self.store.blocking_read(); + let consolidated = self.rt.block_on(self.as_consolidated())?; + let store = Arc::new(RwLock::new(readable_store.with_access_mode(access_mode))); let rt = tokio::runtime::Runtime::new() .map_err(|e| PyValueError::new_err(e.to_string()))?; - Ok(PyIcechunkStore { store, rt }) + Ok(PyIcechunkStore { consolidated, store, rt }) } fn checkout_snapshot<'py>( @@ -732,9 +759,9 @@ fn _icechunk_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; - m.add_function(wrap_pyfunction!(pyicechunk_store_from_json_config, m)?)?; m.add_function(wrap_pyfunction!(pyicechunk_store_exists, m)?)?; m.add_function(wrap_pyfunction!(pyicechunk_store_create, m)?)?; m.add_function(wrap_pyfunction!(pyicechunk_store_open_existing, m)?)?; + m.add_function(wrap_pyfunction!(pyicechunk_store_from_bytes, m)?)?; Ok(()) } diff --git a/icechunk-python/tests/test_concurrency.py b/icechunk-python/tests/test_concurrency.py index 20089024..767ff9fd 100644 --- a/icechunk-python/tests/test_concurrency.py +++ b/icechunk-python/tests/test_concurrency.py @@ -39,8 +39,9 @@ async def list_store(store, barrier): async def test_concurrency(): - store = await icechunk.IcechunkStore.from_config( - config={"storage": {"type": "in_memory"}, "repository": {}}, mode="w" + store = await icechunk.IcechunkStore.open( + mode="w", + storage=icechunk.StorageConfig.memory(prefix='concurrency'), ) group = zarr.group(store=store, overwrite=True) diff --git a/icechunk-python/tests/test_pickle.py b/icechunk-python/tests/test_pickle.py new file mode 100644 index 00000000..4bfe21fb --- /dev/null +++ b/icechunk-python/tests/test_pickle.py @@ -0,0 +1,64 @@ +import pickle + +import pytest +import zarr +from zarr.store.local import LocalStore + +import icechunk + + +@pytest.fixture(scope="function") +async def tmp_store(tmpdir): + store_path = f"{tmpdir}" + store = await icechunk.IcechunkStore.open( + storage=icechunk.StorageConfig.filesystem(store_path), + mode="w", + ) + + yield store + + store.close() + + +async def test_pickle(tmp_store): + root = zarr.group(store=tmp_store) + array = root.ones(name="ones", shape=(10, 10), chunks=(5, 5), dtype="float32") + array[:] = 20 + await tmp_store.commit("firsttt") + + pickled = pickle.dumps(tmp_store) + + store_loaded = pickle.loads(pickled) + assert store_loaded == tmp_store + + root_loaded = zarr.open_group(store_loaded) + array_loaded = root_loaded['ones'] + + assert type(array_loaded) is zarr.Array + assert array_loaded == array + assert array_loaded[0, 5] == 20 + + +async def test_store_equality(tmpdir, tmp_store): + assert tmp_store == tmp_store + + local_store = await LocalStore.open(f"{tmpdir}/zarr", mode="w") + assert tmp_store != local_store + + store2 = await icechunk.IcechunkStore.open( + storage=icechunk.StorageConfig.memory(prefix="test"), + mode="w", + ) + assert tmp_store != store2 + + store3 = await icechunk.IcechunkStore.open( + storage=icechunk.StorageConfig.filesystem(f"{tmpdir}/test"), + mode="a", + ) + assert tmp_store != store3 + + store4 = await icechunk.IcechunkStore.open( + storage=icechunk.StorageConfig.filesystem(f"{tmpdir}/test"), + mode="a", + ) + assert store3 == store4 diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index fca69bb7..6851e2c6 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -86,6 +86,7 @@ pub struct RepositoryBuilder { config: RepositoryConfig, storage: Arc, snapshot_id: SnapshotId, + change_set: Option, virtual_ref_config: Option, } @@ -95,6 +96,7 @@ impl RepositoryBuilder { config: RepositoryConfig::default(), snapshot_id, storage, + change_set: None, virtual_ref_config: None, } } @@ -122,11 +124,17 @@ impl RepositoryBuilder { self } + pub fn with_change_set(&mut self, change_set_bytes: ChangeSet) -> &mut Self { + self.change_set = Some(change_set_bytes); + self + } + pub fn build(&self) -> Repository { Repository::new( self.config.clone(), self.storage.clone(), self.snapshot_id.clone(), + self.change_set.clone(), self.virtual_ref_config.clone(), ) } @@ -243,6 +251,7 @@ impl Repository { config: RepositoryConfig, storage: Arc, snapshot_id: SnapshotId, + change_set: Option, virtual_ref_config: Option, ) -> Self { Repository { @@ -250,7 +259,7 @@ impl Repository { config, storage, last_node_id: None, - change_set: ChangeSet::default(), + change_set: change_set.unwrap_or_default(), virtual_resolver: Arc::new(ObjectStoreVirtualChunkResolver::new( virtual_ref_config, )), diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index fda8aef8..93f1eb61 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -30,7 +30,7 @@ use crate::{ repository::{ get_chunk, ArrayShape, ChunkIndices, ChunkKeyEncoding, ChunkPayload, ChunkShape, Codec, DataType, DimensionNames, FillValue, Path, RepositoryError, - StorageTransformer, UserAttributes, ZarrArrayMetadata, + RepositoryResult, StorageTransformer, UserAttributes, ZarrArrayMetadata, }, storage::{ s3::{S3Config, S3Storage}, @@ -104,6 +104,7 @@ pub struct RepositoryConfig { pub version: Option, pub inline_chunk_threshold_bytes: Option, pub unsafe_overwrite_refs: Option, + pub change_set_bytes: Option>, pub virtual_ref_config: Option, } @@ -139,6 +140,11 @@ impl RepositoryConfig { self } + pub fn with_change_set_bytes(mut self, change_set_bytes: Vec) -> Self { + self.change_set_bytes = Some(change_set_bytes); + self + } + pub async fn make_repository( &self, storage: Arc, @@ -181,6 +187,11 @@ impl RepositoryConfig { if let Some(config) = &self.virtual_ref_config { builder.with_virtual_ref_config(config.clone()); } + if let Some(change_set_bytes) = &self.change_set_bytes { + let change_set = ChangeSet::import_from_bytes(change_set_bytes) + .map_err(|err| format!("Error parsing change set: {err}"))?; + builder.with_change_set(change_set); + } // TODO: add error checking, does the previous version exist? Ok((builder.build(), branch)) @@ -207,6 +218,21 @@ pub struct ConsolidatedStore { pub config: Option, } +impl ConsolidatedStore { + pub fn with_version(mut self, version: VersionInfo) -> Self { + self.repository.version = Some(version); + self + } + + pub fn with_change_set_bytes( + mut self, + change_set: Vec, + ) -> RepositoryResult { + self.repository.change_set_bytes = Some(change_set); + Ok(self) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum AccessMode { #[serde(rename = "r")] @@ -326,6 +352,14 @@ impl Store { self.repository.read().await.snapshot_id().clone() } + pub async fn current_version(&self) -> VersionInfo { + if let Some(branch) = &self.current_branch { + VersionInfo::BranchTipRef(branch.clone()) + } else { + VersionInfo::SnapshotId(self.snapshot_id().await) + } + } + pub async fn has_uncommitted_changes(&self) -> bool { self.repository.read().await.has_uncommitted_changes() } @@ -2079,6 +2113,7 @@ mod tests { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, ]))), unsafe_overwrite_refs: Some(true), + change_set_bytes: None, virtual_ref_config: None, }, config: Some(StoreOptions { get_partial_values_concurrency: 100 }), @@ -2113,6 +2148,7 @@ mod tests { version: None, inline_chunk_threshold_bytes: None, unsafe_overwrite_refs: None, + change_set_bytes: None, virtual_ref_config: None, }, config: None, @@ -2133,6 +2169,7 @@ mod tests { version: None, inline_chunk_threshold_bytes: None, unsafe_overwrite_refs: None, + change_set_bytes: None, virtual_ref_config: None, }, config: None, @@ -2152,6 +2189,7 @@ mod tests { version: None, inline_chunk_threshold_bytes: None, unsafe_overwrite_refs: None, + change_set_bytes: None, virtual_ref_config: None, }, storage: StorageConfig::InMemory { prefix: Some("prefix".to_string()) }, @@ -2171,6 +2209,7 @@ mod tests { version: None, inline_chunk_threshold_bytes: None, unsafe_overwrite_refs: None, + change_set_bytes: None, virtual_ref_config: None, }, storage: StorageConfig::InMemory { prefix: None }, @@ -2190,6 +2229,7 @@ mod tests { version: None, inline_chunk_threshold_bytes: None, unsafe_overwrite_refs: None, + change_set_bytes: None, virtual_ref_config: None, }, storage: StorageConfig::S3ObjectStore { @@ -2228,6 +2268,7 @@ mod tests { version: None, inline_chunk_threshold_bytes: None, unsafe_overwrite_refs: None, + change_set_bytes: None, virtual_ref_config: None, }, storage: StorageConfig::S3ObjectStore { From 3d134478e1b43667168cf88ebf7883a1fbf83629 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Fri, 4 Oct 2024 19:00:46 -0300 Subject: [PATCH 023/167] Eliminate race condition in set-if-not-exists Closes #138 --- icechunk/src/zarr.rs | 274 ++++++++++++++++++++++++++----------------- 1 file changed, 167 insertions(+), 107 deletions(-) diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index 93f1eb61..002faa9c 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -2,7 +2,7 @@ use std::{ collections::HashSet, iter, num::NonZeroU64, - ops::DerefMut, + ops::{Deref, DerefMut}, path::PathBuf, sync::{ atomic::{AtomicUsize, Ordering}, @@ -492,18 +492,9 @@ impl Store { todo!() } - // TODO: prototype argument pub async fn get(&self, key: &str, byte_range: &ByteRange) -> StoreResult { - let bytes = match Key::parse(key)? { - Key::Metadata { node_path } => { - self.get_metadata(key, &node_path, byte_range).await - } - Key::Chunk { node_path, coords } => { - self.get_chunk(key, node_path, coords, byte_range).await - } - }?; - - Ok(bytes) + let repo = self.repository.read().await; + get_key(key, byte_range, repo.deref()).await } /// Get all the requested keys concurrently. @@ -567,13 +558,9 @@ impl Store { res.ok_or(StoreError::PartialValuesPanic) } - // TODO: prototype argument pub async fn exists(&self, key: &str) -> StoreResult { - match self.get(key, &ByteRange::ALL).await { - Ok(_) => Ok(true), - Err(StoreError::NotFound(_)) => Ok(false), - Err(other_error) => Err(other_error), - } + let guard = self.repository.read().await; + exists(key, guard.deref()).await } pub fn supports_writes(&self) -> StoreResult { @@ -585,6 +572,15 @@ impl Store { } pub async fn set(&self, key: &str, value: Bytes) -> StoreResult<()> { + self.set_with_optional_locking(key, value, None).await + } + + async fn set_with_optional_locking( + &self, + key: &str, + value: Bytes, + locked_repo: Option<&mut Repository>, + ) -> StoreResult<()> { if self.mode == AccessMode::ReadOnly { return Err(StoreError::ReadOnly); } @@ -592,39 +588,47 @@ impl Store { match Key::parse(key)? { Key::Metadata { node_path } => { if let Ok(array_meta) = serde_json::from_slice(value.as_ref()) { - self.set_array_meta(node_path, array_meta).await + self.set_array_meta(node_path, array_meta, locked_repo).await } else { match serde_json::from_slice(value.as_ref()) { Ok(group_meta) => { - self.set_group_meta(node_path, group_meta).await + self.set_group_meta(node_path, group_meta, locked_repo).await } Err(err) => Err(StoreError::BadMetadata(err)), } } } Key::Chunk { node_path, coords } => { - // we only lock the repository to get the writer - let writer = self.repository.read().await.get_chunk_writer(); - // then we can write the bytes without holding the lock - let payload = writer(value).await?; - // and finally we lock for write and update the reference - self.repository - .write() - .await - .set_chunk_ref(node_path, coords, Some(payload)) - .await?; + match locked_repo { + Some(repo) => { + let writer = repo.get_chunk_writer(); + let payload = writer(value).await?; + repo.set_chunk_ref(node_path, coords, Some(payload)).await? + } + None => { + // we only lock the repository to get the writer + let writer = self.repository.read().await.get_chunk_writer(); + // then we can write the bytes without holding the lock + let payload = writer(value).await?; + // and finally we lock for write and update the reference + self.repository + .write() + .await + .set_chunk_ref(node_path, coords, Some(payload)) + .await? + } + } Ok(()) } } } pub async fn set_if_not_exists(&self, key: &str, value: Bytes) -> StoreResult<()> { - // TODO: Make sure this is correctly threadsafe. Technically a third API call - // may be able to slip into the gap between the exists and the set. - if self.exists(key).await? { + let mut guard = self.repository.write().await; + if exists(key, guard.deref()).await? { Ok(()) } else { - self.set(key, value).await + self.set_with_optional_locking(key, value, Some(guard.deref_mut())).await } } @@ -756,99 +760,50 @@ impl Store { Ok(futures::stream::iter(parents.into_iter().map(Ok))) } - async fn get_chunk( + async fn set_array_meta( &self, - key: &str, path: Path, - coords: ChunkIndices, - byte_range: &ByteRange, - ) -> StoreResult { - // we only lock the repository while we get the reader - let reader = self - .repository - .read() - .await - .get_chunk_reader(&path, &coords, byte_range) - .await?; - - // then we can fetch the bytes without holding the lock - let chunk = get_chunk(reader).await?; - chunk.ok_or(StoreError::NotFound(KeyNotFoundError::ChunkNotFound { - key: key.to_string(), - path, - coords, - })) - } - - async fn get_metadata( - &self, - _key: &str, - path: &Path, - range: &ByteRange, - ) -> StoreResult { - let node = self.repository.read().await.get_node(path).await.map_err(|_| { - StoreError::NotFound(KeyNotFoundError::NodeNotFound { path: path.clone() }) - })?; - let user_attributes = match node.user_attributes { - None => None, - Some(UserAttributesSnapshot::Inline(atts)) => Some(atts), - // FIXME: implement - Some(UserAttributesSnapshot::Ref(_)) => todo!(), - }; - let full_metadata = match node.node_data { - NodeData::Group => { - Ok::(GroupMetadata::new(user_attributes).to_bytes()) - } - NodeData::Array(zarr_metadata, _) => { - Ok(ArrayMetadata::new(user_attributes, zarr_metadata).to_bytes()) - } - }?; - - Ok(range.slice(full_metadata)) + array_meta: ArrayMetadata, + locked_repo: Option<&mut Repository>, + ) -> Result<(), StoreError> { + match locked_repo { + Some(repo) => set_array_meta(path, array_meta, repo).await, + None => self.set_array_meta_locking(path, array_meta).await, + } } - async fn set_array_meta( + async fn set_array_meta_locking( &self, path: Path, array_meta: ArrayMetadata, ) -> Result<(), StoreError> { // we need to hold the lock while we search the array and do the update to avoid race // conditions with other writers (notice we don't take &mut self) - let mut guard = self.repository.write().await; - if guard.get_array(&path).await.is_ok() { - // TODO: we don't necessarily need to update both - let repository = guard.deref_mut(); - repository.set_user_attributes(path.clone(), array_meta.attributes).await?; - repository.update_array(path, array_meta.zarr_metadata).await?; - Ok(()) - } else { - let repository = guard.deref_mut(); - repository.add_array(path.clone(), array_meta.zarr_metadata).await?; - repository.set_user_attributes(path, array_meta.attributes).await?; - Ok(()) - } + set_array_meta(path, array_meta, guard.deref_mut()).await } async fn set_group_meta( &self, path: Path, group_meta: GroupMetadata, + locked_repo: Option<&mut Repository>, + ) -> Result<(), StoreError> { + match locked_repo { + Some(repo) => set_group_meta(path, group_meta, repo).await, + None => self.set_group_meta_locking(path, group_meta).await, + } + } + + async fn set_group_meta_locking( + &self, + path: Path, + group_meta: GroupMetadata, ) -> Result<(), StoreError> { - // we need to hold the lock while we search the group and do the update to avoid race + // we need to hold the lock while we search the array and do the update to avoid race // conditions with other writers (notice we don't take &mut self) - // let mut guard = self.repository.write().await; - if guard.get_group(&path).await.is_ok() { - let repository = guard.deref_mut(); - repository.set_user_attributes(path, group_meta.attributes).await?; - Ok(()) - } else { - let repository = guard.deref_mut(); - repository.add_group(path.clone()).await?; - repository.set_user_attributes(path, group_meta.attributes).await?; - Ok(()) - } + set_group_meta(path, group_meta, guard.deref_mut()).await } async fn list_metadata_prefix<'a, 'b: 'a>( @@ -899,6 +854,111 @@ impl Store { } } +async fn set_array_meta( + path: Path, + array_meta: ArrayMetadata, + repo: &mut Repository, +) -> Result<(), StoreError> { + if repo.get_array(&path).await.is_ok() { + // TODO: we don't necessarily need to update both + repo.set_user_attributes(path.clone(), array_meta.attributes).await?; + repo.update_array(path, array_meta.zarr_metadata).await?; + Ok(()) + } else { + repo.add_array(path.clone(), array_meta.zarr_metadata).await?; + repo.set_user_attributes(path, array_meta.attributes).await?; + Ok(()) + } +} + +async fn set_group_meta( + path: Path, + group_meta: GroupMetadata, + repo: &mut Repository, +) -> Result<(), StoreError> { + // we need to hold the lock while we search the group and do the update to avoid race + // conditions with other writers (notice we don't take &mut self) + // + if repo.get_group(&path).await.is_ok() { + repo.set_user_attributes(path, group_meta.attributes).await?; + Ok(()) + } else { + repo.add_group(path.clone()).await?; + repo.set_user_attributes(path, group_meta.attributes).await?; + Ok(()) + } +} + +async fn get_metadata( + _key: &str, + path: &Path, + range: &ByteRange, + repo: &Repository, +) -> StoreResult { + let node = repo.get_node(path).await.map_err(|_| { + StoreError::NotFound(KeyNotFoundError::NodeNotFound { path: path.clone() }) + })?; + let user_attributes = match node.user_attributes { + None => None, + Some(UserAttributesSnapshot::Inline(atts)) => Some(atts), + // FIXME: implement + Some(UserAttributesSnapshot::Ref(_)) => todo!(), + }; + let full_metadata = match node.node_data { + NodeData::Group => { + Ok::(GroupMetadata::new(user_attributes).to_bytes()) + } + NodeData::Array(zarr_metadata, _) => { + Ok(ArrayMetadata::new(user_attributes, zarr_metadata).to_bytes()) + } + }?; + + Ok(range.slice(full_metadata)) +} + +async fn get_chunk_bytes( + key: &str, + path: Path, + coords: ChunkIndices, + byte_range: &ByteRange, + repo: &Repository, +) -> StoreResult { + let reader = repo.get_chunk_reader(&path, &coords, byte_range).await?; + + // then we can fetch the bytes without holding the lock + let chunk = get_chunk(reader).await?; + chunk.ok_or(StoreError::NotFound(KeyNotFoundError::ChunkNotFound { + key: key.to_string(), + path, + coords, + })) +} + +async fn get_key( + key: &str, + byte_range: &ByteRange, + repo: &Repository, +) -> StoreResult { + let bytes = match Key::parse(key)? { + Key::Metadata { node_path } => { + get_metadata(key, &node_path, byte_range, repo).await + } + Key::Chunk { node_path, coords } => { + get_chunk_bytes(key, node_path, coords, byte_range, repo).await + } + }?; + + Ok(bytes) +} + +async fn exists(key: &str, repo: &Repository) -> StoreResult { + match get_key(key, &ByteRange::ALL, repo).await { + Ok(_) => Ok(true), + Err(StoreError::NotFound(_)) => Ok(false), + Err(other_error) => Err(other_error), + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum Key { Metadata { node_path: Path }, From 1860db62d6d88a3cee84b9768f33c6ce183ca5f3 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Sat, 5 Oct 2024 00:55:28 -0300 Subject: [PATCH 024/167] Add failing tests for delete --- icechunk/src/repository.rs | 60 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index 6851e2c6..4d6c331b 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -1733,6 +1733,66 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_basic_delete_and_flush() -> Result<(), Box> { + let storage: Arc = + Arc::new(ObjectStorage::new_in_memory_store(Some("prefix".into()))); + let mut ds = Repository::init(Arc::clone(&storage), false).await?.build(); + ds.add_group("/".into()).await?; + ds.add_group("/1".into()).await?; + ds.delete_group("/1".into()).await?; + assert_eq!(ds.list_nodes().await?.count(), 1); + ds.commit("main", "commit", None).await?; + assert!(ds.get_group(&"/".into()).await.is_ok()); + assert!(ds.get_group(&"/1".into()).await.is_err()); + assert_eq!(ds.list_nodes().await?.count(), 1); + Ok(()) + } + + #[tokio::test] + async fn test_basic_delete_after_flush() -> Result<(), Box> { + let storage: Arc = + Arc::new(ObjectStorage::new_in_memory_store(Some("prefix".into()))); + let mut ds = Repository::init(Arc::clone(&storage), false).await?.build(); + ds.add_group("/".into()).await?; + ds.add_group("/1".into()).await?; + ds.commit("main", "commit", None).await?; + + ds.delete_group("/1".into()).await?; + assert!(ds.get_group(&"/".into()).await.is_ok()); + assert!(ds.get_group(&"/1".into()).await.is_err()); + assert_eq!(ds.list_nodes().await?.count(), 1); + Ok(()) + } + + #[tokio::test] + async fn test_commit_after_deleting_old_node() -> Result<(), Box> { + let storage: Arc = + Arc::new(ObjectStorage::new_in_memory_store(Some("prefix".into()))); + let mut ds = Repository::init(Arc::clone(&storage), false).await?.build(); + ds.add_group("/".into()).await?; + ds.commit("main", "commit", None).await?; + ds.delete_group("/".into()).await?; + ds.commit("main", "commit", None).await?; + assert_eq!(ds.list_nodes().await?.count(), 0); + Ok(()) + } + + #[tokio::test] + async fn test_delete_children() -> Result<(), Box> { + let storage: Arc = + Arc::new(ObjectStorage::new_in_memory_store(Some("prefix".into()))); + let mut ds = Repository::init(Arc::clone(&storage), false).await?.build(); + ds.add_group("/".into()).await?; + ds.add_group("/a".into()).await?; + ds.add_group("/b".into()).await?; + ds.add_group("/b/bb".into()).await?; + ds.delete_group("/b".into()).await?; + assert!(ds.get_group(&"/b".into()).await.is_err()); + assert!(ds.get_group(&"/b/bb".into()).await.is_err()); + Ok(()) + } + #[tokio::test(flavor = "multi_thread")] async fn test_all_chunks_iterator() -> Result<(), Box> { let storage: Arc = From c5bd0d610edd576c1eacbdfe8f2fe4a7464b8914 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Sat, 5 Oct 2024 10:01:21 -0400 Subject: [PATCH 025/167] Update to latest zarr python alpha (#144) --- icechunk-python/pyproject.toml | 2 +- icechunk-python/tests/test_pickle.py | 2 +- icechunk-python/tests/test_zarr/test_array.py | 7 ++- icechunk-python/tests/test_zarr/test_group.py | 46 +++++++++++++------ .../tests/test_zarr/test_store/test_core.py | 2 +- 5 files changed, 37 insertions(+), 22 deletions(-) diff --git a/icechunk-python/pyproject.toml b/icechunk-python/pyproject.toml index cf193702..f0ee68d9 100644 --- a/icechunk-python/pyproject.toml +++ b/icechunk-python/pyproject.toml @@ -12,7 +12,7 @@ classifiers = [ ] dynamic = ["version"] -dependencies = ["zarr==3.0.0a6"] +dependencies = ["zarr==3.0.0a7"] [project.optional-dependencies] test = [ diff --git a/icechunk-python/tests/test_pickle.py b/icechunk-python/tests/test_pickle.py index 4bfe21fb..ee02fdff 100644 --- a/icechunk-python/tests/test_pickle.py +++ b/icechunk-python/tests/test_pickle.py @@ -2,7 +2,7 @@ import pytest import zarr -from zarr.store.local import LocalStore +from zarr.storage import LocalStore import icechunk diff --git a/icechunk-python/tests/test_zarr/test_array.py b/icechunk-python/tests/test_zarr/test_array.py index e7dee8b4..31e5fe4e 100644 --- a/icechunk-python/tests/test_zarr/test_array.py +++ b/icechunk-python/tests/test_zarr/test_array.py @@ -10,7 +10,7 @@ import zarr.api.asynchronous from zarr.core.common import ZarrFormat from zarr.errors import ContainsArrayError, ContainsGroupError -from zarr.store.common import StorePath +from zarr.storage import StorePath @pytest.mark.parametrize("store", ["memory"], indirect=["store"]) @@ -88,10 +88,9 @@ async def test_create_creates_parents( expected = [f"{part}/{file}" for file in files for part in parts] if zarr_format == 2: - expected.append("a/b/c/d/.zarray") - expected.append("a/b/c/d/.zattrs") + expected.extend([".zattrs", ".zgroup", "a/b/c/d/.zarray", "a/b/c/d/.zattrs"]) else: - expected.append("a/b/c/d/zarr.json") + expected.extend(["zarr.json", "a/b/c/d/zarr.json"]) expected = sorted(expected) diff --git a/icechunk-python/tests/test_zarr/test_group.py b/icechunk-python/tests/test_zarr/test_group.py index 90118728..2fad26b0 100644 --- a/icechunk-python/tests/test_zarr/test_group.py +++ b/icechunk-python/tests/test_zarr/test_group.py @@ -15,8 +15,7 @@ from zarr.core.group import GroupMetadata from zarr.core.sync import sync from zarr.errors import ContainsArrayError, ContainsGroupError -from zarr.store import StorePath -from zarr.store.common import make_store_path +from zarr.storage import StorePath, make_store_path from ..conftest import parse_store @@ -66,6 +65,20 @@ async def test_create_creates_parents( await zarr.api.asynchronous.open_group( store=store, path="a", zarr_format=zarr_format, attributes={"key": "value"} ) + objs = {x async for x in store.list()} + if zarr_format == 2: + assert objs == {".zgroup", ".zattrs", "a/.zgroup", "a/.zattrs"} + else: + assert objs == {"zarr.json", "a/zarr.json"} + + # test that root group node was created + root = await zarr.api.asynchronous.open_group( + store=store, + ) + + agroup = await root.getitem("a") + assert agroup.attrs == {"key": "value"} + # create a child node with a couple intermediates await zarr.api.asynchronous.open_group( store=store, path="a/b/c/d", zarr_format=zarr_format @@ -80,10 +93,9 @@ async def test_create_creates_parents( expected = [f"{part}/{file}" for file in files for part in parts] if zarr_format == 2: - expected.append("a/b/c/d/.zgroup") - expected.append("a/b/c/d/.zattrs") + expected.extend([".zgroup", ".zattrs", "a/b/c/d/.zgroup", "a/b/c/d/.zattrs"]) else: - expected.append("a/b/c/d/zarr.json") + expected.extend(["zarr.json", "a/b/c/d/zarr.json"]) expected = sorted(expected) @@ -248,12 +260,7 @@ def test_group_create( if not exists_ok: with pytest.raises(ContainsGroupError): - group = Group.from_store( - store, - attributes=attributes, - exists_ok=exists_ok, - zarr_format=zarr_format, - ) + _ = Group.from_store(store, exists_ok=exists_ok, zarr_format=zarr_format) def test_group_open( @@ -334,8 +341,7 @@ def test_group_iter(store: IcechunkStore, zarr_format: ZarrFormat) -> None: """ group = Group.from_store(store, zarr_format=zarr_format) - with pytest.raises(NotImplementedError): - [x for x in group] # type: ignore + assert list(group) == [] def test_group_len(store: IcechunkStore, zarr_format: ZarrFormat) -> None: @@ -344,8 +350,7 @@ def test_group_len(store: IcechunkStore, zarr_format: ZarrFormat) -> None: """ group = Group.from_store(store, zarr_format=zarr_format) - with pytest.raises(NotImplementedError): - len(group) + assert len(group) == 0 def test_group_setitem(store: IcechunkStore, zarr_format: ZarrFormat) -> None: @@ -909,3 +914,14 @@ async def test_require_array(store: IcechunkStore, zarr_format: ZarrFormat) -> N _ = await root.create_group("bar") with pytest.raises(TypeError, match="Incompatible object"): await root.require_array("bar", shape=(10,), dtype="int8") + +class TestGroupMetadata: + def test_from_dict_extra_fields(self): + data = { + "attributes": {"key": "value"}, + "_nczarr_superblock": {"version": "2.0.0"}, + "zarr_format": 2, + } + result = GroupMetadata.from_dict(data) + expected = GroupMetadata(attributes={"key": "value"}, zarr_format=2) + assert result == expected diff --git a/icechunk-python/tests/test_zarr/test_store/test_core.py b/icechunk-python/tests/test_zarr/test_store/test_core.py index 525bfc55..4a334ffc 100644 --- a/icechunk-python/tests/test_zarr/test_store/test_core.py +++ b/icechunk-python/tests/test_zarr/test_store/test_core.py @@ -1,6 +1,6 @@ from icechunk import IcechunkStore -from zarr.store.common import make_store_path +from zarr.storage import make_store_path from ...conftest import parse_store From 770a265b5a8b2f1061d8483e1bb937d2fa564064 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Sat, 5 Oct 2024 11:08:09 -0400 Subject: [PATCH 026/167] Update smoke test example with latest api (#145) --- icechunk-python/examples/smoke-test.py | 22 ++++++++++++--------- icechunk-python/python/icechunk/__init__.py | 4 ---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/icechunk-python/examples/smoke-test.py b/icechunk-python/examples/smoke-test.py index fd64ddeb..3247f1e0 100644 --- a/icechunk-python/examples/smoke-test.py +++ b/icechunk-python/examples/smoke-test.py @@ -1,6 +1,6 @@ import asyncio from typing import Literal -from zarr.store import LocalStore, MemoryStore, RemoteStore +from zarr.storage import LocalStore, MemoryStore, RemoteStore import math import numpy as np @@ -45,7 +45,7 @@ def create_array(*, group, name, size, dtype, fill_value) -> np.ndarray: array, chunk_shape = generate_array_chunks(size=size, dtype=dtype) - group.create_array( + group.require_array( name=name, shape=array.shape, dtype=array.dtype, @@ -157,7 +157,7 @@ async def run(store: Store) -> None: async def create_icechunk_store(*, storage: StorageConfig) -> IcechunkStore: - return await IcechunkStore.create( + return await IcechunkStore.open( storage=storage, mode="r+", config=StoreConfig(inline_chunk_threshold_bytes=1) ) @@ -171,7 +171,13 @@ async def create_zarr_store(*, store: Literal["memory", "local", "s3"]) -> Store return RemoteStore.from_url( "s3://testbucket/root-zarr", mode="w", - storage_options={"endpoint_url": "http://localhost:9000"}, + storage_options={ + "anon": False, + "key": "minio123", + "secret": "minio123", + "region": "us-east-1", + "endpoint_url": "http://localhost:9000" + }, ) @@ -185,17 +191,15 @@ async def create_zarr_store(*, store: Literal["memory", "local", "s3"]) -> Store secret_access_key="minio123", session_token=None, ), + region="us-east-1", + allow_http=True, endpoint_url="http://localhost:9000", ) - S3 = StorageConfig.s3_from_env( - bucket="icechunk-test", - prefix="demo-repository", - ) print("Icechunk store") store = asyncio.run(create_icechunk_store(storage=MINIO)) asyncio.run(run(store)) print("Zarr store") - zarr_store = asyncio.run(create_zarr_store(store="s3")) + zarr_store = asyncio.run(create_zarr_store(store="local")) asyncio.run(run(zarr_store)) diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index 96bf5b7e..4b5ebe7a 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -151,10 +151,6 @@ def with_mode(self, mode: AccessModeLiteral) -> Self: store: A new store of the same type with the new mode. - Examples - -------- - >>> writer = zarr.store.MemoryStore(mode="w") - >>> reader = writer.with_mode("r") """ read_only = mode == "r" new_store = self._store.with_mode(read_only) From c547674965df3048fdd26db95945047a93334c1d Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Sat, 5 Oct 2024 12:19:11 -0300 Subject: [PATCH 027/167] minor improvement to dask script --- icechunk-python/examples/dask_write.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/icechunk-python/examples/dask_write.py b/icechunk-python/examples/dask_write.py index e469d946..59ad60f8 100644 --- a/icechunk-python/examples/dask_write.py +++ b/icechunk-python/examples/dask_write.py @@ -148,7 +148,7 @@ async def update(args): sleep=max( 0, args.max_sleep - - ((args.max_sleep - args.min_sleep) / args.sleep_tasks * time), + - ((args.max_sleep - args.min_sleep) / (args.sleep_tasks + 1) * time), ), ) for time in range(args.t_from, args.t_to, 1) @@ -263,7 +263,7 @@ async def distributed_write(): "--max-sleep", type=float, help="initial tasks sleep by these many seconds", - default=0.3, + default=0, ) update_parser.add_argument( "--min-sleep", @@ -272,30 +272,36 @@ async def distributed_write(): default=0, ) update_parser.add_argument( - "--sleep-tasks", type=int, help="this many tasks sleep", default=0.3 + "--sleep-tasks", type=int, help="this many tasks sleep", default=0 + ) + update_parser.add_argument( + "--distributed-cluster", type=bool, help="use multiple machines", action=argparse.BooleanOptionalAction, default=False ) update_parser.set_defaults(command="update") - update_parser = subparsers.add_parser("verify", help="verify array chunks") - update_parser.add_argument( + verify_parser = subparsers.add_parser("verify", help="verify array chunks") + verify_parser.add_argument( "--t-from", type=int, help="time position where to start adding chunks (included)", required=True, ) - update_parser.add_argument( + verify_parser.add_argument( "--t-to", type=int, help="time position where to stop adding chunks (not included)", required=True, ) - update_parser.add_argument( + verify_parser.add_argument( "--workers", type=int, help="number of workers to use", required=True ) - update_parser.add_argument( + verify_parser.add_argument( "--name", type=str, help="repository name", required=True ) - update_parser.set_defaults(command="verify") + verify_parser.add_argument( + "--distributed-cluster", type=bool, help="use multiple machines", action=argparse.BooleanOptionalAction, default=False + ) + verify_parser.set_defaults(command="verify") args = global_parser.parse_args() match args.command: From 8b6711c976d08780ea3af9f0ecd8a6e54591013b Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Sat, 5 Oct 2024 16:28:00 -0300 Subject: [PATCH 028/167] Group delete acts recursively, deleting all their contents Added test cases were failing before this change. Fixes #142 --- icechunk/src/change_set.rs | 73 ++++++++++++++++++++++++++++---------- icechunk/src/repository.rs | 33 ++++++++++++++--- 2 files changed, 83 insertions(+), 23 deletions(-) diff --git a/icechunk/src/change_set.rs b/icechunk/src/change_set.rs index 6306b618..af4bef6b 100644 --- a/icechunk/src/change_set.rs +++ b/icechunk/src/change_set.rs @@ -2,6 +2,7 @@ use std::{ collections::{HashMap, HashSet}, iter, mem::take, + path::Path as StdPath, }; use itertools::Either; @@ -27,8 +28,8 @@ pub struct ChangeSet { updated_attributes: HashMap>, // FIXME: issue with too many inline chunks kept in mem set_chunks: HashMap>>, - deleted_groups: HashSet, - deleted_arrays: HashSet, + deleted_groups: HashSet, + deleted_arrays: HashSet, } impl ChangeSet { @@ -48,14 +49,43 @@ impl ChangeSet { self.new_arrays.get(path) } - pub fn delete_group(&mut self, path: &Path, node_id: NodeId) { - let new_node_id = self.new_groups.remove(path); - let is_new_group = new_node_id.is_some(); - debug_assert!(!is_new_group || new_node_id == Some(node_id)); - + pub fn delete_group(&mut self, path: Path, node_id: NodeId) { self.updated_attributes.remove(&node_id); - if !is_new_group { - self.deleted_groups.insert(node_id); + match self.new_groups.remove(&path) { + Some(deleted_node_id) => { + // the group was created in this session + // so we delete it directly, no need to flag as deleted + debug_assert!(deleted_node_id == node_id); + self.delete_children(&path); + } + None => { + // it's an old group, we need to flag it as deleted + self.deleted_groups.insert(path); + } + } + } + + fn delete_children(&mut self, path: &Path) { + let groups_to_delete: Vec<_> = self + .new_groups + .iter() + .filter(|(child_path, _)| child_path.starts_with(path)) + .map(|(k, v)| (k.clone(), *v)) + .collect(); + + for (path, node) in groups_to_delete { + self.delete_group(path, node); + } + + let arrays_to_delete: Vec<_> = self + .new_arrays + .iter() + .filter(|(child_path, _)| child_path.starts_with(path)) + .map(|(k, (node, _))| (k.clone(), *node)) + .collect(); + + for (path, node) in arrays_to_delete { + self.delete_array(path, node); } } @@ -72,10 +102,10 @@ impl ChangeSet { self.updated_arrays.insert(node_id, metadata); } - pub fn delete_array(&mut self, path: &Path, node_id: NodeId) { + pub fn delete_array(&mut self, path: Path, node_id: NodeId) { // if deleting a new array created in this session, just remove the entry // from new_arrays - let node_and_meta = self.new_arrays.remove(path); + let node_and_meta = self.new_arrays.remove(&path); let is_new_array = node_and_meta.is_some(); debug_assert!(!is_new_array || node_and_meta.map(|n| n.0) == Some(node_id)); @@ -83,12 +113,14 @@ impl ChangeSet { self.updated_attributes.remove(&node_id); self.set_chunks.remove(&node_id); if !is_new_array { - self.deleted_arrays.insert(node_id); + self.deleted_arrays.insert(path); } } - pub fn is_deleted(&self, node_id: &NodeId) -> bool { - self.deleted_groups.contains(node_id) || self.deleted_arrays.contains(node_id) + pub fn is_deleted(&self, path: &StdPath) -> bool { + self.deleted_groups.contains(path) + || self.deleted_arrays.contains(path) + || path.ancestors().skip(1).any(|parent| self.is_deleted(parent)) } pub fn has_updated_attributes(&self, node_id: &NodeId) -> bool { @@ -309,29 +341,32 @@ impl ChangeSet { &self, node: NodeSnapshot, new_manifests: Option>, - ) -> NodeSnapshot { + ) -> Option { + if self.is_deleted(&node.path) { + return None; + } + let session_atts = self .get_user_attributes(node.id) .cloned() .map(|a| a.map(UserAttributesSnapshot::Inline)); let new_atts = session_atts.unwrap_or(node.user_attributes); match node.node_data { - NodeData::Group => NodeSnapshot { user_attributes: new_atts, ..node }, + NodeData::Group => Some(NodeSnapshot { user_attributes: new_atts, ..node }), NodeData::Array(old_zarr_meta, _) => { let new_zarr_meta = self .get_updated_zarr_metadata(node.id) .cloned() .unwrap_or(old_zarr_meta); - NodeSnapshot { - // FIXME: bad option type, change + Some(NodeSnapshot { node_data: NodeData::Array( new_zarr_meta, new_manifests.unwrap_or_default(), ), user_attributes: new_atts, ..node - } + }) } } } diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index 4d6c331b..b8312ade 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -319,7 +319,7 @@ impl Repository { pub async fn delete_group(&mut self, path: Path) -> RepositoryResult<()> { self.get_group(&path) .await - .map(|node| self.change_set.delete_group(&node.path, node.id)) + .map(|node| self.change_set.delete_group(node.path, node.id)) } /// Add an array to the store. @@ -360,7 +360,7 @@ impl Repository { pub async fn delete_array(&mut self, path: Path) -> RepositoryResult<()> { self.get_array(&path) .await - .map(|node| self.change_set.delete_array(&node.path, node.id)) + .map(|node| self.change_set.delete_array(node.path, node.id)) } /// Record the write or delete of user attributes to array or group @@ -410,11 +410,17 @@ impl Repository { pub async fn get_node(&self, path: &Path) -> RepositoryResult { // We need to look for nodes in self.change_set and the snapshot file + if self.change_set.is_deleted(path) { + return Err(RepositoryError::NodeNotFound { + path: path.clone(), + message: "getting node".to_string(), + }); + } match self.change_set.get_new_node(path) { Some(node) => Ok(node), None => { let node = self.get_existing_node(path).await?; - if self.change_set.is_deleted(&node.id) { + if self.change_set.is_deleted(node.path.as_path()) { Err(RepositoryError::NodeNotFound { path: path.clone(), message: "getting node".to_string(), @@ -965,7 +971,7 @@ async fn updated_existing_nodes<'a>( // TODO: solve this duplication, there is always the possibility of this being the first // version let updated_nodes = - storage.fetch_snapshot(parent_id).await?.iter_arc().map(move |node| { + storage.fetch_snapshot(parent_id).await?.iter_arc().filter_map(move |node| { let new_manifests = if node.node_type() == NodeType::Array { //FIXME: it could be none for empty arrays Some(vec![ManifestRef { @@ -1793,6 +1799,23 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_delete_children_of_old_nodes() -> Result<(), Box> { + let storage: Arc = + Arc::new(ObjectStorage::new_in_memory_store(Some("prefix".into()))); + let mut ds = Repository::init(Arc::clone(&storage), false).await?.build(); + ds.add_group("/".into()).await?; + ds.add_group("/a".into()).await?; + ds.add_group("/b".into()).await?; + ds.add_group("/b/bb".into()).await?; + ds.commit("main", "commit", None).await?; + + ds.delete_group("/b".into()).await?; + assert!(ds.get_group(&"/b".into()).await.is_err()); + assert!(ds.get_group(&"/b/bb".into()).await.is_err()); + Ok(()) + } + #[tokio::test(flavor = "multi_thread")] async fn test_all_chunks_iterator() -> Result<(), Box> { let storage: Arc = @@ -2119,6 +2142,8 @@ mod tests { "Attempting to delete a non-existent path: {path}", ); state.groups.swap_remove(index); + state.groups.retain(|group| !group.starts_with(path)); + state.arrays.retain(|array, _| !array.starts_with(path)); } _ => panic!(), } From 455b287dc8604279161193a2df5cac63afa89e48 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Sat, 5 Oct 2024 22:02:36 -0300 Subject: [PATCH 029/167] Better `Path` type. We were using `PathBuf`, which has platform dependent behavior. Now we use a platform independent type, and verify correctness using a smart constructor. --- Cargo.lock | 7 ++ icechunk/Cargo.toml | 1 + icechunk/examples/low_level_dataset.rs | 10 +- icechunk/src/change_set.rs | 5 +- icechunk/src/format/mod.rs | 71 ++++++++++- icechunk/src/format/snapshot.rs | 40 ++++--- icechunk/src/repository.rs | 139 +++++++++++----------- icechunk/src/strategies.rs | 7 +- icechunk/src/zarr.rs | 138 +++++++++++---------- icechunk/tests/test_concurrency.rs | 16 ++- icechunk/tests/test_distributed_writes.rs | 14 ++- icechunk/tests/test_virtual_refs.rs | 12 +- 12 files changed, 282 insertions(+), 178 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 596e608e..52b10eb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1220,6 +1220,7 @@ dependencies = [ "test-strategy", "thiserror", "tokio", + "typed-path", "url", ] @@ -2413,6 +2414,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c0c7479c430935701ff2532e3091e6680ec03f2f89ffcd9988b08e885b90a5" + [[package]] name = "typenum" version = "1.17.0" diff --git a/icechunk/Cargo.toml b/icechunk/Cargo.toml index 52713ee2..5fe84b05 100644 --- a/icechunk/Cargo.toml +++ b/icechunk/Cargo.toml @@ -31,6 +31,7 @@ rmpv = { version = "1.3.0", features = ["serde", "with-serde"] } aws-sdk-s3 = "1.53.0" aws-config = "1.5.7" aws-credential-types = "1.2.1" +typed-path = "0.9.2" [dev-dependencies] pretty_assertions = "1.4.1" diff --git a/icechunk/examples/low_level_dataset.rs b/icechunk/examples/low_level_dataset.rs index 097a07c3..063ad193 100644 --- a/icechunk/examples/low_level_dataset.rs +++ b/icechunk/examples/low_level_dataset.rs @@ -49,9 +49,9 @@ ds.add_group("/group2".into()).await?; "#, ); - ds.add_group("/".into()).await?; - ds.add_group("/group1".into()).await?; - ds.add_group("/group2".into()).await?; + ds.add_group(Path::root()).await?; + ds.add_group("/group1".try_into().unwrap()).await?; + ds.add_group("/group2".try_into().unwrap()).await?; println!(); print_nodes(&ds).await?; @@ -129,7 +129,7 @@ ds.add_array(array1_path.clone(), zarr_meta1).await?; Some("t".to_string()), ]), }; - let array1_path: Path = "/group1/array1".into(); + let array1_path: Path = "/group1/array1".try_into().unwrap(); ds.add_array(array1_path.clone(), zarr_meta1).await?; println!(); print_nodes(&ds).await?; @@ -292,7 +292,7 @@ async fn print_nodes(ds: &Repository) -> Result<(), StoreError> { format!( "|{:10?}|{:15}|{:10?}\n", node.node_type(), - node.path.to_str().unwrap(), + node.path.to_string(), node.user_attributes, ) }) diff --git a/icechunk/src/change_set.rs b/icechunk/src/change_set.rs index af4bef6b..e91c09e0 100644 --- a/icechunk/src/change_set.rs +++ b/icechunk/src/change_set.rs @@ -2,7 +2,6 @@ use std::{ collections::{HashMap, HashSet}, iter, mem::take, - path::Path as StdPath, }; use itertools::Either; @@ -117,10 +116,10 @@ impl ChangeSet { } } - pub fn is_deleted(&self, path: &StdPath) -> bool { + pub fn is_deleted(&self, path: &Path) -> bool { self.deleted_groups.contains(path) || self.deleted_arrays.contains(path) - || path.ancestors().skip(1).any(|parent| self.is_deleted(parent)) + || path.ancestors().skip(1).any(|parent| self.is_deleted(&parent)) } pub fn has_updated_attributes(&self, node_id: &NodeId) -> bool { diff --git a/icechunk/src/format/mod.rs b/icechunk/src/format/mod.rs index d7168756..e8366f51 100644 --- a/icechunk/src/format/mod.rs +++ b/icechunk/src/format/mod.rs @@ -4,14 +4,15 @@ use std::{ hash::Hash, marker::PhantomData, ops::Bound, - path::PathBuf, }; use bytes::Bytes; use itertools::Itertools; use rand::{thread_rng, Rng}; use serde::{Deserialize, Deserializer, Serialize}; +use serde_with::{serde_as, TryFromInto}; use thiserror::Error; +use typed_path::Utf8UnixPathBuf; use crate::metadata::DataType; @@ -19,7 +20,9 @@ pub mod attributes; pub mod manifest; pub mod snapshot; -pub type Path = PathBuf; +#[serde_as] +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)] +pub struct Path(#[serde_as(as = "TryFromInto")] Utf8UnixPathBuf); #[allow(dead_code)] pub trait FileTypeTag {} @@ -232,6 +235,70 @@ pub mod format_constants { pub const LATEST_ICECHUNK_SNAPSHOT_VERSION_METADATA_KEY: &str = "ic-sna-fmt-ver"; } +impl Display for Path { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, Error, PartialEq, Eq)] +pub enum PathError { + #[error("path must start with a `/` character")] + NotAbsolute, + #[error(r#"path must be cannonic, cannot include "." or "..""#)] + NotCanonic, +} + +impl Path { + pub fn root() -> Path { + Path(Utf8UnixPathBuf::from("/".to_string())) + } + + pub fn new(path: &str) -> Result { + let buf = Utf8UnixPathBuf::from(path); + if !buf.is_absolute() { + return Err(PathError::NotAbsolute); + } + + if buf.normalize() != buf { + return Err(PathError::NotCanonic); + } + Ok(Path(buf)) + } + + pub fn starts_with(&self, other: &Path) -> bool { + self.0.starts_with(&other.0) + } + + pub fn ancestors(&self) -> impl Iterator + '_ { + self.0.ancestors().map(|p| Path(p.to_owned())) + } +} + +impl TryFrom<&str> for Path { + type Error = PathError; + + fn try_from(value: &str) -> Result { + Self::new(value) + } +} + +impl TryFrom<&String> for Path { + type Error = PathError; + + fn try_from(value: &String) -> Result { + value.as_str().try_into() + } +} + +impl TryFrom for Path { + type Error = PathError; + + fn try_from(value: String) -> Result { + value.as_str().try_into() + } +} + #[cfg(test)] #[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] mod tests { diff --git a/icechunk/src/format/snapshot.rs b/icechunk/src/format/snapshot.rs index ac76ed8c..a7930e7a 100644 --- a/icechunk/src/format/snapshot.rs +++ b/icechunk/src/format/snapshot.rs @@ -313,25 +313,25 @@ mod tests { let oid = ObjectId::random(); let nodes = vec![ NodeSnapshot { - path: "/".into(), + path: Path::root(), id: 1, user_attributes: None, node_data: NodeData::Group, }, NodeSnapshot { - path: "/a".into(), + path: "/a".try_into().unwrap(), id: 2, user_attributes: None, node_data: NodeData::Group, }, NodeSnapshot { - path: "/b".into(), + path: "/b".try_into().unwrap(), id: 3, user_attributes: None, node_data: NodeData::Group, }, NodeSnapshot { - path: "/b/c".into(), + path: "/b/c".try_into().unwrap(), id: 4, user_attributes: Some(UserAttributesSnapshot::Inline( UserAttributes::try_new(br#"{"foo": "some inline"}"#).unwrap(), @@ -339,7 +339,7 @@ mod tests { node_data: NodeData::Group, }, NodeSnapshot { - path: "/b/array1".into(), + path: "/b/array1".try_into().unwrap(), id: 5, user_attributes: Some(UserAttributesSnapshot::Ref(UserAttributesRef { object_id: oid.clone(), @@ -351,13 +351,13 @@ mod tests { ), }, NodeSnapshot { - path: "/array2".into(), + path: "/array2".try_into().unwrap(), id: 6, user_attributes: None, node_data: NodeData::Array(zarr_meta2.clone(), vec![]), }, NodeSnapshot { - path: "/b/array3".into(), + path: "/b/array3".try_into().unwrap(), id: 7, user_attributes: None, node_data: NodeData::Array(zarr_meta3.clone(), vec![]), @@ -377,15 +377,17 @@ mod tests { let st = Snapshot::from_iter(&initial, None, manifests, vec![], nodes); assert_eq!( - st.get_node(&"/nonexistent".into()), - Err(IcechunkFormatError::NodeNotFound { path: "/nonexistent".into() }) + st.get_node(&"/nonexistent".try_into().unwrap()), + Err(IcechunkFormatError::NodeNotFound { + path: "/nonexistent".try_into().unwrap() + }) ); - let node = st.get_node(&"/b/c".into()); + let node = st.get_node(&"/b/c".try_into().unwrap()); assert_eq!( node, Ok(&NodeSnapshot { - path: "/b/c".into(), + path: "/b/c".try_into().unwrap(), id: 4, user_attributes: Some(UserAttributesSnapshot::Inline( UserAttributes::try_new(br#"{"foo": "some inline"}"#).unwrap(), @@ -393,21 +395,21 @@ mod tests { node_data: NodeData::Group, }), ); - let node = st.get_node(&"/".into()); + let node = st.get_node(&Path::root()); assert_eq!( node, Ok(&NodeSnapshot { - path: "/".into(), + path: Path::root(), id: 1, user_attributes: None, node_data: NodeData::Group, }), ); - let node = st.get_node(&"/b/array1".into()); + let node = st.get_node(&"/b/array1".try_into().unwrap()); assert_eq!( node, Ok(&NodeSnapshot { - path: "/b/array1".into(), + path: "/b/array1".try_into().unwrap(), id: 5, user_attributes: Some(UserAttributesSnapshot::Ref(UserAttributesRef { object_id: oid, @@ -416,21 +418,21 @@ mod tests { node_data: NodeData::Array(zarr_meta1.clone(), vec![man_ref1, man_ref2]), }), ); - let node = st.get_node(&"/array2".into()); + let node = st.get_node(&"/array2".try_into().unwrap()); assert_eq!( node, Ok(&NodeSnapshot { - path: "/array2".into(), + path: "/array2".try_into().unwrap(), id: 6, user_attributes: None, node_data: NodeData::Array(zarr_meta2.clone(), vec![]), }), ); - let node = st.get_node(&"/b/array3".into()); + let node = st.get_node(&"/b/array3".try_into().unwrap()); assert_eq!( node, Ok(&NodeSnapshot { - path: "/b/array3".into(), + path: "/b/array3".try_into().unwrap(), id: 7, user_attributes: None, node_data: NodeData::Array(zarr_meta3.clone(), vec![]), diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index b8312ade..13772173 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -1,7 +1,6 @@ use std::{ collections::{BTreeMap, HashMap, HashSet}, iter::{self}, - path::PathBuf, pin::Pin, sync::Arc, }; @@ -420,7 +419,7 @@ impl Repository { Some(node) => Ok(node), None => { let node = self.get_existing_node(path).await?; - if self.change_set.is_deleted(node.path.as_path()) { + if self.change_set.is_deleted(&node.path) { Err(RepositoryError::NodeNotFound { path: path.clone(), message: "getting node".to_string(), @@ -649,7 +648,7 @@ impl Repository { /// Warning: The presence of a single error may mean multiple missing items async fn updated_chunk_iterator( &self, - ) -> RepositoryResult> + '_> + ) -> RepositoryResult> + '_> { let snapshot = self.storage.fetch_snapshot(&self.snapshot_id).await?; let nodes = futures::stream::iter(snapshot.iter_arc()); @@ -750,7 +749,7 @@ impl Repository { pub async fn all_chunks( &self, - ) -> RepositoryResult> + '_> + ) -> RepositoryResult> + '_> { let existing_array_chunks = self.updated_chunk_iterator().await?; let new_array_chunks = @@ -1102,7 +1101,7 @@ async fn distributed_flush>( #[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] mod tests { - use std::{error::Error, num::NonZeroU64, path::PathBuf}; + use std::{error::Error, num::NonZeroU64}; use crate::{ format::manifest::ChunkInfo, @@ -1282,10 +1281,10 @@ mod tests { object_id: manifest_id.clone(), extents: ManifestExtents(vec![]), }; - let array1_path: PathBuf = "/array1".to_string().into(); + let array1_path: Path = "/array1".try_into().unwrap(); let nodes = vec![ NodeSnapshot { - path: "/".into(), + path: Path::root(), id: 1, user_attributes: None, node_data: NodeData::Group, @@ -1323,13 +1322,13 @@ mod tests { assert_eq!(nodes.get(1).unwrap(), &node); let group_name = "/tbd-group".to_string(); - ds.add_group(group_name.clone().into()).await?; - ds.delete_group(group_name.clone().into()).await?; - assert!(ds.delete_group(group_name.clone().into()).await.is_err()); - assert!(ds.get_node(&group_name.into()).await.is_err()); + ds.add_group(group_name.clone().try_into().unwrap()).await?; + ds.delete_group(group_name.clone().try_into().unwrap()).await?; + assert!(ds.delete_group(group_name.clone().try_into().unwrap()).await.is_err()); + assert!(ds.get_node(&group_name.try_into().unwrap()).await.is_err()); // add a new array and retrieve its node - ds.add_group("/group".to_string().into()).await?; + ds.add_group("/group".try_into().unwrap()).await?; let zarr_meta2 = ZarrArrayMetadata { shape: vec![3], @@ -1345,7 +1344,7 @@ mod tests { dimension_names: Some(vec![Some("t".to_string())]), }; - let new_array_path: PathBuf = "/group/array2".to_string().into(); + let new_array_path: Path = "/group/array2".to_string().try_into().unwrap(); ds.add_array(new_array_path.clone(), zarr_meta2.clone()).await?; ds.delete_array(new_array_path.clone()).await?; @@ -1377,7 +1376,7 @@ mod tests { assert_eq!( node.ok(), Some(NodeSnapshot { - path: "/group/array2".into(), + path: "/group/array2".try_into().unwrap(), id: 6, user_attributes: Some(UserAttributesSnapshot::Inline( UserAttributes::try_new(br#"{"n":42}"#).unwrap() @@ -1443,7 +1442,7 @@ mod tests { .await?; assert_eq!(chunk, Some(data)); - let path: Path = "/group/array2".into(); + let path: Path = "/group/array2".try_into().unwrap(); let node = ds.get_node(&path).await; assert!(ds.change_set.has_updated_attributes(&node.as_ref().unwrap().id)); assert!(ds.delete_array(path.clone()).await.is_ok()); @@ -1479,8 +1478,8 @@ mod tests { ]), }; - change_set.add_array("foo/bar".into(), 1, zarr_meta.clone()); - change_set.add_array("foo/baz".into(), 2, zarr_meta); + change_set.add_array("/foo/bar".try_into().unwrap(), 1, zarr_meta.clone()); + change_set.add_array("/foo/baz".try_into().unwrap(), 2, zarr_meta); assert_eq!(None, change_set.new_arrays_chunk_iterator().next()); change_set.set_chunk_ref(1, ChunkIndices(vec![0, 1]), None); @@ -1514,7 +1513,7 @@ mod tests { .collect(); let expected_chunks: Vec<_> = [ ( - "foo/baz".into(), + "/foo/baz".try_into().unwrap(), ChunkInfo { node: 2, coord: ChunkIndices(vec![0]), @@ -1522,7 +1521,7 @@ mod tests { }, ), ( - "foo/baz".into(), + "/foo/baz".try_into().unwrap(), ChunkInfo { node: 2, coord: ChunkIndices(vec![1]), @@ -1530,7 +1529,7 @@ mod tests { }, ), ( - "foo/bar".into(), + "/foo/bar".try_into().unwrap(), ChunkInfo { node: 1, coord: ChunkIndices(vec![1, 0]), @@ -1538,7 +1537,7 @@ mod tests { }, ), ( - "foo/bar".into(), + "/foo/bar".try_into().unwrap(), ChunkInfo { node: 1, coord: ChunkIndices(vec![1, 1]), @@ -1563,35 +1562,35 @@ mod tests { let mut ds = Repository::init(Arc::clone(&storage), false).await?.build(); // add a new array and retrieve its node - ds.add_group("/".into()).await?; + ds.add_group(Path::root()).await?; let snapshot_id = ds.flush("commit", SnapshotProperties::default()).await?; assert_eq!(snapshot_id, ds.snapshot_id); assert_eq!( - ds.get_node(&"/".into()).await.ok(), + ds.get_node(&Path::root()).await.ok(), Some(NodeSnapshot { id: 1, - path: "/".into(), + path: Path::root(), user_attributes: None, node_data: NodeData::Group }) ); - ds.add_group("/group".into()).await?; + ds.add_group("/group".try_into().unwrap()).await?; let _snapshot_id = ds.flush("commit", SnapshotProperties::default()).await?; assert_eq!( - ds.get_node(&"/".into()).await.ok(), + ds.get_node(&Path::root()).await.ok(), Some(NodeSnapshot { id: 1, - path: "/".into(), + path: Path::root(), user_attributes: None, node_data: NodeData::Group }) ); assert_eq!( - ds.get_node(&"/group".into()).await.ok(), + ds.get_node(&"/group".try_into().unwrap()).await.ok(), Some(NodeSnapshot { id: 2, - path: "/group".into(), + path: "/group".try_into().unwrap(), user_attributes: None, node_data: NodeData::Group }) @@ -1610,7 +1609,7 @@ mod tests { dimension_names: Some(vec![Some("t".to_string())]), }; - let new_array_path: PathBuf = "/group/array1".to_string().into(); + let new_array_path: Path = "/group/array1".try_into().unwrap(); ds.add_array(new_array_path.clone(), zarr_meta.clone()).await?; // wo commit to test the case of a chunkless array @@ -1626,19 +1625,19 @@ mod tests { let _snapshot_id = ds.flush("commit", SnapshotProperties::default()).await?; assert_eq!( - ds.get_node(&"/".into()).await.ok(), + ds.get_node(&Path::root()).await.ok(), Some(NodeSnapshot { id: 1, - path: "/".into(), + path: Path::root(), user_attributes: None, node_data: NodeData::Group }) ); assert_eq!( - ds.get_node(&"/group".into()).await.ok(), + ds.get_node(&"/group".try_into().unwrap()).await.ok(), Some(NodeSnapshot { id: 2, - path: "/group".into(), + path: "/group".try_into().unwrap(), user_attributes: None, node_data: NodeData::Group }) @@ -1744,13 +1743,13 @@ mod tests { let storage: Arc = Arc::new(ObjectStorage::new_in_memory_store(Some("prefix".into()))); let mut ds = Repository::init(Arc::clone(&storage), false).await?.build(); - ds.add_group("/".into()).await?; - ds.add_group("/1".into()).await?; - ds.delete_group("/1".into()).await?; + ds.add_group(Path::root()).await?; + ds.add_group("/1".try_into().unwrap()).await?; + ds.delete_group("/1".try_into().unwrap()).await?; assert_eq!(ds.list_nodes().await?.count(), 1); ds.commit("main", "commit", None).await?; - assert!(ds.get_group(&"/".into()).await.is_ok()); - assert!(ds.get_group(&"/1".into()).await.is_err()); + assert!(ds.get_group(&Path::root()).await.is_ok()); + assert!(ds.get_group(&"/1".try_into().unwrap()).await.is_err()); assert_eq!(ds.list_nodes().await?.count(), 1); Ok(()) } @@ -1760,13 +1759,13 @@ mod tests { let storage: Arc = Arc::new(ObjectStorage::new_in_memory_store(Some("prefix".into()))); let mut ds = Repository::init(Arc::clone(&storage), false).await?.build(); - ds.add_group("/".into()).await?; - ds.add_group("/1".into()).await?; + ds.add_group(Path::root()).await?; + ds.add_group("/1".try_into().unwrap()).await?; ds.commit("main", "commit", None).await?; - ds.delete_group("/1".into()).await?; - assert!(ds.get_group(&"/".into()).await.is_ok()); - assert!(ds.get_group(&"/1".into()).await.is_err()); + ds.delete_group("/1".try_into().unwrap()).await?; + assert!(ds.get_group(&Path::root()).await.is_ok()); + assert!(ds.get_group(&"/1".try_into().unwrap()).await.is_err()); assert_eq!(ds.list_nodes().await?.count(), 1); Ok(()) } @@ -1776,9 +1775,9 @@ mod tests { let storage: Arc = Arc::new(ObjectStorage::new_in_memory_store(Some("prefix".into()))); let mut ds = Repository::init(Arc::clone(&storage), false).await?.build(); - ds.add_group("/".into()).await?; + ds.add_group(Path::root()).await?; ds.commit("main", "commit", None).await?; - ds.delete_group("/".into()).await?; + ds.delete_group(Path::root()).await?; ds.commit("main", "commit", None).await?; assert_eq!(ds.list_nodes().await?.count(), 0); Ok(()) @@ -1789,13 +1788,13 @@ mod tests { let storage: Arc = Arc::new(ObjectStorage::new_in_memory_store(Some("prefix".into()))); let mut ds = Repository::init(Arc::clone(&storage), false).await?.build(); - ds.add_group("/".into()).await?; - ds.add_group("/a".into()).await?; - ds.add_group("/b".into()).await?; - ds.add_group("/b/bb".into()).await?; - ds.delete_group("/b".into()).await?; - assert!(ds.get_group(&"/b".into()).await.is_err()); - assert!(ds.get_group(&"/b/bb".into()).await.is_err()); + ds.add_group(Path::root()).await?; + ds.add_group("/a".try_into().unwrap()).await?; + ds.add_group("/b".try_into().unwrap()).await?; + ds.add_group("/b/bb".try_into().unwrap()).await?; + ds.delete_group("/b".try_into().unwrap()).await?; + assert!(ds.get_group(&"/b".try_into().unwrap()).await.is_err()); + assert!(ds.get_group(&"/b/bb".try_into().unwrap()).await.is_err()); Ok(()) } @@ -1804,15 +1803,15 @@ mod tests { let storage: Arc = Arc::new(ObjectStorage::new_in_memory_store(Some("prefix".into()))); let mut ds = Repository::init(Arc::clone(&storage), false).await?.build(); - ds.add_group("/".into()).await?; - ds.add_group("/a".into()).await?; - ds.add_group("/b".into()).await?; - ds.add_group("/b/bb".into()).await?; + ds.add_group(Path::root()).await?; + ds.add_group("/a".try_into().unwrap()).await?; + ds.add_group("/b".try_into().unwrap()).await?; + ds.add_group("/b/bb".try_into().unwrap()).await?; ds.commit("main", "commit", None).await?; - ds.delete_group("/b".into()).await?; - assert!(ds.get_group(&"/b".into()).await.is_err()); - assert!(ds.get_group(&"/b/bb".into()).await.is_err()); + ds.delete_group("/b".try_into().unwrap()).await?; + assert!(ds.get_group(&"/b".try_into().unwrap()).await.is_err()); + assert!(ds.get_group(&"/b/bb".try_into().unwrap()).await.is_err()); Ok(()) } @@ -1823,7 +1822,7 @@ mod tests { let mut ds = Repository::init(Arc::clone(&storage), false).await?.build(); // add a new array and retrieve its node - ds.add_group("/".into()).await?; + ds.add_group(Path::root()).await?; let zarr_meta = ZarrArrayMetadata { shape: vec![1, 1, 2], data_type: DataType::Int32, @@ -1838,7 +1837,7 @@ mod tests { dimension_names: Some(vec![Some("t".to_string())]), }; - let new_array_path: PathBuf = "/array".to_string().into(); + let new_array_path: Path = "/array".try_into().unwrap(); ds.add_array(new_array_path.clone(), zarr_meta.clone()).await?; // we 3 chunks ds.set_chunk_ref( @@ -1887,7 +1886,7 @@ mod tests { let mut ds = Repository::init(Arc::clone(&storage), false).await?.build(); // add a new array and retrieve its node - ds.add_group("/".into()).await?; + ds.add_group(Path::root()).await?; let new_snapshot_id = ds.commit(Ref::DEFAULT_BRANCH, "first commit", None).await?; assert_eq!( @@ -1902,10 +1901,10 @@ mod tests { assert_eq!(new_snapshot_id, ref_data.snapshot); assert_eq!( - ds.get_node(&"/".into()).await.ok(), + ds.get_node(&Path::root()).await.ok(), Some(NodeSnapshot { id: 1, - path: "/".into(), + path: Path::root(), user_attributes: None, node_data: NodeData::Group }) @@ -1914,10 +1913,10 @@ mod tests { let mut ds = Repository::from_branch_tip(Arc::clone(&storage), "main").await?.build(); assert_eq!( - ds.get_node(&"/".into()).await.ok(), + ds.get_node(&Path::root()).await.ok(), Some(NodeSnapshot { id: 1, - path: "/".into(), + path: Path::root(), user_attributes: None, node_data: NodeData::Group }) @@ -1936,7 +1935,7 @@ mod tests { dimension_names: Some(vec![Some("t".to_string())]), }; - let new_array_path: PathBuf = "/array1".to_string().into(); + let new_array_path: Path = "/array1".try_into().unwrap(); ds.add_array(new_array_path.clone(), zarr_meta.clone()).await?; ds.set_chunk_ref( new_array_path.clone(), @@ -1973,8 +1972,8 @@ mod tests { let mut ds2 = Repository::from_branch_tip(Arc::clone(&storage), "main").await?.build(); - ds1.add_group("a".into()).await?; - ds2.add_group("b".into()).await?; + ds1.add_group("/a".try_into().unwrap()).await?; + ds2.add_group("/b".try_into().unwrap()).await?; let barrier = Arc::new(Barrier::new(2)); let barrier_c = Arc::clone(&barrier); diff --git a/icechunk/src/strategies.rs b/icechunk/src/strategies.rs index 0f9bfcbc..6c306378 100644 --- a/icechunk/src/strategies.rs +++ b/icechunk/src/strategies.rs @@ -1,7 +1,7 @@ use std::num::NonZeroU64; -use std::path::PathBuf; use std::sync::Arc; +use prop::string::string_regex; use proptest::prelude::*; use proptest::{collection::vec, option, strategy::Strategy}; @@ -15,7 +15,10 @@ use crate::{ObjectStorage, Repository}; pub fn node_paths() -> impl Strategy { // FIXME: Add valid paths - any::() + #[allow(clippy::expect_used)] + vec(string_regex("[a-zA-Z0-9]*").expect("invalid regex"), 0..10).prop_map(|v| { + format!("/{}", v.join("/")).try_into().expect("invalid Path string") + }) } prop_compose! { diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index 002faa9c..48ba01b6 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -1,5 +1,6 @@ use std::{ collections::HashSet, + fmt::Display, iter, num::NonZeroU64, ops::{Deref, DerefMut}, @@ -816,10 +817,8 @@ impl Store { for node in repository.list_nodes().await? { // TODO: handle non-utf8? let meta_key = Key::Metadata { node_path: node.path }.to_string(); - if let Some(key) = meta_key { - if key.starts_with(prefix) { - yield key; - } + if meta_key.starts_with(prefix) { + yield meta_key; } } }; @@ -840,10 +839,8 @@ impl Store { match maybe_path_chunk { Ok((path,chunk)) => { let chunk_key = Key::Chunk { node_path: path, coords: chunk.coord }.to_string(); - if let Some(key) = chunk_key { - if key.starts_with(prefix) { - yield key; - } + if chunk_key.starts_with(prefix) { + yield chunk_key; } } Err(err) => Err(err)? @@ -974,7 +971,7 @@ impl Key { fn parse_chunk(key: &str) -> Result { if key == "c" { return Ok(Key::Chunk { - node_path: "/".into(), + node_path: Path::root(), coords: ChunkIndices(vec![]), }); } @@ -982,10 +979,15 @@ impl Key { let path = path.strip_suffix('/').unwrap_or(path); if coords.is_empty() { Ok(Key::Chunk { - node_path: ["/", path].iter().collect(), + node_path: format!("/{path}").try_into().map_err(|_| { + StoreError::InvalidKey { key: key.to_string() } + })?, coords: ChunkIndices(vec![]), }) } else { + let absolute = format!("/{path}") + .try_into() + .map_err(|_| StoreError::InvalidKey { key: key.to_string() })?; coords .strip_prefix('/') .ok_or(StoreError::InvalidKey { key: key.to_string() })? @@ -993,7 +995,7 @@ impl Key { .map(|s| s.parse::()) .collect::, _>>() .map(|coords| Key::Chunk { - node_path: ["/", path].iter().collect(), + node_path: absolute, coords: ChunkIndices(coords), }) .map_err(|_| StoreError::InvalidKey { key: key.to_string() }) @@ -1004,30 +1006,37 @@ impl Key { } if key == Key::ROOT_KEY { - Ok(Key::Metadata { node_path: "/".into() }) + Ok(Key::Metadata { node_path: Path::root() }) } else if let Some(path) = key.strip_suffix(Key::METADATA_SUFFIX) { // we need to be careful indexing into utf8 strings - Ok(Key::Metadata { node_path: ["/", path].iter().collect() }) + Ok(Key::Metadata { + node_path: format!("/{path}") + .try_into() + .map_err(|_| StoreError::InvalidKey { key: key.to_string() })?, + }) } else { parse_chunk(key) } } +} - fn to_string(&self) -> Option { +impl Display for Key { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Key::Metadata { node_path } => node_path.as_path().to_str().map(|s| { - format!("{}{}", &s[1..], Key::METADATA_SUFFIX) - .trim_start_matches('/') - .to_string() - }), + Key::Metadata { node_path } => { + let s = + format!("{}{}", &node_path.to_string()[1..], Key::METADATA_SUFFIX) + .trim_start_matches('/') + .to_string(); + f.write_str(s.as_str()) + } Key::Chunk { node_path, coords } => { - node_path.as_path().to_str().map(|path| { - let coords = coords.0.iter().map(|c| c.to_string()).join("/"); - [path[1..].to_string(), "c".to_string(), coords] - .iter() - .filter(|s| !s.is_empty()) - .join("/") - }) + let coords = coords.0.iter().map(|c| c.to_string()).join("/"); + let s = [node_path.to_string()[1..].to_string(), "c".to_string(), coords] + .iter() + .filter(|s| !s.is_empty()) + .join("/"); + f.write_str(s.as_str()) } } } @@ -1362,91 +1371,100 @@ mod tests { fn test_parse_key() { assert!(matches!( Key::parse("zarr.json"), - Ok(Key::Metadata { node_path}) if node_path.to_str() == Some("/") + Ok(Key::Metadata { node_path}) if node_path.to_string() == "/" )); assert!(matches!( Key::parse("a/zarr.json"), - Ok(Key::Metadata { node_path }) if node_path.to_str() == Some("/a") + Ok(Key::Metadata { node_path }) if node_path.to_string() == "/a" )); assert!(matches!( Key::parse("a/b/c/zarr.json"), - Ok(Key::Metadata { node_path }) if node_path.to_str() == Some("/a/b/c") + Ok(Key::Metadata { node_path }) if node_path.to_string() == "/a/b/c" )); assert!(matches!( Key::parse("foo/c"), - Ok(Key::Chunk { node_path, coords }) if node_path.to_str() == Some("/foo") && coords == ChunkIndices(vec![]) + Ok(Key::Chunk { node_path, coords }) if node_path.to_string() == "/foo" && coords == ChunkIndices(vec![]) )); assert!(matches!( Key::parse("foo/bar/c"), - Ok(Key::Chunk { node_path, coords}) if node_path.to_str() == Some("/foo/bar") && coords == ChunkIndices(vec![]) + Ok(Key::Chunk { node_path, coords}) if node_path.to_string() == "/foo/bar" && coords == ChunkIndices(vec![]) )); assert!(matches!( Key::parse("foo/c/1/2/3"), Ok(Key::Chunk { node_path, coords, - }) if node_path.to_str() == Some("/foo") && coords == ChunkIndices(vec![1,2,3]) + }) if node_path.to_string() == "/foo" && coords == ChunkIndices(vec![1,2,3]) )); assert!(matches!( Key::parse("foo/bar/baz/c/1/2/3"), Ok(Key::Chunk { node_path, coords, - }) if node_path.to_str() == Some("/foo/bar/baz") && coords == ChunkIndices(vec![1,2,3]) + }) if node_path.to_string() == "/foo/bar/baz" && coords == ChunkIndices(vec![1,2,3]) )); assert!(matches!( Key::parse("c"), - Ok(Key::Chunk { node_path, coords}) if node_path.to_str() == Some("/") && coords == ChunkIndices(vec![]) + Ok(Key::Chunk { node_path, coords}) if node_path.to_string() == "/" && coords == ChunkIndices(vec![]) )); assert!(matches!( Key::parse("c/0/0"), - Ok(Key::Chunk { node_path, coords}) if node_path.to_str() == Some("/") && coords == ChunkIndices(vec![0,0]) + Ok(Key::Chunk { node_path, coords}) if node_path.to_string() == "/" && coords == ChunkIndices(vec![0,0]) )); } #[test] fn test_format_key() { assert_eq!( - Key::Metadata { node_path: "/".into() }.to_string(), - Some("zarr.json".to_string()) + Key::Metadata { node_path: Path::root() }.to_string(), + "zarr.json".to_string() ); assert_eq!( - Key::Metadata { node_path: "/a".into() }.to_string(), - Some("a/zarr.json".to_string()) + Key::Metadata { node_path: "/a".try_into().unwrap() }.to_string(), + "a/zarr.json".to_string() ); assert_eq!( - Key::Metadata { node_path: "/a/b/c".into() }.to_string(), - Some("a/b/c/zarr.json".to_string()) + Key::Metadata { node_path: "/a/b/c".try_into().unwrap() }.to_string(), + "a/b/c/zarr.json".to_string() ); assert_eq!( - Key::Chunk { node_path: "/".into(), coords: ChunkIndices(vec![]) } + Key::Chunk { node_path: Path::root(), coords: ChunkIndices(vec![]) } .to_string(), - Some("c".to_string()) + "c".to_string() ); assert_eq!( - Key::Chunk { node_path: "/".into(), coords: ChunkIndices(vec![0]) } + Key::Chunk { node_path: Path::root(), coords: ChunkIndices(vec![0]) } .to_string(), - Some("c/0".to_string()) + "c/0".to_string() ); assert_eq!( - Key::Chunk { node_path: "/".into(), coords: ChunkIndices(vec![1, 2]) } + Key::Chunk { node_path: Path::root(), coords: ChunkIndices(vec![1, 2]) } .to_string(), - Some("c/1/2".to_string()) + "c/1/2".to_string() ); assert_eq!( - Key::Chunk { node_path: "/a".into(), coords: ChunkIndices(vec![]) } - .to_string(), - Some("a/c".to_string()) + Key::Chunk { + node_path: "/a".try_into().unwrap(), + coords: ChunkIndices(vec![]) + } + .to_string(), + "a/c".to_string() ); assert_eq!( - Key::Chunk { node_path: "/a".into(), coords: ChunkIndices(vec![1]) } - .to_string(), - Some("a/c/1".to_string()) + Key::Chunk { + node_path: "/a".try_into().unwrap(), + coords: ChunkIndices(vec![1]) + } + .to_string(), + "a/c/1".to_string() ); assert_eq!( - Key::Chunk { node_path: "/a".into(), coords: ChunkIndices(vec![1, 2]) } - .to_string(), - Some("a/c/1/2".to_string()) + Key::Chunk { + node_path: "/a".try_into().unwrap(), + coords: ChunkIndices(vec![1, 2]) + } + .to_string(), + "a/c/1/2".to_string() ); } @@ -1569,7 +1587,7 @@ mod tests { assert!(matches!( store.get("zarr.json", &ByteRange::ALL).await, - Err(StoreError::NotFound(KeyNotFoundError::NodeNotFound {path})) if path.to_str() == Some("/") + Err(StoreError::NotFound(KeyNotFoundError::NodeNotFound {path})) if path.to_string() == "/" )); store @@ -1633,14 +1651,14 @@ mod tests { assert!(matches!( store.get("array/zarr.json", &ByteRange::ALL).await, Err(StoreError::NotFound(KeyNotFoundError::NodeNotFound { path })) - if path.to_str() == Some("/array"), + if path.to_string() == "/array", )); store.set("array/zarr.json", zarr_meta.clone()).await.unwrap(); store.delete("array/zarr.json").await.unwrap(); assert!(matches!( store.get("array/zarr.json", &ByteRange::ALL).await, Err(StoreError::NotFound(KeyNotFoundError::NodeNotFound { path } )) - if path.to_str() == Some("/array"), + if path.to_string() == "/array", )); store.set("array/zarr.json", Bytes::copy_from_slice(group_data)).await.unwrap(); @@ -1779,7 +1797,7 @@ mod tests { assert!(matches!( store.get("array/c/0/1/0", &ByteRange::ALL).await, Err(StoreError::NotFound(KeyNotFoundError::ChunkNotFound { key, path, coords })) - if key == "array/c/0/1/0" && path.to_str() == Some("/array") && coords == ChunkIndices([0, 1, 0].to_vec()) + if key == "array/c/0/1/0" && path.to_string() == "/array" && coords == ChunkIndices([0, 1, 0].to_vec()) )); assert!(matches!( store.delete("array/foo").await, diff --git a/icechunk/tests/test_concurrency.rs b/icechunk/tests/test_concurrency.rs index 2e07b2cb..3e9ed546 100644 --- a/icechunk/tests/test_concurrency.rs +++ b/icechunk/tests/test_concurrency.rs @@ -1,4 +1,4 @@ -#![allow(clippy::expect_used)] +#![allow(clippy::expect_used, clippy::unwrap_used)] use bytes::Bytes; use icechunk::{ format::{ByteRange, ChunkIndices, Path}, @@ -33,7 +33,7 @@ async fn test_concurrency() -> Result<(), Box> { Arc::new(ObjectStorage::new_in_memory_store(Some("prefix".into()))); let mut ds = Repository::init(Arc::clone(&storage), false).await?.build(); - ds.add_group("/".into()).await?; + ds.add_group(Path::root()).await?; let zarr_meta = ZarrArrayMetadata { shape: vec![N as u64, N as u64], data_type: DataType::Float64, @@ -51,7 +51,7 @@ async fn test_concurrency() -> Result<(), Box> { dimension_names: Some(vec![Some("x".to_string()), Some("y".to_string())]), }; - let new_array_path: Path = "/array".to_string().into(); + let new_array_path: Path = "/array".try_into().unwrap(); ds.add_array(new_array_path.clone(), zarr_meta.clone()).await?; let ds = Arc::new(RwLock::new(ds)); @@ -101,7 +101,11 @@ async fn write_task(ds: Arc>, x: u32, y: u32) { ds.write() .await - .set_chunk_ref("/array".into(), ChunkIndices(vec![x, y]), Some(payload)) + .set_chunk_ref( + "/array".try_into().unwrap(), + ChunkIndices(vec![x, y]), + Some(payload), + ) .await .expect("Failed to write chunk ref"); } @@ -114,7 +118,7 @@ async fn read_task(ds: Arc>, x: u32, y: u32, barrier: Arc>, barrier: Arc) { .list_nodes() .await .expect("list_nodes failed") - .map(|n| n.path.as_path().to_string_lossy().into_owned()) + .map(|n| n.path.to_string()) .collect::>(); assert_eq!(expected_nodes, nodes); diff --git a/icechunk/tests/test_distributed_writes.rs b/icechunk/tests/test_distributed_writes.rs index f602b2c4..a737641e 100644 --- a/icechunk/tests/test_distributed_writes.rs +++ b/icechunk/tests/test_distributed_writes.rs @@ -1,3 +1,4 @@ +#![allow(clippy::unwrap_used)] use pretty_assertions::assert_eq; use std::{num::NonZeroU64, ops::Range, sync::Arc}; @@ -66,14 +67,17 @@ async fn write_chunks( .collect(); let payload = repo.get_chunk_writer()(Bytes::copy_from_slice(bytes.as_slice())).await?; - repo.set_chunk_ref("/array".into(), ChunkIndices(vec![x, y]), Some(payload)) - .await?; + repo.set_chunk_ref( + "/array".try_into().unwrap(), + ChunkIndices(vec![x, y]), + Some(payload), + ) + .await?; } } Ok(repo) } -#[allow(clippy::unwrap_used)] async fn verify( repo: Repository, ) -> Result<(), Box> { @@ -81,7 +85,7 @@ async fn verify( for y in 0..(SIZE / 2) as u32 { let bytes = get_chunk( repo.get_chunk_reader( - &"/array".into(), + &"/array".try_into().unwrap(), &ChunkIndices(vec![x, y]), &ByteRange::ALL, ) @@ -132,7 +136,7 @@ async fn test_distributed_writes() -> Result<(), Box Repository { + async fn create_local_repository(path: &StdPath) -> Repository { let storage: Arc = Arc::new( ObjectStorage::new_local_store(path).expect("Creating local storage failed"), ); @@ -153,7 +153,7 @@ mod tests { length: 5, }); - let new_array_path: PathBuf = "/array".to_string().into(); + let new_array_path: Path = "/array".try_into().unwrap(); ds.add_array(new_array_path.clone(), zarr_meta.clone()).await.unwrap(); ds.set_chunk_ref( @@ -263,7 +263,7 @@ mod tests { length: 5, }); - let new_array_path: PathBuf = "/array".to_string().into(); + let new_array_path: Path = "/array".try_into().unwrap(); ds.add_array(new_array_path.clone(), zarr_meta.clone()).await.unwrap(); ds.set_chunk_ref( From bfd4c45ce3cc03cb53882efcf2954ad60c4f38a3 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Sun, 6 Oct 2024 11:29:50 -0300 Subject: [PATCH 030/167] Minor improvement to justfile --- .github/workflows/rust-ci.yaml | 6 ------ Justfile | 9 ++++++++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/rust-ci.yaml b/.github/workflows/rust-ci.yaml index 48843b67..c663ea66 100644 --- a/.github/workflows/rust-ci.yaml +++ b/.github/workflows/rust-ci.yaml @@ -78,11 +78,5 @@ jobs: - name: Check if: matrix.os == 'ubuntu-latest' || github.event_name == 'push' - env: - AWS_ACCESS_KEY_ID: minio123 - AWS_SECRET_ACCESS_KEY: minio123 - AWS_ALLOW_HTTP: 1 - AWS_ENDPOINT_URL: http://localhost:9000 - AWS_DEFAULT_REGION: "us-east-1" run: | just pre-commit diff --git a/Justfile b/Justfile index b120b1cf..c47e4640 100644 --- a/Justfile +++ b/Justfile @@ -37,4 +37,11 @@ run-all-examples: for example in icechunk/examples/*.rs; do cargo run --example "$(basename "${example%.rs}")"; done # run all checks that CI actions will run -pre-commit $RUSTFLAGS="-D warnings -W unreachable-pub -W bare-trait-objects": (compile-tests "--locked") build (format "--check") lint test run-all-examples check-deps +pre-commit $RUSTFLAGS="-D warnings -W unreachable-pub -W bare-trait-objects": + just compile-tests "--locked" + just build + just format "--check" + just lint + just test + just run-all-examples + just check-deps From 27ed03b60e0f741d2c9ff6fa3e808e8cdf82d71c Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Sun, 6 Oct 2024 11:50:01 -0300 Subject: [PATCH 031/167] Update shell.nix (rustc 1.80.1) --- shell.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shell.nix b/shell.nix index 40ae0d4c..65e895a8 100644 --- a/shell.nix +++ b/shell.nix @@ -1,6 +1,6 @@ let - # Pinned nixpkgs, deterministic. Last updated to nixpkgs-unstable as of: 2024-07-23 - pkgs = import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/68c9ed8bbed9dfce253cc91560bf9043297ef2fe.tar.gz") {}; + # Pinned nixpkgs, deterministic. Last updated to nixos-unstable as of: 2024-10-06 + pkgs = import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/7d49afd36b5590f023ec56809c02e05d8164fbc4.tar.gz") {}; # Rolling updates, not deterministic. # pkgs = import (fetchTarball("channel:nixpkgs-unstable")) {}; From 227c0ce1bc7da9e2b529307a28d76e8d96a74ec3 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Sun, 6 Oct 2024 12:06:59 -0300 Subject: [PATCH 032/167] Release as alpha version --- Cargo.lock | 4 ++-- icechunk-python/Cargo.toml | 4 ++-- icechunk/Cargo.toml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 52b10eb6..5e979164 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1191,7 +1191,7 @@ dependencies = [ [[package]] name = "icechunk" -version = "0.1.0" +version = "0.1.0-alpha.1" dependencies = [ "async-recursion", "async-stream", @@ -1226,7 +1226,7 @@ dependencies = [ [[package]] name = "icechunk-python" -version = "0.1.0" +version = "0.1.0-alpha.1" dependencies = [ "async-stream", "bytes", diff --git a/icechunk-python/Cargo.toml b/icechunk-python/Cargo.toml index 16a288c8..174170b0 100644 --- a/icechunk-python/Cargo.toml +++ b/icechunk-python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "icechunk-python" -version = "0.1.0" +version = "0.1.0-alpha.1" edition = "2021" publish = false @@ -13,7 +13,7 @@ crate-type = ["cdylib"] bytes = "1.7.2" chrono = { version = "0.4.38" } futures = "0.3.30" -icechunk = { path = "../icechunk", version = "0.1.0" } +icechunk = { path = "../icechunk", version = "0.1.0-alpha.1" } pyo3 = { version = "0.21", features = [ "chrono", "extension-module", diff --git a/icechunk/Cargo.toml b/icechunk/Cargo.toml index 5fe84b05..cbf97960 100644 --- a/icechunk/Cargo.toml +++ b/icechunk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "icechunk" -version = "0.1.0" +version = "0.1.0-alpha.1" edition = "2021" description = "Icechunk client" publish = false From 4e878cc40ccaa654216f3eb87511aad5e003f4ef Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Sun, 6 Oct 2024 12:19:44 -0300 Subject: [PATCH 033/167] Make a sealed trait --- icechunk/src/lib.rs | 6 ++++++ icechunk/src/storage/caching.rs | 11 ++++++++--- icechunk/src/storage/logging.rs | 11 ++++++++--- icechunk/src/storage/mod.rs | 11 +++++++---- icechunk/src/storage/object_store.rs | 13 +++++++++---- icechunk/src/storage/s3.rs | 3 +++ 6 files changed, 41 insertions(+), 14 deletions(-) diff --git a/icechunk/src/lib.rs b/icechunk/src/lib.rs index b09ef491..204c40ae 100644 --- a/icechunk/src/lib.rs +++ b/icechunk/src/lib.rs @@ -30,3 +30,9 @@ pub mod zarr; pub use repository::{Repository, RepositoryBuilder, RepositoryConfig, SnapshotMetadata}; pub use storage::{MemCachingStorage, ObjectStorage, Storage, StorageError}; pub use zarr::Store; + +mod private { + /// Used to seal traits we don't want user code to implement, to maintain compatibility. + /// See https://rust-lang.github.io/api-guidelines/future-proofing.html#sealed-traits-protect-against-downstream-implementations-c-sealed + pub trait Sealed {} +} diff --git a/icechunk/src/storage/caching.rs b/icechunk/src/storage/caching.rs index e36f08b9..34a2a6d1 100644 --- a/icechunk/src/storage/caching.rs +++ b/icechunk/src/storage/caching.rs @@ -5,9 +5,12 @@ use bytes::Bytes; use futures::stream::BoxStream; use quick_cache::sync::Cache; -use crate::format::{ - attributes::AttributesTable, manifest::Manifest, snapshot::Snapshot, AttributesId, - ByteRange, ChunkId, ManifestId, SnapshotId, +use crate::{ + format::{ + attributes::AttributesTable, manifest::Manifest, snapshot::Snapshot, + AttributesId, ByteRange, ChunkId, ManifestId, SnapshotId, + }, + private, }; use super::{Storage, StorageError, StorageResult}; @@ -39,6 +42,8 @@ impl MemCachingStorage { } } +impl private::Sealed for MemCachingStorage {} + #[async_trait] impl Storage for MemCachingStorage { async fn fetch_snapshot( diff --git a/icechunk/src/storage/logging.rs b/icechunk/src/storage/logging.rs index 38ec82f3..904010ad 100644 --- a/icechunk/src/storage/logging.rs +++ b/icechunk/src/storage/logging.rs @@ -5,9 +5,12 @@ use bytes::Bytes; use futures::stream::BoxStream; use super::{Storage, StorageError, StorageResult}; -use crate::format::{ - attributes::AttributesTable, manifest::Manifest, snapshot::Snapshot, AttributesId, - ByteRange, ChunkId, ManifestId, SnapshotId, +use crate::{ + format::{ + attributes::AttributesTable, manifest::Manifest, snapshot::Snapshot, + AttributesId, ByteRange, ChunkId, ManifestId, SnapshotId, + }, + private, }; #[derive(Debug)] @@ -28,6 +31,8 @@ impl LoggingStorage { } } +impl private::Sealed for LoggingStorage {} + #[async_trait] #[allow(clippy::expect_used)] // this implementation is intended for tests only impl Storage for LoggingStorage { diff --git a/icechunk/src/storage/mod.rs b/icechunk/src/storage/mod.rs index 716602dc..c63f5351 100644 --- a/icechunk/src/storage/mod.rs +++ b/icechunk/src/storage/mod.rs @@ -27,9 +27,12 @@ pub mod virtual_ref; pub use caching::MemCachingStorage; pub use object_store::ObjectStorage; -use crate::format::{ - attributes::AttributesTable, manifest::Manifest, snapshot::Snapshot, AttributesId, - ByteRange, ChunkId, ManifestId, SnapshotId, +use crate::{ + format::{ + attributes::AttributesTable, manifest::Manifest, snapshot::Snapshot, + AttributesId, ByteRange, ChunkId, ManifestId, SnapshotId, + }, + private, }; #[derive(Debug, Error)] @@ -65,7 +68,7 @@ pub type StorageResult = Result; /// Different implementation can cache the files differently, or not at all. /// Implementations are free to assume files are never overwritten. #[async_trait] -pub trait Storage: fmt::Debug { +pub trait Storage: fmt::Debug + private::Sealed { async fn fetch_snapshot(&self, id: &SnapshotId) -> StorageResult>; async fn fetch_attributes( &self, diff --git a/icechunk/src/storage/object_store.rs b/icechunk/src/storage/object_store.rs index 56742e70..0f737627 100644 --- a/icechunk/src/storage/object_store.rs +++ b/icechunk/src/storage/object_store.rs @@ -1,7 +1,10 @@ -use crate::format::{ - attributes::AttributesTable, format_constants, manifest::Manifest, - snapshot::Snapshot, AttributesId, ByteRange, ChunkId, FileTypeTag, ManifestId, - ObjectId, SnapshotId, +use crate::{ + format::{ + attributes::AttributesTable, format_constants, manifest::Manifest, + snapshot::Snapshot, AttributesId, ByteRange, ChunkId, FileTypeTag, ManifestId, + ObjectId, SnapshotId, + }, + private, }; use async_trait::async_trait; use bytes::Bytes; @@ -151,6 +154,8 @@ impl ObjectStorage { } } +impl private::Sealed for ObjectStorage {} + #[async_trait] impl Storage for ObjectStorage { async fn fetch_snapshot( diff --git a/icechunk/src/storage/s3.rs b/icechunk/src/storage/s3.rs index d4955a1c..91117e41 100644 --- a/icechunk/src/storage/s3.rs +++ b/icechunk/src/storage/s3.rs @@ -20,6 +20,7 @@ use crate::{ snapshot::Snapshot, AttributesId, ByteRange, ChunkId, FileTypeTag, ManifestId, SnapshotId, }, + private, zarr::ObjectId, Storage, StorageError, }; @@ -217,6 +218,8 @@ pub fn range_to_header(range: &ByteRange) -> Option { } } +impl private::Sealed for S3Storage {} + #[async_trait] impl Storage for S3Storage { async fn fetch_snapshot(&self, id: &SnapshotId) -> StorageResult> { From 9c08d1eab875f74ec2f450a443f025bbd2b7bad9 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Sun, 6 Oct 2024 12:22:57 -0300 Subject: [PATCH 034/167] More sealed traits --- icechunk/src/format/mod.rs | 8 ++++++-- icechunk/src/storage/virtual_ref.rs | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/icechunk/src/format/mod.rs b/icechunk/src/format/mod.rs index e8366f51..c782ffd9 100644 --- a/icechunk/src/format/mod.rs +++ b/icechunk/src/format/mod.rs @@ -14,7 +14,7 @@ use serde_with::{serde_as, TryFromInto}; use thiserror::Error; use typed_path::Utf8UnixPathBuf; -use crate::metadata::DataType; +use crate::{metadata::DataType, private}; pub mod attributes; pub mod manifest; @@ -25,7 +25,7 @@ pub mod snapshot; pub struct Path(#[serde_as(as = "TryFromInto")] Utf8UnixPathBuf); #[allow(dead_code)] -pub trait FileTypeTag {} +pub trait FileTypeTag: private::Sealed {} /// The id of a file in object store #[derive(Hash, Clone, PartialEq, Eq, PartialOrd, Ord)] @@ -43,6 +43,10 @@ pub struct ChunkTag; #[derive(Hash, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct AttributesTag; +impl private::Sealed for SnapshotTag {} +impl private::Sealed for ManifestTag {} +impl private::Sealed for ChunkTag {} +impl private::Sealed for AttributesTag {} impl FileTypeTag for SnapshotTag {} impl FileTypeTag for ManifestTag {} impl FileTypeTag for ChunkTag {} diff --git a/icechunk/src/storage/virtual_ref.rs b/icechunk/src/storage/virtual_ref.rs index 8e60257a..9bc6082d 100644 --- a/icechunk/src/storage/virtual_ref.rs +++ b/icechunk/src/storage/virtual_ref.rs @@ -1,5 +1,6 @@ use crate::format::manifest::{VirtualChunkLocation, VirtualReferenceError}; use crate::format::ByteRange; +use crate::private; use async_trait::async_trait; use aws_sdk_s3::Client; use bytes::Bytes; @@ -15,7 +16,7 @@ use url::{self, Url}; use super::s3::{mk_client, range_to_header, S3Config}; #[async_trait] -pub trait VirtualChunkResolver: Debug { +pub trait VirtualChunkResolver: Debug + private::Sealed { async fn fetch_chunk( &self, location: &VirtualChunkLocation, @@ -135,6 +136,8 @@ pub fn construct_valid_byte_range( ) } +impl private::Sealed for ObjectStoreVirtualChunkResolver {} + #[async_trait] impl VirtualChunkResolver for ObjectStoreVirtualChunkResolver { async fn fetch_chunk( From d907d9701d3fda707561aa9bb625ef3ffb143793 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Mon, 7 Oct 2024 12:29:12 -0300 Subject: [PATCH 035/167] Make enums non_exhaustive --- icechunk/src/format/manifest.rs | 3 +++ icechunk/src/format/mod.rs | 2 ++ icechunk/src/repository.rs | 1 + icechunk/src/zarr.rs | 5 +++++ 4 files changed, 11 insertions(+) diff --git a/icechunk/src/format/manifest.rs b/icechunk/src/format/manifest.rs index abd43b0d..317a9032 100644 --- a/icechunk/src/format/manifest.rs +++ b/icechunk/src/format/manifest.rs @@ -20,6 +20,7 @@ pub struct ManifestRef { } #[derive(Debug, Error)] +#[non_exhaustive] pub enum VirtualReferenceError { #[error("error parsing virtual ref URL {0}")] CannotParseUrl(#[from] url::ParseError), @@ -36,6 +37,7 @@ pub enum VirtualReferenceError { } #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[non_exhaustive] pub enum VirtualChunkLocation { Absolute(String), // Relative(prefix_id, String) @@ -85,6 +87,7 @@ pub struct ChunkRef { } #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[non_exhaustive] pub enum ChunkPayload { Inline(Bytes), Virtual(VirtualChunkRef), diff --git a/icechunk/src/format/mod.rs b/icechunk/src/format/mod.rs index c782ffd9..e35741b5 100644 --- a/icechunk/src/format/mod.rs +++ b/icechunk/src/format/mod.rs @@ -212,6 +212,7 @@ impl From<(Option, Option)> for ByteRange { pub type TableOffset = u32; #[derive(Debug, Clone, Error, PartialEq, Eq)] +#[non_exhaustive] pub enum IcechunkFormatError { #[error("error decoding fill_value from array")] FillValueDecodeError { found_size: usize, target_size: usize, target_type: DataType }, @@ -246,6 +247,7 @@ impl Display for Path { } #[derive(Debug, Clone, Error, PartialEq, Eq)] +#[non_exhaustive] pub enum PathError { #[error("path must start with a `/` character")] NotAbsolute, diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index 13772173..97de9f62 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -140,6 +140,7 @@ impl RepositoryBuilder { } #[derive(Debug, Error)] +#[non_exhaustive] pub enum RepositoryError { #[error("error contacting storage {0}")] StorageError(#[from] StorageError), diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index 48ba01b6..f42c3640 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -44,6 +44,7 @@ pub use crate::format::ObjectId; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "type")] +#[non_exhaustive] pub enum StorageConfig { #[serde(rename = "in_memory")] InMemory { prefix: Option }, @@ -90,6 +91,7 @@ impl StorageConfig { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[non_exhaustive] pub enum VersionInfo { #[serde(rename = "snapshot_id")] SnapshotId(SnapshotId), @@ -235,6 +237,7 @@ impl ConsolidatedStore { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[non_exhaustive] pub enum AccessMode { #[serde(rename = "r")] ReadOnly, @@ -245,6 +248,7 @@ pub enum AccessMode { pub type StoreResult = Result; #[derive(Debug, Clone, PartialEq, Eq, Error)] +#[non_exhaustive] pub enum KeyNotFoundError { #[error("chunk cannot be find for key `{key}`")] ChunkNotFound { key: String, path: Path, coords: ChunkIndices }, @@ -253,6 +257,7 @@ pub enum KeyNotFoundError { } #[derive(Debug, Error)] +#[non_exhaustive] pub enum StoreError { #[error("invalid zarr key format `{key}`")] InvalidKey { key: String }, From eb675210a624eb770ac777704baf092b8e7db889 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Mon, 7 Oct 2024 13:45:10 -0300 Subject: [PATCH 036/167] Fill in Cargo metadata fields --- icechunk-python/Cargo.toml | 8 ++++++++ icechunk/Cargo.toml | 9 ++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/icechunk-python/Cargo.toml b/icechunk-python/Cargo.toml index 174170b0..cfcd893f 100644 --- a/icechunk-python/Cargo.toml +++ b/icechunk-python/Cargo.toml @@ -1,6 +1,14 @@ [package] name = "icechunk-python" version = "0.1.0-alpha.1" +description = "Transactional storage engine for Zarr designed for use on cloud object storage" +readme = "README.md" +repository = "https://github.com/earth-mover/icechunk" +homepage = "https://github.com/earth-mover/icechunk" +license = "MIT OR Apache-2.0" +keywords = ["zarr", "xarray", "database"] +categories = ["database", "science", "science::geo"] +authors = ["Earthmover PBC"] edition = "2021" publish = false diff --git a/icechunk/Cargo.toml b/icechunk/Cargo.toml index cbf97960..2b9d13e9 100644 --- a/icechunk/Cargo.toml +++ b/icechunk/Cargo.toml @@ -1,8 +1,15 @@ [package] name = "icechunk" version = "0.1.0-alpha.1" +description = "Transactional storage engine for Zarr designed for use on cloud object storage" +readme = "README.md" +repository = "https://github.com/earth-mover/icechunk" +homepage = "https://github.com/earth-mover/icechunk" +license = "MIT OR Apache-2.0" +keywords = ["zarr", "xarray", "database"] +categories = ["database", "science", "science::geo"] +authors = ["Earthmover PBC"] edition = "2021" -description = "Icechunk client" publish = false [dependencies] From e5c193b8fd6a0f644bceb68a4296813041ed2585 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Mon, 7 Oct 2024 13:58:34 -0300 Subject: [PATCH 037/167] Change dependency --- Cargo.lock | 2 +- icechunk-python/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e979164..57ba5890 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1226,7 +1226,7 @@ dependencies = [ [[package]] name = "icechunk-python" -version = "0.1.0-alpha.1" +version = "0.1.0" dependencies = [ "async-stream", "bytes", diff --git a/icechunk-python/Cargo.toml b/icechunk-python/Cargo.toml index cfcd893f..675875cf 100644 --- a/icechunk-python/Cargo.toml +++ b/icechunk-python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "icechunk-python" -version = "0.1.0-alpha.1" +version = "0.1.0" description = "Transactional storage engine for Zarr designed for use on cloud object storage" readme = "README.md" repository = "https://github.com/earth-mover/icechunk" From d928dff170550c65b4f1939fefd83863342d6c62 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Tue, 8 Oct 2024 04:47:53 -0400 Subject: [PATCH 038/167] Add python version to package (#160) --- icechunk-python/python/icechunk/__init__.py | 2 ++ icechunk-python/python/icechunk/_icechunk_python.pyi | 2 ++ icechunk-python/src/lib.rs | 1 + 3 files changed, 5 insertions(+) diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index 4b5ebe7a..b7558c51 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -8,6 +8,7 @@ from zarr.core.sync import SyncMixin from ._icechunk_python import ( + __version__, PyIcechunkStore, S3Credentials, SnapshotMetadata, @@ -21,6 +22,7 @@ ) __all__ = [ + "__version__", "IcechunkStore", "StorageConfig", "S3Credentials", diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index e5cb4f8e..18c3e7d5 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -235,3 +235,5 @@ async def pyicechunk_store_open_existing( def pyicechunk_store_from_bytes( bytes: bytes, read_only: bool ) -> PyIcechunkStore: ... + +__version__: str diff --git a/icechunk-python/src/lib.rs b/icechunk-python/src/lib.rs index 4edf0e45..33c8c3fd 100644 --- a/icechunk-python/src/lib.rs +++ b/icechunk-python/src/lib.rs @@ -753,6 +753,7 @@ impl PyIcechunkStore { /// The icechunk Python module implemented in Rust. #[pymodule] fn _icechunk_python(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("__version__", env!("CARGO_PKG_VERSION"))?; m.add_class::()?; m.add_class::()?; m.add_class::()?; From c86d626fc0fbe88047203ffe09b9ad64be0cc929 Mon Sep 17 00:00:00 2001 From: Orestis Herodotou Date: Mon, 7 Oct 2024 17:09:55 -0700 Subject: [PATCH 039/167] add docs site using mkdocs --- .gitignore | 5 +- README.md | 16 +- docs/.env.sample | 1 + docs/.gitignore | 79 + docs/README.md | 41 + docs/docs/icechunk-python/developing.md | 81 + docs/docs/icechunk-python/index.md | 66 + docs/docs/icechunk-python/reference.md | 1 + docs/docs/index.md | 139 + docs/macros.py | 49 + docs/mkdocs.yml | 141 + docs/overrides/main.html | 5 + docs/overrides/partials/copyright.html | 14 + docs/poetry.lock | 2687 +++++++++++++++++++ docs/pyproject.toml | 34 + docs/stylesheets/extra.css | 13 + icechunk-python/pyproject.toml | 10 + spec/{icechunk_spec.md => icechunk-spec.md} | 3 +- 18 files changed, 3375 insertions(+), 10 deletions(-) create mode 100644 docs/.env.sample create mode 100644 docs/.gitignore create mode 100644 docs/README.md create mode 100644 docs/docs/icechunk-python/developing.md create mode 100644 docs/docs/icechunk-python/index.md create mode 100644 docs/docs/icechunk-python/reference.md create mode 100644 docs/docs/index.md create mode 100644 docs/macros.py create mode 100644 docs/mkdocs.yml create mode 100644 docs/overrides/main.html create mode 100644 docs/overrides/partials/copyright.html create mode 100644 docs/poetry.lock create mode 100644 docs/pyproject.toml create mode 100644 docs/stylesheets/extra.css rename spec/{icechunk_spec.md => icechunk-spec.md} (99%) diff --git a/.gitignore b/.gitignore index 7ec47617..e43a1f53 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ /target # macOS -.DS_Store \ No newline at end of file +.DS_Store + +# Docs build +.docs \ No newline at end of file diff --git a/README.md b/README.md index 99f1af2e..5d8f01fe 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ ![Icechunk logo](icechunk_logo.png) Icechunk is a transactional storage engine for Zarr designed for use on cloud object storage. + Let's break down what that means: @@ -35,7 +36,7 @@ Icechunk aspires to support the following core requirements for stores: This Icechunk project consists of three main parts: -1. The [Icechunk specification](spec/icechunk_spec.md). +1. The [Icechunk specification](spec/icechunk-spec.md). 1. A Rust implementation 1. A Python wrapper which exposes a Zarr store interface @@ -51,10 +52,9 @@ Governance of the project will be managed by Earthmover PBC. We recommend using Icechunk from Python, together with the Zarr-Python library -> [!WARNING] -> Icechunk is a very new project. -> It is not recommended for production use at this time. -> These instructions are aimed at Icechunk developers and curious early adopters. +!!! warning "Icechunk is a very new project." + It is not recommended for production use at this time. + These instructions are aimed at Icechunk developers and curious early adopters. ### Installation and Dependencies @@ -142,8 +142,8 @@ This is just an alias for cargo test --all ``` -> [!TIP] -> For other aliases see [Justfile](./Justfile). +!!! tip + For other aliases see [Justfile](./Justfile). ## Snapshots, Branches, and Tags @@ -169,7 +169,7 @@ Tags are appropriate for publishing specific releases of a repository or for any ## How Does It Work? > [!NOTE] -> For more detailed explanation, have a look at the [Icechunk spec](spec/icechunk_spec.md) +> For more detailed explanation, have a look at the [Icechunk spec](spec/icechunk-spec.md) Zarr itself works by storing both metadata and chunk data into a abstract store according to a specified system of "keys". For example, a 2D Zarr array called myarray, within a group called mygroup, would generate the following keys. diff --git a/docs/.env.sample b/docs/.env.sample new file mode 100644 index 00000000..4b87c8e8 --- /dev/null +++ b/docs/.env.sample @@ -0,0 +1 @@ +MKDOCS_GIT_COMMITTERS_APIKEY= \ No newline at end of file diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000..d6abdd64 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,79 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# imported files +docs/icechunk-python/examples +docs/icechunk-python/notebooks +docs/spec.md + +# C extensions +*.so + +# Build +.site + +.env +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +node_modules/ +parts/ +sdist/ +var/ +package*.json +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo + +# Scrapy stuff: +.scrapy + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# virtualenv +venv/ +ENV/ + +# MkDocs documentation +site*/ \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..4a60ad23 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,41 @@ +# Icechunk Documentation Website + +Built with [MkDocs](https://www.mkdocs.org/) using [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/). + +## Developing + +### Prerequisites + +This repository uses [Poetry](https://python-poetry.org/) to manage dependencies + +1. Install dependencies using `poetry install` + +### Running + +1. Run `poetry shell` from the `/docs` directory +2. Start the MkDocs development server: `mkdocs serve` + +!!! tip + You can use the optional `--dirty` flag to only rebuild changed files, although you may need to restart if you make changes to `mkdocs.yaml`. + +### Building + +1. Run `mkdocs build` + +Builds output to: `icechunk-docs/.site` directory. + +## Dev Notes + +#### Symlinked Files + +Several directories and files are symlinked into the MkDocs' `/docs`[^1] directory in order to be made available to MkDocs. Avoid modifying them directly: + * `/docs/icechunk-python/examples/` + * `/docs/icechunk-python/notebooks/` + * `/docs/spec.md` + +These are also ignored in `.gitignore` + +!!! tip + See [icechunk-docs/macros.py](./macros.py) for more info. + +[^1] : Disambiguation: `icechunk/docs/docs` \ No newline at end of file diff --git a/docs/docs/icechunk-python/developing.md b/docs/docs/icechunk-python/developing.md new file mode 100644 index 00000000..0457743c --- /dev/null +++ b/docs/docs/icechunk-python/developing.md @@ -0,0 +1,81 @@ +# Icechunk Python + +Python library for Icechunk Zarr Stores + +## Getting Started + +Activate the virtual environment: + +```bash +python3 -m venv .venv +source .venv/bin/activate +``` + +Install `maturin`: + +```bash +pip install maturin +``` + +Build the project in dev mode: + +```bash +maturin develop +``` + +or build the project in editable mode: + +```bash +pip install -e icechunk@. +``` + +!!! NOTE + This only makes the python source code editable, the rust will need to be recompiled when it changes + +Now you can create or open an icechunk store for use with `zarr-python`: + +```python +from icechunk import IcechunkStore, StorageConfig +from zarr import Array, Group + +storage = StorageConfig.memory("test") +store = await IcechunkStore.open(storage=storage, mode='r+') + +root = Group.from_store(store=store, zarr_format=zarr_format) +foo = root.create_array("foo", shape=(100,), chunks=(10,), dtype="i4") +``` + +You can then commit your changes to save progress or share with others: + +```python +store.commit("Create foo array") + +async for parent in store.ancestry(): + print(parent.message) +``` + +!!! tip + See [`tests/test_timetravel.py`](https://github.com/earth-mover/icechunk/blob/main/icechunk-python/tests/test_timetravel.py) for more example usage of the transactional features. + + +## Running Tests + +You will need [`docker compose`](https://docs.docker.com/compose/install/) and (optionally) [`just`](https://just.systems/). +Once those are installed, first switch to the icechunk root directory, then start up a local minio server: +``` +docker compose up -d +``` + +Use `just` to conveniently run a test +``` +just test +``` + +This is just an alias for + +``` +cargo test --all +``` + +!!! tip + For other aliases see [Justfile](./Justfile). \ No newline at end of file diff --git a/docs/docs/icechunk-python/index.md b/docs/docs/icechunk-python/index.md new file mode 100644 index 00000000..3a0f1add --- /dev/null +++ b/docs/docs/icechunk-python/index.md @@ -0,0 +1,66 @@ +# Icechunk Python + +### Installation and Dependencies + +Icechunk is currently designed to support the [Zarr V3 Specification](https://zarr-specs.readthedocs.io/en/latest/v3/core/v3.0.html). +Using it today requires installing the [still unreleased] Zarr Python V3 branch. + +To set up an Icechunk development environment, follow these steps + +Activate your preferred virtual environment (here we use `virtualenv`): + +```bash +python3 -m venv .venv +source .venv/bin/activate +``` + +Alternatively, create a conda environment + +```bash +mamba create -n icechunk rust python=3.12 +conda activate icechunk +``` + +Install `maturin`: + +```bash +pip install maturin +``` + +Build the project in dev mode: + +```bash +cd icechunk-python/ +maturin develop +``` + +or build the project in editable mode: + +```bash +cd icechunk-python/ +pip install -e icechunk@. +``` + +!!! warning + This only makes the python source code editable, the rust will need to be recompiled when it changes + +### Basic Usage + +Once you have everything installed, here's an example of how to use Icechunk. + +```python +from icechunk import IcechunkStore, StorageConfig +from zarr import Array, Group + +# Example using memory store +storage = StorageConfig.memory("test") +store = await IcechunkStore.open(storage=storage, mode='r+') + +# Example using file store +storage = StorageConfig.filesystem("/path/to/root") +store = await IcechunkStore.open(storage=storage, mode='r+') + +# Example using S3 +s3_storage = StorageConfig.s3_from_env(bucket="icechunk-test", prefix="oscar-demo-repository") +store = await IcechunkStore.open(storage=storage, mode='r+') +``` diff --git a/docs/docs/icechunk-python/reference.md b/docs/docs/icechunk-python/reference.md new file mode 100644 index 00000000..5eab2a5f --- /dev/null +++ b/docs/docs/icechunk-python/reference.md @@ -0,0 +1 @@ +::: icechunk diff --git a/docs/docs/index.md b/docs/docs/index.md new file mode 100644 index 00000000..0741e66a --- /dev/null +++ b/docs/docs/index.md @@ -0,0 +1,139 @@ +--- +title: Icechunk - Transactional storage engine for Zarr on cloud object storage. +--- + +# Icechunk + +!!! info "Welcome to Icechunk!" + Icechunk is a transactional storage engine for Zarr designed for use on cloud object storage. + +Let's break down what that means: + +- **[Zarr](https://zarr.dev/)** is an open source specification for the storage of multidimensional array (a.k.a. tensor) data. + Zarr defines the metadata for describing arrays (shape, dtype, etc.) and the way these arrays are chunked, compressed, and converted to raw bytes for storage. Zarr can store its data in any key-value store. +- **Storage engine** - Icechunk exposes a key-value interface to Zarr and manages all of the actual I/O for getting, setting, and updating both metadata and chunk data in cloud object storage. + Zarr libraries don't have to know exactly how icechunk works under the hood in order to use it. +- **Transactional** - The key improvement that Icechunk brings on top of regular Zarr is to provide consistent serializable isolation between transactions. + This means that Icechunk data are safe to read and write in parallel from multiple uncoordinated processes. + This allows Zarr to be used more like a database. + +## Goals of Icechunk + +The core entity in Icechunk is a **store**. +A store is defined as a Zarr hierarchy containing one or more Arrays and Groups. +The most common scenario is for an Icechunk store to contain a single Zarr group with multiple arrays, each corresponding to different physical variables but sharing common spatiotemporal coordinates. +However, formally a store can be any valid Zarr hierarchy, from a single Array to a deeply nested structure of Groups and Arrays. +Users of Icechunk should aim to scope their stores only to related arrays and groups that require consistent transactional updates. + +Icechunk aspires to support the following core requirements for stores: + +1. **Object storage** - the format is designed around the consistency features and performance characteristics available in modern cloud object storage. No external database or catalog is required to maintain a store. +1. **Serializable isolation** - Reads are isolated from concurrent writes and always use a committed snapshot of a store. Writes are committed atomically and are never partially visible. Readers will not acquire locks. +1. **Time travel** - Previous snapshots of a store remain accessible after new ones have been written. +1. **Data Version Control** - Stores support both _tags_ (immutable references to snapshots) and _branches_ (mutable references to snapshots). +1. **Chunk sharding and references** - Chunk storage is decoupled from specific file names. Multiple chunks can be packed into a single object (sharding). Zarr-compatible chunks within other file formats (e.g. HDF5, NetCDF) can be referenced. +1. **Schema Evolution** - Arrays and Groups can be added, renamed, and removed from the hierarchy with minimal overhead. + +## The Project + +This Icechunk project consists of three main parts: + +1. The [Icechunk specification](./spec.md). +1. A Rust implementation +1. A Python wrapper which exposes a Zarr store interface + +All of this is open source, licensed under the Apache 2.0 license. + +The Rust implementation is a solid foundation for creating bindings in any language. +We encourage adopters to collaborate on the Rust implementation, rather than reimplementing Icechunk from scratch in other languages. + +We encourage collaborators from the broader community to contribute to Icechunk. +Governance of the project will be managed by Earthmover PBC. + +## How Can I Use It? + +We recommend using [Icechunk from Python](./icechunk-python/index.md), together with the Zarr-Python library. + +!!! warning "Icechunk is a very new project." + It is not recommended for production use at this time. + These instructions are aimed at Icechunk developers and curious early adopters. + +## Key Concepts: Snapshots, Branches, and Tags + +Every update to an Icechunk store creates a new **snapshot** with a unique ID. +Icechunk users must organize their updates into groups of related operations called **transactions**. +For example, appending a new time slice to mutliple arrays should be done as single transactions, comprising the following steps +1. Update the array metadata to resize the array to accommodate the new elements. +2. Write new chunks for each array in the group. + +While the transaction is in progress, none of these changes will be visible to other users of the store. +Once the transaction is committed, a new snapshot is generated. +Readers can only see and use committed snapshots. + +Additionally, snapshots occur in a specific linear (i.e. serializable) order within **branch**. +A branch is a mutable reference to a snapshot--a pointer that maps the branch name to a snapshot ID. +The default branch is `main`. +Every commit to the main branch updates this reference. +Icechunk's design protects against the race condition in which two uncoordinated sessions attempt to update the branch at the same time; only one can succeed. + +Finally, Icechunk defines **tags**--_immutable_ references to snapshot. +Tags are appropriate for publishing specific releases of a repository or for any application which requires a persistent, immutable identifier to the store state. + +## How Does It Work? + +!!! note + For more detailed explanation, have a look at the [Icechunk spec](./spec.md) + +Zarr itself works by storing both metadata and chunk data into a abstract store according to a specified system of "keys". +For example, a 2D Zarr array called myarray, within a group called mygroup, would generate the following keys: + +``` +mygroup/zarr.json +mygroup/myarray/zarr.json +mygroup/myarray/c/0/0 +mygroup/myarray/c/0/1 +``` + +In standard regular Zarr stores, these key map directly to filenames in a filesystem or object keys in an object storage system. +When writing data, a Zarr implementation will create these keys and populate them with data. When modifying existing arrays or groups, a Zarr implementation will potentially overwrite existing keys with new data. + +This is generally not a problem, as long there is only one person or process coordinating access to the data. +However, when multiple uncoordinated readers and writers attempt to access the same Zarr data at the same time, [various consistency problems](https://docs.earthmover.io/concepts/version-control-system#consistency-problems-with-zarr) problems emerge. +These consistency problems can occur in both file storage and object storage; they are particularly severe in a cloud setting where Zarr is being used as an active store for data that are frequently changed while also being read. + +With Icechunk, we keep the same core Zarr data model, but add a layer of indirection between the Zarr keys and the on-disk storage. +The Icechunk library translates between the Zarr keys and the actual on-disk data given the particular context of the user's state. +Icechunk defines a series of interconnected metadata and data files that together enable efficient isolated reading and writing of metadata and chunks. +Once written, these files are immutable. +Icechunk keeps track of every single chunk explicitly in a "chunk manifest". + +```mermaid +flowchart TD + zarr-python[Zarr Library] <-- key / value--> icechunk[Icechunk Library] + icechunk <-- data / metadata files --> storage[(Object Storage)] +``` + +## FAQ + +1. _Why not just use Iceberg directly?_ + + Iceberg and all other "table formats" (Delta, Hudi, LanceDB, etc.) are based on tabular data model. + This data model cannot accommodate large, multidimensional arrays (tensors) in a general, scalable way. + +1. Is Icechunk part of Zarr? + + Formally, no. + Icechunk is a separate specification from Zarr. + However, it is designed to interoperate closely with Zarr. + In the future, we may propose a more formal integration between the Zarr spec and Icechunk spec if helpful. + For now, keeping them separate allows us to evolve Icechunk quickly while maintaining the stability and backwards compatibility of the Zarr data model. + +## Inspiration + +Icechunk's was inspired by several existing projects and formats, most notably + +- [FSSpec Reference Filesystem](https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.implementations.reference.ReferenceFileSystem) +- [Apache Iceberg](https://iceberg.apache.org/spec/) +- [LanceDB](https://lancedb.github.io/lance/format.html) +- [TileDB](https://docs.tiledb.com/main/background/key-concepts-and-data-format) +- [OCDBT](https://google.github.io/tensorstore/kvstore/ocdbt/index.html) \ No newline at end of file diff --git a/docs/macros.py b/docs/macros.py new file mode 100644 index 00000000..d904d381 --- /dev/null +++ b/docs/macros.py @@ -0,0 +1,49 @@ +import os +from pathlib import Path +import logging + +def define_env(env): + # TODO: is there a better way of including these files and dirs? Symlinking seems error prone... + # Potentially use: https://github.com/backstage/mkdocs-monorepo-plugin + def symlink_external_dirs(): + """ + Creates symbolic links from external directories to the docs_dir. + """ + try: + # Resolve paths for docs and monorepo root + docs_dir = Path('./docs').resolve() + monorepo_root = docs_dir.parent.parent + + # Symlinked paths + external_sources = { + monorepo_root / 'icechunk-python' / 'notebooks' : docs_dir / 'icechunk-python' / 'notebooks', + monorepo_root / 'icechunk-python' / 'examples' : docs_dir / 'icechunk-python' / 'examples', + monorepo_root / 'spec' / 'icechunk-spec.md' : docs_dir / 'spec.md', + } + + for src, target in external_sources.items(): + if not src.exists(): + logging.error(f"Source directory does not exist: {src}") + raise FileNotFoundError(f"Source directory does not exist: {src}") + + # Ensure parent directory exists + target.parent.mkdir(parents=True, exist_ok=True) + + # Remove existing symlink or directory if it exists + if target.is_symlink() or target.exists(): + if target.is_dir() and not target.is_symlink(): + logging.error(f"Directory {target} already exists and is not a symlink.") + raise Exception(f"Directory {target} already exists and is not a symlink.") + target.unlink() + logging.info(f"Removed existing symlink or directory at: {target}") + + # Create symbolic link + os.symlink(src, target) + logging.info(f"Created symlink: {target} -> {src}") + + except Exception as e: + logging.error(f"Error creating symlinks: {e}") + raise e + + # Execute the symlink creation + symlink_external_dirs() \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml new file mode 100644 index 00000000..e3e8d7d9 --- /dev/null +++ b/docs/mkdocs.yml @@ -0,0 +1,141 @@ +site_name: Icechunk +site_description: >- + Transactional storage engine for Zarr on cloud object storage. +site_author: Earthmover PBC +site_url: https://icechunk.io +repo_url: https://github.com/earth-mover/icechunk +repo_name: earth-mover/icechunk +copyright: Earthmover PBC # @see overrides/partials/footer.html + +site_dir: ./.site + +theme: + name: material + custom_dir: overrides + #logo: assets/logo.png + #favicon: assets/favicon.png + palette: + - primary: deep purple + # Light Mode + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/weather-night + name: Switch to dark mode + # Dark Mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/weather-sunny + name: Switch to light mode + features: + - navigation.instant + - navigation.instant.prefetch + - navigation.instant.progress + - navigation.tracking + - navigation.indexes + #- navigation.tabs + #- navigation.tabs.sticky + - navigation.expand + - toc.follow + - navigation.top + - announce.dismiss + - content.code.copy + - content.code.annotate + icon: + repo: fontawesome/brands/github + font: + text: Roboto + code: Roboto Mono + +extra_css: + - stylesheets/extra.css # TODO: this isn't working anymore? + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/earth-mover/icechunk + - icon: fontawesome/brands/python + link: https://pypi.org/project/icechunk/ + - icon: fontawesome/brands/x-twitter + link: https://x.com/earthmoverhq + generator: false + status: + new: Recently Added + deprecated: Deprecated + analytics: + provider: google + property: G-TNHH1RF342 + feedback: + title: Was this page helpful? + ratings: + - icon: material/emoticon-happy-outline + name: This page was helpful + data: 1 + note: >- + Thanks for your feedback! + - icon: material/emoticon-sad-outline + name: This page could be improved + data: 0 + note: >- + Thanks for your feedback! Help us improve this page by + using our feedback form. + +plugins: + #- mike # TODO: https://squidfunk.github.io/mkdocs-material/setup/setting-up-versioning/ + #- optimize #TODO: https://squidfunk.github.io/mkdocs-material/plugins/optimize/ + - search + - social + - include-markdown + - open-in-new-tab + - mkdocs-breadcrumbs-plugin + - mermaid2 + - minify: + minify_html: true + - awesome-pages: + collapse_single_pages: true + - macros: + module_name: macros + - git-revision-date-localized: + #enabled: !ENV [CI, false] + - git-committers: + repository: earth-mover/icechunk + branch: main + #enabled: !ENV [CI, false] + - mkdocstrings: + default_handler: python + - mkdocs-jupyter: + include_source: True + include: + - "icechunk-python/notebooks/*.ipynb" + - "icechunk-python/examples/*.py" + +markdown_extensions: + - admonition + - tables + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences: + # make exceptions to highlighting of code: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:mermaid2.fence_mermaid_custom + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + +nav: + - Home: + - index.md + - Icechunk Python: + - icechunk-python/index.md + - Reference: icechunk-python/reference.md + - Developing: icechunk-python/developing.md + - Examples: + - ... | flat | icechunk-python/examples/*.py + - Notebooks: + - ... | flat | icechunk-python/notebooks/*.ipynb + - Spec: spec.md + diff --git a/docs/overrides/main.html b/docs/overrides/main.html new file mode 100644 index 00000000..aa4ebd1b --- /dev/null +++ b/docs/overrides/main.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} + +{% block announce %} + +{% endblock %} \ No newline at end of file diff --git a/docs/overrides/partials/copyright.html b/docs/overrides/partials/copyright.html new file mode 100644 index 00000000..f55e0083 --- /dev/null +++ b/docs/overrides/partials/copyright.html @@ -0,0 +1,14 @@ + \ No newline at end of file diff --git a/docs/poetry.lock b/docs/poetry.lock new file mode 100644 index 00000000..369d6b87 --- /dev/null +++ b/docs/poetry.lock @@ -0,0 +1,2687 @@ +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. + +[[package]] +name = "appnope" +version = "0.1.4" +description = "Disable App Nap on macOS >= 10.9" +optional = false +python-versions = ">=3.6" +files = [ + {file = "appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c"}, + {file = "appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee"}, +] + +[[package]] +name = "asttokens" +version = "2.4.1" +description = "Annotate AST trees with source code positions" +optional = false +python-versions = "*" +files = [ + {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, + {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, +] + +[package.dependencies] +six = ">=1.12.0" + +[package.extras] +astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] +test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] + +[[package]] +name = "attrs" +version = "24.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + +[[package]] +name = "babel" +version = "2.16.0" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.8" +files = [ + {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"}, + {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, +] + +[package.extras] +dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] + +[[package]] +name = "beautifulsoup4" +version = "4.12.3" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, + {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, +] + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + +[[package]] +name = "bleach" +version = "6.1.0" +description = "An easy safelist-based HTML-sanitizing tool." +optional = false +python-versions = ">=3.8" +files = [ + {file = "bleach-6.1.0-py3-none-any.whl", hash = "sha256:3225f354cfc436b9789c66c4ee030194bee0568fbf9cbdad3bc8b5c26c5f12b6"}, + {file = "bleach-6.1.0.tar.gz", hash = "sha256:0a31f1837963c41d46bbf1331b8778e1308ea0791db03cc4e7357b97cf42a8fe"}, +] + +[package.dependencies] +six = ">=1.9.0" +webencodings = "*" + +[package.extras] +css = ["tinycss2 (>=1.1.0,<1.3)"] + +[[package]] +name = "bracex" +version = "2.5.post1" +description = "Bash style brace expander." +optional = false +python-versions = ">=3.8" +files = [ + {file = "bracex-2.5.post1-py3-none-any.whl", hash = "sha256:13e5732fec27828d6af308628285ad358047cec36801598368cb28bc631dbaf6"}, + {file = "bracex-2.5.post1.tar.gz", hash = "sha256:12c50952415bfa773d2d9ccb8e79651b8cdb1f31a42f6091b804f6ba2b4a66b6"}, +] + +[[package]] +name = "cairocffi" +version = "1.7.1" +description = "cffi-based cairo bindings for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "cairocffi-1.7.1-py3-none-any.whl", hash = "sha256:9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f"}, + {file = "cairocffi-1.7.1.tar.gz", hash = "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b"}, +] + +[package.dependencies] +cffi = ">=1.1.0" + +[package.extras] +doc = ["sphinx", "sphinx_rtd_theme"] +test = ["numpy", "pikepdf", "pytest", "ruff"] +xcb = ["xcffib (>=1.4.0)"] + +[[package]] +name = "cairosvg" +version = "2.7.1" +description = "A Simple SVG Converter based on Cairo" +optional = false +python-versions = ">=3.5" +files = [ + {file = "CairoSVG-2.7.1-py3-none-any.whl", hash = "sha256:8a5222d4e6c3f86f1f7046b63246877a63b49923a1cd202184c3a634ef546b3b"}, + {file = "CairoSVG-2.7.1.tar.gz", hash = "sha256:432531d72347291b9a9ebfb6777026b607563fd8719c46ee742db0aef7271ba0"}, +] + +[package.dependencies] +cairocffi = "*" +cssselect2 = "*" +defusedxml = "*" +pillow = "*" +tinycss2 = "*" + +[package.extras] +doc = ["sphinx", "sphinx-rtd-theme"] +test = ["flake8", "isort", "pytest"] + +[[package]] +name = "certifi" +version = "2024.8.30" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, +] + +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "comm" +version = "0.2.2" +description = "Jupyter Python Comm implementation, for usage in ipykernel, xeus-python etc." +optional = false +python-versions = ">=3.8" +files = [ + {file = "comm-0.2.2-py3-none-any.whl", hash = "sha256:e6fb86cb70ff661ee8c9c14e7d36d6de3b4066f1441be4063df9c5009f0a64d3"}, + {file = "comm-0.2.2.tar.gz", hash = "sha256:3fd7a84065306e07bea1773df6eb8282de51ba82f77c72f9c85716ab11fe980e"}, +] + +[package.dependencies] +traitlets = ">=4" + +[package.extras] +test = ["pytest"] + +[[package]] +name = "csscompressor" +version = "0.9.5" +description = "A python port of YUI CSS Compressor" +optional = false +python-versions = "*" +files = [ + {file = "csscompressor-0.9.5.tar.gz", hash = "sha256:afa22badbcf3120a4f392e4d22f9fff485c044a1feda4a950ecc5eba9dd31a05"}, +] + +[[package]] +name = "cssselect2" +version = "0.7.0" +description = "CSS selectors for Python ElementTree" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cssselect2-0.7.0-py3-none-any.whl", hash = "sha256:fd23a65bfd444595913f02fc71f6b286c29261e354c41d722ca7a261a49b5969"}, + {file = "cssselect2-0.7.0.tar.gz", hash = "sha256:1ccd984dab89fc68955043aca4e1b03e0cf29cad9880f6e28e3ba7a74b14aa5a"}, +] + +[package.dependencies] +tinycss2 = "*" +webencodings = "*" + +[package.extras] +doc = ["sphinx", "sphinx_rtd_theme"] +test = ["flake8", "isort", "pytest"] + +[[package]] +name = "debugpy" +version = "1.8.6" +description = "An implementation of the Debug Adapter Protocol for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "debugpy-1.8.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:30f467c5345d9dfdcc0afdb10e018e47f092e383447500f125b4e013236bf14b"}, + {file = "debugpy-1.8.6-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d73d8c52614432f4215d0fe79a7e595d0dd162b5c15233762565be2f014803b"}, + {file = "debugpy-1.8.6-cp310-cp310-win32.whl", hash = "sha256:e3e182cd98eac20ee23a00653503315085b29ab44ed66269482349d307b08df9"}, + {file = "debugpy-1.8.6-cp310-cp310-win_amd64.whl", hash = "sha256:e3a82da039cfe717b6fb1886cbbe5c4a3f15d7df4765af857f4307585121c2dd"}, + {file = "debugpy-1.8.6-cp311-cp311-macosx_14_0_universal2.whl", hash = "sha256:67479a94cf5fd2c2d88f9615e087fcb4fec169ec780464a3f2ba4a9a2bb79955"}, + {file = "debugpy-1.8.6-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fb8653f6cbf1dd0a305ac1aa66ec246002145074ea57933978346ea5afdf70b"}, + {file = "debugpy-1.8.6-cp311-cp311-win32.whl", hash = "sha256:cdaf0b9691879da2d13fa39b61c01887c34558d1ff6e5c30e2eb698f5384cd43"}, + {file = "debugpy-1.8.6-cp311-cp311-win_amd64.whl", hash = "sha256:43996632bee7435583952155c06881074b9a742a86cee74e701d87ca532fe833"}, + {file = "debugpy-1.8.6-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:db891b141fc6ee4b5fc6d1cc8035ec329cabc64bdd2ae672b4550c87d4ecb128"}, + {file = "debugpy-1.8.6-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:567419081ff67da766c898ccf21e79f1adad0e321381b0dfc7a9c8f7a9347972"}, + {file = "debugpy-1.8.6-cp312-cp312-win32.whl", hash = "sha256:c9834dfd701a1f6bf0f7f0b8b1573970ae99ebbeee68314116e0ccc5c78eea3c"}, + {file = "debugpy-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:e4ce0570aa4aca87137890d23b86faeadf184924ad892d20c54237bcaab75d8f"}, + {file = "debugpy-1.8.6-cp38-cp38-macosx_14_0_x86_64.whl", hash = "sha256:df5dc9eb4ca050273b8e374a4cd967c43be1327eeb42bfe2f58b3cdfe7c68dcb"}, + {file = "debugpy-1.8.6-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a85707c6a84b0c5b3db92a2df685b5230dd8fb8c108298ba4f11dba157a615a"}, + {file = "debugpy-1.8.6-cp38-cp38-win32.whl", hash = "sha256:538c6cdcdcdad310bbefd96d7850be1cd46e703079cc9e67d42a9ca776cdc8a8"}, + {file = "debugpy-1.8.6-cp38-cp38-win_amd64.whl", hash = "sha256:22140bc02c66cda6053b6eb56dfe01bbe22a4447846581ba1dd6df2c9f97982d"}, + {file = "debugpy-1.8.6-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:c1cef65cffbc96e7b392d9178dbfd524ab0750da6c0023c027ddcac968fd1caa"}, + {file = "debugpy-1.8.6-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1e60bd06bb3cc5c0e957df748d1fab501e01416c43a7bdc756d2a992ea1b881"}, + {file = "debugpy-1.8.6-cp39-cp39-win32.whl", hash = "sha256:f7158252803d0752ed5398d291dee4c553bb12d14547c0e1843ab74ee9c31123"}, + {file = "debugpy-1.8.6-cp39-cp39-win_amd64.whl", hash = "sha256:3358aa619a073b620cd0d51d8a6176590af24abcc3fe2e479929a154bf591b51"}, + {file = "debugpy-1.8.6-py2.py3-none-any.whl", hash = "sha256:b48892df4d810eff21d3ef37274f4c60d32cdcafc462ad5647239036b0f0649f"}, + {file = "debugpy-1.8.6.zip", hash = "sha256:c931a9371a86784cee25dec8d65bc2dc7a21f3f1552e3833d9ef8f919d22280a"}, +] + +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.5" +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, +] + +[[package]] +name = "editorconfig" +version = "0.12.4" +description = "EditorConfig File Locator and Interpreter for Python" +optional = false +python-versions = "*" +files = [ + {file = "EditorConfig-0.12.4.tar.gz", hash = "sha256:24857fa1793917dd9ccf0c7810a07e05404ce9b823521c7dce22a4fb5d125f80"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "executing" +version = "2.1.0" +description = "Get the currently executing AST node of a frame, and other information" +optional = false +python-versions = ">=3.8" +files = [ + {file = "executing-2.1.0-py2.py3-none-any.whl", hash = "sha256:8d63781349375b5ebccc3142f4b30350c0cd9c79f921cde38be2be4637e98eaf"}, + {file = "executing-2.1.0.tar.gz", hash = "sha256:8ea27ddd260da8150fa5a708269c4a10e76161e2496ec3e587da9e3c0fe4b9ab"}, +] + +[package.extras] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] + +[[package]] +name = "fastjsonschema" +version = "2.20.0" +description = "Fastest Python implementation of JSON schema" +optional = false +python-versions = "*" +files = [ + {file = "fastjsonschema-2.20.0-py3-none-any.whl", hash = "sha256:5875f0b0fa7a0043a91e93a9b8f793bcbbba9691e7fd83dca95c28ba26d21f0a"}, + {file = "fastjsonschema-2.20.0.tar.gz", hash = "sha256:3d48fc5300ee96f5d116f10fe6f28d938e6008f59a6a025c2649475b87f76a23"}, +] + +[package.extras] +devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"] + +[[package]] +name = "ghp-import" +version = "2.1.0" +description = "Copy your docs directly to the gh-pages branch." +optional = false +python-versions = "*" +files = [ + {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, + {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, +] + +[package.dependencies] +python-dateutil = ">=2.8.1" + +[package.extras] +dev = ["flake8", "markdown", "twine", "wheel"] + +[[package]] +name = "gitdb" +version = "4.0.11" +description = "Git Object Database" +optional = false +python-versions = ">=3.7" +files = [ + {file = "gitdb-4.0.11-py3-none-any.whl", hash = "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4"}, + {file = "gitdb-4.0.11.tar.gz", hash = "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b"}, +] + +[package.dependencies] +smmap = ">=3.0.1,<6" + +[[package]] +name = "gitpython" +version = "3.1.43" +description = "GitPython is a Python library used to interact with Git repositories" +optional = false +python-versions = ">=3.7" +files = [ + {file = "GitPython-3.1.43-py3-none-any.whl", hash = "sha256:eec7ec56b92aad751f9912a73404bc02ba212a23adb2c7098ee668417051a1ff"}, + {file = "GitPython-3.1.43.tar.gz", hash = "sha256:35f314a9f878467f5453cc1fee295c3e18e52f1b99f10f6cf5b1682e968a9e7c"}, +] + +[package.dependencies] +gitdb = ">=4.0.1,<5" + +[package.extras] +doc = ["sphinx (==4.3.2)", "sphinx-autodoc-typehints", "sphinx-rtd-theme", "sphinxcontrib-applehelp (>=1.0.2,<=1.0.4)", "sphinxcontrib-devhelp (==1.0.2)", "sphinxcontrib-htmlhelp (>=2.0.0,<=2.0.1)", "sphinxcontrib-qthelp (==1.0.3)", "sphinxcontrib-serializinghtml (==1.1.5)"] +test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions"] + +[[package]] +name = "griffe" +version = "1.3.2" +description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." +optional = false +python-versions = ">=3.8" +files = [ + {file = "griffe-1.3.2-py3-none-any.whl", hash = "sha256:2e34b5e46507d615915c8e6288bb1a2234bd35dee44d01e40a2bc2f25bd4d10c"}, + {file = "griffe-1.3.2.tar.gz", hash = "sha256:1ec50335aa507ed2445f2dd45a15c9fa3a45f52c9527e880571dfc61912fd60c"}, +] + +[package.dependencies] +colorama = ">=0.4" + +[[package]] +name = "htmlmin2" +version = "0.1.13" +description = "An HTML Minifier" +optional = false +python-versions = "*" +files = [ + {file = "htmlmin2-0.1.13-py3-none-any.whl", hash = "sha256:75609f2a42e64f7ce57dbff28a39890363bde9e7e5885db633317efbdf8c79a2"}, +] + +[[package]] +name = "icechunk" +version = "0.1.0" +description = "Icechunk Python" +optional = false +python-versions = "*" +files = [] +develop = true + +[package.source] +type = "directory" +url = "../icechunk-python" + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "importlib-metadata" +version = "8.5.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, + {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy"] + +[[package]] +name = "importlib-resources" +version = "6.4.5" +description = "Read resources from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_resources-6.4.5-py3-none-any.whl", hash = "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717"}, + {file = "importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "zipp (>=3.17)"] +type = ["pytest-mypy"] + +[[package]] +name = "ipykernel" +version = "6.29.5" +description = "IPython Kernel for Jupyter" +optional = false +python-versions = ">=3.8" +files = [ + {file = "ipykernel-6.29.5-py3-none-any.whl", hash = "sha256:afdb66ba5aa354b09b91379bac28ae4afebbb30e8b39510c9690afb7a10421b5"}, + {file = "ipykernel-6.29.5.tar.gz", hash = "sha256:f093a22c4a40f8828f8e330a9c297cb93dcab13bd9678ded6de8e5cf81c56215"}, +] + +[package.dependencies] +appnope = {version = "*", markers = "platform_system == \"Darwin\""} +comm = ">=0.1.1" +debugpy = ">=1.6.5" +ipython = ">=7.23.1" +jupyter-client = ">=6.1.12" +jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +matplotlib-inline = ">=0.1" +nest-asyncio = "*" +packaging = "*" +psutil = "*" +pyzmq = ">=24" +tornado = ">=6.1" +traitlets = ">=5.4.0" + +[package.extras] +cov = ["coverage[toml]", "curio", "matplotlib", "pytest-cov", "trio"] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling", "trio"] +pyqt5 = ["pyqt5"] +pyside6 = ["pyside6"] +test = ["flaky", "ipyparallel", "pre-commit", "pytest (>=7.0)", "pytest-asyncio (>=0.23.5)", "pytest-cov", "pytest-timeout"] + +[[package]] +name = "ipython" +version = "8.28.0" +description = "IPython: Productive Interactive Computing" +optional = false +python-versions = ">=3.10" +files = [ + {file = "ipython-8.28.0-py3-none-any.whl", hash = "sha256:530ef1e7bb693724d3cdc37287c80b07ad9b25986c007a53aa1857272dac3f35"}, + {file = "ipython-8.28.0.tar.gz", hash = "sha256:0d0d15ca1e01faeb868ef56bc7ee5a0de5bd66885735682e8a322ae289a13d1a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} +prompt-toolkit = ">=3.0.41,<3.1.0" +pygments = ">=2.4.0" +stack-data = "*" +traitlets = ">=5.13.0" +typing-extensions = {version = ">=4.6", markers = "python_version < \"3.12\""} + +[package.extras] +all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] +black = ["black"] +doc = ["docrepr", "exceptiongroup", "intersphinx-registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "tomli", "typing-extensions"] +kernel = ["ipykernel"] +matplotlib = ["matplotlib"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["packaging", "pickleshare", "pytest", "pytest-asyncio (<0.22)", "testpath"] +test-extra = ["curio", "ipython[test]", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.23)", "pandas", "trio"] + +[[package]] +name = "jedi" +version = "0.19.1" +description = "An autocompletion tool for Python that can be used for text editors." +optional = false +python-versions = ">=3.6" +files = [ + {file = "jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"}, + {file = "jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd"}, +] + +[package.dependencies] +parso = ">=0.8.3,<0.9.0" + +[package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] + +[[package]] +name = "jinja2" +version = "3.1.4" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, + {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "jsbeautifier" +version = "1.15.1" +description = "JavaScript unobfuscator and beautifier." +optional = false +python-versions = "*" +files = [ + {file = "jsbeautifier-1.15.1.tar.gz", hash = "sha256:ebd733b560704c602d744eafc839db60a1ee9326e30a2a80c4adb8718adc1b24"}, +] + +[package.dependencies] +editorconfig = ">=0.12.2" +six = ">=1.13.0" + +[[package]] +name = "jsmin" +version = "3.0.1" +description = "JavaScript minifier." +optional = false +python-versions = "*" +files = [ + {file = "jsmin-3.0.1.tar.gz", hash = "sha256:c0959a121ef94542e807a674142606f7e90214a2b3d1eb17300244bbb5cc2bfc"}, +] + +[[package]] +name = "jsonschema" +version = "4.23.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, + {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.03.6" +referencing = ">=0.28.4" +rpds-py = ">=0.7.1" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2023.12.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jsonschema_specifications-2023.12.1-py3-none-any.whl", hash = "sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c"}, + {file = "jsonschema_specifications-2023.12.1.tar.gz", hash = "sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + +[[package]] +name = "jupyter-client" +version = "8.6.3" +description = "Jupyter protocol implementation and client libraries" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f"}, + {file = "jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419"}, +] + +[package.dependencies] +jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +python-dateutil = ">=2.8.2" +pyzmq = ">=23.0" +tornado = ">=6.2" +traitlets = ">=5.3" + +[package.extras] +docs = ["ipykernel", "myst-parser", "pydata-sphinx-theme", "sphinx (>=4)", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] +test = ["coverage", "ipykernel (>=6.14)", "mypy", "paramiko", "pre-commit", "pytest (<8.2.0)", "pytest-cov", "pytest-jupyter[client] (>=0.4.1)", "pytest-timeout"] + +[[package]] +name = "jupyter-core" +version = "5.7.2" +description = "Jupyter core package. A base package on which Jupyter projects rely." +optional = false +python-versions = ">=3.8" +files = [ + {file = "jupyter_core-5.7.2-py3-none-any.whl", hash = "sha256:4f7315d2f6b4bcf2e3e7cb6e46772eba760ae459cd1f59d29eb57b0a01bd7409"}, + {file = "jupyter_core-5.7.2.tar.gz", hash = "sha256:aa5f8d32bbf6b431ac830496da7392035d6f61b4f54872f15c4bd2a9c3f536d9"}, +] + +[package.dependencies] +platformdirs = ">=2.5" +pywin32 = {version = ">=300", markers = "sys_platform == \"win32\" and platform_python_implementation != \"PyPy\""} +traitlets = ">=5.3" + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling", "traitlets"] +test = ["ipykernel", "pre-commit", "pytest (<8)", "pytest-cov", "pytest-timeout"] + +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +description = "Pygments theme using JupyterLab CSS variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780"}, + {file = "jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d"}, +] + +[[package]] +name = "jupytext" +version = "1.16.4" +description = "Jupyter notebooks as Markdown documents, Julia, Python or R scripts" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jupytext-1.16.4-py3-none-any.whl", hash = "sha256:76989d2690e65667ea6fb411d8056abe7cd0437c07bd774660b83d62acf9490a"}, + {file = "jupytext-1.16.4.tar.gz", hash = "sha256:28e33f46f2ce7a41fb9d677a4a2c95327285579b64ca104437c4b9eb1e4174e9"}, +] + +[package.dependencies] +markdown-it-py = ">=1.0" +mdit-py-plugins = "*" +nbformat = "*" +packaging = "*" +pyyaml = "*" +tomli = {version = "*", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["autopep8", "black", "flake8", "gitpython", "ipykernel", "isort", "jupyter-fs (>=1.0)", "jupyter-server (!=2.11)", "nbconvert", "pre-commit", "pytest", "pytest-cov (>=2.6.1)", "pytest-randomly", "pytest-xdist", "sphinx-gallery (<0.8)"] +docs = ["myst-parser", "sphinx", "sphinx-copybutton", "sphinx-rtd-theme"] +test = ["pytest", "pytest-randomly", "pytest-xdist"] +test-cov = ["ipykernel", "jupyter-server (!=2.11)", "nbconvert", "pytest", "pytest-cov (>=2.6.1)", "pytest-randomly", "pytest-xdist"] +test-external = ["autopep8", "black", "flake8", "gitpython", "ipykernel", "isort", "jupyter-fs (>=1.0)", "jupyter-server (!=2.11)", "nbconvert", "pre-commit", "pytest", "pytest-randomly", "pytest-xdist", "sphinx-gallery (<0.8)"] +test-functional = ["pytest", "pytest-randomly", "pytest-xdist"] +test-integration = ["ipykernel", "jupyter-server (!=2.11)", "nbconvert", "pytest", "pytest-randomly", "pytest-xdist"] +test-ui = ["calysto-bash"] + +[[package]] +name = "markdown" +version = "3.7" +description = "Python implementation of John Gruber's Markdown." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"}, + {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, +] + +[package.extras] +docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] +testing = ["coverage", "pyyaml"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "markupsafe" +version = "2.1.5" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, +] + +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +description = "Inline Matplotlib backend for Jupyter" +optional = false +python-versions = ">=3.8" +files = [ + {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"}, + {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"}, +] + +[package.dependencies] +traitlets = "*" + +[[package]] +name = "mdit-py-plugins" +version = "0.4.2" +description = "Collection of plugins for markdown-it-py" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636"}, + {file = "mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5"}, +] + +[package.dependencies] +markdown-it-py = ">=1.0.0,<4.0.0" + +[package.extras] +code-style = ["pre-commit"] +rtd = ["myst-parser", "sphinx-book-theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +description = "A deep merge function for 🐍." +optional = false +python-versions = ">=3.6" +files = [ + {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, + {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, +] + +[[package]] +name = "mike" +version = "2.1.3" +description = "Manage multiple versions of your MkDocs-powered documentation" +optional = false +python-versions = "*" +files = [ + {file = "mike-2.1.3-py3-none-any.whl", hash = "sha256:d90c64077e84f06272437b464735130d380703a76a5738b152932884c60c062a"}, + {file = "mike-2.1.3.tar.gz", hash = "sha256:abd79b8ea483fb0275b7972825d3082e5ae67a41820f8d8a0dc7a3f49944e810"}, +] + +[package.dependencies] +importlib-metadata = "*" +importlib-resources = "*" +jinja2 = ">=2.7" +mkdocs = ">=1.0" +pyparsing = ">=3.0" +pyyaml = ">=5.1" +pyyaml-env-tag = "*" +verspec = "*" + +[package.extras] +dev = ["coverage", "flake8 (>=3.0)", "flake8-quotes", "shtab"] +test = ["coverage", "flake8 (>=3.0)", "flake8-quotes", "shtab"] + +[[package]] +name = "mistune" +version = "3.0.2" +description = "A sane and fast Markdown parser with useful plugins and renderers" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mistune-3.0.2-py3-none-any.whl", hash = "sha256:71481854c30fdbc938963d3605b72501f5c10a9320ecd412c121c163a1c7d205"}, + {file = "mistune-3.0.2.tar.gz", hash = "sha256:fc7f93ded930c92394ef2cb6f04a8aabab4117a91449e72dcc8dfa646a508be8"}, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +description = "Project documentation with Markdown." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, + {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} +ghp-import = ">=1.0" +jinja2 = ">=2.11.1" +markdown = ">=3.3.6" +markupsafe = ">=2.0.1" +mergedeep = ">=1.3.4" +mkdocs-get-deps = ">=0.2.0" +packaging = ">=20.5" +pathspec = ">=0.11.1" +pyyaml = ">=5.1" +pyyaml-env-tag = ">=0.1" +watchdog = ">=2.0" + +[package.extras] +i18n = ["babel (>=2.9.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.4)", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] + +[[package]] +name = "mkdocs-autorefs" +version = "1.2.0" +description = "Automatically link across pages in MkDocs." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_autorefs-1.2.0-py3-none-any.whl", hash = "sha256:d588754ae89bd0ced0c70c06f58566a4ee43471eeeee5202427da7de9ef85a2f"}, + {file = "mkdocs_autorefs-1.2.0.tar.gz", hash = "sha256:a86b93abff653521bda71cf3fc5596342b7a23982093915cb74273f67522190f"}, +] + +[package.dependencies] +Markdown = ">=3.3" +markupsafe = ">=2.0.1" +mkdocs = ">=1.1" + +[[package]] +name = "mkdocs-awesome-pages-plugin" +version = "2.9.3" +description = "An MkDocs plugin that simplifies configuring page titles and their order" +optional = false +python-versions = ">=3.8.1" +files = [ + {file = "mkdocs_awesome_pages_plugin-2.9.3-py3-none-any.whl", hash = "sha256:1ba433d4e7edaf8661b15b93267f78f78e2e06ca590fc0e651ea36b191d64ae4"}, + {file = "mkdocs_awesome_pages_plugin-2.9.3.tar.gz", hash = "sha256:bdf6369871f41bb17f09c3cfb573367732dfcceb5673d7a2c5c76ac2567b242f"}, +] + +[package.dependencies] +mkdocs = ">=1" +natsort = ">=8.1.0" +wcmatch = ">=7" + +[[package]] +name = "mkdocs-breadcrumbs-plugin" +version = "0.1.10" +description = "Location-based breadcrumbs plugin for mkdocs." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_breadcrumbs_plugin-0.1.10-py3-none-any.whl", hash = "sha256:b3678f9e2acbe33f0720c001a4446fd545f0de3e58f8f3629e9f46a6f1c8d033"}, + {file = "mkdocs_breadcrumbs_plugin-0.1.10.tar.gz", hash = "sha256:36f902df21c6851e1c5108a588865d8098c58cc314d5a85ba2f3b8fb91e519bb"}, +] + +[package.dependencies] +mkdocs = ">=1.0.4" +mkdocs-material = "*" + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, + {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, +] + +[package.dependencies] +mergedeep = ">=1.3.4" +platformdirs = ">=2.2.0" +pyyaml = ">=5.1" + +[[package]] +name = "mkdocs-git-committers-plugin-2" +version = "2.4.1" +description = "An MkDocs plugin to create a list of contributors on the page. The git-committers plugin will seed the template context with a list of GitHub or GitLab committers and other useful GIT info such as last modified date" +optional = false +python-versions = "<4,>=3.8" +files = [ + {file = "mkdocs_git_committers_plugin_2-2.4.1-py3-none-any.whl", hash = "sha256:ec9c1d81445606c471337d1c4a1782c643b7377077b545279dc18b86b7362c6d"}, + {file = "mkdocs_git_committers_plugin_2-2.4.1.tar.gz", hash = "sha256:ea1f80a79cedc42289e0b8e973276df04fb94f56e0ae3efc5385fb28547cf5cb"}, +] + +[package.dependencies] +gitpython = "*" +mkdocs = ">=1.0.3" +requests = "*" + +[[package]] +name = "mkdocs-git-revision-date-localized-plugin" +version = "1.2.9" +description = "Mkdocs plugin that enables displaying the localized date of the last git modification of a markdown file." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_git_revision_date_localized_plugin-1.2.9-py3-none-any.whl", hash = "sha256:dea5c8067c23df30275702a1708885500fadf0abfb595b60e698bffc79c7a423"}, + {file = "mkdocs_git_revision_date_localized_plugin-1.2.9.tar.gz", hash = "sha256:df9a50873fba3a42ce9123885f8c53d589e90ef6c2443fe3280ef1e8d33c8f65"}, +] + +[package.dependencies] +babel = ">=2.7.0" +GitPython = "*" +mkdocs = ">=1.0" +pytz = "*" + +[package.extras] +all = ["GitPython", "babel (>=2.7.0)", "click", "codecov", "mkdocs (>=1.0)", "mkdocs-gen-files", "mkdocs-git-authors-plugin", "mkdocs-material", "mkdocs-static-i18n", "pytest", "pytest-cov", "pytz"] +base = ["GitPython", "babel (>=2.7.0)", "mkdocs (>=1.0)", "pytz"] +dev = ["click", "codecov", "mkdocs-gen-files", "mkdocs-git-authors-plugin", "mkdocs-material", "mkdocs-static-i18n", "pytest", "pytest-cov"] + +[[package]] +name = "mkdocs-include-markdown-plugin" +version = "6.2.2" +description = "Mkdocs Markdown includer plugin." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_include_markdown_plugin-6.2.2-py3-none-any.whl", hash = "sha256:d293950f6499d2944291ca7b9bc4a60e652bbfd3e3a42b564f6cceee268694e7"}, + {file = "mkdocs_include_markdown_plugin-6.2.2.tar.gz", hash = "sha256:f2bd5026650492a581d2fd44be6c22f90391910d76582b96a34c264f2d17875d"}, +] + +[package.dependencies] +mkdocs = ">=1.4" +wcmatch = "*" + +[package.extras] +cache = ["platformdirs"] + +[[package]] +name = "mkdocs-jupyter" +version = "0.25.0" +description = "Use Jupyter in mkdocs websites" +optional = false +python-versions = ">=3.9" +files = [ + {file = "mkdocs_jupyter-0.25.0-py3-none-any.whl", hash = "sha256:d83d71deef19f0401505945bf92ec3bd5b40615af89308e72d5112929f8ee00b"}, + {file = "mkdocs_jupyter-0.25.0.tar.gz", hash = "sha256:e26c1d341916bc57f96ea3f93d8d0a88fc77c87d4cee222f66d2007798d924f5"}, +] + +[package.dependencies] +ipykernel = ">6.0.0,<7.0.0" +jupytext = ">1.13.8,<2" +mkdocs = ">=1.4.0,<2" +mkdocs-material = ">9.0.0" +nbconvert = ">=7.2.9,<8" +pygments = ">2.12.0" + +[[package]] +name = "mkdocs-macros-plugin" +version = "1.2.0" +description = "Unleash the power of MkDocs with macros and variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs-macros-plugin-1.2.0.tar.gz", hash = "sha256:7603b85cb336d669e29a8a9cc3af8b90767ffdf6021b3e023d5ec2e0a1f927a7"}, + {file = "mkdocs_macros_plugin-1.2.0-py3-none-any.whl", hash = "sha256:3e442f8f37aa69710a69b5389e6b6cd0f54f4fcaee354aa57a61735ba8f97d27"}, +] + +[package.dependencies] +jinja2 = "*" +mkdocs = ">=0.17" +packaging = "*" +python-dateutil = "*" +pyyaml = "*" +termcolor = "*" + +[package.extras] +test = ["mkdocs-include-markdown-plugin", "mkdocs-macros-test", "mkdocs-material (>=6.2)"] + +[[package]] +name = "mkdocs-material" +version = "9.5.39" +description = "Documentation that simply works" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_material-9.5.39-py3-none-any.whl", hash = "sha256:0f2f68c8db89523cb4a59705cd01b4acd62b2f71218ccb67e1e004e560410d2b"}, + {file = "mkdocs_material-9.5.39.tar.gz", hash = "sha256:25faa06142afa38549d2b781d475a86fb61de93189f532b88e69bf11e5e5c3be"}, +] + +[package.dependencies] +babel = ">=2.10,<3.0" +cairosvg = {version = ">=2.6,<3.0", optional = true, markers = "extra == \"imaging\""} +colorama = ">=0.4,<1.0" +jinja2 = ">=3.0,<4.0" +markdown = ">=3.2,<4.0" +mkdocs = ">=1.6,<2.0" +mkdocs-material-extensions = ">=1.3,<2.0" +paginate = ">=0.5,<1.0" +pillow = {version = ">=10.2,<11.0", optional = true, markers = "extra == \"imaging\""} +pygments = ">=2.16,<3.0" +pymdown-extensions = ">=10.2,<11.0" +regex = ">=2022.4" +requests = ">=2.26,<3.0" + +[package.extras] +git = ["mkdocs-git-committers-plugin-2 (>=1.1,<2.0)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4,<2.0)"] +imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=10.2,<11.0)"] +recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2.0)", "mkdocs-rss-plugin (>=1.6,<2.0)"] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +description = "Extension pack for Python Markdown and MkDocs Material." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, + {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, +] + +[[package]] +name = "mkdocs-mermaid2-plugin" +version = "1.1.1" +description = "A MkDocs plugin for including mermaid graphs in markdown sources" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mkdocs-mermaid2-plugin-1.1.1.tar.gz", hash = "sha256:bea5f3cbe6cb76bad21b81e49a01e074427ed466666c5d404e62fe8698bc2d7c"}, + {file = "mkdocs_mermaid2_plugin-1.1.1-py3-none-any.whl", hash = "sha256:4e25876b59d1e151ca33a467207b346404b4a246f4f24af5e44c32408e175882"}, +] + +[package.dependencies] +beautifulsoup4 = ">=4.6.3" +jsbeautifier = "*" +mkdocs = ">=1.0.4" +pymdown-extensions = ">=8.0" +requests = "*" +setuptools = ">=18.5" + +[package.extras] +test = ["mkdocs-material"] + +[[package]] +name = "mkdocs-minify-plugin" +version = "0.8.0" +description = "An MkDocs plugin to minify HTML, JS or CSS files prior to being written to disk" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs-minify-plugin-0.8.0.tar.gz", hash = "sha256:bc11b78b8120d79e817308e2b11539d790d21445eb63df831e393f76e52e753d"}, + {file = "mkdocs_minify_plugin-0.8.0-py3-none-any.whl", hash = "sha256:5fba1a3f7bd9a2142c9954a6559a57e946587b21f133165ece30ea145c66aee6"}, +] + +[package.dependencies] +csscompressor = ">=0.9.5" +htmlmin2 = ">=0.1.13" +jsmin = ">=3.0.1" +mkdocs = ">=1.4.1" + +[[package]] +name = "mkdocs-open-in-new-tab" +version = "1.0.6" +description = "MkDocs plugin to open outgoing links and PDFs in new tab." +optional = false +python-versions = ">=3.7" +files = [ + {file = "mkdocs_open_in_new_tab-1.0.6-py3-none-any.whl", hash = "sha256:c188d311b882567dd300b629ef7aa0d7835b4781216ab147a9111bf686ac9221"}, + {file = "mkdocs_open_in_new_tab-1.0.6.tar.gz", hash = "sha256:dd4389b04cc9029697e2398a3ddf1c47ff2ee7f4307112f691cf98ccf148d185"}, +] + +[package.dependencies] +mkdocs = "*" + +[package.extras] +dev = ["build (>=1.2.2,<1.3.0)", "mkdocs-git-revision-date-localized-plugin (>=1.2.6,<1.3.0)", "mkdocs-glightbox (>=0.4.0,<0.5.0)", "mkdocs-material (>=9.5.27,<9.6.0)", "setuptools (>=70.0.0,<70.1.0)", "twine (>=5.1.1,<5.2.0)"] + +[[package]] +name = "mkdocs-redirects" +version = "1.2.1" +description = "A MkDocs plugin for dynamic page redirects to prevent broken links." +optional = false +python-versions = ">=3.6" +files = [ + {file = "mkdocs-redirects-1.2.1.tar.gz", hash = "sha256:9420066d70e2a6bb357adf86e67023dcdca1857f97f07c7fe450f8f1fb42f861"}, + {file = "mkdocs_redirects-1.2.1-py3-none-any.whl", hash = "sha256:497089f9e0219e7389304cffefccdfa1cac5ff9509f2cb706f4c9b221726dffb"}, +] + +[package.dependencies] +mkdocs = ">=1.1.1" + +[package.extras] +dev = ["autoflake", "black", "isort", "pytest", "twine (>=1.13.0)"] +release = ["twine (>=1.13.0)"] +test = ["autoflake", "black", "isort", "pytest"] + +[[package]] +name = "mkdocstrings" +version = "0.26.1" +description = "Automatic documentation from sources, for MkDocs." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocstrings-0.26.1-py3-none-any.whl", hash = "sha256:29738bfb72b4608e8e55cc50fb8a54f325dc7ebd2014e4e3881a49892d5983cf"}, + {file = "mkdocstrings-0.26.1.tar.gz", hash = "sha256:bb8b8854d6713d5348ad05b069a09f3b79edbc6a0f33a34c6821141adb03fe33"}, +] + +[package.dependencies] +click = ">=7.0" +Jinja2 = ">=2.11.1" +Markdown = ">=3.6" +MarkupSafe = ">=1.1" +mkdocs = ">=1.4" +mkdocs-autorefs = ">=1.2" +mkdocstrings-python = {version = ">=0.5.2", optional = true, markers = "extra == \"python\""} +platformdirs = ">=2.2" +pymdown-extensions = ">=6.3" + +[package.extras] +crystal = ["mkdocstrings-crystal (>=0.3.4)"] +python = ["mkdocstrings-python (>=0.5.2)"] +python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] + +[[package]] +name = "mkdocstrings-python" +version = "1.11.1" +description = "A Python handler for mkdocstrings." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocstrings_python-1.11.1-py3-none-any.whl", hash = "sha256:a21a1c05acef129a618517bb5aae3e33114f569b11588b1e7af3e9d4061a71af"}, + {file = "mkdocstrings_python-1.11.1.tar.gz", hash = "sha256:8824b115c5359304ab0b5378a91f6202324a849e1da907a3485b59208b797322"}, +] + +[package.dependencies] +griffe = ">=0.49" +mkdocs-autorefs = ">=1.2" +mkdocstrings = ">=0.26" + +[[package]] +name = "natsort" +version = "8.4.0" +description = "Simple yet flexible natural sorting in Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c"}, + {file = "natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581"}, +] + +[package.extras] +fast = ["fastnumbers (>=2.0.0)"] +icu = ["PyICU (>=1.0.0)"] + +[[package]] +name = "nbclient" +version = "0.10.0" +description = "A client library for executing notebooks. Formerly nbconvert's ExecutePreprocessor." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "nbclient-0.10.0-py3-none-any.whl", hash = "sha256:f13e3529332a1f1f81d82a53210322476a168bb7090a0289c795fe9cc11c9d3f"}, + {file = "nbclient-0.10.0.tar.gz", hash = "sha256:4b3f1b7dba531e498449c4db4f53da339c91d449dc11e9af3a43b4eb5c5abb09"}, +] + +[package.dependencies] +jupyter-client = ">=6.1.12" +jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +nbformat = ">=5.1" +traitlets = ">=5.4" + +[package.extras] +dev = ["pre-commit"] +docs = ["autodoc-traits", "mock", "moto", "myst-parser", "nbclient[test]", "sphinx (>=1.7)", "sphinx-book-theme", "sphinxcontrib-spelling"] +test = ["flaky", "ipykernel (>=6.19.3)", "ipython", "ipywidgets", "nbconvert (>=7.0.0)", "pytest (>=7.0,<8)", "pytest-asyncio", "pytest-cov (>=4.0)", "testpath", "xmltodict"] + +[[package]] +name = "nbconvert" +version = "7.16.4" +description = "Converting Jupyter Notebooks (.ipynb files) to other formats. Output formats include asciidoc, html, latex, markdown, pdf, py, rst, script. nbconvert can be used both as a Python library (`import nbconvert`) or as a command line tool (invoked as `jupyter nbconvert ...`)." +optional = false +python-versions = ">=3.8" +files = [ + {file = "nbconvert-7.16.4-py3-none-any.whl", hash = "sha256:05873c620fe520b6322bf8a5ad562692343fe3452abda5765c7a34b7d1aa3eb3"}, + {file = "nbconvert-7.16.4.tar.gz", hash = "sha256:86ca91ba266b0a448dc96fa6c5b9d98affabde2867b363258703536807f9f7f4"}, +] + +[package.dependencies] +beautifulsoup4 = "*" +bleach = "!=5.0.0" +defusedxml = "*" +jinja2 = ">=3.0" +jupyter-core = ">=4.7" +jupyterlab-pygments = "*" +markupsafe = ">=2.0" +mistune = ">=2.0.3,<4" +nbclient = ">=0.5.0" +nbformat = ">=5.7" +packaging = "*" +pandocfilters = ">=1.4.1" +pygments = ">=2.4.1" +tinycss2 = "*" +traitlets = ">=5.1" + +[package.extras] +all = ["flaky", "ipykernel", "ipython", "ipywidgets (>=7.5)", "myst-parser", "nbsphinx (>=0.2.12)", "playwright", "pydata-sphinx-theme", "pyqtwebengine (>=5.15)", "pytest (>=7)", "sphinx (==5.0.2)", "sphinxcontrib-spelling", "tornado (>=6.1)"] +docs = ["ipykernel", "ipython", "myst-parser", "nbsphinx (>=0.2.12)", "pydata-sphinx-theme", "sphinx (==5.0.2)", "sphinxcontrib-spelling"] +qtpdf = ["pyqtwebengine (>=5.15)"] +qtpng = ["pyqtwebengine (>=5.15)"] +serve = ["tornado (>=6.1)"] +test = ["flaky", "ipykernel", "ipywidgets (>=7.5)", "pytest (>=7)"] +webpdf = ["playwright"] + +[[package]] +name = "nbformat" +version = "5.10.4" +description = "The Jupyter Notebook format" +optional = false +python-versions = ">=3.8" +files = [ + {file = "nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b"}, + {file = "nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a"}, +] + +[package.dependencies] +fastjsonschema = ">=2.15" +jsonschema = ">=2.6" +jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +traitlets = ">=5.1" + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] +test = ["pep440", "pre-commit", "pytest", "testpath"] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +description = "Patch asyncio to allow nested event loops" +optional = false +python-versions = ">=3.5" +files = [ + {file = "nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c"}, + {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, +] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "paginate" +version = "0.5.7" +description = "Divides large result sets into pages for easier browsing" +optional = false +python-versions = "*" +files = [ + {file = "paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591"}, + {file = "paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945"}, +] + +[package.extras] +dev = ["pytest", "tox"] +lint = ["black"] + +[[package]] +name = "pandocfilters" +version = "1.5.1" +description = "Utilities for writing pandoc filters in python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc"}, + {file = "pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e"}, +] + +[[package]] +name = "parso" +version = "0.8.4" +description = "A Python Parser" +optional = false +python-versions = ">=3.6" +files = [ + {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, + {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, +] + +[package.extras] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["docopt", "pytest"] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +description = "Pexpect allows easy control of interactive console applications." +optional = false +python-versions = "*" +files = [ + {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, + {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "pillow" +version = "10.4.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, + {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"}, + {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"}, + {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"}, + {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"}, + {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"}, + {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"}, + {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"}, + {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"}, + {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"}, + {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"}, + {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"}, + {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"}, + {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"}, + {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"}, + {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"}, + {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"}, + {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"}, + {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"}, + {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"}, + {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"}, + {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"}, + {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"}, + {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"}, + {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"}, + {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"}, + {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"}, + {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"}, + {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"}, + {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"}, + {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"}, + {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"}, + {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"}, + {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"}, + {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"}, + {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"}, + {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"}, + {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"}, + {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"}, + {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"}, + {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"}, + {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] +typing = ["typing-extensions"] +xmp = ["defusedxml"] + +[[package]] +name = "platformdirs" +version = "4.3.6" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.11.2)"] + +[[package]] +name = "prompt-toolkit" +version = "3.0.48" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e"}, + {file = "prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "psutil" +version = "6.0.0" +description = "Cross-platform lib for process and system monitoring in Python." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "psutil-6.0.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a021da3e881cd935e64a3d0a20983bda0bb4cf80e4f74fa9bfcb1bc5785360c6"}, + {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:1287c2b95f1c0a364d23bc6f2ea2365a8d4d9b726a3be7294296ff7ba97c17f0"}, + {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a9a3dbfb4de4f18174528d87cc352d1f788b7496991cca33c6996f40c9e3c92c"}, + {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:6ec7588fb3ddaec7344a825afe298db83fe01bfaaab39155fa84cf1c0d6b13c3"}, + {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:1e7c870afcb7d91fdea2b37c24aeb08f98b6d67257a5cb0a8bc3ac68d0f1a68c"}, + {file = "psutil-6.0.0-cp27-none-win32.whl", hash = "sha256:02b69001f44cc73c1c5279d02b30a817e339ceb258ad75997325e0e6169d8b35"}, + {file = "psutil-6.0.0-cp27-none-win_amd64.whl", hash = "sha256:21f1fb635deccd510f69f485b87433460a603919b45e2a324ad65b0cc74f8fb1"}, + {file = "psutil-6.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c588a7e9b1173b6e866756dde596fd4cad94f9399daf99ad8c3258b3cb2b47a0"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ed2440ada7ef7d0d608f20ad89a04ec47d2d3ab7190896cd62ca5fc4fe08bf0"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd9a97c8e94059b0ef54a7d4baf13b405011176c3b6ff257c247cae0d560ecd"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e8d0054fc88153ca0544f5c4d554d42e33df2e009c4ff42284ac9ebdef4132"}, + {file = "psutil-6.0.0-cp36-cp36m-win32.whl", hash = "sha256:fc8c9510cde0146432bbdb433322861ee8c3efbf8589865c8bf8d21cb30c4d14"}, + {file = "psutil-6.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:34859b8d8f423b86e4385ff3665d3f4d94be3cdf48221fbe476e883514fdb71c"}, + {file = "psutil-6.0.0-cp37-abi3-win32.whl", hash = "sha256:a495580d6bae27291324fe60cea0b5a7c23fa36a7cd35035a16d93bdcf076b9d"}, + {file = "psutil-6.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:33ea5e1c975250a720b3a6609c490db40dae5d83a4eb315170c4fe0d8b1f34b3"}, + {file = "psutil-6.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:ffe7fc9b6b36beadc8c322f84e1caff51e8703b88eee1da46d1e3a6ae11b4fd0"}, + {file = "psutil-6.0.0.tar.gz", hash = "sha256:8faae4f310b6d969fa26ca0545338b21f73c6b15db7c4a8d934a5482faa818f2"}, +] + +[package.extras] +test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +optional = false +python-versions = "*" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +description = "Safely evaluate AST nodes without side effects" +optional = false +python-versions = "*" +files = [ + {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"}, + {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, +] + +[package.extras] +tests = ["pytest"] + +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pymdown-extensions" +version = "10.11.2" +description = "Extension pack for Python Markdown." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pymdown_extensions-10.11.2-py3-none-any.whl", hash = "sha256:41cdde0a77290e480cf53892f5c5e50921a7ee3e5cd60ba91bf19837b33badcf"}, + {file = "pymdown_extensions-10.11.2.tar.gz", hash = "sha256:bc8847ecc9e784a098efd35e20cba772bc5a1b529dfcef9dc1972db9021a1049"}, +] + +[package.dependencies] +markdown = ">=3.6" +pyyaml = "*" + +[package.extras] +extra = ["pygments (>=2.12)"] + +[[package]] +name = "pyparsing" +version = "3.1.4" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +optional = false +python-versions = ">=3.6.8" +files = [ + {file = "pyparsing-3.1.4-py3-none-any.whl", hash = "sha256:a6a7ee4235a3f944aa1fa2249307708f893fe5717dc603503c6c7969c070fb7c"}, + {file = "pyparsing-3.1.4.tar.gz", hash = "sha256:f86ec8d1a83f11977c9a6ea7598e8c27fc5cddfa5b07ea2241edbbde1d7bc032"}, +] + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pytz" +version = "2024.2" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, + {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, +] + +[[package]] +name = "pywin32" +version = "307" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +files = [ + {file = "pywin32-307-cp310-cp310-win32.whl", hash = "sha256:f8f25d893c1e1ce2d685ef6d0a481e87c6f510d0f3f117932781f412e0eba31b"}, + {file = "pywin32-307-cp310-cp310-win_amd64.whl", hash = "sha256:36e650c5e5e6b29b5d317385b02d20803ddbac5d1031e1f88d20d76676dd103d"}, + {file = "pywin32-307-cp310-cp310-win_arm64.whl", hash = "sha256:0c12d61e0274e0c62acee79e3e503c312426ddd0e8d4899c626cddc1cafe0ff4"}, + {file = "pywin32-307-cp311-cp311-win32.whl", hash = "sha256:fec5d27cc893178fab299de911b8e4d12c5954e1baf83e8a664311e56a272b75"}, + {file = "pywin32-307-cp311-cp311-win_amd64.whl", hash = "sha256:987a86971753ed7fdd52a7fb5747aba955b2c7fbbc3d8b76ec850358c1cc28c3"}, + {file = "pywin32-307-cp311-cp311-win_arm64.whl", hash = "sha256:fd436897c186a2e693cd0437386ed79f989f4d13d6f353f8787ecbb0ae719398"}, + {file = "pywin32-307-cp312-cp312-win32.whl", hash = "sha256:07649ec6b01712f36debf39fc94f3d696a46579e852f60157a729ac039df0815"}, + {file = "pywin32-307-cp312-cp312-win_amd64.whl", hash = "sha256:00d047992bb5dcf79f8b9b7c81f72e0130f9fe4b22df613f755ab1cc021d8347"}, + {file = "pywin32-307-cp312-cp312-win_arm64.whl", hash = "sha256:b53658acbfc6a8241d72cc09e9d1d666be4e6c99376bc59e26cdb6223c4554d2"}, + {file = "pywin32-307-cp313-cp313-win32.whl", hash = "sha256:ea4d56e48dc1ab2aa0a5e3c0741ad6e926529510516db7a3b6981a1ae74405e5"}, + {file = "pywin32-307-cp313-cp313-win_amd64.whl", hash = "sha256:576d09813eaf4c8168d0bfd66fb7cb3b15a61041cf41598c2db4a4583bf832d2"}, + {file = "pywin32-307-cp313-cp313-win_arm64.whl", hash = "sha256:b30c9bdbffda6a260beb2919f918daced23d32c79109412c2085cbc513338a0a"}, + {file = "pywin32-307-cp37-cp37m-win32.whl", hash = "sha256:5101472f5180c647d4525a0ed289ec723a26231550dbfd369ec19d5faf60e511"}, + {file = "pywin32-307-cp37-cp37m-win_amd64.whl", hash = "sha256:05de55a7c110478dc4b202230e98af5e0720855360d2b31a44bb4e296d795fba"}, + {file = "pywin32-307-cp38-cp38-win32.whl", hash = "sha256:13d059fb7f10792542082f5731d5d3d9645320fc38814759313e5ee97c3fac01"}, + {file = "pywin32-307-cp38-cp38-win_amd64.whl", hash = "sha256:7e0b2f93769d450a98ac7a31a087e07b126b6d571e8b4386a5762eb85325270b"}, + {file = "pywin32-307-cp39-cp39-win32.whl", hash = "sha256:55ee87f2f8c294e72ad9d4261ca423022310a6e79fb314a8ca76ab3f493854c6"}, + {file = "pywin32-307-cp39-cp39-win_amd64.whl", hash = "sha256:e9d5202922e74985b037c9ef46778335c102b74b95cec70f629453dbe7235d87"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "pyyaml-env-tag" +version = "0.1" +description = "A custom YAML tag for referencing environment variables in YAML files. " +optional = false +python-versions = ">=3.6" +files = [ + {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, + {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, +] + +[package.dependencies] +pyyaml = "*" + +[[package]] +name = "pyzmq" +version = "26.2.0" +description = "Python bindings for 0MQ" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyzmq-26.2.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:ddf33d97d2f52d89f6e6e7ae66ee35a4d9ca6f36eda89c24591b0c40205a3629"}, + {file = "pyzmq-26.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dacd995031a01d16eec825bf30802fceb2c3791ef24bcce48fa98ce40918c27b"}, + {file = "pyzmq-26.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89289a5ee32ef6c439086184529ae060c741334b8970a6855ec0b6ad3ff28764"}, + {file = "pyzmq-26.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5506f06d7dc6ecf1efacb4a013b1f05071bb24b76350832c96449f4a2d95091c"}, + {file = "pyzmq-26.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ea039387c10202ce304af74def5021e9adc6297067f3441d348d2b633e8166a"}, + {file = "pyzmq-26.2.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a2224fa4a4c2ee872886ed00a571f5e967c85e078e8e8c2530a2fb01b3309b88"}, + {file = "pyzmq-26.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:28ad5233e9c3b52d76196c696e362508959741e1a005fb8fa03b51aea156088f"}, + {file = "pyzmq-26.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1c17211bc037c7d88e85ed8b7d8f7e52db6dc8eca5590d162717c654550f7282"}, + {file = "pyzmq-26.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b8f86dd868d41bea9a5f873ee13bf5551c94cf6bc51baebc6f85075971fe6eea"}, + {file = "pyzmq-26.2.0-cp310-cp310-win32.whl", hash = "sha256:46a446c212e58456b23af260f3d9fb785054f3e3653dbf7279d8f2b5546b21c2"}, + {file = "pyzmq-26.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:49d34ab71db5a9c292a7644ce74190b1dd5a3475612eefb1f8be1d6961441971"}, + {file = "pyzmq-26.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:bfa832bfa540e5b5c27dcf5de5d82ebc431b82c453a43d141afb1e5d2de025fa"}, + {file = "pyzmq-26.2.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:8f7e66c7113c684c2b3f1c83cdd3376103ee0ce4c49ff80a648643e57fb22218"}, + {file = "pyzmq-26.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3a495b30fc91db2db25120df5847d9833af237546fd59170701acd816ccc01c4"}, + {file = "pyzmq-26.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77eb0968da535cba0470a5165468b2cac7772cfb569977cff92e240f57e31bef"}, + {file = "pyzmq-26.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ace4f71f1900a548f48407fc9be59c6ba9d9aaf658c2eea6cf2779e72f9f317"}, + {file = "pyzmq-26.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92a78853d7280bffb93df0a4a6a2498cba10ee793cc8076ef797ef2f74d107cf"}, + {file = "pyzmq-26.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:689c5d781014956a4a6de61d74ba97b23547e431e9e7d64f27d4922ba96e9d6e"}, + {file = "pyzmq-26.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0aca98bc423eb7d153214b2df397c6421ba6373d3397b26c057af3c904452e37"}, + {file = "pyzmq-26.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1f3496d76b89d9429a656293744ceca4d2ac2a10ae59b84c1da9b5165f429ad3"}, + {file = "pyzmq-26.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5c2b3bfd4b9689919db068ac6c9911f3fcb231c39f7dd30e3138be94896d18e6"}, + {file = "pyzmq-26.2.0-cp311-cp311-win32.whl", hash = "sha256:eac5174677da084abf378739dbf4ad245661635f1600edd1221f150b165343f4"}, + {file = "pyzmq-26.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:5a509df7d0a83a4b178d0f937ef14286659225ef4e8812e05580776c70e155d5"}, + {file = "pyzmq-26.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:c0e6091b157d48cbe37bd67233318dbb53e1e6327d6fc3bb284afd585d141003"}, + {file = "pyzmq-26.2.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:ded0fc7d90fe93ae0b18059930086c51e640cdd3baebdc783a695c77f123dcd9"}, + {file = "pyzmq-26.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:17bf5a931c7f6618023cdacc7081f3f266aecb68ca692adac015c383a134ca52"}, + {file = "pyzmq-26.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55cf66647e49d4621a7e20c8d13511ef1fe1efbbccf670811864452487007e08"}, + {file = "pyzmq-26.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4661c88db4a9e0f958c8abc2b97472e23061f0bc737f6f6179d7a27024e1faa5"}, + {file = "pyzmq-26.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea7f69de383cb47522c9c208aec6dd17697db7875a4674c4af3f8cfdac0bdeae"}, + {file = "pyzmq-26.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7f98f6dfa8b8ccaf39163ce872bddacca38f6a67289116c8937a02e30bbe9711"}, + {file = "pyzmq-26.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e3e0210287329272539eea617830a6a28161fbbd8a3271bf4150ae3e58c5d0e6"}, + {file = "pyzmq-26.2.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6b274e0762c33c7471f1a7471d1a2085b1a35eba5cdc48d2ae319f28b6fc4de3"}, + {file = "pyzmq-26.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:29c6a4635eef69d68a00321e12a7d2559fe2dfccfa8efae3ffb8e91cd0b36a8b"}, + {file = "pyzmq-26.2.0-cp312-cp312-win32.whl", hash = "sha256:989d842dc06dc59feea09e58c74ca3e1678c812a4a8a2a419046d711031f69c7"}, + {file = "pyzmq-26.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:2a50625acdc7801bc6f74698c5c583a491c61d73c6b7ea4dee3901bb99adb27a"}, + {file = "pyzmq-26.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:4d29ab8592b6ad12ebbf92ac2ed2bedcfd1cec192d8e559e2e099f648570e19b"}, + {file = "pyzmq-26.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9dd8cd1aeb00775f527ec60022004d030ddc51d783d056e3e23e74e623e33726"}, + {file = "pyzmq-26.2.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:28c812d9757fe8acecc910c9ac9dafd2ce968c00f9e619db09e9f8f54c3a68a3"}, + {file = "pyzmq-26.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d80b1dd99c1942f74ed608ddb38b181b87476c6a966a88a950c7dee118fdf50"}, + {file = "pyzmq-26.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c997098cc65e3208eca09303630e84d42718620e83b733d0fd69543a9cab9cb"}, + {file = "pyzmq-26.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ad1bc8d1b7a18497dda9600b12dc193c577beb391beae5cd2349184db40f187"}, + {file = "pyzmq-26.2.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:bea2acdd8ea4275e1278350ced63da0b166421928276c7c8e3f9729d7402a57b"}, + {file = "pyzmq-26.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:23f4aad749d13698f3f7b64aad34f5fc02d6f20f05999eebc96b89b01262fb18"}, + {file = "pyzmq-26.2.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a4f96f0d88accc3dbe4a9025f785ba830f968e21e3e2c6321ccdfc9aef755115"}, + {file = "pyzmq-26.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ced65e5a985398827cc9276b93ef6dfabe0273c23de8c7931339d7e141c2818e"}, + {file = "pyzmq-26.2.0-cp313-cp313-win32.whl", hash = "sha256:31507f7b47cc1ead1f6e86927f8ebb196a0bab043f6345ce070f412a59bf87b5"}, + {file = "pyzmq-26.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:70fc7fcf0410d16ebdda9b26cbd8bf8d803d220a7f3522e060a69a9c87bf7bad"}, + {file = "pyzmq-26.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:c3789bd5768ab5618ebf09cef6ec2b35fed88709b104351748a63045f0ff9797"}, + {file = "pyzmq-26.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:034da5fc55d9f8da09015d368f519478a52675e558c989bfcb5cf6d4e16a7d2a"}, + {file = "pyzmq-26.2.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:c92d73464b886931308ccc45b2744e5968cbaade0b1d6aeb40d8ab537765f5bc"}, + {file = "pyzmq-26.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:794a4562dcb374f7dbbfb3f51d28fb40123b5a2abadee7b4091f93054909add5"}, + {file = "pyzmq-26.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aee22939bb6075e7afededabad1a56a905da0b3c4e3e0c45e75810ebe3a52672"}, + {file = "pyzmq-26.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ae90ff9dad33a1cfe947d2c40cb9cb5e600d759ac4f0fd22616ce6540f72797"}, + {file = "pyzmq-26.2.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:43a47408ac52647dfabbc66a25b05b6a61700b5165807e3fbd40063fcaf46386"}, + {file = "pyzmq-26.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:25bf2374a2a8433633c65ccb9553350d5e17e60c8eb4de4d92cc6bd60f01d306"}, + {file = "pyzmq-26.2.0-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:007137c9ac9ad5ea21e6ad97d3489af654381324d5d3ba614c323f60dab8fae6"}, + {file = "pyzmq-26.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:470d4a4f6d48fb34e92d768b4e8a5cc3780db0d69107abf1cd7ff734b9766eb0"}, + {file = "pyzmq-26.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3b55a4229ce5da9497dd0452b914556ae58e96a4381bb6f59f1305dfd7e53fc8"}, + {file = "pyzmq-26.2.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9cb3a6460cdea8fe8194a76de8895707e61ded10ad0be97188cc8463ffa7e3a8"}, + {file = "pyzmq-26.2.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8ab5cad923cc95c87bffee098a27856c859bd5d0af31bd346035aa816b081fe1"}, + {file = "pyzmq-26.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ed69074a610fad1c2fda66180e7b2edd4d31c53f2d1872bc2d1211563904cd9"}, + {file = "pyzmq-26.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:cccba051221b916a4f5e538997c45d7d136a5646442b1231b916d0164067ea27"}, + {file = "pyzmq-26.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:0eaa83fc4c1e271c24eaf8fb083cbccef8fde77ec8cd45f3c35a9a123e6da097"}, + {file = "pyzmq-26.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:9edda2df81daa129b25a39b86cb57dfdfe16f7ec15b42b19bfac503360d27a93"}, + {file = "pyzmq-26.2.0-cp37-cp37m-win32.whl", hash = "sha256:ea0eb6af8a17fa272f7b98d7bebfab7836a0d62738e16ba380f440fceca2d951"}, + {file = "pyzmq-26.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4ff9dc6bc1664bb9eec25cd17506ef6672d506115095411e237d571e92a58231"}, + {file = "pyzmq-26.2.0-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:2eb7735ee73ca1b0d71e0e67c3739c689067f055c764f73aac4cc8ecf958ee3f"}, + {file = "pyzmq-26.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a534f43bc738181aa7cbbaf48e3eca62c76453a40a746ab95d4b27b1111a7d2"}, + {file = "pyzmq-26.2.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:aedd5dd8692635813368e558a05266b995d3d020b23e49581ddd5bbe197a8ab6"}, + {file = "pyzmq-26.2.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8be4700cd8bb02cc454f630dcdf7cfa99de96788b80c51b60fe2fe1dac480289"}, + {file = "pyzmq-26.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fcc03fa4997c447dce58264e93b5aa2d57714fbe0f06c07b7785ae131512732"}, + {file = "pyzmq-26.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:402b190912935d3db15b03e8f7485812db350d271b284ded2b80d2e5704be780"}, + {file = "pyzmq-26.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8685fa9c25ff00f550c1fec650430c4b71e4e48e8d852f7ddcf2e48308038640"}, + {file = "pyzmq-26.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:76589c020680778f06b7e0b193f4b6dd66d470234a16e1df90329f5e14a171cd"}, + {file = "pyzmq-26.2.0-cp38-cp38-win32.whl", hash = "sha256:8423c1877d72c041f2c263b1ec6e34360448decfb323fa8b94e85883043ef988"}, + {file = "pyzmq-26.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:76589f2cd6b77b5bdea4fca5992dc1c23389d68b18ccc26a53680ba2dc80ff2f"}, + {file = "pyzmq-26.2.0-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:b1d464cb8d72bfc1a3adc53305a63a8e0cac6bc8c5a07e8ca190ab8d3faa43c2"}, + {file = "pyzmq-26.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4da04c48873a6abdd71811c5e163bd656ee1b957971db7f35140a2d573f6949c"}, + {file = "pyzmq-26.2.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d049df610ac811dcffdc147153b414147428567fbbc8be43bb8885f04db39d98"}, + {file = "pyzmq-26.2.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:05590cdbc6b902101d0e65d6a4780af14dc22914cc6ab995d99b85af45362cc9"}, + {file = "pyzmq-26.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c811cfcd6a9bf680236c40c6f617187515269ab2912f3d7e8c0174898e2519db"}, + {file = "pyzmq-26.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6835dd60355593de10350394242b5757fbbd88b25287314316f266e24c61d073"}, + {file = "pyzmq-26.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc6bee759a6bddea5db78d7dcd609397449cb2d2d6587f48f3ca613b19410cfc"}, + {file = "pyzmq-26.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c530e1eecd036ecc83c3407f77bb86feb79916d4a33d11394b8234f3bd35b940"}, + {file = "pyzmq-26.2.0-cp39-cp39-win32.whl", hash = "sha256:367b4f689786fca726ef7a6c5ba606958b145b9340a5e4808132cc65759abd44"}, + {file = "pyzmq-26.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:e6fa2e3e683f34aea77de8112f6483803c96a44fd726d7358b9888ae5bb394ec"}, + {file = "pyzmq-26.2.0-cp39-cp39-win_arm64.whl", hash = "sha256:7445be39143a8aa4faec43b076e06944b8f9d0701b669df4af200531b21e40bb"}, + {file = "pyzmq-26.2.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:706e794564bec25819d21a41c31d4df2d48e1cc4b061e8d345d7fb4dd3e94072"}, + {file = "pyzmq-26.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b435f2753621cd36e7c1762156815e21c985c72b19135dac43a7f4f31d28dd1"}, + {file = "pyzmq-26.2.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:160c7e0a5eb178011e72892f99f918c04a131f36056d10d9c1afb223fc952c2d"}, + {file = "pyzmq-26.2.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c4a71d5d6e7b28a47a394c0471b7e77a0661e2d651e7ae91e0cab0a587859ca"}, + {file = "pyzmq-26.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:90412f2db8c02a3864cbfc67db0e3dcdbda336acf1c469526d3e869394fe001c"}, + {file = "pyzmq-26.2.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2ea4ad4e6a12e454de05f2949d4beddb52460f3de7c8b9d5c46fbb7d7222e02c"}, + {file = "pyzmq-26.2.0-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:fc4f7a173a5609631bb0c42c23d12c49df3966f89f496a51d3eb0ec81f4519d6"}, + {file = "pyzmq-26.2.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:878206a45202247781472a2d99df12a176fef806ca175799e1c6ad263510d57c"}, + {file = "pyzmq-26.2.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17c412bad2eb9468e876f556eb4ee910e62d721d2c7a53c7fa31e643d35352e6"}, + {file = "pyzmq-26.2.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:0d987a3ae5a71c6226b203cfd298720e0086c7fe7c74f35fa8edddfbd6597eed"}, + {file = "pyzmq-26.2.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:39887ac397ff35b7b775db7201095fc6310a35fdbae85bac4523f7eb3b840e20"}, + {file = "pyzmq-26.2.0-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:fdb5b3e311d4d4b0eb8b3e8b4d1b0a512713ad7e6a68791d0923d1aec433d919"}, + {file = "pyzmq-26.2.0-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:226af7dcb51fdb0109f0016449b357e182ea0ceb6b47dfb5999d569e5db161d5"}, + {file = "pyzmq-26.2.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bed0e799e6120b9c32756203fb9dfe8ca2fb8467fed830c34c877e25638c3fc"}, + {file = "pyzmq-26.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:29c7947c594e105cb9e6c466bace8532dc1ca02d498684128b339799f5248277"}, + {file = "pyzmq-26.2.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cdeabcff45d1c219636ee2e54d852262e5c2e085d6cb476d938aee8d921356b3"}, + {file = "pyzmq-26.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35cffef589bcdc587d06f9149f8d5e9e8859920a071df5a2671de2213bef592a"}, + {file = "pyzmq-26.2.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18c8dc3b7468d8b4bdf60ce9d7141897da103c7a4690157b32b60acb45e333e6"}, + {file = "pyzmq-26.2.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7133d0a1677aec369d67dd78520d3fa96dd7f3dcec99d66c1762870e5ea1a50a"}, + {file = "pyzmq-26.2.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6a96179a24b14fa6428cbfc08641c779a53f8fcec43644030328f44034c7f1f4"}, + {file = "pyzmq-26.2.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:4f78c88905461a9203eac9faac157a2a0dbba84a0fd09fd29315db27be40af9f"}, + {file = "pyzmq-26.2.0.tar.gz", hash = "sha256:070672c258581c8e4f640b5159297580a9974b026043bd4ab0470be9ed324f1f"}, +] + +[package.dependencies] +cffi = {version = "*", markers = "implementation_name == \"pypy\""} + +[[package]] +name = "referencing" +version = "0.35.1" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de"}, + {file = "referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" + +[[package]] +name = "regex" +version = "2024.9.11" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.8" +files = [ + {file = "regex-2024.9.11-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1494fa8725c285a81d01dc8c06b55287a1ee5e0e382d8413adc0a9197aac6408"}, + {file = "regex-2024.9.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0e12c481ad92d129c78f13a2a3662317e46ee7ef96c94fd332e1c29131875b7d"}, + {file = "regex-2024.9.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16e13a7929791ac1216afde26f712802e3df7bf0360b32e4914dca3ab8baeea5"}, + {file = "regex-2024.9.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46989629904bad940bbec2106528140a218b4a36bb3042d8406980be1941429c"}, + {file = "regex-2024.9.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a906ed5e47a0ce5f04b2c981af1c9acf9e8696066900bf03b9d7879a6f679fc8"}, + {file = "regex-2024.9.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9a091b0550b3b0207784a7d6d0f1a00d1d1c8a11699c1a4d93db3fbefc3ad35"}, + {file = "regex-2024.9.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ddcd9a179c0a6fa8add279a4444015acddcd7f232a49071ae57fa6e278f1f71"}, + {file = "regex-2024.9.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6b41e1adc61fa347662b09398e31ad446afadff932a24807d3ceb955ed865cc8"}, + {file = "regex-2024.9.11-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ced479f601cd2f8ca1fd7b23925a7e0ad512a56d6e9476f79b8f381d9d37090a"}, + {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:635a1d96665f84b292e401c3d62775851aedc31d4f8784117b3c68c4fcd4118d"}, + {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c0256beda696edcf7d97ef16b2a33a8e5a875affd6fa6567b54f7c577b30a137"}, + {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:3ce4f1185db3fbde8ed8aa223fc9620f276c58de8b0d4f8cc86fd1360829edb6"}, + {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:09d77559e80dcc9d24570da3745ab859a9cf91953062e4ab126ba9d5993688ca"}, + {file = "regex-2024.9.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7a22ccefd4db3f12b526eccb129390942fe874a3a9fdbdd24cf55773a1faab1a"}, + {file = "regex-2024.9.11-cp310-cp310-win32.whl", hash = "sha256:f745ec09bc1b0bd15cfc73df6fa4f726dcc26bb16c23a03f9e3367d357eeedd0"}, + {file = "regex-2024.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:01c2acb51f8a7d6494c8c5eafe3d8e06d76563d8a8a4643b37e9b2dd8a2ff623"}, + {file = "regex-2024.9.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2cce2449e5927a0bf084d346da6cd5eb016b2beca10d0013ab50e3c226ffc0df"}, + {file = "regex-2024.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b37fa423beefa44919e009745ccbf353d8c981516e807995b2bd11c2c77d268"}, + {file = "regex-2024.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:64ce2799bd75039b480cc0360907c4fb2f50022f030bf9e7a8705b636e408fad"}, + {file = "regex-2024.9.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4cc92bb6db56ab0c1cbd17294e14f5e9224f0cc6521167ef388332604e92679"}, + {file = "regex-2024.9.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d05ac6fa06959c4172eccd99a222e1fbf17b5670c4d596cb1e5cde99600674c4"}, + {file = "regex-2024.9.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040562757795eeea356394a7fb13076ad4f99d3c62ab0f8bdfb21f99a1f85664"}, + {file = "regex-2024.9.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6113c008a7780792efc80f9dfe10ba0cd043cbf8dc9a76ef757850f51b4edc50"}, + {file = "regex-2024.9.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e5fb5f77c8745a60105403a774fe2c1759b71d3e7b4ca237a5e67ad066c7199"}, + {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:54d9ff35d4515debf14bc27f1e3b38bfc453eff3220f5bce159642fa762fe5d4"}, + {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df5cbb1fbc74a8305b6065d4ade43b993be03dbe0f8b30032cced0d7740994bd"}, + {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7fb89ee5d106e4a7a51bce305ac4efb981536301895f7bdcf93ec92ae0d91c7f"}, + {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a738b937d512b30bf75995c0159c0ddf9eec0775c9d72ac0202076c72f24aa96"}, + {file = "regex-2024.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e28f9faeb14b6f23ac55bfbbfd3643f5c7c18ede093977f1df249f73fd22c7b1"}, + {file = "regex-2024.9.11-cp311-cp311-win32.whl", hash = "sha256:18e707ce6c92d7282dfce370cd205098384b8ee21544e7cb29b8aab955b66fa9"}, + {file = "regex-2024.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:313ea15e5ff2a8cbbad96ccef6be638393041b0a7863183c2d31e0c6116688cf"}, + {file = "regex-2024.9.11-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b0d0a6c64fcc4ef9c69bd5b3b3626cc3776520a1637d8abaa62b9edc147a58f7"}, + {file = "regex-2024.9.11-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:49b0e06786ea663f933f3710a51e9385ce0cba0ea56b67107fd841a55d56a231"}, + {file = "regex-2024.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5b513b6997a0b2f10e4fd3a1313568e373926e8c252bd76c960f96fd039cd28d"}, + {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee439691d8c23e76f9802c42a95cfeebf9d47cf4ffd06f18489122dbb0a7ad64"}, + {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8f877c89719d759e52783f7fe6e1c67121076b87b40542966c02de5503ace42"}, + {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23b30c62d0f16827f2ae9f2bb87619bc4fba2044911e2e6c2eb1af0161cdb766"}, + {file = "regex-2024.9.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85ab7824093d8f10d44330fe1e6493f756f252d145323dd17ab6b48733ff6c0a"}, + {file = "regex-2024.9.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8dee5b4810a89447151999428fe096977346cf2f29f4d5e29609d2e19e0199c9"}, + {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98eeee2f2e63edae2181c886d7911ce502e1292794f4c5ee71e60e23e8d26b5d"}, + {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:57fdd2e0b2694ce6fc2e5ccf189789c3e2962916fb38779d3e3521ff8fe7a822"}, + {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d552c78411f60b1fdaafd117a1fca2f02e562e309223b9d44b7de8be451ec5e0"}, + {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a0b2b80321c2ed3fcf0385ec9e51a12253c50f146fddb2abbb10f033fe3d049a"}, + {file = "regex-2024.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:18406efb2f5a0e57e3a5881cd9354c1512d3bb4f5c45d96d110a66114d84d23a"}, + {file = "regex-2024.9.11-cp312-cp312-win32.whl", hash = "sha256:e464b467f1588e2c42d26814231edecbcfe77f5ac414d92cbf4e7b55b2c2a776"}, + {file = "regex-2024.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:9e8719792ca63c6b8340380352c24dcb8cd7ec49dae36e963742a275dfae6009"}, + {file = "regex-2024.9.11-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c157bb447303070f256e084668b702073db99bbb61d44f85d811025fcf38f784"}, + {file = "regex-2024.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4db21ece84dfeefc5d8a3863f101995de646c6cb0536952c321a2650aa202c36"}, + {file = "regex-2024.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:220e92a30b426daf23bb67a7962900ed4613589bab80382be09b48896d211e92"}, + {file = "regex-2024.9.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb1ae19e64c14c7ec1995f40bd932448713d3c73509e82d8cd7744dc00e29e86"}, + {file = "regex-2024.9.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f47cd43a5bfa48f86925fe26fbdd0a488ff15b62468abb5d2a1e092a4fb10e85"}, + {file = "regex-2024.9.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9d4a76b96f398697fe01117093613166e6aa8195d63f1b4ec3f21ab637632963"}, + {file = "regex-2024.9.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ea51dcc0835eea2ea31d66456210a4e01a076d820e9039b04ae8d17ac11dee6"}, + {file = "regex-2024.9.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7aaa315101c6567a9a45d2839322c51c8d6e81f67683d529512f5bcfb99c802"}, + {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c57d08ad67aba97af57a7263c2d9006d5c404d721c5f7542f077f109ec2a4a29"}, + {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f8404bf61298bb6f8224bb9176c1424548ee1181130818fcd2cbffddc768bed8"}, + {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dd4490a33eb909ef5078ab20f5f000087afa2a4daa27b4c072ccb3cb3050ad84"}, + {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:eee9130eaad130649fd73e5cd92f60e55708952260ede70da64de420cdcad554"}, + {file = "regex-2024.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a2644a93da36c784e546de579ec1806bfd2763ef47babc1b03d765fe560c9f8"}, + {file = "regex-2024.9.11-cp313-cp313-win32.whl", hash = "sha256:e997fd30430c57138adc06bba4c7c2968fb13d101e57dd5bb9355bf8ce3fa7e8"}, + {file = "regex-2024.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:042c55879cfeb21a8adacc84ea347721d3d83a159da6acdf1116859e2427c43f"}, + {file = "regex-2024.9.11-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:35f4a6f96aa6cb3f2f7247027b07b15a374f0d5b912c0001418d1d55024d5cb4"}, + {file = "regex-2024.9.11-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:55b96e7ce3a69a8449a66984c268062fbaa0d8ae437b285428e12797baefce7e"}, + {file = "regex-2024.9.11-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cb130fccd1a37ed894824b8c046321540263013da72745d755f2d35114b81a60"}, + {file = "regex-2024.9.11-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:323c1f04be6b2968944d730e5c2091c8c89767903ecaa135203eec4565ed2b2b"}, + {file = "regex-2024.9.11-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be1c8ed48c4c4065ecb19d882a0ce1afe0745dfad8ce48c49586b90a55f02366"}, + {file = "regex-2024.9.11-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5b029322e6e7b94fff16cd120ab35a253236a5f99a79fb04fda7ae71ca20ae8"}, + {file = "regex-2024.9.11-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6fff13ef6b5f29221d6904aa816c34701462956aa72a77f1f151a8ec4f56aeb"}, + {file = "regex-2024.9.11-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:587d4af3979376652010e400accc30404e6c16b7df574048ab1f581af82065e4"}, + {file = "regex-2024.9.11-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:079400a8269544b955ffa9e31f186f01d96829110a3bf79dc338e9910f794fca"}, + {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f9268774428ec173654985ce55fc6caf4c6d11ade0f6f914d48ef4719eb05ebb"}, + {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:23f9985c8784e544d53fc2930fc1ac1a7319f5d5332d228437acc9f418f2f168"}, + {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:ae2941333154baff9838e88aa71c1d84f4438189ecc6021a12c7573728b5838e"}, + {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e93f1c331ca8e86fe877a48ad64e77882c0c4da0097f2212873a69bbfea95d0c"}, + {file = "regex-2024.9.11-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:846bc79ee753acf93aef4184c040d709940c9d001029ceb7b7a52747b80ed2dd"}, + {file = "regex-2024.9.11-cp38-cp38-win32.whl", hash = "sha256:c94bb0a9f1db10a1d16c00880bdebd5f9faf267273b8f5bd1878126e0fbde771"}, + {file = "regex-2024.9.11-cp38-cp38-win_amd64.whl", hash = "sha256:2b08fce89fbd45664d3df6ad93e554b6c16933ffa9d55cb7e01182baaf971508"}, + {file = "regex-2024.9.11-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:07f45f287469039ffc2c53caf6803cd506eb5f5f637f1d4acb37a738f71dd066"}, + {file = "regex-2024.9.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4838e24ee015101d9f901988001038f7f0d90dc0c3b115541a1365fb439add62"}, + {file = "regex-2024.9.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6edd623bae6a737f10ce853ea076f56f507fd7726bee96a41ee3d68d347e4d16"}, + {file = "regex-2024.9.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c69ada171c2d0e97a4b5aa78fbb835e0ffbb6b13fc5da968c09811346564f0d3"}, + {file = "regex-2024.9.11-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02087ea0a03b4af1ed6ebab2c54d7118127fee8d71b26398e8e4b05b78963199"}, + {file = "regex-2024.9.11-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69dee6a020693d12a3cf892aba4808fe168d2a4cef368eb9bf74f5398bfd4ee8"}, + {file = "regex-2024.9.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:297f54910247508e6e5cae669f2bc308985c60540a4edd1c77203ef19bfa63ca"}, + {file = "regex-2024.9.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecea58b43a67b1b79805f1a0255730edaf5191ecef84dbc4cc85eb30bc8b63b9"}, + {file = "regex-2024.9.11-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:eab4bb380f15e189d1313195b062a6aa908f5bd687a0ceccd47c8211e9cf0d4a"}, + {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0cbff728659ce4bbf4c30b2a1be040faafaa9eca6ecde40aaff86f7889f4ab39"}, + {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:54c4a097b8bc5bb0dfc83ae498061d53ad7b5762e00f4adaa23bee22b012e6ba"}, + {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:73d6d2f64f4d894c96626a75578b0bf7d9e56dcda8c3d037a2118fdfe9b1c664"}, + {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:e53b5fbab5d675aec9f0c501274c467c0f9a5d23696cfc94247e1fb56501ed89"}, + {file = "regex-2024.9.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0ffbcf9221e04502fc35e54d1ce9567541979c3fdfb93d2c554f0ca583a19b35"}, + {file = "regex-2024.9.11-cp39-cp39-win32.whl", hash = "sha256:e4c22e1ac1f1ec1e09f72e6c44d8f2244173db7eb9629cc3a346a8d7ccc31142"}, + {file = "regex-2024.9.11-cp39-cp39-win_amd64.whl", hash = "sha256:faa3c142464efec496967359ca99696c896c591c56c53506bac1ad465f66e919"}, + {file = "regex-2024.9.11.tar.gz", hash = "sha256:6c188c307e8433bcb63dc1915022deb553b4203a70722fc542c363bf120a01fd"}, +] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rpds-py" +version = "0.20.0" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "rpds_py-0.20.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3ad0fda1635f8439cde85c700f964b23ed5fc2d28016b32b9ee5fe30da5c84e2"}, + {file = "rpds_py-0.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9bb4a0d90fdb03437c109a17eade42dfbf6190408f29b2744114d11586611d6f"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6377e647bbfd0a0b159fe557f2c6c602c159fc752fa316572f012fc0bf67150"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb851b7df9dda52dc1415ebee12362047ce771fc36914586b2e9fcbd7d293b3e"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e0f80b739e5a8f54837be5d5c924483996b603d5502bfff79bf33da06164ee2"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a8c94dad2e45324fc74dce25e1645d4d14df9a4e54a30fa0ae8bad9a63928e3"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8e604fe73ba048c06085beaf51147eaec7df856824bfe7b98657cf436623daf"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:df3de6b7726b52966edf29663e57306b23ef775faf0ac01a3e9f4012a24a4140"}, + {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf258ede5bc22a45c8e726b29835b9303c285ab46fc7c3a4cc770736b5304c9f"}, + {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:55fea87029cded5df854ca7e192ec7bdb7ecd1d9a3f63d5c4eb09148acf4a7ce"}, + {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ae94bd0b2f02c28e199e9bc51485d0c5601f58780636185660f86bf80c89af94"}, + {file = "rpds_py-0.20.0-cp310-none-win32.whl", hash = "sha256:28527c685f237c05445efec62426d285e47a58fb05ba0090a4340b73ecda6dee"}, + {file = "rpds_py-0.20.0-cp310-none-win_amd64.whl", hash = "sha256:238a2d5b1cad28cdc6ed15faf93a998336eb041c4e440dd7f902528b8891b399"}, + {file = "rpds_py-0.20.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac2f4f7a98934c2ed6505aead07b979e6f999389f16b714448fb39bbaa86a489"}, + {file = "rpds_py-0.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:220002c1b846db9afd83371d08d239fdc865e8f8c5795bbaec20916a76db3318"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d7919548df3f25374a1f5d01fbcd38dacab338ef5f33e044744b5c36729c8db"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:758406267907b3781beee0f0edfe4a179fbd97c0be2e9b1154d7f0a1279cf8e5"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3d61339e9f84a3f0767b1995adfb171a0d00a1185192718a17af6e124728e0f5"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1259c7b3705ac0a0bd38197565a5d603218591d3f6cee6e614e380b6ba61c6f6"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c1dc0f53856b9cc9a0ccca0a7cc61d3d20a7088201c0937f3f4048c1718a209"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7e60cb630f674a31f0368ed32b2a6b4331b8350d67de53c0359992444b116dd3"}, + {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dbe982f38565bb50cb7fb061ebf762c2f254ca3d8c20d4006878766e84266272"}, + {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:514b3293b64187172bc77c8fb0cdae26981618021053b30d8371c3a902d4d5ad"}, + {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0a26ffe9d4dd35e4dfdd1e71f46401cff0181c75ac174711ccff0459135fa58"}, + {file = "rpds_py-0.20.0-cp311-none-win32.whl", hash = "sha256:89c19a494bf3ad08c1da49445cc5d13d8fefc265f48ee7e7556839acdacf69d0"}, + {file = "rpds_py-0.20.0-cp311-none-win_amd64.whl", hash = "sha256:c638144ce971df84650d3ed0096e2ae7af8e62ecbbb7b201c8935c370df00a2c"}, + {file = "rpds_py-0.20.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a84ab91cbe7aab97f7446652d0ed37d35b68a465aeef8fc41932a9d7eee2c1a6"}, + {file = "rpds_py-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:56e27147a5a4c2c21633ff8475d185734c0e4befd1c989b5b95a5d0db699b21b"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2580b0c34583b85efec8c5c5ec9edf2dfe817330cc882ee972ae650e7b5ef739"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b80d4a7900cf6b66bb9cee5c352b2d708e29e5a37fe9bf784fa97fc11504bf6c"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50eccbf054e62a7b2209b28dc7a22d6254860209d6753e6b78cfaeb0075d7bee"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:49a8063ea4296b3a7e81a5dfb8f7b2d73f0b1c20c2af401fb0cdf22e14711a96"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea438162a9fcbee3ecf36c23e6c68237479f89f962f82dae83dc15feeceb37e4"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18d7585c463087bddcfa74c2ba267339f14f2515158ac4db30b1f9cbdb62c8ef"}, + {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d4c7d1a051eeb39f5c9547e82ea27cbcc28338482242e3e0b7768033cb083821"}, + {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4df1e3b3bec320790f699890d41c59d250f6beda159ea3c44c3f5bac1976940"}, + {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2cf126d33a91ee6eedc7f3197b53e87a2acdac63602c0f03a02dd69e4b138174"}, + {file = "rpds_py-0.20.0-cp312-none-win32.whl", hash = "sha256:8bc7690f7caee50b04a79bf017a8d020c1f48c2a1077ffe172abec59870f1139"}, + {file = "rpds_py-0.20.0-cp312-none-win_amd64.whl", hash = "sha256:0e13e6952ef264c40587d510ad676a988df19adea20444c2b295e536457bc585"}, + {file = "rpds_py-0.20.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:aa9a0521aeca7d4941499a73ad7d4f8ffa3d1affc50b9ea11d992cd7eff18a29"}, + {file = "rpds_py-0.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a1f1d51eccb7e6c32ae89243cb352389228ea62f89cd80823ea7dd1b98e0b91"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a86a9b96070674fc88b6f9f71a97d2c1d3e5165574615d1f9168ecba4cecb24"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c8ef2ebf76df43f5750b46851ed1cdf8f109d7787ca40035fe19fbdc1acc5a7"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b74b25f024b421d5859d156750ea9a65651793d51b76a2e9238c05c9d5f203a9"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57eb94a8c16ab08fef6404301c38318e2c5a32216bf5de453e2714c964c125c8"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1940dae14e715e2e02dfd5b0f64a52e8374a517a1e531ad9412319dc3ac7879"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d20277fd62e1b992a50c43f13fbe13277a31f8c9f70d59759c88f644d66c619f"}, + {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:06db23d43f26478303e954c34c75182356ca9aa7797d22c5345b16871ab9c45c"}, + {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2a5db5397d82fa847e4c624b0c98fe59d2d9b7cf0ce6de09e4d2e80f8f5b3f2"}, + {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a35df9f5548fd79cb2f52d27182108c3e6641a4feb0f39067911bf2adaa3e57"}, + {file = "rpds_py-0.20.0-cp313-none-win32.whl", hash = "sha256:fd2d84f40633bc475ef2d5490b9c19543fbf18596dcb1b291e3a12ea5d722f7a"}, + {file = "rpds_py-0.20.0-cp313-none-win_amd64.whl", hash = "sha256:9bc2d153989e3216b0559251b0c260cfd168ec78b1fac33dd485750a228db5a2"}, + {file = "rpds_py-0.20.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:f2fbf7db2012d4876fb0d66b5b9ba6591197b0f165db8d99371d976546472a24"}, + {file = "rpds_py-0.20.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1e5f3cd7397c8f86c8cc72d5a791071431c108edd79872cdd96e00abd8497d29"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce9845054c13696f7af7f2b353e6b4f676dab1b4b215d7fe5e05c6f8bb06f965"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c3e130fd0ec56cb76eb49ef52faead8ff09d13f4527e9b0c400307ff72b408e1"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b16aa0107ecb512b568244ef461f27697164d9a68d8b35090e9b0c1c8b27752"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa7f429242aae2947246587d2964fad750b79e8c233a2367f71b554e9447949c"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af0fc424a5842a11e28956e69395fbbeab2c97c42253169d87e90aac2886d751"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b8c00a3b1e70c1d3891f0db1b05292747f0dbcfb49c43f9244d04c70fbc40eb8"}, + {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:40ce74fc86ee4645d0a225498d091d8bc61f39b709ebef8204cb8b5a464d3c0e"}, + {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:4fe84294c7019456e56d93e8ababdad5a329cd25975be749c3f5f558abb48253"}, + {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:338ca4539aad4ce70a656e5187a3a31c5204f261aef9f6ab50e50bcdffaf050a"}, + {file = "rpds_py-0.20.0-cp38-none-win32.whl", hash = "sha256:54b43a2b07db18314669092bb2de584524d1ef414588780261e31e85846c26a5"}, + {file = "rpds_py-0.20.0-cp38-none-win_amd64.whl", hash = "sha256:a1862d2d7ce1674cffa6d186d53ca95c6e17ed2b06b3f4c476173565c862d232"}, + {file = "rpds_py-0.20.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:3fde368e9140312b6e8b6c09fb9f8c8c2f00999d1823403ae90cc00480221b22"}, + {file = "rpds_py-0.20.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9824fb430c9cf9af743cf7aaf6707bf14323fb51ee74425c380f4c846ea70789"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11ef6ce74616342888b69878d45e9f779b95d4bd48b382a229fe624a409b72c5"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c52d3f2f82b763a24ef52f5d24358553e8403ce05f893b5347098014f2d9eff2"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d35cef91e59ebbeaa45214861874bc6f19eb35de96db73e467a8358d701a96c"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d72278a30111e5b5525c1dd96120d9e958464316f55adb030433ea905866f4de"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4c29cbbba378759ac5786730d1c3cb4ec6f8ababf5c42a9ce303dc4b3d08cda"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6632f2d04f15d1bd6fe0eedd3b86d9061b836ddca4c03d5cf5c7e9e6b7c14580"}, + {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d0b67d87bb45ed1cd020e8fbf2307d449b68abc45402fe1a4ac9e46c3c8b192b"}, + {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ec31a99ca63bf3cd7f1a5ac9fe95c5e2d060d3c768a09bc1d16e235840861420"}, + {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22e6c9976e38f4d8c4a63bd8a8edac5307dffd3ee7e6026d97f3cc3a2dc02a0b"}, + {file = "rpds_py-0.20.0-cp39-none-win32.whl", hash = "sha256:569b3ea770c2717b730b61998b6c54996adee3cef69fc28d444f3e7920313cf7"}, + {file = "rpds_py-0.20.0-cp39-none-win_amd64.whl", hash = "sha256:e6900ecdd50ce0facf703f7a00df12374b74bbc8ad9fe0f6559947fb20f82364"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:617c7357272c67696fd052811e352ac54ed1d9b49ab370261a80d3b6ce385045"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9426133526f69fcaba6e42146b4e12d6bc6c839b8b555097020e2b78ce908dcc"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deb62214c42a261cb3eb04d474f7155279c1a8a8c30ac89b7dcb1721d92c3c02"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcaeb7b57f1a1e071ebd748984359fef83ecb026325b9d4ca847c95bc7311c92"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d454b8749b4bd70dd0a79f428731ee263fa6995f83ccb8bada706e8d1d3ff89d"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d807dc2051abe041b6649681dce568f8e10668e3c1c6543ebae58f2d7e617855"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3c20f0ddeb6e29126d45f89206b8291352b8c5b44384e78a6499d68b52ae511"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7f19250ceef892adf27f0399b9e5afad019288e9be756d6919cb58892129f51"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:4f1ed4749a08379555cebf4650453f14452eaa9c43d0a95c49db50c18b7da075"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:dcedf0b42bcb4cfff4101d7771a10532415a6106062f005ab97d1d0ab5681c60"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:39ed0d010457a78f54090fafb5d108501b5aa5604cc22408fc1c0c77eac14344"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:bb273176be34a746bdac0b0d7e4e2c467323d13640b736c4c477881a3220a989"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f918a1a130a6dfe1d7fe0f105064141342e7dd1611f2e6a21cd2f5c8cb1cfb3e"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f60012a73aa396be721558caa3a6fd49b3dd0033d1675c6d59c4502e870fcf0c"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d2b1ad682a3dfda2a4e8ad8572f3100f95fad98cb99faf37ff0ddfe9cbf9d03"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:614fdafe9f5f19c63ea02817fa4861c606a59a604a77c8cdef5aa01d28b97921"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa518bcd7600c584bf42e6617ee8132869e877db2f76bcdc281ec6a4113a53ab"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0475242f447cc6cb8a9dd486d68b2ef7fbee84427124c232bff5f63b1fe11e5"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f90a4cd061914a60bd51c68bcb4357086991bd0bb93d8aa66a6da7701370708f"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:def7400461c3a3f26e49078302e1c1b38f6752342c77e3cf72ce91ca69fb1bc1"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:65794e4048ee837494aea3c21a28ad5fc080994dfba5b036cf84de37f7ad5074"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:faefcc78f53a88f3076b7f8be0a8f8d35133a3ecf7f3770895c25f8813460f08"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:5b4f105deeffa28bbcdff6c49b34e74903139afa690e35d2d9e3c2c2fba18cec"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fdfc3a892927458d98f3d55428ae46b921d1f7543b89382fdb483f5640daaec8"}, + {file = "rpds_py-0.20.0.tar.gz", hash = "sha256:d72a210824facfdaf8768cf2d7ca25a042c30320b3020de2fa04640920d4e121"}, +] + +[[package]] +name = "setuptools" +version = "75.1.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-75.1.0-py3-none-any.whl", hash = "sha256:35ab7fd3bcd95e6b7fd704e4a1539513edad446c097797f2985e0e4b960772f2"}, + {file = "setuptools-75.1.0.tar.gz", hash = "sha256:d59a21b17a275fb872a9c3dae73963160ae079f1049ed956880cd7c09b120538"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] + +[[package]] +name = "shtab" +version = "1.7.1" +description = "Automagic shell tab completion for Python CLI applications" +optional = false +python-versions = ">=3.7" +files = [ + {file = "shtab-1.7.1-py3-none-any.whl", hash = "sha256:32d3d2ff9022d4c77a62492b6ec875527883891e33c6b479ba4d41a51e259983"}, + {file = "shtab-1.7.1.tar.gz", hash = "sha256:4e4bcb02eeb82ec45920a5d0add92eac9c9b63b2804c9196c1f1fdc2d039243c"}, +] + +[package.extras] +dev = ["pytest (>=6)", "pytest-cov", "pytest-timeout"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "smmap" +version = "5.0.1" +description = "A pure Python implementation of a sliding window memory map manager" +optional = false +python-versions = ">=3.7" +files = [ + {file = "smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da"}, + {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, +] + +[[package]] +name = "soupsieve" +version = "2.6" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.8" +files = [ + {file = "soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"}, + {file = "soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb"}, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +description = "Extract data from python stack frames and tracebacks for informative displays" +optional = false +python-versions = "*" +files = [ + {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, + {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + +[[package]] +name = "termcolor" +version = "2.5.0" +description = "ANSI color formatting for output in terminal" +optional = false +python-versions = ">=3.9" +files = [ + {file = "termcolor-2.5.0-py3-none-any.whl", hash = "sha256:37b17b5fc1e604945c2642c872a3764b5d547a48009871aea3edd3afa180afb8"}, + {file = "termcolor-2.5.0.tar.gz", hash = "sha256:998d8d27da6d48442e8e1f016119076b690d962507531df4890fcd2db2ef8a6f"}, +] + +[package.extras] +tests = ["pytest", "pytest-cov"] + +[[package]] +name = "tinycss2" +version = "1.3.0" +description = "A tiny CSS parser" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tinycss2-1.3.0-py3-none-any.whl", hash = "sha256:54a8dbdffb334d536851be0226030e9505965bb2f30f21a4a82c55fb2a80fae7"}, + {file = "tinycss2-1.3.0.tar.gz", hash = "sha256:152f9acabd296a8375fbca5b84c961ff95971fcfc32e79550c8df8e29118c54d"}, +] + +[package.dependencies] +webencodings = ">=0.4" + +[package.extras] +doc = ["sphinx", "sphinx_rtd_theme"] +test = ["pytest", "ruff"] + +[[package]] +name = "tomli" +version = "2.0.2" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, + {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, +] + +[[package]] +name = "tornado" +version = "6.4.1" +description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." +optional = false +python-versions = ">=3.8" +files = [ + {file = "tornado-6.4.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:163b0aafc8e23d8cdc3c9dfb24c5368af84a81e3364745ccb4427669bf84aec8"}, + {file = "tornado-6.4.1-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6d5ce3437e18a2b66fbadb183c1d3364fb03f2be71299e7d10dbeeb69f4b2a14"}, + {file = "tornado-6.4.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e20b9113cd7293f164dc46fffb13535266e713cdb87bd2d15ddb336e96cfc4"}, + {file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ae50a504a740365267b2a8d1a90c9fbc86b780a39170feca9bcc1787ff80842"}, + {file = "tornado-6.4.1-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613bf4ddf5c7a95509218b149b555621497a6cc0d46ac341b30bd9ec19eac7f3"}, + {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25486eb223babe3eed4b8aecbac33b37e3dd6d776bc730ca14e1bf93888b979f"}, + {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:454db8a7ecfcf2ff6042dde58404164d969b6f5d58b926da15e6b23817950fc4"}, + {file = "tornado-6.4.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a02a08cc7a9314b006f653ce40483b9b3c12cda222d6a46d4ac63bb6c9057698"}, + {file = "tornado-6.4.1-cp38-abi3-win32.whl", hash = "sha256:d9a566c40b89757c9aa8e6f032bcdb8ca8795d7c1a9762910c722b1635c9de4d"}, + {file = "tornado-6.4.1-cp38-abi3-win_amd64.whl", hash = "sha256:b24b8982ed444378d7f21d563f4180a2de31ced9d8d84443907a0a64da2072e7"}, + {file = "tornado-6.4.1.tar.gz", hash = "sha256:92d3ab53183d8c50f8204a51e6f91d18a15d5ef261e84d452800d4ff6fc504e9"}, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +description = "Traitlets Python configuration system" +optional = false +python-versions = ">=3.8" +files = [ + {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"}, + {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "urllib3" +version = "2.2.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "verspec" +version = "0.1.0" +description = "Flexible version handling" +optional = false +python-versions = "*" +files = [ + {file = "verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31"}, + {file = "verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e"}, +] + +[package.extras] +test = ["coverage", "flake8 (>=3.7)", "mypy", "pretend", "pytest"] + +[[package]] +name = "watchdog" +version = "5.0.3" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.9" +files = [ + {file = "watchdog-5.0.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:85527b882f3facda0579bce9d743ff7f10c3e1e0db0a0d0e28170a7d0e5ce2ea"}, + {file = "watchdog-5.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:53adf73dcdc0ef04f7735066b4a57a4cd3e49ef135daae41d77395f0b5b692cb"}, + {file = "watchdog-5.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e25adddab85f674acac303cf1f5835951345a56c5f7f582987d266679979c75b"}, + {file = "watchdog-5.0.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f01f4a3565a387080dc49bdd1fefe4ecc77f894991b88ef927edbfa45eb10818"}, + {file = "watchdog-5.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91b522adc25614cdeaf91f7897800b82c13b4b8ac68a42ca959f992f6990c490"}, + {file = "watchdog-5.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d52db5beb5e476e6853da2e2d24dbbbed6797b449c8bf7ea118a4ee0d2c9040e"}, + {file = "watchdog-5.0.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d11b07c64f63f49876e0ab8042ae034674c8653bfcdaa8c4b32e71cfff87e8"}, + {file = "watchdog-5.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:349c9488e1d85d0a58e8cb14222d2c51cbc801ce11ac3936ab4c3af986536926"}, + {file = "watchdog-5.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:53a3f10b62c2d569e260f96e8d966463dec1a50fa4f1b22aec69e3f91025060e"}, + {file = "watchdog-5.0.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:950f531ec6e03696a2414b6308f5c6ff9dab7821a768c9d5788b1314e9a46ca7"}, + {file = "watchdog-5.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae6deb336cba5d71476caa029ceb6e88047fc1dc74b62b7c4012639c0b563906"}, + {file = "watchdog-5.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1021223c08ba8d2d38d71ec1704496471ffd7be42cfb26b87cd5059323a389a1"}, + {file = "watchdog-5.0.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:752fb40efc7cc8d88ebc332b8f4bcbe2b5cc7e881bccfeb8e25054c00c994ee3"}, + {file = "watchdog-5.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a2e8f3f955d68471fa37b0e3add18500790d129cc7efe89971b8a4cc6fdeb0b2"}, + {file = "watchdog-5.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b8ca4d854adcf480bdfd80f46fdd6fb49f91dd020ae11c89b3a79e19454ec627"}, + {file = "watchdog-5.0.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:90a67d7857adb1d985aca232cc9905dd5bc4803ed85cfcdcfcf707e52049eda7"}, + {file = "watchdog-5.0.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:720ef9d3a4f9ca575a780af283c8fd3a0674b307651c1976714745090da5a9e8"}, + {file = "watchdog-5.0.3-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:223160bb359281bb8e31c8f1068bf71a6b16a8ad3d9524ca6f523ac666bb6a1e"}, + {file = "watchdog-5.0.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:560135542c91eaa74247a2e8430cf83c4342b29e8ad4f520ae14f0c8a19cfb5b"}, + {file = "watchdog-5.0.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:dd021efa85970bd4824acacbb922066159d0f9e546389a4743d56919b6758b91"}, + {file = "watchdog-5.0.3-py3-none-manylinux2014_armv7l.whl", hash = "sha256:78864cc8f23dbee55be34cc1494632a7ba30263951b5b2e8fc8286b95845f82c"}, + {file = "watchdog-5.0.3-py3-none-manylinux2014_i686.whl", hash = "sha256:1e9679245e3ea6498494b3028b90c7b25dbb2abe65c7d07423ecfc2d6218ff7c"}, + {file = "watchdog-5.0.3-py3-none-manylinux2014_ppc64.whl", hash = "sha256:9413384f26b5d050b6978e6fcd0c1e7f0539be7a4f1a885061473c5deaa57221"}, + {file = "watchdog-5.0.3-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:294b7a598974b8e2c6123d19ef15de9abcd282b0fbbdbc4d23dfa812959a9e05"}, + {file = "watchdog-5.0.3-py3-none-manylinux2014_s390x.whl", hash = "sha256:26dd201857d702bdf9d78c273cafcab5871dd29343748524695cecffa44a8d97"}, + {file = "watchdog-5.0.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:0f9332243355643d567697c3e3fa07330a1d1abf981611654a1f2bf2175612b7"}, + {file = "watchdog-5.0.3-py3-none-win32.whl", hash = "sha256:c66f80ee5b602a9c7ab66e3c9f36026590a0902db3aea414d59a2f55188c1f49"}, + {file = "watchdog-5.0.3-py3-none-win_amd64.whl", hash = "sha256:f00b4cf737f568be9665563347a910f8bdc76f88c2970121c86243c8cfdf90e9"}, + {file = "watchdog-5.0.3-py3-none-win_ia64.whl", hash = "sha256:49f4d36cb315c25ea0d946e018c01bb028048023b9e103d3d3943f58e109dd45"}, + {file = "watchdog-5.0.3.tar.gz", hash = "sha256:108f42a7f0345042a854d4d0ad0834b741d421330d5f575b81cb27b883500176"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + +[[package]] +name = "wcmatch" +version = "10.0" +description = "Wildcard/glob file name matcher." +optional = false +python-versions = ">=3.8" +files = [ + {file = "wcmatch-10.0-py3-none-any.whl", hash = "sha256:0dd927072d03c0a6527a20d2e6ad5ba8d0380e60870c383bc533b71744df7b7a"}, + {file = "wcmatch-10.0.tar.gz", hash = "sha256:e72f0de09bba6a04e0de70937b0cf06e55f36f37b3deb422dfaf854b867b840a"}, +] + +[package.dependencies] +bracex = ">=2.1.1" + +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +description = "Character encoding aliases for legacy web content" +optional = false +python-versions = "*" +files = [ + {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, + {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, +] + +[[package]] +name = "zipp" +version = "3.20.2" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, + {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.10" +content-hash = "426658f4df092427a58c0938a1ea7ed391cb211893dd30b5b9c712726f860471" diff --git a/docs/pyproject.toml b/docs/pyproject.toml new file mode 100644 index 00000000..716682fe --- /dev/null +++ b/docs/pyproject.toml @@ -0,0 +1,34 @@ +[tool.poetry] +name = "icechunk-docs" +description = "Icechunk documentation website" +authors = ["Orestis Herodotou "] +readme = "README.md" + +# Disable package mode +package-mode = false + +[tool.poetry.dependencies] +python = "^3.10" +mkdocs = "^1.6.1" +mkdocs-material = {extras = ["imaging"], version = "^9.5.39"} +icechunk = {path = "../icechunk-python", develop = true} +mkdocstrings = {extras = ["python"], version = "^0.26.1"} +mkdocs-jupyter = "^0.25.0" +mkdocs-awesome-pages-plugin = "^2.9.3" +mkdocs-git-revision-date-localized-plugin = "^1.2.9" +mkdocs-git-committers-plugin-2 = "^2.4.1" +mkdocs-macros-plugin = "^1.2.0" +mkdocs-include-markdown-plugin = "^6.2.2" +mkdocs-open-in-new-tab = "^1.0.6" +mkdocs-redirects = "^1.2.1" +mkdocs-breadcrumbs-plugin = "^0.1.10" +mkdocs-minify-plugin = "^0.8.0" +mkdocs-mermaid2-plugin = "^1.1.1" + +[tool.poetry.group.dev.dependencies] +mike = "^2.1.3" +shtab = "^1.7.1" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 00000000..6a2a3020 --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,13 @@ +/* Notebook Adjustments */ +/* Hides prompts ([In]/[Out]) */ +.jp-InputPrompt { + display: none !important; +} + +.jp-OutputPrompt { + display: none !important; +} + +body { + background-color: red !important; +} \ No newline at end of file diff --git a/icechunk-python/pyproject.toml b/icechunk-python/pyproject.toml index f0ee68d9..205cc4f2 100644 --- a/icechunk-python/pyproject.toml +++ b/icechunk-python/pyproject.toml @@ -14,6 +14,16 @@ dynamic = ["version"] dependencies = ["zarr==3.0.0a7"] +[tool.poetry] +name = "icechunk" +version = "0.1.0" +description = "Icechunk Python" +authors = ["Earthmover "] +readme = "README.md" +packages = [ + { include = "icechunk", from = "python" } +] + [project.optional-dependencies] test = [ "coverage", diff --git a/spec/icechunk_spec.md b/spec/icechunk-spec.md similarity index 99% rename from spec/icechunk_spec.md rename to spec/icechunk-spec.md index e6b23856..9ba057ae 100644 --- a/spec/icechunk_spec.md +++ b/spec/icechunk-spec.md @@ -1,6 +1,7 @@ # Icechunk Specification -The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119.html). +!!! note "Note" + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119.html). ## Introduction From 88cffa3f9be42507f6f577c30070d094f70c3374 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Tue, 8 Oct 2024 09:18:02 -0300 Subject: [PATCH 040/167] Fix many issues reported by repo-review In preparation for launch --- .github/dependabot.yml | 4 ++ .github/workflows/python-ci.yaml | 4 ++ .github/workflows/rust-ci.yaml | 4 ++ icechunk-python/examples/dask_write.py | 39 ++++++++++--------- icechunk-python/examples/smoke-test.py | 18 ++++----- .../notebooks/demo-dummy-data.ipynb | 3 +- icechunk-python/notebooks/demo-s3.ipynb | 6 +-- .../notebooks/version-control.ipynb | 1 - icechunk-python/pyproject.toml | 22 +++++++++++ icechunk-python/python/icechunk/__init__.py | 18 ++++----- .../python/icechunk/_icechunk_python.pyi | 9 ++--- icechunk-python/tests/conftest.py | 2 +- icechunk-python/tests/test_concurrency.py | 4 +- .../tests/test_distributed_writers.py | 15 +++---- icechunk-python/tests/test_pickle.py | 5 +-- icechunk-python/tests/test_timetravel.py | 3 +- icechunk-python/tests/test_virtual_ref.py | 10 ++--- icechunk-python/tests/test_zarr/test_api.py | 5 +-- icechunk-python/tests/test_zarr/test_array.py | 5 +-- icechunk-python/tests/test_zarr/test_group.py | 20 ++++------ .../tests/test_zarr/test_store/test_core.py | 1 - .../test_store/test_icechunk_store.py | 24 ++++-------- 22 files changed, 117 insertions(+), 105 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cc0cdfd8..f226efb9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -18,3 +18,7 @@ updates: day: "monday" time: "05:00" timezone: "US/Pacific" + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml index cc6d5e11..78dd4851 100644 --- a/.github/workflows/python-ci.yaml +++ b/.github/workflows/python-ci.yaml @@ -15,6 +15,10 @@ on: pull_request: workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read diff --git a/.github/workflows/rust-ci.yaml b/.github/workflows/rust-ci.yaml index 48843b67..93dc5c98 100644 --- a/.github/workflows/rust-ci.yaml +++ b/.github/workflows/rust-ci.yaml @@ -9,6 +9,10 @@ on: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: CARGO_INCREMENTAL: 0 CARGO_NET_RETRY: 10 diff --git a/icechunk-python/examples/dask_write.py b/icechunk-python/examples/dask_write.py index 59ad60f8..ca129620 100644 --- a/icechunk-python/examples/dask_write.py +++ b/icechunk-python/examples/dask_write.py @@ -11,14 +11,15 @@ """ import argparse -from dataclasses import dataclass -import time import asyncio -import zarr -from dask.distributed import Client -import numpy as np +import time +from dataclasses import dataclass +from typing import cast import icechunk +import numpy as np +import zarr +from dask.distributed import Client @dataclass @@ -57,7 +58,7 @@ async def execute_write_task(task: Task): store = await mk_store("w", task) group = zarr.group(store=store, overwrite=False) - array = group["array"] + array = cast(zarr.Array, group["array"]) print(f"Writing at t={task.time}") data = generate_task_array(task, array.shape[0:2]) array[:, :, task.time] = data @@ -72,7 +73,7 @@ async def execute_read_task(task: Task): print(f"Reading t={task.time}") store = await mk_store("r", task) group = zarr.group(store=store, overwrite=False) - array = group["array"] + array = cast(zarr.Array, group["array"]) actual = array[:, :, task.time] expected = generate_task_array(task, array.shape[0:2]) @@ -235,9 +236,7 @@ async def distributed_write(): help="size of chunks in the y dimension", default=112, ) - create_parser.add_argument( - "--name", type=str, help="repository name", required=True - ) + create_parser.add_argument("--name", type=str, help="repository name", required=True) create_parser.set_defaults(command="create") update_parser = subparsers.add_parser("update", help="add chunks to the array") @@ -256,9 +255,7 @@ async def distributed_write(): update_parser.add_argument( "--workers", type=int, help="number of workers to use", required=True ) - update_parser.add_argument( - "--name", type=str, help="repository name", required=True - ) + update_parser.add_argument("--name", type=str, help="repository name", required=True) update_parser.add_argument( "--max-sleep", type=float, @@ -275,7 +272,11 @@ async def distributed_write(): "--sleep-tasks", type=int, help="this many tasks sleep", default=0 ) update_parser.add_argument( - "--distributed-cluster", type=bool, help="use multiple machines", action=argparse.BooleanOptionalAction, default=False + "--distributed-cluster", + type=bool, + help="use multiple machines", + action=argparse.BooleanOptionalAction, + default=False, ) update_parser.set_defaults(command="update") @@ -295,11 +296,13 @@ async def distributed_write(): verify_parser.add_argument( "--workers", type=int, help="number of workers to use", required=True ) + verify_parser.add_argument("--name", type=str, help="repository name", required=True) verify_parser.add_argument( - "--name", type=str, help="repository name", required=True - ) - verify_parser.add_argument( - "--distributed-cluster", type=bool, help="use multiple machines", action=argparse.BooleanOptionalAction, default=False + "--distributed-cluster", + type=bool, + help="use multiple machines", + action=argparse.BooleanOptionalAction, + default=False, ) verify_parser.set_defaults(command="verify") diff --git a/icechunk-python/examples/smoke-test.py b/icechunk-python/examples/smoke-test.py index 3247f1e0..14d3905e 100644 --- a/icechunk-python/examples/smoke-test.py +++ b/icechunk-python/examples/smoke-test.py @@ -1,17 +1,15 @@ import asyncio -from typing import Literal -from zarr.storage import LocalStore, MemoryStore, RemoteStore import math +import random +import string +import time +from typing import Literal import numpy as np import zarr -import time - +from icechunk import IcechunkStore, S3Credentials, StorageConfig, StoreConfig from zarr.abc.store import Store - -from icechunk import IcechunkStore, StorageConfig, S3Credentials, StoreConfig -import random -import string +from zarr.storage import LocalStore, MemoryStore, RemoteStore def rdms(n): @@ -149,7 +147,7 @@ async def run(store: Store) -> None: assert isinstance(array, zarr.Array) print( - f"numchunks: {math.prod(s // c for s, c in zip(array.shape, array.chunks))}" + f"numchunks: {math.prod(s // c for s, c in zip(array.shape, array.chunks, strict=False))}" ) np.testing.assert_array_equal(array[:], value) @@ -176,7 +174,7 @@ async def create_zarr_store(*, store: Literal["memory", "local", "s3"]) -> Store "key": "minio123", "secret": "minio123", "region": "us-east-1", - "endpoint_url": "http://localhost:9000" + "endpoint_url": "http://localhost:9000", }, ) diff --git a/icechunk-python/notebooks/demo-dummy-data.ipynb b/icechunk-python/notebooks/demo-dummy-data.ipynb index 5b98d361..b6319158 100644 --- a/icechunk-python/notebooks/demo-dummy-data.ipynb +++ b/icechunk-python/notebooks/demo-dummy-data.ipynb @@ -21,7 +21,6 @@ "\n", "import numpy as np\n", "import zarr\n", - "\n", "from icechunk import IcechunkStore, StorageConfig" ] }, @@ -1381,7 +1380,7 @@ " tic = time.time()\n", " array = root_group[key]\n", " assert array.dtype == value.dtype, (array.dtype, value.dtype)\n", - " print(f\"numchunks: {math.prod(s // c for s, c in zip(array.shape, array.chunks))}\")\n", + " print(f\"numchunks: {math.prod(s // c for s, c in zip(array.shape, array.chunks, strict=False))}\")\n", " np.testing.assert_array_equal(array[:], value)\n", " print(time.time() - tic)" ] diff --git a/icechunk-python/notebooks/demo-s3.ipynb b/icechunk-python/notebooks/demo-s3.ipynb index c43df6d4..93ad64af 100644 --- a/icechunk-python/notebooks/demo-s3.ipynb +++ b/icechunk-python/notebooks/demo-s3.ipynb @@ -18,7 +18,6 @@ "outputs": [], "source": [ "import zarr\n", - "\n", "from icechunk import IcechunkStore, StorageConfig" ] }, @@ -1148,7 +1147,6 @@ "outputs": [], "source": [ "import zarr\n", - "\n", "from icechunk import IcechunkStore, StorageConfig\n", "\n", "# TODO: catalog will handle this\n", @@ -1298,8 +1296,8 @@ "metadata": {}, "outputs": [], "source": [ - "import matplotlib.pyplot as plt\n", - "import matplotlib as mpl" + "import matplotlib as mpl\n", + "import matplotlib.pyplot as plt" ] }, { diff --git a/icechunk-python/notebooks/version-control.ipynb b/icechunk-python/notebooks/version-control.ipynb index 6b94072c..4749ada1 100644 --- a/icechunk-python/notebooks/version-control.ipynb +++ b/icechunk-python/notebooks/version-control.ipynb @@ -16,7 +16,6 @@ "outputs": [], "source": [ "import zarr\n", - "\n", "from icechunk import IcechunkStore, StorageConfig" ] }, diff --git a/icechunk-python/pyproject.toml b/icechunk-python/pyproject.toml index f0ee68d9..a302a709 100644 --- a/icechunk-python/pyproject.toml +++ b/icechunk-python/pyproject.toml @@ -34,7 +34,29 @@ python-source = "python" [tool.pytest.ini_options] asyncio_mode = "auto" +minversion = "7" +testpaths = ["tests"] +log_cli_level = "INFO" +xfail_strict = true +addopts = ["-ra", "--strict-config", "--strict-markers"] +filterwarnings = ["error"] [tool.pyright] venvPath = "." venv = ".venv" + +[tool.mypy] +python_version = "3.10" +strict = true +warn_unreachable = true +enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] + +[tool.ruff] +line-length = 90 + +[tool.ruff.lint] +extend-select = [ + "B", # flake8-bugbear + "I", # isort + "UP", # pypupgrade +] diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index b7558c51..6c0b52eb 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -8,17 +8,17 @@ from zarr.core.sync import SyncMixin from ._icechunk_python import ( - __version__, PyIcechunkStore, S3Credentials, SnapshotMetadata, StorageConfig, StoreConfig, VirtualRefConfig, + __version__, pyicechunk_store_create, pyicechunk_store_exists, - pyicechunk_store_open_existing, pyicechunk_store_from_bytes, + pyicechunk_store_open_existing, ) __all__ = [ @@ -96,7 +96,7 @@ async def open_existing( cls, storage: StorageConfig, mode: AccessModeLiteral = "r", - config: StoreConfig = StoreConfig(), + config: StoreConfig | None = None, *args: Any, **kwargs: Any, ) -> Self: @@ -110,6 +110,7 @@ async def open_existing( If opened with AccessModeLiteral "r", the store will be read-only. Otherwise the store will be writable. """ + config = config or StoreConfig() read_only = mode == "r" store = await pyicechunk_store_open_existing( storage, read_only=read_only, config=config @@ -121,7 +122,7 @@ async def create( cls, storage: StorageConfig, mode: AccessModeLiteral = "w", - config: StoreConfig = StoreConfig(), + config: StoreConfig | None = None, *args: Any, **kwargs: Any, ) -> Self: @@ -133,6 +134,7 @@ async def create( this will be configured automatically with the provided storage_config as the underlying storage backend. """ + config = config or StoreConfig() store = await pyicechunk_store_create(storage, config=config) return cls(store=store, mode=mode, args=args, kwargs=kwargs) @@ -169,8 +171,8 @@ def __getstate__(self) -> object: def __setstate__(self, state: Any) -> None: store_repr = state["store"] - mode = state['mode'] - is_read_only = (mode == "r") + mode = state["mode"] + is_read_only = mode == "r" self._store = pyicechunk_store_from_bytes(store_repr, is_read_only) self._is_open = True @@ -305,9 +307,7 @@ async def get_partial_values( # NOTE: pyo3 has not implicit conversion from an Iterable to a rust iterable. So we convert it # to a list here first. Possible opportunity for optimization. result = await self._store.get_partial_values(list(key_ranges)) - return [ - prototype.buffer.from_bytes(r) if r is not None else None for r in result - ] + return [prototype.buffer.from_bytes(r) if r is not None else None for r in result] async def exists(self, key: str) -> bool: """Check if a key exists in the store. diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index 18c3e7d5..b6acc932 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -224,16 +224,15 @@ class StoreConfig: async def pyicechunk_store_exists(storage: StorageConfig) -> bool: ... async def pyicechunk_store_create( - storage: StorageConfig, config: StoreConfig + storage: StorageConfig, config: StoreConfig | None ) -> PyIcechunkStore: ... async def pyicechunk_store_open_existing( - storage: StorageConfig, read_only: bool, config: StoreConfig + storage: StorageConfig, read_only: bool, config: StoreConfig | None ) -> PyIcechunkStore: ... + # async def pyicechunk_store_from_json_config( # config: str, read_only: bool # ) -> PyIcechunkStore: ... -def pyicechunk_store_from_bytes( - bytes: bytes, read_only: bool -) -> PyIcechunkStore: ... +def pyicechunk_store_from_bytes(bytes: bytes, read_only: bool) -> PyIcechunkStore: ... __version__: str diff --git a/icechunk-python/tests/conftest.py b/icechunk-python/tests/conftest.py index bf670b19..02046273 100644 --- a/icechunk-python/tests/conftest.py +++ b/icechunk-python/tests/conftest.py @@ -1,7 +1,7 @@ from typing import Literal -from icechunk import IcechunkStore, StorageConfig import pytest +from icechunk import IcechunkStore, StorageConfig async def parse_store(store: Literal["local", "memory"], path: str) -> IcechunkStore: diff --git a/icechunk-python/tests/test_concurrency.py b/icechunk-python/tests/test_concurrency.py index 767ff9fd..907d7f0c 100644 --- a/icechunk-python/tests/test_concurrency.py +++ b/icechunk-python/tests/test_concurrency.py @@ -1,8 +1,8 @@ import asyncio import random -import zarr import icechunk +import zarr N = 15 @@ -41,7 +41,7 @@ async def list_store(store, barrier): async def test_concurrency(): store = await icechunk.IcechunkStore.open( mode="w", - storage=icechunk.StorageConfig.memory(prefix='concurrency'), + storage=icechunk.StorageConfig.memory(prefix="concurrency"), ) group = zarr.group(store=store, overwrite=True) diff --git a/icechunk-python/tests/test_distributed_writers.py b/icechunk-python/tests/test_distributed_writers.py index fb464e41..7c2249f5 100644 --- a/icechunk-python/tests/test_distributed_writers.py +++ b/icechunk-python/tests/test_distributed_writers.py @@ -1,11 +1,12 @@ -from dataclasses import dataclass -import time import asyncio -import zarr -from dask.distributed import Client -import numpy as np +import time +from dataclasses import dataclass +from typing import cast import icechunk +import numpy as np +import zarr +from dask.distributed import Client @dataclass @@ -13,7 +14,7 @@ class Task: # fixme: useee StorageConfig and StoreConfig once those are pickable storage_config: dict store_config: dict - area: slice + area: tuple[slice, slice] seed: int @@ -57,7 +58,7 @@ async def execute_task(task: Task): store = await mk_store("w", task) group = zarr.group(store=store, overwrite=False) - array = group["array"] + array = cast(zarr.Array, group["array"]) array[task.area] = generate_task_array(task) return store.change_set_bytes() diff --git a/icechunk-python/tests/test_pickle.py b/icechunk-python/tests/test_pickle.py index ee02fdff..50b0b529 100644 --- a/icechunk-python/tests/test_pickle.py +++ b/icechunk-python/tests/test_pickle.py @@ -1,11 +1,10 @@ import pickle +import icechunk import pytest import zarr from zarr.storage import LocalStore -import icechunk - @pytest.fixture(scope="function") async def tmp_store(tmpdir): @@ -32,7 +31,7 @@ async def test_pickle(tmp_store): assert store_loaded == tmp_store root_loaded = zarr.open_group(store_loaded) - array_loaded = root_loaded['ones'] + array_loaded = root_loaded["ones"] assert type(array_loaded) is zarr.Array assert array_loaded == array diff --git a/icechunk-python/tests/test_timetravel.py b/icechunk-python/tests/test_timetravel.py index 84e49bba..d992e508 100644 --- a/icechunk-python/tests/test_timetravel.py +++ b/icechunk-python/tests/test_timetravel.py @@ -1,6 +1,5 @@ -import zarr - import icechunk +import zarr async def test_timetravel(): diff --git a/icechunk-python/tests/test_virtual_ref.py b/icechunk-python/tests/test_virtual_ref.py index dbcb63cb..543f01c8 100644 --- a/icechunk-python/tests/test_virtual_ref.py +++ b/icechunk-python/tests/test_virtual_ref.py @@ -1,14 +1,14 @@ -from object_store import ClientOptions, ObjectStore +import zarr +import zarr.core +import zarr.core.buffer from icechunk import ( IcechunkStore, + S3Credentials, StorageConfig, StoreConfig, - S3Credentials, VirtualRefConfig, ) -import zarr -import zarr.core -import zarr.core.buffer +from object_store import ClientOptions, ObjectStore def write_chunks_to_minio(chunks: list[tuple[str, bytes]]): diff --git a/icechunk-python/tests/test_zarr/test_api.py b/icechunk-python/tests/test_zarr/test_api.py index 7a1fafb3..3d629f46 100644 --- a/icechunk-python/tests/test_zarr/test_api.py +++ b/icechunk-python/tests/test_zarr/test_api.py @@ -1,11 +1,10 @@ import pathlib -from icechunk import IcechunkStore import numpy as np import pytest -from numpy.testing import assert_array_equal - import zarr +from icechunk import IcechunkStore +from numpy.testing import assert_array_equal from zarr import Array, Group from zarr.abc.store import Store from zarr.api.synchronous import ( diff --git a/icechunk-python/tests/test_zarr/test_array.py b/icechunk-python/tests/test_zarr/test_array.py index 31e5fe4e..43044509 100644 --- a/icechunk-python/tests/test_zarr/test_array.py +++ b/icechunk-python/tests/test_zarr/test_array.py @@ -1,13 +1,12 @@ from typing import Literal -from icechunk import IcechunkStore import numpy as np import pytest - -from zarr import Array, AsyncGroup, Group import zarr import zarr.api import zarr.api.asynchronous +from icechunk import IcechunkStore +from zarr import Array, AsyncGroup, Group from zarr.core.common import ZarrFormat from zarr.errors import ContainsArrayError, ContainsGroupError from zarr.storage import StorePath diff --git a/icechunk-python/tests/test_zarr/test_group.py b/icechunk-python/tests/test_zarr/test_group.py index 2fad26b0..1d51813a 100644 --- a/icechunk-python/tests/test_zarr/test_group.py +++ b/icechunk-python/tests/test_zarr/test_group.py @@ -2,14 +2,13 @@ from typing import TYPE_CHECKING, Any, Literal, cast -from icechunk import IcechunkStore import numpy as np import pytest - -from zarr import Array, AsyncArray, AsyncGroup, Group import zarr import zarr.api import zarr.api.asynchronous +from icechunk import IcechunkStore +from zarr import Array, AsyncArray, AsyncGroup, Group from zarr.core.buffer import default_buffer_prototype from zarr.core.common import JSON, ZarrFormat from zarr.core.group import GroupMetadata @@ -152,7 +151,7 @@ def test_group_members(store: IcechunkStore, zarr_format: ZarrFormat) -> None: members_expected["subgroup"] = group.create_group("subgroup") # make a sub-sub-subgroup, to ensure that the children calculation doesn't go # too deep in the hierarchy - subsubgroup = members_expected["subgroup"].create_group("subsubgroup") + subsubgroup = cast(Group, members_expected["subgroup"]).create_group("subsubgroup") subsubsubgroup = subsubgroup.create_group("subsubsubgroup") members_expected["subarray"] = group.create_array( @@ -600,9 +599,7 @@ async def test_asyncgroup_open_wrong_format( store: IcechunkStore, zarr_format: ZarrFormat, ) -> None: - _ = await AsyncGroup.from_store( - store=store, exists_ok=False, zarr_format=zarr_format - ) + _ = await AsyncGroup.from_store(store=store, exists_ok=False, zarr_format=zarr_format) zarr_format_wrong: ZarrFormat # try opening with the wrong zarr format if zarr_format == 3: @@ -639,9 +636,7 @@ def test_asyncgroup_from_dict(store: IcechunkStore, data: dict[str, Any]) -> Non # todo: replace this with a declarative API where we model a full hierarchy -async def test_asyncgroup_getitem( - store: IcechunkStore, zarr_format: ZarrFormat -) -> None: +async def test_asyncgroup_getitem(store: IcechunkStore, zarr_format: ZarrFormat) -> None: """ Create an `AsyncGroup`, then create members of that group, and ensure that we can access those members via the `AsyncGroup.getitem` method. @@ -663,9 +658,7 @@ async def test_asyncgroup_getitem( await agroup.getitem("foo") -async def test_asyncgroup_delitem( - store: IcechunkStore, zarr_format: ZarrFormat -) -> None: +async def test_asyncgroup_delitem(store: IcechunkStore, zarr_format: ZarrFormat) -> None: agroup = await AsyncGroup.from_store(store=store, zarr_format=zarr_format) array_name = "sub_array" _ = await agroup.create_array( @@ -915,6 +908,7 @@ async def test_require_array(store: IcechunkStore, zarr_format: ZarrFormat) -> N with pytest.raises(TypeError, match="Incompatible object"): await root.require_array("bar", shape=(10,), dtype="int8") + class TestGroupMetadata: def test_from_dict_extra_fields(self): data = { diff --git a/icechunk-python/tests/test_zarr/test_store/test_core.py b/icechunk-python/tests/test_zarr/test_store/test_core.py index 4a334ffc..c0ba4501 100644 --- a/icechunk-python/tests/test_zarr/test_store/test_core.py +++ b/icechunk-python/tests/test_zarr/test_store/test_core.py @@ -1,5 +1,4 @@ from icechunk import IcechunkStore - from zarr.storage import make_store_path from ...conftest import parse_store diff --git a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py index c3981a8e..1ef112b9 100644 --- a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py +++ b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py @@ -1,17 +1,15 @@ from __future__ import annotations + from typing import Any, cast import pytest - +from icechunk import IcechunkStore, StorageConfig from zarr.abc.store import AccessMode from zarr.core.buffer import Buffer, cpu, default_buffer_prototype from zarr.core.common import AccessModeLiteral from zarr.core.sync import collect_aiterator from zarr.testing.store import StoreTests -from icechunk import IcechunkStore, StorageConfig - - DEFAULT_GROUP_METADATA = b'{"zarr_format":3,"node_type":"group","attributes":null}' ARRAY_METADATA = ( b'{"zarr_format":3,"node_type":"array","attributes":{"foo":42},' @@ -25,11 +23,11 @@ class TestIcechunkStore(StoreTests[IcechunkStore, cpu.Buffer]): store_cls = IcechunkStore buffer_cls = cpu.Buffer - @pytest.mark.xfail(reason="not implemented") - async def test_store_eq(self) -> None: + @pytest.mark.xfail(reason="not implemented", strict=False) + def test_store_eq(self, store: IcechunkStore, store_kwargs: dict[str, Any]) -> None: pass - @pytest.mark.xfail(reason="not implemented") + @pytest.mark.xfail(reason="not implemented", strict=False) async def test_serizalizable_store(self, store) -> None: pass @@ -59,9 +57,7 @@ def store_kwargs( return kwargs @pytest.fixture(scope="function") - async def store( - self, store_kwargs: str | None | dict[str, Buffer] - ) -> IcechunkStore: + async def store(self, store_kwargs: str | None | dict[str, Buffer]) -> IcechunkStore: return await IcechunkStore.open(**store_kwargs) @pytest.mark.xfail(reason="Not implemented") @@ -72,9 +68,7 @@ def test_store_repr(self, store: IcechunkStore) -> None: def test_serializable_store(self, store: IcechunkStore) -> None: super().test_serializable_store(store) - async def test_not_writable_store_raises( - self, store_kwargs: dict[str, Any] - ) -> None: + async def test_not_writable_store_raises(self, store_kwargs: dict[str, Any]) -> None: create_kwargs = {**store_kwargs, "mode": "r"} with pytest.raises(ValueError): _store = await self.store_cls.open(**create_kwargs) @@ -105,9 +99,7 @@ async def test_set_many(self, store: IcechunkStore) -> None: ] # icechunk strictly checks metadata? data_buf = [ - self.buffer_cls.from_bytes( - k.encode() if k != "zarr.json" else ARRAY_METADATA - ) + self.buffer_cls.from_bytes(k.encode() if k != "zarr.json" else ARRAY_METADATA) for k in keys ] store_dict = dict(zip(keys, data_buf, strict=True)) From edf87bb7f20524a14363c3f1f6fde0c06ca28219 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Tue, 8 Oct 2024 10:15:28 -0300 Subject: [PATCH 041/167] mypy --- icechunk-python/pyproject.toml | 2 +- icechunk-python/python/icechunk/__init__.py | 2 -- icechunk-python/python/icechunk/_icechunk_python.pyi | 3 ++- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/icechunk-python/pyproject.toml b/icechunk-python/pyproject.toml index a302a709..a35756e7 100644 --- a/icechunk-python/pyproject.toml +++ b/icechunk-python/pyproject.toml @@ -46,7 +46,7 @@ venvPath = "." venv = ".venv" [tool.mypy] -python_version = "3.10" +python_version = "3.11" strict = true warn_unreachable = true enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index 6c0b52eb..8965091f 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -279,8 +279,6 @@ async def get( """ try: result = await self._store.get(key, byte_range) - if result is None: - return None except ValueError as _e: # Zarr python expects None to be returned if the key does not exist # but an IcechunkStore returns an error if the key does not exist diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index b6acc932..f7451515 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -1,3 +1,4 @@ +from typing import Any import abc import datetime from collections.abc import AsyncGenerator @@ -52,7 +53,7 @@ class PyIcechunkStore: def list(self) -> PyAsyncStringGenerator: ... def list_prefix(self, prefix: str) -> PyAsyncStringGenerator: ... def list_dir(self, prefix: str) -> PyAsyncStringGenerator: ... - def __eq__(self, other) -> bool: ... + def __eq__(self, other: Any) -> bool: ... class PyAsyncStringGenerator(AsyncGenerator[str, None], metaclass=abc.ABCMeta): def __aiter__(self) -> PyAsyncStringGenerator: ... From ebf46df18549e04ae05161cabe550106521e2763 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Tue, 8 Oct 2024 10:18:59 -0300 Subject: [PATCH 042/167] Readme file in package fields --- Cargo.lock | 2 +- icechunk-python/Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 57ba5890..5e979164 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1226,7 +1226,7 @@ dependencies = [ [[package]] name = "icechunk-python" -version = "0.1.0" +version = "0.1.0-alpha.1" dependencies = [ "async-stream", "bytes", diff --git a/icechunk-python/Cargo.toml b/icechunk-python/Cargo.toml index 675875cf..78fddcc0 100644 --- a/icechunk-python/Cargo.toml +++ b/icechunk-python/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "icechunk-python" -version = "0.1.0" +version = "0.1.0-alpha.1" description = "Transactional storage engine for Zarr designed for use on cloud object storage" -readme = "README.md" +readme = "../README.md" repository = "https://github.com/earth-mover/icechunk" homepage = "https://github.com/earth-mover/icechunk" license = "MIT OR Apache-2.0" From 98b05a774ef617695013f3e20601a30f5acca3ba Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Tue, 8 Oct 2024 10:22:37 -0300 Subject: [PATCH 043/167] Readme file in package fields --- icechunk/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/icechunk/Cargo.toml b/icechunk/Cargo.toml index 2b9d13e9..dc3de6a6 100644 --- a/icechunk/Cargo.toml +++ b/icechunk/Cargo.toml @@ -2,7 +2,7 @@ name = "icechunk" version = "0.1.0-alpha.1" description = "Transactional storage engine for Zarr designed for use on cloud object storage" -readme = "README.md" +readme = "../README.md" repository = "https://github.com/earth-mover/icechunk" homepage = "https://github.com/earth-mover/icechunk" license = "MIT OR Apache-2.0" From fe2fd6f6ac46e78b2d951bfeab26857951662888 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Tue, 8 Oct 2024 10:24:23 -0300 Subject: [PATCH 044/167] mypy --- icechunk-python/python/icechunk/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index 8965091f..f4457c3d 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -305,7 +305,7 @@ async def get_partial_values( # NOTE: pyo3 has not implicit conversion from an Iterable to a rust iterable. So we convert it # to a list here first. Possible opportunity for optimization. result = await self._store.get_partial_values(list(key_ranges)) - return [prototype.buffer.from_bytes(r) if r is not None else None for r in result] + return [prototype.buffer.from_bytes(r) for r in result] async def exists(self, key: str) -> bool: """Check if a key exists in the store. @@ -392,7 +392,7 @@ async def set_partial_values( """ # NOTE: pyo3 does not implicit conversion from an Iterable to a rust iterable. So we convert it # to a list here first. Possible opportunity for optimization. - return await self._store.set_partial_values(list(key_start_values)) # type: ignore + return await self._store.set_partial_values(list(key_start_values)) @property def supports_listing(self) -> bool: From 1aec1eed0dc6d2b6558e21fb3cfdcad1f897d01c Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Tue, 8 Oct 2024 10:32:28 -0300 Subject: [PATCH 045/167] ruff --- icechunk-python/python/icechunk/_icechunk_python.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index f7451515..68fe74b4 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -1,7 +1,7 @@ -from typing import Any import abc import datetime from collections.abc import AsyncGenerator +from typing import Any class PyIcechunkStore: def as_bytes(self) -> bytes: ... From aa83a0c40d94d523f826d4607d019b688b067674 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Tue, 8 Oct 2024 14:42:25 -0300 Subject: [PATCH 046/167] Icechunk is python >= 3.11 --- .github/workflows/python-ci.yaml | 2 +- icechunk-python/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml index 78dd4851..b4e7b435 100644 --- a/.github/workflows/python-ci.yaml +++ b/.github/workflows/python-ci.yaml @@ -60,7 +60,7 @@ jobs: docker compose exec -T minio mc alias set minio http://minio:9000 minio123 minio123 - uses: actions/setup-python@v5 with: - python-version: 3.x + python-version: '3.11' - name: Build wheels uses: PyO3/maturin-action@v1 with: diff --git a/icechunk-python/pyproject.toml b/icechunk-python/pyproject.toml index a35756e7..f02766a8 100644 --- a/icechunk-python/pyproject.toml +++ b/icechunk-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "icechunk" -requires-python = ">=3.10" +requires-python = ">=3.11" classifiers = [ "Programming Language :: Rust", "Programming Language :: Python :: Implementation :: CPython", From 0e728535d32e2053d40a6635b364ea5082096c29 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Tue, 8 Oct 2024 17:32:57 -0300 Subject: [PATCH 047/167] Add new dtypes string and bytes --- icechunk/src/metadata/data_type.rs | 21 +++++++------------- icechunk/src/metadata/fill_value.rs | 30 ++++++++++++++++++++--------- icechunk/src/zarr.rs | 3 ++- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/icechunk/src/metadata/data_type.rs b/icechunk/src/metadata/data_type.rs index c052d1be..4349c5f2 100644 --- a/icechunk/src/metadata/data_type.rs +++ b/icechunk/src/metadata/data_type.rs @@ -20,8 +20,8 @@ pub enum DataType { Float64, Complex64, Complex128, - // FIXME: serde serialization - RawBits(usize), + String, + Bytes, } impl DataType { @@ -67,17 +67,9 @@ impl TryFrom<&str> for DataType { "float64" => Ok(DataType::Float64), "complex64" => Ok(DataType::Complex64), "complex128" => Ok(DataType::Complex128), - _ => { - let mut it = value.chars(); - if it.next() == Some('r') { - it.as_str() - .parse() - .map(DataType::RawBits) - .map_err(|_| "Cannot parse RawBits size") - } else { - Err("Unknown data type, cannot parse") - } - } + "string" => Ok(DataType::String), + "bytes" => Ok(DataType::Bytes), + _ => Err("Unknown data type, cannot parse"), } } } @@ -100,7 +92,8 @@ impl Display for DataType { Float64 => f.write_str("float64"), Complex64 => f.write_str("complex64"), Complex128 => f.write_str("complex128"), - RawBits(usize) => write!(f, "r{}", usize), + String => f.write_str("string"), + Bytes => f.write_str("bytes"), } } } diff --git a/icechunk/src/metadata/fill_value.rs b/icechunk/src/metadata/fill_value.rs index 7f03f49e..31910248 100644 --- a/icechunk/src/metadata/fill_value.rs +++ b/icechunk/src/metadata/fill_value.rs @@ -1,3 +1,4 @@ +use itertools::Itertools; use serde::{Deserialize, Serialize}; use test_strategy::Arbitrary; @@ -22,7 +23,8 @@ pub enum FillValue { Float64(f64), Complex64(f32, f32), Complex128(f64, f64), - RawBits(Vec), + String(String), + Bytes(Vec), } impl FillValue { @@ -181,20 +183,29 @@ impl FillValue { } } - (DataType::RawBits(n), serde_json::Value::Array(arr)) if arr.len() == *n => { - let bits = arr + (DataType::String, serde_json::Value::String(s)) => { + Ok(FillValue::String(s.clone())) + } + + (DataType::Bytes, serde_json::Value::Array(arr)) => { + let bytes = arr .iter() .map(|b| FillValue::from_data_type_and_json(&DataType::UInt8, b)) .collect::, _>>()?; - Ok(FillValue::RawBits( - bits.iter() + Ok(FillValue::Bytes( + bytes + .iter() .map(|b| match b { - FillValue::UInt8(n) => *n, - _ => 0, + FillValue::UInt8(n) => Ok(*n), + _ => Err(IcechunkFormatError::FillValueParse { + data_type: dt.clone(), + value: value.clone(), + }), }) - .collect(), + .try_collect()?, )) } + _ => Err(IcechunkFormatError::FillValueParse { data_type: dt.clone(), value: value.clone(), @@ -218,7 +229,8 @@ impl FillValue { FillValue::Float64(_) => DataType::Float64, FillValue::Complex64(_, _) => DataType::Complex64, FillValue::Complex128(_, _) => DataType::Complex128, - FillValue::RawBits(v) => DataType::RawBits(v.len()), + FillValue::String(_) => DataType::String, + FillValue::Bytes(_) => DataType::Bytes, } } } diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index f42c3640..454b471a 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -1168,7 +1168,8 @@ impl From for ZarrArrayMetadataSerialzer { } FillValue::Complex64(r, i) => ([r, i].as_ref()).into(), FillValue::Complex128(r, i) => ([r, i].as_ref()).into(), - FillValue::RawBits(r) => r.into(), + FillValue::String(s) => s.into(), + FillValue::Bytes(b) => b.into(), } } From b183eddbaee62205ef3ba9073af6b7d6f4cfe2da Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Tue, 8 Oct 2024 20:21:41 -0400 Subject: [PATCH 048/167] Switch tokio runtime to use bundled pyo3_async (#167) --- icechunk-python/src/lib.rs | 36 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/icechunk-python/src/lib.rs b/icechunk-python/src/lib.rs index 33c8c3fd..a1b13bb0 100644 --- a/icechunk-python/src/lib.rs +++ b/icechunk-python/src/lib.rs @@ -29,7 +29,6 @@ use tokio::sync::{Mutex, RwLock}; struct PyIcechunkStore { consolidated: ConsolidatedStore, store: Arc>, - rt: tokio::runtime::Runtime, } #[pyclass(name = "StoreConfig")] @@ -166,8 +165,7 @@ impl PyIcechunkStore { let store = Store::from_consolidated(&consolidated, access_mode).await?; let store = Arc::new(RwLock::new(store)); - let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; - Ok(Self { consolidated, store, rt }) + Ok(Self { consolidated, store }) } async fn as_consolidated(&self) -> PyIcechunkStoreResult { @@ -259,7 +257,8 @@ impl PyIcechunkStore { } fn as_bytes(&self) -> PyResult> { - let consolidated = self.rt.block_on(self.as_consolidated())?; + let consolidated = + pyo3_asyncio_0_21::tokio::get_runtime().block_on(self.as_consolidated())?; // FIXME: Use rmp_serde instead of serde_json to optimize performance let serialized = serde_json::to_vec(&consolidated) @@ -275,12 +274,10 @@ impl PyIcechunkStore { }; let readable_store = self.store.blocking_read(); - let consolidated = self.rt.block_on(self.as_consolidated())?; + let consolidated = + pyo3_asyncio_0_21::tokio::get_runtime().block_on(self.as_consolidated())?; let store = Arc::new(RwLock::new(readable_store.with_access_mode(access_mode))); - let rt = tokio::runtime::Runtime::new() - .map_err(|e| PyValueError::new_err(e.to_string()))?; - - Ok(PyIcechunkStore { consolidated, store, rt }) + Ok(PyIcechunkStore { consolidated, store }) } fn checkout_snapshot<'py>( @@ -340,7 +337,8 @@ impl PyIcechunkStore { #[getter] fn snapshot_id(&self) -> PyIcechunkStoreResult { let store = self.store.blocking_read(); - let snapshot_id = self.rt.block_on(store.snapshot_id()); + let snapshot_id = + pyo3_asyncio_0_21::tokio::get_runtime().block_on(store.snapshot_id()); Ok(snapshot_id.to_string()) } @@ -385,8 +383,7 @@ impl PyIcechunkStore { fn change_set_bytes(&self) -> PyIcechunkStoreResult> { let store = self.store.blocking_read(); - let res = self - .rt + let res = pyo3_asyncio_0_21::tokio::get_runtime() .block_on(store.change_set_bytes()) .map_err(PyIcechunkStoreError::from)?; Ok(res) @@ -402,7 +399,8 @@ impl PyIcechunkStore { #[getter] fn has_uncommitted_changes(&self) -> PyIcechunkStoreResult { let store = self.store.blocking_read(); - let has_uncommitted_changes = self.rt.block_on(store.has_uncommitted_changes()); + let has_uncommitted_changes = pyo3_asyncio_0_21::tokio::get_runtime() + .block_on(store.has_uncommitted_changes()); Ok(has_uncommitted_changes) } @@ -459,8 +457,7 @@ impl PyIcechunkStore { } fn ancestry(&self) -> PyIcechunkStoreResult { - let list = self - .rt + let list = pyo3_asyncio_0_21::tokio::get_runtime() .block_on(async move { let store = self.store.read().await; store.ancestry().await @@ -713,8 +710,7 @@ impl PyIcechunkStore { } fn list(&self) -> PyIcechunkStoreResult { - let list = self - .rt + let list = pyo3_asyncio_0_21::tokio::get_runtime() .block_on(async move { let store = self.store.read().await; store.list().await @@ -726,8 +722,7 @@ impl PyIcechunkStore { } fn list_prefix(&self, prefix: String) -> PyIcechunkStoreResult { - let list = self - .rt + let list = pyo3_asyncio_0_21::tokio::get_runtime() .block_on(async move { let store = self.store.read().await; store.list_prefix(prefix.as_str()).await @@ -738,8 +733,7 @@ impl PyIcechunkStore { } fn list_dir(&self, prefix: String) -> PyIcechunkStoreResult { - let list = self - .rt + let list = pyo3_asyncio_0_21::tokio::get_runtime() .block_on(async move { let store = self.store.read().await; store.list_dir(prefix.as_str()).await From 3cf8834f5671b6e6cf3f4c62d62b48c2b94b1842 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Wed, 9 Oct 2024 20:45:34 -0300 Subject: [PATCH 049/167] Implement zarr's `clear` --- .../test_store/test_icechunk_store.py | 1 - icechunk/src/change_set.rs | 17 ++-- icechunk/src/format/manifest.rs | 4 + icechunk/src/format/snapshot.rs | 4 + icechunk/src/repository.rs | 86 +++++++++++++------ icechunk/src/zarr.rs | 86 ++++++++++++++++++- 6 files changed, 166 insertions(+), 32 deletions(-) diff --git a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py index 1ef112b9..1103655e 100644 --- a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py +++ b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py @@ -122,7 +122,6 @@ def test_store_supports_partial_writes(self, store: IcechunkStore) -> None: async def test_list_prefix(self, store: IcechunkStore) -> None: assert True - @pytest.mark.xfail(reason="Not implemented") async def test_clear(self, store: IcechunkStore) -> None: await self.set( store, diff --git a/icechunk/src/change_set.rs b/icechunk/src/change_set.rs index e91c09e0..bf50344d 100644 --- a/icechunk/src/change_set.rs +++ b/icechunk/src/change_set.rs @@ -175,7 +175,11 @@ impl ChangeSet { pub fn array_chunks_iterator( &self, node_id: NodeId, + node_path: &Path, ) -> impl Iterator)> { + if self.is_deleted(node_path) { + return Either::Left(iter::empty()); + } match self.set_chunks.get(&node_id) { None => Either::Left(iter::empty()), Some(h) => Either::Right(h.iter()), @@ -186,7 +190,7 @@ impl ChangeSet { &self, ) -> impl Iterator + '_ { self.new_arrays.iter().flat_map(|(path, (node_id, _))| { - self.array_chunks_iterator(*node_id).filter_map(|(coords, payload)| { + self.array_chunks_iterator(*node_id, path).filter_map(|(coords, payload)| { payload.as_ref().map(|p| { ( path.clone(), @@ -315,22 +319,25 @@ impl ChangeSet { &'a self, manifest_id: &'a ManifestId, ) -> impl Iterator + 'a { - self.new_nodes().map(move |path| { + self.new_nodes().filter_map(move |path| { + if self.is_deleted(path) { + return None; + } // we should be able to create the full node because we // know it's a new node #[allow(clippy::expect_used)] let node = self.get_new_node(path).expect("Bug in new_nodes implementation"); match node.node_data { - NodeData::Group => node, + NodeData::Group => Some(node), NodeData::Array(meta, _no_manifests_yet) => { let new_manifests = vec![ManifestRef { object_id: manifest_id.clone(), extents: ManifestExtents(vec![]), }]; - NodeSnapshot { + Some(NodeSnapshot { node_data: NodeData::Array(meta, new_manifests), ..node - } + }) } } }) diff --git a/icechunk/src/format/manifest.rs b/icechunk/src/format/manifest.rs index 317a9032..95d8856c 100644 --- a/icechunk/src/format/manifest.rs +++ b/icechunk/src/format/manifest.rs @@ -139,6 +139,10 @@ impl Manifest { pub fn chunks(&self) -> &BTreeMap<(NodeId, ChunkIndices), ChunkPayload> { &self.chunks } + + pub fn size(&self) -> usize { + self.chunks.len() + } } impl FromIterator for Manifest { diff --git a/icechunk/src/format/snapshot.rs b/icechunk/src/format/snapshot.rs index a7930e7a..bd21dbd9 100644 --- a/icechunk/src/format/snapshot.rs +++ b/icechunk/src/format/snapshot.rs @@ -207,6 +207,10 @@ impl Snapshot { (0..self.short_term_history.len()) .map(move |ix| self.short_term_history[ix].clone()) } + + pub fn size(&self) -> usize { + self.nodes.len() + } } // We need this complex dance because Rust makes it really hard to put together an object and a diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index 97de9f62..990be5a4 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -316,10 +316,18 @@ impl Repository { } } + /// Delete a group in the hierarchy + /// + /// Deletes of non existing groups will succeed. pub async fn delete_group(&mut self, path: Path) -> RepositoryResult<()> { - self.get_group(&path) - .await - .map(|node| self.change_set.delete_group(node.path, node.id)) + match self.get_group(&path).await { + Ok(node) => { + self.change_set.delete_group(node.path, node.id); + } + Err(RepositoryError::NodeNotFound { .. }) => {} + Err(err) => Err(err)?, + } + Ok(()) } /// Add an array to the store. @@ -357,10 +365,18 @@ impl Repository { .map(|node| self.change_set.update_array(node.id, metadata)) } + /// Delete an array in the hierarchy + /// + /// Deletes of non existing array will succeed. pub async fn delete_array(&mut self, path: Path) -> RepositoryResult<()> { - self.get_array(&path) - .await - .map(|node| self.change_set.delete_array(node.path, node.id)) + match self.get_array(&path).await { + Ok(node) => { + self.change_set.delete_array(node.path, node.id); + } + Err(RepositoryError::NodeNotFound { .. }) => {} + Err(err) => Err(err)?, + } + Ok(()) } /// Record the write or delete of user attributes to array or group @@ -625,6 +641,19 @@ impl Repository { } } + pub async fn clear(&mut self) -> RepositoryResult<()> { + let to_delete: Vec<(NodeType, Path)> = + self.list_nodes().await?.map(|node| (node.node_type(), node.path)).collect(); + + for (t, p) in to_delete { + match t { + NodeType::Group => self.delete_group(p).await?, + NodeType::Array => self.delete_array(p).await?, + } + } + Ok(()) + } + async fn get_old_chunk( &self, node: NodeId, @@ -655,13 +684,28 @@ impl Repository { let nodes = futures::stream::iter(snapshot.iter_arc()); let res = nodes.then(move |node| async move { let path = node.path.clone(); - self.node_chunk_iterator(node).await.map_ok(move |ci| (path.clone(), ci)) + self.node_chunk_iterator(&node.path) + .await + .map_ok(move |ci| (path.clone(), ci)) }); Ok(res.flatten()) } /// Warning: The presence of a single error may mean multiple missing items async fn node_chunk_iterator( + &self, + path: &Path, + ) -> impl Stream> + '_ { + match self.get_node(path).await { + Ok(node) => futures::future::Either::Left( + self.verified_node_chunk_iterator(node).await, + ), + Err(_) => futures::future::Either::Right(futures::stream::empty()), + } + } + + /// Warning: The presence of a single error may mean multiple missing items + async fn verified_node_chunk_iterator( &self, node: NodeSnapshot, ) -> impl Stream> + '_ { @@ -670,14 +714,14 @@ impl Repository { NodeData::Array(_, manifests) => { let new_chunk_indices: Box> = Box::new( self.change_set - .array_chunks_iterator(node.id) + .array_chunks_iterator(node.id, &node.path) .map(|(idx, _)| idx) .collect(), ); let new_chunks = self .change_set - .array_chunks_iterator(node.id) + .array_chunks_iterator(node.id, &node.path) .filter_map(move |(idx, payload)| { payload.as_ref().map(|payload| { Ok(ChunkInfo { @@ -1149,12 +1193,8 @@ mod tests { // deleting the added group must succeed prop_assert!(repository.delete_group(path.clone()).await.is_ok()); - // deleting twice must fail - let matches = matches!( - repository.delete_group(path.clone()).await.unwrap_err(), - RepositoryError::NodeNotFound{path: reported_path, ..} if reported_path == path - ); - prop_assert!(matches); + // deleting twice must succeed + prop_assert!(repository.delete_group(path.clone()).await.is_ok()); // getting a deleted group must fail prop_assert!(repository.get_node(&path).await.is_err()); @@ -1181,12 +1221,8 @@ mod tests { // first delete must succeed prop_assert!(repository.delete_array(path.clone()).await.is_ok()); - // deleting twice must fail - let matches = matches!( - repository.delete_array(path.clone()).await.unwrap_err(), - RepositoryError::NodeNotFound{path: reported_path, ..} if reported_path == path - ); - prop_assert!(matches); + // deleting twice must succeed + prop_assert!(repository.delete_array(path.clone()).await.is_ok()); // adding again must succeed prop_assert!(repository.add_array(path.clone(), metadata.clone()).await.is_ok()); @@ -1325,7 +1361,8 @@ mod tests { let group_name = "/tbd-group".to_string(); ds.add_group(group_name.clone().try_into().unwrap()).await?; ds.delete_group(group_name.clone().try_into().unwrap()).await?; - assert!(ds.delete_group(group_name.clone().try_into().unwrap()).await.is_err()); + // deleting non-existing is no-op + assert!(ds.delete_group(group_name.clone().try_into().unwrap()).await.is_ok()); assert!(ds.get_node(&group_name.try_into().unwrap()).await.is_err()); // add a new array and retrieve its node @@ -1349,9 +1386,8 @@ mod tests { ds.add_array(new_array_path.clone(), zarr_meta2.clone()).await?; ds.delete_array(new_array_path.clone()).await?; - // Delete a non-existent array - assert!(ds.delete_array(new_array_path.clone()).await.is_err()); - assert!(ds.delete_array(new_array_path.clone()).await.is_err()); + // Delete a non-existent array is no-op + assert!(ds.delete_array(new_array_path.clone()).await.is_ok()); assert!(ds.get_node(&new_array_path.clone()).await.is_err()); ds.add_array(new_array_path.clone(), zarr_meta2.clone()).await?; diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index 454b471a..88dc837d 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -495,7 +495,8 @@ impl Store { } pub async fn clear(&mut self) -> StoreResult<()> { - todo!() + let mut repo = self.repository.write().await; + Ok(repo.clear().await?) } pub async fn get(&self, key: &str, byte_range: &ByteRange) -> StoreResult { @@ -2155,6 +2156,89 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_clear() -> Result<(), Box> { + let storage: Arc = + Arc::new(ObjectStorage::new_in_memory_store(Some("prefix".into()))); + + let mut store = Store::new_from_storage(Arc::clone(&storage)).await?; + + store + .set( + "zarr.json", + Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group"}"#), + ) + .await + .unwrap(); + + let empty: Vec = Vec::new(); + store.clear().await?; + assert_eq!( + store.list_prefix("").await?.try_collect::>().await?, + empty + ); + + store + .set( + "zarr.json", + Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group"}"#), + ) + .await + .unwrap(); + store + .set( + "group/zarr.json", + Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group"}"#), + ) + .await + .unwrap(); + let zarr_meta = Bytes::copy_from_slice(br#"{"zarr_format":3,"node_type":"array","attributes":{"foo":42},"shape":[2,2,2],"data_type":"int32","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[1,1,1]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value":0,"codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[{"name":"mytransformer","configuration":{"bar":43}}],"dimension_names":["x","y","t"]}"#); + let new_data = Bytes::copy_from_slice(b"world"); + store.set("array/zarr.json", zarr_meta.clone()).await.unwrap(); + store.set("group/array/zarr.json", zarr_meta.clone()).await.unwrap(); + store.set("array/c/1/0/0", new_data.clone()).await.unwrap(); + store.set("group/array/c/1/0/0", new_data.clone()).await.unwrap(); + + let _ = store.commit("initial commit").await.unwrap(); + + store + .set( + "group/group2/zarr.json", + Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group"}"#), + ) + .await + .unwrap(); + store.set("group/group2/array/zarr.json", zarr_meta.clone()).await.unwrap(); + store.set("group/group2/array/c/1/0/0", new_data.clone()).await.unwrap(); + + store.clear().await?; + + assert_eq!( + store.list_prefix("").await?.try_collect::>().await?, + empty + ); + + let empty_snap = store.commit("no content commit").await.unwrap(); + + assert_eq!( + store.list_prefix("").await?.try_collect::>().await?, + empty + ); + + let store = Store::from_repository( + Repository::update(Arc::clone(&storage), empty_snap).build(), + AccessMode::ReadWrite, + None, + None, + ); + assert_eq!( + store.list_prefix("").await?.try_collect::>().await?, + empty + ); + + Ok(()) + } + #[tokio::test] async fn test_access_mode() { let storage: Arc = From c597fc14492ad2ceb133877c5f5907c64a0dcaf6 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Wed, 9 Oct 2024 22:25:30 -0300 Subject: [PATCH 050/167] Fix `list_prefix` This seems to be the behavior zarr-python expects. Closes #168 --- icechunk/src/zarr.rs | 59 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index 88dc837d..0d41007c 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -823,8 +823,20 @@ impl Store { for node in repository.list_nodes().await? { // TODO: handle non-utf8? let meta_key = Key::Metadata { node_path: node.path }.to_string(); - if meta_key.starts_with(prefix) { - yield meta_key; + match meta_key.strip_prefix(prefix) { + None => {} + Some(rest) => { + // we have a few cases + if prefix.is_empty() // if prefix was empty anything matches + || rest.is_empty() // if stripping prefix left empty we have a match + || rest.starts_with('/') // next component so we match + // what we don't include is other matches, + // we want to catch prefix/foo but not prefix-foo + { + yield meta_key; + } + + } } } }; @@ -2023,6 +2035,49 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_list_dir_with_prefix() -> Result<(), Box> { + let storage: Arc = + Arc::new(ObjectStorage::new_in_memory_store(Some("prefix".into()))); + let ds = Repository::init(Arc::clone(&storage), false).await?.build(); + let mut store = Store::from_repository( + ds, + AccessMode::ReadWrite, + Some("main".to_string()), + None, + ); + + store + .borrow_mut() + .set( + "zarr.json", + Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group"}"#), + ) + .await?; + + store + .borrow_mut() + .set( + "group/zarr.json", + Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group"}"#), + ) + .await?; + + store + .borrow_mut() + .set( + "group-suffix/zarr.json", + Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group"}"#), + ) + .await?; + + assert_eq!( + store.list_dir("group/").await?.try_collect::>().await?, + vec!["zarr.json"] + ); + Ok(()) + } + #[tokio::test] async fn test_get_partial_values() -> Result<(), Box> { let storage: Arc = From c94f2e77055ac2ba1b67de7670310f529d800df8 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Thu, 10 Oct 2024 11:44:27 -0300 Subject: [PATCH 051/167] python: handle open modes explicitly Also better error message when trying to open an existing store and it doesn't exist --- icechunk-python/python/icechunk/__init__.py | 60 ++++++++++++--------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index f4457c3d..c94851fe 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -2,7 +2,7 @@ from collections.abc import AsyncGenerator, Iterable from typing import Any, Self -from zarr.abc.store import AccessMode, ByteRangeRequest, Store +from zarr.abc.store import ByteRangeRequest, Store from zarr.core.buffer import Buffer, BufferPrototype from zarr.core.common import AccessModeLiteral, BytesLike from zarr.core.sync import SyncMixin @@ -37,16 +37,11 @@ class IcechunkStore(Store, SyncMixin): @classmethod async def open(cls, *args: Any, **kwargs: Any) -> Self: - """FIXME: Better handle the open method based on the access mode the user passed in along with the kwargs - https://github.com/zarr-developers/zarr-python/blob/c878da2a900fc621ff23cc6d84d45cd3cb26cbed/src/zarr/abc/store.py#L24-L30 - """ if "mode" in kwargs: mode = kwargs.pop("mode") else: mode = "r" - access_mode = AccessMode.from_literal(mode) - if "storage" in kwargs: storage = kwargs.pop("storage") else: @@ -54,22 +49,28 @@ async def open(cls, *args: Any, **kwargs: Any) -> Self: "Storage configuration is required. Pass a Storage object to construct an IcechunkStore" ) - store_exists = await pyicechunk_store_exists(storage) - - if access_mode.overwrite: - if store_exists: - raise ValueError( - "Store already exists and overwrite is not allowed for IcechunkStore" - ) - store = await cls.create(storage, mode, *args, **kwargs) - elif access_mode.create or access_mode.update: - if store_exists: + store = None + match mode: + case "r" | "r+": store = await cls.open_existing(storage, mode, *args, **kwargs) - else: - store = await cls.create(storage, mode, *args, **kwargs) - else: - store = await cls.open_existing(storage, mode, *args, **kwargs) - + case "a": + if await pyicechunk_store_exists(storage): + store = await cls.open_existing(storage, mode, *args, **kwargs) + else: + store = await cls.create(storage, mode, *args, **kwargs) + case "w": + if await pyicechunk_store_exists(storage): + store = await cls.open_existing(storage, mode, *args, **kwargs) + await store.clear() + else: + store = await cls.create(storage, mode, *args, **kwargs) + case "w-": + if await pyicechunk_store_exists(storage): + raise ValueError("""Zarr store already exists, open using mode "w" or "r+""""") + else: + store = await cls.create(storage, mode, *args, **kwargs) + + assert(store) # We dont want to call _open() becuase icechunk handles the opening, etc. # if we have gotten this far we can mark it as open store._is_open = True @@ -112,9 +113,20 @@ async def open_existing( """ config = config or StoreConfig() read_only = mode == "r" - store = await pyicechunk_store_open_existing( - storage, read_only=read_only, config=config - ) + # We have delayed checking if the repository exists, to avoid the delay in the happy case + # So we need to check now if open fails, to provide a nice error message + try: + store = await pyicechunk_store_open_existing( + storage, read_only=read_only, config=config + ) + # TODO: we should have an exception type to catch here, for the case of non-existing repo + except Exception as e: + if await pyicechunk_store_exists(storage): + # if the repo exists, this is an actual error we need to raise + raise e + else: + # if the repo doesn't exists, we want to point users to that issue instead + raise ValueError("No Icechunk repository at the provided location, try opening in create mode or changing the location") from None return cls(store=store, mode=mode, args=args, kwargs=kwargs) @classmethod From cb06e2995b640f09267ca381286a8a0e916c42f9 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Thu, 10 Oct 2024 12:09:34 -0300 Subject: [PATCH 052/167] fix tests --- icechunk-python/tests/test_virtual_ref.py | 2 +- .../tests/test_zarr/test_store/test_icechunk_store.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/icechunk-python/tests/test_virtual_ref.py b/icechunk-python/tests/test_virtual_ref.py index 543f01c8..59611878 100644 --- a/icechunk-python/tests/test_virtual_ref.py +++ b/icechunk-python/tests/test_virtual_ref.py @@ -51,7 +51,7 @@ async def test_write_minino_virtual_refs(): allow_http=True, region="us-east-1", ), - mode="r+", + mode="w", config=StoreConfig( virtual_ref_config=VirtualRefConfig.s3_from_config( credentials=S3Credentials( diff --git a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py index 1103655e..c837d08f 100644 --- a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py +++ b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py @@ -52,7 +52,7 @@ def store_kwargs( ) -> dict[str, str | None | dict[str, Buffer]]: kwargs = { "storage": StorageConfig.memory(""), - "mode": "r+", + "mode": "w", } return kwargs @@ -68,6 +68,10 @@ def test_store_repr(self, store: IcechunkStore) -> None: def test_serializable_store(self, store: IcechunkStore) -> None: super().test_serializable_store(store) + def test_store_mode(self, store, store_kwargs: dict[str, Any]) -> None: + assert store.mode == AccessMode.from_literal("w") + assert not store.mode.readonly + async def test_not_writable_store_raises(self, store_kwargs: dict[str, Any]) -> None: create_kwargs = {**store_kwargs, "mode": "r"} with pytest.raises(ValueError): From 33a1a7e53d565a88aaacf561c8d4fd24168e233b Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Thu, 10 Oct 2024 11:49:51 -0400 Subject: [PATCH 053/167] Start tracking down virtual ref errors --- icechunk-python/python/icechunk/__init__.py | 1 + .../python/icechunk/_icechunk_python.pyi | 4 +- icechunk-python/tests/test_virtual_ref.py | 59 +++++++++++++---- icechunk/tests/test_virtual_refs.rs | 65 ++++++++++++++++--- 4 files changed, 106 insertions(+), 23 deletions(-) diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index f4457c3d..7761e775 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -282,6 +282,7 @@ async def get( except ValueError as _e: # Zarr python expects None to be returned if the key does not exist # but an IcechunkStore returns an error if the key does not exist + print(_e) return None return prototype.buffer.from_bytes(result) diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index 68fe74b4..a031dec5 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -173,7 +173,7 @@ class VirtualRefConfig: region: str | None @classmethod - def s3_from_env(cls) -> StorageConfig: + def s3_from_env(cls) -> VirtualRefConfig: """Create a VirtualReferenceConfig object for an S3 Object Storage compatible storage backend with the given bucket and prefix @@ -194,7 +194,7 @@ class VirtualRefConfig: endpoint_url: str | None, allow_http: bool | None = None, region: str | None = None, - ) -> StorageConfig: + ) -> VirtualRefConfig: """Create a VirtualReferenceConfig object for an S3 Object Storage compatible storage backend with the given bucket, prefix, and configuration diff --git a/icechunk-python/tests/test_virtual_ref.py b/icechunk-python/tests/test_virtual_ref.py index 543f01c8..bf23f11e 100644 --- a/icechunk-python/tests/test_virtual_ref.py +++ b/icechunk-python/tests/test_virtual_ref.py @@ -1,3 +1,4 @@ +import numpy as np import zarr import zarr.core import zarr.core.buffer @@ -38,19 +39,9 @@ async def test_write_minino_virtual_refs(): ] ) - # Open the store, the S3 credentials must be set in environment vars for this to work for now + # Open the store store = await IcechunkStore.open( - storage=StorageConfig.s3_from_config( - bucket="testbucket", - prefix="python-virtual-ref", - credentials=S3Credentials( - access_key_id="minio123", - secret_access_key="minio123", - ), - endpoint_url="http://localhost:9000", - allow_http=True, - region="us-east-1", - ), + storage=StorageConfig.memory("virtual"), mode="r+", config=StoreConfig( virtual_ref_config=VirtualRefConfig.s3_from_config( @@ -86,3 +77,47 @@ async def test_write_minino_virtual_refs(): assert array[0, 0, 0] == 1936877926 assert array[0, 0, 1] == 1852793701 + + _snapshot_id = await store.commit("Add virtual refs") + + +async def test_from_s3_public_virtual_refs(tmpdir): + buffer_prototype = zarr.core.buffer.default_buffer_prototype() + + # Open the store, + store = await IcechunkStore.open( + storage=StorageConfig.filesystem(f'{tmpdir}/virtual'), + mode="w", + config=StoreConfig( + virtual_ref_config=VirtualRefConfig.s3_from_env() + ), + ) + + root = zarr.Group.from_store(store=store, zarr_format=3) + depth = root.require_array( + name="depth", shape=((22, )), chunk_shape=((22,)), dtype="float64" + ) + + await store.set_virtual_ref( + "depth/c/0", + "s3://noaa-nos-ofs-pds/dbofs/netcdf/202410/dbofs.t00z.20241009.regulargrid.f030.nc", + offset=42499, + length=176 + ) + + nodes = [n async for n in store.list()] + assert "depth/c/0" in nodes + + # dd = await store.get("depth/c/0", prototype=buffer_prototype) + # print(dd) + + # depth_values = depth[:] + # assert len(depth_values) == 22 + # actual_values = np.array([ + # 0., 1., 2., 4., 6., 8., 10., 12., 15., 20., 25., + # 30., 35., 40., 45., 50., 60., 70., 80., 90., 100., 125. + # ]) + # assert np.allclose(depth_values, actual_values) + + + diff --git a/icechunk/tests/test_virtual_refs.rs b/icechunk/tests/test_virtual_refs.rs index 5da2e7ce..7d21fb70 100644 --- a/icechunk/tests/test_virtual_refs.rs +++ b/icechunk/tests/test_virtual_refs.rs @@ -26,7 +26,7 @@ mod tests { }; use pretty_assertions::assert_eq; - fn s3_config() -> S3Config { + fn minino_s3_config() -> S3Config { S3Config { region: Some("us-east-1".to_string()), endpoint: Some("http://localhost:9000".to_string()), @@ -39,12 +39,21 @@ mod tests { } } - async fn create_repository(storage: Arc) -> Repository { + fn anon_s3_config() -> S3Config { + S3Config { + region: Some("us-east".to_string()), + endpoint: None, + credentials: None, + allow_http: None, + } + } + + async fn create_repository(storage: Arc, virtual_s3_config: S3Config) -> Repository { Repository::init(storage, true) .await .expect("building repository failed") .with_virtual_ref_config(ObjectStoreVirtualChunkResolverConfig::S3( - s3_config(), + virtual_s3_config, )) .build() } @@ -67,12 +76,12 @@ mod tests { .expect(&format!("putting chunk to {} failed", &path)); } } - async fn create_local_repository(path: &StdPath) -> Repository { + async fn create_local_repository(path: &StdPath, virtual_s3_config: S3Config) -> Repository { let storage: Arc = Arc::new( ObjectStorage::new_local_store(path).expect("Creating local storage failed"), ); - create_repository(storage).await + create_repository(storage, virtual_s3_config).await } async fn create_minio_repository() -> Repository { @@ -80,13 +89,13 @@ mod tests { S3Storage::new_s3_store( "testbucket".to_string(), format!("{:?}", ChunkId::random()), - Some(&s3_config()), + Some(&minino_s3_config()), ) .await .expect("Creating minio storage failed"), ); - create_repository(storage).await + create_repository(storage, minino_s3_config()).await } async fn write_chunks_to_local_fs(chunks: impl Iterator) { @@ -96,7 +105,7 @@ mod tests { } async fn write_chunks_to_minio(chunks: impl Iterator) { - let client = mk_client(Some(&s3_config())).await; + let client = mk_client(Some(&minino_s3_config())).await; let bucket_name = "testbucket".to_string(); for (key, bytes) in chunks { @@ -123,7 +132,7 @@ mod tests { write_chunks_to_local_fs(chunks.iter().cloned()).await; let repo_dir = TempDir::new()?; - let mut ds = create_local_repository(repo_dir.path()).await; + let mut ds = create_local_repository(repo_dir.path(), anon_s3_config()).await; let zarr_meta = ZarrArrayMetadata { shape: vec![1, 1, 2], @@ -392,4 +401,42 @@ mod tests { ); Ok(()) } + + #[tokio::test] + async fn test_zarr_store_virtual_refs_from_public_s3() -> Result<(), Box> { + let repo_dir = TempDir::new()?; + let ds = create_local_repository(repo_dir.path(), anon_s3_config()).await; + + let mut store = Store::from_repository( + ds, + AccessMode::ReadWrite, + Some("main".to_string()), + None, + ); + + store + .set( + "zarr.json", + Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group"}"#), + ) + .await + .unwrap(); + + let zarr_meta = Bytes::copy_from_slice(br#"{"zarr_format":3,"node_type":"array","attributes":{"foo":42},"shape":[22],"data_type":"float64","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[22]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value": 0.0,"codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[],"dimension_names":["depth"]}"#); + store.set("depth/zarr.json", zarr_meta.clone()).await.unwrap(); + + let ref2 = VirtualChunkRef { + location: VirtualChunkLocation::from_absolute_path( + "s3://noaa-nos-ofs-pds/dbofs/netcdf/202410/dbofs.t00z.20241009.regulargrid.f030.nc", + )?, + offset: 42499, + length: 176, + }; + + store.set_virtual_ref("depth/c/0", ref2).await?; + + let _chunk = store.get("depth/c/0", &ByteRange::ALL).await.unwrap(); + + Ok(()) + } } From fc5c333049206fec47d3e745d2d1d4ff8550fb4b Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Thu, 10 Oct 2024 12:02:11 -0400 Subject: [PATCH 054/167] Add public s3 test from rust --- icechunk/tests/test_virtual_refs.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/icechunk/tests/test_virtual_refs.rs b/icechunk/tests/test_virtual_refs.rs index 7d21fb70..4deaf1f9 100644 --- a/icechunk/tests/test_virtual_refs.rs +++ b/icechunk/tests/test_virtual_refs.rs @@ -41,7 +41,7 @@ mod tests { fn anon_s3_config() -> S3Config { S3Config { - region: Some("us-east".to_string()), + region: Some("us-east-1".to_string()), endpoint: None, credentials: None, allow_http: None, @@ -435,7 +435,14 @@ mod tests { store.set_virtual_ref("depth/c/0", ref2).await?; - let _chunk = store.get("depth/c/0", &ByteRange::ALL).await.unwrap(); + let chunk = store.get("depth/c/0", &ByteRange::ALL).await.unwrap(); + assert_eq!(chunk.len(), 176); + + let second_depth = f64::from_le_bytes(chunk[8..16].try_into().unwrap()); + assert!(second_depth - 1. < 0.000001); + + let last_depth = f64::from_le_bytes(chunk[(176 - 8)..].try_into().unwrap()); + assert!(last_depth - 125. < 0.000001); Ok(()) } From 03df16bff9f0cbdcd8205725c721f2e364172312 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Thu, 10 Oct 2024 12:03:13 -0400 Subject: [PATCH 055/167] Add public test from python --- icechunk-python/tests/test_virtual_ref.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/icechunk-python/tests/test_virtual_ref.py b/icechunk-python/tests/test_virtual_ref.py index bf23f11e..ad4099fa 100644 --- a/icechunk-python/tests/test_virtual_ref.py +++ b/icechunk-python/tests/test_virtual_ref.py @@ -108,16 +108,13 @@ async def test_from_s3_public_virtual_refs(tmpdir): nodes = [n async for n in store.list()] assert "depth/c/0" in nodes - # dd = await store.get("depth/c/0", prototype=buffer_prototype) - # print(dd) - - # depth_values = depth[:] - # assert len(depth_values) == 22 - # actual_values = np.array([ - # 0., 1., 2., 4., 6., 8., 10., 12., 15., 20., 25., - # 30., 35., 40., 45., 50., 60., 70., 80., 90., 100., 125. - # ]) - # assert np.allclose(depth_values, actual_values) + depth_values = depth[:] + assert len(depth_values) == 22 + actual_values = np.array([ + 0., 1., 2., 4., 6., 8., 10., 12., 15., 20., 25., + 30., 35., 40., 45., 50., 60., 70., 80., 90., 100., 125. + ]) + assert np.allclose(depth_values, actual_values) From 1ae2e8a8899cfd861e88913db038ee0ebfe8eeb0 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Thu, 10 Oct 2024 12:04:57 -0400 Subject: [PATCH 056/167] some docs --- icechunk-python/python/icechunk/_icechunk_python.pyi | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index a031dec5..85afaba5 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -124,10 +124,10 @@ class StorageConfig: with the given bucket and prefix This assumes that the necessary credentials are available in the environment: + AWS_REGION AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN (optional) - AWS_REGION (optional) AWS_ENDPOINT_URL (optional) AWS_ALLOW_HTTP (optional) """ @@ -178,10 +178,10 @@ class VirtualRefConfig: with the given bucket and prefix This assumes that the necessary credentials are available in the environment: + AWS_REGION AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN (optional) - AWS_REGION (optional) AWS_ENDPOINT_URL (optional) AWS_ALLOW_HTTP (optional) """ From 3c3532f1568721981a6185a65c43326480f603a2 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Thu, 10 Oct 2024 12:07:35 -0400 Subject: [PATCH 057/167] Virtual refs fmt --- icechunk-python/tests/test_virtual_ref.py | 2 -- icechunk/tests/test_virtual_refs.rs | 13 ++++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/icechunk-python/tests/test_virtual_ref.py b/icechunk-python/tests/test_virtual_ref.py index ad4099fa..eccd9837 100644 --- a/icechunk-python/tests/test_virtual_ref.py +++ b/icechunk-python/tests/test_virtual_ref.py @@ -82,8 +82,6 @@ async def test_write_minino_virtual_refs(): async def test_from_s3_public_virtual_refs(tmpdir): - buffer_prototype = zarr.core.buffer.default_buffer_prototype() - # Open the store, store = await IcechunkStore.open( storage=StorageConfig.filesystem(f'{tmpdir}/virtual'), diff --git a/icechunk/tests/test_virtual_refs.rs b/icechunk/tests/test_virtual_refs.rs index 4deaf1f9..1575ec33 100644 --- a/icechunk/tests/test_virtual_refs.rs +++ b/icechunk/tests/test_virtual_refs.rs @@ -48,7 +48,10 @@ mod tests { } } - async fn create_repository(storage: Arc, virtual_s3_config: S3Config) -> Repository { + async fn create_repository( + storage: Arc, + virtual_s3_config: S3Config, + ) -> Repository { Repository::init(storage, true) .await .expect("building repository failed") @@ -76,7 +79,10 @@ mod tests { .expect(&format!("putting chunk to {} failed", &path)); } } - async fn create_local_repository(path: &StdPath, virtual_s3_config: S3Config) -> Repository { + async fn create_local_repository( + path: &StdPath, + virtual_s3_config: S3Config, + ) -> Repository { let storage: Arc = Arc::new( ObjectStorage::new_local_store(path).expect("Creating local storage failed"), ); @@ -403,7 +409,8 @@ mod tests { } #[tokio::test] - async fn test_zarr_store_virtual_refs_from_public_s3() -> Result<(), Box> { + async fn test_zarr_store_virtual_refs_from_public_s3( + ) -> Result<(), Box> { let repo_dir = TempDir::new()?; let ds = create_local_repository(repo_dir.path(), anon_s3_config()).await; From 8dcd3a982dfab5efdce48321a371f07808c8379c Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Thu, 10 Oct 2024 12:09:46 -0400 Subject: [PATCH 058/167] Cleanup --- icechunk-python/python/icechunk/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index 7761e775..f4457c3d 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -282,7 +282,6 @@ async def get( except ValueError as _e: # Zarr python expects None to be returned if the key does not exist # but an IcechunkStore returns an error if the key does not exist - print(_e) return None return prototype.buffer.from_bytes(result) From c78f64ad0d606f35b55ce8969ab3c1f508991a98 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Thu, 10 Oct 2024 13:17:34 -0400 Subject: [PATCH 059/167] Actions --- .github/workflows/rust-ci.yaml | 1 + icechunk-python/python/icechunk/_icechunk_python.pyi | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust-ci.yaml b/.github/workflows/rust-ci.yaml index 41fa58e0..cacda350 100644 --- a/.github/workflows/rust-ci.yaml +++ b/.github/workflows/rust-ci.yaml @@ -83,4 +83,5 @@ jobs: - name: Check if: matrix.os == 'ubuntu-latest' || github.event_name == 'push' run: | + env just pre-commit diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index 85afaba5..313f5ac8 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -178,7 +178,7 @@ class VirtualRefConfig: with the given bucket and prefix This assumes that the necessary credentials are available in the environment: - AWS_REGION + AWS_REGION or AWS_DEFAULT_REGION AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN (optional) From 43de67c8bc2d6909bc1dff0fded94aedf43d2af5 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Thu, 10 Oct 2024 13:27:44 -0400 Subject: [PATCH 060/167] celanup --- .github/workflows/rust-ci.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/rust-ci.yaml b/.github/workflows/rust-ci.yaml index cacda350..41fa58e0 100644 --- a/.github/workflows/rust-ci.yaml +++ b/.github/workflows/rust-ci.yaml @@ -83,5 +83,4 @@ jobs: - name: Check if: matrix.os == 'ubuntu-latest' || github.event_name == 'push' run: | - env just pre-commit From 2317056455cd65ee43fbcee361bd4db819a2c663 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Thu, 10 Oct 2024 16:00:05 -0300 Subject: [PATCH 061/167] Introduce anonymous s3 configuration --- icechunk-python/src/storage.rs | 88 ++++++++++++++++++----- icechunk/src/storage/s3.rs | 39 ++++++---- icechunk/src/zarr.rs | 10 +-- icechunk/tests/test_concurrency.rs | 1 - icechunk/tests/test_distributed_writes.rs | 6 +- icechunk/tests/test_s3_storage.rs | 6 +- icechunk/tests/test_virtual_refs.rs | 10 +-- 7 files changed, 111 insertions(+), 49 deletions(-) diff --git a/icechunk-python/src/storage.rs b/icechunk-python/src/storage.rs index 5217572f..3760351f 100644 --- a/icechunk-python/src/storage.rs +++ b/icechunk-python/src/storage.rs @@ -1,8 +1,11 @@ +#![allow(clippy::too_many_arguments)] +// TODO: we only need that allow for PyStorageConfig, but i don't know how to set it + use std::path::PathBuf; use icechunk::{ storage::{ - s3::{S3Config, S3Credentials}, + s3::{S3Config, S3Credentials, StaticS3Credentials}, virtual_ref::ObjectStoreVirtualChunkResolverConfig, }, zarr::StorageConfig, @@ -20,9 +23,9 @@ pub struct PyS3Credentials { session_token: Option, } -impl From<&PyS3Credentials> for S3Credentials { +impl From<&PyS3Credentials> for StaticS3Credentials { fn from(credentials: &PyS3Credentials) -> Self { - S3Credentials { + StaticS3Credentials { access_key_id: credentials.access_key_id.clone(), secret_access_key: credentials.secret_access_key.clone(), session_token: credentials.session_token.clone(), @@ -53,6 +56,7 @@ pub enum PyStorageConfig { S3 { bucket: String, prefix: String, + anon: bool, credentials: Option, endpoint_url: Option, allow_http: Option, @@ -84,6 +88,7 @@ impl PyStorageConfig { PyStorageConfig::S3 { bucket, prefix, + anon: false, credentials: None, endpoint_url, allow_http, @@ -104,12 +109,44 @@ impl PyStorageConfig { PyStorageConfig::S3 { bucket, prefix, + anon: false, credentials: Some(credentials), endpoint_url, allow_http, region, } } + + #[classmethod] + fn s3_anonymous( + _cls: &Bound<'_, PyType>, + bucket: String, + prefix: String, + endpoint_url: Option, + allow_http: Option, + region: Option, + ) -> Self { + PyStorageConfig::S3 { + bucket, + prefix, + anon: true, + credentials: None, + endpoint_url, + allow_http, + region, + } + } +} + +fn mk_credentials(config: Option<&PyS3Credentials>, anon: bool) -> S3Credentials { + if anon { + S3Credentials::Anonymous + } else { + match config { + None => S3Credentials::FromEnv, + Some(credentials) => S3Credentials::Static(credentials.into()), + } + } } impl From<&PyStorageConfig> for StorageConfig { @@ -124,20 +161,25 @@ impl From<&PyStorageConfig> for StorageConfig { PyStorageConfig::S3 { bucket, prefix, + anon, credentials, endpoint_url, allow_http, region, - } => StorageConfig::S3ObjectStore { - bucket: bucket.clone(), - prefix: prefix.clone(), - config: Some(S3Config { + } => { + let s3_config = S3Config { region: region.clone(), - credentials: credentials.as_ref().map(S3Credentials::from), + credentials: mk_credentials(credentials.as_ref(), *anon), endpoint: endpoint_url.clone(), - allow_http: *allow_http, - }), - }, + allow_http: allow_http.unwrap_or(false), + }; + + StorageConfig::S3ObjectStore { + bucket: bucket.clone(), + prefix: prefix.clone(), + config: Some(s3_config), + } + } } } } @@ -150,6 +192,7 @@ pub enum PyVirtualRefConfig { endpoint_url: Option, allow_http: Option, region: Option, + anon: bool, }, } @@ -162,6 +205,7 @@ impl PyVirtualRefConfig { endpoint_url: None, allow_http: None, region: None, + anon: false, } } @@ -172,12 +216,14 @@ impl PyVirtualRefConfig { endpoint_url: Option, allow_http: Option, region: Option, + anon: Option, ) -> Self { PyVirtualRefConfig::S3 { credentials: Some(credentials), endpoint_url, allow_http, region, + anon: anon.unwrap_or(false), } } } @@ -185,14 +231,18 @@ impl PyVirtualRefConfig { impl From<&PyVirtualRefConfig> for ObjectStoreVirtualChunkResolverConfig { fn from(config: &PyVirtualRefConfig) -> Self { match config { - PyVirtualRefConfig::S3 { credentials, endpoint_url, allow_http, region } => { - ObjectStoreVirtualChunkResolverConfig::S3(S3Config { - region: region.clone(), - endpoint: endpoint_url.clone(), - credentials: credentials.as_ref().map(S3Credentials::from), - allow_http: *allow_http, - }) - } + PyVirtualRefConfig::S3 { + credentials, + endpoint_url, + allow_http, + region, + anon, + } => ObjectStoreVirtualChunkResolverConfig::S3(S3Config { + region: region.clone(), + endpoint: endpoint_url.clone(), + credentials: mk_credentials(credentials.as_ref(), *anon), + allow_http: allow_http.unwrap_or(false), + }), } } } diff --git a/icechunk/src/storage/s3.rs b/icechunk/src/storage/s3.rs index 91117e41..c30eccec 100644 --- a/icechunk/src/storage/s3.rs +++ b/icechunk/src/storage/s3.rs @@ -35,18 +35,26 @@ pub struct S3Storage { } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub struct S3Credentials { +pub struct StaticS3Credentials { pub access_key_id: String, pub secret_access_key: String, pub session_token: Option, } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Default)] +pub enum S3Credentials { + #[default] + FromEnv, + Anonymous, + Static(StaticS3Credentials), +} + #[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] pub struct S3Config { pub region: Option, pub endpoint: Option, - pub credentials: Option, - pub allow_http: Option, + pub credentials: S3Credentials, + pub allow_http: bool, } pub async fn mk_client(config: Option<&S3Config>) -> Client { @@ -56,8 +64,9 @@ pub async fn mk_client(config: Option<&S3Config>) -> Client { .unwrap_or_else(RegionProviderChain::default_provider); let endpoint = config.and_then(|c| c.endpoint.clone()); - let allow_http = config.and_then(|c| c.allow_http).unwrap_or(false); - let credentials = config.and_then(|c| c.credentials.clone()); + let allow_http = config.map(|c| c.allow_http).unwrap_or(false); + let credentials = + config.map(|c| c.credentials.clone()).unwrap_or(S3Credentials::FromEnv); #[allow(clippy::unwrap_used)] let app_name = AppName::new("icechunk").unwrap(); let mut aws_config = aws_config::defaults(BehaviorVersion::v2024_03_28()) @@ -68,14 +77,18 @@ pub async fn mk_client(config: Option<&S3Config>) -> Client { aws_config = aws_config.endpoint_url(endpoint) } - if let Some(credentials) = credentials { - aws_config = aws_config.credentials_provider(Credentials::new( - credentials.access_key_id, - credentials.secret_access_key, - credentials.session_token, - None, - "user", - )); + match credentials { + S3Credentials::FromEnv => {} + S3Credentials::Anonymous => aws_config = aws_config.no_credentials(), + S3Credentials::Static(credentials) => { + aws_config = aws_config.credentials_provider(Credentials::new( + credentials.access_key_id, + credentials.secret_access_key, + credentials.session_token, + None, + "user", + )); + } } let mut s3_builder = Builder::from(&aws_config.load().await); diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index 0d41007c..52039a87 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -1364,7 +1364,7 @@ mod tests { use std::borrow::BorrowMut; - use crate::storage::s3::S3Credentials; + use crate::storage::s3::{S3Credentials, StaticS3Credentials}; use super::*; use pretty_assertions::assert_eq; @@ -2460,8 +2460,8 @@ mod tests { prefix: String::from("root"), config: Some(S3Config { endpoint: None, - credentials: None, - allow_http: None, + credentials: S3Credentials::FromEnv, + allow_http: false, region: None }), }, @@ -2500,12 +2500,12 @@ mod tests { config: Some(S3Config { region: None, endpoint: Some(String::from("http://localhost:9000")), - credentials: Some(S3Credentials { + credentials: S3Credentials::Static(StaticS3Credentials { access_key_id: String::from("my-key"), secret_access_key: String::from("my-secret-key"), session_token: None, }), - allow_http: Some(true), + allow_http: true, }) }, config: None, diff --git a/icechunk/tests/test_concurrency.rs b/icechunk/tests/test_concurrency.rs index 3e9ed546..a5776be5 100644 --- a/icechunk/tests/test_concurrency.rs +++ b/icechunk/tests/test_concurrency.rs @@ -128,7 +128,6 @@ async fn read_task(ds: Arc>, x: u32, y: u32, barrier: Arc { if bytes == &expected_bytes { diff --git a/icechunk/tests/test_distributed_writes.rs b/icechunk/tests/test_distributed_writes.rs index a737641e..49cafb2c 100644 --- a/icechunk/tests/test_distributed_writes.rs +++ b/icechunk/tests/test_distributed_writes.rs @@ -7,7 +7,7 @@ use icechunk::{ format::{ByteRange, ChunkIndices, Path, SnapshotId}, metadata::{ChunkKeyEncoding, ChunkShape, DataType, FillValue}, repository::{get_chunk, ChangeSet, ZarrArrayMetadata}, - storage::s3::{S3Config, S3Credentials, S3Storage}, + storage::s3::{S3Config, S3Credentials, S3Storage, StaticS3Credentials}, Repository, Storage, }; use tokio::task::JoinSet; @@ -24,12 +24,12 @@ async fn mk_storage( Some(&S3Config { region: Some("us-east-1".to_string()), endpoint: Some("http://localhost:9000".to_string()), - credentials: Some(S3Credentials { + credentials: S3Credentials::Static(StaticS3Credentials { access_key_id: "minio123".into(), secret_access_key: "minio123".into(), session_token: None, }), - allow_http: Some(true), + allow_http: true, }), ) .await?, diff --git a/icechunk/tests/test_s3_storage.rs b/icechunk/tests/test_s3_storage.rs index bc53ab55..8cf09f1d 100644 --- a/icechunk/tests/test_s3_storage.rs +++ b/icechunk/tests/test_s3_storage.rs @@ -11,7 +11,7 @@ use icechunk::{ create_tag, fetch_branch_tip, fetch_tag, list_refs, update_branch, Ref, RefError, }, storage::{ - s3::{S3Config, S3Credentials, S3Storage}, + s3::{S3Config, S3Credentials, S3Storage, StaticS3Credentials}, StorageResult, }, Storage, @@ -25,12 +25,12 @@ async fn mk_storage() -> StorageResult { Some(&S3Config { region: Some("us-east-1".to_string()), endpoint: Some("http://localhost:9000".to_string()), - credentials: Some(S3Credentials { + credentials: S3Credentials::Static(StaticS3Credentials { access_key_id: "minio123".into(), secret_access_key: "minio123".into(), session_token: None, }), - allow_http: Some(true), + allow_http: true, }), ) .await diff --git a/icechunk/tests/test_virtual_refs.rs b/icechunk/tests/test_virtual_refs.rs index 1575ec33..73e76a97 100644 --- a/icechunk/tests/test_virtual_refs.rs +++ b/icechunk/tests/test_virtual_refs.rs @@ -9,7 +9,7 @@ mod tests { metadata::{ChunkKeyEncoding, ChunkShape, DataType, FillValue}, repository::{get_chunk, ChunkPayload, ZarrArrayMetadata}, storage::{ - s3::{mk_client, S3Config, S3Credentials, S3Storage}, + s3::{mk_client, S3Config, S3Credentials, S3Storage, StaticS3Credentials}, virtual_ref::ObjectStoreVirtualChunkResolverConfig, ObjectStorage, }, @@ -30,12 +30,12 @@ mod tests { S3Config { region: Some("us-east-1".to_string()), endpoint: Some("http://localhost:9000".to_string()), - credentials: Some(S3Credentials { + credentials: S3Credentials::Static(StaticS3Credentials { access_key_id: "minio123".into(), secret_access_key: "minio123".into(), session_token: None, }), - allow_http: Some(true), + allow_http: true, } } @@ -43,8 +43,8 @@ mod tests { S3Config { region: Some("us-east-1".to_string()), endpoint: None, - credentials: None, - allow_http: None, + credentials: S3Credentials::Anonymous, + allow_http: false, } } From 7f065b2bb615d5dcac32438281217c5e8265552d Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Thu, 10 Oct 2024 16:23:01 -0300 Subject: [PATCH 062/167] fix config serialization test --- icechunk/src/storage/s3.rs | 4 ++++ icechunk/src/zarr.rs | 8 ++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/icechunk/src/storage/s3.rs b/icechunk/src/storage/s3.rs index c30eccec..80b50574 100644 --- a/icechunk/src/storage/s3.rs +++ b/icechunk/src/storage/s3.rs @@ -42,10 +42,14 @@ pub struct StaticS3Credentials { } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Default)] +#[serde(tag = "type")] pub enum S3Credentials { #[default] + #[serde(rename = "from_env")] FromEnv, + #[serde(rename = "anonymous")] Anonymous, + #[serde(rename = "static")] Static(StaticS3Credentials), } diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index 52039a87..5c9ce517 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -2458,12 +2458,7 @@ mod tests { storage: StorageConfig::S3ObjectStore { bucket: String::from("test"), prefix: String::from("root"), - config: Some(S3Config { - endpoint: None, - credentials: S3Credentials::FromEnv, - allow_http: false, - region: None - }), + config: None, }, config: None, }, @@ -2476,6 +2471,7 @@ mod tests { "bucket":"test", "prefix":"root", "credentials":{ + "type":"static", "access_key_id":"my-key", "secret_access_key":"my-secret-key" }, From 21d0c047fc1d90a5224e30cd43178546057429d0 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Thu, 10 Oct 2024 16:42:49 -0300 Subject: [PATCH 063/167] fix virtual ref python test --- .../python/icechunk/_icechunk_python.pyi | 12 ++++++++++++ icechunk-python/src/storage.rs | 16 ++++++++++++++++ icechunk-python/tests/test_virtual_ref.py | 3 +-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index 313f5ac8..4696c0e7 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -203,6 +203,18 @@ class VirtualRefConfig: """ ... + @classmethod + def s3_anonymous( + cls, + endpoint_url: str | None, + allow_http: bool | None = None, + region: str | None = None, + ) -> VirtualRefConfig: + """Create a VirtualReferenceConfig object for an S3 Object Storage compatible storage + using anonymous access + """ + ... + class StoreConfig: # The number of concurrent requests to make when fetching partial values get_partial_values_concurrency: int | None diff --git a/icechunk-python/src/storage.rs b/icechunk-python/src/storage.rs index 3760351f..a5e2d0fd 100644 --- a/icechunk-python/src/storage.rs +++ b/icechunk-python/src/storage.rs @@ -226,6 +226,22 @@ impl PyVirtualRefConfig { anon: anon.unwrap_or(false), } } + + #[classmethod] + fn s3_anonymous( + _cls: &Bound<'_, PyType>, + endpoint_url: Option, + allow_http: Option, + region: Option, + ) -> Self { + PyVirtualRefConfig::S3 { + credentials: None, + endpoint_url, + allow_http, + region, + anon: true, + } + } } impl From<&PyVirtualRefConfig> for ObjectStoreVirtualChunkResolverConfig { diff --git a/icechunk-python/tests/test_virtual_ref.py b/icechunk-python/tests/test_virtual_ref.py index eccd9837..ba3fc5a2 100644 --- a/icechunk-python/tests/test_virtual_ref.py +++ b/icechunk-python/tests/test_virtual_ref.py @@ -87,10 +87,9 @@ async def test_from_s3_public_virtual_refs(tmpdir): storage=StorageConfig.filesystem(f'{tmpdir}/virtual'), mode="w", config=StoreConfig( - virtual_ref_config=VirtualRefConfig.s3_from_env() + virtual_ref_config=VirtualRefConfig.s3_anonymous() ), ) - root = zarr.Group.from_store(store=store, zarr_format=3) depth = root.require_array( name="depth", shape=((22, )), chunk_shape=((22,)), dtype="float64" From 7d75f75e969ba60799b6e1a488dd602cceb4c325 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Thu, 10 Oct 2024 17:04:34 -0300 Subject: [PATCH 064/167] set region explicitly --- icechunk-python/tests/test_virtual_ref.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/icechunk-python/tests/test_virtual_ref.py b/icechunk-python/tests/test_virtual_ref.py index ba3fc5a2..4d69fd31 100644 --- a/icechunk-python/tests/test_virtual_ref.py +++ b/icechunk-python/tests/test_virtual_ref.py @@ -87,7 +87,7 @@ async def test_from_s3_public_virtual_refs(tmpdir): storage=StorageConfig.filesystem(f'{tmpdir}/virtual'), mode="w", config=StoreConfig( - virtual_ref_config=VirtualRefConfig.s3_anonymous() + virtual_ref_config=VirtualRefConfig.s3_anonymous(region="us-east-1", allow_http=False) ), ) root = zarr.Group.from_store(store=store, zarr_format=3) From 837fdffed5576de75bcfca845d4f4e6e9d974ff3 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Thu, 10 Oct 2024 18:30:24 -0300 Subject: [PATCH 065/167] Set publish = true in cargo --- icechunk/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/icechunk/Cargo.toml b/icechunk/Cargo.toml index dc3de6a6..2f00b83f 100644 --- a/icechunk/Cargo.toml +++ b/icechunk/Cargo.toml @@ -10,7 +10,7 @@ keywords = ["zarr", "xarray", "database"] categories = ["database", "science", "science::geo"] authors = ["Earthmover PBC"] edition = "2021" -publish = false +publish = true [dependencies] async-trait = "0.1.83" From c1e7a206204aa8c42c8c52feb4ecccd038b29722 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Thu, 10 Oct 2024 18:41:36 -0300 Subject: [PATCH 066/167] Update yanked dependency --- Cargo.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e979164..d4c64965 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -874,9 +874,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -884,9 +884,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" @@ -901,15 +901,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", @@ -918,21 +918,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", From 1b40912e2e532e98b4977b415deac4ca7a3ea8b0 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Thu, 10 Oct 2024 19:06:35 -0300 Subject: [PATCH 067/167] More cargo release prep --- Cargo.toml | 6 ++++++ icechunk-python/Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 60af7b42..03e38d03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,3 +7,9 @@ resolver = "2" expect_used = "warn" unwrap_used = "warn" panic = "warn" + +[workspace.metadata.release] +allow-branch = ["main"] +sign-commit = true +sign-tag = true +push = false diff --git a/icechunk-python/Cargo.toml b/icechunk-python/Cargo.toml index 78fddcc0..5422b081 100644 --- a/icechunk-python/Cargo.toml +++ b/icechunk-python/Cargo.toml @@ -10,7 +10,7 @@ keywords = ["zarr", "xarray", "database"] categories = ["database", "science", "science::geo"] authors = ["Earthmover PBC"] edition = "2021" -publish = false +publish = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] From 626b34b626ec86f210ac2d58c1d8b9d62074a4e5 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Fri, 11 Oct 2024 11:27:03 -0400 Subject: [PATCH 068/167] Add python wheel builds and enable more platforms (#183) * Add python wheel artifacts and enable other platforms * Disable aarch 64 for now * Attempt to fix aarch build * Add doc * Adjust manylinux for all ubuntu targets * plzzz * Simplify test runners * Add new ci flow with only tests for pushes, and a special release flow --- .github/workflows/python-check.yaml | 75 +++++++ .github/workflows/python-ci.yaml | 324 ++++++++++------------------ 2 files changed, 192 insertions(+), 207 deletions(-) create mode 100644 .github/workflows/python-check.yaml diff --git a/.github/workflows/python-check.yaml b/.github/workflows/python-check.yaml new file mode 100644 index 00000000..4e6ebd9c --- /dev/null +++ b/.github/workflows/python-check.yaml @@ -0,0 +1,75 @@ +name: Python Check + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +defaults: + run: + working-directory: ./icechunk-python + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Stand up MinIO + run: | + docker compose up -d minio + - name: Wait for MinIO to be ready + run: | + for i in {1..10}; do + if curl --silent --fail http://minio:9000/minio/health/live; then + break + fi + sleep 3 + done + docker compose exec -T minio mc alias set minio http://minio:9000 minio123 minio123 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + working-directory: icechunk-python + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + manylinux: ${{ matrix.platform.manylinux }} # https://github.com/PyO3/maturin-action/issues/245 + - name: mypy + shell: bash + working-directory: icechunk-python + run: | + set -e + python3 -m venv .venv + source .venv/bin/activate + pip install icechunk['test'] --find-links dist --force-reinstall + mypy python + - name: ruff + shell: bash + working-directory: icechunk-python + run: | + set -e + python3 -m venv .venv + source .venv/bin/activate + pip install icechunk['test'] --find-links dist --force-reinstall + ruff check + - name: pytest + shell: bash + working-directory: icechunk-python + run: | + set -e + python3 -m venv .venv + source .venv/bin/activate + pip install icechunk['test'] --find-links dist --force-reinstall + pytest \ No newline at end of file diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml index b4e7b435..239649cb 100644 --- a/.github/workflows/python-ci.yaml +++ b/.github/workflows/python-ci.yaml @@ -6,14 +6,14 @@ name: Python CI on: - push: - branches: - - main - - master - tags: - - '*' - pull_request: + publish: + types: + # published triggers for both releases and prereleases + - published workflow_dispatch: + schedule: + # run every day at 4am + - cron: '0 4 * * *' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -34,16 +34,15 @@ jobs: platform: - runner: ubuntu-latest target: x86_64 + manylinux: auto # - runner: ubuntu-latest # target: x86 - # - runner: ubuntu-latest - # target: aarch64 - # - runner: ubuntu-latest - # target: armv7 - # - runner: ubuntu-latest - # target: s390x - # - runner: ubuntu-latest - # target: ppc64le + - runner: ubuntu-latest + target: aarch64 + manylinux: 2_28 + - runner: ubuntu-latest + target: armv7 + manylinux: 2_28 steps: - uses: actions/checkout@v4 - name: Stand up MinIO @@ -68,189 +67,100 @@ jobs: target: ${{ matrix.platform.target }} args: --release --out dist --find-interpreter sccache: 'true' - manylinux: auto - # - name: Upload wheels - # uses: actions/upload-artifact@v4 - # with: - # working-directory: icechunk-python - # name: wheels-linux-${{ matrix.platform.target }} - # path: icechunk-python/dist - - name: mypy - shell: bash - working-directory: icechunk-python - run: | - set -e - python3 -m venv .venv - source .venv/bin/activate - pip install icechunk['test'] --find-links dist --force-reinstall - mypy python - - name: ruff - shell: bash - working-directory: icechunk-python - run: | - set -e - python3 -m venv .venv - source .venv/bin/activate - pip install icechunk['test'] --find-links dist --force-reinstall - ruff check - - name: pytest - if: ${{ startsWith(matrix.platform.target, 'x86_64') }} - shell: bash - working-directory: icechunk-python - run: | - set -e - python3 -m venv .venv - source .venv/bin/activate - pip install icechunk['test'] --find-links dist --force-reinstall - pytest - - name: pytest - if: ${{ !startsWith(matrix.platform.target, 'x86') && matrix.platform.target != 'ppc64' }} - uses: uraimo/run-on-arch-action@v2 + manylinux: ${{ matrix.platform.manylinux }} # https://github.com/PyO3/maturin-action/issues/245 + - name: Upload wheels + uses: actions/upload-artifact@v4 with: working-directory: icechunk-python - arch: ${{ matrix.platform.target }} - distro: ubuntu22.04 - githubToken: ${{ github.token }} - install: | - apt-get update - apt-get install -y --no-install-recommends python3 python3-pip - pip3 install -U pip pytest - run: | - set -e - pip3 install icechunk['test'] --find-links dist --force-reinstall - pytest + name: wheels-linux-${{ matrix.platform.target }} + path: icechunk-python/dist - # musllinux: - # runs-on: ${{ matrix.platform.runner }} - # strategy: - # matrix: - # platform: - # - runner: ubuntu-latest - # target: x86_64 - # - runner: ubuntu-latest - # target: x86 - # - runner: ubuntu-latest - # target: aarch64 - # - runner: ubuntu-latest - # target: armv7 - # steps: - # - uses: actions/checkout@v4 - # - uses: actions/setup-python@v5 - # with: - # python-version: 3.x - # - name: Build wheels - # uses: PyO3/maturin-action@v1 - # with: - # target: ${{ matrix.platform.target }} - # args: --release --out dist --find-interpreter - # sccache: 'true' - # manylinux: musllinux_1_2 - # - name: Upload wheels - # uses: actions/upload-artifact@v4 - # with: - # name: wheels-musllinux-${{ matrix.platform.target }} - # path: dist - # - name: pytest - # if: ${{ startsWith(matrix.platform.target, 'x86_64') }} - # uses: addnab/docker-run-action@v3 - # with: - # image: alpine:latest - # options: -v ${{ github.workspace }}:/io -w /io - # run: | - # set -e - # apk add py3-pip py3-virtualenv - # python3 -m virtualenv .venv - # source .venv/bin/activate - # pip install icechunk --no-index --find-links dist --force-reinstall - # pip install pytest - # pytest - # - name: pytest - # if: ${{ !startsWith(matrix.platform.target, 'x86') }} - # uses: uraimo/run-on-arch-action@v2 - # with: - # arch: ${{ matrix.platform.target }} - # distro: alpine_latest - # githubToken: ${{ github.token }} - # install: | - # apk add py3-virtualenv - # run: | - # set -e - # python3 -m virtualenv .venv - # source .venv/bin/activate - # pip install pytest - # pip install icechunk --find-links dist --force-reinstall - # pytest + musllinux: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: ubuntu-latest + target: x86_64 + - runner: ubuntu-latest + target: x86 + - runner: ubuntu-latest + target: aarch64 + - runner: ubuntu-latest + target: armv7 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + working-directory: icechunk-python + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + manylinux: musllinux_1_2 + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-musllinux-${{ matrix.platform.target }} + path: icechunk-python/dist - # windows: - # runs-on: ${{ matrix.platform.runner }} - # strategy: - # matrix: - # platform: - # - runner: windows-latest - # target: x64 - # - runner: windows-latest - # target: x86 - # steps: - # - uses: actions/checkout@v4 - # - uses: actions/setup-python@v5 - # with: - # python-version: 3.x - # architecture: ${{ matrix.platform.target }} - # - name: Build wheels - # uses: PyO3/maturin-action@v1 - # with: - # target: ${{ matrix.platform.target }} - # args: --release --out dist --find-interpreter - # sccache: 'true' - # - name: Upload wheels - # uses: actions/upload-artifact@v4 - # with: - # name: wheels-windows-${{ matrix.platform.target }} - # path: dist - # - name: pytest - # if: ${{ !startsWith(matrix.platform.target, 'aarch64') }} - # shell: bash - # run: | - # set -e - # python3 -m venv .venv - # source .venv/Scripts/activate - # pip install icechunk --find-links dist --force-reinstall - # pip install pytest - # pytest + windows: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: windows-latest + target: x64 + - runner: windows-latest + target: x86 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + architecture: ${{ matrix.platform.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + working-directory: icechunk-python + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.platform.target }} + path: icechunk-python/dist - # macos: - # runs-on: ${{ matrix.platform.runner }} - # strategy: - # matrix: - # platform: - # - runner: macos-12 - # target: x86_64 - # - runner: macos-14 - # target: aarch64 - # steps: - # - uses: actions/checkout@v4 - # - uses: actions/setup-python@v5 - # with: - # python-version: 3.x - # - name: Build wheels - # uses: PyO3/maturin-action@v1 - # with: - # target: ${{ matrix.platform.target }} - # args: --release --out dist --find-interpreter - # sccache: 'true' - # - name: Upload wheels - # uses: actions/upload-artifact@v4 - # with: - # name: wheels-macos-${{ matrix.platform.target }} - # path: dist - # - name: pytest - # run: | - # set -e - # python3 -m venv .venv - # source .venv/bin/activate - # pip install icechunk --find-links dist --force-reinstall - # pip install pytest - # pytest + macos: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: macos-12 + target: x86_64 + - runner: macos-14 + target: aarch64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + working-directory: icechunk-python + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.platform.target }} + path: icechunk-python/dist sdist: runs-on: ubuntu-latest @@ -269,17 +179,17 @@ jobs: name: wheels-sdist path: icechunk-python/dist - # release: - # name: Release - # runs-on: ubuntu-latest - # if: "startsWith(github.ref, 'refs/tags/')" - # needs: [linux, musllinux, windows, macos, sdist] - # steps: - # - uses: actions/download-artifact@v4 - # - name: Publish to PyPI - # uses: PyO3/maturin-action@v1 - # env: - # MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} - # with: - # command: upload - # args: --non-interactive --skip-existing wheels-*/* + release: + name: Release + runs-on: ubuntu-latest + if: "github.event_name == 'published'" + needs: [linux, musllinux, windows, macos, sdist] + steps: + - uses: actions/download-artifact@v4 + - name: Publish to PyPI + uses: PyO3/maturin-action@v1 + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + with: + command: upload + args: --non-interactive --skip-existing wheels-*/* From eeb1482272aa38fbf8dce5273b5eebbf48a28760 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Fri, 11 Oct 2024 15:56:18 -0300 Subject: [PATCH 069/167] Encode refs according to the spec --- icechunk/src/refs.rs | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/icechunk/src/refs.rs b/icechunk/src/refs.rs index e5ba906c..d228bc5f 100644 --- a/icechunk/src/refs.rs +++ b/icechunk/src/refs.rs @@ -8,13 +8,15 @@ use thiserror::Error; use crate::{format::SnapshotId, Storage, StorageError}; fn crock_encode_int(n: u64) -> String { - base32::encode(base32::Alphabet::Crockford, &n.to_be_bytes()) + // skip the first 3 bytes (zeroes) + base32::encode(base32::Alphabet::Crockford, &n.to_be_bytes()[3..=7]) } fn crock_decode_int(data: &str) -> Option { - let bytes = base32::decode(base32::Alphabet::Crockford, data)?; - let bytes = bytes.try_into().ok()?; - Some(u64::from_be_bytes(bytes)) + // re insert the first 3 bytes removed during encoding + let mut bytes = vec![0, 0, 0]; + bytes.extend(base32::decode(base32::Alphabet::Crockford, data)?); + Some(u64::from_be_bytes(bytes.as_slice().try_into().ok()?)) } #[derive(Debug, Error)] @@ -70,14 +72,16 @@ impl Ref { pub struct BranchVersion(pub u64); impl BranchVersion { + const MAX_VERSION_NUMBER: u64 = 1099511627775; + fn decode(version: &str) -> RefResult { let n = crock_decode_int(version) .ok_or(RefError::InvalidBranchVersion(version.to_string()))?; - Ok(BranchVersion(u64::MAX - n)) + Ok(BranchVersion(BranchVersion::MAX_VERSION_NUMBER - n)) } fn encode(&self) -> String { - crock_encode_int(u64::MAX - self.0) + crock_encode_int(BranchVersion::MAX_VERSION_NUMBER - self.0) } fn to_path(&self, branch_name: &str) -> RefResult { @@ -281,9 +285,24 @@ mod tests { #[tokio::test] async fn test_branch_version_encoding() -> Result<(), Box> { - let targets = (0..10u64).chain(once(u64::MAX)); + let targets = (0..10u64).chain(once(BranchVersion::MAX_VERSION_NUMBER)); + let encodings = [ + "ZZZZZZZZ", "ZZZZZZZY", "ZZZZZZZX", "ZZZZZZZW", "ZZZZZZZV", + // no U + "ZZZZZZZT", "ZZZZZZZS", "ZZZZZZZR", "ZZZZZZZQ", "ZZZZZZZP", + ]; + for n in targets { - let round = BranchVersion::decode(BranchVersion(n).encode().as_str())?; + let encoded = BranchVersion(n).encode(); + + if n < 100 { + assert_eq!(encoded, encodings[n as usize]); + } + if n == BranchVersion::MAX_VERSION_NUMBER { + assert_eq!(encoded, "00000000"); + } + + let round = BranchVersion::decode(encoded.as_str())?; assert_eq!(round, BranchVersion(n)); } Ok(()) From facb5a19ebeaa8de8cee42d9789321215f7ffb39 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Fri, 11 Oct 2024 16:17:00 -0300 Subject: [PATCH 070/167] fix workflow --- .github/workflows/python-ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml index 239649cb..d459546c 100644 --- a/.github/workflows/python-ci.yaml +++ b/.github/workflows/python-ci.yaml @@ -6,7 +6,7 @@ name: Python CI on: - publish: + pull_request: types: # published triggers for both releases and prereleases - published From 610dc9a20e73a05466c7aff92cc05ba00918c3d9 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Fri, 11 Oct 2024 19:57:56 -0300 Subject: [PATCH 071/167] Checkout no longer loses virtual resolver config Instead of creating a new `Repository` we now mutate the existing one. Fixes #192 --- icechunk/src/repository.rs | 22 ++++++++++++++++++++++ icechunk/src/zarr.rs | 25 ++++++++++--------------- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index 990be5a4..6e16d171 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -266,6 +266,28 @@ impl Repository { } } + pub(crate) fn set_snapshot_id(&mut self, snapshot_id: SnapshotId) { + self.snapshot_id = snapshot_id; + } + + pub(crate) async fn set_snapshot_from_tag( + &mut self, + tag: &str, + ) -> RepositoryResult<()> { + let ref_data = fetch_tag(self.storage.as_ref(), tag).await?; + self.snapshot_id = ref_data.snapshot; + Ok(()) + } + + pub(crate) async fn set_snapshot_from_branch( + &mut self, + branch: &str, + ) -> RepositoryResult<()> { + let ref_data = fetch_branch_tip(self.storage.as_ref(), branch).await?; + self.snapshot_id = ref_data.snapshot; + Ok(()) + } + /// Returns a pointer to the storage for the repository pub fn storage(&self) -> &Arc { &self.storage diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index 5c9ce517..d96818f4 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -391,33 +391,28 @@ impl Store { /// /// If there are uncommitted changes, this method will return an error. pub async fn checkout(&mut self, version: VersionInfo) -> StoreResult<()> { - // this needs to be done carefully to avoid deadlocks and race conditions - let storage = { - let guard = self.repository.read().await; - // Checking out is not allowed if there are uncommitted changes - if guard.has_uncommitted_changes() { - return Err(StoreError::UncommittedChanges); - } - guard.storage().clone() - }; + let mut repo = self.repository.write().await; + + // Checking out is not allowed if there are uncommitted changes + if repo.has_uncommitted_changes() { + return Err(StoreError::UncommittedChanges); + } - let repository = match version { + match version { VersionInfo::SnapshotId(sid) => { self.current_branch = None; - Repository::update(storage, sid) + repo.set_snapshot_id(sid); } VersionInfo::TagRef(tag) => { self.current_branch = None; - Repository::from_tag(storage, &tag).await? + repo.set_snapshot_from_tag(tag.as_str()).await? } VersionInfo::BranchTipRef(branch) => { self.current_branch = Some(branch.clone()); - Repository::from_branch_tip(storage, &branch).await? + repo.set_snapshot_from_branch(&branch).await? } } - .build(); - self.repository = Arc::new(RwLock::new(repository)); Ok(()) } From b0b063700ca752b4e2c69bedd331b4a1c0729602 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Fri, 11 Oct 2024 20:06:11 -0300 Subject: [PATCH 072/167] pull_requset -> release --- .github/workflows/python-ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml index d459546c..5beb31c8 100644 --- a/.github/workflows/python-ci.yaml +++ b/.github/workflows/python-ci.yaml @@ -6,7 +6,7 @@ name: Python CI on: - pull_request: + release: types: # published triggers for both releases and prereleases - published From ed5fcba788f41a1ef532880ab2fd576891543fd0 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Fri, 11 Oct 2024 20:27:04 -0300 Subject: [PATCH 073/167] Test that verifies we can read repos generated with prev versions --- .../test-repo/chunks/3X7F8JDC8KBX3GGKXQJ0 | Bin 0 -> 100 bytes .../test-repo/chunks/DZJ2SZFPAHPVNBTKM8Y0 | Bin 0 -> 100 bytes .../test-repo/chunks/Q72986RVXEZ5NNV94Y5G | Bin 0 -> 100 bytes .../test-repo/chunks/TGKWEJAE6BYZQFFKNVK0 | Bin 0 -> 100 bytes .../test-repo/manifests/8WDWSTN2G5G66C3RP6T0 | Bin 0 -> 215 bytes .../test-repo/manifests/9QZWF97ZRE7653BGATC0 | Bin 0 -> 233 bytes .../test-repo/manifests/J0KFSTSCDYDXBPP4F31G | Bin 0 -> 233 bytes .../test-repo/manifests/JQ3WE20YA2C36SZXD7HG | Bin 0 -> 4 bytes .../test-repo/manifests/V9QDN7624X40SRTBV5C0 | Bin 0 -> 248 bytes .../test-repo/refs/branch:main/ZZZZZZZW.json | 1 + .../test-repo/refs/branch:main/ZZZZZZZX.json | 1 + .../test-repo/refs/branch:main/ZZZZZZZY.json | 1 + .../test-repo/refs/branch:main/ZZZZZZZZ.json | 1 + .../refs/branch:my-branch/ZZZZZZZX.json | 1 + .../refs/branch:my-branch/ZZZZZZZY.json | 1 + .../refs/branch:my-branch/ZZZZZZZZ.json | 1 + .../refs/tag:it also works!/ref.json | 1 + .../test-repo/refs/tag:it works!/ref.json | 1 + .../test-repo/snapshots/3KP6E7F3C2PE2HNGCNM0 | Bin 0 -> 735 bytes .../test-repo/snapshots/8AEWDWJRTMECASF516SG | Bin 0 -> 806 bytes .../test-repo/snapshots/AG1HZQ5SWS8DM8DNC670 | Bin 0 -> 1563 bytes .../test-repo/snapshots/C1ZKMGE3ESPJ24YKN9MG | Bin 0 -> 672 bytes .../test-repo/snapshots/H01K0XJPGVW4HFX470AG | Bin 0 -> 117 bytes .../test-repo/snapshots/VNKSCC59M58V0MSJ01RG | Bin 0 -> 874 bytes icechunk-python/tests/test_can_read_old.py | 165 ++++++++++++++++++ 25 files changed, 174 insertions(+) create mode 100644 icechunk-python/tests/data/test-repo/chunks/3X7F8JDC8KBX3GGKXQJ0 create mode 100644 icechunk-python/tests/data/test-repo/chunks/DZJ2SZFPAHPVNBTKM8Y0 create mode 100644 icechunk-python/tests/data/test-repo/chunks/Q72986RVXEZ5NNV94Y5G create mode 100644 icechunk-python/tests/data/test-repo/chunks/TGKWEJAE6BYZQFFKNVK0 create mode 100644 icechunk-python/tests/data/test-repo/manifests/8WDWSTN2G5G66C3RP6T0 create mode 100644 icechunk-python/tests/data/test-repo/manifests/9QZWF97ZRE7653BGATC0 create mode 100644 icechunk-python/tests/data/test-repo/manifests/J0KFSTSCDYDXBPP4F31G create mode 100644 icechunk-python/tests/data/test-repo/manifests/JQ3WE20YA2C36SZXD7HG create mode 100644 icechunk-python/tests/data/test-repo/manifests/V9QDN7624X40SRTBV5C0 create mode 100644 icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZW.json create mode 100644 icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZX.json create mode 100644 icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZY.json create mode 100644 icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZZ.json create mode 100644 icechunk-python/tests/data/test-repo/refs/branch:my-branch/ZZZZZZZX.json create mode 100644 icechunk-python/tests/data/test-repo/refs/branch:my-branch/ZZZZZZZY.json create mode 100644 icechunk-python/tests/data/test-repo/refs/branch:my-branch/ZZZZZZZZ.json create mode 100644 icechunk-python/tests/data/test-repo/refs/tag:it also works!/ref.json create mode 100644 icechunk-python/tests/data/test-repo/refs/tag:it works!/ref.json create mode 100644 icechunk-python/tests/data/test-repo/snapshots/3KP6E7F3C2PE2HNGCNM0 create mode 100644 icechunk-python/tests/data/test-repo/snapshots/8AEWDWJRTMECASF516SG create mode 100644 icechunk-python/tests/data/test-repo/snapshots/AG1HZQ5SWS8DM8DNC670 create mode 100644 icechunk-python/tests/data/test-repo/snapshots/C1ZKMGE3ESPJ24YKN9MG create mode 100644 icechunk-python/tests/data/test-repo/snapshots/H01K0XJPGVW4HFX470AG create mode 100644 icechunk-python/tests/data/test-repo/snapshots/VNKSCC59M58V0MSJ01RG create mode 100644 icechunk-python/tests/test_can_read_old.py diff --git a/icechunk-python/tests/data/test-repo/chunks/3X7F8JDC8KBX3GGKXQJ0 b/icechunk-python/tests/data/test-repo/chunks/3X7F8JDC8KBX3GGKXQJ0 new file mode 100644 index 0000000000000000000000000000000000000000..bf22122791ba5ce53d2ce272d61f818d1bb28818 GIT binary patch literal 100 OcmZQz&~Rd)F$Mttl?qz` literal 0 HcmV?d00001 diff --git a/icechunk-python/tests/data/test-repo/chunks/DZJ2SZFPAHPVNBTKM8Y0 b/icechunk-python/tests/data/test-repo/chunks/DZJ2SZFPAHPVNBTKM8Y0 new file mode 100644 index 0000000000000000000000000000000000000000..bf22122791ba5ce53d2ce272d61f818d1bb28818 GIT binary patch literal 100 OcmZQz&~Rd)F$Mttl?qz` literal 0 HcmV?d00001 diff --git a/icechunk-python/tests/data/test-repo/chunks/Q72986RVXEZ5NNV94Y5G b/icechunk-python/tests/data/test-repo/chunks/Q72986RVXEZ5NNV94Y5G new file mode 100644 index 0000000000000000000000000000000000000000..bf22122791ba5ce53d2ce272d61f818d1bb28818 GIT binary patch literal 100 OcmZQz&~Rd)F$Mttl?qz` literal 0 HcmV?d00001 diff --git a/icechunk-python/tests/data/test-repo/chunks/TGKWEJAE6BYZQFFKNVK0 b/icechunk-python/tests/data/test-repo/chunks/TGKWEJAE6BYZQFFKNVK0 new file mode 100644 index 0000000000000000000000000000000000000000..bf22122791ba5ce53d2ce272d61f818d1bb28818 GIT binary patch literal 100 OcmZQz&~Rd)F$Mttl?qz` literal 0 HcmV?d00001 diff --git a/icechunk-python/tests/data/test-repo/manifests/8WDWSTN2G5G66C3RP6T0 b/icechunk-python/tests/data/test-repo/manifests/8WDWSTN2G5G66C3RP6T0 new file mode 100644 index 0000000000000000000000000000000000000000..bf375ccdb7cfedd1b90057d1a893a68bd25345f4 GIT binary patch literal 215 zcmbQt(9k)Fc@hIdkHBh1|_yj+|uyqzM9-QB$-0=*0vQa~ygp(;Y$y~AC- z99_+vBBKJ`+`Rq5ydf$WVP?2Qc^L&qxdk|S1cdoHg?Rf~L_$@-%m_3$va~P@3X5=! rGWGKdvowh`bqCtQGLfNinP*;3W?t$M#tkj=Tj}eUq!yPXl_qDWmgpBG zmSpIc?|RG^!iw_ljI z0ayhi15|}el$TL(lv{wKM?jdLQ;4^(MI=-O%#1*DBTEajps)znC{sVbFiVq2Q+J>l dEE5?TmwD#pWagzFVGIFs8Bw`Rs9a_wE&v8TQeprA literal 0 HcmV?d00001 diff --git a/icechunk-python/tests/data/test-repo/manifests/J0KFSTSCDYDXBPP4F31G b/icechunk-python/tests/data/test-repo/manifests/J0KFSTSCDYDXBPP4F31G new file mode 100644 index 0000000000000000000000000000000000000000..d13cb491b4f9635e3a31756eb071ad80099067a5 GIT binary patch literal 233 zcmbQt(9ki7c@hIdkj=Tj}eUq!yPXl_qDWmgpBG zmSpIc?|RG^!iw_ljI z0ayhi15|}el$TL(lv{wKM?jdLQ;4^(MI=-O%#1*DBTEajps)znC{sVbFiVq2Q+J>l dEE5?TmwD#pWagzFVGIFs8Bw`Rs9a_wE&v8TQeprA literal 0 HcmV?d00001 diff --git a/icechunk-python/tests/data/test-repo/manifests/JQ3WE20YA2C36SZXD7HG b/icechunk-python/tests/data/test-repo/manifests/JQ3WE20YA2C36SZXD7HG new file mode 100644 index 0000000000000000000000000000000000000000..32b9aed5e8762e240adbe1d0ba89b73ac96785e1 GIT binary patch literal 4 LcmbQt(9i$?1JD7K literal 0 HcmV?d00001 diff --git a/icechunk-python/tests/data/test-repo/manifests/V9QDN7624X40SRTBV5C0 b/icechunk-python/tests/data/test-repo/manifests/V9QDN7624X40SRTBV5C0 new file mode 100644 index 0000000000000000000000000000000000000000..ef487e872b00f4033992e3b1531c4c0aca8bdeef GIT binary patch literal 248 zcmbQt(9k)Fc@hIdkj=Tj}eUq!yPXl_qDWmgpBG zmSpIc?|RG^!iw_ljI z0ayhi15|}el$TL(lv{wKM?jdLQ;4^(MI=-O%#1*DBTEajps)znC{sVbFiVq2Q+J>l gEE5?TmwD#pWagzFVGIFs8Bw`Rs9a`LE(;PD00~`KEdT%j literal 0 HcmV?d00001 diff --git a/icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZW.json b/icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZW.json new file mode 100644 index 00000000..12c2ab49 --- /dev/null +++ b/icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZW.json @@ -0,0 +1 @@ +{"snapshot":"8AEWDWJRTMECASF516SG"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZX.json b/icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZX.json new file mode 100644 index 00000000..6b6af296 --- /dev/null +++ b/icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZX.json @@ -0,0 +1 @@ +{"snapshot":"3KP6E7F3C2PE2HNGCNM0"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZY.json b/icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZY.json new file mode 100644 index 00000000..6c1eaf57 --- /dev/null +++ b/icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZY.json @@ -0,0 +1 @@ +{"snapshot":"C1ZKMGE3ESPJ24YKN9MG"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZZ.json b/icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZZ.json new file mode 100644 index 00000000..4aafcd6f --- /dev/null +++ b/icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZZ.json @@ -0,0 +1 @@ +{"snapshot":"H01K0XJPGVW4HFX470AG"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/branch:my-branch/ZZZZZZZX.json b/icechunk-python/tests/data/test-repo/refs/branch:my-branch/ZZZZZZZX.json new file mode 100644 index 00000000..9d7881c6 --- /dev/null +++ b/icechunk-python/tests/data/test-repo/refs/branch:my-branch/ZZZZZZZX.json @@ -0,0 +1 @@ +{"snapshot":"AG1HZQ5SWS8DM8DNC670"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/branch:my-branch/ZZZZZZZY.json b/icechunk-python/tests/data/test-repo/refs/branch:my-branch/ZZZZZZZY.json new file mode 100644 index 00000000..04a3db74 --- /dev/null +++ b/icechunk-python/tests/data/test-repo/refs/branch:my-branch/ZZZZZZZY.json @@ -0,0 +1 @@ +{"snapshot":"VNKSCC59M58V0MSJ01RG"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/branch:my-branch/ZZZZZZZZ.json b/icechunk-python/tests/data/test-repo/refs/branch:my-branch/ZZZZZZZZ.json new file mode 100644 index 00000000..12c2ab49 --- /dev/null +++ b/icechunk-python/tests/data/test-repo/refs/branch:my-branch/ZZZZZZZZ.json @@ -0,0 +1 @@ +{"snapshot":"8AEWDWJRTMECASF516SG"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/tag:it also works!/ref.json b/icechunk-python/tests/data/test-repo/refs/tag:it also works!/ref.json new file mode 100644 index 00000000..9d7881c6 --- /dev/null +++ b/icechunk-python/tests/data/test-repo/refs/tag:it also works!/ref.json @@ -0,0 +1 @@ +{"snapshot":"AG1HZQ5SWS8DM8DNC670"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/refs/tag:it works!/ref.json b/icechunk-python/tests/data/test-repo/refs/tag:it works!/ref.json new file mode 100644 index 00000000..04a3db74 --- /dev/null +++ b/icechunk-python/tests/data/test-repo/refs/tag:it works!/ref.json @@ -0,0 +1 @@ +{"snapshot":"VNKSCC59M58V0MSJ01RG"} \ No newline at end of file diff --git a/icechunk-python/tests/data/test-repo/snapshots/3KP6E7F3C2PE2HNGCNM0 b/icechunk-python/tests/data/test-repo/snapshots/3KP6E7F3C2PE2HNGCNM0 new file mode 100644 index 0000000000000000000000000000000000000000..89a119915794dc3b346f2570d78fdf75d6d0795d GIT binary patch literal 735 zcmbu7O;5rw7{>=nV*C{Q0!r78ZFo{)Oe7El;>#|kLV?CI$XX6~5uzUeCI=HwB)sU! z#1N0hvwkTj%55HqO_Qhn=jreH9r_@32j1K`NuCr-IcUW!O-rCcUMm5q9}atW^Mqn& zvX+U=V*U`SXPI0)YrR1L)hz{VDM|^V9e*RVt;Tf_N4lZd&!o{39hnQaT^4PZ_>kgh z>G()LMesP4G*wO8Pl8~Gt^-3y0NSqw(rR)lnr=s?4iywLdPORCbCk(zrk+9xm^a}; z&PwF6!K@fiRn-77)qbi{#^eeXIB@8qQAcrLcW+10VQ3WXP16pe4(U!Tx7lvZqP6d$ z48zOQvf<{DmPxtZb}mC>?)!c2n$lv8MV<0z%4IA&ev7;S{Lo&0Y0(Q>J72uz!?l2L zW~ra#*wTGHF#f0gATp+oFb1Jt@p0O}ZLDnk1qC()5C8xG literal 0 HcmV?d00001 diff --git a/icechunk-python/tests/data/test-repo/snapshots/8AEWDWJRTMECASF516SG b/icechunk-python/tests/data/test-repo/snapshots/8AEWDWJRTMECASF516SG new file mode 100644 index 0000000000000000000000000000000000000000..232a89dce2d57b65572da9c6c2630e4b29c31ca5 GIT binary patch literal 806 zcmbu7PjA{V7{T10KrlH7Mk@1}WR$ayUT+?oMf+aFf4$DB9xLzS|F6 ze`Q^7o19sSg!9j9TiLFq>@#u4gi2LE8BwEt h_vGS=a=vGOo+|$*{Vca+zBFf6<6nwy+B+VJQK&7t@8=Wnw9$9Zc{d#FIB(YGUGP z6JGSP8x=hnvuFJTej9m>FDV-dv1#(m{QJ!R`OQ4jH=$OyH%YRoxREmy^+YWmWHKlY zK_b+NL|*kL`E<%q6kgVODbMOgk_FksSB~Y-K4A9&$Z_!D^o8&N&&oVP00Hx|jBQLY zV=<*l!+Fx5NHMLT7LwVVt|>7i&I7?%=OKukAPJKB-ouo6N?h8o?3E2nWdu!(Lxsy| z++jMQq;+-;3PMO?d1SsjBeue-rLM|UyH6#cJYA^#5MOG3a%bB0ExL)%Jt+`ExID`_*CeLuW zKZzy4p?S<31w&GGNlhz)80^IZ8On%J|v_y}f8po9do_3|R>hcBY- z(UD^l2e*a^x7MhSqvx{#{9CKhD&fUjQo{4jg$Lf0^!p<%G=B!PLcA;DO&jY>Q<%BI z!tKM-EqY4MAJ5l%{_Oy9;~JLhTFv$_HVeJiy}iBhnO(Ce*NoTsijSzhRT3ML5BH_m! zi6JfxYkeupPj_fSoZRHjJvsNxm&_~Eb=-L-53dyc$mc1h&$7<*JYFal zOL@5rK`EI~!jg2B5I%tb!XZ%ziDD7J6R#jV3d^z(k)Z(8k1NzR38tpi;jn=z)^vQ2 z>Ry(pW^*Y8Duv4o2v@Qf(OhbKR#XzV|47J*{!x3uPHELVv54$KGtKFv|KVXM^ zZ<022Y@qHqWtnz+$~Trx9PIfw-|+Z`YpB+=jXQQkJj(A=g;|{uZDKORgwr^~vv8|` zb=vzTjj!Uy&1#nn_P2;TzoA){*6EF7%jUkg!Qk{(H#G`@8wiXGx<;D4X>zT6JuDc; zamUpTMWi=DMjdO$M8}lssK5B-OZ#)5oU}Au_t_7&%KxNy0wZkDh{JBcM`>?5+?n_X DBpLFf literal 0 HcmV?d00001 diff --git a/icechunk-python/tests/data/test-repo/snapshots/H01K0XJPGVW4HFX470AG b/icechunk-python/tests/data/test-repo/snapshots/H01K0XJPGVW4HFX470AG new file mode 100644 index 0000000000000000000000000000000000000000..7fe668f86c644c135c192b514e50d0f7866f7056 GIT binary patch literal 117 zcmbQu&@f>F1H**LTbzRoz4E7lr7@6oA8t57th8P)J0g wo{71+frYuTfl<`9pwxo=;>?o#qDqC#yv&l!#GK5k)D)b$ERDNyC96~JRGTfwGE1(lNUB@;Edv=HOW;_NS~7f*ZU~<%K-FYbS9R}4NVxcp20X${ z?Dix7?L2|ChH7axH06dRzp7Wvx&sc_AcS_>4 z8v+%0<4-rmMee3JS)clh`eeMS1cPX}DPG+NY4K!#+9!PcO1oq`?c6d Date: Fri, 11 Oct 2024 20:35:43 -0300 Subject: [PATCH 074/167] ruff --- icechunk-python/tests/test_can_read_old.py | 136 +++++++++++++++------ 1 file changed, 99 insertions(+), 37 deletions(-) diff --git a/icechunk-python/tests/test_can_read_old.py b/icechunk-python/tests/test_can_read_old.py index 04fa4e07..63856767 100644 --- a/icechunk-python/tests/test_can_read_old.py +++ b/icechunk-python/tests/test_can_read_old.py @@ -1,4 +1,4 @@ -""" This test reads a repository generated with an older version of icechunk. +"""This test reads a repository generated with an older version of icechunk. In this way, we check we maintain read compatibility. The repository lives in the git repository, as a filesystem store, in the directory icechunk-python/tests/data/test-repo @@ -13,9 +13,8 @@ import icechunk as ic import zarr -import pytest -from object_store import ClientOptions, ObjectStore from numpy.testing import assert_array_equal +from object_store import ClientOptions, ObjectStore def write_chunks_to_minio(chunks: list[tuple[str, bytes]]): @@ -42,7 +41,7 @@ async def mk_store(mode): store_path = "./tests/data/test-repo" store = await ic.IcechunkStore.open( storage=ic.StorageConfig.filesystem(store_path), - config = ic.StoreConfig( + config=ic.StoreConfig( inline_chunk_threshold_bytes=10, virtual_ref_config=ic.VirtualRefConfig.s3_from_config( credentials=ic.S3Credentials( @@ -52,12 +51,13 @@ async def mk_store(mode): endpoint_url="http://localhost:9000", allow_http=True, region="us-east-1", - ) + ), ), mode=mode, ) return store + async def write_a_test_repo(): """Write the test repository. @@ -69,26 +69,45 @@ async def write_a_test_repo(): """ print("Writing repository to ./tests/data/test-repo") - store = await mk_store("w"); + store = await mk_store("w") root = zarr.group(store=store) - group1 = root.create_group("group1", attributes={"this": "is a nice group", "icechunk": 1, "size":42.0}) + group1 = root.create_group( + "group1", attributes={"this": "is a nice group", "icechunk": 1, "size": 42.0} + ) # these chunks will be materialized - big_chunks = group1.create_array("big_chunks", shape=(10, 10), chunk_shape=(5, 5), dtype="float32", fill_value=float('nan'), attributes={"this": "is a nice array", "icechunk": 1, "size":42.0}) + big_chunks = group1.create_array( + "big_chunks", + shape=(10, 10), + chunk_shape=(5, 5), + dtype="float32", + fill_value=float("nan"), + attributes={"this": "is a nice array", "icechunk": 1, "size": 42.0}, + ) # these chunks will be inline - small_chunks = group1.create_array("small_chunks", shape=(5), chunk_shape=(1), dtype="int8", fill_value=8, attributes={"this": "is a nice array", "icechunk": 1, "size":42.0}) - snap1 = await store.commit("empty structure") + small_chunks = group1.create_array( + "small_chunks", + shape=(5), + chunk_shape=(1), + dtype="int8", + fill_value=8, + attributes={"this": "is a nice array", "icechunk": 1, "size": 42.0}, + ) + await store.commit("empty structure") big_chunks[:] = 42.0 small_chunks[:] = 84 - snap2 = await store.commit("fill data") + await store.commit("fill data") await store.set_virtual_ref( - "group1/big_chunks/c/0/0", "s3://testbucket/path/to/python/chunk-1", offset=0, length=5*5*4 + "group1/big_chunks/c/0/0", + "s3://testbucket/path/to/python/chunk-1", + offset=0, + length=5 * 5 * 4, ) - snap3 = await store.commit("set virtual chunk") + await store.commit("set virtual chunk") await store.new_branch("my-branch") await store.delete("group1/small_chunks/c/4") @@ -96,24 +115,48 @@ async def write_a_test_repo(): await store.tag("it works!", snap4) - group2 = root.create_group("group2", attributes={"this": "is a nice group", "icechunk": 1, "size":42.0}) - group3 = group2.create_group("group3", attributes={"this": "is a nice group", "icechunk": 1, "size":42.0}) - group4 = group3.create_group("group4", attributes={"this": "is a nice group", "icechunk": 1, "size":42.0}) - group5 = group4.create_group("group5", attributes={"this": "is a nice group", "icechunk": 1, "size":42.0}) - inner = group5.create_array("inner", shape=(10, 10), chunk_shape=(5, 5), dtype="float32", fill_value=float('nan'), attributes={"this": "is a nice array", "icechunk": 1, "size":42.0}) + group2 = root.create_group( + "group2", attributes={"this": "is a nice group", "icechunk": 1, "size": 42.0} + ) + group3 = group2.create_group( + "group3", attributes={"this": "is a nice group", "icechunk": 1, "size": 42.0} + ) + group4 = group3.create_group( + "group4", attributes={"this": "is a nice group", "icechunk": 1, "size": 42.0} + ) + group5 = group4.create_group( + "group5", attributes={"this": "is a nice group", "icechunk": 1, "size": 42.0} + ) + group5.create_array( + "inner", + shape=(10, 10), + chunk_shape=(5, 5), + dtype="float32", + fill_value=float("nan"), + attributes={"this": "is a nice array", "icechunk": 1, "size": 42.0}, + ) snap5 = await store.commit("some more structure") await store.tag("it also works!", snap5) store.close() - -async def test_icechunk_can_read_old_repo(): - store = await mk_store("r"); - expected_main_history = ["set virtual chunk", "fill data", "empty structure", "Repository initialized"] + +async def test_icechunk_can_read_old_repo(): + store = await mk_store("r") + + expected_main_history = [ + "set virtual chunk", + "fill data", + "empty structure", + "Repository initialized", + ] assert [p.message async for p in store.ancestry()] == expected_main_history await store.checkout(branch="my-branch") - expected_branch_history = ["some more structure", "delete a chunk"] + expected_main_history + expected_branch_history = [ + "some more structure", + "delete a chunk", + ] + expected_main_history assert [p.message async for p in store.ancestry()] == expected_branch_history await store.checkout(tag="it also works!") @@ -122,29 +165,50 @@ async def test_icechunk_can_read_old_repo(): await store.checkout(tag="it works!") assert [p.message async for p in store.ancestry()] == expected_branch_history[1:] - store = await mk_store("r"); + store = await mk_store("r") await store.checkout(branch="my-branch") - assert sorted([p async for p in store.list_dir("")]) == ["group1", "group2", "zarr.json"] - assert sorted([p async for p in store.list_dir("group1")]) == ["big_chunks", "small_chunks", "zarr.json"] + assert sorted([p async for p in store.list_dir("")]) == [ + "group1", + "group2", + "zarr.json", + ] + assert sorted([p async for p in store.list_dir("group1")]) == [ + "big_chunks", + "small_chunks", + "zarr.json", + ] assert sorted([p async for p in store.list_dir("group2")]) == ["group3", "zarr.json"] - assert sorted([p async for p in store.list_dir("group2/group3")]) == ["group4", "zarr.json"] - assert sorted([p async for p in store.list_dir("group2/group3/group4")]) == ["group5", "zarr.json"] - assert sorted([p async for p in store.list_dir("group2/group3/group4/group5")]) == ["inner", "zarr.json"] - assert sorted([p async for p in store.list_dir("group2/group3/group4/group5/inner")]) == ["zarr.json"] + assert sorted([p async for p in store.list_dir("group2/group3")]) == [ + "group4", + "zarr.json", + ] + assert sorted([p async for p in store.list_dir("group2/group3/group4")]) == [ + "group5", + "zarr.json", + ] + assert sorted([p async for p in store.list_dir("group2/group3/group4/group5")]) == [ + "inner", + "zarr.json", + ] + assert sorted( + [p async for p in store.list_dir("group2/group3/group4/group5/inner")] + ) == ["zarr.json"] root = zarr.group(store=store) # inner is not initialized, so it's all fill values inner = root["group2/group3/group4/group5/inner"] - assert_array_equal(inner[:], float('nan')) + assert_array_equal(inner[:], float("nan")) small_chunks = root["group1/small_chunks"] # has 5 elements, we deleted the last chunk (of size 1), and the fill value is 8 - assert_array_equal(small_chunks[:], [84,84,84,84, 8]) + assert_array_equal(small_chunks[:], [84, 84, 84, 84, 8]) # big_chunks array has a virtual chunk, so we need to write it to local MinIO # we get the bytes from one of the materialized chunks buffer_prototype = zarr.core.buffer.default_buffer_prototype() - chunk_data = (await store.get("group1/big_chunks/c/0/1", prototype=buffer_prototype)).to_bytes() + chunk_data = ( + await store.get("group1/big_chunks/c/0/1", prototype=buffer_prototype) + ).to_bytes() # big chunks array has a virtual chunk pointing here write_chunks_to_minio( @@ -157,9 +221,7 @@ async def test_icechunk_can_read_old_repo(): assert_array_equal(big_chunks[:], 42.0) -if __name__ == '__main__': +if __name__ == "__main__": import asyncio - asyncio.run(write_a_test_repo()) - - + asyncio.run(write_a_test_repo()) From 14bab3ca2b9ac2ff0a796d6b841cd29b283dd4bf Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Fri, 11 Oct 2024 23:21:21 -0300 Subject: [PATCH 075/167] change url to missing dataset --- icechunk-python/tests/test_virtual_ref.py | 2 +- icechunk/tests/test_virtual_refs.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/icechunk-python/tests/test_virtual_ref.py b/icechunk-python/tests/test_virtual_ref.py index 3b92e1ee..87c4696e 100644 --- a/icechunk-python/tests/test_virtual_ref.py +++ b/icechunk-python/tests/test_virtual_ref.py @@ -97,7 +97,7 @@ async def test_from_s3_public_virtual_refs(tmpdir): await store.set_virtual_ref( "depth/c/0", - "s3://noaa-nos-ofs-pds/dbofs/netcdf/202410/dbofs.t00z.20241009.regulargrid.f030.nc", + "s3://noaa-nos-ofs-pds/dbofs/netcdf/202410/dbofs.t00z.20241012.regulargrid.f030.nc", offset=42499, length=176 ) diff --git a/icechunk/tests/test_virtual_refs.rs b/icechunk/tests/test_virtual_refs.rs index 73e76a97..b4bc60ff 100644 --- a/icechunk/tests/test_virtual_refs.rs +++ b/icechunk/tests/test_virtual_refs.rs @@ -434,7 +434,7 @@ mod tests { let ref2 = VirtualChunkRef { location: VirtualChunkLocation::from_absolute_path( - "s3://noaa-nos-ofs-pds/dbofs/netcdf/202410/dbofs.t00z.20241009.regulargrid.f030.nc", + "s3://noaa-nos-ofs-pds/dbofs/netcdf/202410/dbofs.t00z.20241012.regulargrid.f030.nc", )?, offset: 42499, length: 176, From 77de941d0004b2d37516924bd4da71d512ec935f Mon Sep 17 00:00:00 2001 From: Joe Hamman Date: Sat, 12 Oct 2024 07:11:04 -0700 Subject: [PATCH 076/167] bump zarr dep to 3.0.0b0 (#194) * bump zarr dep to 3.0.0b0 * Fix config test * fix some types * Sync with new store api * Sync tests with zarr 3 beta * Fix open mode --------- Co-authored-by: Matthew Iannucci --- icechunk-python/pyproject.toml | 2 +- icechunk-python/python/icechunk/__init__.py | 2 +- .../python/icechunk/_icechunk_python.pyi | 6 +- icechunk-python/tests/test_zarr/test_array.py | 206 +++++++++++++++++- icechunk-python/tests/test_zarr/test_group.py | 26 ++- .../test_store/test_icechunk_store.py | 20 +- 6 files changed, 245 insertions(+), 17 deletions(-) diff --git a/icechunk-python/pyproject.toml b/icechunk-python/pyproject.toml index c0a9a97f..5facea81 100644 --- a/icechunk-python/pyproject.toml +++ b/icechunk-python/pyproject.toml @@ -12,7 +12,7 @@ classifiers = [ ] dynamic = ["version"] -dependencies = ["zarr==3.0.0a7"] +dependencies = ["zarr==3.0.0b0"] [tool.poetry] name = "icechunk" diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index c94851fe..7d96b77f 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -85,7 +85,7 @@ def __init__( **kwargs: Any, ): """Create a new IcechunkStore. This should not be called directly, instead use the create or open_existing class methods.""" - super().__init__(mode, *args, **kwargs) + super().__init__(*args, mode=mode, **kwargs) if store is None: raise ValueError( "An IcechunkStore should not be created with the default constructor, instead use either the create or open_existing class methods." diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index 4696c0e7..4608fc98 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -191,7 +191,8 @@ class VirtualRefConfig: def s3_from_config( cls, credentials: S3Credentials, - endpoint_url: str | None, + *, + endpoint_url: str | None = None, allow_http: bool | None = None, region: str | None = None, ) -> VirtualRefConfig: @@ -206,7 +207,8 @@ class VirtualRefConfig: @classmethod def s3_anonymous( cls, - endpoint_url: str | None, + *, + endpoint_url: str | None = None, allow_http: bool | None = None, region: str | None = None, ) -> VirtualRefConfig: diff --git a/icechunk-python/tests/test_zarr/test_array.py b/icechunk-python/tests/test_zarr/test_array.py index 43044509..704fcf8e 100644 --- a/icechunk-python/tests/test_zarr/test_array.py +++ b/icechunk-python/tests/test_zarr/test_array.py @@ -1,4 +1,6 @@ -from typing import Literal +import pickle +from itertools import accumulate +from typing import Any, Literal import numpy as np import pytest @@ -6,8 +8,12 @@ import zarr.api import zarr.api.asynchronous from icechunk import IcechunkStore -from zarr import Array, AsyncGroup, Group -from zarr.core.common import ZarrFormat +from zarr import Array, AsyncArray, AsyncGroup, Group +from zarr.codecs import BytesCodec, VLenBytesCodec +from zarr.core.array import chunks_initialized +from zarr.core.common import JSON, ZarrFormat +from zarr.core.indexing import ceildiv +from zarr.core.sync import sync from zarr.errors import ContainsArrayError, ContainsGroupError from zarr.storage import StorePath @@ -185,3 +191,197 @@ def test_array_v3_fill_value( assert arr.fill_value == np.dtype(dtype_str).type(fill_value) assert arr.fill_value.dtype == arr.dtype + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +async def test_array_v3_nan_fill_value(store: IcechunkStore) -> None: + shape = (10,) + arr = Array.create( + store=store, + shape=shape, + dtype=np.float64, + zarr_format=3, + chunk_shape=shape, + fill_value=np.nan, + ) + arr[:] = np.nan + + assert np.isnan(arr.fill_value) + assert arr.fill_value.dtype == arr.dtype + # # all fill value chunk is an empty chunk, and should not be written + # assert len([a async for a in store.list_prefix("/")]) == 0 + + +@pytest.mark.parametrize("store", ["local"], indirect=["store"]) +@pytest.mark.parametrize("zarr_format", [3]) +async def test_serializable_async_array( + store: IcechunkStore, zarr_format: ZarrFormat +) -> None: + expected = await AsyncArray.create( + store=store, shape=(100,), chunks=(10,), zarr_format=zarr_format, dtype="i4" + ) + # await expected.setitems(list(range(100))) + + p = pickle.dumps(expected) + actual = pickle.loads(p) + + assert actual == expected + # np.testing.assert_array_equal(await actual.getitem(slice(None)), await expected.getitem(slice(None))) + # TODO: uncomment the parts of this test that will be impacted by the config/prototype changes in flight + + +@pytest.mark.parametrize("store", ["local"], indirect=["store"]) +@pytest.mark.parametrize("zarr_format", [3]) +def test_serializable_sync_array(store: IcechunkStore, zarr_format: ZarrFormat) -> None: + expected = Array.create( + store=store, shape=(100,), chunks=(10,), zarr_format=zarr_format, dtype="i4" + ) + expected[:] = list(range(100)) + + p = pickle.dumps(expected) + actual = pickle.loads(p) + + assert actual == expected + np.testing.assert_array_equal(actual[:], expected[:]) + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +def test_storage_transformers(store: IcechunkStore) -> None: + """ + Test that providing an actual storage transformer produces a warning and otherwise passes through + """ + metadata_dict: dict[str, JSON] = { + "zarr_format": 3, + "node_type": "array", + "shape": (10,), + "chunk_grid": {"name": "regular", "configuration": {"chunk_shape": (1,)}}, + "data_type": "uint8", + "chunk_key_encoding": {"name": "v2", "configuration": {"separator": "/"}}, + "codecs": (BytesCodec().to_dict(),), + "fill_value": 0, + "storage_transformers": ({"test": "should_raise"}), + } + match = "Arrays with storage transformers are not supported in zarr-python at this time." + with pytest.raises(ValueError, match=match): + Array.from_dict(StorePath(store), data=metadata_dict) + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +@pytest.mark.parametrize("test_cls", [Array, AsyncArray[Any]]) +@pytest.mark.parametrize("nchunks", [2, 5, 10]) +def test_nchunks(store: IcechunkStore, test_cls: type[Array] | type[AsyncArray[Any]], nchunks: int) -> None: + """ + Test that nchunks returns the number of chunks defined for the array. + """ + shape = 100 + arr = Array.create(store, shape=(shape,), chunks=(ceildiv(shape, nchunks),), dtype="i4") + expected = nchunks + if test_cls == Array: + observed = arr.nchunks + else: + observed = arr._async_array.nchunks + assert observed == expected + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +@pytest.mark.parametrize("test_cls", [Array, AsyncArray[Any]]) +def test_nchunks_initialized(store: IcechunkStore, test_cls: type[Array] | type[AsyncArray[Any]]) -> None: + """ + Test that nchunks_initialized accurately returns the number of stored chunks. + """ + arr = Array.create(store, shape=(100,), chunks=(10,), dtype="i4") + + # write chunks one at a time + for idx, region in enumerate(arr._iter_chunk_regions()): + arr[region] = 1 + expected = idx + 1 + if test_cls == Array: + observed = arr.nchunks_initialized + else: + observed = arr._async_array.nchunks_initialized + assert observed == expected + + # delete chunks + for idx, key in enumerate(arr._iter_chunk_keys()): + sync(arr.store_path.store.delete(key)) + if test_cls == Array: + observed = arr.nchunks_initialized + else: + observed = arr._async_array.nchunks_initialized + expected = arr.nchunks - idx - 1 + assert observed == expected + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +@pytest.mark.parametrize("test_cls", [Array, AsyncArray[Any]]) +def test_chunks_initialized(store: IcechunkStore, test_cls: type[Array] | type[AsyncArray[Any]]) -> None: + """ + Test that chunks_initialized accurately returns the keys of stored chunks. + """ + arr = Array.create(store, shape=(100,), chunks=(10,), dtype="i4") + + chunks_accumulated = tuple( + accumulate(tuple(tuple(v.split(" ")) for v in arr._iter_chunk_keys())) + ) + for keys, region in zip(chunks_accumulated, arr._iter_chunk_regions(), strict=False): + arr[region] = 1 + + if test_cls == Array: + observed = sorted(chunks_initialized(arr)) + else: + observed = sorted(chunks_initialized(arr._async_array)) + + expected = sorted(keys) + assert observed == expected + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +def test_default_fill_values(store: IcechunkStore) -> None: + root = Group.from_store(store) + + a = root.create(name="u4", shape=5, chunk_shape=5, dtype=" None: + with pytest.raises(ValueError, match="At least one ArrayBytesCodec is required."): + Array.create(store, shape=5, chunk_shape=5, dtype=" None: + # regression test for https://github.com/zarr-developers/zarr-python/issues/2328 + arr = Array.create(store=store, shape=5, chunk_shape=5, dtype="f8", zarr_format=zarr_format) + arr.attrs["foo"] = "bar" + assert arr.attrs["foo"] == "bar" + + arr2 = zarr.open_array(store=store, zarr_format=zarr_format) + assert arr2.attrs["foo"] == "bar" \ No newline at end of file diff --git a/icechunk-python/tests/test_zarr/test_group.py b/icechunk-python/tests/test_zarr/test_group.py index 1d51813a..966efd16 100644 --- a/icechunk-python/tests/test_zarr/test_group.py +++ b/icechunk-python/tests/test_zarr/test_group.py @@ -141,11 +141,7 @@ def test_group_members(store: IcechunkStore, zarr_format: ZarrFormat) -> None: """ path = "group" - agroup = AsyncGroup( - metadata=GroupMetadata(zarr_format=zarr_format), - store_path=StorePath(store=store, path=path), - ) - group = Group(agroup) + group = Group.from_store(store=store, zarr_format=zarr_format) members_expected: dict[str, Array | Group] = {} members_expected["subgroup"] = group.create_group("subgroup") @@ -313,6 +309,26 @@ def test_group_getitem(store: IcechunkStore, zarr_format: ZarrFormat) -> None: group["nope"] +def test_group_get_with_default(store: IcechunkStore, zarr_format: ZarrFormat) -> None: + group = Group.from_store(store, zarr_format=zarr_format) + + # default behavior + result = group.get("subgroup") + assert result is None + + # custom default + result = group.get("subgroup", 8) + assert result == 8 + + # now with a group + subgroup = group.require_group("subgroup") + subgroup.attrs["foo"] = "bar" + + result = group.get("subgroup", 8) + result = cast(Group, result) + assert result.attrs["foo"] == "bar" + + def test_group_delitem(store: IcechunkStore, zarr_format: ZarrFormat) -> None: """ Test the `Group.__delitem__` method. diff --git a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py index c837d08f..f51b618f 100644 --- a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py +++ b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py @@ -47,17 +47,15 @@ async def get(self, store: IcechunkStore, key: str) -> Buffer: return self.buffer_cls.from_bytes(result) @pytest.fixture(scope="function", params=[None, True]) - def store_kwargs( - self, request: pytest.FixtureRequest - ) -> dict[str, str | None | dict[str, Buffer]]: + def store_kwargs(self) -> dict[str, Any]: kwargs = { - "storage": StorageConfig.memory(""), + "storage": StorageConfig.memory("store_test"), "mode": "w", } return kwargs @pytest.fixture(scope="function") - async def store(self, store_kwargs: str | None | dict[str, Buffer]) -> IcechunkStore: + async def store(self, store_kwargs: dict[str, Any]) -> IcechunkStore: return await IcechunkStore.open(**store_kwargs) @pytest.mark.xfail(reason="Not implemented") @@ -72,6 +70,18 @@ def test_store_mode(self, store, store_kwargs: dict[str, Any]) -> None: assert store.mode == AccessMode.from_literal("w") assert not store.mode.readonly + @pytest.mark.parametrize("mode", ["r", "r+", "a", "w", "w-"]) + async def test_store_open_mode( + self, store_kwargs: dict[str, Any], mode: AccessModeLiteral + ) -> None: + store_kwargs["mode"] = mode + try: + store = await self.store_cls.open(**store_kwargs) + assert store._is_open + assert store.mode == AccessMode.from_literal(mode) + except Exception: + assert 'r' in mode + async def test_not_writable_store_raises(self, store_kwargs: dict[str, Any]) -> None: create_kwargs = {**store_kwargs, "mode": "r"} with pytest.raises(ValueError): From 3047033c25f792e6286a83bd15b26009bd70158f Mon Sep 17 00:00:00 2001 From: Aimee Barciauskas Date: Sat, 12 Oct 2024 09:50:58 -0700 Subject: [PATCH 077/167] Minor changes to readme --- README.md | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5d8f01fe..6f7a3489 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Let's break down what that means: Zarr defines the metadata for describing arrays (shape, dtype, etc.) and the way these arrays are chunked, compressed, and converted to raw bytes for storage. Zarr can store its data in any key-value store. - **Storage engine** - Icechunk exposes a key-value interface to Zarr and manages all of the actual I/O for getting, setting, and updating both metadata and chunk data in cloud object storage. Zarr libraries don't have to know exactly how icechunk works under the hood in order to use it. -- **Transactional** - The key improvement that Icechunk brings on top of regular Zarr is to provide consistent serializable isolation between transactions. +- **Transactional** - The key improvement Icechunk brings on top of regular Zarr is to provide consistent serializable isolation between transactions. This means that Icechunk data are safe to read and write in parallel from multiple uncoordinated processes. This allows Zarr to be used more like a database. @@ -61,7 +61,14 @@ We recommend using Icechunk from Python, together with the Zarr-Python library Icechunk is currently designed to support the [Zarr V3 Specification](https://zarr-specs.readthedocs.io/en/latest/v3/core/v3.0.html). Using it today requires installing the [still unreleased] Zarr Python V3 branch. -To set up an Icechunk development environment, follow these steps +To set up an Icechunk development environment, follow these steps: + +Clone the repository and navigate to the repository's directory. For example: + +```bash +git clone https://github.com/earth-mover/icechunk +cd icechunk/ +``` Activate your preferred virtual environment (here we use `virtualenv`): @@ -99,11 +106,11 @@ pip install -e icechunk@. > [!WARNING] > This only makes the python source code editable, the rust will need to -> be recompiled when it changes +> be recompiled when it changes. ### Basic Usage -Once you have everything installed, here's an example of how to use Icechunk. +Once you have everything installed, here's an example of how to use Icechunk: ```python from icechunk import IcechunkStore, StorageConfig @@ -149,7 +156,7 @@ cargo test --all Every update to an Icechunk store creates a new **snapshot** with a unique ID. Icechunk users must organize their updates into groups of related operations called **transactions**. -For example, appending a new time slice to mutliple arrays should be done as single transactions, comprising the following steps +For example, appending a new time slice to mutliple arrays should be done as single transactions, comprising the following steps: 1. Update the array metadata to resize the array to accommodate the new elements. 2. Write new chunks for each array in the group. @@ -171,8 +178,8 @@ Tags are appropriate for publishing specific releases of a repository or for any > [!NOTE] > For more detailed explanation, have a look at the [Icechunk spec](spec/icechunk-spec.md) -Zarr itself works by storing both metadata and chunk data into a abstract store according to a specified system of "keys". -For example, a 2D Zarr array called myarray, within a group called mygroup, would generate the following keys. +Zarr itself works by storing both metadata and chunk data into an abstract store according to a specified system of "keys". +For example, a 2D Zarr array called myarray, within a group called mygroup, would generate the following keys: ``` mygroup/zarr.json @@ -181,7 +188,7 @@ mygroup/myarray/c/0/0 mygroup/myarray/c/0/1 ``` -In standard regular Zarr stores, these key map directly to filenames in a filesystem or object keys in an object storage system. +In standard Zarr stores, these key map directly to filenames in a filesystem or object keys in an object storage system. When writing data, a Zarr implementation will create these keys and populate them with data. When modifying existing arrays or groups, a Zarr implementation will potentially overwrite existing keys with new data. This is generally not a problem, as long there is only one person or process coordinating access to the data. @@ -217,7 +224,7 @@ flowchart TD ## Inspiration -Icechunk's was inspired by several existing projects and formats, most notably +Icechunk was inspired by several existing projects and formats, most notably: - [FSSpec Reference Filesystem](https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.implementations.reference.ReferenceFileSystem) - [Apache Iceberg](https://iceberg.apache.org/spec/) From c23229b031435c3dccacc17f6b0fb7045450f904 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Sat, 12 Oct 2024 13:33:49 -0300 Subject: [PATCH 078/167] Make Icechunk Store pickling work with Dask Also in this PR: * rewrite Dask example using Store pickling * document Dask example --- icechunk-python/examples/dask_write.py | 231 +++++++++++--------- icechunk-python/python/icechunk/__init__.py | 17 +- 2 files changed, 135 insertions(+), 113 deletions(-) diff --git a/icechunk-python/examples/dask_write.py b/icechunk-python/examples/dask_write.py index ca129620..72a29fa7 100644 --- a/icechunk-python/examples/dask_write.py +++ b/icechunk-python/examples/dask_write.py @@ -1,106 +1,142 @@ """ -This scripts uses dask to write a big array to S3. +This example uses Dask to write or update an array in an Icechunk repository. + +To understand all the available options run: +``` +python ./examples/dask_write.py --help +python ./examples/dask_write.py create --help +python ./examples/dask_write.py update --help +python ./examples/dask_write.py verify --help +``` Example usage: ``` -python ./examples/dask_write.py create --name test --t-chunks 100000 -python ./examples/dask_write.py update --name test --t-from 0 --t-to 1500 --workers 16 --max-sleep 0.1 --min-sleep 0 --sleep-tasks 300 -python ./examples/dask_write.py verify --name test --t-from 0 --t-to 1500 --workers 16 +python ./examples/dask_write.py create --url s3://my-bucket/my-icechunk-repo --t-chunks 100000 --x-chunks 4 --y-chunks 4 --chunk-x-size 112 --chunk-y-size 112 +python ./examples/dask_write.py update --url s3://my-bucket/my-icechunk-repo --t-from 0 --t-to 1500 --workers 16 +python ./examples/dask_write.py verify --url s3://my-bucket/my-icechunk-repo --t-from 0 --t-to 1500 --workers 16 ``` + +The work is split into three different commands. +* `create` initializes the repository and the array, without writing any chunks. For this example + we chose a 3D array that simulates a dataset that needs backfilling across its time dimension. +* `update` can be called multiple times to write a number of "pancakes" to the array. + It does so by distributing the work among Dask workers, in small tasks, one pancake per task. + The example invocation above, will write 1,500 pancakes using 16 Dask workers. +* `verify` can read a part of the array and check that it contains the required data. + +Icechunk can do distributed writes to object store, but currently, it cannot use the Dask array API +(we are working on it, see https://github.com/earth-mover/icechunk/issues/185). +Dask can still be used to read and write to Icechunk from multiple processes and machines, we just need to use a lower level +Dask API based, for example, in `map/gather`. This mechanism is what we show in this example. """ import argparse import asyncio -import time from dataclasses import dataclass -from typing import cast +from typing import Any, cast +from urllib.parse import urlparse import icechunk import numpy as np import zarr from dask.distributed import Client +from dask.distributed import print as dprint @dataclass class Task: - # fixme: useee StorageConfig and StoreConfig once those are pickable - storage_config: dict - store_config: dict - time: int - seed: int - sleep: float - - -async def mk_store(mode: str, task: Task): - storage_config = icechunk.StorageConfig.s3_from_env(**task.storage_config) - store_config = icechunk.StoreConfig(**task.store_config) - - store = await icechunk.IcechunkStore.open( - storage=storage_config, - mode="a", - config=store_config, - ) - - return store + """A task distributed to Dask workers""" + store: icechunk.IcechunkStore # The worker will use this Icechunk store to read/write to the dataset + time: int # The position in the coordinate dimension where the read/write should happen + seed: int # An RNG seed used to generate or recreate random data for the array -def generate_task_array(task: Task, shape): +def generate_task_array(task: Task, shape: tuple[int,...]) -> np.typing.ArrayLike: + """Generates a randm array with the given shape and using the seed in the Task""" np.random.seed(task.seed) return np.random.rand(*shape) -async def execute_write_task(task: Task): - from zarr import config +async def execute_write_task(task: Task) -> icechunk.IcechunkStore: + """Execute task as a write task. - config.set({"async.concurrency": 10}) + This will read the time coordinade from `task` and write a "pancake" in that position, + using random data. Random data is generated using the task seed. - store = await mk_store("w", task) + Returns the Icechunk store after the write is done. + + As you can see Icechunk stores can be passed to remote workers, and returned from them. + The reason to return the store is that we'll need all the remote stores, when they are + done, to be able to do a single, global commit to Icechunk. + """ + + store = task.store group = zarr.group(store=store, overwrite=False) array = cast(zarr.Array, group["array"]) - print(f"Writing at t={task.time}") + dprint(f"Writing at t={task.time}") data = generate_task_array(task, array.shape[0:2]) array[:, :, task.time] = data - print(f"Writing at t={task.time} done") - if task.sleep != 0: - print(f"Sleeping for {task.sleep} secs") - time.sleep(task.sleep) - return store.change_set_bytes() + dprint(f"Writing at t={task.time} done") + return store + + +async def execute_read_task(task: Task) -> None: + """Execute task as a read task. + This will read the time coordinade from `task` and read a "pancake" in that position. + Then it will assert the data is valid by re-generating the random data from the passed seed. -async def execute_read_task(task: Task): - print(f"Reading t={task.time}") - store = await mk_store("r", task) + As you can see Icechunk stores can be passed to remote workers. + """ + + store = task.store group = zarr.group(store=store, overwrite=False) array = cast(zarr.Array, group["array"]) actual = array[:, :, task.time] expected = generate_task_array(task, array.shape[0:2]) np.testing.assert_array_equal(actual, expected) + dprint(f"t={task.time} verified") -def run_write_task(task: Task): +def run_write_task(task: Task) -> icechunk.IcechunkStore: + """Sync helper for write tasks""" return asyncio.run(execute_write_task(task)) -def run_read_task(task: Task): +def run_read_task(task: Task) -> None: + """Sync helper for read tasks""" return asyncio.run(execute_read_task(task)) -def storage_config(args): - prefix = f"seba-tests/icechunk/{args.name}" +def storage_config(args: argparse.Namespace) -> dict[str, Any]: + """Return the Icechunk store S3 configuration map""" + bucket = args.url.netloc + prefix = args.url.path[1:] return { - "bucket": "arraylake-test", + "bucket": bucket, "prefix": prefix, } -def store_config(args): +def store_config(args: argparse.Namespace) -> dict[str, Any]: + """Return the Icechunk store configuration. + + We lower the default to make sure we write chunks and not inline them. + """ return {"inline_chunk_threshold_bytes": 1} -async def create(args): +async def create(args: argparse.Namespace) -> None: + """Execute the create subcommand. + + Creates an Icechunk store, a root group and an array named "array" + with the shape passed as arguments. + + Commits the Icechunk repository when done. + """ store = await icechunk.IcechunkStore.open( storage=icechunk.StorageConfig.s3_from_env(**storage_config(args)), mode="w", @@ -122,11 +158,22 @@ async def create(args): dtype="f8", fill_value=float("nan"), ) - _first_snap = await store.commit("array created") + _first_snapshot = await store.commit("array created") print("Array initialized") -async def update(args): +async def update(args: argparse.Namespace) -> None: + """Execute the update subcommand. + + Uses Dask to write chunks to the Icechunk repository. Currently Icechunk cannot + use the Dask array API (see https://github.com/earth-mover/icechunk/issues/185) but we + can still use a lower level API to do the writes: + * We split the work into small `Task`s, one 'pancake' per task, at a given t coordinate. + * We use Dask's `map` to ship the `Task` to a worker + * The `Task` includes a copy of the Icechunk Store, so workers can do the writes + * When workers are done, they send their store back + * When all workers are done (Dask's `gather`), we take all Stores and do a distributed commit in Icechunk + """ storage_conf = storage_config(args) store_conf = store_config(args) @@ -137,20 +184,14 @@ async def update(args): ) group = zarr.group(store=store, overwrite=False) - array = group["array"] - print(f"Found an array of shape: {array.shape}") + array = cast(zarr.Array, group["array"]) + print(f"Found an array with shape: {array.shape}") tasks = [ Task( - storage_config=storage_conf, - store_config=store_conf, + store=store, time=time, seed=time, - sleep=max( - 0, - args.max_sleep - - ((args.max_sleep - args.min_sleep) / (args.sleep_tasks + 1) * time), - ), ) for time in range(args.t_from, args.t_to, 1) ] @@ -158,38 +199,45 @@ async def update(args): client = Client(n_workers=args.workers, threads_per_worker=1) map_result = client.map(run_write_task, tasks) - change_sets_bytes = client.gather(map_result) + worker_stores = client.gather(map_result) print("Starting distributed commit") # we can use the current store as the commit coordinator, because it doesn't have any pending changes, # all changes come from the tasks, Icechunk doesn't care about where the changes come from, the only # important thing is to not count changes twice - commit_res = await store.distributed_commit("distributed commit", change_sets_bytes) + commit_res = await store.distributed_commit("distributed commit", [ws.change_set_bytes() for ws in worker_stores]) assert commit_res print("Distributed commit done") -async def verify(args): +async def verify(args: argparse.Namespace) -> None: + """Execute the verify subcommand. + + Uses Dask to read and verify chunks from the Icechunk repository. Currently Icechunk cannot + use the Dask array API (see https://github.com/earth-mover/icechunk/issues/185) but we + can still use a lower level API to do the verification: + * We split the work into small `Task`s, one 'pancake' per task, at a given t coordinate. + * We use Dask's `map` to ship the `Task` to a worker + * The `Task` includes a copy of the Icechunk Store, so workers can do the Icechunk reads + """ storage_conf = storage_config(args) store_conf = store_config(args) store = await icechunk.IcechunkStore.open( storage=icechunk.StorageConfig.s3_from_env(**storage_conf), - mode="r+", + mode="r", config=icechunk.StoreConfig(**store_conf), ) group = zarr.group(store=store, overwrite=False) - array = group["array"] - print(f"Found an array of shape: {array.shape}") + array = cast(zarr.Array, group["array"]) + print(f"Found an array with shape: {array.shape}") tasks = [ Task( - storage_config=storage_conf, - store_config=store_conf, + store=store, time=time, seed=time, - sleep=0, ) for time in range(args.t_from, args.t_to, 1) ] @@ -201,17 +249,14 @@ async def verify(args): print("done, all good") -async def distributed_write(): - """Write to an array using uncoordinated writers, distributed via Dask. +async def main() -> None: + """Main entry point for the script. - We create a big array, and then we split into workers, each worker gets - an area, where it writes random data with a known seed. Each worker - returns the bytes for its ChangeSet, then the coordinator (main thread) - does a distributed commit. When done, we open the store again and verify - we can write everything we have written. + Parses arguments and delegates to a subcommand. """ global_parser = argparse.ArgumentParser(prog="dask_write") + global_parser.add_argument("--url", type=str, help="url for the repository: s3://bucket/optional-prefix/repository-name", required=True) subparsers = global_parser.add_subparsers(title="subcommands", required=True) create_parser = subparsers.add_parser("create", help="create repo and array") @@ -236,7 +281,6 @@ async def distributed_write(): help="size of chunks in the y dimension", default=112, ) - create_parser.add_argument("--name", type=str, help="repository name", required=True) create_parser.set_defaults(command="create") update_parser = subparsers.add_parser("update", help="add chunks to the array") @@ -255,29 +299,6 @@ async def distributed_write(): update_parser.add_argument( "--workers", type=int, help="number of workers to use", required=True ) - update_parser.add_argument("--name", type=str, help="repository name", required=True) - update_parser.add_argument( - "--max-sleep", - type=float, - help="initial tasks sleep by these many seconds", - default=0, - ) - update_parser.add_argument( - "--min-sleep", - type=float, - help="last task that sleeps does it by these many seconds, a ramp from --max-sleep", - default=0, - ) - update_parser.add_argument( - "--sleep-tasks", type=int, help="this many tasks sleep", default=0 - ) - update_parser.add_argument( - "--distributed-cluster", - type=bool, - help="use multiple machines", - action=argparse.BooleanOptionalAction, - default=False, - ) update_parser.set_defaults(command="update") verify_parser = subparsers.add_parser("verify", help="verify array chunks") @@ -296,17 +317,15 @@ async def distributed_write(): verify_parser.add_argument( "--workers", type=int, help="number of workers to use", required=True ) - verify_parser.add_argument("--name", type=str, help="repository name", required=True) - verify_parser.add_argument( - "--distributed-cluster", - type=bool, - help="use multiple machines", - action=argparse.BooleanOptionalAction, - default=False, - ) verify_parser.set_defaults(command="verify") args = global_parser.parse_args() + url = urlparse(args.url, "s3") + if url.scheme != "s3" or url.netloc == '' or url.path == '' or url.params != '' or url.query != '' or url.fragment != '': + raise ValueError(f"Invalid url {args.url}") + + args.url = url + match args.command: case "create": await create(args) @@ -317,4 +336,4 @@ async def distributed_write(): if __name__ == "__main__": - asyncio.run(distributed_write()) + asyncio.run(main()) diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index 7d96b77f..d857db76 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -178,15 +178,18 @@ def __eq__(self, value: object) -> bool: return self._store == value._store def __getstate__(self) -> object: - store_repr = self._store.as_bytes() - return {"store": store_repr, "mode": self.mode} + # we serialize the Rust store as bytes + d = self.__dict__.copy() + d["_store"] = self._store.as_bytes() + return d def __setstate__(self, state: Any) -> None: - store_repr = state["store"] - mode = state["mode"] - is_read_only = mode == "r" - self._store = pyicechunk_store_from_bytes(store_repr, is_read_only) - self._is_open = True + # we have to deserialize the bytes of the Rust store + mode = state["_mode"] + is_read_only = mode.readonly + store_repr = state["_store"] + state["_store"] = pyicechunk_store_from_bytes(store_repr, is_read_only) + self.__dict__ = state @property def snapshot_id(self) -> str: From bdb1c0471b2537843bd649532f4a7c8304d90836 Mon Sep 17 00:00:00 2001 From: Aimee Barciauskas Date: Sat, 12 Oct 2024 10:22:10 -0700 Subject: [PATCH 079/167] Minor changes to icechunk spec --- spec/icechunk-spec.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/icechunk-spec.md b/spec/icechunk-spec.md index 9ba057ae..30fb7958 100644 --- a/spec/icechunk-spec.md +++ b/spec/icechunk-spec.md @@ -191,7 +191,7 @@ The process of creating and updating branches is designed to use the limited con When a client checks out a branch, it obtains a specific snapshot ID and uses this snapshot as the basis for any changes it creates during its session. The client creates a new snapshot and then updates the branch reference to point to the new snapshot (a "commit"). However, when updating the branch reference, the client must detect whether a _different session_ has updated the branch reference in the interim, possibly retrying or failing the commit if so. -This is an "optimistic concurrency" strategy; the resolution mechanism can be expensive, and conflicts are expected to be infrequent. +This is an "optimistic concurrency" strategy; the resolution mechanism can be expensive, but conflicts are expected to be infrequent. The simplest way to do this would be to store the branch reference in a specific file (e.g. `main.json`) and update it via an atomic "compare and swap" operation. Unfortunately not all popular object stores support this operation (AWS S3 notably does not). @@ -209,12 +209,12 @@ When a client checks out a branch, it keeps track of its current sequence number When it tries to commit, it attempts to create the file corresponding to sequence number _N + 1_ in an atomic "create if not exists" operation. If this succeeds, the commit is successful. If this fails (because another client created that file already), the commit fails. -At this point, the client may choose retry its commit (possibly re-reading the updated data) and then create sequence number _N + 2_. +At this point, the client may choose to retry its commit (possibly re-reading the updated data) and then create sequence number _N + 2_. Branch references are stored in the `r/` directory within a subdirectory corresponding to the branch name: `r/$BRANCH_NAME/`. Branch names may not contain the `/` character. -To facilitate easy lookups of the latest branch reference, we use the following encoding for the sequence number. +To facilitate easy lookups of the latest branch reference, we use the following encoding for the sequence number: - subtract the sequence number from the integer `1099511627775` - encode the resulting integer as a string using [Base 32 Crockford](https://www.crockford.com/base32.html) - left-padding the string with 0s to a length of 8 characters @@ -1084,11 +1084,11 @@ A tag can be created from any snapshot. ### Comparison with Iceberg Like Iceberg, Icechunk uses a series of linked metadata files to describe the state of the repository. -But while Iceberg describes a table, the Icechunk repository is a Zarr store (hierarchical structure of Arrays and Groups.) +But while Iceberg describes a table, an Icechunk repository is a Zarr store (hierarchical structure of Arrays and Groups). | Iceberg Entity | Icechunk Entity | Comment | |--|--|--| | Table | Repository | The fundamental entity described by the spec | | Column | Array | The logical container for a homogenous collection of values | | Snapshot | Snapshot | A single committed snapshot of the repository | -| Catalog | N/A | There is no concept of a catalog in Icechunk. Consistency provided by object store. | +| Catalog | N/A | There is no concept of a catalog in Icechunk. Consistency is provided by the object store. | From b0e88e1bc31fb7f99ddc36342f2c093fa2e39aeb Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Sat, 12 Oct 2024 17:47:35 -0300 Subject: [PATCH 080/167] Don't swallow exceptions on `get` We were turning all exceptions into `None`, this was hiding potential problems like missing chunks, invalid virtual chunk resolver configuration, etc. Everything was being turned into fill values as if it was for an uninitialized chunk. The new behavior is more correct, we only return `None` if there is no chunk reference (or metadata reference), but any other errors raise an exception. So now, if you virtual chunk resolver configuration is wrong, you'll get an error, instead of a dataset full of fill values. Fixes: #191 --- icechunk-python/python/icechunk/__init__.py | 10 ++++- icechunk-python/src/errors.rs | 4 ++ icechunk-python/src/lib.rs | 46 ++++++++++++++------- icechunk-python/tests/test_virtual_ref.py | 17 +++++++- 4 files changed, 59 insertions(+), 18 deletions(-) diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index d857db76..44647c93 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -8,6 +8,7 @@ from zarr.core.sync import SyncMixin from ._icechunk_python import ( + KeyNotFound, PyIcechunkStore, S3Credentials, SnapshotMetadata, @@ -32,6 +33,9 @@ ] +def is_known_key(key: str) -> bool: + return key.endswith(".zgroup") or key.endswith(".zarray") or key.endswith(".zattrs") or key.endswith(".zmetadata") + class IcechunkStore(Store, SyncMixin): _store: PyIcechunkStore @@ -292,9 +296,13 @@ async def get( ------- Buffer """ + + if is_known_key(key): + return None + try: result = await self._store.get(key, byte_range) - except ValueError as _e: + except KeyNotFound as _e: # Zarr python expects None to be returned if the key does not exist # but an IcechunkStore returns an error if the key does not exist return None diff --git a/icechunk-python/src/errors.rs b/icechunk-python/src/errors.rs index 9458fa48..d7e89d3a 100644 --- a/icechunk-python/src/errors.rs +++ b/icechunk-python/src/errors.rs @@ -4,6 +4,8 @@ use icechunk::{ use pyo3::{exceptions::PyValueError, PyErr}; use thiserror::Error; +use crate::KeyNotFound; + /// A simple wrapper around the StoreError to make it easier to convert to a PyErr /// /// When you use the ? operator, the error is coerced. But if you return the value it is not. @@ -12,6 +14,8 @@ use thiserror::Error; #[allow(clippy::enum_variant_names)] #[derive(Debug, Error)] pub(crate) enum PyIcechunkStoreError { + #[error("key not found error: {0}")] + KeyNotFound(#[from] KeyNotFound), #[error("store error: {0}")] StoreError(#[from] StoreError), #[error("repository Error: {0}")] diff --git a/icechunk-python/src/lib.rs b/icechunk-python/src/lib.rs index a1b13bb0..5bbe31a4 100644 --- a/icechunk-python/src/lib.rs +++ b/icechunk-python/src/lib.rs @@ -15,12 +15,16 @@ use icechunk::{ repository::VirtualChunkLocation, storage::virtual_ref::ObjectStoreVirtualChunkResolverConfig, zarr::{ - ConsolidatedStore, ObjectId, RepositoryConfig, StorageConfig, StoreOptions, - VersionInfo, + ConsolidatedStore, ObjectId, RepositoryConfig, StorageConfig, StoreError, + StoreOptions, VersionInfo, }, Repository, SnapshotMetadata, }; -use pyo3::{exceptions::PyValueError, prelude::*, types::PyBytes}; +use pyo3::{ + exceptions::{PyException, PyValueError}, + prelude::*, + types::PyBytes, +}; use storage::{PyS3Credentials, PyStorageConfig, PyVirtualRefConfig}; use streams::PyAsyncGenerator; use tokio::sync::{Mutex, RwLock}; @@ -111,6 +115,13 @@ impl From for PySnapshotMetadata { type KeyRanges = Vec<(String, (Option, Option))>; +pyo3::create_exception!( + _icechunk_python, + KeyNotFound, + PyException, + "The key is not present in the repository" +); + impl PyIcechunkStore { pub(crate) fn consolidated(&self) -> &ConsolidatedStore { &self.consolidated @@ -496,17 +507,20 @@ impl PyIcechunkStore { let store = Arc::clone(&self.store); pyo3_asyncio_0_21::tokio::future_into_py(py, async move { let byte_range = byte_range.unwrap_or((None, None)).into(); - let data = store - .read() - .await - .get(&key, &byte_range) - .await - .map_err(PyIcechunkStoreError::from)?; - let pybytes = Python::with_gil(|py| { - let bound_bytes = PyBytes::new_bound(py, &data); - bound_bytes.to_object(py) - }); - Ok(pybytes) + let data = store.read().await.get(&key, &byte_range).await; + // We need to distinguish the "safe" case of trying to fetch an uninitialized key + // from other types of errors, we use KeyNotFound exception for that + match data { + Ok(data) => { + let pybytes = Python::with_gil(|py| { + let bound_bytes = PyBytes::new_bound(py, &data); + bound_bytes.to_object(py) + }); + Ok(pybytes) + } + Err(StoreError::NotFound(_)) => Err(KeyNotFound::new_err(key)), + Err(err) => Err(PyIcechunkStoreError::StoreError(err).into()), + } }) } @@ -524,6 +538,7 @@ impl PyIcechunkStore { .await .map_err(PyIcechunkStoreError::StoreError)?; + // FIXME: this processing is hiding errors in certain keys let result = partial_values_stream .into_iter() // If we want to error instead of returning None we can collect into @@ -746,8 +761,9 @@ impl PyIcechunkStore { /// The icechunk Python module implemented in Rust. #[pymodule] -fn _icechunk_python(m: &Bound<'_, PyModule>) -> PyResult<()> { +fn _icechunk_python(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add("__version__", env!("CARGO_PKG_VERSION"))?; + m.add("KeyNotFound", py.get_type_bound::())?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/icechunk-python/tests/test_virtual_ref.py b/icechunk-python/tests/test_virtual_ref.py index 87c4696e..0f7a8254 100644 --- a/icechunk-python/tests/test_virtual_ref.py +++ b/icechunk-python/tests/test_virtual_ref.py @@ -1,4 +1,5 @@ import numpy as np +import pytest import zarr import zarr.core import zarr.core.buffer @@ -31,7 +32,7 @@ def write_chunks_to_minio(chunks: list[tuple[str, bytes]]): store.put(key, data) -async def test_write_minino_virtual_refs(): +async def test_write_minio_virtual_refs(): write_chunks_to_minio( [ ("path/to/python/chunk-1", b"first"), @@ -56,7 +57,7 @@ async def test_write_minino_virtual_refs(): ), ) - array = zarr.Array.create(store, shape=(1, 1, 2), chunk_shape=(1, 1, 1), dtype="i4") + array = zarr.Array.create(store, shape=(1, 1, 3), chunk_shape=(1, 1, 1), dtype="i4") await store.set_virtual_ref( "c/0/0/0", "s3://testbucket/path/to/python/chunk-1", offset=0, length=4 @@ -64,6 +65,10 @@ async def test_write_minino_virtual_refs(): await store.set_virtual_ref( "c/0/0/1", "s3://testbucket/path/to/python/chunk-2", offset=1, length=4 ) + # we write a ref that simulates a lost chunk + await store.set_virtual_ref( + "c/0/0/2", "s3://testbucket/path/to/python/non-existing", offset=1, length=4 + ) buffer_prototype = zarr.core.buffer.default_buffer_prototype() @@ -78,6 +83,14 @@ async def test_write_minino_virtual_refs(): assert array[0, 0, 0] == 1936877926 assert array[0, 0, 1] == 1852793701 + # fetch uninitialized chunk should be None + assert await store.get("c/0/0/3", prototype=buffer_prototype) is None + + # fetching a virtual ref that disappeared should be an exception + with pytest.raises(ValueError): + # TODO: we should include the key and other info in the exception + await store.get("c/0/0/2", prototype=buffer_prototype) + _snapshot_id = await store.commit("Add virtual refs") From e69d03255b14a7ba0eccb126ac44938be7018cea Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Sat, 12 Oct 2024 18:03:23 -0300 Subject: [PATCH 081/167] make mypy happy --- icechunk-python/python/icechunk/_icechunk_python.pyi | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index 4608fc98..239a0498 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -217,6 +217,12 @@ class VirtualRefConfig: """ ... +class KeyNotFound(Exception): + def __init__( + self, + info: Any + ): ... + class StoreConfig: # The number of concurrent requests to make when fetching partial values get_partial_values_concurrency: int | None From d90d1924900e032e6767570ed53d38a322394bed Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Sat, 12 Oct 2024 18:55:04 -0300 Subject: [PATCH 082/167] Parse zarr v2 keeys on the rust side --- icechunk-python/python/icechunk/__init__.py | 6 -- icechunk/src/zarr.rs | 62 +++++++++++++++++++-- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index 44647c93..b49a89d5 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -33,9 +33,6 @@ ] -def is_known_key(key: str) -> bool: - return key.endswith(".zgroup") or key.endswith(".zarray") or key.endswith(".zattrs") or key.endswith(".zmetadata") - class IcechunkStore(Store, SyncMixin): _store: PyIcechunkStore @@ -297,9 +294,6 @@ async def get( Buffer """ - if is_known_key(key): - return None - try: result = await self._store.get(key, byte_range) except KeyNotFound as _e: diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index d96818f4..5b639bca 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -254,6 +254,8 @@ pub enum KeyNotFoundError { ChunkNotFound { key: String, path: Path, coords: ChunkIndices }, #[error("node not found at `{path}`")] NodeNotFound { path: Path }, + #[error("v2 key not found at `{key}`")] + ZarrV2KeyNotFound { key: String }, } #[derive(Debug, Error)] @@ -622,6 +624,9 @@ impl Store { } Ok(()) } + Key::ZarrV2(_) => Err(StoreError::Unimplemented( + "Icechunk cannot set Zarr V2 metadata keys", + )), } } @@ -645,10 +650,6 @@ impl Store { } match Key::parse(key)? { - Key::Metadata { .. } => Err(StoreError::NotAllowed(format!( - "use .set to modify metadata for key {}", - key - ))), Key::Chunk { node_path, coords } => { self.repository .write() @@ -661,6 +662,9 @@ impl Store { .await?; Ok(()) } + Key::Metadata { .. } | Key::ZarrV2(_) => Err(StoreError::NotAllowed( + format!("use .set to modify metadata for key {}", key), + )), } } @@ -692,6 +696,7 @@ impl Store { let repository = guard.deref_mut(); Ok(repository.set_chunk_ref(node_path, coords, None).await?) } + Key::ZarrV2(_) => Ok(()), } } @@ -956,6 +961,9 @@ async fn get_key( Key::Chunk { node_path, coords } => { get_chunk_bytes(key, node_path, coords, byte_range, repo).await } + Key::ZarrV2(key) => { + Err(StoreError::NotFound(KeyNotFoundError::ZarrV2KeyNotFound { key })) + } }?; Ok(bytes) @@ -973,6 +981,7 @@ async fn exists(key: &str, repo: &Repository) -> StoreResult { enum Key { Metadata { node_path: Path }, Chunk { node_path: Path, coords: ChunkIndices }, + ZarrV2(String), } impl Key { @@ -982,6 +991,18 @@ impl Key { fn parse(key: &str) -> Result { fn parse_chunk(key: &str) -> Result { + if key == ".zgroup" + || key == ".zarray" + || key == ".zattrs" + || key == ".zmetadata" + || key.ends_with("/.zgroup") + || key.ends_with("/.zarray") + || key.ends_with("/.zattrs") + || key.ends_with("/.zmetadata") + { + return Ok(Key::ZarrV2(key.to_string())); + } + if key == "c" { return Ok(Key::Chunk { node_path: Path::root(), @@ -1051,6 +1072,7 @@ impl Display for Key { .join("/"); f.write_str(s.as_str()) } + Key::ZarrV2(key) => f.write_str(key.as_str()), } } } @@ -1425,6 +1447,38 @@ mod tests { Key::parse("c/0/0"), Ok(Key::Chunk { node_path, coords}) if node_path.to_string() == "/" && coords == ChunkIndices(vec![0,0]) )); + assert!(matches!( + Key::parse(".zarray"), + Ok(Key::ZarrV2(s) ) if s == ".zarray" + )); + assert!(matches!( + Key::parse(".zgroup"), + Ok(Key::ZarrV2(s) ) if s == ".zgroup" + )); + assert!(matches!( + Key::parse(".zattrs"), + Ok(Key::ZarrV2(s) ) if s == ".zattrs" + )); + assert!(matches!( + Key::parse(".zmetadata"), + Ok(Key::ZarrV2(s) ) if s == ".zmetadata" + )); + assert!(matches!( + Key::parse("foo/.zgroup"), + Ok(Key::ZarrV2(s) ) if s == "foo/.zgroup" + )); + assert!(matches!( + Key::parse("foo/bar/.zarray"), + Ok(Key::ZarrV2(s) ) if s == "foo/bar/.zarray" + )); + assert!(matches!( + Key::parse("foo/.zmetadata"), + Ok(Key::ZarrV2(s) ) if s == "foo/.zmetadata" + )); + assert!(matches!( + Key::parse("foo/.zattrs"), + Ok(Key::ZarrV2(s) ) if s == "foo/.zattrs" + )); } #[test] From 7c6a5926d04f5413f1719db876f8ef9c95e8b322 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Sat, 12 Oct 2024 23:12:42 -0300 Subject: [PATCH 083/167] Manifests shrink when there are fewer chunk references In the previous algorithm we were copying all chunks from the previous version to the new manifest (modulo changes in the current session). Now, we only copy the chunks we need to copy. For example, after a `clear` operation, there will be 0 references in the manifest. Most of the commit is moving code around to make it accessible from `distributed_flush`, that is, moving it from the `Repository` impl to free functions. The only important change happens in `distributed_flush`, where instead of starting from all the chunks in the previous manifest, we use the `all_chunks` iterator. A new test verifies manifests shrink when we delete chunks or whole arrays. Implements: #174 --- icechunk/src/format/manifest.rs | 19 +- icechunk/src/format/snapshot.rs | 7 +- icechunk/src/repository.rs | 569 +++++++++++++++++--------------- 3 files changed, 322 insertions(+), 273 deletions(-) diff --git a/icechunk/src/format/manifest.rs b/icechunk/src/format/manifest.rs index 95d8856c..7d76be79 100644 --- a/icechunk/src/format/manifest.rs +++ b/icechunk/src/format/manifest.rs @@ -1,3 +1,4 @@ +use futures::{pin_mut, Stream, TryStreamExt}; use itertools::Itertools; use std::{collections::BTreeMap, ops::Bound, sync::Arc}; use thiserror::Error; @@ -136,13 +137,29 @@ impl Manifest { } } + pub async fn from_stream( + chunks: impl Stream>, + ) -> Result { + let mut chunk_map = BTreeMap::new(); + pin_mut!(chunks); + while let Some(chunk) = chunks.try_next().await? { + chunk_map.insert((chunk.node, chunk.coord), chunk.payload); + } + Ok(Self::new(chunk_map)) + } + pub fn chunks(&self) -> &BTreeMap<(NodeId, ChunkIndices), ChunkPayload> { &self.chunks } - pub fn size(&self) -> usize { + pub fn len(&self) -> usize { self.chunks.len() } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } } impl FromIterator for Manifest { diff --git a/icechunk/src/format/snapshot.rs b/icechunk/src/format/snapshot.rs index bd21dbd9..8fd4f009 100644 --- a/icechunk/src/format/snapshot.rs +++ b/icechunk/src/format/snapshot.rs @@ -208,9 +208,14 @@ impl Snapshot { .map(move |ix| self.short_term_history[ix].clone()) } - pub fn size(&self) -> usize { + pub fn len(&self) -> usize { self.nodes.len() } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } } // We need this complex dance because Rust makes it really hard to put together an object and a diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index 6e16d171..54e30bc6 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeMap, HashMap, HashSet}, + collections::HashSet, iter::{self}, pin::Pin, sync::Arc, @@ -32,7 +32,6 @@ use chrono::Utc; use futures::{future::ready, Future, FutureExt, Stream, StreamExt, TryStreamExt}; use itertools::Either; use thiserror::Error; -use tokio::task; use crate::{ format::{ @@ -444,30 +443,8 @@ impl Repository { Ok(new) } - // FIXME: add moves - pub async fn get_node(&self, path: &Path) -> RepositoryResult { - // We need to look for nodes in self.change_set and the snapshot file - if self.change_set.is_deleted(path) { - return Err(RepositoryError::NodeNotFound { - path: path.clone(), - message: "getting node".to_string(), - }); - } - match self.change_set.get_new_node(path) { - Some(node) => Ok(node), - None => { - let node = self.get_existing_node(path).await?; - if self.change_set.is_deleted(&node.path) { - Err(RepositoryError::NodeNotFound { - path: path.clone(), - message: "getting node".to_string(), - }) - } else { - Ok(node) - } - } - } + get_node(self.storage.as_ref(), &self.change_set, self.snapshot_id(), path).await } pub async fn get_array(&self, path: &Path) -> RepositoryResult { @@ -492,45 +469,6 @@ impl Repository { } } - async fn get_existing_node(&self, path: &Path) -> RepositoryResult { - // An existing node is one that is present in a Snapshot file on storage - let snapshot_id = &self.snapshot_id; - let snapshot = self.storage.fetch_snapshot(snapshot_id).await?; - - let node = snapshot.get_node(path).map_err(|err| match err { - // A missing node here is not really a format error, so we need to - // generate the correct error for repositories - IcechunkFormatError::NodeNotFound { path } => RepositoryError::NodeNotFound { - path, - message: "existing node not found".to_string(), - }, - err => RepositoryError::FormatError(err), - })?; - let session_atts = self - .change_set - .get_user_attributes(node.id) - .cloned() - .map(|a| a.map(UserAttributesSnapshot::Inline)); - let res = NodeSnapshot { - user_attributes: session_atts.unwrap_or_else(|| node.user_attributes.clone()), - ..node.clone() - }; - if let Some(session_meta) = - self.change_set.get_updated_zarr_metadata(node.id).cloned() - { - if let NodeData::Array(_, manifests) = res.node_data { - Ok(NodeSnapshot { - node_data: NodeData::Array(session_meta, manifests), - ..res - }) - } else { - Ok(res) - } - } else { - Ok(res) - } - } - pub async fn get_chunk_ref( &self, path: &Path, @@ -697,111 +635,6 @@ impl Repository { Ok(None) } - /// Warning: The presence of a single error may mean multiple missing items - async fn updated_chunk_iterator( - &self, - ) -> RepositoryResult> + '_> - { - let snapshot = self.storage.fetch_snapshot(&self.snapshot_id).await?; - let nodes = futures::stream::iter(snapshot.iter_arc()); - let res = nodes.then(move |node| async move { - let path = node.path.clone(); - self.node_chunk_iterator(&node.path) - .await - .map_ok(move |ci| (path.clone(), ci)) - }); - Ok(res.flatten()) - } - - /// Warning: The presence of a single error may mean multiple missing items - async fn node_chunk_iterator( - &self, - path: &Path, - ) -> impl Stream> + '_ { - match self.get_node(path).await { - Ok(node) => futures::future::Either::Left( - self.verified_node_chunk_iterator(node).await, - ), - Err(_) => futures::future::Either::Right(futures::stream::empty()), - } - } - - /// Warning: The presence of a single error may mean multiple missing items - async fn verified_node_chunk_iterator( - &self, - node: NodeSnapshot, - ) -> impl Stream> + '_ { - match node.node_data { - NodeData::Group => futures::future::Either::Left(futures::stream::empty()), - NodeData::Array(_, manifests) => { - let new_chunk_indices: Box> = Box::new( - self.change_set - .array_chunks_iterator(node.id, &node.path) - .map(|(idx, _)| idx) - .collect(), - ); - - let new_chunks = self - .change_set - .array_chunks_iterator(node.id, &node.path) - .filter_map(move |(idx, payload)| { - payload.as_ref().map(|payload| { - Ok(ChunkInfo { - node: node.id, - coord: idx.clone(), - payload: payload.clone(), - }) - }) - }); - - futures::future::Either::Right( - futures::stream::iter(new_chunks).chain( - futures::stream::iter(manifests) - .then(move |manifest_ref| { - let new_chunk_indices = new_chunk_indices.clone(); - async move { - let manifest = self - .storage - .fetch_manifests(&manifest_ref.object_id) - .await; - match manifest { - Ok(manifest) => { - let old_chunks = manifest - .iter(&node.id) - .filter(move |(coord, _)| { - !new_chunk_indices.contains(coord) - }) - .map(move |(coord, payload)| ChunkInfo { - node: node.id, - coord, - payload, - }); - - let old_chunks = - self.change_set.update_existing_chunks( - node.id, old_chunks, - ); - futures::future::Either::Left( - futures::stream::iter(old_chunks.map(Ok)), - ) - } - // if we cannot even fetch the manifest, we generate a - // single error value. - Err(err) => futures::future::Either::Right( - futures::stream::once(ready(Err( - RepositoryError::StorageError(err), - ))), - ), - } - } - }) - .flatten(), - ), - ) - } - } - } - pub async fn list_nodes( &self, ) -> RepositoryResult + '_> { @@ -818,10 +651,7 @@ impl Repository { &self, ) -> RepositoryResult> + '_> { - let existing_array_chunks = self.updated_chunk_iterator().await?; - let new_array_chunks = - futures::stream::iter(self.change_set.new_arrays_chunk_iterator().map(Ok)); - Ok(existing_array_chunks.chain(new_array_chunks)) + all_chunks(self.storage.as_ref(), &self.change_set, self.snapshot_id()).await } pub async fn distributed_flush>( @@ -834,8 +664,8 @@ impl Repository { let change_sets = iter::once(self.change_set.clone()).chain(other_change_sets); let new_snapshot_id = distributed_flush( self.storage.as_ref(), - self.snapshot_id(), change_sets, + self.snapshot_id(), message, properties, ) @@ -999,26 +829,6 @@ fn new_inline_chunk(data: Bytes) -> ChunkPayload { ChunkPayload::Inline(data) } -fn update_manifest( - original_chunks: &mut BTreeMap<(NodeId, ChunkIndices), ChunkPayload>, - set_chunks: &HashMap>>, -) { - for (node_id, chunks) in set_chunks.iter() { - for (coord, maybe_payload) in chunks.iter() { - match maybe_payload { - Some(payload) => { - // a chunk was updated or inserted - original_chunks.insert((*node_id, coord.clone()), payload.clone()); - } - None => { - // a chunk was deleted - original_chunks.remove(&(*node_id, coord.clone())); - } - } - } - } -} - pub async fn get_chunk( reader: Option> + Send>>>, ) -> RepositoryResult> { @@ -1064,10 +874,79 @@ async fn updated_nodes<'a>( .chain(change_set.new_nodes_iterator(manifest_id))) } +async fn get_node<'a>( + storage: &(dyn Storage + Send + Sync), + change_set: &'a ChangeSet, + snapshot_id: &SnapshotId, + path: &Path, +) -> RepositoryResult { + // We need to look for nodes in self.change_set and the snapshot file + if change_set.is_deleted(path) { + return Err(RepositoryError::NodeNotFound { + path: path.clone(), + message: "getting node".to_string(), + }); + } + match change_set.get_new_node(path) { + Some(node) => Ok(node), + None => { + let node = get_existing_node(storage, change_set, snapshot_id, path).await?; + if change_set.is_deleted(&node.path) { + Err(RepositoryError::NodeNotFound { + path: path.clone(), + message: "getting node".to_string(), + }) + } else { + Ok(node) + } + } + } +} + +async fn get_existing_node<'a>( + storage: &(dyn Storage + Send + Sync), + change_set: &'a ChangeSet, + snapshot_id: &SnapshotId, + path: &Path, +) -> RepositoryResult { + // An existing node is one that is present in a Snapshot file on storage + let snapshot = storage.fetch_snapshot(snapshot_id).await?; + + let node = snapshot.get_node(path).map_err(|err| match err { + // A missing node here is not really a format error, so we need to + // generate the correct error for repositories + IcechunkFormatError::NodeNotFound { path } => RepositoryError::NodeNotFound { + path, + message: "existing node not found".to_string(), + }, + err => RepositoryError::FormatError(err), + })?; + let session_atts = change_set + .get_user_attributes(node.id) + .cloned() + .map(|a| a.map(UserAttributesSnapshot::Inline)); + let res = NodeSnapshot { + user_attributes: session_atts.unwrap_or_else(|| node.user_attributes.clone()), + ..node.clone() + }; + if let Some(session_meta) = change_set.get_updated_zarr_metadata(node.id).cloned() { + if let NodeData::Array(_, manifests) = res.node_data { + Ok(NodeSnapshot { + node_data: NodeData::Array(session_meta, manifests), + ..res + }) + } else { + Ok(res) + } + } else { + Ok(res) + } +} + async fn distributed_flush>( storage: &(dyn Storage + Send + Sync), - parent_id: &SnapshotId, change_sets: I, + parent_id: &SnapshotId, message: &str, properties: SnapshotProperties, ) -> RepositoryResult { @@ -1077,93 +956,155 @@ async fn distributed_flush>( return Err(RepositoryError::NoChangesToCommit); } - // We search for the current manifest. We are assumming a single one for now - let old_snapshot = storage.fetch_snapshot(parent_id).await?; - let old_snapshot_c = Arc::clone(&old_snapshot); - let manifest_id = old_snapshot_c.iter_arc().find_map(|node| { - match node.node_data { - NodeData::Array(_, man) => { - // TODO: can we avoid clone - man.first().map(|manifest| manifest.object_id.clone()) - } - NodeData::Group => None, - } - }); + let chunks = all_chunks(storage, &change_set, parent_id) + .await? + .map_ok(|(_path, chunk_info)| chunk_info); - let old_manifest = match manifest_id { - Some(ref manifest_id) => storage.fetch_manifests(manifest_id).await?, - // If there is no previous manifest we create an empty one - None => Arc::new(Manifest::default()), - }; + let new_manifest = Arc::new(Manifest::from_stream(chunks).await?); + let new_manifest_id = ObjectId::random(); + storage.write_manifests(new_manifest_id.clone(), Arc::clone(&new_manifest)).await?; - // The manifest update process is CPU intensive, so we want to executed it on a worker - // thread. Currently it's also destructive of the manifest, so we are also cloning the - // old manifest data - // - // The update process requires reference access to the set_chunks map, since we are running - // it on blocking task, it wants that reference to be 'static, which we cannot provide. - // As a solution, we temporarily `take` the map, replacing it an empty one, run the thread, - // and at the end we put the map back to where it was, in case there is some later failure. - // We always want to leave things in the previous state if there was a failure. - - let chunk_changes = Arc::new(change_set.take_chunks()); - let chunk_changes_c = Arc::clone(&chunk_changes); - - let update_task = task::spawn_blocking(move || { - //FIXME: avoid clone, this one is extremely expensive en memory - //it's currently needed because we don't want to destroy the manifest in case of later - //failure - let mut new_chunks = old_manifest.as_ref().chunks().clone(); - update_manifest(&mut new_chunks, &chunk_changes_c); - (new_chunks, chunk_changes) + let all_nodes = + updated_nodes(storage, &change_set, parent_id, &new_manifest_id).await?; + + let old_snapshot = storage.fetch_snapshot(parent_id).await?; + let mut new_snapshot = Snapshot::from_iter( + old_snapshot.as_ref(), + Some(properties), + vec![ManifestFileInfo { + id: new_manifest_id.clone(), + format_version: new_manifest.icechunk_manifest_format_version, + }], + vec![], + all_nodes, + ); + new_snapshot.metadata.message = message.to_string(); + new_snapshot.metadata.written_at = Utc::now(); + + let new_snapshot = Arc::new(new_snapshot); + let new_snapshot_id = &new_snapshot.metadata.id; + storage.write_snapshot(new_snapshot_id.clone(), Arc::clone(&new_snapshot)).await?; + + Ok(new_snapshot_id.clone()) +} + +/// Warning: The presence of a single error may mean multiple missing items +async fn updated_chunk_iterator<'a>( + storage: &'a (dyn Storage + Send + Sync), + change_set: &'a ChangeSet, + snapshot_id: &'a SnapshotId, +) -> RepositoryResult> + 'a> { + let snapshot = storage.fetch_snapshot(snapshot_id).await?; + let nodes = futures::stream::iter(snapshot.iter_arc()); + let res = nodes.then(move |node| async move { + let path = node.path.clone(); + node_chunk_iterator(storage, change_set, snapshot_id, &node.path) + .await + .map_ok(move |ci| (path.clone(), ci)) }); + Ok(res.flatten()) +} - match update_task.await { - Ok((new_chunks, chunk_changes)) => { - // reset the set_chunks map to it's previous value - #[allow(clippy::expect_used)] - { - // It's OK to call into_inner here because we created the Arc locally and never - // shared it with other code - let chunks = - Arc::into_inner(chunk_changes).expect("Bug in flush task join"); - change_set.set_chunks(chunks); - } +/// Warning: The presence of a single error may mean multiple missing items +async fn node_chunk_iterator<'a>( + storage: &'a (dyn Storage + Send + Sync), + change_set: &'a ChangeSet, + snapshot_id: &SnapshotId, + path: &Path, +) -> impl Stream> + 'a { + match get_node(storage, change_set, snapshot_id, path).await { + Ok(node) => futures::future::Either::Left( + verified_node_chunk_iterator(storage, change_set, node).await, + ), + Err(_) => futures::future::Either::Right(futures::stream::empty()), + } +} - let new_manifest = Arc::new(Manifest::new(new_chunks)); - let new_manifest_id = ObjectId::random(); - storage - .write_manifests(new_manifest_id.clone(), Arc::clone(&new_manifest)) - .await?; - - let all_nodes = - updated_nodes(storage, &change_set, parent_id, &new_manifest_id).await?; - - let mut new_snapshot = Snapshot::from_iter( - old_snapshot.as_ref(), - Some(properties), - vec![ManifestFileInfo { - id: new_manifest_id.clone(), - format_version: new_manifest.icechunk_manifest_format_version, - }], - vec![], - all_nodes, +/// Warning: The presence of a single error may mean multiple missing items +async fn verified_node_chunk_iterator<'a>( + storage: &'a (dyn Storage + Send + Sync), + change_set: &'a ChangeSet, + node: NodeSnapshot, +) -> impl Stream> + 'a { + match node.node_data { + NodeData::Group => futures::future::Either::Left(futures::stream::empty()), + NodeData::Array(_, manifests) => { + let new_chunk_indices: Box> = Box::new( + change_set + .array_chunks_iterator(node.id, &node.path) + .map(|(idx, _)| idx) + .collect(), ); - new_snapshot.metadata.message = message.to_string(); - new_snapshot.metadata.written_at = Utc::now(); - - let new_snapshot = Arc::new(new_snapshot); - let new_snapshot_id = &new_snapshot.metadata.id; - storage - .write_snapshot(new_snapshot_id.clone(), Arc::clone(&new_snapshot)) - .await?; - Ok(new_snapshot_id.clone()) + let new_chunks = change_set + .array_chunks_iterator(node.id, &node.path) + .filter_map(move |(idx, payload)| { + payload.as_ref().map(|payload| { + Ok(ChunkInfo { + node: node.id, + coord: idx.clone(), + payload: payload.clone(), + }) + }) + }); + + futures::future::Either::Right( + futures::stream::iter(new_chunks).chain( + futures::stream::iter(manifests) + .then(move |manifest_ref| { + let new_chunk_indices = new_chunk_indices.clone(); + async move { + let manifest = storage + .fetch_manifests(&manifest_ref.object_id) + .await; + match manifest { + Ok(manifest) => { + let old_chunks = manifest + .iter(&node.id) + .filter(move |(coord, _)| { + !new_chunk_indices.contains(coord) + }) + .map(move |(coord, payload)| ChunkInfo { + node: node.id, + coord, + payload, + }); + + let old_chunks = change_set + .update_existing_chunks(node.id, old_chunks); + futures::future::Either::Left( + futures::stream::iter(old_chunks.map(Ok)), + ) + } + // if we cannot even fetch the manifest, we generate a + // single error value. + Err(err) => futures::future::Either::Right( + futures::stream::once(ready(Err( + RepositoryError::StorageError(err), + ))), + ), + } + } + }) + .flatten(), + ), + ) } - Err(_) => Err(RepositoryError::OtherFlushError), } } +async fn all_chunks<'a>( + storage: &'a (dyn Storage + Send + Sync), + change_set: &'a ChangeSet, + snapshot_id: &'a SnapshotId, +) -> RepositoryResult> + 'a> { + let existing_array_chunks = + updated_chunk_iterator(storage, change_set, snapshot_id).await?; + let new_array_chunks = + futures::stream::iter(change_set.new_arrays_chunk_iterator().map(Ok)); + Ok(existing_array_chunks.chain(new_array_chunks)) +} + #[cfg(test)] #[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] mod tests { @@ -1874,6 +1815,92 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_manifests_shrink() -> Result<(), Box> { + let storage: Arc = + Arc::new(ObjectStorage::new_in_memory_store(Some("prefix".into()))); + let mut ds = Repository::init(Arc::clone(&storage), false).await?.build(); + ds.add_group(Path::root()).await?; + let zarr_meta = ZarrArrayMetadata { + shape: vec![5, 5], + data_type: DataType::Float16, + chunk_shape: ChunkShape(vec![NonZeroU64::new(2).unwrap()]), + chunk_key_encoding: ChunkKeyEncoding::Slash, + fill_value: FillValue::Float16(f32::NEG_INFINITY), + codecs: vec![Codec { name: "mycodec".to_string(), configuration: None }], + storage_transformers: Some(vec![StorageTransformer { + name: "mytransformer".to_string(), + configuration: None, + }]), + dimension_names: Some(vec![Some("t".to_string())]), + }; + + let a1path: Path = "/array1".try_into()?; + let a2path: Path = "/array2".try_into()?; + + ds.add_array(a1path.clone(), zarr_meta.clone()).await?; + ds.add_array(a2path.clone(), zarr_meta.clone()).await?; + + // add 3 chunks + ds.set_chunk_ref( + a1path.clone(), + ChunkIndices(vec![0, 0]), + Some(ChunkPayload::Inline("hello".into())), + ) + .await?; + ds.set_chunk_ref( + a1path.clone(), + ChunkIndices(vec![0, 1]), + Some(ChunkPayload::Inline("hello".into())), + ) + .await?; + ds.set_chunk_ref( + a2path.clone(), + ChunkIndices(vec![0, 1]), + Some(ChunkPayload::Inline("hello".into())), + ) + .await?; + + ds.commit("main", "commit", None).await?; + + let manifest_id = match ds.get_array(&a1path).await?.node_data { + NodeData::Array(_, manifests) => { + manifests.first().as_ref().unwrap().object_id.clone() + } + NodeData::Group => panic!("must be an array"), + }; + let manifest = storage.fetch_manifests(&manifest_id).await?; + let initial_size = manifest.len(); + + ds.delete_array(a2path).await?; + ds.commit("main", "array2 deleted", None).await?; + let manifest_id = match ds.get_array(&a1path).await?.node_data { + NodeData::Array(_, manifests) => { + manifests.first().as_ref().unwrap().object_id.clone() + } + NodeData::Group => panic!("must be an array"), + }; + let manifest = storage.fetch_manifests(&manifest_id).await?; + let size_after_delete = manifest.len(); + + assert!(size_after_delete < initial_size); + + // delete a chunk + ds.set_chunk_ref(a1path.clone(), ChunkIndices(vec![0, 0]), None).await?; + ds.commit("main", "chunk deleted", None).await?; + let manifest_id = match ds.get_array(&a1path).await?.node_data { + NodeData::Array(_, manifests) => { + manifests.first().as_ref().unwrap().object_id.clone() + } + NodeData::Group => panic!("must be an array"), + }; + let manifest = storage.fetch_manifests(&manifest_id).await?; + let size_after_chunk_delete = manifest.len(); + assert!(size_after_chunk_delete < size_after_delete); + + Ok(()) + } + #[tokio::test(flavor = "multi_thread")] async fn test_all_chunks_iterator() -> Result<(), Box> { let storage: Arc = From 52d284ba2933b61982093852ef069147b85bcbb0 Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Sun, 13 Oct 2024 01:37:10 -0300 Subject: [PATCH 084/167] Don't write empty manifests Closes: 173 --- ...F8JDC8KBX3GGKXQJ0 => 4QRKS3EWJ09A3640ZDFG} | Bin ...2SZFPAHPVNBTKM8Y0 => A5MVK53M25JDFF2GQZQ0} | Bin ...986RVXEZ5NNV94Y5G => JHGE2962KESE5R46V430} | Bin ...WEJAE6BYZQFFKNVK0 => TJEPHY7N0HVGYKRZHZZ0} | Bin .../test-repo/manifests/0WVX0EE119DG0NV7ZQ5G | Bin 0 -> 323 bytes .../test-repo/manifests/8WDWSTN2G5G66C3RP6T0 | Bin 215 -> 0 bytes .../test-repo/manifests/9QZWF97ZRE7653BGATC0 | Bin 233 -> 0 bytes .../test-repo/manifests/BA4J883ECE2DH6W60AZG | Bin 0 -> 308 bytes .../test-repo/manifests/F6PKC2QHTMEQD64RMSJG | Bin 0 -> 215 bytes .../test-repo/manifests/J0KFSTSCDYDXBPP4F31G | Bin 233 -> 0 bytes .../test-repo/manifests/JQ3WE20YA2C36SZXD7HG | Bin 4 -> 0 bytes .../test-repo/manifests/V9QDN7624X40SRTBV5C0 | Bin 248 -> 0 bytes .../test-repo/manifests/VPGN5MM8K8N34MA02C30 | Bin 0 -> 308 bytes .../test-repo/refs/branch:main/ZZZZZZZW.json | 2 +- .../test-repo/refs/branch:main/ZZZZZZZX.json | 2 +- .../test-repo/refs/branch:main/ZZZZZZZY.json | 2 +- .../test-repo/refs/branch:main/ZZZZZZZZ.json | 2 +- .../refs/branch:my-branch/ZZZZZZZX.json | 2 +- .../refs/branch:my-branch/ZZZZZZZY.json | 2 +- .../refs/branch:my-branch/ZZZZZZZZ.json | 2 +- .../refs/tag:it also works!/ref.json | 2 +- .../test-repo/refs/tag:it works!/ref.json | 2 +- .../test-repo/snapshots/3EMAFJFYV394722VTAPG | Bin 0 -> 603 bytes .../test-repo/snapshots/3KP6E7F3C2PE2HNGCNM0 | Bin 735 -> 0 bytes .../test-repo/snapshots/8AEWDWJRTMECASF516SG | Bin 806 -> 0 bytes .../test-repo/snapshots/AG1HZQ5SWS8DM8DNC670 | Bin 1563 -> 0 bytes .../test-repo/snapshots/ATFBNT6AY8J7ERFY8SD0 | Bin 0 -> 1563 bytes .../test-repo/snapshots/C1ZKMGE3ESPJ24YKN9MG | Bin 672 -> 0 bytes .../test-repo/snapshots/H01K0XJPGVW4HFX470AG | Bin 117 -> 0 bytes .../test-repo/snapshots/J0N9DYWKA5ECGPP056NG | Bin 0 -> 117 bytes .../test-repo/snapshots/JSQ148MYX6VKHPP4D0WG | Bin 0 -> 806 bytes .../test-repo/snapshots/MTH5CQPGNWZ516P7QVK0 | Bin 0 -> 735 bytes .../test-repo/snapshots/VNKSCC59M58V0MSJ01RG | Bin 874 -> 0 bytes .../test-repo/snapshots/ZW2AXQ9ZDPRS9V5331PG | Bin 0 -> 874 bytes icechunk/src/change_set.rs | 14 +- icechunk/src/repository.rs | 144 ++++++++++++++---- icechunk/src/storage/object_store.rs | 12 ++ 37 files changed, 148 insertions(+), 40 deletions(-) rename icechunk-python/tests/data/test-repo/chunks/{3X7F8JDC8KBX3GGKXQJ0 => 4QRKS3EWJ09A3640ZDFG} (100%) rename icechunk-python/tests/data/test-repo/chunks/{DZJ2SZFPAHPVNBTKM8Y0 => A5MVK53M25JDFF2GQZQ0} (100%) rename icechunk-python/tests/data/test-repo/chunks/{Q72986RVXEZ5NNV94Y5G => JHGE2962KESE5R46V430} (100%) rename icechunk-python/tests/data/test-repo/chunks/{TGKWEJAE6BYZQFFKNVK0 => TJEPHY7N0HVGYKRZHZZ0} (100%) create mode 100644 icechunk-python/tests/data/test-repo/manifests/0WVX0EE119DG0NV7ZQ5G delete mode 100644 icechunk-python/tests/data/test-repo/manifests/8WDWSTN2G5G66C3RP6T0 delete mode 100644 icechunk-python/tests/data/test-repo/manifests/9QZWF97ZRE7653BGATC0 create mode 100644 icechunk-python/tests/data/test-repo/manifests/BA4J883ECE2DH6W60AZG create mode 100644 icechunk-python/tests/data/test-repo/manifests/F6PKC2QHTMEQD64RMSJG delete mode 100644 icechunk-python/tests/data/test-repo/manifests/J0KFSTSCDYDXBPP4F31G delete mode 100644 icechunk-python/tests/data/test-repo/manifests/JQ3WE20YA2C36SZXD7HG delete mode 100644 icechunk-python/tests/data/test-repo/manifests/V9QDN7624X40SRTBV5C0 create mode 100644 icechunk-python/tests/data/test-repo/manifests/VPGN5MM8K8N34MA02C30 create mode 100644 icechunk-python/tests/data/test-repo/snapshots/3EMAFJFYV394722VTAPG delete mode 100644 icechunk-python/tests/data/test-repo/snapshots/3KP6E7F3C2PE2HNGCNM0 delete mode 100644 icechunk-python/tests/data/test-repo/snapshots/8AEWDWJRTMECASF516SG delete mode 100644 icechunk-python/tests/data/test-repo/snapshots/AG1HZQ5SWS8DM8DNC670 create mode 100644 icechunk-python/tests/data/test-repo/snapshots/ATFBNT6AY8J7ERFY8SD0 delete mode 100644 icechunk-python/tests/data/test-repo/snapshots/C1ZKMGE3ESPJ24YKN9MG delete mode 100644 icechunk-python/tests/data/test-repo/snapshots/H01K0XJPGVW4HFX470AG create mode 100644 icechunk-python/tests/data/test-repo/snapshots/J0N9DYWKA5ECGPP056NG create mode 100644 icechunk-python/tests/data/test-repo/snapshots/JSQ148MYX6VKHPP4D0WG create mode 100644 icechunk-python/tests/data/test-repo/snapshots/MTH5CQPGNWZ516P7QVK0 delete mode 100644 icechunk-python/tests/data/test-repo/snapshots/VNKSCC59M58V0MSJ01RG create mode 100644 icechunk-python/tests/data/test-repo/snapshots/ZW2AXQ9ZDPRS9V5331PG diff --git a/icechunk-python/tests/data/test-repo/chunks/3X7F8JDC8KBX3GGKXQJ0 b/icechunk-python/tests/data/test-repo/chunks/4QRKS3EWJ09A3640ZDFG similarity index 100% rename from icechunk-python/tests/data/test-repo/chunks/3X7F8JDC8KBX3GGKXQJ0 rename to icechunk-python/tests/data/test-repo/chunks/4QRKS3EWJ09A3640ZDFG diff --git a/icechunk-python/tests/data/test-repo/chunks/DZJ2SZFPAHPVNBTKM8Y0 b/icechunk-python/tests/data/test-repo/chunks/A5MVK53M25JDFF2GQZQ0 similarity index 100% rename from icechunk-python/tests/data/test-repo/chunks/DZJ2SZFPAHPVNBTKM8Y0 rename to icechunk-python/tests/data/test-repo/chunks/A5MVK53M25JDFF2GQZQ0 diff --git a/icechunk-python/tests/data/test-repo/chunks/Q72986RVXEZ5NNV94Y5G b/icechunk-python/tests/data/test-repo/chunks/JHGE2962KESE5R46V430 similarity index 100% rename from icechunk-python/tests/data/test-repo/chunks/Q72986RVXEZ5NNV94Y5G rename to icechunk-python/tests/data/test-repo/chunks/JHGE2962KESE5R46V430 diff --git a/icechunk-python/tests/data/test-repo/chunks/TGKWEJAE6BYZQFFKNVK0 b/icechunk-python/tests/data/test-repo/chunks/TJEPHY7N0HVGYKRZHZZ0 similarity index 100% rename from icechunk-python/tests/data/test-repo/chunks/TGKWEJAE6BYZQFFKNVK0 rename to icechunk-python/tests/data/test-repo/chunks/TJEPHY7N0HVGYKRZHZZ0 diff --git a/icechunk-python/tests/data/test-repo/manifests/0WVX0EE119DG0NV7ZQ5G b/icechunk-python/tests/data/test-repo/manifests/0WVX0EE119DG0NV7ZQ5G new file mode 100644 index 0000000000000000000000000000000000000000..ee6e3f5c5ff6f5795c2404bcd157c064ec395fcf GIT binary patch literal 323 zcmbQt(9k!Dc_KsOGS9r6%)Hbij3JYlCowQIE)UBrDk)9OncTR-F{wB|r?e#XrdqMF zmA-ySYH>+YX>xXIiGD$1Nrrw&zJ5VvNk)F2esV@>Ube0wLkh?U#>T}#scDn9gm}3I zcto1}8F+-bM|uZEc|=7SfK*ImM75KV0jj~v!`;=$(#*))HQ3cO$iytn#2Bn$5+lq= zlfWSFU}M*CF9S1(jC>J+(u#rqCMowf#<+31iSy27Vg6bTWi6};~pc=(85!omJ DUdwba literal 0 HcmV?d00001 diff --git a/icechunk-python/tests/data/test-repo/manifests/8WDWSTN2G5G66C3RP6T0 b/icechunk-python/tests/data/test-repo/manifests/8WDWSTN2G5G66C3RP6T0 deleted file mode 100644 index bf375ccdb7cfedd1b90057d1a893a68bd25345f4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 215 zcmbQt(9k)Fc@hIdkHBh1|_yj+|uyqzM9-QB$-0=*0vQa~ygp(;Y$y~AC- z99_+vBBKJ`+`Rq5ydf$WVP?2Qc^L&qxdk|S1cdoHg?Rf~L_$@-%m_3$va~P@3X5=! rGWGKdvowh`bqCtQGLfNinP*;3W?t$M#tkj=Tj}eUq!yPXl_qDWmgpBG zmSpIc?|RG^!iw_ljI z0ayhi15|}el$TL(lv{wKM?jdLQ;4^(MI=-O%#1*DBTEajps)znC{sVbFiVq2Q+J>l dEE5?TmwD#pWagzFVGIFs8Bw`Rs9a_wE&v8TQeprA diff --git a/icechunk-python/tests/data/test-repo/manifests/BA4J883ECE2DH6W60AZG b/icechunk-python/tests/data/test-repo/manifests/BA4J883ECE2DH6W60AZG new file mode 100644 index 0000000000000000000000000000000000000000..917634ddcd024beb8fd83bc81d137a2bbaf94cf2 GIT binary patch literal 308 zcmbQt(9k=Hc_KsOGS9r6%)Hbij3JYlCowQIE)UBrDk)9OncTR-F{wB|r?e#XrdqMF zmA-ySYH>+YX>xXIiGD$1Nrrw&zJ5VvNk)F2esV@>Ube0wLkh?U#>T}#scDn9gm}3I zcto1}8F+-bM|uZEc|=7SfK*ImM75KV0jj~v!`;=$(#*))HQ3cO$iytn#2Bn$5+lq= zlfWSFU}M*CF9S1(jC>J+(u#rqCMowf#<+31iSy27Vg6bTWi6};~pcn-Jw-#i>O?|_>O^tnxOuby(+>G1WQa~ygp(;YWTmw8J z&HW5K!rUXhgQ7g5q97_5VP<%FxVsuzni+Y!2D_RDnV5x{7(-RS%rFTI@(wn34fisz rbTl?IF^F<;a|hbOGLfNinP*;3W?t$M#tkj=Tj}eUq!yPXl_qDWmgpBG zmSpIc?|RG^!iw_ljI z0ayhi15|}el$TL(lv{wKM?jdLQ;4^(MI=-O%#1*DBTEajps)znC{sVbFiVq2Q+J>l dEE5?TmwD#pWagzFVGIFs8Bw`Rs9a_wE&v8TQeprA diff --git a/icechunk-python/tests/data/test-repo/manifests/JQ3WE20YA2C36SZXD7HG b/icechunk-python/tests/data/test-repo/manifests/JQ3WE20YA2C36SZXD7HG deleted file mode 100644 index 32b9aed5e8762e240adbe1d0ba89b73ac96785e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4 LcmbQt(9i$?1JD7K diff --git a/icechunk-python/tests/data/test-repo/manifests/V9QDN7624X40SRTBV5C0 b/icechunk-python/tests/data/test-repo/manifests/V9QDN7624X40SRTBV5C0 deleted file mode 100644 index ef487e872b00f4033992e3b1531c4c0aca8bdeef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 248 zcmbQt(9k)Fc@hIdkj=Tj}eUq!yPXl_qDWmgpBG zmSpIc?|RG^!iw_ljI z0ayhi15|}el$TL(lv{wKM?jdLQ;4^(MI=-O%#1*DBTEajps)znC{sVbFiVq2Q+J>l gEE5?TmwD#pWagzFVGIFs8Bw`Rs9a`LE(;PD00~`KEdT%j diff --git a/icechunk-python/tests/data/test-repo/manifests/VPGN5MM8K8N34MA02C30 b/icechunk-python/tests/data/test-repo/manifests/VPGN5MM8K8N34MA02C30 new file mode 100644 index 0000000000000000000000000000000000000000..917634ddcd024beb8fd83bc81d137a2bbaf94cf2 GIT binary patch literal 308 zcmbQt(9k=Hc_KsOGS9r6%)Hbij3JYlCowQIE)UBrDk)9OncTR-F{wB|r?e#XrdqMF zmA-ySYH>+YX>xXIiGD$1Nrrw&zJ5VvNk)F2esV@>Ube0wLkh?U#>T}#scDn9gm}3I zcto1}8F+-bM|uZEc|=7SfK*ImM75KV0jj~v!`;=$(#*))HQ3cO$iytn#2Bn$5+lq= zlfWSFU}M*CF9S1(jC>J+(u#rqCMowf#<+31iSy27Vg6bTWi6};~pcn-Jw-#X-hO@qu{Ob)4#<|SQmv6rLy)gsNV_APVFJG%#p?cz#DM*y`H&fep!$^5-nitSh8lL}rL^I)|4QZXK{m``D)OP1Lzt-;&Yc4)M=F zbj#8mH_EMA@ERnO(|g0zsRV*VBDyhj(sifWM)`hPFpM8WO@~6_P9Sb09Zw8IsR6x( hH;)lXPdAJh@@S|0PfR}%B1{#Z-4ceE%k6kS+Ap+x-S7Ya literal 0 HcmV?d00001 diff --git a/icechunk-python/tests/data/test-repo/snapshots/3KP6E7F3C2PE2HNGCNM0 b/icechunk-python/tests/data/test-repo/snapshots/3KP6E7F3C2PE2HNGCNM0 deleted file mode 100644 index 89a119915794dc3b346f2570d78fdf75d6d0795d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 735 zcmbu7O;5rw7{>=nV*C{Q0!r78ZFo{)Oe7El;>#|kLV?CI$XX6~5uzUeCI=HwB)sU! z#1N0hvwkTj%55HqO_Qhn=jreH9r_@32j1K`NuCr-IcUW!O-rCcUMm5q9}atW^Mqn& zvX+U=V*U`SXPI0)YrR1L)hz{VDM|^V9e*RVt;Tf_N4lZd&!o{39hnQaT^4PZ_>kgh z>G()LMesP4G*wO8Pl8~Gt^-3y0NSqw(rR)lnr=s?4iywLdPORCbCk(zrk+9xm^a}; z&PwF6!K@fiRn-77)qbi{#^eeXIB@8qQAcrLcW+10VQ3WXP16pe4(U!Tx7lvZqP6d$ z48zOQvf<{DmPxtZb}mC>?)!c2n$lv8MV<0z%4IA&ev7;S{Lo&0Y0(Q>J72uz!?l2L zW~ra#*wTGHF#f0gATp+oFb1Jt@p0O}ZLDnk1qC()5C8xG diff --git a/icechunk-python/tests/data/test-repo/snapshots/8AEWDWJRTMECASF516SG b/icechunk-python/tests/data/test-repo/snapshots/8AEWDWJRTMECASF516SG deleted file mode 100644 index 232a89dce2d57b65572da9c6c2630e4b29c31ca5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 806 zcmbu7PjA{V7{T10KrlH7Mk@1}WR$ayUT+?oMf+aFf4$DB9xLzS|F6 ze`Q^7o19sSg!9j9TiLFq>@#u4gi2LE8BwEt h_vGS=a=vGOo+|$*{Vca+zBFf6<6nwy+B+VJQK&7t@8=Wnw9$9Zc{d#FIB(YGUGP z6JGSP8x=hnvuFJTej9m>FDV-dv1#(m{QJ!R`OQ4jH=$OyH%YRoxREmy^+YWmWHKlY zK_b+NL|*kL`E<%q6kgVODbMOgk_FksSB~Y-K4A9&$Z_!D^o8&N&&oVP00Hx|jBQLY zV=<*l!+Fx5NHMLT7LwVVt|>7i&I7?%=OKukAPJKB-ouo6N?h8o?3E2nWdu!(Lxsy| z++jMQq;+-;3PMO?d1SsjBeue-rLM|UyH6#cJYA^#5MOG3a%bB0ExL)%Jt+`ExID`_*CeLuW zKZzy4p?S<31w&GGNlhz)80^IZ8On%J|v_y}f8po9do_3|R>hcBY- z(UD^l2e*a^x7MhSqvx{#{9CKhD&fUjQo{4jg$Lf0^!p<%G=B!PLcA;DO&jY>Q<%BI z!tKM-EqY4MAJ5l%{_Oy9;~JLhTFv$_HVeJiy}iBhnO(Cej20}J@m;{NmP!?!e zPeuB%*sGDRmw{Z#OL{^r6r~c+vW%Mg3TVLXG4vk8YBbnqxP4A|$Uy)EAkl(;QAZ{s zh%%^JqisDI`I%y#;b5*>5lY##s&Wasyv9S6Xa)cX^!F|z)Klz`wqXV~IW5f}=GD}3 zS?3u+74xMm9gZRj44`=)>hDgmX;O8A7$cue=42(KR7JtL0B-1#U@45a2vguvQ)l3 zzluZ#!^ug9Ah$O~RvjDG)Wn3CsDHKQFYo&Sx!W{MGhn|HDsM?Yh>fvD;2^pJA0N$k zI~x+tmH> W;HJWHY+1*NoTsijSzhRT3ML5BH_m! zi6JfxYkeupPj_fSoZRHjJvsNxm&_~Eb=-L-53dyc$mc1h&$7<*JYFal zOL@5rK`EI~!jg2B5I%tb!XZ%ziDD7J6R#jV3d^z(k)Z(8k1NzR38tpi;jn=z)^vQ2 z>Ry(pW^*Y8Duv4o2v@Qf(OhbKR#XzV|47J*{!x3uPHELVv54$KGtKFv|KVXM^ zZ<022Y@qHqWtnz+$~Trx9PIfw-|+Z`YpB+=jXQQkJj(A=g;|{uZDKORgwr^~vv8|` zb=vzTjj!Uy&1#nn_P2;TzoA){*6EF7%jUkg!Qk{(H#G`@8wiXGx<;D4X>zT6JuDc; zamUpTMWi=DMjdO$M8}lssK5B-OZ#)5oU}Au_t_7&%KxNy0wZkDh{JBcM`>?5+?n_X DBpLFf diff --git a/icechunk-python/tests/data/test-repo/snapshots/H01K0XJPGVW4HFX470AG b/icechunk-python/tests/data/test-repo/snapshots/H01K0XJPGVW4HFX470AG deleted file mode 100644 index 7fe668f86c644c135c192b514e50d0f7866f7056..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 117 zcmbQu&@f>F1H**LTbzRoz4E7lr7@6oA8t57th8P)J0g wo{71+frYuTfl<`9pwxo=;>?o#qDqC#yv&l!#GK5k)D)b$ERDF1H**LTLJf#T7{|jH($r6pFVJB-?>!9_l9q@nX;DaXnOsPVH8-HxnZzywZ65#<4wH7O z#LIT7Hbpy5J#SxXK+6pd)qHvEzn|lBtzB^@|qRL+C8osNCT=4g8T5c&j%voanj~V9A>%W8m8sje)ZUHn&*b(+x0yV z+0c=sOG@zTJnZ)v;k|eul8A7EDftj~vb=ubLRoKI`l@H2IF4KetvxKw01zP^1wUBG z#rGuU38rGZn?&z+zhFH86-5e$pYO`k!d-c^IE^TY!r|PCdr5y;zF8MIeX-qb8$Nv_ z?Qlab%-G8F@0*&iSu@@x?4B?Yi_xk@+yEYFKc8sjD{}L8Gs~vM9pSU@z;TQRqj_ar z(l6=3!Sb5+FqcqTD$Oq_X5G=!d`$j)d=P|5I=>q5kc}2$)FC)pP{KJ4#}9ix);Qmq gvs+B**8Szq_&@C@r5TC1J}Eqj&rVmTSI;*72Tmgy&j0`b literal 0 HcmV?d00001 diff --git a/icechunk-python/tests/data/test-repo/snapshots/MTH5CQPGNWZ516P7QVK0 b/icechunk-python/tests/data/test-repo/snapshots/MTH5CQPGNWZ516P7QVK0 new file mode 100644 index 0000000000000000000000000000000000000000..ee0bacf08e110f7f2bf7db250a0785d93726649f GIT binary patch literal 735 zcmbu7%}&BV6om^)Vtk6e0Mbr>XkAH=R%kFSHi+Gr5k{Ff7Sb6OxDlcc0ELB#D-wQm zWnvna#I?j9u-Wk7mtU7NXiv6 zM!`5KOS+r}pzLOB^9=|HA=cvu?lNKWL*Q2CMn))2{juOvqsS7v5y&s1Ye`tAS${CO4hN>!(&eo zRXgpJEnziKS`~qyD4O?ljtSvwkioS^QJO;}$h$bxfZz)3P8Yj$xUoX~ z^9>jTu-%|j&WXZm{QvN4>KQYEW)B3Sr@Ns_r+WIr`7jlaQhX4Qo literal 0 HcmV?d00001 diff --git a/icechunk-python/tests/data/test-repo/snapshots/VNKSCC59M58V0MSJ01RG b/icechunk-python/tests/data/test-repo/snapshots/VNKSCC59M58V0MSJ01RG deleted file mode 100644 index 4976f9a43132f9a00a5c95a245150bf611428901..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 874 zcmbu7%Wm306oyR;QkAFh3#5!a<6BoH#-;%o5sV=9rkcPcj5;yM9#^89g4zcF!=h4G zk+`&*My;$atE}6XssmwX7EwndotaNyC96~JRGTfwGE1(lNUB@;Edv=HOW;_NS~7f*ZU~<%K-FYbS9R}4NVxcp20X${ z?Dix7?L2|ChH7axH06dRzp7Wvx&sc_AcS_>4 z8v+%0<4-rmMee3JS)clh`eeMS1cPX}DPG+NY4K!#+9!PcO1oq`?c6dY zO8nU_t2RYDPCaj5YBZD^IaKrIwI98Ho}XX&OJ+2UV#lgp$fl_p>IIU_5`bj{GLu|x zK8tG&Ta;Au(p4Pe%(A2kXjOkh0HtFQ9E+$4;E5=mNXl!er~wf|6?#8H!i75;@BwB* zyF2jT&0@28CYS9^Ov{r+#VXp40a9!f5dv9Oy>Hi)F`( &'a self, - manifest_id: &'a ManifestId, + manifest_id: Option<&'a ManifestId>, ) -> impl Iterator + 'a { self.new_nodes().filter_map(move |path| { if self.is_deleted(path) { @@ -330,10 +330,14 @@ impl ChangeSet { match node.node_data { NodeData::Group => Some(node), NodeData::Array(meta, _no_manifests_yet) => { - let new_manifests = vec![ManifestRef { - object_id: manifest_id.clone(), - extents: ManifestExtents(vec![]), - }]; + let new_manifests = manifest_id + .map(|mid| { + vec![ManifestRef { + object_id: mid.clone(), + extents: ManifestExtents(vec![]), + }] + }) + .unwrap_or_default(); Some(NodeSnapshot { node_data: NodeData::Array(meta, new_manifests), ..node diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index 54e30bc6..e89312e3 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -638,13 +638,8 @@ impl Repository { pub async fn list_nodes( &self, ) -> RepositoryResult + '_> { - updated_nodes( - self.storage.as_ref(), - &self.change_set, - &self.snapshot_id, - &ObjectId::FAKE, - ) - .await + updated_nodes(self.storage.as_ref(), &self.change_set, &self.snapshot_id, None) + .await } pub async fn all_chunks( @@ -842,18 +837,16 @@ async fn updated_existing_nodes<'a>( storage: &(dyn Storage + Send + Sync), change_set: &'a ChangeSet, parent_id: &SnapshotId, - manifest_id: &'a ManifestId, + manifest_id: Option<&'a ManifestId>, ) -> RepositoryResult + 'a> { - // TODO: solve this duplication, there is always the possibility of this being the first - // version + let manifest_refs = manifest_id.map(|mid| { + vec![ManifestRef { object_id: mid.clone(), extents: ManifestExtents(vec![]) }] + }); let updated_nodes = storage.fetch_snapshot(parent_id).await?.iter_arc().filter_map(move |node| { let new_manifests = if node.node_type() == NodeType::Array { //FIXME: it could be none for empty arrays - Some(vec![ManifestRef { - object_id: manifest_id.clone(), - extents: ManifestExtents(vec![]), - }]) + manifest_refs.clone() } else { None }; @@ -867,7 +860,7 @@ async fn updated_nodes<'a>( storage: &(dyn Storage + Send + Sync), change_set: &'a ChangeSet, parent_id: &SnapshotId, - manifest_id: &'a ManifestId, + manifest_id: Option<&'a ManifestId>, ) -> RepositoryResult + 'a> { Ok(updated_existing_nodes(storage, change_set, parent_id, manifest_id) .await? @@ -961,20 +954,30 @@ async fn distributed_flush>( .map_ok(|(_path, chunk_info)| chunk_info); let new_manifest = Arc::new(Manifest::from_stream(chunks).await?); - let new_manifest_id = ObjectId::random(); - storage.write_manifests(new_manifest_id.clone(), Arc::clone(&new_manifest)).await?; + let new_manifest_id = if new_manifest.len() > 0 { + let id = ObjectId::random(); + storage.write_manifests(id.clone(), Arc::clone(&new_manifest)).await?; + Some(id) + } else { + None + }; let all_nodes = - updated_nodes(storage, &change_set, parent_id, &new_manifest_id).await?; + updated_nodes(storage, &change_set, parent_id, new_manifest_id.as_ref()).await?; let old_snapshot = storage.fetch_snapshot(parent_id).await?; let mut new_snapshot = Snapshot::from_iter( old_snapshot.as_ref(), Some(properties), - vec![ManifestFileInfo { - id: new_manifest_id.clone(), - format_version: new_manifest.icechunk_manifest_format_version, - }], + new_manifest_id + .as_ref() + .map(|mid| { + vec![ManifestFileInfo { + id: mid.clone(), + format_version: new_manifest.icechunk_manifest_format_version, + }] + }) + .unwrap_or_default(), vec![], all_nodes, ); @@ -1721,6 +1724,9 @@ mod tests { atts == UserAttributesSnapshot::Inline(UserAttributes::try_new(br#"{"foo":42}"#).unwrap()) )); + // since we wrote every asset and we are using a caching storage, we should never need to fetch them + assert!(logging.fetch_operations().is_empty()); + //test the previous version is still alive let ds = Repository::update(Arc::clone(&storage), previous_snapshot_id).build(); assert_eq!( @@ -1732,9 +1738,6 @@ mod tests { Some(ChunkPayload::Inline("new chunk".into())) ); - // since we write every asset and we are using a caching storage, we should never need to fetch them - assert!(logging.fetch_operations().is_empty()); - Ok(()) } @@ -1817,9 +1820,29 @@ mod tests { #[tokio::test] async fn test_manifests_shrink() -> Result<(), Box> { - let storage: Arc = + let in_mem_storage = Arc::new(ObjectStorage::new_in_memory_store(Some("prefix".into()))); + let storage: Arc = in_mem_storage.clone(); let mut ds = Repository::init(Arc::clone(&storage), false).await?.build(); + + // there should be no manifests yet + assert!(!in_mem_storage + .all_keys() + .await? + .iter() + .any(|key| key.contains("manifest"))); + + // initialization creates one snapshot + assert_eq!( + 1, + in_mem_storage + .all_keys() + .await? + .iter() + .filter(|key| key.contains("snapshot")) + .count(), + ); + ds.add_group(Path::root()).await?; let zarr_meta = ZarrArrayMetadata { shape: vec![5, 5], @@ -1841,6 +1864,30 @@ mod tests { ds.add_array(a1path.clone(), zarr_meta.clone()).await?; ds.add_array(a2path.clone(), zarr_meta.clone()).await?; + let _ = ds.commit("main", "first commit", None).await?; + + // there should be no manifests yet because we didn't add any chunks + assert_eq!( + 0, + in_mem_storage + .all_keys() + .await? + .iter() + .filter(|key| key.contains("manifest")) + .count(), + ); + // there should be two snapshots, one for the initialization commit and one for the real + // commit + assert_eq!( + 2, + in_mem_storage + .all_keys() + .await? + .iter() + .filter(|key| key.contains("snapshot")) + .count(), + ); + // add 3 chunks ds.set_chunk_ref( a1path.clone(), @@ -1863,6 +1910,17 @@ mod tests { ds.commit("main", "commit", None).await?; + // there should be one manifest now + assert_eq!( + 1, + in_mem_storage + .all_keys() + .await? + .iter() + .filter(|key| key.contains("manifest")) + .count() + ); + let manifest_id = match ds.get_array(&a1path).await?.node_data { NodeData::Array(_, manifests) => { manifests.first().as_ref().unwrap().object_id.clone() @@ -1874,6 +1932,18 @@ mod tests { ds.delete_array(a2path).await?; ds.commit("main", "array2 deleted", None).await?; + + // there should be two manifests + assert_eq!( + 2, + in_mem_storage + .all_keys() + .await? + .iter() + .filter(|key| key.contains("manifest")) + .count() + ); + let manifest_id = match ds.get_array(&a1path).await?.node_data { NodeData::Array(_, manifests) => { manifests.first().as_ref().unwrap().object_id.clone() @@ -1888,6 +1958,28 @@ mod tests { // delete a chunk ds.set_chunk_ref(a1path.clone(), ChunkIndices(vec![0, 0]), None).await?; ds.commit("main", "chunk deleted", None).await?; + + // there should be three manifests + assert_eq!( + 3, + in_mem_storage + .all_keys() + .await? + .iter() + .filter(|key| key.contains("manifest")) + .count() + ); + // there should be five snapshots + assert_eq!( + 5, + in_mem_storage + .all_keys() + .await? + .iter() + .filter(|key| key.contains("snapshot")) + .count(), + ); + let manifest_id = match ds.get_array(&a1path).await?.node_data { NodeData::Array(_, manifests) => { manifests.first().as_ref().unwrap().object_id.clone() diff --git a/icechunk/src/storage/object_store.rs b/icechunk/src/storage/object_store.rs index 0f737627..c63e905f 100644 --- a/icechunk/src/storage/object_store.rs +++ b/icechunk/src/storage/object_store.rs @@ -104,6 +104,18 @@ impl ObjectStorage { }) } + /// Return all keys in the store + /// + /// Intended for testing and debugging purposes only. + pub async fn all_keys(&self) -> StorageResult> { + Ok(self + .store + .list(None) + .map_ok(|obj| obj.location.to_string()) + .try_collect() + .await?) + } + fn get_path( &self, file_prefix: &str, From 45f647897f31a9bad749e805cb0f093bf3e5fddb Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Sun, 13 Oct 2024 13:17:53 -0300 Subject: [PATCH 085/167] Move KeyNotFound exception to errors module --- icechunk-python/src/errors.rs | 14 +++++++++++--- icechunk-python/src/lib.rs | 15 +++------------ 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/icechunk-python/src/errors.rs b/icechunk-python/src/errors.rs index d7e89d3a..2323012c 100644 --- a/icechunk-python/src/errors.rs +++ b/icechunk-python/src/errors.rs @@ -1,11 +1,12 @@ use icechunk::{ format::IcechunkFormatError, repository::RepositoryError, zarr::StoreError, }; -use pyo3::{exceptions::PyValueError, PyErr}; +use pyo3::{ + exceptions::{PyException, PyValueError}, + PyErr, +}; use thiserror::Error; -use crate::KeyNotFound; - /// A simple wrapper around the StoreError to make it easier to convert to a PyErr /// /// When you use the ? operator, the error is coerced. But if you return the value it is not. @@ -37,3 +38,10 @@ impl From for PyErr { } pub(crate) type PyIcechunkStoreResult = Result; + +pyo3::create_exception!( + _icechunk_python, + KeyNotFound, + PyException, + "The key is not present in the repository" +); diff --git a/icechunk-python/src/lib.rs b/icechunk-python/src/lib.rs index 5bbe31a4..3e756990 100644 --- a/icechunk-python/src/lib.rs +++ b/icechunk-python/src/lib.rs @@ -20,15 +20,13 @@ use icechunk::{ }, Repository, SnapshotMetadata, }; -use pyo3::{ - exceptions::{PyException, PyValueError}, - prelude::*, - types::PyBytes, -}; +use pyo3::{exceptions::PyValueError, prelude::*, types::PyBytes}; use storage::{PyS3Credentials, PyStorageConfig, PyVirtualRefConfig}; use streams::PyAsyncGenerator; use tokio::sync::{Mutex, RwLock}; +pub use errors::KeyNotFound; + #[pyclass] struct PyIcechunkStore { consolidated: ConsolidatedStore, @@ -115,13 +113,6 @@ impl From for PySnapshotMetadata { type KeyRanges = Vec<(String, (Option, Option))>; -pyo3::create_exception!( - _icechunk_python, - KeyNotFound, - PyException, - "The key is not present in the repository" -); - impl PyIcechunkStore { pub(crate) fn consolidated(&self) -> &ConsolidatedStore { &self.consolidated From ceb994eb7b8eb239e5c786776bd9a0ad5d451782 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Sun, 13 Oct 2024 14:54:15 -0400 Subject: [PATCH 086/167] rename branch tag separator, update tests --- ...4QRKS3EWJ09A3640ZDFG => 1H3ZMQ27T6XPD5CGK1DG} | Bin ...A5MVK53M25JDFF2GQZQ0 => EWW1EVYRD0RVW23YZ3N0} | Bin ...JHGE2962KESE5R46V430 => HDRYBA66N2Z6YEV174D0} | Bin ...TJEPHY7N0HVGYKRZHZZ0 => Q7HMN2SYVTRD4YP93780} | Bin .../test-repo/manifests/0WVX0EE119DG0NV7ZQ5G | Bin 323 -> 0 bytes .../test-repo/manifests/BA4J883ECE2DH6W60AZG | Bin 308 -> 0 bytes .../test-repo/manifests/BNV9Q6Q9Y9VKYT45MHJG | Bin 0 -> 308 bytes .../test-repo/manifests/F6PKC2QHTMEQD64RMSJG | Bin 215 -> 0 bytes .../test-repo/manifests/ME8XN2EDY7P3E2XSR5TG | Bin 0 -> 323 bytes .../test-repo/manifests/PGFG33J5SJNP38N7NFV0 | Bin 0 -> 215 bytes .../test-repo/manifests/VPGN5MM8K8N34MA02C30 | Bin 308 -> 0 bytes .../test-repo/manifests/Z77TTBHNH7GG72HRTXN0 | Bin 0 -> 308 bytes .../test-repo/refs/branch.main/ZZZZZZZW.json | 1 + .../test-repo/refs/branch.main/ZZZZZZZX.json | 1 + .../test-repo/refs/branch.main/ZZZZZZZY.json | 1 + .../test-repo/refs/branch.main/ZZZZZZZZ.json | 1 + .../refs/branch.my-branch/ZZZZZZZX.json | 1 + .../refs/branch.my-branch/ZZZZZZZY.json | 1 + .../refs/branch.my-branch/ZZZZZZZZ.json | 1 + .../test-repo/refs/branch:main/ZZZZZZZW.json | 1 - .../test-repo/refs/branch:main/ZZZZZZZX.json | 1 - .../test-repo/refs/branch:main/ZZZZZZZY.json | 1 - .../test-repo/refs/branch:main/ZZZZZZZZ.json | 1 - .../refs/branch:my-branch/ZZZZZZZX.json | 1 - .../refs/branch:my-branch/ZZZZZZZY.json | 1 - .../refs/branch:my-branch/ZZZZZZZZ.json | 1 - .../test-repo/refs/tag.it also works!/ref.json | 1 + .../data/test-repo/refs/tag.it works!/ref.json | 1 + .../test-repo/refs/tag:it also works!/ref.json | 1 - .../data/test-repo/refs/tag:it works!/ref.json | 1 - .../test-repo/snapshots/0XEZMR4J7SJ5QBWAYBE0 | Bin 0 -> 1542 bytes .../test-repo/snapshots/3EMAFJFYV394722VTAPG | Bin 603 -> 0 bytes .../test-repo/snapshots/ATFBNT6AY8J7ERFY8SD0 | Bin 1563 -> 0 bytes .../test-repo/snapshots/F8R612XQGFW9CR08HTN0 | Bin 0 -> 723 bytes .../test-repo/snapshots/J0N9DYWKA5ECGPP056NG | Bin 117 -> 0 bytes .../test-repo/snapshots/J4DQH8NAGRYC10YGT7F0 | Bin 0 -> 594 bytes .../test-repo/snapshots/JSQ148MYX6VKHPP4D0WG | Bin 806 -> 0 bytes .../test-repo/snapshots/MTH5CQPGNWZ516P7QVK0 | Bin 735 -> 0 bytes .../test-repo/snapshots/QVY2RGJBE9DX8E525CA0 | Bin 0 -> 856 bytes .../test-repo/snapshots/RZD9SW6JJZHKA94VY1DG | Bin 0 -> 791 bytes .../test-repo/snapshots/WT2Z2GQ09G0RTAEQ3D70 | Bin 0 -> 111 bytes .../test-repo/snapshots/ZW2AXQ9ZDPRS9V5331PG | Bin 874 -> 0 bytes icechunk/src/refs.rs | 8 ++++---- 43 files changed, 13 insertions(+), 13 deletions(-) rename icechunk-python/tests/data/test-repo/chunks/{4QRKS3EWJ09A3640ZDFG => 1H3ZMQ27T6XPD5CGK1DG} (100%) rename icechunk-python/tests/data/test-repo/chunks/{A5MVK53M25JDFF2GQZQ0 => EWW1EVYRD0RVW23YZ3N0} (100%) rename icechunk-python/tests/data/test-repo/chunks/{JHGE2962KESE5R46V430 => HDRYBA66N2Z6YEV174D0} (100%) rename icechunk-python/tests/data/test-repo/chunks/{TJEPHY7N0HVGYKRZHZZ0 => Q7HMN2SYVTRD4YP93780} (100%) delete mode 100644 icechunk-python/tests/data/test-repo/manifests/0WVX0EE119DG0NV7ZQ5G delete mode 100644 icechunk-python/tests/data/test-repo/manifests/BA4J883ECE2DH6W60AZG create mode 100644 icechunk-python/tests/data/test-repo/manifests/BNV9Q6Q9Y9VKYT45MHJG delete mode 100644 icechunk-python/tests/data/test-repo/manifests/F6PKC2QHTMEQD64RMSJG create mode 100644 icechunk-python/tests/data/test-repo/manifests/ME8XN2EDY7P3E2XSR5TG create mode 100644 icechunk-python/tests/data/test-repo/manifests/PGFG33J5SJNP38N7NFV0 delete mode 100644 icechunk-python/tests/data/test-repo/manifests/VPGN5MM8K8N34MA02C30 create mode 100644 icechunk-python/tests/data/test-repo/manifests/Z77TTBHNH7GG72HRTXN0 create mode 100644 icechunk-python/tests/data/test-repo/refs/branch.main/ZZZZZZZW.json create mode 100644 icechunk-python/tests/data/test-repo/refs/branch.main/ZZZZZZZX.json create mode 100644 icechunk-python/tests/data/test-repo/refs/branch.main/ZZZZZZZY.json create mode 100644 icechunk-python/tests/data/test-repo/refs/branch.main/ZZZZZZZZ.json create mode 100644 icechunk-python/tests/data/test-repo/refs/branch.my-branch/ZZZZZZZX.json create mode 100644 icechunk-python/tests/data/test-repo/refs/branch.my-branch/ZZZZZZZY.json create mode 100644 icechunk-python/tests/data/test-repo/refs/branch.my-branch/ZZZZZZZZ.json delete mode 100644 icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZW.json delete mode 100644 icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZX.json delete mode 100644 icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZY.json delete mode 100644 icechunk-python/tests/data/test-repo/refs/branch:main/ZZZZZZZZ.json delete mode 100644 icechunk-python/tests/data/test-repo/refs/branch:my-branch/ZZZZZZZX.json delete mode 100644 icechunk-python/tests/data/test-repo/refs/branch:my-branch/ZZZZZZZY.json delete mode 100644 icechunk-python/tests/data/test-repo/refs/branch:my-branch/ZZZZZZZZ.json create mode 100644 icechunk-python/tests/data/test-repo/refs/tag.it also works!/ref.json create mode 100644 icechunk-python/tests/data/test-repo/refs/tag.it works!/ref.json delete mode 100644 icechunk-python/tests/data/test-repo/refs/tag:it also works!/ref.json delete mode 100644 icechunk-python/tests/data/test-repo/refs/tag:it works!/ref.json create mode 100644 icechunk-python/tests/data/test-repo/snapshots/0XEZMR4J7SJ5QBWAYBE0 delete mode 100644 icechunk-python/tests/data/test-repo/snapshots/3EMAFJFYV394722VTAPG delete mode 100644 icechunk-python/tests/data/test-repo/snapshots/ATFBNT6AY8J7ERFY8SD0 create mode 100644 icechunk-python/tests/data/test-repo/snapshots/F8R612XQGFW9CR08HTN0 delete mode 100644 icechunk-python/tests/data/test-repo/snapshots/J0N9DYWKA5ECGPP056NG create mode 100644 icechunk-python/tests/data/test-repo/snapshots/J4DQH8NAGRYC10YGT7F0 delete mode 100644 icechunk-python/tests/data/test-repo/snapshots/JSQ148MYX6VKHPP4D0WG delete mode 100644 icechunk-python/tests/data/test-repo/snapshots/MTH5CQPGNWZ516P7QVK0 create mode 100644 icechunk-python/tests/data/test-repo/snapshots/QVY2RGJBE9DX8E525CA0 create mode 100644 icechunk-python/tests/data/test-repo/snapshots/RZD9SW6JJZHKA94VY1DG create mode 100644 icechunk-python/tests/data/test-repo/snapshots/WT2Z2GQ09G0RTAEQ3D70 delete mode 100644 icechunk-python/tests/data/test-repo/snapshots/ZW2AXQ9ZDPRS9V5331PG diff --git a/icechunk-python/tests/data/test-repo/chunks/4QRKS3EWJ09A3640ZDFG b/icechunk-python/tests/data/test-repo/chunks/1H3ZMQ27T6XPD5CGK1DG similarity index 100% rename from icechunk-python/tests/data/test-repo/chunks/4QRKS3EWJ09A3640ZDFG rename to icechunk-python/tests/data/test-repo/chunks/1H3ZMQ27T6XPD5CGK1DG diff --git a/icechunk-python/tests/data/test-repo/chunks/A5MVK53M25JDFF2GQZQ0 b/icechunk-python/tests/data/test-repo/chunks/EWW1EVYRD0RVW23YZ3N0 similarity index 100% rename from icechunk-python/tests/data/test-repo/chunks/A5MVK53M25JDFF2GQZQ0 rename to icechunk-python/tests/data/test-repo/chunks/EWW1EVYRD0RVW23YZ3N0 diff --git a/icechunk-python/tests/data/test-repo/chunks/JHGE2962KESE5R46V430 b/icechunk-python/tests/data/test-repo/chunks/HDRYBA66N2Z6YEV174D0 similarity index 100% rename from icechunk-python/tests/data/test-repo/chunks/JHGE2962KESE5R46V430 rename to icechunk-python/tests/data/test-repo/chunks/HDRYBA66N2Z6YEV174D0 diff --git a/icechunk-python/tests/data/test-repo/chunks/TJEPHY7N0HVGYKRZHZZ0 b/icechunk-python/tests/data/test-repo/chunks/Q7HMN2SYVTRD4YP93780 similarity index 100% rename from icechunk-python/tests/data/test-repo/chunks/TJEPHY7N0HVGYKRZHZZ0 rename to icechunk-python/tests/data/test-repo/chunks/Q7HMN2SYVTRD4YP93780 diff --git a/icechunk-python/tests/data/test-repo/manifests/0WVX0EE119DG0NV7ZQ5G b/icechunk-python/tests/data/test-repo/manifests/0WVX0EE119DG0NV7ZQ5G deleted file mode 100644 index ee6e3f5c5ff6f5795c2404bcd157c064ec395fcf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 323 zcmbQt(9k!Dc_KsOGS9r6%)Hbij3JYlCowQIE)UBrDk)9OncTR-F{wB|r?e#XrdqMF zmA-ySYH>+YX>xXIiGD$1Nrrw&zJ5VvNk)F2esV@>Ube0wLkh?U#>T}#scDn9gm}3I zcto1}8F+-bM|uZEc|=7SfK*ImM75KV0jj~v!`;=$(#*))HQ3cO$iytn#2Bn$5+lq= zlfWSFU}M*CF9S1(jC>J+(u#rqCMowf#<+31iSy27Vg6bTWi6};~pc=(85!omJ DUdwba diff --git a/icechunk-python/tests/data/test-repo/manifests/BA4J883ECE2DH6W60AZG b/icechunk-python/tests/data/test-repo/manifests/BA4J883ECE2DH6W60AZG deleted file mode 100644 index 917634ddcd024beb8fd83bc81d137a2bbaf94cf2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 308 zcmbQt(9k=Hc_KsOGS9r6%)Hbij3JYlCowQIE)UBrDk)9OncTR-F{wB|r?e#XrdqMF zmA-ySYH>+YX>xXIiGD$1Nrrw&zJ5VvNk)F2esV@>Ube0wLkh?U#>T}#scDn9gm}3I zcto1}8F+-bM|uZEc|=7SfK*ImM75KV0jj~v!`;=$(#*))HQ3cO$iytn#2Bn$5+lq= zlfWSFU}M*CF9S1(jC>J+(u#rqCMowf#<+31iSy27Vg6bTWi6};~pcn-Jw-#+YX>xXIiGD$1Nrrw&zJ5VvNk)F2esV@>Ube0wLkh?U#>T}#scDn9c(?>b zIyst|`58r-MY@I=nwz*7fK*ImM75KV0jj|@JlxPVEHcQ&ASf){$T%{}*bl5>5+lq= zLl5I9-#{bt5VME?7gJ|i>O?|_>O^tnxOuby(+>G1WQa~ygp(;YWTmw8J z&HW5K!rUXhgQ7g5q97_5VP<%FxVsuzni+Y!2D_RDnV5x{7(-RS%rFTI@(wn34fisz rbTl?IF^F<;a|hbOGLfNinP*;3W?t$M#t+YX>xXIiGD$1Nrrw&zJ5VvNk)F2esV@>Ube0wLkh?U#>T}#scDn9c(?>b zIyst|`58r-MY@I=nwz*7fK*ImM75KV0jj|@JlxPVEHcQ&ASf){$T%{}*bl5>5+lq= zLl5I9-#{bt5VME?7gJ|in%{_emjDjP>LV{dOA_FXq%`FTVQa~ygp(;FFf+C$9 z&CL9aqRb*)!wk($Tp%hKVP?37ha0+vMFzPT1cikg8AnDL`$1K}%rNvYj`9sOG7m9} r2yiiVcK0@PaR=JMGLfNinP*;3W?t$M#t+YX>xXIiGD$1Nrrw&zJ5VvNk)F2esV@>Ube0wLkh?U#>T}#scDn9gm}3I zcto1}8F+-bM|uZEc|=7SfK*ImM75KV0jj~v!`;=$(#*))HQ3cO$iytn#2Bn$5+lq= zlfWSFU}M*CF9S1(jC>J+(u#rqCMowf#<+31iSy27Vg6bTWi6};~pcn-Jw-#+YX>xXIiGD$1Nrrw&zJ5VvNk)F2esV@>Ube0wLkh?U#>T}#scDn9c(?>b zIyst|`58r-MY@I=nwz*7fK*ImM75KV0jj|@JlxPVEHcQ&ASf){$T%{}*bl5>5+lq= zLl5I9-#{bt5VME?7gJ|Ch)Bo#puss*jWu*JMA z#fBL6YT_$ZjZ!5!l@&5|KFd;US|q+u1jQVY#1TnXNdB0mj~VtM!%(z9>X%Jm0t9da zZ?wABlZmhD8KGQD%Cep>hyqj9$cz+iKnpz0>+fBF@Tbs0UBe7La=e-(sk$QNYC>8i z_SYmw*lU^9qI#Etz`(9V4sKlDr|ypjHx*7m%L0yf|6j6prugqK F<0n*qDzpFq literal 0 HcmV?d00001 diff --git a/icechunk-python/tests/data/test-repo/snapshots/3EMAFJFYV394722VTAPG b/icechunk-python/tests/data/test-repo/snapshots/3EMAFJFYV394722VTAPG deleted file mode 100644 index d0aa72ed1046c8612e5261d10f43e083cb4c4fc7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 603 zcmbu3&rZTX5XM`T#PAe-0c^Xi&~lO}B|=OP2|+HVzzQ47mUOp=dXYvSpe-CsJdw2E zjl>X-hO@qu{Ob)4#<|SQmv6rLy)gsNV_APVFJG%#p?cz#DM*y`H&fep!$^5-nitSh8lL}rL^I)|4QZXK{m``D)OP1Lzt-;&Yc4)M=F zbj#8mH_EMA@ERnO(|g0zsRV*VBDyhj(sifWM)`hPFpM8WO@~6_P9Sb09Zw8IsR6x( hH;)lXPdAJh@@S|0PfR}%B1{#Z-4ceE%k6kS+Ap+x-S7Ya diff --git a/icechunk-python/tests/data/test-repo/snapshots/ATFBNT6AY8J7ERFY8SD0 b/icechunk-python/tests/data/test-repo/snapshots/ATFBNT6AY8J7ERFY8SD0 deleted file mode 100644 index 68953825563e6bb4668e2ae901129a24bf5337ec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1563 zcmcJOOOMh}6vshD$N3bE`;qqcbvr9N1BHPwDbVof#&qD$aARpndojU{5LfPa)WpQq zBs_F86ScZBX4d)%{5CQ`<0EAvA$D`_{rBAe`JJBLH<9jO=pV>j20}J@m;{NmP!?!e zPeuB%*sGDRmw{Z#OL{^r6r~c+vW%Mg3TVLXG4vk8YBbnqxP4A|$Uy)EAkl(;QAZ{s zh%%^JqisDI`I%y#;b5*>5lY##s&Wasyv9S6Xa)cX^!F|z)Klz`wqXV~IW5f}=GD}3 zS?3u+74xMm9gZRj44`=)>hDgmX;O8A7$cue=42(KR7JtL0B-1#U@45a2vguvQ)l3 zzluZ#!^ug9Ah$O~RvjDG)Wn3CsDHKQFYo&Sx!W{MGhn|HDsM?Yh>fvD;2^pJA0N$k zI~x+tmH> W;HJWHY+157MU;&hV1$WdA)V<0H$wCQps+A;MZ!f_ zCZ=)YhPA$w1!RW{<802HKmYm9w>VE+Z|Hh?LpK1(Dy6KM2b!sx`kBBDfM zt(i%qcyTNW7e-mtg%3%Pawr2y5!C7{y6LbkPnqr1nPa1o7pn+L zC7H^VG}YAPBjh9$T1UNboIcj67W{#zg77VCa?jaZ%9H zW%D-_H4c>O%M~VsZ$JijuLHhIuJ#@#(Q#lB?awn7!4~Sx47=@g7SZOah~wb)w>8{9 z<2tIh9P2tTr9Ro$(FwPHQ`W-t6;mGamT%$L0h_e9pR{NVH!s(-biB7i{PPZI+pyc8 zC0E6Lbi?7r6=_2TBsUbAl?bG*{zBg|LwWXrz4Q~oFYFf_#$(}sZ`@M*^H4*pF14GEM1iU0rr literal 0 HcmV?d00001 diff --git a/icechunk-python/tests/data/test-repo/snapshots/J0N9DYWKA5ECGPP056NG b/icechunk-python/tests/data/test-repo/snapshots/J0N9DYWKA5ECGPP056NG deleted file mode 100644 index e6dc53983107e43f7e33593a97f2965016c03025..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 117 zcmbQu&@f>F1H**LTLJz~&7Jd|7i#2rH0_UjAZB#Nh%3wnu%*52XlxtyZZ?1M5dzz>k&K$ z>Gb>-nK}`LAeD-0NXLD5Uf3p|4=alDgQ)EgfZZ7|x==S}2oi#TxAK;WA|B`nC6Y&b X<9}-UsSp}O@#&pmSc+fAKdXHM%LLnw literal 0 HcmV?d00001 diff --git a/icechunk-python/tests/data/test-repo/snapshots/JSQ148MYX6VKHPP4D0WG b/icechunk-python/tests/data/test-repo/snapshots/JSQ148MYX6VKHPP4D0WG deleted file mode 100644 index 747d6b3cdd437d7ef9aca3ca617e29399a82a7f2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 806 zcmbu7O>f#T7{|jH($r6pFVJB-?>!9_l9q@nX;DaXnOsPVH8-HxnZzywZ65#<4wH7O z#LIT7Hbpy5J#SxXK+6pd)qHvEzn|lBtzB^@|qRL+C8osNCT=4g8T5c&j%voanj~V9A>%W8m8sje)ZUHn&*b(+x0yV z+0c=sOG@zTJnZ)v;k|eul8A7EDftj~vb=ubLRoKI`l@H2IF4KetvxKw01zP^1wUBG z#rGuU38rGZn?&z+zhFH86-5e$pYO`k!d-c^IE^TY!r|PCdr5y;zF8MIeX-qb8$Nv_ z?Qlab%-G8F@0*&iSu@@x?4B?Yi_xk@+yEYFKc8sjD{}L8Gs~vM9pSU@z;TQRqj_ar z(l6=3!Sb5+FqcqTD$Oq_X5G=!d`$j)d=P|5I=>q5kc}2$)FC)pP{KJ4#}9ix);Qmq gvs+B**8Szq_&@C@r5TC1J}Eqj&rVmTSI;*72Tmgy&j0`b diff --git a/icechunk-python/tests/data/test-repo/snapshots/MTH5CQPGNWZ516P7QVK0 b/icechunk-python/tests/data/test-repo/snapshots/MTH5CQPGNWZ516P7QVK0 deleted file mode 100644 index ee0bacf08e110f7f2bf7db250a0785d93726649f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 735 zcmbu7%}&BV6om^)Vtk6e0Mbr>XkAH=R%kFSHi+Gr5k{Ff7Sb6OxDlcc0ELB#D-wQm zWnvna#I?j9u-Wk7mtU7NXiv6 zM!`5KOS+r}pzLOB^9=|HA=cvu?lNKWL*Q2CMn))2{juOvqsS7v5y&s1Ye`tAS${CO4hN>!(&eo zRXgpJEnziKS`~qyD4O?ljtSvwkioS^QJO;}$h$bxfZz)3P8Yj$xUoX~ z^9>jTu-%|j&WXZm{QvN4>KQYEW)B3Sr@Ns_r+WIr`7jlaQhX4Qo diff --git a/icechunk-python/tests/data/test-repo/snapshots/QVY2RGJBE9DX8E525CA0 b/icechunk-python/tests/data/test-repo/snapshots/QVY2RGJBE9DX8E525CA0 new file mode 100644 index 0000000000000000000000000000000000000000..8f9fa67201d73abd5eb199e2bc7cbefe52865cf4 GIT binary patch literal 856 zcmbu7&u-d45XPYesmfFM1yby_jn_HV!C(heOBgGFFVzM%VbzU6_PP?i6x2Qd*c>YL z6p26WrBN%ZmtJyiU#bSe4IiTJWp+p3eDj;pe#uSdQQ|6!#oLMhir48=)Z(wio7=df{Fzt?X@X4GA2k30C0cVcr*+M8~S68c!Uu| z$$Q+7ljda?*hc-ls_GqBm4RH*GEgKSs)GByk11xDL;F1(pLi?f-N~oB(jtFXnyxQA zN<2JS8U8RFZAv%WAS|98ZU@1~ucU`}>&-8N6z}h=YHoLyY(Rp05~N+dRKo2H_HI8P z-AY&F=FP4)SUf+Hd~y%u`)E8}Y13<`1bJ&p7^8Uhknt5~$$>n$MU)=c3rExcNk1|U=ejTcjoBa@0`W_5(eWiKFPvH8Dy=d{#D6>M%B@3wlGR2 zqbSZnCnJMK#m?1@V@HDdT3LDq5>O9i=}=Z`GCb0hBUSsXD!QR)?%g?|RBR)TqPVD9 zl{_q)w$nV8rKVlebNhCICh6`yzU=Y7$haT0c;MqG&eSEXZd#=()XPvcPx4yH-krVz z0N_43xYuQbcm2LdJi-a0bItB?ExtURG|C7Fc+_g&jUoo zRwwYj?~dKL2S29iNn)BFE(#tc9v;kXzZ>+H>5JDPPd;pKTf^;d(!y(OV#Zbt|9;5| z>oemW!tMzZk?5@~;u^4_J-*S>XXN5^y~`%a9pbw)knf}ZaBjYi@Mm~%uso+-#1#;x zQu8WBtTSBZHp-uy7eN?@^Rqt3Y`DN)n;>sN3Fj1#UiSQ?QM_%=E)k{M?jLu`|Ji<= Tnh}q~apJA`tgw20dUxZ04MY~? literal 0 HcmV?d00001 diff --git a/icechunk-python/tests/data/test-repo/snapshots/WT2Z2GQ09G0RTAEQ3D70 b/icechunk-python/tests/data/test-repo/snapshots/WT2Z2GQ09G0RTAEQ3D70 new file mode 100644 index 0000000000000000000000000000000000000000..52d1ff26671516067eb8ea75131dc7b0c3ab4990 GIT binary patch literal 111 zcmbQu&@f>F1H**LTg<`@Ov4>ryn`*w{VYrzeLYOQ-FF)q7@6oA8t57thZtH|nHpP} snCh7rnHm@wMQsa8EyypY zO8nU_t2RYDPCaj5YBZD^IaKrIwI98Ho}XX&OJ+2UV#lgp$fl_p>IIU_5`bj{GLu|x zK8tG&Ta;Au(p4Pe%(A2kXjOkh0HtFQ9E+$4;E5=mNXl!er~wf|6?#8H!i75;@BwB* zyF2jT&0@28CYS9^Ov{r+#VXp40a9!f5dv9Oy>Hi)F` RefResult { - match path.strip_prefix("tag:") { + match path.strip_prefix("tag.") { Some(name) => Ok(Ref::Tag(name.to_string())), - None => match path.strip_prefix("branch:") { + None => match path.strip_prefix("branch.") { Some(name) => Ok(Ref::Branch(name.to_string())), None => Err(RefError::InvalidRefType(path.to_string())), }, @@ -109,14 +109,14 @@ fn tag_key(tag_name: &str) -> RefResult { return Err(RefError::InvalidRefName(tag_name.to_string())); } - Ok(format!("tag:{}/{}", tag_name, TAG_KEY_NAME)) + Ok(format!("tag.{}/{}", tag_name, TAG_KEY_NAME)) } fn branch_root(branch_name: &str) -> RefResult { if branch_name.contains('/') { return Err(RefError::InvalidRefName(branch_name.to_string())); } - Ok(format!("branch:{}", branch_name)) + Ok(format!("branch.{}", branch_name)) } fn branch_key(branch_name: &str, version_id: &str) -> RefResult { From cadc68614ee15bfbeb69be94bb70dc04e9313d96 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Sun, 13 Oct 2024 15:29:11 -0400 Subject: [PATCH 087/167] Fix actions trigge --- .github/workflows/python-ci.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml index 5beb31c8..042caeb0 100644 --- a/.github/workflows/python-ci.yaml +++ b/.github/workflows/python-ci.yaml @@ -175,14 +175,13 @@ jobs: - name: Upload sdist uses: actions/upload-artifact@v4 with: - working-directory: icechunk-python name: wheels-sdist path: icechunk-python/dist release: name: Release runs-on: ubuntu-latest - if: "github.event_name == 'published'" + if: ${{ github.event_name == 'release' }} needs: [linux, musllinux, windows, macos, sdist] steps: - uses: actions/download-artifact@v4 From 7649e952ea42604d10c091a2fa864dfa50b44014 Mon Sep 17 00:00:00 2001 From: Ryan Abernathey Date: Mon, 14 Oct 2024 09:29:44 -0400 Subject: [PATCH 088/167] Reorganize docs and create blank pages (#207) * reorganize docs * Update docs/docs/icechunk-python/quickstart.md Co-authored-by: Joe Hamman --------- Co-authored-by: Joe Hamman --- docs/docs/icechunk-python/concurrency.md | 15 ++++ docs/docs/icechunk-python/index.md | 71 ++--------------- docs/docs/icechunk-python/quickstart.md | 84 ++++++++++++++++++++ docs/docs/icechunk-python/version-control.md | 3 + docs/docs/icechunk-python/virtual.md | 3 + docs/docs/icechunk-python/xarray.md | 1 + docs/docs/sample-datasets.md | 6 ++ docs/mkdocs.yml | 26 +++--- 8 files changed, 134 insertions(+), 75 deletions(-) create mode 100644 docs/docs/icechunk-python/concurrency.md create mode 100644 docs/docs/icechunk-python/quickstart.md create mode 100644 docs/docs/icechunk-python/version-control.md create mode 100644 docs/docs/icechunk-python/virtual.md create mode 100644 docs/docs/icechunk-python/xarray.md create mode 100644 docs/docs/sample-datasets.md diff --git a/docs/docs/icechunk-python/concurrency.md b/docs/docs/icechunk-python/concurrency.md new file mode 100644 index 00000000..0fe06981 --- /dev/null +++ b/docs/docs/icechunk-python/concurrency.md @@ -0,0 +1,15 @@ +# Concurrency + +TODO: describe the general approach to concurrency in Icechunk + +## Built-in concurrency + +Describe the multi-threading and async concurrency in Icechunk / Zarr + +## Distributed concurrency within a single transaction + +"Cooperative" concurrency + +## Concurrency across uncoordinated sessions + +### Conflict detection \ No newline at end of file diff --git a/docs/docs/icechunk-python/index.md b/docs/docs/icechunk-python/index.md index 3a0f1add..0a09a922 100644 --- a/docs/docs/icechunk-python/index.md +++ b/docs/docs/icechunk-python/index.md @@ -1,66 +1,7 @@ -# Icechunk Python +# Index of icechunk-python -### Installation and Dependencies - -Icechunk is currently designed to support the [Zarr V3 Specification](https://zarr-specs.readthedocs.io/en/latest/v3/core/v3.0.html). -Using it today requires installing the [still unreleased] Zarr Python V3 branch. - -To set up an Icechunk development environment, follow these steps - -Activate your preferred virtual environment (here we use `virtualenv`): - -```bash -python3 -m venv .venv -source .venv/bin/activate -``` - -Alternatively, create a conda environment - -```bash -mamba create -n icechunk rust python=3.12 -conda activate icechunk -``` - -Install `maturin`: - -```bash -pip install maturin -``` - -Build the project in dev mode: - -```bash -cd icechunk-python/ -maturin develop -``` - -or build the project in editable mode: - -```bash -cd icechunk-python/ -pip install -e icechunk@. -``` - -!!! warning - This only makes the python source code editable, the rust will need to be recompiled when it changes - -### Basic Usage - -Once you have everything installed, here's an example of how to use Icechunk. - -```python -from icechunk import IcechunkStore, StorageConfig -from zarr import Array, Group - -# Example using memory store -storage = StorageConfig.memory("test") -store = await IcechunkStore.open(storage=storage, mode='r+') - -# Example using file store -storage = StorageConfig.filesystem("/path/to/root") -store = await IcechunkStore.open(storage=storage, mode='r+') - -# Example using S3 -s3_storage = StorageConfig.s3_from_env(bucket="icechunk-test", prefix="oscar-demo-repository") -store = await IcechunkStore.open(storage=storage, mode='r+') -``` +- [developing](/icechunk-python/developing/) +- [examples](/icechunk-python/examples/) +- [notebooks](/icechunk-python/notebooks/) +- [quickstart](/icechunk-python/quickstart/) +- [reference](/icechunk-python/reference/) \ No newline at end of file diff --git a/docs/docs/icechunk-python/quickstart.md b/docs/docs/icechunk-python/quickstart.md new file mode 100644 index 00000000..5143b59f --- /dev/null +++ b/docs/docs/icechunk-python/quickstart.md @@ -0,0 +1,84 @@ +# Quickstart + +Icechunk is designed to be mostly in the background. +As a Python user, you'll mostly be interacting with Zarr. +If you're not familiar with Zarr, you may want to start with the [Zarr Tutorial](https://zarr.readthedocs.io/en/latest/tutorial.html) + +## Installation + +Install Icechunk with pip + +```python +pip install icechunk +``` + +!!! note + + Icechunk is currently designed to support the [Zarr V3 Specification](https://zarr-specs.readthedocs.io/en/latest/v3/core/v3.0.html). + Using it today requires installing the latest pre-release of Zarr Python 3. + + +## Create a new store + +To get started, let's create a new Icechunk store. +We recommend creating your store on S3 to get the most out of Icechunk's cloud-native design. +However, you can also create a store on your local filesystem. + +=== "S3 Storage" + + ```python + # TODO + ``` + +=== "Local Storage" + + ```python + # TODO + ``` + +## Write some data and commit + +We can now use our Icechunk `store` with Zarr. +Let's first create a group and an array within it. + +```python +group = zarr.group(store) +array = group.create("my_array", shape=10, dtype=int) +``` + +Now let's write some data + +```python +array[:] = 1 +``` + +Now let's commit our update + +```python +# TODO: update when we change the API to be async +await store.commit("first commit") +``` + +🎉 Congratulations! You just made your first Icechunk snapshot. + +## Make a second commit + +Let's now put some new data into our array, overwriting the first five elements. + +```python +array[:5] = 2 +``` + +...and commit the changes + +``` +await store.commit("overwrite some values") +``` + +### Explore version history + +We can now see both versions of our array + +```python +# TODO: use ancestors +``` \ No newline at end of file diff --git a/docs/docs/icechunk-python/version-control.md b/docs/docs/icechunk-python/version-control.md new file mode 100644 index 00000000..40682164 --- /dev/null +++ b/docs/docs/icechunk-python/version-control.md @@ -0,0 +1,3 @@ +# Version Control + +TODO: describe the basic version control model \ No newline at end of file diff --git a/docs/docs/icechunk-python/virtual.md b/docs/docs/icechunk-python/virtual.md new file mode 100644 index 00000000..28c96015 --- /dev/null +++ b/docs/docs/icechunk-python/virtual.md @@ -0,0 +1,3 @@ +# Virtual Datasets + +Kerchunk, VirtualiZarr, etc. \ No newline at end of file diff --git a/docs/docs/icechunk-python/xarray.md b/docs/docs/icechunk-python/xarray.md new file mode 100644 index 00000000..de88bf05 --- /dev/null +++ b/docs/docs/icechunk-python/xarray.md @@ -0,0 +1 @@ +# Icechunk + Xarray diff --git a/docs/docs/sample-datasets.md b/docs/docs/sample-datasets.md new file mode 100644 index 00000000..91c3d992 --- /dev/null +++ b/docs/docs/sample-datasets.md @@ -0,0 +1,6 @@ +# Sample Datasets + +## Native Datasets + + +## Virtual Datasets \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index e3e8d7d9..d4a8de76 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -106,9 +106,9 @@ plugins: default_handler: python - mkdocs-jupyter: include_source: True - include: - - "icechunk-python/notebooks/*.ipynb" - - "icechunk-python/examples/*.py" + #include: + # - "icechunk-python/notebooks/*.ipynb" + # - "icechunk-python/examples/*.py" markdown_extensions: - admonition @@ -121,21 +121,27 @@ markdown_extensions: - name: mermaid class: mermaid format: !!python/name:mermaid2.fence_mermaid_custom + - pymdownx.tabbed: + alternate_style: true - pymdownx.highlight: anchor_linenums: true line_spans: __span pygments_lang_class: true - nav: - Home: - index.md - Icechunk Python: - - icechunk-python/index.md - - Reference: icechunk-python/reference.md + - icechunk-python/quickstart.md + - icechunk-python/xarray.md + - icechunk-python/version-control.md + - Virtual Datasets: icechunk-python/virtual.md + - icechunk-python/concurrency.md + - API Reference: icechunk-python/reference.md - Developing: icechunk-python/developing.md - - Examples: - - ... | flat | icechunk-python/examples/*.py - - Notebooks: - - ... | flat | icechunk-python/notebooks/*.ipynb +# - Examples: +# - ... | flat | icechunk-python/examples/*.py +# - Notebooks: +# - ... | flat | icechunk-python/notebooks/*.ipynb + - Sample Datasets: sample-datasets.md - Spec: spec.md From 28ad3fccbfa3e507350dfc10563071136512d338 Mon Sep 17 00:00:00 2001 From: Ryan Abernathey Date: Mon, 14 Oct 2024 09:43:04 -0400 Subject: [PATCH 089/167] FAQ (#196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * faq wip * wrote more faq * big faq update * Apply suggestions from code review Co-authored-by: Sebastián Galkin Co-authored-by: Joe Hamman --------- Co-authored-by: Sebastián Galkin Co-authored-by: Joe Hamman --- docs/docs/faq.md | 374 +++++++++++++++++++++++++++++++++++++++++++++++ docs/mkdocs.yml | 9 +- 2 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 docs/docs/faq.md diff --git a/docs/docs/faq.md b/docs/docs/faq.md new file mode 100644 index 00000000..4c490036 --- /dev/null +++ b/docs/docs/faq.md @@ -0,0 +1,374 @@ +--- +title: Icechunk - Frequenctly Asked Questions +--- + +# FAQ + +## Why was Icechunk created? + +Icechunk was created by [Earthmover](https://earthmover.io/) as the open-source format for its cloud data platform [Arraylake](https://docs.earthmover.io). + +Icechunk builds on the successful [Zarr](https://zarr.dev) project. +Zarr is a great foundation for storing and querying large multidimensional array data in a flexible, scalable way. +But when people started using Zarr together with cloud object storage in a collaborative way, it became clear that Zarr alone could not offer the sort of consistency many users desired. +Icechunk makes Zarr work a little bit more like a database, enabling different users / processes to safely read and write concurrently, while still only using object storage as a persistence layer. + +Another motivation for Icechunk was the success of [Kerchunk](https://github.com/fsspec/kerchunk/). +The Kerchunk project showed that it was possible to map many existing archival formats (e.g. HDF5, NetCDF, GRIB) to the Zarr data model without actually rewriting any bytes, by creating "virtual" Zarr datasets referencing binary chunks inside other files. +Doing this at scale requires tracking millions of "chunk references." +Icechunk's storage model allows for these virtual chunks to be stored seamlessly alongside native Zarr chunks. + +Finally, Icechunk provides a universal I/O layer for cloud object storage, implementing numerous performance optimizations designed to accelerate data-intensive applications. + +Solving these problems in one go via a powerful, open-source, Rust-based library will bring massive benefits +to the cloud-native scientific data community. + +## Where does the name "Icechunk" come from? + +Icechunk was inspired partly by [Apache Iceberg](https://iceberg.apache.org/), a popular cloud-native table format. +However, instead of storing tabular data, Icechunk stores multidimensional arrays, for which the individual unit of +storage is the _chunk_. + +## When should I use Icechunk? + +Here are some scenarios where it makes sense to use Icechunk: + +- You want to store large, dynamically evolving multi-dimensional array (a.k.a. tensor) in cloud object storage. +- You want to allow multiple uncoordinated processes to access your data at the same time (like a database). +- You want to be able to safely roll back failed updates or revert Zarr data to an earlier state. +- You want to use concepts from data version control (e.g. tagging, branching, snapshots) with Zarr data. +- You want to achieve cloud-native performance on archival file formats (HDF5, NetCDF, GRIB) by exposing them as virtual Zarr datasets and need to store chunk references in a a robust, scalable, interoperable way. +- You want to get the best possible performance for reading / writing tensor data in AI / ML workflows. + +## What are the downsides to using Icechunk? + +As with all things in technology, the benefits of Icechunk come with some tradeoffs: + +- There may be slightly higher cold-start latency to opening Icechunk datasets compared with regular Zarr. +- The on-disk format is less transparent than regular Zarr. +- The process for distributed writes is more complex to coordinate. + +!!! warning + Another downside of Icechunk in its current state is its immaturity. + The library is very new, likely contains bugs, and is not recommended + for production usage at this point in time. + + +## What is Icechunk's relationship to Zarr? + +The Zarr format and protocol is agnostic to the underlying storage system ("store" in Zarr terminology) +and communicates with the store via a simple key / value interface. +Zarr tells the store which keys and values it wants to get or set, and it's the store's job +to figure out how to persist or retrieve the required bytes. + +Most existing Zarr stores have a simple 1:1 mapping between Zarr's keys and the underlying file / object names. +For example, if Zarr asks for a key call `myarray/c/0/0`, the store may just look up a key of the same name +in an underlying cloud object storage bucket. + +Icechunk is a storage engine which creates a layer of indirection between the +Zarr keys and the actual files in storage. +A Zarr library doesn't have to know explicitly how Icechunk works or how it's storing data on disk. +It just gets / sets keys as it would with any store. +Icechunk figures out how to materialize these keys based on its [storage schema](./spec.md). + +
+ +- __Standard Zarr + Fsspec__ + + --- + + In standard Zarr usage (without Icechunk), [fsspec](https://filesystem-spec.readthedocs.io/) sits + between the Zarr library and the object store, translating Zarr keys directly to object store keys. + + ```mermaid + flowchart TD + zarr-python[Zarr Library] <-- key / value--> icechunk[fsspec] + icechunk <-- key / value --> storage[(Object Storage)] + ``` + +- __Zarr + Icechunk__ + + --- + + With Icechunk, the Icechunk library intercepts the Zarr keys and translates them to the + Icechunk schema, storing data in object storage using its own format. + + ```mermaid + flowchart TD + zarr-python[Zarr Library] <-- key / value--> icechunk[Icechunk Library] + icechunk <-- icechunk data / metadata files --> storage[(Object Storage)] + ``` + +
+ + +Implementing Icechunk this way allows Icechunk's specification to evolve independently from Zarr's, +maintaining interoperability while enabling rapid iteration and promoting innovation on the I/O layer. + +## Is Icechunk part of the Zarr Spec? + +No. At the moment, the Icechunk spec is completely independent of the Zarr spec. + +In the future, we may choose to propose Icechunk as a Zarr extension. +However, because it sits _below_ Zarr in the stack, it's not immediately clear how to do that. + +## Should I implement Icechunk on my own based on the spec? + +No, we do not recommend implementing Icechunk independently of the existing Rust library. +There are two reasons for this: + +1. The spec has not yet been stabilized and is still evolving rapidly. +1. It's probably much easier to bind to the Rust library from your language of choice, + rather than re-implement from scratch. + +We welcome contributions from folks interested in developing Icechunk bindings for other languages! + +## Is Icechunk stable? + +The Icechunk library is reasonably well-tested and performant. +The Rust-based core library provides a solid foundation of correctness, safety, and speed. + +However, the actual on disk format is still evolving and may change from one alpha release to the next. +Until Icechunk reaches v1.0, we can't commit to long-term stability of the on-disk format. +This means Icechunk can't yet be used for production uses which require long-term persistence of data. + +😅 Don't worry! We are working as fast as we can and aim to release v1.0 soon! + +## Is Icechunk fast? + +We have not yet begun the process of optimizing Icechunk for performance. +Our focus so far has been on correctness and delivering the features needed for full interoperability with Zarr and Xarray. + +However, preliminary investigations indicate that Icechunk is at least as fast as the existing Zarr / Dask / fsspec stack +and in many cases achieves significantly lower latency and higher throughput. +Furthermore, Icechunk achieves this without using Dask, by implementing its own asynchronous multithreaded I/O pipeline. + +## How does Icechunk compare to X? + +### Array Formats + +Array formats are file formats for storing multi-dimensional array (tensor) data. +Icechunk is an array format. +Here is how Icechunk compares to other popular array formats. + +#### [HDF5](https://www.hdfgroup.org/solutions/hdf5/) + +HDF5 (Hierarchical Data Format version 5) is a popular format for storing scientific data. +HDF is widely used in high-performance computing. + +
+ +- __Similarities__ + + --- + + Icechunk and HDF5 share the same data model: multidimensional arrays and metadata organized into a hierarchical tree structure. + This data model can accomodate a wide range of different use cases and workflows. + + Both Icechunk and HDF5 use the concept of "chunking" to split large arrays into smaller storage units. + +- __Differences__ + + --- + + HDF5 is a monolithic file format designed first and foremost for POSIX filesystems. + All of the chunks in an HDF5 dataset live within a single file. + The size of an HDF5 dataset is limited to the size of a single file. + HDF5 relies on the filesystem for consistency and is not designed for multiple concurrent yet uncoordinated readers and writers. + + Icechunk spreads chunks over many files and is designed first and foremost for cloud object storage. + Icechunk can accommodate datasets of arbitrary size. + Icechunk's optimistic concurrency design allows for safe concurrent access for uncoordinated readers and writers. + +
+ +#### [NetCDF](https://www.unidata.ucar.edu/software/netcdf/) + +> NetCDF (Network Common Data Form) is a set of software libraries and machine-independent data formats that support the creation, access, and sharing of array-oriented scientific data. + +NetCDF4 uses HDF5 as its underlying file format. +Therefore, the similarities and differences with Icechunk are fundamentally the same. + +Icechunk can accommodate the NetCDF data model. +It's possible to write NetCDF compliant data in Icechunk using [Xarray](https://xarray.dev/). + +#### [Zarr](https://zarr.dev) + +Icechunk works together with Zarr. +(See [What is Icechunk's relationship to Zarr?](#what-is-icechunks-relationship-to-zarr) for more detail.) + +Compared to regular Zarr (without Icechunk), Icechunk offers many benefits, including + +- Serializable isolation of updates via transactions +- Data version control (snapshots, branches, tags) +- Ability to store references to chunks in external datasets (HDF5, NetCDF, GRIB, etc.) +- A Rust-optimized I/O layer for communicating with object storage + +#### [Cloud Optimized GeoTiff](http://cogeo.org/) (CoG) + +> A Cloud Optimized GeoTIFF (COG) is a regular GeoTIFF file, aimed at being hosted on a HTTP file server, with an internal organization that enables more efficient workflows on the cloud. +> It does this by leveraging the ability of clients issuing ​HTTP GET range requests to ask for just the parts of a file they need. + +CoG has become very popular in the geospatial community as a cloud-native format for raster data. +A CoG file contains a single image (possibly with multiple bands), sharded into chunks of an appropriate size. +A CoG also contains "overviews," lower resolution versions of the same data. +Finally, a CoG contains relevant geospatial metadata regarding projection, CRS, etc. which allow georeferencing of the data. + +Data identical to what is found in a CoG can be stored in the Zarr data model and therefore in an Icechunk repo. +Furthermore, Zarr / Icechunk can accommodate rasters of arbitrarily large size and facilitate massive-scale concurrent writes (in addition to reads); +A CoG, in contrast, is limited to a single file and thus has limitations on scale and write concurrency. + +However, Zarr and Icechunk currently do not offer the same level of broad geospatial interoperability that CoG does. +The [GeoZarr](https://github.com/zarr-developers/geozarr-spec) project aims to change that. + +#### [TileDB Embedded](https://docs.tiledb.com/main/background/key-concepts-and-data-format) + +TileDB Embedded is an innovative array storage format that bears many similarities to both Zarr and Icechunk. +Like TileDB Embedded, Icechunk aims to provide database-style features on top of the array data model. +Both technologies use an embedded / serverless architecture, where client processes interact directly with +data files in storage, rather than through a database server. +However, there are a number of difference, enumerated below. + +The following table compares Zarr + Icechunk with TileDB Embedded in a few key areas + +| feature | **Zarr + Icechunk** | **TileDB Embedded** | Comment | +|---------|---------------------|---------------------|---------| +| *atomicity* | atomic updates can span multiple arrays and groups | _array fragments_ limited to a single array | Icechunk's model allows a writer to stage many updates across interrelated arrays into a single transaction. | +| *concurrency and isolation* | serializable isolation of transactions | [eventual consistency](https://docs.tiledb.com/main/background/internal-mechanics/consistency) | While both formats enable lock-free concurrent reading and writing, Icechunk can catch (and potentially reject) inconsistent, out-of order updates. | +| *versioning* | snapshots, branches, tags | linear version history | Icechunk's data versioning model is closer to Git's. | +| *unit of storage* | chunk | tile | (basically the same thing) | +| *minimum write* | chunk | cell | TileDB allows atomic updates to individual cells, while Zarr requires writing an entire chunk. | +| *sparse arrays* | :material-close: | :material-check: | Zar + Icechunk do not currently support sparse arrays. | +| *virtual chunk references* | :material-check: | :material-close: | Icechunk enables references to chunks in other file formats (HDF5, NetCDF, GRIB, etc.), while TileDB does not. | + +Beyond this list, there are numerous differences in the design, file layout, and implementation of Icechunk and TileDB embedded +which may lead to differences in suitability and performance for different workfloads. + +#### [SafeTensors](https://github.com/huggingface/safetensors) + +SafeTensors is a format developed by HuggingFace for storing tensors (arrays) safely, in contrast to Python pickle objects. + +By the same criteria Icechunk and Zarr are also "safe", in that it is impossible to trigger arbitrary code execution when reading data. + +SafeTensors is a single-file format, like HDF5, +SafeTensors optimizes for a simple on-disk layout that facilitates mem-map-based zero-copy reading in ML training pipleines, +assuming that the data are being read from a local POSIX filesystem +Zarr and Icechunk instead allow for flexible chunking and compression to optimize I/O against object storage. + +### Tabular Formats + +Tabular formats are for storing tabular data. +Tabular formats are extremely prevalent in general-purpose data analytics but are less widely used in scientific domains. +The tabular data model is different from Icechunk's multidimensional array data model, and so a direct comparison is not always apt. +However, Icechunk is inspired by many tabular file formats, and there are some notable similarities. + +#### [Apache Parquet](https://parquet.apache.org/) + +> Apache Parquet is an open source, column-oriented data file format designed for efficient data storage and retrieval. +> It provides high performance compression and encoding schemes to handle complex data in bulk and is supported in many programming language and analytics tools. + +Parquet employs many of the same core technological concepts used in Zarr + Icechunk such as chunking, compression, and efficient metadata access in a cloud context. +Both formats support a range of different numerical data types. +Both are "columnar" in the sense that different columns / variables / arrays can be queried efficiently without having to fetch unwanted data from other columns. +Both also support attaching arbitrary key-value metadata to variables. +Parquet supports "nested" types like variable-length lists, dicts, etc. that are currently unsupported in Zarr (but may be possible in the future). + +In general, Parquet and other tabular formats can't be substituted for Zarr / Icechunk, due to the lack of multidimensional array support. +On the other hand, tabular data can be modeled in Zarr / Icechunk in a relatively straightforward way: each column as a 1D array, and a table / dataframe as a group of same-sized 1D arrays. + +#### [Apache Iceberg](https://iceberg.apache.org/) + +> Iceberg is a high-performance format for huge analytic tables. +> Iceberg brings the reliability and simplicity of SQL tables to big data, while making it possible for engines like Spark, Trino, Flink, Presto, Hive and Impala to safely work with the same tables, at the same time. + +Iceberg is commonly used to manage many Parquet files as a single table in object storage. + +Iceberg was influential in the design of Icechunk. +Many of the [spec](./spec.md) core requirements are similar to Iceberg. +Specifically, both formats share the following properties: + +- Files written to object storage immutably +- All data and metadata files are tracked explicitly by manifests +- Similar mechanism for staging snapshots and committing transactions +- Support for branches and tags + +However, unlike Iceberg, Icechunk _does not require an external catalog_ to commit transactions; it relies solely on the consistency of the object store. + +#### [Delta Lake](https://delta.io/) + +Delta is another popular table format based on a log of updates to the table state. +Its functionality and design is quite similar to Iceberg, as is its comparison to Icechunk. + +#### [Lance](https://lancedb.github.io/lance/index.html) + +> Lance is a modern columnar data format that is optimized for ML workflows and datasets. + +Despite its focus on multimodal data, as a columnar format, Lance can't accommodate large arrays / tensors chunked over arbitrary dimensions, making it fundamentally different from Icechunk. + +However, the modern design of Lance was very influential on Icechunk. +Icechunk's commit and conflict resolution mechanism is partly inspired by Lance. + +### Other Related projects + +#### [Xarray](https://xarray.dev/) + +> Xarray is an open source project and Python package that introduces labels in the form of dimensions, coordinates, and attributes on top of raw NumPy-like arrays, which allows for more intuitive, more concise, and less error-prone user experience. +> +> Xarray includes a large and growing library of domain-agnostic functions for advanced analytics and visualization with these data structures. + +Xarray and Zarr / Icechunk work great together! +Xarray is the recommended way to read and write Icechunk data for Python users in geospatial, weather, climate, and similar domains. + +#### [Kerchunk](https://fsspec.github.io/kerchunk/) + +> Kerchunk is a library that provides a unified way to represent a variety of chunked, compressed data formats (e.g. NetCDF/HDF5, GRIB2, TIFF, …), allowing efficient access to the data from traditional file systems or cloud object storage. +> It also provides a flexible way to create virtual datasets from multiple files. It does this by extracting the byte ranges, compression information and other information about the data and storing this metadata in a new, separate object. +> This means that you can create a virtual aggregate dataset over potentially many source files, for efficient, parallel and cloud-friendly in-situ access without having to copy or translate the originals. +> It is a gateway to in-the-cloud massive data processing while the data providers still insist on using legacy formats for archival storage + +Kerchunk emerged from the [Pangeo](https://www.pangeo.io/) community as an experimental +way of reading archival files, allowing those files to be accessed "virtually" using the Zarr protocol. +Kerchunk pioneered the concept of a "chunk manifest", a file containing references to compressed binary chunks in other files in the form of the tuple `(uri, offset, size)`. +Kerchunk has experimented with different ways of serializing chunk manifests, including JSON and Parquet. + +Icechunk provides a highly efficient and scalable mechanism for storing and tracking the references generated by Kerchunk. +Kerchunk and Icechunk are highly complimentary. + +#### [VirtualiZarr](https://virtualizarr.readthedocs.io/en/latest/) + +> VirtualiZarr creates virtual Zarr stores for cloud-friendly access to archival data, using familiar Xarray syntax. + +VirtualiZarr is another way of generating and manipulating Kerchunk-style references. +Icechunk provides a highly efficient and scalable mechanism for storing and tracking the references generated by VirtualiZarr. +Kerchunk and VirtualiZarr are highly complimentary. + +#### [LakeFS](https://lakefs.io/) + +LakeFS is a solution git-style version control on top of cloud object storage. +LakeFS enables git-style commits, tags, and branches representing the state of an entire object storage bucket. + +LakeFS is format agnostic and can accommodate any type of data, including Zarr. +LakeFS can therefore be used to create a versioned Zarr store, similar to Icechunk. + +Icechunk, however, is designed specifically for array data, based on the Zarr data model. +This specialization enables numerous optimizations and user-experience enhancements not possible with LakeFS. + +LakeFS also requires a server to operate. +Icechunk, in contrast, works with just object storage. + +#### [TensorStore](https://google.github.io/tensorstore/index.html) + +> TensorStore is a library for efficiently reading and writing large multi-dimensional arrays. + +TensorStore can read and write a variety of different array formats, including Zarr. + +While TensorStore is not yet compatible with Icechunk, it should be possible to implement Icechunk support in TensorStore. + +TensorStore implements an [ocdbt](https://google.github.io/tensorstore/kvstore/ocdbt/index.html#ocdbt-key-value-store-driver): + +> The ocdbt driver implements an Optionally-Cooperative Distributed B+Tree (OCDBT) on top of a base key-value store. + +Ocdbt implements a transactional, versioned key-value store suitable for storing Zarr data, thereby supporting some of the same features as Icechunk. +Unlike Icechunk, the ocdbt key-value store is not specialized to Zarr, does not differentiate between chunk or metadata keys, and does not store any metadata about chunks. + + diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index d4a8de76..15e6ac1d 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -127,9 +127,16 @@ markdown_extensions: anchor_linenums: true line_spans: __span pygments_lang_class: true + - attr_list + - md_in_html + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + nav: - - Home: + - Overview: - index.md + - FAQ: faq.md - Icechunk Python: - icechunk-python/quickstart.md - icechunk-python/xarray.md From 06e09faaa8c638eb00f07e1ed1da9669fc992012 Mon Sep 17 00:00:00 2001 From: Ryan Abernathey Date: Mon, 14 Oct 2024 09:54:17 -0400 Subject: [PATCH 090/167] Updates to overview page (#206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update overview * update to top-level docs page * Apply suggestions from code review Co-authored-by: Joe Hamman Co-authored-by: Sebastián Galkin --------- Co-authored-by: Joe Hamman Co-authored-by: Sebastián Galkin --- docs/docs/index.md | 129 +++++++++++++++++++++++---------------------- 1 file changed, 66 insertions(+), 63 deletions(-) diff --git a/docs/docs/index.md b/docs/docs/index.md index 0741e66a..d6b4ed63 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -1,68 +1,87 @@ --- -title: Icechunk - Transactional storage engine for Zarr on cloud object storage. +title: Icechunk - Open-source, cloud-native transactional tensor storage engine --- # Icechunk -!!! info "Welcome to Icechunk!" - Icechunk is a transactional storage engine for Zarr designed for use on cloud object storage. +Icechunk is an open-source (Apache 2.0), transactional storage engine for tensor / ND-array data designed for use on cloud object storage. +Icechunk works together with **[Zarr](https://zarr.dev/)**, augmenting the Zarr core data model with features +that enhance performance, collaboration, and safety in a cloud-computing context. -Let's break down what that means: +## Docs Organization + +This is the Icechunk documentation. It's organized into the following parts. + +- This page: a general overview of the project's goals and components. +- [Frequently Asked Questions](./faq.md) +- Documentation for [Icechunk Python](./icechunk-python), the main user-facing + library +- Documentation for the [Icechunk Rust Crate](https://docs.rs/icechunk/latest/icechunk/) +- The [Icechunk Spec](./spec.md) + +## Icechunk Overview + +Let's break down what "transactional storage engine for Zarr" actually means: - **[Zarr](https://zarr.dev/)** is an open source specification for the storage of multidimensional array (a.k.a. tensor) data. Zarr defines the metadata for describing arrays (shape, dtype, etc.) and the way these arrays are chunked, compressed, and converted to raw bytes for storage. Zarr can store its data in any key-value store. + There are many different implementations of Zarr in different languages. _Right now, Icechunk only supports + [Zarr Python](https://zarr.readthedocs.io/en/stable/)._ + If you're interested in implementing Icehcunk support, please [open an issue](https://github.com/earth-mover/icechunk/issues) so we can help you. - **Storage engine** - Icechunk exposes a key-value interface to Zarr and manages all of the actual I/O for getting, setting, and updating both metadata and chunk data in cloud object storage. Zarr libraries don't have to know exactly how icechunk works under the hood in order to use it. - **Transactional** - The key improvement that Icechunk brings on top of regular Zarr is to provide consistent serializable isolation between transactions. This means that Icechunk data are safe to read and write in parallel from multiple uncoordinated processes. This allows Zarr to be used more like a database. -## Goals of Icechunk - -The core entity in Icechunk is a **store**. -A store is defined as a Zarr hierarchy containing one or more Arrays and Groups. -The most common scenario is for an Icechunk store to contain a single Zarr group with multiple arrays, each corresponding to different physical variables but sharing common spatiotemporal coordinates. -However, formally a store can be any valid Zarr hierarchy, from a single Array to a deeply nested structure of Groups and Arrays. -Users of Icechunk should aim to scope their stores only to related arrays and groups that require consistent transactional updates. +The core entity in Icechunk is a repository or **repo**. +A repo is defined as a Zarr hierarchy containing one or more Arrays and Groups, and a repo functions as +self-contained _Zarr Store_. +The most common scenario is for an Icechunk repo to contain a single Zarr group with multiple arrays, each corresponding to different physical variables but sharing common spatiotemporal coordinates. +However, formally a repo can be any valid Zarr hierarchy, from a single Array to a deeply nested structure of Groups and Arrays. +Users of Icechunk should aim to scope their repos only to related arrays and groups that require consistent transactional updates. -Icechunk aspires to support the following core requirements for stores: +Icechunk supports the following core requirements: -1. **Object storage** - the format is designed around the consistency features and performance characteristics available in modern cloud object storage. No external database or catalog is required to maintain a store. -1. **Serializable isolation** - Reads are isolated from concurrent writes and always use a committed snapshot of a store. Writes are committed atomically and are never partially visible. Readers will not acquire locks. -1. **Time travel** - Previous snapshots of a store remain accessible after new ones have been written. -1. **Data Version Control** - Stores support both _tags_ (immutable references to snapshots) and _branches_ (mutable references to snapshots). -1. **Chunk sharding and references** - Chunk storage is decoupled from specific file names. Multiple chunks can be packed into a single object (sharding). Zarr-compatible chunks within other file formats (e.g. HDF5, NetCDF) can be referenced. -1. **Schema Evolution** - Arrays and Groups can be added, renamed, and removed from the hierarchy with minimal overhead. +1. **Object storage** - the format is designed around the consistency features and performance characteristics available in modern cloud object storage. No external database or catalog is required to maintain a repo. +(It also works with file storage.) +1. **Serializable isolation** - Reads are isolated from concurrent writes and always use a committed snapshot of a repo. Writes are committed atomically and are never partially visible. No locks are required for reading. +1. **Time travel** - Previous snapshots of a repo remain accessible after new ones have been written. +1. **Data version control** - Repos support both _tags_ (immutable references to snapshots) and _branches_ (mutable references to snapshots). +1. **Chunk shardings** - Chunk storage is decoupled from specific file names. Multiple chunks can be packed into a single object (sharding). +1. **Chunk references** - Zarr-compatible chunks within other file formats (e.g. HDF5, NetCDF) can be referenced. +1. **Schema evolution** - Arrays and Groups can be added, renamed, and removed from the hierarchy with minimal overhead. -## The Project +## Key Concepts -This Icechunk project consists of three main parts: +### Groups, Arrays, and Chunks -1. The [Icechunk specification](./spec.md). -1. A Rust implementation -1. A Python wrapper which exposes a Zarr store interface +Icechunk is designed around the Zarr data model, widely used in scientific computing, data science, and AI / ML. +(The Zarr high-level data model is effectively the same as HDF5.) +The core data structure in this data model is the **array**. +Arrays have two fundamental properties: -All of this is open source, licensed under the Apache 2.0 license. +- **shape** - a tuple of integers which specify the dimensions of each axis of the array. A 10 x 10 square array would have shape (10, 10) +- **data type** - a specification of what type of data is found in each element, e.g. integer, float, etc. Different data types have different precision (e.g. 16-bit integer, 64-bit float, etc.) -The Rust implementation is a solid foundation for creating bindings in any language. -We encourage adopters to collaborate on the Rust implementation, rather than reimplementing Icechunk from scratch in other languages. +In Zarr / Icechunk, arrays are split into **chunks**, +A chunk is the minimum unit of data that must be read / written from storage, and thus choices about chunking have strong implications for performance. +Zarr leaves this completely up to the user. +Chunk shape should be chosen based on the anticipated data access patten for each array +An Icechunk array is not bounded by an individual file and is effectively unlimited in size. -We encourage collaborators from the broader community to contribute to Icechunk. -Governance of the project will be managed by Earthmover PBC. +For further organization of data, Icechunk supports **groups** withing a single repo. +Group are like folders which contain multiple arrays and or other groups. +Groups enable data to be organized into hierarchical trees. +A common usage pattern is to store multiple arrays in a group representing a NetCDF-style dataset. -## How Can I Use It? +Arbitrary JSON-style key-value metadata can be attached to both arrays and groups. -We recommend using [Icechunk from Python](./icechunk-python/index.md), together with the Zarr-Python library. - -!!! warning "Icechunk is a very new project." - It is not recommended for production use at this time. - These instructions are aimed at Icechunk developers and curious early adopters. - -## Key Concepts: Snapshots, Branches, and Tags +### Snapshots Every update to an Icechunk store creates a new **snapshot** with a unique ID. Icechunk users must organize their updates into groups of related operations called **transactions**. -For example, appending a new time slice to mutliple arrays should be done as single transactions, comprising the following steps +For example, appending a new time slice to mutliple arrays should be done as a single transaction, comprising the following steps 1. Update the array metadata to resize the array to accommodate the new elements. 2. Write new chunks for each array in the group. @@ -70,22 +89,30 @@ While the transaction is in progress, none of these changes will be visible to o Once the transaction is committed, a new snapshot is generated. Readers can only see and use committed snapshots. +### Branches and Tags + Additionally, snapshots occur in a specific linear (i.e. serializable) order within **branch**. A branch is a mutable reference to a snapshot--a pointer that maps the branch name to a snapshot ID. The default branch is `main`. Every commit to the main branch updates this reference. Icechunk's design protects against the race condition in which two uncoordinated sessions attempt to update the branch at the same time; only one can succeed. -Finally, Icechunk defines **tags**--_immutable_ references to snapshot. +Icechunk also defines **tags**--_immutable_ references to snapshot. Tags are appropriate for publishing specific releases of a repository or for any application which requires a persistent, immutable identifier to the store state. +### Chunk References + +Chunk references are "pointers" to chunks that exist in other files--HDF5, NetCDF, GRIB, etc. +Icechunk can store these references alongside native Zarr chunks as "virtual datasets". +You can then can update these virtual datasets incrementally (overwrite chunks, change metadata, etc.) without touching the underling files. + ## How Does It Work? !!! note For more detailed explanation, have a look at the [Icechunk spec](./spec.md) Zarr itself works by storing both metadata and chunk data into a abstract store according to a specified system of "keys". -For example, a 2D Zarr array called myarray, within a group called mygroup, would generate the following keys: +For example, a 2D Zarr array called `myarray`, within a group called `mygroup`, would generate the following keys: ``` mygroup/zarr.json @@ -113,27 +140,3 @@ flowchart TD icechunk <-- data / metadata files --> storage[(Object Storage)] ``` -## FAQ - -1. _Why not just use Iceberg directly?_ - - Iceberg and all other "table formats" (Delta, Hudi, LanceDB, etc.) are based on tabular data model. - This data model cannot accommodate large, multidimensional arrays (tensors) in a general, scalable way. - -1. Is Icechunk part of Zarr? - - Formally, no. - Icechunk is a separate specification from Zarr. - However, it is designed to interoperate closely with Zarr. - In the future, we may propose a more formal integration between the Zarr spec and Icechunk spec if helpful. - For now, keeping them separate allows us to evolve Icechunk quickly while maintaining the stability and backwards compatibility of the Zarr data model. - -## Inspiration - -Icechunk's was inspired by several existing projects and formats, most notably - -- [FSSpec Reference Filesystem](https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.implementations.reference.ReferenceFileSystem) -- [Apache Iceberg](https://iceberg.apache.org/spec/) -- [LanceDB](https://lancedb.github.io/lance/format.html) -- [TileDB](https://docs.tiledb.com/main/background/key-concepts-and-data-format) -- [OCDBT](https://google.github.io/tensorstore/kvstore/ocdbt/index.html) \ No newline at end of file From fb5022199461e3b5b6aed22bcec132e470f3cc43 Mon Sep 17 00:00:00 2001 From: Ryan Abernathey Date: Mon, 14 Oct 2024 10:21:45 -0400 Subject: [PATCH 091/167] update quickstart and add configuration (#210) --- docs/docs/icechunk-python/configuration.md | 5 +++ docs/docs/icechunk-python/quickstart.md | 44 ++++++++++++++++++---- docs/mkdocs.yml | 1 + 3 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 docs/docs/icechunk-python/configuration.md diff --git a/docs/docs/icechunk-python/configuration.md b/docs/docs/icechunk-python/configuration.md new file mode 100644 index 00000000..a15f032d --- /dev/null +++ b/docs/docs/icechunk-python/configuration.md @@ -0,0 +1,5 @@ +# Configuration + +## Storage Config + +## Creating and Opening Repos \ No newline at end of file diff --git a/docs/docs/icechunk-python/quickstart.md b/docs/docs/icechunk-python/quickstart.md index 5143b59f..64de5b83 100644 --- a/docs/docs/icechunk-python/quickstart.md +++ b/docs/docs/icechunk-python/quickstart.md @@ -27,13 +27,18 @@ However, you can also create a store on your local filesystem. === "S3 Storage" ```python - # TODO + storage_config = icechunk.StorageConfig.s3_from_env( + bucket="icechunk-test", + prefix="quickstart-demo-1" + ) + store = await icechunk.IcechunkStore.create(storage_config) ``` === "Local Storage" ```python - # TODO + storage_config = icechunk.StorageConfig.filesystem("./icechunk-local") + store = await icechunk.IcechunkStore.create(storage_config) ``` ## Write some data and commit @@ -71,14 +76,39 @@ array[:5] = 2 ...and commit the changes -``` +```python await store.commit("overwrite some values") ``` -### Explore version history +## Explore version history -We can now see both versions of our array +We can see the full version history of our repo: ```python -# TODO: use ancestors -``` \ No newline at end of file +hist = [anc async for anc in store.ancestry()] +for anc in hist: + print(anc.id, anc.message, anc.written_at) + +# Output: +# AHC3TSP5ERXKTM4FCB5G overwrite some values 2024-10-14 14:07:27.328429+00:00 +# Q492CAPV7SF3T1BC0AA0 first commit 2024-10-14 14:07:26.152193+00:00 +# T7SMDT9C5DZ8MP83DNM0 Repository initialized 2024-10-14 14:07:22.338529+00:00 +``` + +...and we can go back in time to the earlier version. + +```python +# latest version +assert array[0] == 2 +# check out earlier snapshot +await store.checkout(snapshot_id=hist[1].id) +# verify data matches first version +assert array[0] == 1 +``` + +--- + +That's it! You now know how to use Icechunk! +For your next step, dig deeper into [configuration](./configuration.md), +explore the [version control system](./version-control.md), or learn how to +[use Icechunk with Xarray](./xarray.md). \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 15e6ac1d..c44ed405 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -139,6 +139,7 @@ nav: - FAQ: faq.md - Icechunk Python: - icechunk-python/quickstart.md + - icechunk-python/configuration.md - icechunk-python/xarray.md - icechunk-python/version-control.md - Virtual Datasets: icechunk-python/virtual.md From cad87fdbb4f65ce4b64806184566f1321c5c8fde Mon Sep 17 00:00:00 2001 From: Joseph Hamman Date: Mon, 14 Oct 2024 10:44:06 -0700 Subject: [PATCH 092/167] docs: add content to xarray page --- docs/docs/icechunk-python/xarray.md | 141 ++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/docs/docs/icechunk-python/xarray.md b/docs/docs/icechunk-python/xarray.md index de88bf05..97b5c68c 100644 --- a/docs/docs/icechunk-python/xarray.md +++ b/docs/docs/icechunk-python/xarray.md @@ -1 +1,142 @@ # Icechunk + Xarray + +Icechunk was designed to work seamlessly with Xarray. Xarray users can read and +write data to Icechunk using [`xarray.open_zarr`](https://docs.xarray.dev/en/latest/generated/xarray.open_zarr.html#xarray.open_zarr) +and [`xarray.Dataset.to_zarr`](https://docs.xarray.dev/en/latest/generated/xarray.Dataset.to_zarr.html#xarray.Dataset.to_zarr). + +!!! note + + Using Xarray and Icechunk together currently requires installing Xarray from source. + + ```shell + pip install git+https://github.com/TomAugspurger/xarray/@fix/zarr-v3 + ``` + + We expect this functionality to be included in Xarray's next release. + +In this example, we'll explain how to create a new Icechunk store, write some sample data +to it, and append data a second block of data using Icechunk's version control features. + +## Create a new store + +Similar to the example in [quickstart](/icechunk-python/quickstart/), we'll create an Icechunk store in S3. You will need to replace the `StorageConfig` with a bucket that +you have access to. + +```python +import xarray as xr +from icechunk import IcechunkStore, StorageConfig + +storage_config = icechunk.StorageConfig.s3_from_env( + bucket="icechunk-test", + prefix="xarray-demo" +) +store = await icechunk.IcechunkStore.create(storage_config) +``` + +## Open tutorial dataset from Xarray + +For this demo, we'll open Xarray's RASM tutorial dataset and split it into two blocks. +We'll write the two blocks to Icechunk in separate transactions later in the this example. + +```python +ds = xr.tutorial.open_dataset('rasm') + +ds1 = ds.isel(time=slice(None, 18)) # part 1 +ds2 = ds.isel(time=slice(18, None)) # part 2 +``` + +## Write Xarray data to Icechunk + +Writing Xarray data to Icechunk is as easy as calling `Dataset.to_zarr`: + +```python +ds1.to_zarr(store, zarr_format=3, consolidated=False) +``` + +After writing, we commit the changes: + +```python +await store.commit("add RASM data to store") +# output: 'ME4VKFPA5QAY0B2YSG8G' +``` + +## Append to an existing store + +Next, we want to add a second block of data to our store. Above, we created `ds2` for just +this reason. Again, we'll use `Dataset.to_zarr`, this time with `append_dim='time'`. + +```python +ds2.to_zarr(store, append_dim='time') +``` + +And then we'll commit the changes: + +```python +await store.commit("append more data") +# output: 'WW4V8V34QCZ2NXTD5DXG' +``` + +## Reading data with Xarray + +To read data stored in Icechunk with Xarray, we'll use `xarray.open_zarr`: + +```python +xr.open_zarr(store, zarr_format=3, consolidated=False) +# output: Size: 9MB +# Dimensions: (y: 205, x: 275, time: 18) +# Coordinates: +# xc (y, x) float64 451kB dask.array +# yc (y, x) float64 451kB dask.array +# * time (time) object 144B 1980-09-16 12:00:00 ... 1982-02-15 12:00:00 +# Dimensions without coordinates: y, x +# Data variables: +# Tair (time, y, x) float64 8MB dask.array +# Attributes: +# NCO: netCDF Operators version 4.7.9 (Homepage = htt... +# comment: Output from the Variable Infiltration Capacity... +# convention: CF-1.4 +# history: Fri Aug 7 17:57:38 2020: ncatted -a bounds,,d... +# institution: U.W. +# nco_openmp_thread_number: 1 +# output_frequency: daily +# output_mode: averaged +# references: Based on the initial model of Liang et al., 19... +# source: RACM R1002RBRxaaa01a +# title: /workspace/jhamman/processed/R1002RBRxaaa01a/l... +``` + +We can also read data from previous snapshots by checking out prior versions: + +```python +await store.checkout('ME4VKFPA5QAY0B2YSG8G') + +xr.open_zarr(store, zarr_format=3, consolidated=False) +# Size: 9MB +# Dimensions: (time: 18, y: 205, x: 275) +# Coordinates: +# xc (y, x) float64 451kB dask.array +# yc (y, x) float64 451kB dask.array +# * time (time) object 144B 1980-09-16 12:00:00 ... 1982-02-15 12:00:00 +# Dimensions without coordinates: y, x +# Data variables: +# Tair (time, y, x) float64 8MB dask.array +# Attributes: +# NCO: netCDF Operators version 4.7.9 (Homepage = htt... +# comment: Output from the Variable Infiltration Capacity... +# convention: CF-1.4 +# history: Fri Aug 7 17:57:38 2020: ncatted -a bounds,,d... +# institution: U.W. +# nco_openmp_thread_number: 1 +# output_frequency: daily +# output_mode: averaged +# references: Based on the initial model of Liang et al., 19... +# source: RACM R1002RBRxaaa01a +# title: /workspace/jhamman/processed/R1002RBRxaaa01a/l... +``` + +Notice that this second `xarray.Dataset` has a time dimension of length 18 whereas the +first has a time dimension of length 36. + +## Next steps + +For more details on how to use Xarray's Zarr integration, checkout [Xarray's documentation](https://docs.xarray.dev/en/stable/user-guide/io.html#zarr). From a008dedc39cbf359ce000ecb344ccb631316c480 Mon Sep 17 00:00:00 2001 From: Orestis Herodotou Date: Wed, 9 Oct 2024 11:26:51 -0700 Subject: [PATCH 093/167] add logo --- .../assets/favicon/android-chrome-192x192.png | Bin 0 -> 24761 bytes .../assets/favicon/android-chrome-512x512.png | Bin 0 -> 104068 bytes docs/docs/assets/favicon/apple-touch-icon.png | Bin 0 -> 22110 bytes docs/docs/assets/favicon/favicon-16x16.png | Bin 0 -> 737 bytes docs/docs/assets/favicon/favicon-32x32.png | Bin 0 -> 1772 bytes docs/docs/assets/favicon/favicon.ico | Bin 0 -> 15406 bytes docs/docs/assets/favicon/index.md | 1 + docs/docs/assets/index.md | 1 + docs/docs/assets/logo.svg | 1110 +++++++++++++++++ docs/mkdocs.yml | 8 +- 10 files changed, 1117 insertions(+), 3 deletions(-) create mode 100644 docs/docs/assets/favicon/android-chrome-192x192.png create mode 100644 docs/docs/assets/favicon/android-chrome-512x512.png create mode 100644 docs/docs/assets/favicon/apple-touch-icon.png create mode 100644 docs/docs/assets/favicon/favicon-16x16.png create mode 100644 docs/docs/assets/favicon/favicon-32x32.png create mode 100644 docs/docs/assets/favicon/favicon.ico create mode 100644 docs/docs/assets/favicon/index.md create mode 100644 docs/docs/assets/index.md create mode 100644 docs/docs/assets/logo.svg diff --git a/docs/docs/assets/favicon/android-chrome-192x192.png b/docs/docs/assets/favicon/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..57158c3d674dccca8dd1e50ccbd6888a66e275e0 GIT binary patch literal 24761 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 Y4?2|9&+eymKmY&$07*qoM6N<$g0ZNw3;+NC literal 0 HcmV?d00001 diff --git a/docs/docs/assets/favicon/android-chrome-512x512.png b/docs/docs/assets/favicon/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..cea0ed71d91a3badd51a57da8bd44e26d454ff9f GIT binary patch literal 104068 zcmX_nbyQSu)b*WUXa*4&LP}B^q?87g5G5s~L4JfZ(l9eL5`ripprjxTl9EFUC?F{% zLnArl(DUK@uJ^m^u66JH_uTWGIeVXd_VZfjsTw626Bz&ilp5*}^Z)>S{R#$1VAqdB zzp=CH2gplLO$8_)y!H3`2h>YN!+_-a3L<$C4FK$b#sg&oKZ~sv(*&-*WS;;2+wmBP zZk{hLehGWx>cySSM-p3Lk)JB8H0%J&%}*_E6IINMRa6aS#TRXAV9|N)D(gL(#!3J) zr=T(Ck4lZ>q^{iQVU>rbG{xpzX#447h@Q|f+4qvt37Mpz4YFNG~F@Yy*@-qMIORW}Sd0Z${6TCnp=y z${9T|xH=D*Quwk0gJdO4wY5BJU}ve-IxWbRZJK4KBDKYM6H{M_32 zKPxjah7Uor!{fSoEb)XDd>b2KTN{}qbUe5;l?Tm*3hVz{i=eT_d4DD%c5rT&8m1oNZZpboWEpZ`3V314*U@EGpe zV6%K?yK~~)LBD&1F?6g(R-)&3R1oLhi|X%rgyawdG}T<5a_BT3WO2x)bO4L(X60ETt28nzCcT z=kfHD)j6dR`;*Lml(tG`DjtQRWc()-WyMxt>@A%i+w>)b8?ARA$iAN_%272|y5|k; z#8RU?^5bOrJ%bDMf}MJn?v792zbuhm)cD^#p?s?(0l2EGbq_Q@q*01PQQ1_qDOI*! zYJHe15N}BrPtG;f*80`&ImsVW^Q9OvsUZc3YpvpJNe8PB9M5!wsNKKd%U?&3nMV}L_YXzY->kh zq@-lH`J8{d*tN5v=FRWG79D?8=9!VfGTZJpD!iN-!F}5_ia~MlBE1U9>4?(#=8o?i zugE$17&$OMZn=41LW{J}>vO4FZAX<#arXW0J<|7tqFadLF~(NILYOhAd0sghNJGDH z;3aA%nNsJLwQcfDM=W_D@jG){wx9PMpv{INT_O)j1N$&^Xhyeszr}JLxjPCTS z_jm3Ou0MV9IamhL^KI~v>3_k8a6$$XVaq;!E#ZHPfaGI#CSY@kctB5vo#3hv+vDut{yV1aJ zBvH)$kdCJ~6EZ6{EC_rO`0>f?(J0Gv);7)@Ad|OKAosG@p=tUH)mP%iBo%d$5Ggg1 z8#Q|^oRP#y=?X)<-PwkZ1L)?q2-32}a9eIBpkkDKPgWO9o^rjv^nzHwGrkbFcbJmX zi*5U%9|2YRVr5SEo${0WUzll(PCKRi4IKV}ue`0cqcC-T*owZ$oC!74s71!PQ-{HFE^*Bd(_6OM`NFty*1z!E-`ww z=ygI)j%YOz<1q=dAqq*D4sq?$wA{vyyzSkM-cYk>n?@hMIBzUDA87QtXl4dm0y}{G zd)wGbv{E`USz2Um-!wiK&Mf62^CqVrN{cX2I$*Vay6all|NOh1`0s#vtRXKNtzzjZ?8AB052NPm*k z#LjTzP!~Cz--G!AD)+ffr|DAwj;({EZx;=q=N6D7K49{v^5{b z8(wyzn6@&;wrwSw;VXW`GV-2>D0IcF?PrCch;trVK6Fs_Y)CQ?k#6zsE9!Ak=yQge zjuYE(m_#R7#)o*2%n-|*+O&ovlZ$!+k?H`SL(eBpDimpkLY>82a6%8+2!fi}z<(86 z=D{_Bn1GizP_T!WO^{@&DEs7Bp+`bC7sj+AqFi|~ne<*KGfL_KvU?iF=X=Xw>_ zWK#x0le)~))ur+j6!L4J#Fas}t)M#w6TJvRDG!|OQ3Zw@KJM3ODjdRm_wDRHGNv9C z#-&t#wWG);Ju%@_oAYE7234qID7P7JN@u8!`Hkmayx?10yLSdkR27H_kSeWyX}nkN z8MSB@*6|>K?FSTnfioXFRyMeVF#-(+;ow=I1Vzupgh^vWQzSA%FvI{P+aR?!6mHc5 zvLeFh2Bjc~N@y$`^mpl}?tP`)c|}-NBN6KQAEq<%Kk#ZpH(J#r?t&5&v_y z>AR0bvxdn!R}|ZWVN@V`7N({DjFqAzqNf{F$CxW!XgM7q_~Az|XfyOw!+K7mQC#7w zMAp})EaW8d8sl@JuG23(U)Qs0({WcakOm!Jm+Fa_v&iy^iJWHWPq$k#XJw7V1Z<+`*`LEA0*LCF&<158KOHEviNlc&(<{B*A9RuOp z+PO!Dkni>VWY4cCS~4?#@w2X=m)%tttP4)%x+Y_0N$g9Gu2E2ic>=in#GHQplqs<7 zV|kH>x*H7j48N4feQSzx-eFeH`HyPQ4Rs|va2@_|_{Sb6aiYlM-a z(M7#Y9tguML=Y$2JIq~K$B1r{oKHz8cHI? zUScOGa{Tgk`TlX%JYXqno|QDQ5x?7JLv`g?=3rP{8)O#o$92`e)urYU6{@2dxw>C{ zd!A<^C*veTNa;kX9oYu9&8AW^Yl+*=6Z&?>-7+NhbGLZvA|>}ANCv1Ss= z0ZR0x8a&}Cz2#x}r*Y!Ed=jrh+wOS)0~c4!%b>SZhOQ7< za=K@~IFpgpd6i>E$$c6X&l@x(H3Z6T)wC42yPr0ti1*#c1jqf&(_~9K53*+6-Jj%d zI19nG?c2y32{aB6B-24Rzf*q#$uEe_5k$dn(u4q`z8idzGhFncTiVu&A6M@ydfVRG z1V4MfFUU4oEjFFpBU7@oi*U&|*+S-QA%5FxEDr2e`t@Y+UT@!QvqG#~wkO(X(URLZ`aq1=Yu!gu}~!bn;IEGR}AJ4UJ!| zgviZ9ZRF_8gIBDsK@GSnS-U!&qM_>JkQVsv+o}YmHW;nKb)=T@E|g@&U{y4xwX)ZK zoAOK@%=J_SOuzp?2D2~IDaH@KErFr827!t9#=Q>P@8Q4>+xL{xa3t9~0ETJSt89qu zV}g^0oq?Iwqn5q)^}Pg?+-D|a*g%YBJdIxk7d7u^e6j4(&*n0y10WY1bJ(h;S)X-h z6ilhzO@Zg-Y0BU_qoVCM4FBZM zR@!Ssu2xH!y~)8F69k@|EZa-1Q2E!lk=^N?H|8gWmS6P;w0wdm^=SSa<85?E&vh`_ zeEwSMNr^YP#hOs*?awbn=#L$Qd|vNyu2xLsuxDS)_3O>emNhYep+bfSB%}^eI5Z57 z4n-)5!??JzB~Kv3i-CTYOOgr#`{mUI75u-gDV$5o52MT_r?v%Vkl9&}rJw~L0$ zn04wmBJ)7*C5^Iw1i6*Uk$$*I=UU@&`Ugn9Mq9n=5YHM@cYe2!7mxJtmHTSCtWmjg zPIP21D9MO2ucG&4<7L_^@shZ7v8x92EGXIG0Mr!)Cc5jeMG=q@38+BcTcoC;_;@Wa=1Ry-j(6iass2X~P{(&uR(h{Y(}TUwY=!8!jHqteZ+jU1;rp?x5m6$E;7HD_ON zgbfjzFlrh6wr^G>Vh-t5IVi;)a&4-vOkeSe;n71nifr0|}2@#+n`V$K@3 zoydFn=pa|bv)yO-IWQ{WQHR(Mh#zQ{H1wQT6qsJOM-zc)Yj&vf;{b|Pk{euk#l>+w z({H>!#>ZHvWYm_Pw9hr2Dz<0)2{Mv;#M1aF{-Y-NJhmnC_cSM4Ms3CAyITguozLxz zwFQdP#%3G7=4w^xm2gp-GTbtyKKvqNikZtw6Y9FWG2$!D)~Jr?3ecLoGsI$nMY>^Z=7GZE?D^=@3&oONdo51&BtA!#TI3w1s-2|h`R?f zgByY)z7q?m@cTJ(04ZB8%6}`Y_T9dApF{Ef2u1x1B`|xhN0SE)`;@p11R%RV#y~C3 zQ3tV7JZoTlLFw?~hQojGY~aQTBo6#R$1_xE=#GD|7zT5){|I#gcLG>t>GuAt zE+rN|0%HA8wTr?cdkMaG_Wi$XW+`={(xPKj{XF>6{aU2h5sfxd5DAgV>E3g<<)Z$5 z_6Zx4Wg|u4FkJqzlJyGyZnY)YOJSWotgTM!PUPnF=go+L$(zZQx93?(alnSYFEwFB zOy>%_XGB&KJRY93=J9HGKJ6`T2ccO3#K~I$A%Imsn-4=I#f@g*&LMf;I|u_Hg8ReK z>Pzwi!=XpO7ba-rMB?T7-`DeztO0t3xIWeX>nj+&gPngGLhlJIyjl$K&xA7s)DRpgpN; zUT#(Nubun6^U2Rc8Jrb-Y%+2tP2hQAXrw<{QP7xDV4`R8Bqr3LdDFO+C}L>*lILrC zVy_#ps+u`p#KYr9yS67A2P!zau`oo_LKkHCR=mZd$%V%1l6S&}=SxBq34{R)GGmKW zVgqF>^bldfU=dMc_u_`B0FJeYuX=GL^M5gS-FIq0lpbQD!Yl?ceOCC+`1S;ocF}9T z{8;#!M+o{8cMlxUoLqRNPAKRlBlVwEdsL(slxd1>?6PkL!ab(tOXqdoOn#-^t+U)K zabepuV#mGhkTW=p+vNP5P!z1M!?x-kk$L5qsTgySqMj`%L%Wmm2#^P})9jBES5@1U zfpjRsT#cJpZ|TYdkOV8qY(X)fSb2~FK->8OAz?BiUnbF%Kv0RHzm{zOf52w^Jz7PF z^l4Z9xgz|1GAuK!TMZxXAm=1#sQ1$vi`N6h`iVKm$o~ z9BcX>Got}SE$Gzh?9-HQP#l6Y%>_U|I~7C^$he)n0?r>8w156)sX z%lId+jYn^$zTH46R%>aSdkLtc(FCvoFPpK6)o zHCsMORNpr%i5};DV)Xl&ZPGK*<1(A>S|{Z>ayYv5M6B%0P=G&Z_}m7sPWV2Ka+LQv zfBL`;=y>S9wkZ}5imMQF=d#BEXg!9pju9su3EEI5khOnF-X9;;C=}KO| zrk%`=ws;a5_hplcAgpzA7rb-ejQm%gO-WDQD^sdsif&Ec z=S2Y&!{qL;L*?7y^mHUShc^t8roo~*L*7Z5bQ3=Bz~QRT=8Ehz3f)DCU=IyvOfx8C zE$vX3ztnKT*8y^o<+++``k#(RxYIB4aSFyBb)uT5;JA~kH00`ZB+O&@9y=ar9LzCT zajY>DJ9+-8(2`cJJ;;IRx6vx@ZmOltl~~Je()n7-$ETGbQDJU<^qnwKKu1wM3=#b> z8bgCUOe;Y$0fYAqI9|vAjL@gG@tSW0WuR8gVBW-6%s4WNfKwSr6IKL;^gXQ3YLB9; z=L>n6^iSZ_z||kySDCHFok5w-+x4}I5@>6haw{yVHwJgxR2Jp}>R-zcT2$!pi9Nn> z0sfjZ))D-U%~=aE1ig0^_&KzYZ%L-L7P4{XHhVxK^MWW*)A%by$J03P7%h((4sxY% z2M@|i_h+uuF*G<$ORr5~CMM3}6AXSuuEg{zRf2wTx?f)UXOy!)&8#n~PsLiGNRgNC z2!6(8<^JVY&p#RTy6UM}w>-?vL-D=Nz`}{O8}svdKvUU3BqeS+x#)_LJG%uGPnC zNKdV~Rt$5RsHu`^efBzN=jQYu7|#?xOmOjnX1AhyoZxx1?b5=e&6N+Lei08Mz5*(N z`|czRR*=jL^<&^ow*Xqa%P%Apy9=Dv1;>{cXNi=R=u1j_nzm z%#-=S$$3XT? zA9<{~jz9HS#}{67rIDIAF+dZ}fewIZ5Z4MelAye4w(9uM+@it%!GGI~u$h(O*$6Td zObx-EYx}!0Pm5DUMos;DEU!kMy_wKvHc66xepDM!{uTE7m(-I5^GkPvmv4=(DRu@5eKM%W;)p+*uBrfRkQy$wX`b4(;YRR}InR8RV(NTy?rGr1ZNkyRdizG0F z9)*MhSdL-72T(1vm`grAdsw#2(l@j~lB02~cS~5Bph2_)vDv}T~bxtO7!Z+TQ5CXdi6PDkz4aJQC=eIUIy}6Fr zr*A{=^nlET+0FGFzYT~DBNn_r%ON*oqfsZpH0-Yve>;eF49k;b-Wd(~&=Uv3LL_3y zLKFd_s9~_2!w@dwE*45nRVi-;2@oX<-~mlyWQgI)-p;S2o<6a^=!;@|!&!~Rzb5#g zSSisnbA$?Sd=_eFM15`XcV05T8k~Jz+mOw9OZ;e8ZqiMFxtyj2y<34%DM6{i;cDn;3ReTfLBn^h*ajWNiHJq!AJj?`4$E*x%C3viw-{R)C243|fg3}~n3{2TgWn8qLoL%IP zPU7-aSd2-{55sFt1{vds#+cQgV!z$fz(^T0#CJS+s93e6EIxdx&AN4bU7`Q`=rPmE za{W0IOKt&e?gT>8LvJdB6=y#~je{B+f#ia+#-_mo@Ab?7^8&2+R=sZ~UO^!qYtXK7 zlqveE1)%EKcf@dJ;O}dYOYrJDV?~ROyX?Jg0UCSf^CBQ7Ks7%J&NEw9(^;x>lP`G6 zkviM1VvHZAsi5oa81Iz5;t{+f<1>OExL|41g}pclSKs4kJuZX2g#C?poNg^A8Ar%k zj3pNRqU(kMaaw)KOL*%2$ye8=l6;`)pgW3rF6&q2_2dOpugEY1K6*BbJXYy)^ zO#r`vAN}*il+j+}b8+>f;!#-4frOIXw5a-882`K~(R+D6L44JVsW5TkjI2i=ES8U7 z798hz>&1(>rV4_bU?%*YfBC8|s7K)*NHzmj>lMZD#nie(n7T2V6)n!Cq*_Y$a5o-3 zWl@L#DjcC>?pkq5N)9o5hPjfj+0szKef3P*Yuo_$4u&{8GV%Oc*ohQ{-CvL3l(Sb` zM}8$|Z6`;IhTNAfQs}Mr+u3iJ>a zzuu)8JnDE5b@kZ&X#Qncn(nXC^jAcC-38Ua5o2{^(6YBJb+7M(ea;BD7^+lDDjS`R zBRv}x(o({%6MI+aoIJu`-Hx23ot2JX3G{7kI9{734ZXNYJr+r)NY~ay$a(774h%y8 zb;j8X3Gn(=>XPGtE#Y1j$f1iGD@NDWOq@mKIbOIcw1`}~*Ufw_VQX-Nx66lYmhQ#_ zUQ|nfH7LS%^YM+02OAVJ=9!&+6Jc-vXzPzMH`dJe_g?PEi1=j4p0ddn`yDE+&>*8{ z%HChII#;`T)cKQlCG!$Y%-s#Y1@-FR0%K7O1gaaC>T1(IgQ3-IP=)B-PDN7MwnrXp z)OeXmw3ISc+tA;rjHJUBF~4T?-ox%~x+@ASZxwRtW6i6*C7t(jRE1c@J%PtOX#DMA zfPLK!c6Ap320@UA-#B3s#I(!!1<^!2l#8$8^4?+{HL23tkQ3#J?LNtuoUOqdcSXaW zJ*og&fDQ@1?no?M<+Nt(ABCThd0y3q8DmlY6Q!qGyEOD+SrOSr?)v zg$Rq2h$i-(xz6d}Av+&7$cuT#U}BUP;$}aK7FRU>U5VxF`5m4tnz2G_0i^|7jZji` z@qt_i1ShEckULUa$8u_WbnW0S(&?jhe4<;;Me!Y1* zhb8?2_E>F!9x|Pz?6RXI3q1&CxC|&*#V(=ZW2oZ+6!j{) z%^zgCN5oKT!ES=9G^}_73k!0d^Se-Xvp;;7weD6iMiZ^QrRO`eVt&i(RM$k1 z@ehH2>I>>cPpwZ6iU{Jx8Zt(%s8u{)vqa&g?2wqKK z5^Y8mJpM^0{+7EIK3NyhXUj~kP{*8RP}%hL*-!L3{2=JTf#p-TerMAw&YghuJ?a5C znPO)~(xf8dAk|n=74Y&P6oD{dBP4z-5T(|um>U=wMOBE|3h^Jb6@@|Kcg8n=pRgNZ zY+?U>BGQ&xmM8ZxO@k)l;UehVP~UvoozAWMyrR^G_i+F~(*H8{IYg97#~wpRl+ZLT z@mJeGs`BB8^t)p$;k2G06}Z3rI<9NvB`sx0g!jzRM;V3qN#1jT%N5IxXDE;I^g8MM z4!6})60IPv4GrmEu3y#RpWIo={oyxA)&*XN3C z-0M{}E2ZZKYXVE>x8yulP~YbGLjEKbrwSPzx9H{w_k44!8s!8CrEp5@8sh~eiWgA4 z*PAtOF$obROjblf94^FGIX?g^{R#}wP-2n>wD=45NEIu@i}>CFIup4WEpT`M2@l|A z%_s9>gA4X|A4`-S&I(>xMn}EQMevA78e=1iJ%=Nis+ z`AWau>W!<6&JuS!wXQ-b7LkT=^lEB5U$UD z0qj940y*~r-MB$jj6?|Fv)dtullLv^flxRELH#Rwn(Ij z>x56t(%$58+X=37v0giCx;KGsv@&{zGDRuqkE{Dy#ADuP6=%53S0g8)VwcqtF8Rr` zp9T1Cd|j+D=kSM{b@PqABQa9EqS`^$zaH$ZqMuYWyfRIrmQq^R@7&~X)$e|w+Z|`- z4}Vbez8-t;ESNuQXuh}K0zjFK2X>-&W6#+Hksz#^QCm2PCItH=s5;dNz~a|PLmWu_ z>~@opBp~#Q;Wowkp#x%EnkfJuA&b5_8X8$zcnd}auq(I267?8%P?1DjT-Z0d?eB`h zOI1aIw7dkShY%ZR=2Nw>DwYao2{p&H&AopEqQ6ldP3<(%LsOJH&F+8OJj>5APzvRe zrnOF=cyXBjD3|z|zkSs?$=#B7Oh{Id&(Qm#Mgij3bhJ5|3g1@~Zi^VOe3zFs>()NU z_U#NI@hEzhO|_~z*%p1GQpMW0zzfTkpjtnMM}~?LlKZn#eavBKKxy@TEu8d+dYGK< zFohh0D5N?L%qE;>2XD?)u4J#8KOXgLpb&L{VKyDr8yOoHO}fCJpQo4jw3Y#0fgY z9k!1T1p|n-8d0!ZVuDt%@nz~KfQ>3m_i9k!LR$B#Iey>}_+A|RRH~gJO&gLq#EO1) z#{!MMVHJ%Tg`ZcVwKX!$mqNQ(wPP2EIJA7@DkwbF$a0uEs2Os}l_a%jIAUv*A*>+Z zXwe1hBre{Ex&Yt$>&UB(E2cM`GB_Ui7nyJK=s&h`ie{_&VB9D9$37YGANhf461&?O z7N}u8jS-2{%FkM5ed5hJ$G)YtzEg`JwaoeL@8_C<@7v}%jR>J<1Uu2Jgueq$!HofG zZ|IlKKJVKHWf2Ab*FVn;4#_)u2F|K)*&2`nT6gb<8ln3w8HjS^)tS1OAUT78X?{jZ z{)0cU)G`sP@vnE!%T7*BH8BXb+lNd;C!^j^4GOx^L#asj)awdKbItyBW>-t}Nl+rQKsH z)Y{uiXZe1`M*Muaed|F5*r;u7P6RJ*o)E6U5#iR=fc$#}=Rn_asR#$;sMz^XSL;m$ zzbtD(n9Yw9)Ys-;M0TmPs2zf$7Pfj1y6QyneLnS0H2HeDpJtX{lI@3(;;=7rB)e!F zsZ2X8)21)v>$RGkxlOX2iIdb3P$frJJ^0LiGgM=3Vgej#pAwMJAMgWg+uKSPEA`2y=$*n5)ax=4^hLq4Jonq7TOZ(F=Zw1| z2lbk%gn_+F-)C3NlLFGfvh?Fgg(XcI7_Q_3AiL1dL=yx>;V^3JQpw8bU;JzNC{1zA zU#hnvg> zmrU6UJ|V`jK9~FQ`^KhkacI=H(5O^CF0 zDAI3mq>0dOy)}{U!VlQGoe+h$DYjB{v$-A=Qvz460D0t+JEcPKFf)<9p)&uK-YIb$ zpbC?FQwyUPHMTE&VaJ!k@~B29lErA7f*dWaWRIhI;nxJ)f4?j(AiZGfORhjkk$Q1(a5?cbi0t48A$V?02$d3o*9I)egD;;Xl}W z7|;pvrY0VHjo>q7BDFqv^cKZJwe+G66S5Q~2MR?vyuNC~m?9YPeB$|3z-<}y4R@@w z2T7(H;=`jkxglX4%Y1V5ex+oiTi}wtYyBk#x&QjB6@oTW`EMHumzi^KaPQR8T`!kh z)M=FBIfdD!qTH9-tMNG}vA>gMu1;}#vhPas8;zA){qAzuE6!e4_LzL~jr#1b{42tC zdDnJ4VVukByy#-#_#afW-|<^1mQTb}f7|`*+rq5qe*7+VK6O>71YZ?BWcHlxnUT&# zM}jYr_L8?BV9`s0lZ45si-yBqtSB1!C+=#;(*Fw72_*`C*m>>Lxv_M);+15>U;@rZ zJgNg=f<6W;4~SGo$PO6h742F=I;P(|edlHLn+nH_z;ZI0m^0G#M8OOQo6U0sYP7PQ zVhAV28j0Uz_xz)ahz}erc2&1%PF6(BSGrvbiT;$0OO(~bX`t8ZUkVR>sMnshsEIzm z*&=dr5xQtU@Uh!J-7ne@i&+w5yjZLQNTE8Gz*)pdv+^(fzk#7_mZecy2S-^;n}~Qz z{!9Ptd9ZL`K)>Bxpd;c*NGLZpp~9Xbw4E6eE-!=72Py?!k3 za+qM7Q1Z7LjP&dZzyin*c1s>|OhG?&r_~FV$Q~VuW)>x3rhSO`+$|g6jNuh!N1 zofhF#owGv5+~tI}O;-=~W&Q*QdnYV>y{1$Ik=(RoN_stxX2lX=V;iD%x-Iw|JWsi4IrqbB+_&yU8^ZJS#@Wbky5Y+J ze5^=~ijwou6C;sNMp#9U-gfnB zczkLQQU`OC52cXFHJo(vXYBUhs;4$`s_4iQ49dG52Z145Rkoqc%3Tt7F^q^|!p=1SyxK zSH3lhsXR|1kB4FC4!hP2Ml4lLq@PW*`S8CS=VCeL^%fj^iSwGM?5}WaxOf{>op-aI zvaXfI_~%UAUf6fyvaXAPiD!&^UGG2;AR3vlnlJQ4L6Ndo1(4G1rF;qS}i=;wc_b8?kcdg|cRDPMiL1FS0jFlxOJr2xx4;VmETf*n%R ztbgb_yNCpY7PNbql(Wi@b?iMypB`ZKjeh04ikm}!ea zBJ#DlaPnGQ@}>onsOSkL$U^!h`TK1-loEzK(blSUUFVA;_I3Dwk?l#pUpy8Q7CyR- z;g8+{sq1`+xGaChb}&gqf7kqnFCwj8*vj+M($R-%`iHM|+}NMlPlK5gX%QN4%_xY& zVhMj0=j{rVXX*JuR?&R1o((CL*^3%ZlB7Vi}=R(2YGn|}vFPpKk@lZw2d~e5y*| z+EAVnX{Vwgw_{?(U93O! zx44<;kQ{>vQJePH)U$70M|NwX2Wt6@$Tthel!7a6i9+lQpw=ke=tfBL8sRePXV~MG z7{9NI{|?CfvDVSK_pTTpWy$YVQ~u+T`G*5K_I>TZ+UN2`s}be7b&n|BP54l?AZxev zE@#YT+v|0C!%J5LZOivH`?&d#k;yTSK^QAp&DXPpYu2@%<#w*0b1%bl|NIuRd>ZmW z`}+~94}u;i0GkY_taNscfEL}dxV%OK-|h}v0GVa0y&^((LnLT(;HbX^E5c<8>TRX5 z{?K7uAhsVogu?!f;qI@3GVgkAQ1T_bCc+62J|NIAG@S879t%}j;0PeeCm%qX73`RS z#VmWcqo+l}Y`I3)Z*Y;oslGx;Xu}aoav&IM2>1iI?`>VpYN^2DvyDQMqd4jUqVGip~n5SFeZL$;-dChu==SM76M8kbPI{24| zJ|2@Z>i$`*E8q?)HK3^twdGgcbNC({cxCkGfZ7nF^IRN}Qj~D8@P_-F$LolNEGrVN z&Re=H^29*UWlGjY7%LJ|ZpjhRR_+f=2=HCZauo zyvGq|TMj7+QEUM|*KX7eI*p0PX5yI*eU*Coor(!3ic@rznwqWL+Ep|qt6S}2{hNX+bg+?+YBJ`h6dkzcK5o) zGt!Nw!gEtuVp<~%j{|ZIUsiuSJ?OA8W>V;qN*u^0Pv|9*V@22rz;3pHz6Me+e|0{1 zbS>~JK-fzU?SDs^SFSmYfD)rvC3YjEZYA7no?xXtTpDSON+pUtpE28%HD2>cSAmi>}Y6-hvtU*GZ!VJ zIuBd_okrl-c=OPZj!eF+32Jcld_IgQP@5CQ7RGRTu5>yqh3r)W%n@_e*!7Pip%E_wF0dqunBd3#>^6j}uEj zvgN;aC4_wQy=-=IVj$p;)13Vn1Cw~4T1wqaYC&++sLM`SY^&Su&EdW9B| zlkSnLH$kos+-%Yh*VB$&Jvl7I?}*K?ySj0NaA%GGS$rdWq^)GCy8-Gs8)qXJyTlvv zN0b=|)IxK5O5bMG*pbbInzj)Y|4EtXlY)uHp- zC1j`Mj8IC;1A)f1XOn*}6}vW4#I7f@)s!oK5p!;?5wbrMQn^lv%nx6Fl8SrCeQ0s^f1Qr8eX_un=TRG(ExNY^2h=$I9V1WdfM6ZtgP(I&GX*3*K}6nY0a)rp zNgmnJsnQ0CP=fYZPIieaUTdQSTR3IRSlK{#S^v9}D96KtE+Oc6B=*w4vdtcIS}BZL zUA0VZm7ml~Cj+eVrN_NN55$aa+oG#bFUX92Lr~D$|gg1V_4KJ_zghvOkfXm|~sP3$}soo8gOpZaB#GS0}^1L?$Fu z_hvI)H>+2#qOYYYQ&!pA7diN&utH76&VVak!iQq|u+i;+mdK#sdOPx3$^{qO!Qbs% zYFvs&=dvd^-H+at8%>Jwg)C2c8`H$?8i@wK>rM~~L3)^?q2Mpt05W%WDxlI*=ZtlM zl~#Uf>h6kK#F3sbK~ew}^PL&6>$AS)^;sK=`pq~%k4Mfm;iW74*Q$Jd-?1s?6a33B z{JYgUBi9N?=q&}WNOPzgPm9LxsJQ`t4|hXC4CRP|8Hj^xS= z1D^a>(}DafLT2Fy4N7^y&PcYbY~iDjp){T3dE$apy!!c41K?7zqRDTgiVJlR1s{>v z(prN;VJ7w%%I_RjHTNL;8}}55vj7Tk!c7^X!~^IAq#68>t?OmpEjkk7Rhz^0tKd)e zCR@KIX?mUo-C+XetpWEP)P96G-Z1$p)_eSmRF7}=tsQ#dw%E+^rsWOdR~N#qKE(QK z=5fNF_^`^}kYa@7Mb|QP%5Ey93fV8$AFQ00QPVuzdvEbBfn5gogL|<_$B_V?tkZ*YItAKMGQ;DUp9b4f0#5bMl6{DqW=;Nfr6s&P#}D}7 zOYmlmJ7LPyBdkTUcVrzO!%g$cDV2_f{l z$X0&3p&|dof8$UOFPQqoVM}FsNqzOE5uQAq?Ez{3x62Dr zZ_`x&i#X-;G5N-m%VJBjac5Oc%f4~8b+_O+ls{{j|E6f^^RSoG^~jaoxpQl^0Mcu% zTR>gjr09F=>!pENC}7;5)GGlN{#TXJSfXZWE3#a{yRcASZa-K<0Xb~KoB4Upq$-|R zsvV$taZNS-o@;WV%dj8|v0MxBn*ZMB4Qz$_35$e)7h5pDl}T;#Vf>Sw2Z52K3nalx zhV7g`hFs+CsB_jJ)=ORW(qaWO%z!WQ`g(hw|K|nh<9}#KQ5{q^F)%i1xwd`GyTeFE zE#IE67kOG9Xl&uAC^GcJ_jCQ_e!|-lOP3&HHeZ;rj$OP8HzlP`7WbE-bo!w1ClsQq zo1K?%T1EX+^lEQoNEOQcggl`w;j(uli2Scc#RbGV!R_HxVw9!l+2x*u`4a{5z*FR@ zdCyrP>>8QUDbaTt;HkGU)!$Nf&L72OUOI05d3Q3lkWyen7OqexUh7r{J=r_5a~-94 z?U)k2Mn+u<$P2Wq{y&<&GAiot`}#9M*U%{;jdX*=0E(1?(t?DP3eufJE1-m=k|HH3 z2+}ijhcr?{cgGL|JbZua`MwTMZu8Fw%T$WlnD$wQ!BEmS_LRiyRwO zcVmX@g`@y{k+`(XK=_jX&ZYEUg>finTh5X9vzNG!4q^Rf?I_rOI>xNVVod^7w}{IQg7ovbuv zpO8WRCtMAo!X26;VHs`CT1(~+cKi^?C>u@F2|9Blp42UL3&Z2D#6_{kaTL^dgBbJ* zzWjQqx!dC-Y9#Q}zHQ0;*|Vs1mLOB?PVz6p&gW6&CtkOk{*+{AH9Wq+Rc)>*=j+Qo z(nRb%s;b)1md>Hzd9SHzCz6f8A521E2q7`xy;oOb+FgNM<<8L7C;B0Gtk;w0D;`#r96!{GQ@)^@88f(4IMr9VWcYcP35zCob4Ntk7$L+FzTHqco(H|(`H3IbKbw(X0Ukh{TKP5=YGjLo^_#<4^n%nfJAT`_`Sg9K=* zuonx5E!aOaVfub@0ICR&Ec_8T%;GWQ6O>+V_|@l^Ydq%WkmdO9r)=S;drMyiC(XZg zr@$k_x=X)P#jkTO1!VSfIfgjYpL!&2$*Dg${b!pqM=A$AJOCUZp@fcsotL``CA|UZ zb}XZy4L_VF^Om?`3ul+lpFdUPRM0BF&fb`Gm&EyTVcfKu8$^pt0 z0ktQ0+~h zEQAK3G{K}+y16R&q%@fD9Z5{&r`Q@?vL5Y&OvgSEH-5F+tRT{#^9?_?N+;dENEqn7 zd_vk&ul|LE$BoaMcNoHkuKtHoAsAuSTV=1%4sCoQ(|$fqr~nC$Ejz61u+X5p-_EJ| z@-Rg3+8`^L(Bi{*?vLFhhX7~-P6ulQyohJzS|%i`qTW}cdcPirAu$L+_ZNoV3Z&M& zwtb`N%G`NOzn(>biEHN`o*=YqGQ3d;ZeaxVRjAVeVVWR+dSS5Xp`UX;o+Ab0K|%rG z4PvtnfkXyTMu`c0BterP>h!PtYdPbH)HhVUX_ZY}1rq_~7J^1F1#w4Ya?bud(ten- zf0uohcAGeHthFo_FCPzc|K-Fz_+w?OX9ql)uMhD^gIA!SpJLg>50hfrRq}Of4b}t7 zBq}&`yIw~u*dZ_VPxrP$*I4h>Xc_!={c!{$uMd^oQ(M-(N{zwLuXjDty?_2ZCpPIK z#?ewoIF_%)E~IbiS3fw=SSEv##%=Y_)mU@^7G@0-!IlO)Akf-@_BbGqxPR%zDk*eI)rXPBskhpE?Urpb}s z^Wlm^{O4ODF5q>QosAi>6M>l=Z>UDOlDb0sj7joMddi`HFH5;dnCpv8Zbi#sI()sh zoVy{LG@V*xKIaD~%yM&KgIx`vN+Aql(1*Z=!haFx0^)76)nCyB;9sx1;%TP!`F+4tV3s`zf8Xq3XDH(Eu3(FMUp^1UgAJGKy%5*0G=9z+Ca2vg z>m+Srq7re-z-6v;tX#)##}d@d6vz|!WGVBZx8veGZD3&X%Hv}7X~sV|YN!X>#!oMN z5N4?dX-Mr=ZFV%6@VnF?CkG7QR{7zmzY?z?I%HufvBid|_OeKLE&f_zo(w~octew< zND`dT5^Lkj_@Z+1k9AJN=8WFQ6jg?nU~$06`NJb+hxIBbM-1XwrWBL%8eG~?_&oWs zWR7-+5hVoS@JXSO-4y)hZZ1?5USOGi0c&&pv}_Z(=dn1UvqAzPh-YIXT{dj{ z7Dt05w>!>fkqHsBt5GuJu)W@HBQ!RmW>tpC7efgr{6FMgMtljsrtDon+*4uH}~h-b=MSt zZf`9qWaMS?#_?vy1)Kk_vElc$M&IugFi9E9q+^#w_{{e4*{{kjyM%_J*Yd<5bZVZT z{p`A$T`~?DQ)Xp3k8klQIHe_obHMokoLG%RN0`0-geODu2INH5s5OTzoAjg)x}^w$ z=;FSGR^0Z9!2Vr)1uqGthRE1?;0Z4KF3H@)KsihRghCG_*_v&PdDw24#E$NT-8m7A zut;}=kUeAj2mCTyvPa}ixDf0OBb{C3zci&i!ULFIr}|5+ch%MES87k0Z;cv>q#PT5 z;R2e6Aw9hBG2h9Bc?ycv9KZnv9Sf)W_~)jO~C*?!Igo0h{}<(lBY5@+DJU1S@srJ?MSom<>4r zts%Wp&`39~e6{TPrhEXqCdIsW)GX1&}B(daitTZp%dY!N(=L9Oi_JuoVi9N9E6!<5z>pAU^34 zbhs2}hpa7V>IC7IF`W|+Tsl73w=;QBY;sP}TA3xoV?KKlU@?|3EY)-PIqCzEDuOpm z-iQDo{K@RQLZ$L)^+T+)KfcN$J}Sg5WwUJj%N~>-H}-biz>B^hfB(z%Ct*MVhv}G- zYW3|tU)7ABZFPSC{zFNB#j=3}-#*hY*`rmduqncnX)cywqdBT4eJ@~tY0}~#7j#D_$_p2P{Yis@$1A)Maq4#x)2|r_o3$7$D$X&Vm9SPvsuG{Zq8n!?)7hxRdizY5 zVtL9q75af3XcC@+wg`TxW-~l1l-n>v!KLBABF>bWIcvZHRQn)J2|q_?+UH#hf9+S= z!p9CKD7@b|EWi%@fA_%&}3Ic^--eLhJRPzig?i zG3W^?^cJ^AIkl>y9KT7Nxp`vby((T^jaZ4#i? z?>?@Hb`T(2Ap8TI)$6nI7C%TbyO2xep`dEf)5wlB>qKjl#~y`*Y}A)@Ka@39CH&dt zfGn1K)jPG-j$d9sowk<$Lm#!zap%UEWTrtSv<1bx3-M@)oc$v)l@ zF(z{9LQ@Yeb1D@=}h+87l>*XKK_R&3pVf+uzFXG!`jsCj| zf+^}(*ZS@haDV6Hhrm^@v|^T6qhTl3DPq#s#+y02Y$hP^LEZO?(5_SXJUbO;bmSaQ<#{tH#~n#ivV1V7evFL&MPhII4i|1# zxaEdM93~xs#0%`GY2f+R>0d+hG}X&^RU6%a=OC%Jikf0~S6Y_2@EM%aCL(USm*FAu zTg+=V%%i6)drw+_YqGCmc9cU*{54ut+|u@pl-%5^*D$bUOld4;v|LE=8r^BCTrrA8 zrQL(vi=s6>q#PfbE5D(vj(jbDRm%5rr&wPEs1;Z&o!f}#Ic4e92+hfB-Oonr&^D4g z)QJ(J?THhU*fcE=bo8&s1Zd;LEi|nAEmHacV#!CJ{9oZWCXWrjjVja@AgM(hcm)n$ z2!8&u@IA7ATdB7n--2*aCMMiX?i8KEyfm4Vop$}V z7m{s>w*-!looT^eh#1D1Hov1KX|KP!bfJ-lI`& zSAR%{Ga|4W!yT8;xs`|7W#Y1a)*90jAzfN~)W;M!G&wY-;HnwkU2vZ#h}o_3bMNg@ z7AfB;j5JPMIp(1gzPGRCYuu@4x`cftU=UFGB){QRTUDIq^BLTK-@Ev?++lGE_^ z;AS&Q)80aUz_8?*0iwbbXZhyq;GFD|kUjbxm;631K3(^MqG39odlQ38V1WVsRTl|? zABz(Y-)-MLX`!4Fmv_&;zX;>z@l;>U-L8gSC5g<&wPg(*w&6ig4Jzdx=TE~QnJ-ZF z#Co;4bWdhEL2_b$uWWQZV`p8#RINU`_opufu(}k@&(BtLa`!F&f`>!F(LIcy!?j68 zl-`=1H{&r1Mn6H1|Glr*QjWms;9-?&H)WK@@%POk6AQopeDVCqk1g|8A~1@RHvv$c z;pY-agU0A}PKbp+!AeJYlsQl>V(tUHuL)3ufbbg+snShSzQ3MB4cB{QEIXes@OBG& z<$c>L+5|*^)IMaxX^K5?RKi#cA1CTHd*#5Gv4b{VcpT2ven~xh3qO1~r%dn!fz3Yu zm5Lww$6p}2(eu^_GcGK^?zspe*W+XZx@O++Km^M~G9?B15#D8@kZZEPte5~AW#BNj4(;Fx_ z3f+&liOT`KHP#NHhYEw?i;_4bBNhj^@NNd9ehFlc@wz-nF7rsqSuWX|;`zzBFdbO; zJ{uTE6TSptuV7&6|8`A28_-d&Y( z;k}jR%ml$Zl3FYsRr~wnllrIg^y6M3F=GP;FGVc0V5TC;4JX4JgwncDc3s@3U0vz-2@ zO^F_rx6DPQO$kynGw`6!D{^IfsG}p+A~~@>{-cLF6&5pBhy0f|lESo0AzweE#o3I+ zvtXII*O-4)P+9O`Vlu!S%YG7ofhh(VgY_=g+o6bu*`cx&4g^$*0bqQo;4r50yqv8W z*NW)p75?848k)JR)?{G^i9!!P9pXDB0{8`2eL02FFUlLu-%>TA+>gMic6To(lI|7W zft11%V4aa)JahSY{la@p)1c42g-ri&ra*hQ$}X14J&(BKu`iTHl1ssAde)Aw4q2Aq zQks#DQJ*v^daHBR8d0kMAU`7gnv3d|MvGT{wGF_NLMk~d?7=M_)=GC`yja0}0VQYm zhOg_R_CuP49HZk9PhO>njEZ{lJ-BpT?Xar1#7niPJGv8zQB{9o8g+ z8X6QTd_2n##w_Jf4pKSjeWi zZ0+8Ps7(&HuEw4tJ&P*>4+3}s*@i_4<~0wE@5nL3-KVD4fn;4ANAXP8uH3-EP{0#+ z2bl)`zINk5kMGzv3vN`@FFzf14Fv_0gtKm!Y}<~l%wGXCTLdD3>ib`ein;AA(-URkYw_quOLS*UYIn4XM#OR`85mLbf`n>_M_8 z47|^}LW0WPT0xVm5uyoRjN+M9#A@JNBQJ&aE6iH*oQT8#h1-l`%+rGB<4LLtgA7T& z^)INN8Js<%eb~wM=*Vi7%pwcNDyzlO#Q{FQ%w&qKkC3n-fh5UX6Zff>-DvLLD9SGi zmu&SV^ZF+|qb96Sn;q{mTTi=b59c#+{%PLQ(Y&S&2rY3BjzHjEMR8|wX=FXvVw+&* z=O@l2Ks?`j)?@ONpt{Ng>M?gtYD1m!X&l4LYIFyohMch0<-`L|27Il1I|=V~Uf?hG zd?(kjt-^m3XI5%#jn>#-9A_b-^!QMM!_I0AcGkjm07ww<`y9Ms3G%}11r!cSZyTG- zPr1sft85hq)m=iI&!D}5G2`UDxBxA{O=ZBrqilWIOcxKyiOZ2f)7=}bdGi+u5Vxv1 zZxQe9A4e)qwR;H$nWTaa$b?MY#HHVra@Y?v($(lCfCplynyXnXe#|e6xOXz!+EiQ? zdCc-?oW5hSs=jq~ooX#RJnHZ{9-6XpU=h*%cxbi0Z2@SJ1Q-3pXeQX~XZt`9U8bu} z!C!)kp&0Uw^-rK3=PM!`AM5!#f^@aJCNYA)JVnA2f%%{ zID)qcgzEH`6a)dGX(uvr;2}#MxkyXrRD@PxH~0JEpHG60bM+AfLP37fbu*jViu#u= zkUX#8q1zPwcSoTb&2xoiRBx*bxyLnfiU(=z1zqfG|4NJ<1)CtJ5Q`t>0);Qw%F0RH zF7KJ%{@5FT%JheoIC0I!qq+7~4KyGBUA+V(n@mMSD<%egd~IyHK#7U-_!JeSKn$Dx z&K7$sfctlKKC^$2Gu3HOLh-StRf3hjqRx4l#eLJqa#;XMn{t}YuIf#B z=eDZCwP-$5FjIEjXGXXd;K4OWQx+i$mx*o0MO(&32xAes$x3hhsk#IO z+_EFqKw`1}=W;sqB3$0}RLBDR)U6QWqTSP{h=WZk(yN+kjy)DbE`$PhS8}NRe=RpH zHG(N`K>f5Yj-(eqDz6KM`}C#;U1YZ3YVrfz>PmW}uQWuna86X@R!`L|GW(QmEr>S^ zFTl!AL9uKHM!^U+{H()js-{*Mu(YH%LD~ACEpn?3Y;$2Lli3Ej({h5%RIY=4e76AB z#GyAF?JdZ{J&t3Rh&ZcymX~oq-pC^))E!Vb$~gC^>a1jz#43ru(-JU4XIS5nAnf4w zq5G*s{1tvl8vn%JibULGZHz9~`qnE3p6iT|oBR1KJKDaT&k8I`h<=~+`)Zf!;;+t> zhsE`rB*pzd-L5GTfJqT=rQWA25)M@<-EG?h14T!cf%ckUkRkPzV*!9q+wc z9Bc0Xo&Z1kACrqVaN)wq@#c-vW0mQY!)xPsW@HV`V6P}MpR58<6e&?HV3beFiF zmhX`M@reS_S%uD!tm&O~_E2eKjV{19In{ zE!d>9d^u)z{E&2I9n));T*IZHS-WU_&-S9NCz_tx(AnUbD+o>dGHoZ0hVwy2Wrk;q zJJ+F#HLq3n>Q7J<; zDm?v$v?tYg?L;(pP0Wjh%hRs z-T63H&`=rU^BFdjRX+TrLmOAI%wL&P(Re!A9Qml$ydieMkJ+2PbzE_hVsppvDCd#T z?Df(bMnFkh96z;hD4@x~igzA|xD|s=^fv$pcof?IZ4+~Ad98wK3o8juc^jT41Iv_% zz>#~vR`q%<-n`F3crM!M|F{5fJTrI}CrpFv$}X~3F$_ru{vr&&XvBdSSAZ7j`A6{= zLLJRVOyC5sS0myufR){cMRB<48vYmG8ICo4tLT!DE+fe-u}{K(*O;hE?`eIwzVBu% zxo6!&+6yDoiKul$N;3v*N-Ew=DM0FmhXNzsMXsJ@-~v7VFU>O~pLR%=7L{zwN-jnj zJsq(ox%p9jUuc5zcWs47n5blqbm5AWvr^3UojXzOWq6f2+Z{3_iodgqQy!z_`Y!W_ z>*F)qFE7)!P5A=UqwmwO0vU8;iR6nZsL59)UE0}!TPxP$ID#%mt%LeRqo-@57+cfp z)ZyC77TUTcJB3@qMiBJMrGxU$=uHqB*kvEH=6C18tYfWa&IR6cdyXtfF_(P+} zt_oow?;lgRlENr6N4Ep$R!f}@Q53?N;ZZ1qt|Fw9O|_wIBxH`seWOHyf+-t*vgwLz zFtMw!tC@5G>sq%r-u!Y{5_9GDf>6tD>^R4^UW(aPD@e#V zVzTx!%kV~yW=-swp6O}IgTT(S08Ry@ShFHTb|Cde8Ak$yS$rhY?9-5y;?!^z$18R# z2W6MBz&U5a3ANDR5P!n8@-f{19KL18pF_?HZy9N$g@*=@zc>(j%>*4r_v@m!$-J>z z7ECehZ!Ia}UP6rI)>E_Nd9%}GgsuOw@&ArAI{1W+XOn?FAgkmM;Uj;Ez#T6V=}~`9 zRX`V1kfgE8A=XhL(p#l&mG1HRjvGeCjH*2#zFv3>V3#J#lD}02SU=iNd(_r(XSMp= z%UQ|mp4D~C#e?6^sE~MvNeG3Q0J{5FYX7Q}c|k3`KiGC- z$yVo15O)r-BC6oAe(!;*>FvT1-r7UQTm1^O-bc;UfzYSw0{nVyHZv-HFi>!aGJpGt zaq>rA5_<*^#aEO2zas5er_O`9yfq|D^9Kn*G+FX;Zlm{&IJ1D@(EpU<)<2UE?3_6) z)pC%+=@nQS(;8>|bhJDIGEOj$w_Bl5Inj)=!&84pnq5w|V*!?Ib#QR^NdG!`{)J4D zDe@A0NsCmle3$8v+xWt`I8Dz`OSY$r+HQpU{jt2w3%%7UKQ*PP@ZumALTDF&VOxu} zw?1SpIg=m%{o8D@>ph6#z~#E`T%kqH%`uRf64j0ks5~d>O&08Vd-3XTOkW;u0VRd? z+TT7g49DE(?^sQPD&OgZe!D*eP(G?#!Mm96nkWRh+8FTFQaC5K$+q%}Artm-z zleP=kE#i-y-=gUuo2lvkJ3Th2W_B2ODnSN@3z9v+|9K8uYa@@Pr-9paD@-h16B|QL z@9(x9Z_5L>2d4*ka`4-&kGydyu}x}U`Nts2A^IWb4vv5Rj^5dBQ0=hUA+5}AaeaO6 z;R3DTuPgGRG%Lu|Ua!pzX5eu`jp5M=9(q2e5PFCey-{Pg=-oE2zg_(vvSfZA0_S3x zmTv=IeG2}fxU!+UIoT(ErzLULs!^!Ft}Aybu+L6+UTSpPR1wasNCaA{5;%2iiQ+_r z(O|GSswBgOpx=%x%olRZSy?^`!^cHPrOT6{A%-`ctG+T> zNb}a_&W5xnlw$cp!~uDJS*7hKnlRSbx{){nTT(QJ!nh|vE*J1vsWCfTb~b{y&)q8fk34}!P*<_u)buP`r8`%Dxx2i-}Qas-=EFD|*d0g?@DZoOA`^HY@ z=zbDU*!8Owj$Bpb{6B-3_?8?*Sm_&WLH?*$d)uqWE$!s$9I^^6uYnv6gA^p~UO-b@ zH49X6Y~Apc+m8ot+wZSb=D+6C9!HipU`2;IO@X}Gt?8d;kdgJ0(%`S4ppr0pUjJ_GSS{z!}Vv`+*ZQz6*R2362@T2K7%8ArjP0y5m zv(G`XfF7Ne1gk!be3|Z6(s3S%UBPJI?8jz76_Wj=}fDns=>` ztlo+&Vd)f=1cO#Co}4zvvRGj`I;W#B6n_$;;Z`e{j&PZqIvoJ;{}y#?;s^oSGw{xr z?k$*0H46$g;ABL#yXLvw@JQtZTy4mrucm9meR=$0tIf?vVxAYtk~g?!mxL3-#jwr8>LGC&He@C z-0-k#2tw?CbNFRrtYD(?*u$2fR-D)BTz$(eD)ZgvzFs&!K-3-UFcvBzo1AyV{XP-n zYA&>N>uyuqAxPh$zQ$fx-hlxDQ)xp3zRDME9ZvTXUdLA%+&1qW$uVjILo8GeyMw!_ z(D(Axu`Bz4B-8 zl71awB-aRY*3w3(fE|aIN2hyaf5nk*9TQo+K6*r?W^|UbaT`K&Q&7se|IqllWeu+= zwpXF&Q4L)uIMF6b$eCr0tpGa628vituVg%)*T>yGl8T3|hPP0kOC2`4#l$gd5E<&X zKtb!$1#V2aLN~8`8s71gsQ5q)@*-O1TqXyEfeLMH>U8m7x7m!gE>=ZWVa_Omjvv;~ z+4c#5iYHAjGH*>6ySYeYGQ0lvFT@h{7P5Mr9ef#-IfERM^)^Upcu0chPVjZ46@LW4%9&#p+Y`&kvp3MH`f^I+hOWTL3 zX4AV>BGD{!mj`E(FD_PYViTL`TF6q%d4p59Ko6WiAo*MV`-yKu-pGaN0Gi&kfHR%a zZoM&H*k+UHebaZ|LUN#4F;F1yIT*yll-S5JNsjuOQ!$UviQ1lQBM^E|y0h!c1$ak1 z1aC9H!q6cxsTZw#vIZCJ7k*e%kIQAI_n?^3rb0Y_4sm{=tqehO4)epFGT*ueJ zGa8HAD@Z<}uL0eM#}1D#iNCl8c2sF*8(kaB9uiR2PJBrZ8*Mb~;>{W!#5J9Ux8szdm`*zaX>c9!f0zl-=ACh-0U_G{3U}!OkydHGB8zDJ1Tk`nl77 zDL{N+NrpQ-EC`|qDGTyjr#Dpv`$vfFL_W>C>0#ffK#}19D(QpKMo^emcAfE7=}QpSx%gyRjVMGv7OY@>zJ6}_ zynTH?==5{-<#piq+V!pis97VF3anFS>5yv?7ftx90tUc^LyGUVG)PYg=H|FS&z7i- z;_#M9!3T!wizT!OJquTyZb}%yV}|&Yg?)@iFBGV3dI07IyhEIyuIc{Gbnjua5t_r; zUjFn{{2fSJu3_#Y;s*e=&B_NA!@7%YQIn7;=^%r3t40A3pkn4JkUkk$MH>l@Hnu&G2k%B@rve8DLv!Ij0Yz>swy zO}Vq*g^4%AsqeL3elcrZ-7jMzY&z_b1EDA#SreOId1+lJ{WPlYBX z6N^VfR{S;O$5EHet7%BwwBLz6e6i|@ck^MkXhwOH<>@3uixk*8*&dj^K?4@)|34zu z1mSmxnA2dK(8?g7HS&!lbO@*gF?|t(mn%`zjMR^`k@|r^M)O)!9L6yF7+t*QV%gFV zLQ0xQ;Hr|Vn@3n&7hgN*I~O4R%G1BL_g`!zDOuU8Yk%RYdqUeMw3gg{q1;5N4i-?d zk$>|l#wBG~Hs++QPmu|4_+_&0UHs(@He?Gv z;^`d!iZ(pF>A%Byh>p}?Ru`{e&wb^7xmzD!nPbgIJ z%0Hjaug`jrZ>MPAH@fy-28~I+IKR2v9$LPh{)W6!X|!UD3Cg?vFovgW?sXx*F44dL zmBM?I0-lQgrgO;(eT{YF@qgcCw7)_1bw&9O6cRRezN}$Uw*e9dwcTRUMW7+*spQB* zu`t3ZDu)vB>Lf-t65sQiXF!F^&2sPs=aQ@Yq8ORiv?FbA7Bi4=RrxHa@-`29_!t1L zz9zg{gya{_H*f7V_;cHzE`QQfjJpN{SR143Ao^bc;#(~rUS9KUh+HC^sCH-HYglNY z)d*@Qfl-JU6}&u`kz{pWKT5ifA~ZD2IaiDnousul5^o+9KSu<-DOyR`ykR-xk(GB& zq%q(@lNur(ZE+ab^(;oGpL}9*2O~a7!33q(He~aJ66KirX*P>e@4Y*1d%&8I73EoI zpqQi>c?}>AQALqrTXO8SL-*y4Q}KG62`^GNA_H2NBFAs_cOu`*#jy%5NdusV_5Gi` zy!RwIogY$Il;81WEk3zyT98AkZ!Bg`A+w42itJVZ9BvGKi99?c=^w|rR;_HqY zs-WwtLq;LwH|CncrIg;=(W#qdKasQBM-vJfXR7aSIF3cS6hWXysOlo!p~V&<&Kw5f zCMeW(L81#D5&(H~=-(5c%q-!~?#;Q+y}ZBhNSfj<$Yi=105{1tbs8dlE002))(6|< z5fbLPv}}ctP{A*v5loqyG1Kzygm2V zubH0g>FTU)FUd02I4qRoPLby@L3wf6KVvtV`KL5GMdM;A_}l-o$h8KF6-WdBWaVjP zRXs=y?ABBI0jrMy+ETQh4w`X_zguaUeV&+IiLBpDcsN=J=ki$%5E#oPgnNf(1j(|V^r50A*HtAw9)PG)1p$|uM=&6r{3OnNsyFVq%Qelb&EU7;Vz zXl^D;v&ok1vZQQc@zja}1Uj5#fx$}Yp-?RX)0bdV>;U;uO~mm7>K)knGyF$rKmxfsyQw z!qKVH!Ik7|Th^<1-e|Mg0Re-(?+Os1YiO1?_BDIEZuI;1jvM@NmcYQz@uQdm=Tr9Z zeQmAHtC*sIrK7ar$;{7}yC1p56(#Q;l`}Pgj!VH=C&GPgOMzkLfE2-a@Mnc+;}clL z^j6g5ZW`jJ1Yp#D6+4mRSnjf)@WK#!Hk4l#!!VQqcqQlN#kA6gN zLUc;$+yyW~;)*yON&2IyZ9G=|igWtR@biw($(L2yX|yB&O`h*4^%_{g^V6bTeY@z5 z_9WfWm5J=YcO%?oZTc|TtKF$U)bldhy;7wE$Vm9o6GsKN6UEW)6Zr}o2t(Djfddf< z^4{CP!?YB+=|7rYdor8{uAHdT52;;Tt|c~1)m)HxqmPe=faP{&{^18DMucJ zaD-VlL=j3bBm7_%?oFT*2Ip6xNJaB42k*ZCz@fn7p13WLU@p16Id?JUo%`jcex21G zGa8xv=Aieb>#O3sojrnq73U(Y|86NwuRIUC@L#h8gXdm&@EP5QO$?Y z%YWSzZFS7e?jy(1xIo2T5Ftx*F{Y(@$X5R1>`W!k7ZdX4N`(^;X235Z5t@HD2Ep7Y zez?Tm3g6}1wsM8o-Jy*=uRMj6?)K-t#18ehm8Vp3b&Ouy+nX+?t>e`_Eo-~;wD+G-z zB?aGb0Qog2hBDAr(~R{wg#ks6tuKo%1~#c&dZV$bi)rrVbs5;mc+(pEx~j~>lwH#@ zVfE{-Sp2+QO5o=Y$G7^PTUQS}cdud`1NpEI{Df-cYux^qu) z;0F+WAEUC$xa+deYvt*I^_3@L_GSh4npQWNo5DYOC@D2$%oLmV38If(h8#YPWzs$r1ZQCRdHR@v<-w^9 zJM(xeb6uIc@lTrIBy`(6Z(r;DuEUA_Hp=`dxpv2sx1ih-QOwUIu02t7r1f~ z#3*ykdKsU?U?AUBd#vABaA)E~J|=PYcSTeD>#{jc&i?+Bimd8Ot4F(m`df^r<@+^V zjjw3OB~}!WZgq-{lFlbfT(1{DGniHPxGtfqkSqM120MK?)2Zq)%j z|JCq4;Z_)_5rc8ADBny$8;Nsh`8NQFVXvOOF2lacA?v-l`k)PhdWYd5bcGywj`h8) z=bX|Wz;jj8ZTD0bRW5Er{UK02pFUT^Pg?xf>yVJ z_oPrFbzr(f)i>y5vz^ahds*0}4V#XO{!nw#!BNw=^2dlR?%QehN3{e1x>i0>ZV5xD zpX?=m6#R{VL-DR=JR;C~aLeHIhRZ;Ns53)vYDp;9f=u@0a9}WCznjvcFgu)BwZZJ^ za7w>*Q_fu*?V*9@;()(6jk1z(z4;PEeTDkxg%7av2L=NY%wPaccNa&fzd%TqG`OiQ zP86?xaaCf1pVYMzYb6xY?r2 z?ebkI;|K6xfj1tSqbJ`XV;$0A9 z5H5g{^oE!RF!w4_DIx$>uV%g!z25GQ;Ij|UvUYh>&BZwn2szP43Mq8u9lVh*jtUdL zPQPf9uh$?bwupOyGcLE=RncE^P69^Ld%qR(fmnM#Z=nRTC*eFdk4Zp=EKGS3en#kj z)1h}kK1vg@G|gIg%J)IkM2R30Fs&n7=24vX)7RWa;;%Y&1mOWcPFdIh5Ml!zsownu z0Jw5T0r~Hy72H2x8OIpfl=Kz}2<<9mOp5&nEfe9~hx+6a^(UjPk$FN5p)`sy!VTC- zOT-=sC0TMk2>@CrZf^|qarVpEP#%&pFQYxAe87`mbMHba86W!Tzo@F*(Okk`?J2KM zp4G3|u^=JxYVzfB^otuZ5YT>004P{JFpnTScorMn6UtGRiW;d=w*VQkVqfe;4?N#b zK}*+7w233VG|)607DMl3sq2zO)4xtAOd6K))-j}ZG9$%Uog;v6kY7qL{HbyxU@C$8 ziY3C3&xahSdbj;G{a(=V*S^oCziz;CrrRF+0BT8oQA+tQbNht|1%9HYx_;pvj~`zb z`Lgz0kN~~eo*!8la6{(-WS`ugjN9RW6IW7!?%co6KOYdHh)|{mF?Xw;b(}n)?cPmA z-ZCPC?EisFVghIRS0Y&B5h)V>m^(ZzqP5Q^Rx~nl6a~pEcWvP-zPWyI2nka_Hc!Ve&5`6w) zbF|~fxk5=$O@;#-@bWh2{6cEyOJQPb*=x!2!oqcM}XgE1?-7RZEv6BG! z+)#XH_Q-Qb9j+k{w7~}+DMbK!`5C4mC(QivLb(`JhqfE`|F{4y3(fEMqd@@AzWE&q zM?r!5qGF32<3(GPv+?8~!BpPXPJ!$V7?5e?qC6o0vPJnsjqIW`tIOO(6B1i@4<7;` zZLO5%ogdT%)ehgx{tqZv3>35uvDYZ-U4EVA2IA&Xde0)utb76H=7twLe7rH_vw)$; zZg|-5&UXSeapFM7@@BC-f zCUYtg^%A_bA?2-K_~YkK_v=z^8B?7u0LVV{n7ZuBx*I-v4=M%rjcrfZA((0Oojnw3 zlj$Q;Bnd)&9X(nvE> zS^<&n?rs|H^E%H@ov<=Ee_1)b9H_%k?LC@j4T5(q*aYHo%KDO1 z2cZzsej5hrT8Fsh|Arl0>jijG0o%W*mV1{90f_ls;*8e|UBbS7%0JR*!AR6 zAZ5XPC6hyI>j{v2XutIVY~NLU#3eh;y1~)+D?(;)UBSg}HT{&kM9N{8C7`&W5fudj zx*_JzXoxygQ}Vf42qVvB|F0v?169pXVHcSis1Q#8^L@pOa?oWvCc&hh7a*Y z^kx733v`s}@WXjXe_j5KrWe0$A4+e<3Tb^ZKax*;sR>9Ttrf~l~(|#TzObR>= z)yG`2^{RFaW~RlRAsX=P_Hlj=%Hx>4VVG-}B}Wx&6$FF`>w&fRi6i(-Fc&AMi%+eqapBo#+Cq5NmWf&v0^Aiy`?4Ou7PlsbN!ILsxvnZSw* zIQzAmYw9wyw#Un26jaJFZmZ}mC(Q8{TPSTQ`D-o@k2j8DRw^3Hj6la%FV7XXhYd@g zHDkk{!JqNkCY&fI@MV1LVRu9$UQ*@Cyd-2!?XX>N!Lmhz5QX=rC4uGQ6)|a*zgcil zdgFI!2}dwu=!JRn#o8z`3mQZU)!+{R5ll#b?65SWZZ(=dT>KbJ0gV1xzeB&+^Rk4S zTvdmu9fug-bKcmHq_8dM=wq;Wv+Q$U?B>M_N;Eqe?q1$2#RRASXxarl<6Y(Gb9coN z*Ar2?CYtF-cgv|AWRIX5$l=IAe}zfCAR{pYQ|BinLi-%Jl|B+;p`xy3#wQR!rgg(l z^Yt+fO@8NCQ$J0=OPKm27j~SlOb*eCN3ed{K*``pO3Wsf*s@$>j-~Plp}f+sW&0B( zGV`6<%B96KkN75E==_*ZYwv!Yuz?=1_IhZ<`l8AQ34Ez>U$yzW(p6-M1N$*A5k#6T z5P#E&g-0KPKHeTCCT<7^74Qhiq_$x<*;#)+%@DXh+r9sc=U*W88W>fQD5Hiw^qnM8 z#TlBwGx2PU!f7QHakrYBi~xXjs5wK-)s?hE@y0uB`<;#{;&Bvu7Zpcicc9tn%;O>*%`<@k8jqy z8vE%SFZkcvW40R1um_t1!7{V1Y;Z!s-G!go+2wjizh|w)F+_iZ_I1Q6{^<5#v*6yy zEfX6Ah!te7P*`mLa4@nP@)+mu=8QcDeZrVn(eI&m{#z=G#DMWTHHfz03n3{IPpAxu zt!^)78ryM`A1$SbT#NTE6a6l&K9iErI@+aTVALPXH2r$|Ui`{pa@+3@Ml9$`V!#HB zcxl_*uSrxpxUAb^a}i{d1p4p6Z6rq*1PnZ+xfB`YzMoJ)nb|1!roGtz=PSN9;EJ_F zB3!xxF|d+n#Wk12uPyE9ET6T?NN#I_08Wk`g)ssUaOe-%p1%J|zbmOKs@waMuCCI{ zHszP7P8S*1l5CWQn)M3HAMpn29~t%mMw9SB@bah%t(4&I)ZX96ppIgPfwjIK(Nh0* zf?P1zO_k;qsDK%l49kRWzWh*ozjJ?C=`8R2lTacx^Wqh+8g^S-(h~JJZ|Iu-)++k? zt&D9F3l8G1GYpFO_?VkhSrWB?{;qCj_?z#{n7XO3B-z=L!_~*NaEy{FuSJv4Tpw)i z;?>gwHEN$nbDn_=k}STGWS-6%}2#(-2Z-5a6cvla?P+iql*-u7pW^tcH{?< zddtJM$UBIar?~Go?oTT{HTlvY1}Ks6={{xolc~6~IVA*PrU!;^^CP#rg-2_Z+mbji zDw?)Oi#wvW?Wd_89v%qieC)1sStG%wUbrm4ciy#m(wd!uAPhPjU)e1csbBW!F_zod zA^oT`5zr{B95ig)b){}uO9)iRG|8AGXD45exQ04_r~CO55hS%>;C|r#7)}WMYOlJq zsJQCeJ}J{hmd?moUIkiBH5@SjE=`mZ&d@jb`6ECVp3{Hl><-&8hOVCsa24r(d(hF& zK3r%T5me9Dz4QE2oFSed7+_jg{wehQT|*GOZ>N?T*mtHgt;;%pSF2acPU~JIF`52A z9|b>_xNm(5Jj;Jp0KFc#NecQd`_HTNz71O&tx!ViMVYpvr*h$d2J!bPy~owfwy2#h zgYKUpZCt?gRtPVyR+kTrTf9~Bmz<+pRQtWw{S_F@6MULp04+|fe zMOg^6aen#2|7QKx>d4k|LSji8*p}d@Z`S$5$!$$?PEpePq+td3TKWw^)eMEompY!j zG)wR=mD=;;ADQH5wrpNr>C64&3<3rytMGw%_SkS9d$lg>1Wvq%p|qmjfyD131^i08=oz-0 z)tASp+&iCS$QjYg+v~187owE})V}mCS%{QpR;OlJ#edUlbbTwBgPpx=4^GyUc7)?J zcrL#;P17jA)y`+JCvO;jr$7f>KLKOxH|EB|RS<{R$!~{tdE`bWhLBa^PgCDtDt|%1 z`jdWkmH_8?JYLvcjNe^bUv09q6FF#*jN`p~+gFH?Am~ETgzl)b)n2^a+ z=*^8LuC>TA9%(5dAS5@(Dj#Zuse8%dWG+)UMrn5T-#kT z`4|5BA0f<|2IIbbx1C`>zV@+VIwm4{eQ=-GzVb`CLZaKl$)cskB^EBA@eo-(cEs_t zwD=t5%kc_Trf}1M>ApXNsy+&RKA2t}iUTy-$q>Z8eR0@n{*rU%ZjAn;&j9349f!zxL z+eKC;E-j#qH^+iZJK4`rBvUqeg z7qawFcLMqctyPuO@b+M+KTbWv)NSbVMoJs!bsBbE;1qoD^Gm=zZoD4uP#_-vDut+3 zYrLmEEf@zhc4vZkBL*bXcc_QFg2Ym6dkcUg3CL+|MCj}d-8BlK%7KKNSSxR<1JFS@ z_YmiWmD)+M`8?iP?WZDj^3<*?rspx!Ec}~XurAcGZJ4oS5Gk_+snyV8+}aP0?}CA} zXRludy*cJG&`>~^pn5?qd|03{yTo$)gOvCZkeaps zOO*#~AYk%ewwd8AC*~25@AvNr0?v>b3SpfwzKfw4BxmPL{0lp5apFAN$_ouT^E-Io zJ~Bl~RkadxQ96%#2Jrt_f?B7VDrEb5-utnU110wRloSlh-Bm=O#;*WYAERt+O{=LL zutFlX?jQNdvZvzvA}2S8a`Hm{xgw_fy3e{J!|yB2><@~Um-MlY5C011CLLn@Vqt$` z(#3I^g_b|jvCKJ5UXqv~*G(X}o3f!BdyY#)m9-3O2h12&B5ju?R)F$_)lhugQo+Xb zojlF`Rl#{XigE48t?m7lM8RGA&CJn~iI9IAYkCKZgu;fb?RA8?Erp0Im<(TF9>ati z`UFr#Yo+NM>T}%tFXQUV) zisf6hbCU8-g2E8Pq^gyalY-#GsWqhcwA5n&BuRs5DA;c&1(qo-0RVh=&~iPeweA1J z3jdMzXtgX-_f9Tp>2-)KECBMLK)7da25r_8LIpqxO5o=d1Po8&<@f5YNWkA{QuwR- zp@5A@Exsi=wuNCO&G>12Kq2O}#6bc82ufB^4~IRPN*l?=NH_JI2~QzCN7v@Gb0FIo z5k^kQU9ulmCLQ}d97-4%e2({r#nlk}iAVvzWyI%Jr-r_rDIJ+x`tUj+*G9@S-}jd5g9&d|1uu>A zoxbYxEK^7l-Pr)JT2Dmnny}z~l;!GD`L9pPH1q^L zi42!U{UMGR)VKL`VzY^}eQ;$n3E{XUHP^OR*ogi)<24Jh>}qrMwvw{dtJ{3#Fc5!e zI@EzfAi#jecE!_eEDlxI`s=md+0^*LxH0qVCxWa9rvTRWJE_czyKxu*g7sX{)&vVK z{=9f;)qMM?`@r82IFjlk$vCLMZQQ6x2_% zh1k2}!H7jQiaOA5+>)BE@*~*A1LJXO?d=t=`BgB(heJ`v6F|845?P`3Pv*pUdSTc+5DA)hA&|popz8nmG#XM9 z4P1k}`8h<6->mz;o7j5VXWqE4wrPd~1n4q?MTXc&O9^W5TkeZD4;8REJh#iw6GHKp zVV@{WJW+y@KznqL7As<@{qJhb-2K)ypxl3L(B9l-RroJTAz>Dz+k7aue7TlUe*70%qX7qT<%1CTOE8H1`2hg zuok(g`5 z=HXa;gt+)39+9^9q|WJOg21=e8kwIg|X zl4Rv$q=_mS0OOkm9U_Z3ISIZOCeP%Lyz+yOq`nPfp-GxQ7JDHB)CBfvufq)_pf{jh z1!x6g?{!Pkr86!7fB}?ady}*nWk@jpj2@GX;L;7>4o`xN$(n(a2T(hOY=(qi(?N*mtf_Ey)S%% zzj{n!=LtMLmWtu$Ck*dzjNPjc9pHHS9gNWjK)6O2-nmMi2MDwYVXXvEi)v}ikVRw+ zzO{+rj8uQ3(V&Ikh8xB5f6V}R`YS5)@!Kf9-~6hSZOuKdauNeSdz9WaGR=2ZlP2Oe z(K$(;-?S{7@d)id6gj36_>M#_H(gKH-etoZ=qHw%4289Ct8{P39z6%Fe-to2N*kUk z!)&!CI)_eGUH-d%v0- zl8;#Drte*rl_KUjSm1qT)|pQzp|MnPk@AM$zaMKllY34Rdk45(Z!N2ymz>^xYaQY+ z)!`?3*dzCViFfke>u&5Yr1^H|kQ|o$MJ)&t=LSNFCl%ikRpJ2SMXtgze@iJ1KcGrB zHf$KF8^_RiP#JT*Q(fwvK>#2wWx{XTLezpq#X1<{Y3oq^-{5$8+zmH~jc%E^-8@2kCC@-BEQM zeCUzdJ(~*5v_?4d)0$P(>A&#f{wXHSa0a)}&9WSijZ=Vp@$S#1rBvu!+J4T<3Owa| zs3rZ|mZB4te6?H$0P-89*GM^A6lM-qQrC9vW$ATS(m^0~Z+Es#SxE%AJ80D8htjRQ zJs+!B1T#ML&nSPl!fQ>q3c(){Xo-g}7y=@ncDm!N%&xW7Up3YeXgW5@c;}0NxjmEK zS>Eyao#Wn!?6I@vVi!uqXc;-=Af4wYd;^LjiNKKzS+TOGfY z1`vIW7>|X^{)qm=H@LJ7%8)t>Ve$EZPF8Q5M|q$8kbf!+C!vCd(UVTE97-%x`KE(| z!~yH#yB~|Hg}S_VHpU75q2b~De4+4*2~bCYid}hR$#w){L*fA$tqK&L6w>?SBl*?E zF^kuKW}ptF=#!2(N;-bEup2r>?YdJL6OpOk=KM$mtJkEkqm_`8<+Nw8Mi1L(h*TRn zaEwo5A6VJ-VmkW8#bChn^V7l9KYEVc?(fx45@??A^0aAWMhxN0N zf?E-gQXZH9y$YG)zwP-CKy0 zF|t>qf>=J3fFeO$}G9mVKe{mI<|WgikQehsK&N?jJF-W%=-5H zBOBWrlQYzk8=AXsJvQLWoc}tezG2@WHZ|XBUfFKKUrZ22^iEJFeX%W)wH-WQ$K%|u zKiFpf{FN_+_@XS7{3x!Ld*9?lF3eKqJdl(X@%Mvp)N*`!P?Z3h63hS;Ol>I?8{V(^ zpPvVRU52ed3{2L&5cR(ofh6F(vM4^(~#nI3Cyx)d@dv_2ZkJbmO08oNmkWMO? zDc{<-)aG{_KQ_C?#jgrRcSjml@@pxv5C1H^3kBoi`0RT%2Ly!rz0j`B|2%mu09O* z&E9!l>D_$0a4WRp#sm95mMUj#y4t?CfwXr}bTlimEnu>pXwI#VgJdp32`gS6%kjZ? zV~|)<%B=A-Sk;JMIkl5T7IcQg_ab=(2Z)%$16Wgv6cO`SinWZ1;=v0ah%#x|cOg}` zPCLJ?i@O~4_(R-@Z`YIpG%!H|afJsfbPyotys? zxSdu*f7~k@n541tIw+Y_4G^i}oG&z5xTV>n2N+<>ymhx$HTc%X7_9!$Ij`w%=g^4X zb7H@}XX+0aFaxgb2VTBiEnk^Ok9)OUU<8j9bE1W(_mUaI1e3)vyqMc5(K>%WCf49A?3} zjLh6gK?5FsOm=UY7pgipao+o_UHcme*W-IrE>rn_S`3dpHVy$2Yus+XY1mgWZG@eh zMQ-`i$XgNIJeBkyVEeB#k?f5;Qb72dNjTsSru)@)2%3&Ro%UyyAOTXwGbZ_4K}GGb z%12PBHCD+@&6Sr=L_$d;phqP!cvRRThYz@CrmlWvto^F8;Tpl-Q$M#vu{10VhEQO2 zO>Mf}`giq2`Cp!<)81>{h;Sya0MsJ5^(jn<_PLrzR!Rl*{b$9M8zDwe*Ta{2M|KgSGw|JT3s32C`b_p*m>lR#)8P$ zCw@i6)rcLB>~i$90(*T9pI=8Xm-Q=mLV3}D#kCGy&1&VhjC!bf;uX^s$;$x}Z7yv~ zf3JeS|2-9C8x%t%YaqfP;N_}MkpabZE4Eg9w%4l5wpRKcHxx4jd6p5*EP$YurQG<9*D;Fs<)1zCrA@HBS8#I4l2-IcOl)T+CF4N zjejs6{-d|ycQCT>1d!xxwGnw#{hqEJ0OD7$*0)ngVVjKdM*@C3Ywp=SDWF4<@iybU zLNZFQ6P7YZF|{cZ0ABt%6Lm2DW9n6o+&NTJ|(L#+a^b|){KX$9y%a1bdd z5LDAWC;enphWz{Q<1;hnDKN12POo~G6Ik6^t}QL0fEi3uAyysRu;KX3bXCvyaqq;z z5n${FyQ;>C<#Gyv+U^3KU{h|^>2p@KrEofjsnHw^A6#) zsF#P%YuBS<3NJ>EeT12TSCq`u(F{g^SaB|iesVo?>pm^~2Hhhl(iFROLlw6=m&SDMH4n!Z zMjwYr?ArLWr;Gn}zR+8;*yf!U2b#KCJN~~Gz$xxt^CUN6&2MNaZc!Zd=e#|6+t*_n zD+GNdB|sQz|N4pcaza>xpoR9^II$wAS|lSE{6C5+dZ44n#3+Z@=fCcE3f7&hkrEqi zOw0-D5f#$L!{#h*!kK$gtj_}(m~43dQ#;of)lUQedq8Qm%mD(v6NfVTWVMsH(drT` z%r6A7PDtrIyAw4?i(NNOJ2|u@wDWV7+wM`3>SSS|cdkqU=k-a!{!ykMFN@ zn+2JA@}5dlv_YWY=y7*%M z=Bt7fh*$c3(X+}o9@+Qic{`dp=i)iPGnz|$W%2Im^yT`MiI3bxm}whM-`u05cVef=QkK#3ZFv3JsDJr9q=dNu(K zB7Wv&OB{w9Mhapzkpu}1s-o$`8%wMP>*trD9AWTszg1qS3M;>R(Rn5gSPwlny#y_}nf)#`En@!tW0)CPA}5ah}r+nYH_tY zU)nt@ZhLsn-fMLd@(XPMQ$y?pETCf|y-9)5wEOU1D%zvw=iMcY5ty(rHuv78Pd6k$nwEqvBZcXr&5msD<{ z^*g-%`cH!hheP0QyZ#F`uhSFh+MHCf)!E|mj;?Wb34^gGE1GEaeH+eJu;LDhVKadf z(kyT7uX>X~5?S~TD%iGCWO0N-t^bcYv*?|CsQdfSqfE^3CMg#wCfLX)%6Pem6kfFAWAjV8ABJ z<1f<;zQcbSk6HU%zNV!fE`CRjj4JuUem^dVuXv?G{6h&xUcp?exb-pTC_j<(Rme|f zp)Tve#6C5SQ*kN6X-8-|V<4_AXb%d^0f6?cizH+imf%~5lJ~bHO5R}pbJKqLl}tBv z^~Ww9cd>ANQ!5bi>+u^`Q5tWvgND53no4_r?u_;~$MHObBG#U41ffgl!d3Z{9i9vl zXSi17h7Xx$EKEHb+cLYq+ZW3Y>6MJWz{YxU9}NzU^ipBPDCGTI?zV;<)$(J&r@*gCT6n>neGzwU0WHos8ted@#RXMHK zGPTr=L#Z1#T12=h@xKgF+R*0DIl8^qK4!}&jD0nXXCr4~Af1)`HQ?y|LVC}Rkibgn zd#2Nl;-gf#&Apf^eF!fDMrbRZrhUF*I5zbM&90^8VJPG1Xw@lUP1JUZOxVqUZ|Eva#H+<&|X(OtknI* zMe5olHQh%vl8$z-bjU_NE>-DA z1TmUQ3mavdg-=fXD^n3Q-t7AEUpqnYqHiZ_!+QB}@D3%z37S*qYlX~hXG;e@iD$E4 zi718cz@+?+ryqHYou-Pja$zomvf1L--_#$3)Ix5gh6FkzMy@`2`3s`34C;v{F~O@@ zD_3FDj}B3tZ&cPZmZrKz$HDMXpIW#(R~Q|WbQ zxWUWd3#KYtG1ZRiS<3~6(}^itDMQOln`0_%-+>0iSYJJ1I2SPyKthg_`bqEl(@0+2 z038y<^VA|uK`!07d)OkXFxM`3n(5&P!92>1&ymd|wDH!YV#+?yEA{HxD|xn}@ukF6 zg^>Zv!c9_B)4T35q0zuE$1SHBEpYpTZ$I7@*Re~q6UixGbf?oubYMMf1D z79({(#EABap`hjH!Dk{+RVeR-S>9r>iT%#EQ-_56#4IcIcK-XF#U+7N85vE$y?Gz+ z1E0k|m%KTD@Xx!f)g!1iqb9*2BKTXH9-K`pKh~o3u4T?jHbTeZXzOo{J*mH_eX+0JMRzN8G&64StD85ppYP9mxUa}7*oNnn)hjrTomWI?r zzi)1xwi$HMKhN`h(oM!2jHzH+VcgvAi5ZO0N4d6tj~Y9g#GGoJai+5aAxA$?^F*Vt z634i6U7L6L-+H`$()`D+oe#4?FdHob;gP1XDstgU-!|Q04ZGwQGWiA;i|=&azFi6M zU#-jATYDD*(Imh#eSbIkLe&PKo~~)sUL&be4h3hk+Dc9Z9NtJ;5+E7uCVc5t=$*h& zsLp6^n9H+gWA?+e(U8@rK}u{Xqf}0eUk*s0S>7&^Xjm*#Qs1=x`4rlG{dVQ*p~B$@ zcOI|Rv8}d?>o(=?l&GtPJ3Qbw7_#HkKsHR#IeElX9r8lBnRcw0JY?zghEQNX+IaI+ zYY+b8C;5>CsN>)I)asd<|C{ut^6O$08#@TgChS6$@OZ?tCvf@U(l>ZlIg4Wpf@dbn z(087`QRiGq5l)~e5DXly~%@Bf@Y#6)ie;T*1aPGnN zcfy9*Z$sD?Y2N=h6jzw=pU4{8`+J(S7o3kbayYVPFF+~pPXyBuIqqn25dZ;jadr5zz^MXDCfkhpXryWI-QHT7-o{699 zld#15G0Byp`oe1j5$U;achM9g?wh2YbtH97J9wLIqRd1)QZNSm(~F#) zr2LxXssE~x$QPaJ&=_z{vbF)0H{S{Y`?c4Dk8B@e^MhD&{R1UVJj~sM*%IFRL$v?9 zR`M@g^wNjNr$_lvPx&T(OfAl`PxO*Gmsz{J&^*okhYhQ-I)qQ17)e=~))aRPdqi(Z z^^SHE>c;A)jPi`@t}E|XJN-A_3f9oB75HX0V%RXoZ+RpJw!HNnd%;v~1KmQIuaydYAZdo4ZxM3o*RrK)cac^F~e8f~lTtualK@0H;-Zm|{wwXwt-gI>vAjUX^w#I@>z>MVzSWib-Vp<2d0e3Tuc>4J*rQV* zaE+>aeweB|HfYN?x+zOy{nX1^Vkwzd-@#MSl8f4jyN$@FzumzXCvcL^nO$Iw0!sm( z=Lfc-xJvXIjmmM$TNdfR@e)6&dZYFxx6zeZ?nN=2f zF#)$PHg5U9)~SGjClql3(GE`;9)Gjkb>=DkdM-Li3gi!ltdT~INTU_Du7})S+>}4CO4mTwJ|S3&;FS}HvW#-L%j`0gia=S z;HaL+&653~Idqa;e-plmySz2Xod4U>sdUAN1ZXY{qhdP5xVZn44gGMX|JBG1TW`1# z0qB#jV{UZ!iwOp1>q^CXQ>MtJ>ggfvk(|V8WKJ>1NcQ4B2c->lVpytvMqvP`86TEx zM|E5IB?hbINm)KJt~uc>SA(mPN3O!PfaAouec4Ydpt^_3*cc7KQ<47dnokN#gE&v< zSf?<@ekk9Y@_e6e173(-F_8P?UBrI z()`GijN|+}yo`Zx*l0JI-i=#xHT~>sFoKxyGPV0MT%Lq5vhE%PK$oV!8d+aBH0Oh0 ziQj_ZA)9!L2+)Z80smU(GgSi;8msU{gh4DK9|30~eW!`1qeX?mRk(`-b<~7Zo1O6W z9+{%+?}9*V`p@(oU=H|CJgxi+td1*Ke75;^T0P5?yyQAcm-4UaM~ngoj>~wa@(6dxp?&V zkT+v3`E!u(vLkIe6$Q|@`IhIB?P>1P{kd5}yC=#qzvO1N7=qO?ZoK`gHh@E+uAm5< z{Z!f+;-|1nZdk2Yf^cB<*`|tISNA-&N`t?9;_$gkj zMAbSOkHBagaWUgF=!d@6N%5R>q%aMTs;i+f*>q=r5u+Pvdz3CWe`oM}D}!ee)r^iUmq~If2$>o zPy&`>R`G%BT-fpyRs;8#a@hMfAu!C@ufdE50P1-FRJjqeK^U8TVC}+0kPuu?V*YXb z>steA=Jv|7c_J{G#k67+d^tk~geOlLgHLwxfX5QH4?&D7fxJDqU<`fzG{YM6*BUHm z#aP@W&@fSRLy06%r?&kAO26Cc2F-<0TrpzL_eLLKO;_H`U6w&00H7pK#0r(#0Rd30!m|9vC3%if z^&=p_mI>F)`Ud_X7a?pbQ|tPA)3ZPofN3g5Ln3ijj|}g$K)~CAM*g<6$t6Yt)f4>z z>ugqf=|o?u&^!H$nES7*$A15C3_pwRkWzPB3BZ%l*61PP zd_N=1aUpbH6x31FU@al@0;tL|^SF9e4@LxD?@{RfS_OT{YHqrx#dAV=C%Y%fMicAG zJh@AbY_z#)i7y}*r~4T)snSq5DgM3?rcUgA2R{b-DVLX&8#~r{jGP2E?jlp`SK8A!n=qln=>}^`h*|BOpCDN4~c$x9EQAZD3}k z-?ZsU6p2gZxrGhBK&<&yjztB%IL2mrO9>^OQO{EekhmuJgF=PQQG~#yXuPIO5+R`Z zajrIa*BCfHRtv+*(Z8(|Ywz^wB~Tv3DR^XL(-J@%phleh-t>Z0eWR~EIONFMnc}BY zvcIeqqfSjY17LdIRN_`k?8ues zhEi>=KSx?6-~ER9nYk>)T`P-a3zbUiZ?umNFypzyxEM-W%UT)UVLhKM7q4&nnCRrp zQL%d*y-5;?aw#FA_bg;@vGUQj0QdJC*iX_@x4YppFrwhjrrb-1a4!$JL$%-myHzQ}Wn)x*Sz(f@dF6Mi=jssmtQrW4QS5 zX~+_i1r1K2=gFo3} zBn?U(Gx9;%Wg#8QES*thDY84(e!C|qo&#o705Md+$}iM4w(y$&<&0MasI2$sXU_8~ z5jU^Hlml|?g59!qyF;?&<@Ju13^`DTnx2gWTP9#F(Xe{>E}IDupii}VaD4LBXE*xU zDZw`Sbu^j$J2EFSjs)D!W=nN}q|bDHtEj=0ok!|1=ry0Rj;`9*J!@P*r_L!3mBhlk zj}ItFYEp@b1=XUcOwS>z9IrwqcC<9$Jm`Y`6PdnSmM!6tPL4A# zFhY;@7dqGhj6l!F%Q$)x0`AA2SvB_jtJ=4&Y6&`V3IMQnYjX7?WRt-H>3;E_=yIUW zlicaGgVH@gk_q$jpJl%Yt#iC1@jbIhV;jr6o~}t=nHf7GG1#A*u~YmgkM_2WI8Ncs zIY%Db#VzH%ik(zA1cA%r-+u69>BHC+a<4R6E)V`;EW@dZ2K|`aAoN4L-skl`Tf?8* zc4W4iUwtZz0MD&`0w_V`($Pf`f<1{#^OzV)xZRJkv6+f9Z9O z)TlH5bnU1d-&LfAR(mu*2qE5RwsqQVFHDc&S%}w{r?_p?@MMD_zF!UW93^+!rJw9{ zNuxh@^Si~gab3=e73B4OQScXxL0r23q>cUMx|!{ru3&-==GN+YUHoS3PAt;OnW{v0>B9DZ4pyJXV6kp&A1`Tyg9)o#iCjpRtV#dOJ_kLhvZ=QBUIKKlC-f>#;E8Dc+7!ov{zfrOf{KtL8>3%QN0h-vfk@>py|{;*pU=qPb8 ziYKHa_J=H!_Dc}DkRiAkk`1_q5)5XR7mtW7O{M*2{@Cp>z3Mbysh`iPebX;!m)!N3lo^-At! zF8$e%B?nG??ZD8-KTB|-Q>(DBV1`o}8TaK%|B(y{%OrEU(!sunv!OwG#i!T1 zDM(@Kd|%|+%W$hdN(MiJ2br^g<#*Iuga4C~4@xW-RKi2aVH%8sk7DChwIi17wZJYM zvKxSkD@c_+70nsx?alvZDr=W%MrV?y=2cGh2LZ(*r>DBj+StF=^C);j2b^g#a+B{ZrYx@C_3Hq$>=V z^8=#`xu#TNBTWGWhe%QhBJkvtU4x)AKmqx=>r4n<$7Lvv2{MM|F_xOV<4+`}WB>qv z#m-(P^_3sie4#ApM{RL03qN}N<|5*6QkNVa2ms5yC!Zsr z-oL1mtgYG2U@xyY$0e)B+LBO3>3E^6wAbmbp#Tt+S$21V2ng~AAOIb1C>7um@{4Q% zd(U1^6M?1 zzZoh~Ek04wFWuyBFSe#T7Xd3GI{y!#k00J21kc_2jew>otgjIj)_sVi$|nRpZX4vq zoY=7yihwPvV*ih+_CA&^W>s8EgvdN9@uvIFk`j=3c6Ey(W6x1K+>-8{ec8^l{LdUU zcIF&9w6=hU8fT%qRo(KEZzW=9v1I$umWgfQrGO8})NoDTM7z83koR|Wi_80z}C_W)E5XjIguFvM6rS5tCvy8w)N)$e(qyN&M} zy;vG-dvG4LZaxpSxM3?BH9EVdsJ@WgNJ0_XyUE9@1ww#q zF~$&$&wI0|h%YTGyb4&;q+{P(HaHl;vedqS4Kcy|wu!lD*QRJ1gLNwHbW=k`a(#PV z{eLY$G7_*=R3c)(r2s@I0RIntm^rzkn4@vhaKK8{??PHYn^Vyr#}+>l^8E$B6wWCP z&9~)#1|a}wqCeH327(Fqun~8?*igC#f19RSe=R4!UpG46;3i+`;d*5Efn?qiH;2>c zg+KQtbqX}6lcfhiM=yen*1YWH#Wz1--4ckUlq<^I4AfH+Y%TQp0 zEHjJGq+a=%q&-Wc?y+fetyYQgpQ@`SJLzek^TA#I;Uo{2j&^K*tp}(3O;8*);?pU^&n6ZqHj;oCwe{Px%ZtVlOM$6CaWIb+cFhg}wHV zZ+<6$cS?6e?|KQ6%5&;;Y&k?!UlI7!k22j>}VT2*UXig*rO;`6XrG>Tkma z14X{x^oH}FO9vfxI+_P@VN&x1)FS30V?+nE#$S9ld~Q9paDmp!f!{_07ELECEq?DT z`EFI$yfT+m50nzIIY~hfKx1Ly;a#sr@xHhj@*coVQerf_CAxPanqBK2)w%;KWD(q& zxUTtu*`+HLYh+mg#M$F9{o0`=>RjB905R}O&JsWX*j(9t&(ak&?DJU)@)8H28XRlm z#$R-Avc60*%@_we1dCzV+>${H&ti!k__m*~v1f``Hx_dPs5wLbU@5uP-a6djzVg); zE#tdH$%sEKvqVq+YCVtAl@bwH?s_U`XFX0);u=Lb#>^f-1R#DJx8kqM(;e&Fv3i8? z1oqzOHAU=y^K9eW{YMyPNLBnqLSw^IhO7skXPbc)^}zohQ)l58RTsAVHA8oYNJ%JN ziZl!$f}pf?BO=`$GnAk-l7fV^q@?r+(jZ7nH`3iOoO!Qvedm1tz@FG^ul=m&`Q7(z z5hPjMZFwW@$9bbuE#v5_e+|5WAEuX-uBi#+kTvgs=0T#RBzTEq513>69pP%;*#2rB zSqf)2e2FL#v_u1k1btplNpH3mJ;DUmq|5olF1#cN2x29=O0?RS_Qg9dbUO}k;k|*p znNIn3YemZ(7OEUvDnM(3RnPp;iCGq06OWDfn)Ff^ag>M-Rvrfqeu}BnnHpc>RipC# zB0T?zV6m$G=J_jiZvA!Qp2Eh1>kxcp%l=D!>$z1sxL#wZcc2LK(X;p;lmx3WwH?G(*^@|OGMzo;IpBvQ|IMVlOs!=lkLP7Ra>JUm!r3> zYZ=6^_;1kquV1Q-m-4^0Q7$CXznEmqqRtmFSF5QX^(%jvtJXZcbD&3{A7Wny5HR!< z2q6B9Xj$}Uu{O&GUA0Dbz4GrMAYH*k+qevRoS_e~;;suPW$KA~+3}v;WLTE%))&&$ zwoyzLHl$-^@d;Rs3mU;udB2N6L$jC zO6qOKalUWkiV1mTbi%K7kJJ@N$w6eQqUkTm<%o97LsvVKjU#?ZfK~$s2V0%C2IyaV zkhrmq>m|@h{B$Z_?7LEXvY17pn)B-u)Yv!XM6!XW#_-_n_ri;GNs<2dQdra885f%$ z04)2U9A{Awm{qZ^V`OQx&Y?q+G4M^3?+(BXy3x%RoN#k*<^X?SQkq==T?g(H3@Jx@ zQy$lnKj%YDb7xaZ@e>-L?1USM&P|b{r$+)E#+P+43P2*G;;=`$IX;0 z$+ebhKWQI3#4*EWwrUEg>OPa*;~D!JgXn+(le|k~n@wCCo|OGoNe`i^uT%Xo>v&0h z?XQ|k?=Sj7@n>rKf)DSleNw!f`oTy}XzufV)A$u_)-O}ft%A2ay` z98}w$_7~J`n2}f*Qk)eH(^bRER?80j_n@g>{W|CRY++fmY0WZm{9^Zk&D8su4dGt3Q!u=n)wcjc0*vFHarVjc7w1m1iuREgoGPZ&ZAxF%QJ*pN z4~nDF5ij9B0z*sX|20n!OZ7O)wVU6rcv+F~1K7`v@7{LWDhpmYwOF{sO}iw<6s!yATe2kxL#4Xp0$7H1xH*$X%79+21~spe7CNL-F1=YCjx5vp_POy zk9BUI2l~bRHMl%?qd&iZ;WGsdc)ym<(mp~($|86Jh6IC4F!dBpO8gRR0>QgWqZt*m zwb|HMJ8v%F{O+GrlTTek$i7S^DJ@uJYKgAl%Ldf@?8kNk1ic=Nyt4Hb`p_?n94zrBy zy;bc}WJAAmWniLD0wYM(ww3qC0VfKL)En3WMLfo$ido&}4&$dzyO_1*qRKY*Em7P% zUbvJ9f<=+Jfdk4U3r)gle~7_{P2=FTJBKHmmrrNw7tEqWcw0#g#!nY6zd15*7=EvO zcbtMU&ccYanL2xXpET=xm}*>_6NW(eZ%`+#NLKi?Z!4uKYbN5{DV3W5#OYK2U|Kem z9Dr2fz}V!&&`Ho>?!E;gJd^BTFAmVT1slb=AM!&GlHk}wf*g(HcXX;AxbF$4X?70>^ifB_|G4O?Ecy>vX z9FYEsP{^RVm0KzKmNj*&gQ+U-x31!Uux#zJ} zAr2_cQYIMxw*gl>w#e-7`0p2Yy^ToH>)f{xVltb*ce`F1+ z(}%mYL=t5Y5ez{=!nn+EOjrE0)6Yj&a}jDKsc|rN7{=ZgOBB~??p8qOo?5{j zp4TZ0)A!m6zqE6~eHnN)6w9IF<`-+^n-2fD?{l|oJq0|I$x@QMj`Mi|M*XtpPu%S4 zUS=JEHvse6%k;vk6#O#hoR2#^<`SA)Cd6 zF3d7{+Bc+-daL=ghmVbJzTo0c%E&>LC@>o$A3zSEuJQcXs35HcD5xo_^W8fDdFjId z$n=9r@(u4Bjgh0yBXHH-{qy3iOpf85&Iz9+Q>BSb1M+?l7AH-QWJtnfj$5TjHcz)U zee>_SIb#sn>4#=J#yYd=+;AQ$Zzc|8%yW{P>B==KVkfGr?ySX~kLLCUG@kcgT-aFq zwyH%R>%V2>34{+#&RPDOoX3n6gV%ofC|Rd)Fxulf=w7`|1QS>-FGL8HTcVYZCO?R& z4Enr$r%NyJ+-IaVho*IanL_y$QSqY9u64%3s|<3=o}&~RITH*-yjDw~VV=NwLnwNK z`w^<0b(pFruXNG>PzoDbPKMUq24zx#lFK;FKuWM$%gdFZub;POcK*O??W+i~PwUxe zQaVBn0AF!f;Mh~6aUP9Gw-*AHrxKIQ*|_RPX5GsVd0deJO)jzvzS1dEL&`mfMLyBS zpvZT@_a^StQD@43?pI=SE1Jx!&*CL$9*&O3ibV*9UOvcd;|I-_H(+m2XSa3f;R4#z zp|O7X5p2*)bx?dO7cghOf}L7Pyp6vJyb)f$E!s!?kok~}i}R~GX)iG3hA2hA>u>RY zd-i7Zp^Zm7ao{AcaMST*8l>`LCr*TjB&dwB)X+4@N$oz5hkh|Fcg=Ifn`~IcrjBC4 zgJ-utr zShd`D&>&c|;PjCqF6=KQ*J6nC{m9E~?@6?4)(eDa{+uyAPAyx;+O?g;0!`-K`M zK#~!tqwHf5N(j{zmGcz{R)O|+p6m2bcK)xvskH}aITSWljq(Hu>Ve%QW`ke+cPndv z2%|9pHv{?Nt+4^P7NhxL_qbnKnR(gNA1G$%J+ur8YhJHq7i%WFFO2G%Qzh-!VEUfW z5l)Eh8yy;wy4yh4gd(jzuMH|k%0Zr=umlw;m12x_hk0>kIVPB0?W^=J1Z<; zUQJ}w{J6+ayI;)InO-^rxnES!r;`ko1R7D|m07C#$@V7}x31UKT1E+XzFS$`$iK@X zsd0*slDh{qXN`ZOnt}XMA-dm$@EzV%9gECu+a|2>>Abfz!tm0HTH$Lrs}$t$aTWC7 z`A5tW!u893?rb~r{$(S{!FM7UpEojTzUzoyoXSe^1xZU|Jl#3Ekj-ojSL?{#9=y*i zeS816TWHQr%CFX(6XDThnaQ0dy8!9s_d+>CgDH#COaX#p_eL`jt!VMp76-@pUIIYE z5EG&s2SP-{OHb#6MEN=3}E3NIaa zRe#zAL&Vcj(IL&M14WGYAMcC>={cl$y?rG*qXhFqcYgo}uWR0^Y(t5E6$H@^;mR*1 z%PNsm7JKCPZ>RWcdo2w_-~%F}v+gwmFFEJEFDm`?xKF`h`*MCw+{E ze08xs2fpz2xNC$UFmcq9e%{*YM2#(#S2UQ z#n`MESy&J;B6by@qibOW#o9W_YmEUcVmsyh#DLk_5X&Pr@ff}kv{gA{<%P;&x2F%p zb*5Mw12dz?eL>mXyHS*}Ehlz2-h`M-aCUx%3*}Ea~=jh(tDxUn*kd;l$O$;5=+h`^$X- z5LKC8;giTt4bj^T(_P{>xI4!vX?=&w%-@2wxnDB3QwGb29Z(6htNj0AHWtWU^s|Tm zKv7*=&%yohM+^}-#zvx@m-YX0Ti^BYW)QGQ&r(My_%~auU7euY1|6i59$mK`_%E=A z-V2NWRj_yplimGveP*@oP3rv4f?@ayt)SP%1|7?j)JZCi7fvlGROLl?p2YLA6FDW0 zxYaFx_sec0SiUrOx2ki=28-oGENq@OV{ef7p*l|SH+TK@u4Jr4aB8dqaczq1$ z0SGTJ&+_FyXnkGBAn6?a^)Oyvu>m{aeAMZW%<`lNZ5sh8!b0x`FrTUx|HDaF4bL(i)c10ltNx?L7g+cV8j)NZ&roA9`eOG2___8yfSjh z3^~kVd)O7c22t-qv;%ciIU}wU%nN^8Jf+X?6ILS?=Im`_|L*~8!1oHed59a_sIGBu3nLH^V;4-_Yg9eZM6Il_DU~kV5kn zw&Wu`QpOV&$XgSX62?o%-_sb*%RAqDZZACdo@ISaZs=D^7l^W6Z&F7sKrwIfT%ZUF zr>@8T_UP;#(xYNi2nmu7WFuqe|J;OopsH0E$6g!U7%94~Z3+5rHvYM{KXyEFs2IcF z#w8mu^ppMFDZ}|CMO{{qQ4A{)DW94*YygFWi)3jIWWWTXI=1*RKQd`cRAEO@?07lm zpZrJIc_no%F%ZaYgc{Av?Z){_V8-FC?31IOZ%;={3swEQ#a7R8r+M>TX1zi4vdZ4c z;D&&d^txzj{9g*Kia^z30=^zCeXutqu;pr> z8GAnPF|fipdi_=Qrru$2f>LqTezHWDnBL)iO!SlQkoKd?gaR1JZ{2S@CIuIyQqL8? zz&*fDs0`C|r_}6f2+0#;cYQ-JvEOwpT4R`xk#Er@ovV^Vjnlm2;oYvAME*}0WK+eR zB0cNb6dZBD8-Z>OKfKqR?mmKq_4M!5Bq6|Dn2VnIK?l zNoqt6UaqUlff2X-&r1qi?Ovo9-hV=u^xe_$P$%zS((et&@GN#*TARQ!X?S2O&+ZhW zlZXT<4-mCrd3`bM>!U5Sfi_IS7xGNs#22ET#WaWz@^F$2zaM&vj5Y?f7qV{*L=s%F zG!O-g^bkFE zN<-JY$9`}p8&QHPW`O))CzVu4!@O}>fP;*FpZX!LqFOt}(3`%7t7^kn3w3~7Y8oyH zl88v*+I+QY?xgp=D8OQ1)KsovKf?+a3B8*5=bD3W3QY$aWC41DnA9To@neQ4r;h8t zzNW$ykJv;i6tx08D>Xb{9ER9hC=rVddN6zAc@0;^ddr_26Xh19>Ct4&HG=~X_>eFQ z%!7#f$8P_yyuacgkAJ%U^uOIiE0v0G1kt+9joVgFq+wwp)sG;$^5}a?`r*%J{t7i( zp#HTs>|o>0KZ78c;C{!0==PJ-&(~+@!4;Rq!dZSefa#)4o8gM2!^Xum>c=^6WMi;^ zQWxK_M9Z3$Wz1^<_-Py*(XVRokTE=5b^-qv(>r-&Tbh)PmKuUa# zy)kmbl0%Y*l643kTGM};a^JP^ESNK==GbXY^Zu{z^U6BZHFM$8H1sY!_TiDZ4w`S} z-6{b=HJhu3#fw#pYz!v-Pk_b)U0!A*%Pw4&<2t^OBHA$}qc84P;9ai{ePlk!8Vm8d zUvXk6otP$F9s06Eew42%W5d`a{mo?Sk3>#sS{ccFN@v|G`7omMmi;Eg^0!JgoCKWA zu&|M#>&Ldo{YUQ$iz%+g`rCKhsoFG-8Dporv^83AzECua8%lwyD@89f*EBw3ZR<$A zH(gcZ9Y}|{=5x zh;l38{wxUru4PcUdW1tjTqF?dQZ3 z0Fm;Nd)0_J{Jl;Wkd21On>e{(9XV+Rim!~-Msgb>criY-;z)lMDc>_i1$K;PF!fSl{URmfVi=WfGrSfV( z90n#*hG~LxE=I{6ZQR*JJ>p1ZR&qqrVrO(#=lN+UGEzoKjiY)9YCVd068|s^>%8({ z=*OzK&m+BXDy)A=vaP_^M-0ux2g%ca&2s;)DBEuIxKc>@8*pR@hvPt3uCZq8TKyM%mEM7*+S-cTv&(;?Bg+{xB-${s z1!n{GAC8@NBGQzth6lboS={QMX~t!-q~v`ui@cZG3$7Q;5q_colSU^(P#hE4C2=qC zAAM^0YO%Gs`*O#SQhL40m(9S{k*RxLlw+b3B28+Y&KJnZt?QASusphYEcnj_CXGG@F z`18>5slGv%XFu=vEjaeo4gdN2XGojSD>}K;D)0MXXE#B!@{gxqHIG4QkXn5?C0%b7 zk|ESSjlhRR`_p`vMOpyPw{nC$#K?bU%SK^UK!vZSu5Yi&iwYh5hOqjRQEs;1lo+;L zp`!QRfA4z7KqI{$-n~^%^Nryh(aV!Gm;GxWjhZ=&*sS8BV-zHj76NN;{S7Hi8za(I;yoY2yi?#M~1$Cv2O*81z^W0p5i`)h__()UG zxu_t1dx8zAxzg$t$7H5@YB3U3nBXm9K+fdKkkXkD$k& zC>9&|^9>8wqW2JNf8*P1LkN=P`X`!`%XrAlhp;iqozXhii2Y~GsbfkkEx)_(P+r>c z;KXRSMFxh*+VD_l;~nZaK__;M1fHh>v$pf~Qbw>DXD-OMd>0*+lykEa(CSX*`iH|} zxCiTft+P@%722`A-!{IMlvinUYe266B`J!5Nq@Pur@}QCE(J_@C?+XEWzurkYkYVA z4UN(40k~zN`-k-gE&@3h?Yf6LJPNw61*3Fca#*=le0>SxEO_)ibpq~o9^}7XuF=!# zg6g0v664E-+7qo_uP>jvjZHfOCTlPuxz{GTdP;M-U&F&3w%hyyc5au7Qtx!mq<-7| zT@reM7`S7Cfop9&wnuB!0O8jPW|_~$3YKbnib(RBpqp97LHpHTw(&Vw<7YScuxpL4_ekV9X{Ty+QfC! zA{6*^@>E(B`m|dWdzUaF51zQNXp*thHtb7MuMR@_`biO!sIU>^v<~#UV(x33Q~I+W z3_4WeXDpq0&*SPY`Ha&36QNipWgZ_UGuR=6JT+dK>(#jWA+y^!9+yNx0!eyFI4aZ91@hj1=s41)}il-9r1I1z$@&!_J&R~BsERb7zyW!BAB2m=9_JEvJ3 zB!2X_U&5o!x4QUhN-m!<78A9t!J?DJNq_&^aLVZAfz-g-&~>^1>*JS4_@a~D=S~gf zr_zM6q%UoL4IQl#F*_3;x&_>z*{BBY>&pT3|It9>^?P-e5y6{sMG2fIZ>1Bpzn8fQ z)_9D)K$sBHZ2Xl3Up_4+vTbln)X(Uk87tAEIh09U@faT-_y9AO7yWJuN|>~Q$l7%&H+ttjn?(c^AIovxGy^i7v&t*FNY zdD+i3$T@GUG(I);T_Ol1dNsEdit&@xy<$ZlQn!x0Lf&rF3!5t1_6rK((tJqRTt{PyzDa#UIRwo1)yrwBoq(vzG&We% zo&05PU`sWi*6}t8`9`wkAYgDTu#X9h&VlxiM}hk$47Z+3jC!TyxyB@T8UC^4N*+l6 zgE1whhPwkqy{Sde;6#ydiLJR04ofyv)y!9Kj8SxQ?x$n=xDYBvGj^}8c})Bs>dd?L zvMu$xHGKR2#9n&ka#c^V6u^eWVjxPjr;P3%LZq?o(8A*-pQ%GWG^}y4K1&CRo=9{{ z-n+9ee)V?2B|tK9%KdIIj-8X>bNT9e`@Xni?~Vyqf0%Ag0Ll!%gJK;`Xgv6!K&Qhc z;H7{4BvGasOQ1FSo$JkQN*s;fpK$*`)xR%fd#M(ZZri8f|NNVTdT$$VCYVlL_5}u* zE@G}e?!Aa`XgVF&eqI`Akga`VHf1+cp>_myk?b0!07dg5fu1B+h0cHwDAygf>|n(a zp`)3W2kEW@f)78hK2O16-eVAcS0ywZKxbZ#*>-NTS!m zlD6%ixia;IyqBgTSS~*r>exfeQbLck6|ZIwlx-ev7to4EUsg1EjjTj!)4 z(Qrs=SK?YfK~w!|T_y8q#Fb$Ig?6OmG5?%A)@W?Q|FNAtp?udN@~aaWO$Qa3ZNPQo zsV%#hblXV@ami_*f_LA^)E0y}`-MezB<|0naC+hQ_r-!p=FVILdbVH}N|Ll!tslM0$$@B;iMD%rG37FJ8tA1aT-!Djx2z zigWw+gQ@DZIL|c+o)x3OyzK@#ra(pZzq7Xrt^HBq@|oFl5X3`MF^dEtA6)hE*(NyO z*^IEo!BM7mx77Pr&xF|GkP@+T1h~CTp0qF3fOC7u;YVq-R3gUdsavE*Zu)*bef<** z-#&Ji?=e`N(f0airx%9f>Bn=Nya_0&B*iZP`25~%VcdLs81(a(X`Ns|pP19_jt78k ztERlB9E9j)iLB;--ujJ+Ae85f<0k#cEQSF9s|tD!5!pV^RTzG@ooznn5ZmcjCBbB{ zEO&_u67>fhs#p9PW=_4zA&8IQOBTkWc0U$gr8j6};Bj{+7Q3RwdPvA-;U2UDFji^Rn5_QeOWKEaC5Ppz90DUu+X%(!wfglWaenjLiR|o)9V_jm=*~xcHqls7( zS8n=GlEHmJekav4UPVv8+YEAI&-Zgg4twK*VZVe487j7CW|^?}5CAB#<1B`c#8-up z$Z3z}Te_=ESGRf}vKgND7-1QEDCuvsG0otjR8ER@$Bht>lBsXnv4E!pVqy;pjgnSH z5+7|iF&8UP10_VS)?!kI08Kc}OJ_a7+h)U5ia7S}@5X)%xL>aF^(yZz48-?w)A+`?sD>v_1^_b`#;2I0#((`N1h|>y zF;N5J>+mh%=%E)6JWEPK9^LHHC)XIQ z!1dlMaWPW+yQuh~lKCT*bkb`hdHPO<10!SN+*DNmb{9?G2ou#|3I^ zRi;L?@Q!D2tvQ_Csc~RZ_7S>~TXcGOp?axhl`r{u-MEcnJ4(nHpZ4%=e1HUHIZVD6 zfJx$kHzgK~=|i%~kk&|rdLzd3pd-c3XK%~%2GBhfK)Oign(@E-)z|KdFpp4(bilDR z1Q7(D^Qqd%rgFl!tWMv;ta{hmSFuo(VA#iGyjz+okPJG?lAEBau)8h3=6mzSXrW7n z0KmT3*!g>ZQ@d=uilVwLvY+H_fnZOVqLrJBSkDu>{r3i4-3w)B{W0i#v0Y80UYis9 z7C{AP_{zB9{&KH;W)pP#Pyznu*8;tB36lzMznN?84DG}n+5R9{eMjIRP$|vr-{y^3 zh3dzMGXXS);;+5T9o36JS04%TOsT(B*Wm!6f0DP=r^BU0&R>in>egE!X2;m4Puq98 zQmB0g;qww-xOwd}D*`=ZAw35Vh{4|IhKqfu6_rtO8TQ8B&^#fP^oEx|j zDI#FqE(=X8m}Q+?0fe3n5fEr4fx-G^DDST6`vX$@7UP2FebcH;MtOS$%ft;lU4Js7 zHOo+Ay@|S$6_HpwA*wPl6t}H?1J* z;38JlYXwy9<{zK$XNB@HL{-+ z?7dztZ%O}5<8N4o!*quYwQUn7VNP-}blXlWZfn%ffZt>H!nvMz3FK7mbk#Do{gtyk zcW-Ha&vCY_pLXJ49^S&O<$o98s7v35f!6Hx+1v`D{f+s%B++Q$N1ydbzUVBIxJ1Yh zZ=Iib7Wd($7Z$eOxSH}pp3FdWH5Bo-7D{+xa>m|`@o9>=3Zrq}<&h%=J}oo;JRMtP`4Um zj$5QmY&%~Xip>@__l>7JsB#D1rurAJhUX8i)s@Cr{bVko*{%?G=W8`_pCs6ictH|o z-KloUbhjcAsiz>Y_@vWb;0^o1)Ty%zL!Zl^dK1&rH^59vl|xHTpUc!47#Z~dv)jjk z``6zzUip3$%S@Ky@lR@bg)RO3edsgKn(h?@s<7majRZ!2UpY(14vMyg;+k(-%f5C9 zY7Y(D|0C%7o{?6^X07;f@?PRE1^E)~V5Xm)O48VMhY63Yz0)UlH2MS~Vp)nkq#HwS zDUw7M^sdK4);M6%ChwFamgsF?+F9N2OO>@Vjf2t=vZ4ZKIG;s_kKcFy9LxH$N6=ej#=o8=RX^zE^Mlt>G8l{TiK zR2n8Y_?;IfNvTWnYBs5lfdtkw`T${R)$=#r;M+9mu{9a@mQ(S|g@U7QK6Pp)i&oLr zI|w5?$F;WE*}IDj#?Sg_dGY?IAM0dCY#<-nlocmZdKRyZG%U(HX1-hgh90)8hGn`{~ znX)JU=Bb~?Ui^37K+fwWGFk+v+A8;l&~%=e`*5Ru1%u>yM!W0gruOd1`7=DLCS6bQ}C&me~OrtOlk zKK2f-fn}d&pN^vx`}~!+Va{uo#HoSsHrUA$6bptBjkp!3QLKvFRYO92IJ!n zGdN(Gq};PudYSTV^pnxxZ-T9-3muxcuLT8sAg7Zjk8B}Huf;8PdaKG8VxJV2i|$3F z<6H8)sk<5P59rlvD=FGDnF|0&*YgI#dk+^sgpA77=6^Pi_*AGEED|n8-GcLOFn3DZ zI;Y>Ztfpz|XX#x|&3ovKtBDSqy1_ONV$ZzTGGBBb3lPDVGQO2M_u1;tJ}UDIhJ-Z# zk&#{d@bXB~zCtJpb`JL?w2*dZ=K8o+r1_4eyyP-I%TkT9)F}gw0Wo}|2bh!K@%A}I zZu9eps%tM8F~NlVsP&)7 zqFBMQVzF7w_iR?mxXoZ<5+sJYOB;?wcPtdq{Mm!p)dM5+5i=xcz_aXGcQEjUjIe$8 zs2cclEy@#sK&PQJAY(3^Ei%6k_mWCqaFM{ed|x5BzD}_+B23<1kOEqr+*U@5O=|l3 zoEtaUeU~jutNI^E?5l%(>{qJQ&r(+-Q~8xvj+NNghhZ^iHB){yry1GIq>l?&f^e8S z=!`E%eRsE%^iC^rkTfahniCtqre@3y6|KRZRJ?P@H>9$XHT#%fl4LEZkHm7}7r{GI zLVb=eL;3_<=`BSlJu@CDK}AUlbnRAU+)M%(r5vBDH)hL&>}RRWNb0-P?cNz=0Bb+| z#Sw*QoGyZ~(Ub4P(0=^-&FTDq>t}wi2X6yIgq1Sjl#uxma|!k|1~?+2nIH=(t+35R z_(5QNe0q;7hf^1|_GI{>hxIJlKJnG$;!D%EWyV(=K5Rn6@z0YsweZ~tOV;^x7f}?n zeR}x$rn;uI{gJ`)!VL~kG$95a=$K%mLHMaKsrEiYsy%s@f^>0S_&6CZ`~9kGE@c%s zg9%HNX=j9(dK$UUZwJvWzAEWvWPf#@XKkdTAFA4C$g ziT{4Q`h(9bE=vp`ZMRn;=vY&b532A%v@#;hbi{~B=w&Bk8xtmMQo)(3JDz1z%x)joka9*V zxG8bS&J;hBEAsO4_&-qQxbL`B(Xx@$B4TsdQyPwgIkjON7o7*<&k7xANL>jvb-qkW z`k>>aBAEl%<+S>mr_Q@HRc5-6m#1pUDyqWCQU#SoSX#O!N9f~5^r^Ug)^b^LRBc~q-(46HDU17jaw2>u1sL5~5$!=Uj7ES~qh=WDRwhNs)whV*ok4V7TD^GH@^X%T$ z4Xb~U5A>!#gLzl0?tHoSc%ilRw^%xY`_5D$B2O5Jce=dTx<>ptE~{16*Nz}d5wbJ zfpLtoVetOV5%LC?vXyDn9qsBtE9#sHJAn*1(+!=dlYs&4jA|)b{X^*z(}e3N^t)Pe#HYU)W5 zprvNE9OZ00mce5Fia9~t7O!LjT=ZdLK$G8fP8g7?s>?ic_7|L!Q}NKr?ehV&s~^(8 zK!72ta32y^?$QHMXJ7ipqr|}~3BEQb9%@SI@%^q4v^!p$(!$Rg&GA+2F1E|e7ZT}& zKL9X+^0Kd?*3H3m2_LvWu&d<+YnV3iUs7N{w-eG<%esFpF7jC>+yr;NTc!(_!yb|Q zm&aPTV8uoCVG#OGKWx*wecB*ZoV-AB%h3>>O#Q)cfi5Yvv*MLI@h=IIHf=4lH&*ww z()1(WnmVsm*mbC#reK31*@NoHQG z98|8d6DTRd6dq_%m9qfiK`e2*>|bP(4i4sLauS1peubywK>! zw&X|Xt1hK6aYGSlk?x*2gi@eui>B+6x7A5G{b3W2t$`CySDqT3 z?L#u#P^5n@j9(v=7Ke~qGJ9a<1sp+tP5LV{LlKw$eEzSo!Io8E=0euv#rWdxyW&HK z$n>CK^>{Vc65)gF?$g;DS)s*<5M%Vj4^PXSXMQ;ghq4VCM91_KQ?9gMeeB+L)bmvH zv7GW69Apm1Upz=6XNCdsNA}l@XD_ua#|;)gYZAH{<2o!Vusdp7!a35{Br(8GwY^S% z5<=`Z^dl2h$Gr3KRGKwJxN@vjptO{m=CYkMJ1GR|FHM63 z+NT5U?)nXN&-n>--+jg3BKa}H8R$#sRCWFfzue1nf-L!977FU9i&qCyUJ8GRdUkoe z7IUusGs4f!)o!%x=X#74STw>9_oYC0zmigRL?V?QVZZsATH&!oKq<2}L~F++_24U4 zvJqAevi9FIhqP2LKx$GN+lS>^W1H9^WtekR7(ib5_zC4pJsf$0$>9YrU#zzJYx#L{ z4`=_uLQEErI<%pQ0gUiqFX50SlF3Vo1sRWB8xH=fzpwEt(;w?SlixObe(o3<@}sOJ z|G|${+Mbo7?ZZGHot_7H!=j9!@u`}4?zQp>MY3!|Cm$w+(EAC-mFUm)+&#-*3ttEa z(^<>uAGPR7ID8VQtRObY;Px z%5_^Q)O8w-#7VV8yET=6S4?dBT%t!OD z`0DV>P}hD#M!!g#na>?Aq8)LabkIb#JeXIgs+EZXW;!J6lra<#Bqy$iPIw+E;?d7u#V`^mp2nz8SXH!w2!a6k4@ z+E8G|AKEv2Ab+|JMOtv+lUagizVZp1vsVC$u*tgl3+G2$o1|R+vZhB{a{s{kn6;4Y z#4a%G|MzQ8z zVdWqG@7IYx`XVii&%oYiBfLeyiCgtR&v)G&$mB+CXlRziiznj%C`1n^Qp0Hk_a%&_i)P$NwgeSwxxd2V^Y?1I=6DP&F7t<`*V(P^kiK}2X>;Nb=5rl(SWE0>R$@`z)&!xXH#r7)TdCUt7YL{ z6h}8jz64!KQ=_4q2$v#mqMo4x0^4^=VA0}E_FS~8t}F2q12MQsO22Nz&W?$DaQnB^ z_ZT`%Hs~>@>H0{#;D=8q1}r&8q8i`P;&Mr^+?dyyb$4`G^&=ebV-^B9#2$gM--VOw zbK?^|J=~af4E@wRMt^ft+47SWx)@_eH5yoDy^+%WEBrO7hw?=?#hlcfZy-hZ+8zVp zdv0`vi50`6F=63rV7TGMj(1q!&=anjY`gu)MuS^`SRckjRFN_$h7ZOQVFfLUxXDA6d9Rfg77kn?f__%*`k))ULX(6fD%z_nz+u|vPr0VZK}2m4D%0G;r0*J6zRpuu;^3Jrfg}`N4t-1YA>lA>^yp8B?Ig9O zM52}^&uFD~HK)9mFOED&R$L0w)_TuKImI50fq4=ox-xf~a_|7b?wyd_Lx9*719bGi;`k#R{jDcX=L zWa}Tv4|oh3N>xH|F!cD0%$dzzQX#ZZ?z}&q9sO)L<|L)X0k+v{8=1@RM7ly_7tUw0 z1CH_tmELnHCRA&ourZ1BabzaDvwKF{nb&JAyVEdCSeWoh;%D368e(U^Y3-*ah5%!V zpcYz>eVfRo*I;A4cW1wP&_;c2h!D$fP1_<_jR498pWHp z^AG#K;P#L3I1ctraf-$U-b(d&km)Tsht={+6M~{6w4N(quTc>p|4E}e=ucybR0hRq zAeCw$ex9gy{=DSS?f$XmbDt8zsr%l`o25ein)xJ?p)bfalxW{PV6l12?&^6-?#qk; z%pX06g1no09y0VMCpIBKOCodZb=Ujj)S{gKhRXE?0RhXp+1*7zIAtW-E zeIIz4X;?_#3LCP?g>Cctsfie=J)}7Q7Dhq%%)7tK(EoSKCJ()qbNB{6ZsozAdELqX zq3JBRqI%yhd?sk5TUxqPkQ};8luiMWZfO`wQW^v#C8bju2c)~CM7leq>z&`b*8d}% zbJp{mJNCYIKO{GLiUFW40AK2)9={iMR239wP_J(zJ~{}7 znO4bou50$uhg&Ls^1;7cw4oG16q{h*=(n2Mss(=jht^kJV}n+(PC?snswi29hR}an z_Xg%5u&9Y2P{5hMaiND6;R(my{NTFo#+Li-K~pC+b5HC|gZDR#eAH{{lj4=gZT_F) zX8eGe3EpYOO>7=r#7A-Y{;pzPGGNM}wrnZQ?m#vh!wBNd)(wFZz!u~Ip4n11 zD^<8DE%}vG^`O9lKw1GGUpOIz0iFQ|mk^3Zv+GJ@L93xuP-2O&?;-?ndts>R{bsUG zzmjCVD95{Iio*Gr>yqo^IhjD!vT0J4%vMh+2%LdcX~m>Miu9!7bsLPJh(!(`@N?yK zlI)nR7T!4w#uK9&YIk^sME9Y=%%%nP$`>quDTf>@kQgrr0l8_GmL@2iTdDJ$^M5x$ z2x0o3DklnUR3OtTSEpTn*!>taSJ8$74^M7O9OP$g5o(+I)0o@Wrb!Erm7A4#V~_yi zZ<|N)An1wyCXD^AV7&V<5U+!>ErJO^7Y8%0m#cL2TFq*YU5Uy_f5)?xlz5+wno7sokLf| z@!o1;xTz3iaSR2EZO6$1&ZD>pb#6jpmF)HNzIPP>BJC*xFM{aj34n*5D=(EV4kRt6 z8jg>gbp^}L;&Zk9t83JhVbrN=q;4=F1JJ*Rjx6xv;r;0Gh?=|MdZ3@Ke>Z3K3TY}1 z{!<;l*j1`r_daNKeVt@3-}1xWNIB;~YrIqB=0-aSofiS#$|h;zOTn>SOS0bi3kk~j z*K&4%lx1!yvlkD8jP`vv2x!UX37xi76QB-I&(y|6(mDml_}-57(*d+?!v>RV5DL~R z3waff#@5k~24}1{*j*djk44EH3X-iM%m7t!*{EDG;RL#`JSiI7u>EED@6TcA=CK`EP+B-k`Spj zPUs2&s1sFD93=g#=%ZzJ*{O#KK?JbU#;`10)w3ntSnUmjVvPT+kmGUKy_)LC{G<0b z{GS^-z|i4T9_)ejgXFIGdL<&dgvykZg){`0qi@__d>}M;kNJ7;k(NeTSf!z^zQ{Zcy>6??T?8b!^nRDDJZAcAJNDz8wN)W! zHDj+S^Tn&u;LcH@K?v-OfdrNhem_b7AT8V_W5$OAjElV_|5WH|60p~-r9osG_qn)t zjuH#lSs*$%bL5G8h7jD`Ho6C))SfF(P)^-Q>f z7nvi$<~|T-$e~(OXH^xg%?ZsNR3WKZ88N8GVYRL4Uunsp0~C&jq8OYN(3F!n1H|t+ zN}}Y_Z<>Atu~_>j@VQk6nsSj3jwepbLjWh@Nqy;|Wg=ztOzdRJ(oFFdAm+S&dTqr^O0G2b6;Z^t*v-;%0N)Cy#BZ!`GPVD4$iIkqU2?^%bwHBQHIFI+)uXgMv;f;}C!H^J6sEGN$paqgx6K%|J_dw>eEB zo&G(=Al5uVx|8&FfL?`bGWPDVP6{nCX33jz1{fH$NJt9+uA8lbhg=WINvH8Womv}dwpfr3;WD!er}sa@0<-kd8hWN09#n65(b>T;7&x=Z>dW^ zs_PP8bBCmyU^G<0gCY7pw=vg10S|LqpOz~ldBz$haT*A8UZm<9yXtU)bDMOc)^W7Z|b+EvjP7!;Tqa$Iwoyj_j=`1P6> zRUjh*3h1Sq2a!0z6s=2@)DJMmWwVi&Q*G``qqVIi0X#fCqty7>4X`tbnoNtjy3}{d z5JD6b5?yQ3=3w~ygN4QI+MQ8m0Nv_C`dZOH6zmTN3jj_Ko8S`_IvGEo(kC5|S)>`1 z=ezeaw|Q;H$WO__Kc0K3&F$y9m_Vo+n&NEUQ7_XkFMOk6(=xmdaX3#LqXbsK{lS8x z{_i>60fayuiyemA2kLMz7)wFV1oN*W`Xt92!uKPhU^eX&g#pdKvX`gMl`&t1wn0Go zqiGk=1);TAGfRm+#|)(npG~+H!NNe^;i#`uDXi+u_FZ1)`Chnu+zQggN`S$wgxqG@ zLBZZg)oP|K*Kb76AW3=35K6-eCf)%)Wf4M8K9%@L4AgtHMR>%qsG0xOs*oI>gyhvS z$tcw)*;RVK2FEF1`;rE0nrD1@LAeA?v!wMd`@5F045yB!p$mVnvNf zfFLBSW%pJ>12E%bVsyy7i~XU;&Cxc(^Zv*i-S08xH{JD_L{9h`?EByu99sci9B*I7 zDsy|SIF@d5OR$odsu)gwz^$8t33gcgWB})yT`ohmm zATsw$ulfbI)|Wi}<{!jP@Jn{UO?(1j-(4-^=pXT4-4@JfvCn%0@XtsaY&+xbb@a+yPaPqzZ3uk!tnwZ4x-h*O7~kz#_eJSr<3v;}sHSv0^vjD~Jo|KVbv)E?VbH z#^5Zb>E@WgZMvF4JeuW?F^PTnmb879SLI0lghL3?(-E!y>>(Ec zSpNy_N`}vUU?gL`yG|HT!11M0cz;^gmxw&1eFU?exUFQV^hOlxqlvTr#Ty}9_G<5_ zgkE(iY1GK0k#XSw)x#@1pAH8%^+K24WxYus$k_hH$iXcd!xfw-M`_T1Ic=+CLyF9! zB1k{a48_X-8($gUmyq|{-%xR&c2B18eJWW9t8sFl8sX0pwDgVlr+>a_)c&}xGN@E{ zNeDOZ?i`~4oQZ)A2jeRo2$20zk3)ZVPETVRyP4bQZ7&&}f#2dIEq&z~LY`9yCoTN<5QdX`1!0K=zkt<`o^Br`ow|^KLLu9O>G{4$>OT z=A{0(YiN3PVy$9OM))`v>SQt~_YVktrZOx?PJidTroQ*6YrjcwHH=~#;IDlN=Jb1I z;wII`0?-aqUtt8e3VBp1kSz#PpvEuSZoJA9Qf|d1z^e}PB8tH5D}nqB>KkNyj~c3j zd4qkc)F`TBv_&T(j4_4@2y1ln0rHwB>p!T~{ zMtF|2v5Fi$-%b+(R$%I61e*`lXHpWIgasbr9<`}IX;8}5tLshEtKp&p7CH-x29OIwl(N;KEtCmJA}33?FdWs*4Q&u@Jvcos?Rj6aScNBTuzY zkHo>%tWTEGLJ`sCaM#RtKQb}f=O@?@xXu*e<^u(xxnhKwhpoSz)B*3ivc1#5elO+sJt*D#9uLA7pZqHbWen3Y(124 zd|yK(V0`Bghght5lbt4S5hE#rB^@tk#Qk2H7%ns^VzXlrelQu-itfdjxhgyPuMjy& zcEAfiedS^uHsFixU{~1DLAiC3WXgaG$IlYRgR!S}VE;Lhwn-&muky*17++@gVq5>| zqXodw3luBMo&Qx7#U*8J>dqO{ATxHQ-j2ONf75(04FcI@i4L&WhgO7fuyU*O6p(RS zKP4nKZpdq2cLW0Z)d!m&+SH#Tf9p&C097!+lrU0|!;3=3COfMQf47cVP?BG{Ziz1S zCf?o)UmD2It^QYpAYu3-)3U)IWtFs+nH@Ey(95~BPFFx!=oz0Id{dK}B zduH_UJ1W**K{UH@YQo=X5<3NKOEqyZ1Txb!H2SLP&z7pMc%%J(=F@rvTpk1%#7l3E zVPrg!aF06HZO_DktcO&NANq_zsfL|#BSW911( z1h}q9bp!`VTZc14g5RoA^Rg%WhPJK8dS2OMc%@-wrIg@Ny5IsI42M8Gu%3Lmp!-mj z-VMHjQ-Xfpjh$luL{8r@0abIe+yOTi5&+?y<6i50XK92t#cYwY!9)za1}pv%F9Qop zJ1^y)y?v2+uga%MJIz2>Rzdt@Db#KSpE^w`XVdDz2nr}XQ+{Zju6Vh3VY8O_dSYNm zC*D9$XXtp((8ifs`_=3a)an$g8R?ziM_~~3TuXlOaRxx_8vf-C+f?z-AfKw;5J7mT zHvcC2QW^2i>V`pDSEa(W&d#zdzF}w8@lU?&K2e9kqTow}86_3K84kcTV=b&okRX%P z#X63n`t_iQDhV5~4j4#<;xW~pHfdUQ-<8J{Chx;^{Blc_vNMVHl6BjCSG1^sAD;%k z!Ywf8W3BV+{lfCI6|Vg*PH#0sQ_B;-1gDTSKSJ=`GtUE5(AV$=BzOV<14vGBqB3htbN=-KxM93rKa?`ZB?tZxX-isry_C& zf&PbJx@|%V-JDk{g#v;avNGSPuG)%GfQ%WzGH+u=TjOB?S=y@&t5>`JO=R{wHwugn z7-Uli?)NJsLMC(XAwZWrN0GoY*~zgaFmUzCh}3_CTcC z)JwNaQzaZK%Me%nX1wPnq)1GR8+~!S`Azdg0*h8fQIp>4?aL+w5IhzHK%V=>`bdT; z^?phsU(TE!HDne&R~G;QK_DOR6aH7vUiyy-3U>|I{KUwxHOBsY{DOzck_*%8#r^^e z7&;dN;7baMYHFA{d-qrFt#OHxgSX5gW($*lHoT_YCRqoTig@hz+|-g~N8lwoZ^Paa zip^fg@)cXuu9&;V0V~L(5Y60cx#axwtM4-5^#`Uh>hmU&!1LFO(i9w}+4y*7z^shq zpRP4_T=@@Q^55q?`p*El?-PV7J0F=i+#b~i`+k3(GRQ3nOfB47&Q-%!EE4hHqSWl< z{RB@ZO;a7GT%%*%Z=@NfwbY{*uo3OGc3I`RI%dwqG!UTrtj|TX3FM=VPP;+KW5z$x z#IsVsmw(6S7?9KW`U=3K&Hd7^$_iJYYs4o-42BO0e|A>v6*%YNiNCT`J+eM?$FuDo zLMc0W@!2}j*mP6z&p80(HFg?EC;8?i-)FP=F3d;;g`&mY@$ujVjA65(=IeBJiIL2x zP4$NNk0&s&@<<#B8ikE10I;ObAZv)nOh78iqODGt234vME7MH|yMFp+Zp}U)R*pEh z#fz*N&)W4IY*nAJ#e`*7wU0Gueqx5a6Zq!;*3+14g+~qhbQJi{3Ff9`@x!ZrJEi^srw*QurUWx@1^oB*f0@*Nxu zjeb7Cmo%&00Il;|5zl)%3xh@XQcG{GbL~mQ@1QE7_YT+18CVO+IT99+Mvw!HQSFZ+ zZh7Z|-lUbD-h(NWoAaw-COMHoKmgP2UzeHewfM?T&%k?F! zqwvV7lcYR;7FQ{?_jhOkjpi1N_b#J%A~oi+ygJqIdJ=0JisX^jPqTw{ihhV007t+< ztPp_fOa7@4ge2OShk)YsK=L+1brfm-N6SO%j;?3R=+eom0Y-Lr%kNUYrOD5KxbQ7+ zKA1Pu%!#dC@(~Jt;K1USgkE$B>@9k93Ksqy-nhJt0$_9HiX0G~H{^>>NkcrVm$9aJlWVqqD&A7Ci zOm;__JxaK`S3%4c47_j1!20tRfDSf!N4sYu{e?J?eh-X@v-QB~9r}bw&rV5ET?JF$ zY*7FDn6GF1p{S#eKXz;zn>&B}Ys_uN4GV7q4#^bW5slVM5zUIR?Oy;O#7d8%vJzUE zZmF0*s}Z*_i~K7H9F-GvrO0>v)yR5a`CcWI><`A^`qBl*@YS83%Fm9%_;k`ubIKd& zlq)zJY;a60m}s9BPk56+s57eShyP_G1Rq$DYH9H=Tb^yYl?r8De_!5uOoSHpyt4Dg z0XCZ~&MIEEKO>wT#=dv7G7)tn3*@Gjgjx;kR$?F_CSLe?rsJ8O@IcO=jc*=`e#=RQ z8(k4J&bYBFmPmdx!yk7W?BZ6QPN}dNP9bT)6Nov7@&L56ToPu63GG~Z<=r?qsSs+w zz(_&wYxpz%Cl^y=^X6y&#I@&8p&!`!l7xEBL>H*j45904h6;Lx%#Hh!rDCH1T)%Py zlie!i9SaFV=s+j+AT6b%q-&%? z>ZfUo|FWhMGO~ju%kj)u`(vKjh>F>YU;zcm>9wm_+z#e#Mzxr zS>-AWq9m4fQ(Uxf1mpaXK~S4};>4?923I~TE=y1{^_c=PL+0ll$uMT-!A+?`a8*)D z2Me1miv8WUCcdk?bxEmaE;L};wU9tmheGWA^cuAb)+hvS-JcDB-Rz}?&NvOd!*J*+ zsEGauaffK%kFs4Hgs^!B#^BF(^3^j*Lczi{a!8XJWn`gNTZDvGJmCu@zu7g*or5|P z3&U^`3Z4V&3bnHOqk7#kew|{<*8&h@&$$&q?Hv~aAb7^4XH9_GnYQN2r)@Ia?tl4L zmdo`GV^zBz!R(R{m+W!j@t#BI)R84=K<{$LGyvm3q1wp%6Q68k_E}%Y=7W;2P`;`2 z==Hp+pUAd4wnc0kEebKXbIhE^(KDyhlV6)CjT{^OjZP(RB-hfx7XOeUaUG|;+WOCz zt4vM&ZWf|4WqJ2DJ^A~XgbTv^CdX4Ga2K6-CBh#hhn-*cY>*$csU%6*=~Nw{u~!|% z{&%Q9El(H{sJs!!EpM7z=gDUB1fNE}=4Thu)M_ii=GPtGMg^2n1t9vEY0!|tPIaFJ3oorNsx?s z&b=Gs!zOwm3Sd|4rgG`KvijL=Y6g&ih3z@0nM_+u*y`3}d>guzq@DvM|EOa=Pq@P8 zS}mN0pY@HFG@5N&I(_jWW_Xf%cMBEh$1?MH98SRNI8D!ZVYwjXin+{h!D#g7S2HuJ z*^5`CwG=398mnhpTk&6>BkLAEm8E3!Yf+WyiKcG`_h^H+(xJjaaigY+BW$k`guP_D z$En*Z78VPrsYmmBHcH-!ZO$Lmq1u31QWuA*36G-E#J4=hz}qZF4cGbXVAP-0G`L`| zGW9RkCK37-ulCDy6pSIlQd5PzMP*dVv1HwWzkS?YEI(T*hsk%1{!a@qpTrV_OdEU@ z!vR8N!aMje-9uA(LGej?^n&D*xR_g(F3H}o)Qfk_DkOirYmrH8TVl+qMj{oP%2##( zi~z(aBZduXVt`Q@)$&AJ4{|(>L(I;2dR`#yF?1J(GzKNDC34y3S`;)+!(^*9dD29* zW=p7l>Bd#osdLuHBYm{z3_AysK}w0ELpG_nE#ZgMDSz;rW0U_T2*InyfS5`*RN3o$ z18EysWmKTRKvtXwbFBjFUCBz=-?-!s2H;lbcOtElJ^7@Tulr;V z2MgwC`{5whUp~b?dE0&`DmVZRU$)?o!Uwv7-@W=D+7NfdoykkDB#U2YIAImH-omuz zEvsF*+1Fk3a4GJUxro>vRY7o$P!r7&c%CZ=(pw{elcj1_mCWkoA$LJ;~G)422PTp&4-wirRma=O+bJX>+OF1CzV$j z8hUQMi~~l?3KI7(B+Ov2-XNWX9SQxrp)AVdVFxAeIX@oVv-;T;5S)c@3*=1FvLwkm zVdn8t=#m8~h`1^Z>A_;;b-|i#!UO^;&Fx7CvRCuZleV1kG^bppsFhbhEAf$^Ub8p_ zw=vfxuaeAN{D?5dby{F9TMQy4re^mOX>1oP&c^_33;hJ*PL29otE+1K>u#y~b*)U3 z+I{4Ts7S3(#SH_c$GLtckscbJm2G0g2jKNMy%J!CtXjL|Fu&>xLjx{eam3A#5N@MR z>YYe!E=SaU^<$u)30zHmL4v>EshEU3nAz=m)_!@GnrRF8hxepqhV2#7si1g zH8etjkpoh|2KahYzNoQ9F0OUO^yjSTGB%^B!#5k!(BMR~u0k3*NQar%R}HE%YL5y! zWJ{Bn2+R+y$xxaubGMYAgX;bIbq`>yL?XBHU|JKFNg4=ossSb+%%BMDknIMg1010>LTXfwN;_xb~Fjxy##Z zPV1Gk{c@aK$BR?8YP@K{l>(^X_pe}ESO6JthNN?f*+{IbrP?W?sM6|K>ICYHhI}&z zd@I$rPyySb+_6o(gjp#>9#-q^r}=1iWPSRd1nb;gF90%P2VRvYUFxN+9}Ql6{IZC1 z+06p?{MVOk)PEG2?HD+tkp z0$jqbS0h@R$+zEoGPhcuUgeUslGEgD(u-F0)q+&{zSn$rLFN|WzWd^{&N&=Fhm~gF zvenX?8ap9%pH_!dm$i7gO(vVJn)=JT%QYS| zHb-ah*f4(a8^#%Q$+HCpEHyd%xU}WgfO!j2|5!#Qu5y%%0;+?9v2c zAuiBZ&st_M&m#HiK_MsTT!yvZ*(yeTYiU#TqgD>G3&jFfe90BBLuQ0ueB{G5?cX*N z=r(VZp}UyyqxQI%GO~`AB+JRW+V6&(X5y|8-ADSvbcfDcfVSm zc~kro0hkey$99K&t?=3nnkB#R%M9$bJ9vY;THaxq8p~NtHnxwzG#O+&z`{MXtTP`-A9cNt4Z6{4+zJwjegk!PgVnF~g*q3?H!Ndx}+lV^@ zG2gwx@A|gPdP>{_x>n$81GA6DNo70{W#_?pl~Qh`v1^x1<8oG(#@&geMCijP$nXdv zdNLYr$CjlLTLFuSH+?SK?!u#9bXx35Vsz$(L7Wu%pAwKF2-l2FyCY<}Q9Kba_gO@x zJ-cVbA`+fdHW@$sq?Due;UY5jiwpOI*l_fVvB(S2EW6B;F(jCEZTSr{x5gG&Zr5~m z*%_?Zi#-0k(p_PoBxjYD-E)OB{*dy;Aw77bpG-9_6Dulk{a0YYR;u^!vhf1 z$~%KqAi!;GF2wbevg$tW>$cllrcg5mh_ZYkJ7ciUduyW=k}DcH4N)X@5Vv+0gg!JL zv+9t^hLMEIOioCL?#w0omL+_XQ22qg>8XkVz&jk~w%`=tv5Iy?U%@f<{5{zi_BVv@ z?c*M(T9b|{x(}6+6xBQrGUgu(f=!i?j#_*jgK+Ni<8~FePbI}XML&{X^6M+Uk$N-o zA~%qqyELZ}mEG!|!rgQIoF{Jj73QHdvLRHm%U`<{O9%f$jYcb!=5tk-N3e%2}TJ;=Q?+32BHXKEyZWJLG%kvehC*;xji^_1$0%=Q%9ora@VUqx4;1q0lx&Z?hWt@Wu{6&^lSqR9_hJj*bWi-EKx zo5J6#M)2yrU}Dox*7I5yEC%4co^FfefS^_H3*+q?M$Oo^J|(nU#(N`&#m^`7Tcgns=5j8f4)vcFNO@2jNjFge2JE=S5T4y;u zPc}Iu^LUDxbA|qiY(SJ?nW0e3i}T}PqHUt?BUHfXCbuPQ>|LG!xzb3B24`kZm)7~o zhq;*2mnDxm5%Bi|5VKj8nWEoMhV(Xd9huod&8pVC8(F?HF`%e0G&;`tFM(i1#rAqhWf;akMNQKOcdQ2Gn3kMJm0(;Dj zleeX7EdS;v|Lv02zp2b;!`X?3l6i{>B#N?*BQHn~6NoLoF&~!MHPe;LJIQ!2F(&bhe&d2#oyWAY1@nQ;XCHh^>yQKm70AlI?!kWPG?q6v$G&mL4RHYzz6sQ7Geip zy!lA_OK*ru&`_Wl;%qI^?62zO^nMP#ON6RFiMaMi zOA#L!V1Z7Ri(Uw|`-d)Cl1Bcp%X5Y=U5FRm!{WjSS_v*&rms@`Q>U19fHmeTSkrtmC zPTj*?8ZFdSeP8hCGoTt!vSD$FqxIO=WR8aO-Zi7VitTDnfnB$4H0uX~mMIn2Dl-a) zQ_~JK?nPJu^m6qw)M`q&7GV_*AZ8Q)7G=&|aMSgT#2^CzpFb|Qh6ue$EO)3xnDyA2 z;ECVyk~|}5@QAJ>Ljv1up~<{>xex0;KqmdO$^@e)$|q)AcyFo0^DP}k`!{>DpELd- z5Fe13q>N#-brK5{z+EFbx~uJeQ2o`OUMiIN0i^&8-*p~iL9%t5%}yr754HDAH#ZwO z@t?-OCQClV!lMS%#kdK_zFl|nCjuiv=Mj8pkZ`$E6`EhJ!1Tw_Ti$F6;6sb|p|v&V zgyq{k8$>Vk1NZv~qGW4qIogM}epiFxV(9Jg35XD2W%p+oCTk zP#MtF&Ygjy@pEn@(>w#ZC!DY& zKwga6$@*U5M~XP`ARWL%YX+Dhi(Ojf-lXhMSDtzA_;<#S1qHD%xD!=Hq(_iKPa7Tm zizmk|uya7@V4O_oT-DpBk`#^Tiu$dTBd_{pzw}9LQ8a@NY13aMb?V>^>!K^FOM#M} zdtM1&+HE>Kvp@d#s^$Kc7fIIeA4E@$*3wrbAOunL3~OLmZ?T8GRn^zZpvQVXH$Sc@ z+|Mmzl#mq-H_>8rVDC-4hlJI`*JzJVH3axwS~JrELeGD?`4I3hQqS~!q8+`oLm=RY z2;upDb|m=AY{|V=Q>KxXeFD+2Ec~722C+8t_GSH|{FkfWDww-GT!CRn`t62%H=9<2 zRX8zFqE|h6SpMin3>;?U7$*2JU;!i*`Y-K(vPo!)>QP|rW1ly$7d3RH&avFz`I$oYDNjVSi)Fz0`Usr(VzG+^_jdt-hqih%Pk`4!-hKp`^zDvbryL z)y(#O6y!XZ10S+41B|v6QbOJ+hGyOP4KKGbcFi!SKX!!VxL*A661QS>@hR)wlyq%c zB1e-G-nMcm5&B-H_3X~Zg{@0O5Lii~Ne-3alp4*RMu~TvHVg;=iyjAY#T2`4pXTy^ zfsG7VVAbkh-E!D@gJu%-NIiy7TwEHqh}Drrv#9MR&Fh43DKsl!S~JI0rw!Go)5eqXA8Abn3m&A&dmV? z!TJux93*$p6b?uOdCz6I^JaderL?f#J3s%p`>TU(zG$<|&H(RZ+FJTd8&2YbGPkGO z<9O;{cjo{7yTR)pxu{CI5=VPzZ>WI&hloaLF902Vm;%r{r`F!G>`4l?wln4Qt^KDq zVR%;X+4Kq9vb=^;L3}5D?7$0Q!{`iu4r>o{^w-=xUZ^W8MznoC@E7aZ_Ok^LYgcsO zHLt+&=%w0BrKT4%nKo9`KZW`ToF5tmw|`wZRr6_VV3hQbw2V76B%x@$?~#2Av{d`* z)-$M|7GN$-MGYrE*GF>L$e*h_x8N>YNcq-l|E-R_rI2~vY6mXdS-13 zA(fG>;wxJtRulGZOa+OC)JiwUXgI@lj zg=Iaa!O=(K@2{)el;~3qTrEzL9Olp?Qm~>Gk}>0ei%_l0P2(#lY1xl9!a)y9H zEgXk=La8MR<8t!y?~~h`xPirX`|l3s1$lWow};;~dP(ZaBKN7Y5?SHz?kpe9bw+)w zeXZU+5lYNpo${iB_HHbUZMr%S0 z=DIh6I3ua02HNKST-+zjcv;*wgtO)Pmry`8GC>*-Hx2#{2eiZSV=V5?W0BtcHxfp> z-@p=qWQcf&7w_l1su{HEQ7XsRZXJ7(Z57iQ~UU z_fYt}MJ`5$j(?{)p&(NMV>Q#IO*)OZCzkG9u<)*%0I<@<(q#Vn+WxaPiA3@l*U~>8 zLy~WNA)h+47#LjdNeoq9GI)PP=qT4Z&YXwO{ER;2$2;)`{-ra)KVt_nr)9cbzeU=7 z3dyVMLp=?#9MsU$rP{U3F{Umub8Nb#rXX+#x_pvbeds`+M8>~})RT>gVM>X7FQsAz zkj&3_RAp9Hrwb5cstS%bH*|BDSjnYquRm7puh}cTrBgA``=TE8nZ$!jd&BFib!z5i zDkq1zPF)buW$^bzv*a<@JOuERKoM96fX4P`M71iV+WS9sD0T!^q}~c+0-CCSi6onU zlLIT#EZxp4VweTjP7I2E<1kps$uqwR8C0f9&Abx@wysj*1bkC-vfc`6%Hj<6biPx1tGMQpRCUjZ(>(PAQMM94THHzC4 z+Fe7h7^ejYImz`uLuF!{%%v2 zLlq@_Uc|1BcS4p`zFE-Uiu3z7YYkcHP-*c^I6Rh+iFi#;w{a9aWhtVwVv>-Nn7f>S zV&y+-V@LFTi@umsO`>cpCG4-^dL14N=~HV+Y*5VOMvh44nJXHEdYt@FGR=>PYR0Oq zv-Hog2D7R&N2biKsJxI2NIg}5d|J2(LCB?(_AM%A+uMb-Y1gh>+1Vb4$$=^|PMiIO zcZOi-mQ;1zby{ zqvzu4R%VHrG>{((!}xw#J2{A_s^-prDnn`le(7)^@As`uh+>Zzg&>2k%ItH=0okPR zB*cv5D{Fu}+x=ys&r3St)UrY$5<{v6YRdK7HH9iTbeNH8UAZ@vISli1fC6jHYVwxFhota;zMoKP)%fdfi{KO=VZr;rm{t{$zO6 z7n`4=z(Ox+r!bg6kg)S-<&&V&&Nf7UccTZpxS=oA6l=<8Y37^~%r@PE0X=QtXB;HSQmzz%Nnc&ty%P z&!ZOaZpPXqn)!x2a&R&B(hC*a{rYyZR3QkCR909PMz7H@>_fpH(fl0jS3Z>VizFG8 z=ZLW|$XwYcZ>1UC(UCE3j#(;Z9P}G9o=4hACBwAA>Q@ga=*TkEej6Fgu3vM64vn~;R+@E z-XzL>zgky2?eCJJ^4b9X9l2_g-l3m2@PXdvDj=y|k#Ak?R7e~pd{8e;W%OY&Yds1jS1eu0Llpg_?l_4(B;7X+Kd=V z{sHeQpX{?|090?C`e+ml_f4AN%KH6Mhy`_u(w6-F@b4r9sH&tgOsr0ImQDB~3&Y_s z`IY>WYc`N?Kf-9^1&%!dH1Ab7pliHa<%Vva=@(rKHnd1idO6_eY0q3k=Bc z?~d%#=V6>zP%=nT*m2G^jIV8cfsnEhsmPTE9?X;VlgV|vOHjk}SRVhHpBYI3-bvEO zM8>3U`0fnmiLT5nG4xh`%Jb2jVR_dQ#A3S?<<;R&cy@PjuL7#p`s^J2h~bRCYlEoI zzQ;Q<0mp5iC=&Z}lz~FWmdCQd<0p5w1rlSo&GaE220LB=?-Tgm)rFWwt8Ke39>#g9 z+^%K0Df;CnLH~*Lb0f{2p4#%KXaES6T+Vn(Yp}=ku0OtK+2p|b#Gw0Wq>&-8t2R1L zm*AD{2+xoV2l3mSpx^XXAqKW3ZKxx>?j`_&=qFM))pzif`y6m52I4uwMl^09=s?W% zlKya#S8UmfAT7JE>pkdFh=>D2A*ZLc<)>mnV4On^Z%-#Da>oLPuO>hGX%D;UyiI!m zauVq-!1d~71*1(W)v{3~H$5_gDo)6u8LCP5x%;?{eM~$~{`;ITt!9!OLWwrlq}`Vi zO!kBsci0tR`++QiM;-5yY2|7B_gGX;%FiusBN$-jO_&N(MocE&-B#bTVk4{)Pl*|w zqX=@n4PN}wJ{HTW9pw#v<-j;|5P43T@>uwEmUF5|JncB3+wpN3zi8a3h_HX+@3zM2 z<62%mG!rTvmt=K+hwYt%H%l}x2LTEcDIIE3?@q-9Ydx{C(^~UMwk^_}Ppnqs5_(hHfrOffXB4jZM&3*0KHO$?Yxv80?Z26&bK@(RWrgn2)AF{qb-1UP@6hne&uhpU`@TT$6NvO*1Uppo$Hf575jWH!o z9fA5?(fVms5tM^kr%?-u18>5BIUZB|t==w8>!XAtlZMb}HSYsPl{C4bAOa^6+HS8^ zJirVKbj}$+x!p+c!W@~HaH8;W2F=%cLr>OCHR`L?5I~#o5kXX`RV80xNQ>i{8mCeX+06wHnZ)vpQmo(Ux&?`Zw_e(Pv)vnSncYudBN<$;TcnZfn=-vV4D6sY-P{0` zLx{y5=emdxW1QtMzy6(Zk?`W@8s6_P(tZz#qrI6|!Zu<9$|+;XH@mzpFh*4g-%}uW z3l;~;IMP4?rGvU?PkA1I2Igo+%J-TV?HY4uIziFP1KkS;w>_QezxG2wfg-VkkprKR z&v^`L?Ss%Ft_q@Z8aJqQ3u(YXMNQVv0NWLGNxW);>P@@z?>fuEz{>>mrW(l=v6$;9 zf~I}~xOCNmzRtH@YZSgR0y~qt3#=V66eVUGX9WnsnaQ_QoFra;3FlTg=%~CmT*ELJL zaUMGOr;(Kepe3pBA*`<1Fx?i{EL#?);5ZtiM!Emo&NVm5y9EN8{O~umRz&FW!05UA`V^-pZDKQCR7uJRS5)Efp2gp>_!@8|L#2Cpq(OZ5 z^lOgjGr)PpU{RJR&`puR@*X{J8-7`6^QEJL!8H^OX0$-@Uej3$Q@os-mVMb3kpB3K z!t>Q=U+N{L7ZQjb$FP%dF=pipfINC7Y@o7gL6`Bro&Hi4Q(>L@YJs;oLBrlG4>}r4 zHn>)0;s`$zsmbHVU${@q3!R}(d9k;>)ru!vPF?&TA%tOYpjs-i%a{~|@Rl6#di>6y z88?r~9au37PbF^o(Or5_B$96Mw+^`#-$x(!B#barc~|4k=m}5hNMPj!;#*enpw)$y zywZ{DD#7uBd8=IhUWDt$3e6`K=Tu?WVOx5Z%5R!(f0oHH2|XbfEc?UEhBiH|vtNz^ z7jJO?RO{nD7>k^%iDIr@eS>bvfYYYhIaDrroIFJj?Hj)qw+i%-1NXO*k5kL>ZgXoupV|QVE8A4AP3$IN@<83cyA%mDUs#H1#6RM|dk#6ZM$Z5asY)%VGi+v3 zUDqZ!<)vPEzzFu1KkIHG>PvI^YQghbWM*;CuwdS20Dggul)g2t<7lSGCSpJUy+8h! zVAu^CF&D+5tq0uxYt{M&5ITy7Wu(~uI)I+h@#rzAe&;~n*O8neSa&Y#lH%SzdZ zO;XRcUs!A)x9}u+%b~Xxzv(`2eHg`B{x`U{M64pr?wNd+(G9^1J@vjE|M!d#(0a{9 zenzOu;dY1hwu*8H5z#mp5*vDO`vnt2ceXMz!7u050fIN&(T@?ZAgd(Gf|*O;ylP2_ z9%^y;Pp9nbWrcsuI)se3hzSKCspR3EXg5?c>2Gy1zTOeZa6nGuz$}Z-129ZCo^IV_ z#Jw`$h8rcpSreO*#{cigV))2R+R@_V$P9XH#<|eD@S}&8_SZ{|M$l3^oc}@e=T*qh z?mS^osjDZ$|@js+^v5bpp2n>Ut zgS#>Ke}-(9nx@A^-enW(Nba{fv9885HVeX~=VE@@Iv1k?rGF~i<2c^_fcI|QnlqzgHeV)sC+WF79h0?{Um_pfpEqL0IyA#Au{m8D-R#!qVcN#;gpKiYSG^aW?;EcT=-`}jf<)TU2;jyw$_roS?>4%z~>E6|a*8*~eb833=o%r}k z3`x7hrZjkao)z)j_XCibu$wN1%Do|V;Cu{F9+## zA2cBP#zGAOq&Z|@@X~R4{h546wUF_%M<i^ej`@My>z|IS_&goopj^SwYA#m!A`TjzQnI##Go&BqikI7GZtd?vpO|;* z``u!_Knb~~qbYUKCxIb8FHqFT$5bbr+vu;pPqTq_7b~5RQ-G2QZt8y=M-n}A@nx0o z8nyiX%c_ANdGrich*UEeS`!ni%YFis!#b-Feg<0(iQ~TMa3Bm-SPXt2%%kdzA4*+y zYO923u47H*tbRKL5)X=JC!U^sh4p;8Go)z-1339ga3mn%NK&UJqCsQlVwaV0F)V&X zTZA0#17MJ!hEE{$^^S;Ar?zfuPb^E|LSto47Xb@hkj?+mbRGU|eo_BP#GcinHnq17 zwYMrA_NGQl?OAFkF^bZnC_0F$mfCw0qxPouioL~Nk>t(qeLwFX@Z8V2=Q;P>bIcUS7b#_}3T zl?&M`>ySs}{Y8+h6Nl!bzeSP~g5{I#x2C#Ef-h#3;H@0E!Nbu05wXboH)eG+Yb=UN zTCvAcau?91Df8?6&#%92*}`fjh2N$nf6lPMTr&An8?nh%EJD$`ay7}TuLaO^ExtjZ z6Sy?$%7&FD*+JC0kBn55NK@rdK>BXR!N>_?@#(|j3ln`t_x`d%L1A8sR9QGtF!8d; z6I*LO+aT#e#;j6!07(jGOEooJ%TffF4P4Yjr+n-}c1y&S+EqJf^pN(;Mqwkb>>&M( zOEVc@$J#h#tiVpfy9YE(2UOoWwh14#0Rt%Mvp*W;nIe+dFWQ&;E#O7akkaRw5v8sf zZ*8^)fl!)hFTe0=)|&mFIVa?RFBusMy;>)k8MxWLxs997b(hoRc7Y7 zRz&TEb$?r7|3(Pyacj@MWPBEV4zO8kL9@>}ug7zAi9>kDd4QJS=>p86^_1n7sBcu! zJG%yCqRI3fA-w27s#6@1821^B7E@y6;KNzErc{hkY9x9g>fwIfOg*gfEAnzY_`_km z#8*gv_iB$_YA`C5|AJj_`m%3294$3kp0I>)Oxe^T#V|Y=Ga_^ zU(As-08b2mJIgbD_e(+)Q+I<(q*7&-S8N&HoB&WK^n-zk$=$7Ur%8@pWt8I{W=W}w z(UsESl1cA+C^7mRGPWf2yVp<Aa(twx!{9 zDB<&YUH9b33i~`35wOv7Zw;c{8$&BKthol5TY>78GVgy5UF14--=I%Cn!Gw5gA$CgP3XNTUrSQ1O3 zCkN>80MfY#PJWaBSl>GA9b?QesVBwVzcW~K#ioGo_dmfWj@q{*8XInJc&*UhrM|I} zONCEa1i&_}qj(CHOOeQ~=C;M=Hd`3khXcC%S9Zo!oJtq(Jl#1qz9uM+Enc=O_nTfR z&(B-4`^vXxTkh{kxSfv8c1Q-2zqyG%Vm14{e;Q3(OBmY{jQb3(1E=pkO>fl@YlLIe zhd{eT7svJ;?uI7nyHZ}?A2TTjbI_Anq9*eEqxx^;fhiC*&q>~~FHcINSQu{;XPy#YkS7XeE zLMFssry}i>mb`6cE)i6K;mKvG`!9Jj*XlA|?@+fJJGvDc<|a!SO6cm*jlH)xs?IY* zqiqFM+r2HFI_!xwz8A45JNTK66==jR1WzNIk4jc`nI>l_FRdGD-R>REPE^dacWXj0-EIYcJ$gOWNaIk z9YdLA5UXGC*ZIdoF3$}ER0>@eUr(xmsDSPx$8u(~rn&bz$@mAnDz+EG08qZn=R6d_ zrQ4RHoi<@S{E1ed*QkL{_)p?HqBaec-aj#0p^ged%Ukh$Ae|l6mIZ|%Gc~>DF7i-J z>9uo1^azfMh@CaL>dixbAcDTT6@$O}#@|%@pY;Iu@sJq0)CdA|5~dhs6qG zYKApXsk|g6C(R!_(2VhKSYu%fJ9dyiqoQYwaA zOYV?a=`_%Siqu?StEPM+D}x`qf8?XQhdwXRnb564oFOaD?>9*{EBE*j_WjI9@2Vem z)Sw(x=0v@J2eu^c!)){S`P|NFL7L>DO&f2Ad@Jifv>rP!=@gHpLiQD6OV=r$1=U~F z35L~uQn~*aKD&JHk1jjpfkb*Up^1}#`eXf?fJCH#n@=H@_>gM$hlAnjLxBE+1n}%_ zG~$~br-VH{x!!AK$J3{3Y@g4Un&O6;tT07>kqr}{h$?6Z(3Qa9V+Z4q$RX&-IdsWV z`TOcY7qi*SU{Y{pWFqs9C?-Q2MiakE23)_XihQ(W zkSIxsji)Ce*J3^E3$IB9KNrvnIRUFU^&hC zX4HpRo93st(;q`9Jc(VU{P4ChSprvKbN$fW5l5)w9?+OU-urQ}iWgQM`4D^HKdT~Z zNs@|*GZYaC_*=OhyU_UKmuc#p16Sl@0joC9#1iy zYD<5Yssf6NkE^&T=q5Ky#WVQNe-)j0&di5k_?3F1M?_`?A>Y$8Y=yHMieOf14#*P> zo7Pex3rWvs;)dBDMdV9sk7SYZg=il^o`PgBA~VrPo_nhqzNCoxy;a$-mqtUMST72D zEp!`ta22HZbwu#PZ*lpYYY#vw0(GIeV=!DL}tI6wPYf1`di+Mn$gr9Y8fJP)~`wpVCYy3Kf=_tjQ1&m+8J zha@S~qwS{3UUXdzV5xrq{vIW{eD^10&=5>;xVa}Qbw44mML+?dr^DvERmW~jRhZIU z%U}T9y{(I+Y?2BcARywL#tXN6p z4wv;OE9>|Yr!1MKs?A>?V6Q(w^K&Q_FAcrKe(i^6i`g zCDfda?>3RHfN#BltSN9Xzlc}$_ttCFdwuzwZ1tUwObBQJK90?{Vq@$uK-!2i7}t}T zdzFAfI1g#|%Qm@X(fvx0I0a~2AKGLT>K`C4&o-iFaL@N#1 zCb@o-1<0%pbJ{*G4S(II+aTZ{wGhywPk~Y~Y{o1HtH_T2WM}nOx{dzJC>$9Y$ot? z)4$#d)6%o1aQj$O?o|vKwW#flEihnf`4&W`BE?n!(Ftz(yMoAskP*756a_=qZ=l?p zSh+uUdi$$BO*C{_)-HA-jF=zaj_J?^TJnP4|3o~&x)?RhWT&OT()*cMkDnc#EIY1i zgR@Umhi0EpEN^uw{Bpy0)pmAOc3fXukcEu;<&>5E&t+Ooq)xIbAwytg4Qx575j!I8cpXsW^f@AUbE1R%vsW`{Iw`au5;GxZrK{&?<)ibwW zP0cTgvZkmfpgtJyFO7JU_iVD-F3Gm|FZJz%%)qm=m}X1@^uWh}Y7y>*Bv0sLP5igh zeZveSadGb!{+?V!5f>Z$8qO%iriKWMYy`c~y7yR=WZ^cy)$i>vN^fMcHXn5R-V=BV z^4>db^6zK2B}-H`Z)r;6{uk`(Q9xb9h=5O04%R86tQ$n6rVC zs~MeqVbsI(8~ybZq7A*ZlN$p3IQ$5pP9Ezkv}^OWXou=u|DAfwo0+cfG)T0Z31P$9 z?zImE3r?zkKvu=qRfb7di=`a>8al2Fi8nxQWSC&g1)fUB=Bqr1)ivn7eS2mT?6A>_ zc#09It2T$76WXS2FpJGr93ILIQJGznrB^QsdY7tHzZBDqc+*8;g>PL87g9W6dxA!_ zHpO6>xbv9oP9q)ze31^sUzdb)v08T(cDQdPNIxad;~vsbd)<_9Lh(QaUb?2e58sQn zf_%RZr_5j^Fzll5!jLuFJ-z;SVY|uiJm15MNAz2uPE=6G?UnCq?eDwl>FK$?S{vdT z5x+6%N;xQL+fz?l8aw5{aI=Y5Es-UkfD0-2k>fW^buJBim2<8FmmaB~gPl{)T2Ci+ zk8`mu4h4AB1~guQ{LOJa9RQc3g9=>goH=8rgyy&q&96(t$}d|9 z82To6c5(y)!52+ZLnB^bA~Fa5?~H)iJ-KXw7&!vhG|c;(bs2gHF%Cip}Pl_usYb`e64Ev^b~uv^-vfrG=ZP6Tq6=^Rmcd!IsXdeCb+L}!5g$lYwfrGCP|_; zpb8Ylb+YtX-!^Oe(t#33XWt{^1pyP)T6TSd7=QES9=J)ZS`{$yO!SFt{8NrFHdWBu zlVOp!Jm236cAzspL%wQ-mWr;aggc}Ktbrnf+1_Q6*88(Zd&(W&x*<&b^Q zOfK3x6*^or{1Q`mB0DHi23;TpN@-yCqJ70PKC3JTWnSfoY+=CK}If`#+ONCa#J_cPFNKRIT# z4g6motDoTvU)$Lmyy&faTcXT{osXo`VaKf?6Bsa)gUCHH^wpkb-xFtIP z5WZXajNH5~8X8R`Qya0Nyp){^VcI0R*Zr4TTt=IAnQ*BA^3TXUODFg(WPu<&H;b~R z6zD+SZrm#7^(gU%?P3N)x6k~0LPuwq-q&7VO14VWUky2i{H@>+2W^-~{2rozu%YVu z;_n4%chQzQ@$M3O$d0af#ee7r72z?X{HVeD!4}ARVcYjeEPy1FXNmUude0F%zqf(E zL>?PXAtZL7N#0&|pceGC_c-)qQ$N_#uB53JImN;I$iHFE{7;aRTx675sk+JVLpSQJ zZ#AWYO>jgYB{7VM0!r;V5rK-jsc$ zj3~P?6cl-6`jM1)a5prigVw7tTBt1gfZ5^Hvn?hB;s!9muEZJq4HDhkd1QfB6 z%<7uqfpej<65};_r=yyNjKbaPb(^||H#Zz$ge}UW8^?l<2mHtI*h~Ca{W%kifByR5 zc$a1O%f~aF=7!0k;}AgixvSLkq{5#y7Bm!p?+PGkbfrH=7U`TZ%73F;pSM4`=Une<$EneD!UPtRiVWj zj+4#*PRE)Zj#wi&Eip5R0uMLp)QHgGi}^;=a9IwC?4w8Qc*feR8X&f(KDWA2YyT-U zGQEZNZZizRb={6D#j`zQ>!7dHkPrNkC--RD{WPx9j6-SQZS1!=1k-Wqob=YK`cl9) zi8W_C%-4U%qaY*u4p&p0QntTc(_EO;8_Kvjb~ACZn<+JA3u0NVdQ7oM%Ada@LYHr3 z>8lamPF+!^a8=bxH~vo{*Zm4wE$NV+BS$WPPuB!W0Jpe&V+LO=mjGHsU|?`Mt5+WR zyhIaE@S=jkQULWUzO3u(luzJx-EUeZ>h{eos^96Hhk%y4Ap)Of)BMA{Aj=}LgHvS^ zwTs*2P-V67%dWjUc*60*_-&*7g>2GTfX3r!P6?`@t&i5SwPEG)tXZBaIx2xprc@KC zUMuy6C8>W8ZE_OkudwzzkE%{)tmf*N}pGZco-tYS>_oNM!U`|drOa8&JGg3pJN=z)VCN1#QE!E zyjfN3$Tex5qC|Mf`QxVE5)&F`V|EsK+eM)33VgP*plUvqmR`ghjslYYNKe9`S}T%3 zE1|VJCZqh(zkot=5+Ar=so|o>dnRFm{I~aXPs#l1H1v`^!p1MOLvqTP zP%3zx#(q}tY%%v5&eCM2tEf&)iq-2G=89I6^@ z)+Vnqa0^`K{TRX{izxz)Jt6?v9{dV<#23|dRYORlTMNZfg_4X;>a$_xL^Di-u`j8` z^&-jgUA5S94B8wDKgi;uG=?>*yJ@|eNb>l62{}6+Qs!2Ou3>c1~Mt%7Ub;K z&6n~K-_xc`)i1x!U_yn@8#T^}2!L41|HY?O8}&w?0|20aB@ehtR{XG`cpb{R5<-~? zFJe0k4QQ?&euCRE`KtV+g5r7bkwnA%?v9B(`aBk^T0c*0j8@b;{r%?Ac<>CZG#Jpj zXh7m4T=$@{tH@TrzaJ+?^Uc~5&ERWD$=g&+BmTA^0L7BdjRuP?Ji?M45hn#}h-#d) zUF~NTCN_fgf9EQ?tQ_Vh0o2cSm~1V<#PP)UY3Bc|vLJn<=(&zKG>XUopwFP`Aso1k zNs7F8I=M7x^Y}aCs$$W`MZcU z`k(9hrRPZeJ+Do;F?f1y5YOTx0zk(1sXSWQe_LK{=Vcxr|b8O+)M)Sp!q8`qA8do z1ph$xpDpO{0Uv47pdeinkiUouP=0MGRE`-d0jQew3aSwQ-wObCT{M;a*W6jFDQj~C zIh!&(iG+>1yi!K*riDWMEd}u+1q*$9g%Va+&(#0T6fDeo-b}AGxKS!I+x6l2d>FoY zd0b;jE3Z6gz3p#_5G^X%Tkoiet2}iN0HE~NV~B856pdVtm_8B&!1=ai;Ume((G)}p z1eo;AY(tqY6?^9IW1rVttWxQI1a#;|7^=V8-1Rlm5nrgwPm#DzK5k$hIb= z$ZQ6G>KYX|Q~IzHZ9Wkc({aw-Ey}s(yi;&TD6>WTp!S8R!ajazY*{2l<@?^N zP9jq}Ui8mc)WaZEu~#X366f_DeUFmnAAA493cJro_u+{JaSUd-zD>Cp_xB26zT~|0 zPtBeYZUd5idS}?yT`uj2QGYIiJcHK%C0m;MSC|FT0811^Py0N1Q~-*DSYiK8CWtkf zWG%V7J37Bhhn7?8wlKnn|YEiB25y0{xIfA%G5~ zvZAl?=)r3@17)}kZsFWw@e=j+YV>KrRy!-@ygyrW(E4(ZU%VEejtDkcR-O+bFOtv>qj_*(|VY>US* z!76t*CdnSj4u`XwT~1ZMJve6k4(N`2-yolu%)OYq`QXI-u?5fuWE~Co@ZuO|0YIM8 zxtTy-?jdhEkF7$$ZWsfmeND? zI$pI+sbQBFUA6TzQScqFENh9_}*I_iy1UO4R{16H7-nkDG9Eb2wJ7W93I0DDDsW5~T{tg(MyA zCi)5z@)=Oph46_iX)`mOE3EayP0`he%BkB64QW)+HAh%#IiZ>s0!!MU2et4TupEEE zUnMA6hSb*tS19&-P$eDlgY%?GnvPht?t@BHjwt9MHmccN?(*HVC>Kxw)yx#>U4|vg zeNG{Bv%AtLi0&Pzp8>fBfdnIGd4n)YX3?Dx3w0hG%g$+L^f?js88<9MBw;hd`!|Hr zMG!6ZFbayDk{{Q~VQHu?fE;mhRiul7Up64^&V5}vQTVhh>2yisJC1Pvzf?phT4P{;;9;VD?CyZ{epgPqnY2T_H z$_=d!Wy7D&rr*E6DJ|fQy$teF$KKRdsSpIDJ=S{Y-dN$XW z)>MQE!*MU`YfDRO6l*6)<7|!Tf^Dlvej!cxi&(R6G?ULmiOkOVQG0%1BE2(!D1axB zE{~Bf@>zQ?dBllQ>#vRyP4W*ZDiFZi_InznjhK2L%l-07k$0avPI(Um(JNmxywE^) zp53L&F0o@%96Y-93|gdUh9^YF;0l{yQf=@~dk%7h`R&A~mrXd-hV`OyNpSt<#>Xe? z5ZwcI->)HOq?}a^w%__KM9B30j$e|d7)0RIVCipf&~JjNMC}7{cOcZMt*hdRVl(un z65$pXa%y1dgLSb$ViY@y0<>HeDXsd|q>IpZL-ij+6V*FrT0c?p((;i<)K3t-7iXyD zMPgmNxajqZ!Z6yy_nhj7uy9Rx1XaQI%?C%!cdC`OD)zH7nA&w*f7tkqVbAtGN-UUF z4v}@R@hMC^-wBTr8>mI}m+ZP3ehYsz5_+q&`QEcW)5NkXid)xxn22Q>Gm;w|d7Gi% zgvrwX)(h4>0mQ}OVVlG<5nXS7>D&Odd=8p>^QJ=QQY2bg6uP`kcode4{XMOrTZ!&d zzT^hlkbf6(mED&3BWc{WG*Vew+Dlu4myrDmDE;TkZRk<9hlkt8b?0$e(+QF?GfK^+x zoMTO-kG$ULlVI{Y=rE!Cf5RX7HU(d>d;ZCx<$&)gcoGP7J5zfvnp-ZeA+F20MJBi& z*x^&p5WWW~yko;Jiw1stf7>;1FsVgYa-*J{vm`nyBm&OoD#!+FZeJGa(7t z<&6XznN)%ol1Z)ojX99VW4kC>CzId36)^9<>S^ObOHc(HJHjM4Q`&PP%$QEuRCuyi zm9}4?S47VbZTHoe7FDKE2KrpeS%TzLqTDI)JIqDj-e; z)O4g16?6D%RxK5JO6EG3mbU|oIo)|`c6W&GP$LxvUPE|(b}q|EY0n7p_71K!-Q}N^ zmq%Vs;;#qIt&WV99zy`f!;_^hSTxzYg;43h<6!Np*1hww$FS<&av1bgSdDA@cIT+t zhNVi^Vy6U@K2*Hp7H0}E<%MPV(PVBQCv+cvQ&X??9=tu%;1EYkYjI9?5AR!9>y zPVt1|P~0@@V#J2)Ma#9y%8y$gBFDjPq*=qLVZg5eh&I`klKeAQGP7|9sCCf|sXmW8 zRQC=w>90%}0OUje@{o4U(*B~|wzfZW+@e`%;5t`zU09{$Jty>}CP^^P?n#tB3SJo8 zKlAdZZ62Oq#Y+2gD#Q!~y~b3=D_IaAR{J)eDIFG|u_G@(SvAaiNX&^xHOUu=BIhNd zTsulFSW?!Gyv)RT^b=VqyionzZ^~S~Z;ZA)3NJv;w4tQ|fNmw1Ur}t=bSE%L@g4feyxVj*;=%DVDm2&erR@H)H48K`-Oq1QxCq<}I`?}UrwN&e@1 zkFH^6;%9L_H|*8o^!DTaIRZELS+(*4zf`BkOHk*RQ+ujL=S6)on$l!YRT8LSka#u7 zOK@i26%#3aW$h0Q-zp2?O;S^}&VHukY}oDwN6|Ms#-NqepW&HBaRR8jOM@Yjj}p7n zihhcrJu)lbGR<7-%m}|Npeg^6?@kx^MoS0+s$ z*b_#(k6DdyW8RCFuc?4R+@^`! z&&sPmp!dBYoRuGT);MR<7=QU65clz^g81lo;&ODjz?$wfR&wD>30_3-TH?kA?4QT+ z+0(hPw9+f2=v9wtL?vOmz}sDM{x{d_l4l&~l%755r*Wr-*8d{>7atAZ6UHCkrX+|M z{ZBw)BiQ(JC~imzed2$Wp7GYDxq61n#pGe#My0Md)C|jNd#YZM=~gk{Bb}c;)!&fual*Fr z(*94=r6p`h=?2g-$@U-BqHKuZTW$MaNDK~>)lobD#6zOy?bSE)@`}c}9duC}o`i4F zTGg@8)qz5@l>!UtU5(-=Ur=#EG2X%syv$3om5V@%M&WLGyHLl3*Azl)Qwb8A^k5XD!{ z49QPhry64@1Dm@FVGdQu+Qw@C(4}Oo&E+(L-6{a|Vw9B-%nWjhMgVJc7j$gkMb-G0 z*X611zWzt94$>kdL$l`1K9tPWZ5-+RHFk%K98}SZDq1LOb0dkR+Y+iXOku|6*iFtAE*zou*XU|ThUBW0U!$Em%6rWpF~DA%UN>Ef;e zpsbm9*wui=3A8Wa4%Vw?A5}e_ejMSm#X1@5()SXZ(C5Dx6sJadx6V_CR2u@kxw7SN z78N{19>YSs?{B#`>E&Dwh`P^ow+4(Wmf&5kZeVMq!8}u$F?>aNlfz1f_wqUP4 zsDd6#tkNjt5W)PqIfeGld-y}|37zX(;{BvNK&@oL_CIl>%gZmhoGYNq4z@+KUFW&; zQ%?Z+cc6awGe^R+W}+d`m1kfy+WAvR6f#s!bZqDdhEonXBlJfuUni7M3KOhJd^6%r z8a1Jis(j&e%5nts>XcK+!Hog9tJ8x>e)aK>xU5?9I}hNaEjv!)l_n`dK{P3@WaPua z?*Whj5h0ocn?#C#B3r5ePk;2e$nF!gMkfCk6H2!9a&lEGyYX$&F=L6IopE%WEX~+N z`=%Fga)dpW~A>2q!W$U1(1`6ODAGrq<`7`;U{R7B_vrJ7Z@aR zKvI~MZ_8i}=B4(@&LRS7$cB(>-11cYFA!wlq?jv=7Q0p=xb*TGQU@fl6pn7ERo&}@ zvS`va_uTc+4IYiY1vlhar+nX;WndkVUg_tQh`pvJ`yFZM)~p6_i~Su_fEEGM60EGy za8k1vEP?4;3~4y^+Ua8Eqo_V>a4H!eO3U}Cu95EUgIw0NFP#bx?QdwGQtSr1$ed6p z5Y^Bz)`AkfA{rqnS~1ci6!*zml%zQbAnUO;goh*>5ie+Y`!O@;EsXeP!1>Ic8&j&H z-!_Kq6FvdOe37Tjb}cz1uS?Rz?R0g#q!zZm7Pn_-{5Hy=Oq%T^le%OcH%USZjhDxE zOcJN6Sj|Zsw960vf}Nkkyxg5@V4R{H=SyoyT1_}6!Y(kl1>mZqk!YdjC8r0nya?rI zgPkRsHqc*g73!Fn%Z5i`?Su@FOcJ7GNRzfDL^0uu4RzV+T~lPgS)sD}^agz!?UJH=FSOD=c+R_B|P>J@Td zz}WfTO_rGzhPeW=MA|g8pde}^cX&9P9nqvK*A-rT0*EEx?J1VLdLbqP`?39wFToxB zD`Vfbd7sWyd0qVY%rMwG5<~{LaW}vy9iTgc=NRxp^W!Ncw~hH|iTR+wxD@=i1Wr@V zkONhQWNiE?uXum-{~b7crmMkV*r=+_pt0G(HA!@fOUa~-+8M1r*-tqx;ta3%R^dnD zFT>C2W`fB(af;th9|e%8NPHN&rOm_zpd;b%hAc9$&Y9cb(JbKt`&Bt~X4OAJdy}I- zM17Yj=9tMDyrc9y^JTNr^U(=I8IsI9-(dH3lb;(=aa+f@XS&00+p~w<*$K2Lwm}yb zA?pv9m8#bG@~u<2yCxbPF@i(8ZiHpO8x6o`#|upMPZvWePmlp+;W6)_Os-sPD=q|X^{CR~w#WE&%5ACF-@?m!dN# z*?3b)c*7%x97YQ$diMnfb-e?9j(?^ig_oKizlP03JcV)oYi#6uN9)<**u8)keOfWZ zRDZb`cD8M^QDpE35GD(Hqt!;U#a10)747xQ{g9BQvd z-qwbY_k@c?sFDMES@v}9v8o~;ZvL>Be4(0pBZ%5o`MF&8O{cIsDXoASaoJ?cdqn3h zEy369Is`F3=IziewLoyztqKvrIAb{>N_r=MXA%7J2VghuK3H7u=~3m3PC1nnrstLK zZt(Xoqma6alK{!xpHiNVj-?j+jb0eMCwdOLT3>w9EU{rQ*=%Gp_1qvwOMAlXJ3#(G zLiI>R`56g$(iblHg@6+rc&OQGb)+<+kn~>(jS^H1lZKEFmSYo=HPt39Mlgr)}-J!33fU~pCM%zV1)lF(+ zQoS#p3zI46)BUm2{H1?8g}%+ap%DV~&wOCp4N0#_VrUjP`)pVQ-9G=kZ5pm96M|b@ z(S5Kb7CvNc5RIR^$ zS2aBPnvqk;P(h<@#V#@Jrm3m38>=%j#5pCtBGsKK!pZ8pyC)Ov0<Xub2DtUu)DTdA#=iX+HT|KfUn_!|(fp$9+QbA~=`Fk8On{Vr6m-0Pi2^NotGG zbYI{P;6f=951*v7P3G5-@N^*t8Wm!MU_$L#uE=(>|o?han<} z&8)AOv+D=ctuX8$Iu>z{R*h*MZ*jgG;X0{@PJ1qTAR@{IsEB{GaRs3Z@$+J}>eQz9 zdrDuc8LIQ-De8C+I>EkzrPEWsaMz>$C zmCCY2ds|U9T;}OCEIsNDqbxjGBCjQ8lUr8xDANlHV?cT;RK_GAc;qCb+Yzs(hc@x( zET)U9=j8gOENd(wVTOd^5n>h$oO2BZG_#i|&ndsIQts^0B|A?r-%B9Rpauvs(PaCW3C;s}9p9Y1?4Y%A7IqA&|gQ3J1URqM_Vj^ySQ$ zHBs6?1ir@p)W_|KJXI8i4a5@Nbu-r$^jAgWu}R*O@LMwM&dl}CcI;9}`y5nMs*b0w zIEX&zQ7Vunx`eg&cT{kQ5J;890xC;NJX&P%6p)K(+n13cB)ir>JZ*`Tzbdlf(-OVV zN5Pj#9-00l=MKM0?ziE_7s#j$*dK~Scw12U$IeTja)7g-ZtyZG>`)_&iAj9 zniJ4c!(Ufj`Kw)J&iy>>U6cEZezKNkuH}$DA&_dffdx1*-as;T#P>kG4Z8FRY1p?X?p7D0aD-ascazKXaW| zhc!26!j1KNyG~OpOi|Kwk?jM)c@za)cu| z-xKfN6|vN4F&nv`8egvD<7Pq~8m~;%eK?y%Ycj=cTYWRNBVPQgeD;FB*-qafvY()% z)p{9v%DG1K8Qty0guXe=_HyO98^pS7AuAk8UWkokfL0xA z()K{--(A6&T>4+M=ViPD%yim@IOP6Unr4D8=_Ji z#^eM-%zTH=;SLu3p+_tZIwksd59FjZG*4Z=GQ~OX=LrikfxvMZlq?D*%mw_bN(@~3 z@Sk6#-UDnO*%9k6$S{tp$B!k?x+2fJ^s~D9yVwoD%4)Vd(Hg|iIj4WnoE2WFFV7?D zWHR~&bC7sTis{8ak)2~9#f_Sx2L+;BA|iDUt@DT%8ZO)42peq(+Q@b7?Xrcyt0Ko? znRO2@2Od&K#{gN}S;on}s>$*TDFS-=-EkCw-aoMPYTXg#>=Oh9)3}yJu%ml|WsdSo z$Df`qmwL}w=h_4o;annjb=Qh#YdYQOFM`GB)Tv2*9@RnlzADXYuJN z<9J?Oc-51-y6n~x=ca<-gBQcwriy?{f~KOP#HzY*7U`@XYg4}%uG+X{ygGHJs3cnt{6_5mQ@O~Z4uy~THzFbRr*7d|Ele~9M7*N`vrUIuS| zogLUWsrC*uC_cyhst+1JoK3%Hor{lIQ9j`94%2-_5K;G)RN%mysupSdb=R9}bL-C5 zG5hW&O^wrgB6tU_@aMvmUvnrnWlhN_4`aVm{nQiOJ}OYfpf$GLSI{&-`JPIr0xy3A}QDa!mR>K`^rp|WB>iY7sh=*FKY@Uh)1j7MejQ-{o^5EsJ^0>#)j+&#+O4I{-^>(89NkGW}OL$}M znc5nvMj1HBz3s@)cnJL>O(6`4)(o3}&(lD?WFN8P#3Sv}PO{ed(bMXVFxNLT+iEc` zLVbDT@u0^dc+wNc^))Z3Y2v|YwwBj^btaP_nQ)bn{cf+=&)ZWyPo)#Sq8bf77Gy^u zg9ECl{F}xHFV(1Hto-7?ov|Efv_7d{JE0>c2ClJ)f63o$$Md>2ZwVlh+H>PM2cShx z_3G1^CaXt%FRYuMZX!9&$4JSj+Pcs5Pt*&LbNX^qJ#qH_s#R}soBk6~M=K1J{q+QW z^-$18hWWG)~%H%Ib;Off(2 ziIgi*k&s0$3g<8eHavxfTK!EQNIc?O{h=+YQUe45uIT~z@wv2`p6#~fK~SCb z6b9R!9(FVkm{Lb=)c710i{R~@AB^MjY!mmkYajUdCORO2bw+>W!+Pw;v$tiqG*2dJ z4Phl0hTSKtx}4LM>o4WEcHw`^Oia)|hACF?P_;KD(lpgh)zsDE|BG zk|+QKpc~E^jYs!xvx|Ri6I(`Y-P>HR9UE$;&iYWw#je7V_2Bp-f;a}Cl(`@_@%L+M zIAPMe!53L6A?~cS8VDApkwxXe;bRm8p$Io=+q#6;c5_|4|FzSpuK(}4$6WAn*zn_j zjw-yibQX$m%2Ms6MfF;~$~`d-D8_$rE^)M}9V@!FIbB6~+D>P%%2u>ns^FLW@w)2N z#`lNy)pxQ!F_LeG)kk^JeT?$`c%-UJX4O(qVmMY6`~&XgXOsc`=N`fQ`h)>ebJP_nC&XGAFj28>APn|=9WZvI z{A`x%vyIig)zyH`(q8s zfkqSM`Ae=dz613sV+y)W`iV>WcL;gGv(VNH;$P5j-I^cV)-{Do+qKqsVsT^M_qrLw z?{8gQ1)X7##@aw1L9849iFN0aJM8W4o+QrxUdEtHxvGPxs45WOOksRYl!fhi23Q1HMp&3rEM!JLAFW zHmPjEbN|I^y2a}<_jEkEHPwE*@Ta884%$reiizJG;n@kAWkuL-wb~M$-XnL5mwQ?_Vu0@CckX^-siA1 zZ^=x;eYrq%*QdRVGnD)XDJU9yofdelO$1=#k?_?GhhXL^M~vs8BB$!v@!#ghrB{m6 zp2^?lQ$^y$w79<2iJv{w49oNQ{oLJ<{qI&aJ0r4g=1~dKCZD!M&K!&E7co}CJTfCF zF0QmZq_f|$B_h1aM8r(kWUqSQ5BN>klbN&^FPozTuUW747T}a#vOgzPzA{mFhily! z^UM--%^@`lFkt~Jv7p{ZC@c&;8A|Uz@FoUunrk<))`&&rzs-YbPAJs<-mAQ&Nuj0C z?objTo73A|r8jO@*r_+^Q$|qpA_O-?s{bc#4wCVTTyIXS-u(7&zlOxGY+`}aZ25B#YGDSAAfLNfa z^}lxOmRa1rY~oiQn_#eDN*gU?WzexMSY+AR!I>{o4M>GRL8Bqxa0u-HM~?xXC*Qp2 zM@+fMdwC84Bth1l>_`nGGd~;E0>=sf>^px??(^{!8uet{u&O%!s$SoiA)%IC?qXY@ ztt}4PBJA%&{V4c3YWRaegsiU#%n)tI5e1=W_18vc(dz$}X!XmlUMwI0K&}C?z`g}4 z(VzDL#h=`|rQcdv(Zjo)c-)F=+UhvSbS+bP-DG*Ev=%3iv-gf)(PyAkYXE|Eo?Bzp zzXnKX6$OIvg|bu>SpIGW037~0BdlLB`@QpLMeiRcIQ>1}p^tk0JwpFoO8Fkb`@4^* z(>?P0_jMlJbG~)2MeHX?Gn<>thBk}p0rmUCd}$nKYlGMGYu73@K-t6sr`iG%0XWsu zFJJyJ7P!u8KK&oJmuyya3S&F?Cf|N|HA%`>kQjaik8+-s|0|>h<~=zO3F0U(s2bq$ zlov4dW&B$eti23+`OHQDDz?2h%5=n94uiqyN4ghc-*ogH`t%Mp{X5j{Z-;@|0VLcf zJ@W;neRon_ei6s7-MR4%Gyr513y1~2(gFeizS2Z;fLLH^0nZbd{L|0c!H{M`CYcL@ zsGaBO;w0C;!{7?)fqq=|X#v21Z&TbtL9pTq;L>9BX=296_Zt8UM3u^5B`U;7*x*Ij zc~$`c8vQxQ$ZPfI;3MzwQ}zLfsSjN0W1Imie`@)4FR->pIbT1Hf*u@zEogy{T!>bG z<{tfM{I!R-WOe@{E+zSl6JUWCa??2hc9O3k7N~dvH~wm)o@Cv6Q48u(k<}A~gHmVc zE?CUD+{`)U<~VGDhC@rdpo8=3IOmpd@@>FD6D~m$0MLXR;KwHAT}w@e&@ngZ9FmLc1;NOb#m}3!TlQ(B`E{(lV}1!ah|23uZ?=-yp-&U{6542$Ib!*0FK?1$qCQ81^)eC{olnRt6kKgyMSN60B*QI<-i%_hL_<5 ztN;X-_tOP*EJjw?`APvt#XzMcz~}DH7 ze9B+{mN5UPx}1FaAAav2Jeu{nm-7<~948A1060#wCFgo9Ebzzw?bEhV!I{#k)w0Oe zP!wE6JZO=9AuaTLk>^AKv@a!W?WY5(krV(eh_zMD1!M&1GvJ#K{hfxuQIG~-H~{cc zy;Cw@rPGfy=PQ;8_y(PRHvu4xnN)f-daZs#3tlyd0$XYIlNk7tLdq}7%zSCX_>2Ga zFE8#Miwi(bQY>&xEFb{jm`s$M=JBz>dpG}AJ!r1aYm>~|#4J$1Hb2Sp7A1j=G%Fjl z8>ZP%B^>01{{U7$dj8b*+hEhA=~muY-2BhK zvo?I{FD@?>3y1~&o&^K|$VDL*IARN2|DX(%`nWb2gf&*RH8kgI8O;B5qQbNc!ZgpQ z0w_adY%h@Oz5ocg02sJP(0|H)RnZCTd zSS%nG_&Ey*0FVnpEbx3RfWu~u%X`27{`>sS-k*L7gBKd|57*Q`dea8^Z*b6?9QPZ9 z#V?U2n4`}?E6WNh3#t;r+O$Fd>H7)@Jln~>XaIoI_s&1H0b2d&^i#i|0toJL9-pJt z|9Kdy&ziLmqF>Q3%e-&HI^@4mfAmk@9AY?cyD1q$h<(pC@$zc1z%Q|Y006lx!~!qL z0^j=1PcB%OU!=_KVif@>)!8H~&=zp5EH6nJ&}#r*06xFYDc14+bqI5H&kf+QCk^`F zZzVT;(64Zm0ASiG*E1KVta~rkzZdf9@wYCk{yiRdVfyd+W<6ff4{iUQ7&X5} zBit?0Ns=3t)Wb3vJ;)OY_doOHC2t@W_(}^10QgE1$pK=4=VO6C{O-eMw!PWXQPjk# zx2(2}v?%uT|GBRSsJq;CaFpj*W1qvZo@2#*4yWJt^1?(2Dw$zF9<*%mC;*4MXUe$TyOHOVu4?@fB*owCd2|S$pY7}UpH5-ys!Uy_xp4poHt3)wPDt* zN1>@HXY1CgT3Qet$jaAopsy3^e;r5vMqao@^aNtK0uj!iu|Q_{V2!;gQ4|@IN`8RR?+aaxa!_aP_+rP>AZE;SU zl=y9?@x@JP)-$u0pq$t#vw#4AQ#Rr9b!XTD0D$YaJ`Q!g6O3!)FrVb1GGWu@y=3VL9a0TWm5NPA%+e8Rjd^<#iv>&~&5Zt=T`5SlwzTGkYx+3eb z`<8@Ni^BcK{{M&3|8h%#zyFAaXY1M0bsQC2| z^yx*B?SVVIq;HQ#KV7AVy-#{qy3(v?hOa?6wNqsQ0RX3Jy5-BxxCQjho2B_!eQbNG zXYKrDB!ai%qK8w*u~*JrX}`~xh3e$G=%BIJK`Xz5N&roV$|cBwzVT3pz5*RDEll45 zLldG(X4qE?90UM7S@{#r_vF3`D4}o?5ZUZ#2UGV@+ zFsYexVk4Cp8;uD-OgPU8N&u7cqd%M6xN!qKBAZx1Eby`|AOPTH&wzZsSm0+Zz$$&J z9ia3lS8nOIR^HJ&?xV(hYYA8GPm&_P9L%yl?A| z<7}rahAr2L5=SJ1LdL|Y&7t$Tesxv38xRI%!o>n&f!VNt0D#$;2RRF|z=17r{evI0 z@=;@{EQ%#M2KctaOKD!Nq9JewPQV$YgjdPCud?c|0RrkceqT0NBLd)g0xaU`QlJ^p z6R5)e#v=pZ1b^_1{;HhMuwqwLxIghDxqU(sx=SnGZWOTFD*S&J!0{zzig!s@eEA>$ z&DVVcAKAnLVu52~0RaHVWTNCW#R5+(;Cl_s&#$&|_HEh_&(SrgJYVEvB+O@2Q#6P*7f41=+9#ei;ygFw&q z3H1*|y-%)DvB2@NfB=BwHD7YJVgcp#1Ju>4Ms2Q`CuG5-=-YgEYzt)fX(;Gh3Wt$3 z7lOc2I_NG!&bt6f@EqV^fylukynscV`vSEA0jv56$Bjd)CxrM3{p))?f1B0)ZGZrK z@)J|WiE~*FTF6`xv7xy!qJ`ynCs*%W$tAxp7pGX@cv(OI!10>037GplXDjf{0a*Q j0FX;VEFc#6DhvF79QYIJ)ug_@00000NkvXXu0mjfw*kHQ literal 0 HcmV?d00001 diff --git a/docs/docs/assets/favicon/apple-touch-icon.png b/docs/docs/assets/favicon/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1f89e55624b5c66bc57d5b42abfe70460944f7d8 GIT binary patch literal 22110 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 Z{r}3&&Kpy<8>;{S002ovPDHLkV1m#%NihHb literal 0 HcmV?d00001 diff --git a/docs/docs/assets/favicon/favicon-16x16.png b/docs/docs/assets/favicon/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..d62cb9a63bcae3540852f65779aa2e5b7b438c3a GIT binary patch literal 737 zcmV<70v`Q|P)Px#1am@3R0s$N2z&@+hyVZri%CR5R5(v{lS@w%Q547jccynbGcBEJAr`1bgEo>N z8{@_*QG*6H?g@SsO4_>cGx&;ow}KEQF`CB2N05avpn{+%0vhOx(wVu}GXs+H@FtVY zoH_sBd0vM9obkotB`M?xV@v}?u0Lr_f-$+@o$WVFES7qu6d8bBt=3)AGZWNIzHo+wlsX1FE%d|<%9`QRpBg5=mQg2r$B}IiMJ@=Y>hx zC;)c;)=SwO3n6|if<&{4REv%lBY?Q65-12gp!mX?E3r_t@oK4zrHw7fy6r#gT<(S6 zQH79aPq!e^7J<%H9Je@uRCLEod@nngTCU>vrUOH|__0<7cIL1P@F1sJ!{};{06`9y za~M=cgr1F>im-~b;cBVw!!>HIsnZk&@4f4H1REAoUTBPx#1am@3R0s$N2z&@+hyVZvmPtfGR9Huym&ck08cG zNLvsoCV-KO8(qM{g&Pz71Kel}ndkyaUAl75s)`bgp%`P81t>9*hrA1IDF~tTHJx@| z_uk|8+<99^I zVgKq^JRaw}KKgVw*`kjadkZ^Nxmys~IOsg*;`m%Fe0D7$s{)7%!QYrn_6TA35wZP6 z;YxG~f@>F;q8v{|{llvPSr!0R^E2afdth-NV{9Knbt0C_v;;=NxFaqKry$(ySLyAIVKM;l z`$Uo^7jmR43?Le{hTsT3yOsljD+A+Gbo*D9#ub?L}W!2br2`*6eq7pj85d^S9hkolpUWz#rBN3l^BmlvDf+hWk}^&v!D z;#*0(?7jrKK9!~)M-w!cu^f;X9ErLdVL#oQ7vqR{6yj32xcn%;gRj(6ETB?{;UNzy zeJ#sEJ{EI=(n#g$TXQrzmnm&`gRl=yUK4?^pSY`!<&zTN0088Yt*}~-5dpGJqn9j{anwU!D^lk4i1gVQQ$CAo)l=Dx+<&AT`4Qr2tn1X@?`S_ub1KaZ%U= z%w0tO$HW39kb=@OO%jHK-#NAx%aKD+g4O! zC`<>W@Y})z^&XDyu_@};;h0U26B+0IAfumIeC|Eu7*P`-f+E|wr4a*(oI+cGwHm}s zD1Bl|~;y8uisWsq*1M~DqUazuh0(dn}+cwluGg>Z( z=wyLK(5@-dB96o5%(4OW8j95mmuAyBXI>;tn_lv2B=jX}_$Q}7eTy^HtfD2;-hoJo}lSDx*Fcs0R3JbCr~OeXiaX^Jk{6*VaeHzY_C za?KH~6x|*(oLvu5@sBnMSV@v`H2=LhQ2ubYPL-1<8S`fQo7a)h<)4L%Sh7S_7Ij;PE!njt zTM{Wz)I2gGca$bRVb1d>*Pj9sHc(^c#9(;_4g5q;)S6!TdZQ{!MLM z@N>1%OG^yVue)oaUZ?u_H>n|E6^V9|H@rkd0qyW}b+P~RJlo@Elm|bnJd8=JD^02E zUJefrf0*Ia#03AhA$n=4@)=<_A%vPQzDL%IIBL!K0BwZ$wv!lAWlG!do6;k{q{?G2 zd2QCkt)!MqJE%P;lsdHGG+1|zY_+M>S9${XXWKEQtf!WX+vxho|0IsmBTtesc^#hl znA&sq;n+(ZnsBliQpsMQPPUpPd{3l4{Ymj$+vWF(5BYgM*Tw{UpJ&^aqJ3nqiN^E5 zP?HEOsnlN!-nHDSayi))u(8X{m`FKeZJ)2zfjA$ zwPY>XE1nx|P9ld{M^mmox;0QiBef^d&Ox%49;LQS2|Hup=L{!bppHvxgiU_ViRUKm zbu@j)Medm?nz=nr(~~x|bAyJeKc~K;9n`J;K(y0)bw8PNH4(c!dkOnJ%Pfm-@J=T5^xFVfk)9i>If3Us_de&ei?(FklK0$P&+)U$ zkF!Vjl*6p$cCL?GbiZ22_*Fi?(C<9YJb~jG96Ucg$PZzR8{

tc?w>Fea_7Z%W&; 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 Iryqg;1D5wp3IG5A literal 0 HcmV?d00001 diff --git a/docs/docs/assets/favicon/index.md b/docs/docs/assets/favicon/index.md new file mode 100644 index 00000000..681250a9 --- /dev/null +++ b/docs/docs/assets/favicon/index.md @@ -0,0 +1 @@ +# Index of assets/favicon diff --git a/docs/docs/assets/index.md b/docs/docs/assets/index.md new file mode 100644 index 00000000..5297d775 --- /dev/null +++ b/docs/docs/assets/index.md @@ -0,0 +1 @@ +# Index of assets diff --git a/docs/docs/assets/logo.svg b/docs/docs/assets/logo.svg new file mode 100644 index 00000000..4714c06a --- /dev/null +++ b/docs/docs/assets/logo.svg @@ -0,0 +1,1110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index c44ed405..d83b9be6 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -12,10 +12,12 @@ site_dir: ./.site theme: name: material custom_dir: overrides - #logo: assets/logo.png - #favicon: assets/favicon.png + logo: assets/logo.svg + favicon: assets/favicon/favicon-32x32.png palette: - - primary: deep purple + - primary: light blue + - accent: light blue + # Light Mode - media: "(prefers-color-scheme: light)" scheme: default From 92ac943cf5306648663613e24bf0fa4fb4ada0e2 Mon Sep 17 00:00:00 2001 From: Orestis Herodotou Date: Wed, 9 Oct 2024 11:27:20 -0700 Subject: [PATCH 094/167] Add notebook css overrides --- docs/docs/stylesheets/index.md | 1 + .../{stylesheets/extra.css => docs/stylesheets/notebooks.css} | 4 ---- docs/mkdocs.yml | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) create mode 100644 docs/docs/stylesheets/index.md rename docs/{stylesheets/extra.css => docs/stylesheets/notebooks.css} (77%) diff --git a/docs/docs/stylesheets/index.md b/docs/docs/stylesheets/index.md new file mode 100644 index 00000000..d0ff6d5c --- /dev/null +++ b/docs/docs/stylesheets/index.md @@ -0,0 +1 @@ +# Index of stylesheets diff --git a/docs/stylesheets/extra.css b/docs/docs/stylesheets/notebooks.css similarity index 77% rename from docs/stylesheets/extra.css rename to docs/docs/stylesheets/notebooks.css index 6a2a3020..583875ef 100644 --- a/docs/stylesheets/extra.css +++ b/docs/docs/stylesheets/notebooks.css @@ -6,8 +6,4 @@ .jp-OutputPrompt { display: none !important; -} - -body { - background-color: red !important; } \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index d83b9be6..b2b1db6d 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -51,7 +51,7 @@ theme: code: Roboto Mono extra_css: - - stylesheets/extra.css # TODO: this isn't working anymore? + - stylesheets/notebooks.css extra: social: From f8b0cfe19b7964575f954f3d62f6ae5c96ebab49 Mon Sep 17 00:00:00 2001 From: Orestis Herodotou Date: Wed, 9 Oct 2024 11:28:30 -0700 Subject: [PATCH 095/167] Add next/previous links in footer --- docs/mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index b2b1db6d..9f2da871 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -36,6 +36,7 @@ theme: - navigation.instant.progress - navigation.tracking - navigation.indexes + - navigation.footer #- navigation.tabs #- navigation.tabs.sticky - navigation.expand From cc222297c552a68b6c3a4d88790d8afa1a644f3e Mon Sep 17 00:00:00 2001 From: Orestis Herodotou Date: Mon, 14 Oct 2024 11:18:16 -0700 Subject: [PATCH 096/167] update styling & add homepage --- docs/README.md | 2 +- docs/docs/assets/earthmover/lockup.svg | 1 + docs/docs/assets/earthmover/workmark.svg | 1 + .../assets/favicon/android-chrome-192x192.png | Bin 24761 -> 0 bytes .../assets/favicon/android-chrome-512x512.png | Bin 104068 -> 0 bytes docs/docs/assets/favicon/apple-touch-icon.png | Bin 22110 -> 0 bytes docs/docs/assets/favicon/favicon-16x16.png | Bin 737 -> 0 bytes docs/docs/assets/favicon/favicon-32x32.png | Bin 1772 -> 0 bytes docs/docs/assets/favicon/favicon-96x96.png | Bin 0 -> 6500 bytes docs/docs/assets/favicon/favicon.ico | Bin 15406 -> 0 bytes docs/docs/assets/favicon/index.md | 1 - docs/docs/assets/hero/heart.svg | 77 ++++++++ docs/docs/assets/hero/hero-bottom-dark.svg | 170 ++++++++++++++++ docs/docs/assets/hero/hero-bottom.svg | 170 ++++++++++++++++ docs/docs/assets/index.md | 1 - docs/docs/assets/logo-wire.svg | 17 ++ docs/docs/getting-started.md | 135 +++++++++++++ docs/docs/icechunk-python/index.md | 2 +- docs/docs/stylesheets/global.css | 5 + docs/docs/stylesheets/homepage.css | 187 ++++++++++++++++++ docs/docs/stylesheets/index.md | 1 - docs/docs/stylesheets/theme.css | 33 ++++ docs/mkdocs.yml | 40 ++-- docs/overrides/home.html | 68 +++++++ docs/overrides/main.html | 6 +- 25 files changed, 895 insertions(+), 22 deletions(-) create mode 100755 docs/docs/assets/earthmover/lockup.svg create mode 100755 docs/docs/assets/earthmover/workmark.svg delete mode 100644 docs/docs/assets/favicon/android-chrome-192x192.png delete mode 100644 docs/docs/assets/favicon/android-chrome-512x512.png delete mode 100644 docs/docs/assets/favicon/apple-touch-icon.png delete mode 100644 docs/docs/assets/favicon/favicon-16x16.png delete mode 100644 docs/docs/assets/favicon/favicon-32x32.png create mode 100644 docs/docs/assets/favicon/favicon-96x96.png delete mode 100644 docs/docs/assets/favicon/favicon.ico delete mode 100644 docs/docs/assets/favicon/index.md create mode 100644 docs/docs/assets/hero/heart.svg create mode 100644 docs/docs/assets/hero/hero-bottom-dark.svg create mode 100644 docs/docs/assets/hero/hero-bottom.svg delete mode 100644 docs/docs/assets/index.md create mode 100644 docs/docs/assets/logo-wire.svg create mode 100644 docs/docs/getting-started.md create mode 100644 docs/docs/stylesheets/global.css create mode 100644 docs/docs/stylesheets/homepage.css delete mode 100644 docs/docs/stylesheets/index.md create mode 100644 docs/docs/stylesheets/theme.css create mode 100644 docs/overrides/home.html diff --git a/docs/README.md b/docs/README.md index 4a60ad23..7b75e304 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,4 +38,4 @@ These are also ignored in `.gitignore` !!! tip See [icechunk-docs/macros.py](./macros.py) for more info. -[^1] : Disambiguation: `icechunk/docs/docs` \ No newline at end of file +[^1]: Disambiguation: `icechunk/docs/docs` \ No newline at end of file diff --git a/docs/docs/assets/earthmover/lockup.svg b/docs/docs/assets/earthmover/lockup.svg new file mode 100755 index 00000000..73ff51ae --- /dev/null +++ b/docs/docs/assets/earthmover/lockup.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/docs/assets/earthmover/workmark.svg b/docs/docs/assets/earthmover/workmark.svg new file mode 100755 index 00000000..11e8c955 --- /dev/null +++ b/docs/docs/assets/earthmover/workmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/docs/assets/favicon/android-chrome-192x192.png b/docs/docs/assets/favicon/android-chrome-192x192.png deleted file mode 100644 index 57158c3d674dccca8dd1e50ccbd6888a66e275e0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24761 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 Y4?2|9&+eymKmY&$07*qoM6N<$g0ZNw3;+NC diff --git a/docs/docs/assets/favicon/android-chrome-512x512.png b/docs/docs/assets/favicon/android-chrome-512x512.png deleted file mode 100644 index cea0ed71d91a3badd51a57da8bd44e26d454ff9f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 104068 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 diff --git a/docs/docs/assets/favicon/apple-touch-icon.png b/docs/docs/assets/favicon/apple-touch-icon.png deleted file mode 100644 index 1f89e55624b5c66bc57d5b42abfe70460944f7d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22110 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 Z{r}3&&Kpy<8>;{S002ovPDHLkV1m#%NihHb diff --git a/docs/docs/assets/favicon/favicon-16x16.png b/docs/docs/assets/favicon/favicon-16x16.png deleted file mode 100644 index d62cb9a63bcae3540852f65779aa2e5b7b438c3a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 737 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

tc?w>Fea_7Z%W&; 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 Iryqg;1D5wp3IG5A diff --git a/docs/docs/assets/favicon/index.md b/docs/docs/assets/favicon/index.md deleted file mode 100644 index 681250a9..00000000 --- a/docs/docs/assets/favicon/index.md +++ /dev/null @@ -1 +0,0 @@ -# Index of assets/favicon diff --git a/docs/docs/assets/hero/heart.svg b/docs/docs/assets/hero/heart.svg new file mode 100644 index 00000000..a5062563 --- /dev/null +++ b/docs/docs/assets/hero/heart.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + diff --git a/docs/docs/assets/hero/hero-bottom-dark.svg b/docs/docs/assets/hero/hero-bottom-dark.svg new file mode 100644 index 00000000..9c61b5dd --- /dev/null +++ b/docs/docs/assets/hero/hero-bottom-dark.svg @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/docs/assets/hero/hero-bottom.svg b/docs/docs/assets/hero/hero-bottom.svg new file mode 100644 index 00000000..4dd6ffd5 --- /dev/null +++ b/docs/docs/assets/hero/hero-bottom.svg @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/docs/assets/index.md b/docs/docs/assets/index.md deleted file mode 100644 index 5297d775..00000000 --- a/docs/docs/assets/index.md +++ /dev/null @@ -1 +0,0 @@ -# Index of assets diff --git a/docs/docs/assets/logo-wire.svg b/docs/docs/assets/logo-wire.svg new file mode 100644 index 00000000..fed114fd --- /dev/null +++ b/docs/docs/assets/logo-wire.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md new file mode 100644 index 00000000..28e0fbea --- /dev/null +++ b/docs/docs/getting-started.md @@ -0,0 +1,135 @@ +# Icechunk + +!!! info "Welcome to Icechunk!" + Icechunk is a transactional storage engine for Zarr designed for use on cloud object storage. + +Let's break down what that means: + +- **[Zarr](https://zarr.dev/)** is an open source specification for the storage of multidimensional array (a.k.a. tensor) data. + Zarr defines the metadata for describing arrays (shape, dtype, etc.) and the way these arrays are chunked, compressed, and converted to raw bytes for storage. Zarr can store its data in any key-value store. +- **Storage engine** - Icechunk exposes a key-value interface to Zarr and manages all of the actual I/O for getting, setting, and updating both metadata and chunk data in cloud object storage. + Zarr libraries don't have to know exactly how icechunk works under the hood in order to use it. +- **Transactional** - The key improvement that Icechunk brings on top of regular Zarr is to provide consistent serializable isolation between transactions. + This means that Icechunk data are safe to read and write in parallel from multiple uncoordinated processes. + This allows Zarr to be used more like a database. + +## Goals of Icechunk + +The core entity in Icechunk is a **store**. +A store is defined as a Zarr hierarchy containing one or more Arrays and Groups. +The most common scenario is for an Icechunk store to contain a single Zarr group with multiple arrays, each corresponding to different physical variables but sharing common spatiotemporal coordinates. +However, formally a store can be any valid Zarr hierarchy, from a single Array to a deeply nested structure of Groups and Arrays. +Users of Icechunk should aim to scope their stores only to related arrays and groups that require consistent transactional updates. + +Icechunk aspires to support the following core requirements for stores: + +1. **Object storage** - the format is designed around the consistency features and performance characteristics available in modern cloud object storage. No external database or catalog is required to maintain a store. +1. **Serializable isolation** - Reads are isolated from concurrent writes and always use a committed snapshot of a store. Writes are committed atomically and are never partially visible. Readers will not acquire locks. +1. **Time travel** - Previous snapshots of a store remain accessible after new ones have been written. +1. **Data Version Control** - Stores support both _tags_ (immutable references to snapshots) and _branches_ (mutable references to snapshots). +1. **Chunk sharding and references** - Chunk storage is decoupled from specific file names. Multiple chunks can be packed into a single object (sharding). Zarr-compatible chunks within other file formats (e.g. HDF5, NetCDF) can be referenced. +1. **Schema Evolution** - Arrays and Groups can be added, renamed, and removed from the hierarchy with minimal overhead. + +## The Project + +This Icechunk project consists of three main parts: + +1. The [Icechunk specification](./spec.md). +1. A Rust implementation +1. A Python wrapper which exposes a Zarr store interface + +All of this is open source, licensed under the Apache 2.0 license. + +The Rust implementation is a solid foundation for creating bindings in any language. +We encourage adopters to collaborate on the Rust implementation, rather than reimplementing Icechunk from scratch in other languages. + +We encourage collaborators from the broader community to contribute to Icechunk. +Governance of the project will be managed by Earthmover PBC. + +## How Can I Use It? + +We recommend using [Icechunk from Python](./icechunk-python/index.md), together with the Zarr-Python library. + +!!! warning "Icechunk is a very new project." + It is not recommended for production use at this time. + These instructions are aimed at Icechunk developers and curious early adopters. + +## Key Concepts: Snapshots, Branches, and Tags + +Every update to an Icechunk store creates a new **snapshot** with a unique ID. +Icechunk users must organize their updates into groups of related operations called **transactions**. +For example, appending a new time slice to mutliple arrays should be done as single transactions, comprising the following steps +1. Update the array metadata to resize the array to accommodate the new elements. +2. Write new chunks for each array in the group. + +While the transaction is in progress, none of these changes will be visible to other users of the store. +Once the transaction is committed, a new snapshot is generated. +Readers can only see and use committed snapshots. + +Additionally, snapshots occur in a specific linear (i.e. serializable) order within **branch**. +A branch is a mutable reference to a snapshot--a pointer that maps the branch name to a snapshot ID. +The default branch is `main`. +Every commit to the main branch updates this reference. +Icechunk's design protects against the race condition in which two uncoordinated sessions attempt to update the branch at the same time; only one can succeed. + +Finally, Icechunk defines **tags**--_immutable_ references to snapshot. +Tags are appropriate for publishing specific releases of a repository or for any application which requires a persistent, immutable identifier to the store state. + +## How Does It Work? + +!!! note + For more detailed explanation, have a look at the [Icechunk spec](./spec.md) + +Zarr itself works by storing both metadata and chunk data into a abstract store according to a specified system of "keys". +For example, a 2D Zarr array called myarray, within a group called mygroup, would generate the following keys: + +``` +mygroup/zarr.json +mygroup/myarray/zarr.json +mygroup/myarray/c/0/0 +mygroup/myarray/c/0/1 +``` + +In standard regular Zarr stores, these key map directly to filenames in a filesystem or object keys in an object storage system. +When writing data, a Zarr implementation will create these keys and populate them with data. When modifying existing arrays or groups, a Zarr implementation will potentially overwrite existing keys with new data. + +This is generally not a problem, as long there is only one person or process coordinating access to the data. +However, when multiple uncoordinated readers and writers attempt to access the same Zarr data at the same time, [various consistency problems](https://docs.earthmover.io/concepts/version-control-system#consistency-problems-with-zarr) problems emerge. +These consistency problems can occur in both file storage and object storage; they are particularly severe in a cloud setting where Zarr is being used as an active store for data that are frequently changed while also being read. + +With Icechunk, we keep the same core Zarr data model, but add a layer of indirection between the Zarr keys and the on-disk storage. +The Icechunk library translates between the Zarr keys and the actual on-disk data given the particular context of the user's state. +Icechunk defines a series of interconnected metadata and data files that together enable efficient isolated reading and writing of metadata and chunks. +Once written, these files are immutable. +Icechunk keeps track of every single chunk explicitly in a "chunk manifest". + +```mermaid +flowchart TD + zarr-python[Zarr Library] <-- key / value--> icechunk[Icechunk Library] + icechunk <-- data / metadata files --> storage[(Object Storage)] +``` + +## FAQ + +1. _Why not just use Iceberg directly?_ + + Iceberg and all other "table formats" (Delta, Hudi, LanceDB, etc.) are based on tabular data model. + This data model cannot accommodate large, multidimensional arrays (tensors) in a general, scalable way. + +1. Is Icechunk part of Zarr? + + Formally, no. + Icechunk is a separate specification from Zarr. + However, it is designed to interoperate closely with Zarr. + In the future, we may propose a more formal integration between the Zarr spec and Icechunk spec if helpful. + For now, keeping them separate allows us to evolve Icechunk quickly while maintaining the stability and backwards compatibility of the Zarr data model. + +## Inspiration + +Icechunk's was inspired by several existing projects and formats, most notably + +- [FSSpec Reference Filesystem](https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.implementations.reference.ReferenceFileSystem) +- [Apache Iceberg](https://iceberg.apache.org/spec/) +- [LanceDB](https://lancedb.github.io/lance/format.html) +- [TileDB](https://docs.tiledb.com/main/background/key-concepts-and-data-format) +- [OCDBT](https://google.github.io/tensorstore/kvstore/ocdbt/index.html) \ No newline at end of file diff --git a/docs/docs/icechunk-python/index.md b/docs/docs/icechunk-python/index.md index 0a09a922..0317b3c2 100644 --- a/docs/docs/icechunk-python/index.md +++ b/docs/docs/icechunk-python/index.md @@ -4,4 +4,4 @@ - [examples](/icechunk-python/examples/) - [notebooks](/icechunk-python/notebooks/) - [quickstart](/icechunk-python/quickstart/) -- [reference](/icechunk-python/reference/) \ No newline at end of file +- [reference](/icechunk-python/reference/) diff --git a/docs/docs/stylesheets/global.css b/docs/docs/stylesheets/global.css new file mode 100644 index 00000000..8a88a074 --- /dev/null +++ b/docs/docs/stylesheets/global.css @@ -0,0 +1,5 @@ +/* Global Adjustments */ + +[dir=ltr] .md-header__title { + margin-left: 0px; +} diff --git a/docs/docs/stylesheets/homepage.css b/docs/docs/stylesheets/homepage.css new file mode 100644 index 00000000..c5e13b42 --- /dev/null +++ b/docs/docs/stylesheets/homepage.css @@ -0,0 +1,187 @@ +/* Homepage styles */ + +/* Application header should be static for the landing page */ +.md-header { + position: initial; + } + + /* Remove spacing, as we cannot hide it completely */ + .md-main__inner { + margin: 0; + max-width: 100%; + } + + /* Hide breadcrumb in content */ + #home-content a:first-child { + display:none; + } + + /* Hide table of contents for desktop */ + @media screen and (min-width: 60em) { + .md-sidebar--secondary { + display: none !important; + } + } + + /* Hide navigation for desktop */ + @media screen and (min-width: 76.25em) { + .md-sidebar--primary { + display: none; + } + } + +/* Hero */ +#hero-container { + + --ice-height: 390px; + --header-height: 155px; + + min-height: calc(100vh - var(--header-height)); + position: relative; + text-align:center; + background: linear-gradient(180deg, var(--md-primary-fg-color) 0%, var(--md-primary-fg-color--dark) 100%); +} + +.mdx-hero { + min-height: calc(100vh - var(--header-height) - var(--ice-height)); +} + +.hero-image-container {} + +#hero-image { + margin-top: 10%; + width: 30%; + height: auto; + filter: drop-shadow( 3px 5px 5px rgba(0, 0, 0, .15)); +} + +h1.hero-title { + font-size: 4rem; + font-weight: bold; + text-transform: uppercase; + margin-bottom: 10px; + color: white; + color: var(--md-primary-bg-color); + text-shadow: 5px 5px 0px rgba(0,0,0,0.2); + +} + +h3.hero-subtitle { + color: var(--md-primary-fg-color--light); + margin-top:0px; + margin-bottom: 60px; + font-size: 1.5rem; + text-transform: lowercase; + text-shadow: 3px 3px 0px rgba(0,0,0,0.2); + +} + + +.hero-cta-button { + background-color: var(--md-primary-bg-color); + box-shadow: 5px 5px 0px rgba(0,0,0,0.1); +} + +.links { + display: flex; + justify-content: center; + align-items: flex-start; + gap: 20px; + margin-top: 60px; +} + + +.by-line-wrapper { + margin: 60px auto; + width: 50%; + +} + +.by-line-container { + + border-radius: 20px; + padding: 10px; + color: var(--md-primary-fg-color--light); + font-size: 1.25rem; + justify-content: center; + display: flex; + align-items: center; + gap: 10px; + +} + +.by-line { + display: flex; + align-items: center; + gap: 10px; + text-shadow: 3px 3px 0px rgba(0,0,0,0.2); + +} + +.heart-container { + display:block; + padding-top:15px; +} +.heart-image { + width: 40px; + min-width: 40px; + height: auto; +} + +.earthmover-wordmark { + width: 260px; +} + +/* ice */ +.ice-container { + height: var(--ice-height); + width: 100%; + overflow:hidden; + background-image: url('../assets/hero/hero-bottom.svg'); + background-repeat: no-repeat; + background-position: bottom center; +} + +/* Mobile */ +@media screen and (max-width: 60em) { + + #hero-image { + min-width: 200px; + } + + h1.hero-title{ + font-size:2rem; + } + + h3.hero-subtitle { + font-size: 1rem; + } + + + .links { + flex-direction: column; + align-items: center; + } + + .by-line-wrapper { + width:70%; + } + + .by-line-container { + flex-direction: column; + } + .by-line span { + font-size: 0.85rem; + } + + .earthmover-wordmark { + width: 200px; + } +} + +/* Dark theme */ +[data-md-color-scheme="slate"]{ + .ice-container { + background-image: url('../assets/hero/hero-bottom-dark.svg'); + } +} \ No newline at end of file diff --git a/docs/docs/stylesheets/index.md b/docs/docs/stylesheets/index.md deleted file mode 100644 index d0ff6d5c..00000000 --- a/docs/docs/stylesheets/index.md +++ /dev/null @@ -1 +0,0 @@ -# Index of stylesheets diff --git a/docs/docs/stylesheets/theme.css b/docs/docs/stylesheets/theme.css new file mode 100644 index 00000000..d527b944 --- /dev/null +++ b/docs/docs/stylesheets/theme.css @@ -0,0 +1,33 @@ +/* Colors + @see https://m2.material.io/design/color/the-color-system.html#tools-for-picking-colors + @see https://github.com/squidfunk/mkdocs-material/blob/master/src/templates/assets/stylesheets/main/_colors.scss +*/ + +[data-md-color-scheme="light"] { + /* Primary color shades */ + --md-primary-fg-color: #5ea0d1; + --md-primary-fg-color--light: #e4f1f8; + --md-primary-fg-color--dark: #1d467f; + + --md-primary-bg-color: hsla(0, 0%, 100%, 1); + --md-primary-bg-color--light: hsla(0, 0%, 100%, 0.7); + + /* Accent color shades */ + --md-accent-fg-color: #a653ff; + --md-accent-fg-color--transparent: rgba(166, 83, 255, 0.7); + //--md-accent-bg-color: hsla(0, 0%, 100%, 1); + //--md-accent-bg-color--light: hsla(0, 0%, 100%, 0.7); +} + +/* Dark */ +[data-md-color-scheme="slate"] { + --md-primary-fg-color: #1d467f; + --md-primary-fg-color--light: #e4f1f8; + --md-primary-fg-color--dark: #5ea0d1; + + /* Accent color shades */ + --md-accent-fg-color: #a653ff; + --md-accent-fg-color--transparent: rgba(166, 83, 255, 0.7); + //--md-accent-bg-color: hsla(0, 0%, 100%, 1); + //--md-accent-bg-color--light: hsla(0, 0%, 100%, 0.7); +} \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 9f2da871..659c51ae 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -1,6 +1,6 @@ site_name: Icechunk site_description: >- - Transactional storage engine for Zarr on cloud object storage. + Open-source, cloud-native transactional tensor storage engine site_author: Earthmover PBC site_url: https://icechunk.io repo_url: https://github.com/earth-mover/icechunk @@ -9,27 +9,38 @@ copyright: Earthmover PBC # @see overrides/partials/footer.html site_dir: ./.site +extra_css: + - stylesheets/theme.css + - stylesheets/global.css + - stylesheets/notebooks.css + theme: name: material custom_dir: overrides - logo: assets/logo.svg - favicon: assets/favicon/favicon-32x32.png + logo: assets/logo-wire.svg + favicon: assets/favicon/favicon-96x96.png palette: - - primary: light blue - - accent: light blue + + # Palette toggle for automatic mode + #- media: "(prefers-color-scheme)" + # toggle: + # icon: material/brightness-auto + # name: Switch to light mode # Light Mode - media: "(prefers-color-scheme: light)" - scheme: default + scheme: light toggle: icon: material/weather-night name: Switch to dark mode + # Dark Mode - media: "(prefers-color-scheme: dark)" scheme: slate toggle: icon: material/weather-sunny name: Switch to light mode + features: - navigation.instant - navigation.instant.prefetch @@ -37,8 +48,8 @@ theme: - navigation.tracking - navigation.indexes - navigation.footer - #- navigation.tabs - #- navigation.tabs.sticky + - navigation.tabs + - navigation.tabs.sticky - navigation.expand - toc.follow - navigation.top @@ -51,15 +62,14 @@ theme: text: Roboto code: Roboto Mono -extra_css: - - stylesheets/notebooks.css - extra: social: - icon: fontawesome/brands/github link: https://github.com/earth-mover/icechunk - icon: fontawesome/brands/python link: https://pypi.org/project/icechunk/ + - icon: fontawesome/brands/rust + link: https://crates.io/crates/icechunk - icon: fontawesome/brands/x-twitter link: https://x.com/earthmoverhq generator: false @@ -91,7 +101,13 @@ plugins: - social - include-markdown - open-in-new-tab - - mkdocs-breadcrumbs-plugin + - mkdocs-breadcrumbs-plugin: + exclude_paths: + #- icechunk-python + - assets + - stylesheets + - index.md + generate_home_index: false - mermaid2 - minify: minify_html: true diff --git a/docs/overrides/home.html b/docs/overrides/home.html new file mode 100644 index 00000000..f3ccced5 --- /dev/null +++ b/docs/overrides/home.html @@ -0,0 +1,68 @@ +{% extends "main.html" %} + + +{% block announce %} +

+{% endblock %} + + +{% block tabs %} + {{ super() }} + + + + + +
+
+ + +
+ +
+ + +
+

Icechunk

+

{{ config.site_description }}

+ + Get Started + + + + + +
+
+
+
+{% endblock %} + + +{% block content %} +
+
+ {{ page.content }} + + AAAA fgsdfgsdfgsd +
+
+{% endblock %} + diff --git a/docs/overrides/main.html b/docs/overrides/main.html index aa4ebd1b..63913c18 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -1,5 +1 @@ -{% extends "base.html" %} - -{% block announce %} - -{% endblock %} \ No newline at end of file +{% extends "base.html" %} \ No newline at end of file From b27392f95a83945db4c6576025ddfebe405e198e Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Mon, 14 Oct 2024 15:40:42 -0300 Subject: [PATCH 097/167] All python repo methods are sync now There are also versions with the same name starting with `async` that are async. Example: `commit` (sync) and `async_commit` (async). I don't like this, it's not a good API, but it will have to do for now. We'll refactor soon. --- icechunk-python/examples/dask_write.py | 45 +- icechunk-python/examples/smoke-test.py | 25 +- icechunk-python/python/icechunk/__init__.py | 148 +++++-- icechunk-python/src/lib.rs | 415 ++++++++++++++---- icechunk-python/tests/conftest.py | 10 +- icechunk-python/tests/test_can_read_old.py | 44 +- icechunk-python/tests/test_concurrency.py | 4 +- icechunk-python/tests/test_config.py | 2 +- .../tests/test_distributed_writers.py | 14 +- icechunk-python/tests/test_pickle.py | 10 +- icechunk-python/tests/test_timetravel.py | 26 +- icechunk-python/tests/test_virtual_ref.py | 14 +- icechunk-python/tests/test_zarr/test_api.py | 6 +- icechunk-python/tests/test_zarr/test_group.py | 4 +- .../tests/test_zarr/test_store/test_core.py | 2 +- .../test_store/test_icechunk_store.py | 8 +- 16 files changed, 541 insertions(+), 236 deletions(-) diff --git a/icechunk-python/examples/dask_write.py b/icechunk-python/examples/dask_write.py index 72a29fa7..e6bc78f5 100644 --- a/icechunk-python/examples/dask_write.py +++ b/icechunk-python/examples/dask_write.py @@ -32,7 +32,6 @@ """ import argparse -import asyncio from dataclasses import dataclass from typing import Any, cast from urllib.parse import urlparse @@ -58,7 +57,7 @@ def generate_task_array(task: Task, shape: tuple[int,...]) -> np.typing.ArrayLik return np.random.rand(*shape) -async def execute_write_task(task: Task) -> icechunk.IcechunkStore: +def execute_write_task(task: Task) -> icechunk.IcechunkStore: """Execute task as a write task. This will read the time coordinade from `task` and write a "pancake" in that position, @@ -82,7 +81,7 @@ async def execute_write_task(task: Task) -> icechunk.IcechunkStore: return store -async def execute_read_task(task: Task) -> None: +def execute_read_task(task: Task) -> None: """Execute task as a read task. This will read the time coordinade from `task` and read a "pancake" in that position. @@ -101,16 +100,6 @@ async def execute_read_task(task: Task) -> None: dprint(f"t={task.time} verified") -def run_write_task(task: Task) -> icechunk.IcechunkStore: - """Sync helper for write tasks""" - return asyncio.run(execute_write_task(task)) - - -def run_read_task(task: Task) -> None: - """Sync helper for read tasks""" - return asyncio.run(execute_read_task(task)) - - def storage_config(args: argparse.Namespace) -> dict[str, Any]: """Return the Icechunk store S3 configuration map""" bucket = args.url.netloc @@ -129,7 +118,7 @@ def store_config(args: argparse.Namespace) -> dict[str, Any]: return {"inline_chunk_threshold_bytes": 1} -async def create(args: argparse.Namespace) -> None: +def create(args: argparse.Namespace) -> None: """Execute the create subcommand. Creates an Icechunk store, a root group and an array named "array" @@ -137,7 +126,7 @@ async def create(args: argparse.Namespace) -> None: Commits the Icechunk repository when done. """ - store = await icechunk.IcechunkStore.open( + store = icechunk.IcechunkStore.open( storage=icechunk.StorageConfig.s3_from_env(**storage_config(args)), mode="w", config=icechunk.StoreConfig(**store_config(args)), @@ -158,11 +147,11 @@ async def create(args: argparse.Namespace) -> None: dtype="f8", fill_value=float("nan"), ) - _first_snapshot = await store.commit("array created") + _first_snapshot = store.commit("array created") print("Array initialized") -async def update(args: argparse.Namespace) -> None: +def update(args: argparse.Namespace) -> None: """Execute the update subcommand. Uses Dask to write chunks to the Icechunk repository. Currently Icechunk cannot @@ -177,7 +166,7 @@ async def update(args: argparse.Namespace) -> None: storage_conf = storage_config(args) store_conf = store_config(args) - store = await icechunk.IcechunkStore.open( + store = icechunk.IcechunkStore.open( storage=icechunk.StorageConfig.s3_from_env(**storage_conf), mode="r+", config=icechunk.StoreConfig(**store_conf), @@ -198,19 +187,19 @@ async def update(args: argparse.Namespace) -> None: client = Client(n_workers=args.workers, threads_per_worker=1) - map_result = client.map(run_write_task, tasks) + map_result = client.map(execute_write_task, tasks) worker_stores = client.gather(map_result) print("Starting distributed commit") # we can use the current store as the commit coordinator, because it doesn't have any pending changes, # all changes come from the tasks, Icechunk doesn't care about where the changes come from, the only # important thing is to not count changes twice - commit_res = await store.distributed_commit("distributed commit", [ws.change_set_bytes() for ws in worker_stores]) + commit_res = store.distributed_commit("distributed commit", [ws.change_set_bytes() for ws in worker_stores]) assert commit_res print("Distributed commit done") -async def verify(args: argparse.Namespace) -> None: +def verify(args: argparse.Namespace) -> None: """Execute the verify subcommand. Uses Dask to read and verify chunks from the Icechunk repository. Currently Icechunk cannot @@ -223,7 +212,7 @@ async def verify(args: argparse.Namespace) -> None: storage_conf = storage_config(args) store_conf = store_config(args) - store = await icechunk.IcechunkStore.open( + store = icechunk.IcechunkStore.open( storage=icechunk.StorageConfig.s3_from_env(**storage_conf), mode="r", config=icechunk.StoreConfig(**store_conf), @@ -244,12 +233,12 @@ async def verify(args: argparse.Namespace) -> None: client = Client(n_workers=args.workers, threads_per_worker=1) - map_result = client.map(run_read_task, tasks) + map_result = client.map(execute_read_task, tasks) client.gather(map_result) print("done, all good") -async def main() -> None: +def main() -> None: """Main entry point for the script. Parses arguments and delegates to a subcommand. @@ -328,12 +317,12 @@ async def main() -> None: match args.command: case "create": - await create(args) + create(args) case "update": - await update(args) + update(args) case "verify": - await verify(args) + verify(args) if __name__ == "__main__": - asyncio.run(main()) + main() diff --git a/icechunk-python/examples/smoke-test.py b/icechunk-python/examples/smoke-test.py index 14d3905e..891639c3 100644 --- a/icechunk-python/examples/smoke-test.py +++ b/icechunk-python/examples/smoke-test.py @@ -66,7 +66,7 @@ async def run(store: Store) -> None: first_commit = None if isinstance(store, IcechunkStore): - first_commit = await store.commit("initial commit") + first_commit = store.commit("initial commit") expected = {} expected["root-foo"] = create_array( @@ -79,32 +79,32 @@ async def run(store: Store) -> None: group["root-foo"].attrs["update"] = "new attr" if isinstance(store, IcechunkStore): - _second_commit = await store.commit("added array, updated attr") + _second_commit = store.commit("added array, updated attr") assert len(group["root-foo"].attrs) == 2 assert len(group.members()) == 1 if isinstance(store, IcechunkStore) and first_commit is not None: - await store.checkout(first_commit) + store.checkout(first_commit) group.attrs["update"] = "new attr 2" if isinstance(store, IcechunkStore): try: - await store.commit("new attr 2") + store.commit("new attr 2") except ValueError: pass else: raise ValueError("should have conflicted") - await store.reset() # FIXME: WHY - await store.checkout(branch="main") + store.reset() + store.checkout(branch="main") group["root-foo"].attrs["update"] = "new attr 2" if isinstance(store, IcechunkStore): - _third_commit = await store.commit("new attr 2") + _third_commit = store.commit("new attr 2") try: - await store.commit("rewrote array") + store.commit("rewrote array") except ValueError: pass else: @@ -134,7 +134,7 @@ async def run(store: Store) -> None: fill_value=-1234, ) if isinstance(store, IcechunkStore): - _fourth_commit = await store.commit("added groups and arrays") + _fourth_commit = store.commit("added groups and arrays") print(f"Write done in {time.time() - write_start} secs") @@ -155,8 +155,8 @@ async def run(store: Store) -> None: async def create_icechunk_store(*, storage: StorageConfig) -> IcechunkStore: - return await IcechunkStore.open( - storage=storage, mode="r+", config=StoreConfig(inline_chunk_threshold_bytes=1) + return IcechunkStore.open( + storage=storage, mode="w", config=StoreConfig(inline_chunk_threshold_bytes=1) ) @@ -173,7 +173,6 @@ async def create_zarr_store(*, store: Literal["memory", "local", "s3"]) -> Store "anon": False, "key": "minio123", "secret": "minio123", - "region": "us-east-1", "endpoint_url": "http://localhost:9000", }, ) @@ -199,5 +198,5 @@ async def create_zarr_store(*, store: Literal["memory", "local", "s3"]) -> Store asyncio.run(run(store)) print("Zarr store") - zarr_store = asyncio.run(create_zarr_store(store="local")) + zarr_store = asyncio.run(create_zarr_store(store="s3")) asyncio.run(run(zarr_store)) diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index b49a89d5..b3b63a48 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -1,5 +1,5 @@ # module -from collections.abc import AsyncGenerator, Iterable +from collections.abc import AsyncGenerator, Iterable, Generator from typing import Any, Self from zarr.abc.store import ByteRangeRequest, Store @@ -37,7 +37,7 @@ class IcechunkStore(Store, SyncMixin): _store: PyIcechunkStore @classmethod - async def open(cls, *args: Any, **kwargs: Any) -> Self: + def open(cls, *args: Any, **kwargs: Any) -> Self: if "mode" in kwargs: mode = kwargs.pop("mode") else: @@ -53,23 +53,23 @@ async def open(cls, *args: Any, **kwargs: Any) -> Self: store = None match mode: case "r" | "r+": - store = await cls.open_existing(storage, mode, *args, **kwargs) + store = cls.open_existing(storage, mode, *args, **kwargs) case "a": - if await pyicechunk_store_exists(storage): - store = await cls.open_existing(storage, mode, *args, **kwargs) + if pyicechunk_store_exists(storage): + store = cls.open_existing(storage, mode, *args, **kwargs) else: - store = await cls.create(storage, mode, *args, **kwargs) + store = cls.create(storage, mode, *args, **kwargs) case "w": - if await pyicechunk_store_exists(storage): - store = await cls.open_existing(storage, mode, *args, **kwargs) - await store.clear() + if pyicechunk_store_exists(storage): + store = cls.open_existing(storage, mode, *args, **kwargs) + store.sync_clear() else: - store = await cls.create(storage, mode, *args, **kwargs) + store = cls.create(storage, mode, *args, **kwargs) case "w-": - if await pyicechunk_store_exists(storage): + if pyicechunk_store_exists(storage): raise ValueError("""Zarr store already exists, open using mode "w" or "r+""""") else: - store = await cls.create(storage, mode, *args, **kwargs) + store = cls.create(storage, mode, *args, **kwargs) assert(store) # We dont want to call _open() becuase icechunk handles the opening, etc. @@ -94,7 +94,7 @@ def __init__( self._store = store @classmethod - async def open_existing( + def open_existing( cls, storage: StorageConfig, mode: AccessModeLiteral = "r", @@ -117,12 +117,12 @@ async def open_existing( # We have delayed checking if the repository exists, to avoid the delay in the happy case # So we need to check now if open fails, to provide a nice error message try: - store = await pyicechunk_store_open_existing( + store = pyicechunk_store_open_existing( storage, read_only=read_only, config=config ) # TODO: we should have an exception type to catch here, for the case of non-existing repo except Exception as e: - if await pyicechunk_store_exists(storage): + if pyicechunk_store_exists(storage): # if the repo exists, this is an actual error we need to raise raise e else: @@ -131,7 +131,7 @@ async def open_existing( return cls(store=store, mode=mode, args=args, kwargs=kwargs) @classmethod - async def create( + def create( cls, storage: StorageConfig, mode: AccessModeLiteral = "w", @@ -148,7 +148,7 @@ async def create( storage backend. """ config = config or StoreConfig() - store = await pyicechunk_store_create(storage, config=config) + store = pyicechunk_store_create(storage, config=config) return cls(store=store, mode=mode, args=args, kwargs=kwargs) def with_mode(self, mode: AccessModeLiteral) -> Self: @@ -205,7 +205,7 @@ def branch(self) -> str | None: """Return the current branch name.""" return self._store.branch - async def checkout( + def checkout( self, snapshot_id: str | None = None, branch: str | None = None, @@ -217,49 +217,98 @@ async def checkout( raise ValueError( "only one of snapshot_id, branch, or tag may be specified" ) - return await self._store.checkout_snapshot(snapshot_id) + return self._store.checkout_snapshot(snapshot_id) if branch is not None: if tag is not None: raise ValueError( "only one of snapshot_id, branch, or tag may be specified" ) - return await self._store.checkout_branch(branch) + return self._store.checkout_branch(branch) if tag is not None: - return await self._store.checkout_tag(tag) + return self._store.checkout_tag(tag) raise ValueError("a snapshot_id, branch, or tag must be specified") - async def commit(self, message: str) -> str: + async def async_checkout( + self, + snapshot_id: str | None = None, + branch: str | None = None, + tag: str | None = None, + ) -> None: + """Checkout a branch, tag, or specific snapshot.""" + if snapshot_id is not None: + if branch is not None or tag is not None: + raise ValueError( + "only one of snapshot_id, branch, or tag may be specified" + ) + return await self._store.async_checkout_snapshot(snapshot_id) + if branch is not None: + if tag is not None: + raise ValueError( + "only one of snapshot_id, branch, or tag may be specified" + ) + return await self._store.async_checkout_branch(branch) + if tag is not None: + return await self._store.async_checkout_tag(tag) + + raise ValueError("a snapshot_id, branch, or tag must be specified") + + def commit(self, message: str) -> str: """Commit any uncommitted changes to the store. This will create a new snapshot on the current branch and return the snapshot id. """ - return await self._store.commit(message) + return self._store.commit(message) + + async def async_commit(self, message: str) -> str: + """Commit any uncommitted changes to the store. - async def distributed_commit( + This will create a new snapshot on the current branch and return + the snapshot id. + """ + return await self._store.async_commit(message) + + def distributed_commit( self, message: str, other_change_set_bytes: list[bytes] ) -> str: - return await self._store.distributed_commit(message, other_change_set_bytes) + return self._store.distributed_commit(message, other_change_set_bytes) + + async def async_distributed_commit( + self, message: str, other_change_set_bytes: list[bytes] + ) -> str: + return await self._store.async_distributed_commit(message, other_change_set_bytes) @property def has_uncommitted_changes(self) -> bool: """Return True if there are uncommitted changes to the store""" return self._store.has_uncommitted_changes - async def reset(self) -> None: + async def async_reset(self) -> None: + """Discard any uncommitted changes and reset to the previous snapshot state.""" + return await self._store.async_reset() + + def reset(self) -> None: """Discard any uncommitted changes and reset to the previous snapshot state.""" - return await self._store.reset() + return self._store.reset() + + async def async_new_branch(self, branch_name: str) -> str: + """Create a new branch from the current snapshot. This requires having no uncommitted changes.""" + return await self._store.async_new_branch(branch_name) - async def new_branch(self, branch_name: str) -> str: + def new_branch(self, branch_name: str) -> str: """Create a new branch from the current snapshot. This requires having no uncommitted changes.""" - return await self._store.new_branch(branch_name) + return self._store.new_branch(branch_name) + + def tag(self, tag_name: str, snapshot_id: str) -> None: + """Tag an existing snapshot with a given name.""" + return self._store.tag(tag_name, snapshot_id=snapshot_id) - async def tag(self, tag_name: str, snapshot_id: str) -> None: + async def async_tag(self, tag_name: str, snapshot_id: str) -> None: """Tag an existing snapshot with a given name.""" - return await self._store.tag(tag_name, snapshot_id=snapshot_id) + return await self._store.async_tag(tag_name, snapshot_id=snapshot_id) - def ancestry(self) -> AsyncGenerator[SnapshotMetadata, None]: + def ancestry(self) -> Generator[SnapshotMetadata, None]: """Get the list of parents of the current version. Returns @@ -268,6 +317,15 @@ def ancestry(self) -> AsyncGenerator[SnapshotMetadata, None]: """ return self._store.ancestry() + def async_ancestry(self) -> AsyncGenerator[SnapshotMetadata, None]: + """Get the list of parents of the current version. + + Returns + ------- + AsyncGenerator[SnapshotMetadata, None] + """ + return self._store.async_ancestry() + async def empty(self) -> bool: """Check if the store is empty.""" return await self._store.empty() @@ -276,6 +334,10 @@ async def clear(self) -> None: """Clear the store.""" return await self._store.clear() + def sync_clear(self) -> None: + """Clear the store.""" + return self._store.sync_clear() + async def get( self, key: str, @@ -363,7 +425,25 @@ async def set_if_not_exists(self, key: str, value: Buffer) -> None: """ return await self._store.set_if_not_exists(key, value.to_bytes()) - async def set_virtual_ref( + async def async_set_virtual_ref( + self, key: str, location: str, *, offset: int, length: int + ) -> None: + """Store a virtual reference to a chunk. + + Parameters + ---------- + key : str + The chunk to store the reference under. This is the fully qualified zarr key eg: 'array/c/0/0/0' + location : str + The location of the chunk in storage. This is absolute path to the chunk in storage eg: 's3://bucket/path/to/file.nc' + offset : int + The offset in bytes from the start of the file location in storage the chunk starts at + length : int + The length of the chunk in bytes, measured from the given offset + """ + return await self._store.async_set_virtual_ref(key, location, offset, length) + + def set_virtual_ref( self, key: str, location: str, *, offset: int, length: int ) -> None: """Store a virtual reference to a chunk. @@ -379,7 +459,7 @@ async def set_virtual_ref( length : int The length of the chunk in bytes, measured from the given offset """ - return await self._store.set_virtual_ref(key, location, offset, length) + return self._store.set_virtual_ref(key, location, offset, length) async def delete(self, key: str) -> None: """Remove a key from the store diff --git a/icechunk-python/src/lib.rs b/icechunk-python/src/lib.rs index 3e756990..91cc4efb 100644 --- a/icechunk-python/src/lib.rs +++ b/icechunk-python/src/lib.rs @@ -20,10 +20,17 @@ use icechunk::{ }, Repository, SnapshotMetadata, }; -use pyo3::{exceptions::PyValueError, prelude::*, types::PyBytes}; +use pyo3::{ + exceptions::PyValueError, + prelude::*, + types::{PyBytes, PyList, PyNone, PyString}, +}; use storage::{PyS3Credentials, PyStorageConfig, PyVirtualRefConfig}; use streams::PyAsyncGenerator; -use tokio::sync::{Mutex, RwLock}; +use tokio::{ + runtime::Runtime, + sync::{Mutex, RwLock}, +}; pub use errors::KeyNotFound; @@ -183,8 +190,38 @@ impl PyIcechunkStore { } } +fn mk_runtime() -> PyResult { + Ok(tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| PyIcechunkStoreError::UnkownError(e.to_string()))?) +} + +#[pyfunction] +fn pyicechunk_store_open_existing( + storage: &PyStorageConfig, + read_only: bool, + config: PyStoreConfig, +) -> PyResult { + let storage = storage.into(); + let repository_config = (&config).into(); + let store_config = (&config).into(); + + let rt = mk_runtime()?; + rt.block_on(async move { + PyIcechunkStore::open_existing( + storage, + read_only, + repository_config, + store_config, + ) + .await + .map_err(PyValueError::new_err) + }) +} + #[pyfunction] -fn pyicechunk_store_open_existing<'py>( +fn async_pyicechunk_store_open_existing<'py>( py: Python<'py>, storage: &'py PyStorageConfig, read_only: bool, @@ -206,7 +243,7 @@ fn pyicechunk_store_open_existing<'py>( } #[pyfunction] -fn pyicechunk_store_exists<'py>( +fn async_pyicechunk_store_exists<'py>( py: Python<'py>, storage: &'py PyStorageConfig, ) -> PyResult> { @@ -217,7 +254,16 @@ fn pyicechunk_store_exists<'py>( } #[pyfunction] -fn pyicechunk_store_create<'py>( +fn pyicechunk_store_exists(storage: &PyStorageConfig) -> PyResult { + let storage = storage.into(); + let rt = mk_runtime()?; + rt.block_on(async move { + PyIcechunkStore::store_exists(storage).await.map_err(PyErr::from) + }) +} + +#[pyfunction] +fn async_pyicechunk_store_create<'py>( py: Python<'py>, storage: &'py PyStorageConfig, config: PyStoreConfig, @@ -232,6 +278,22 @@ fn pyicechunk_store_create<'py>( }) } +#[pyfunction] +fn pyicechunk_store_create( + storage: &PyStorageConfig, + config: PyStoreConfig, +) -> PyResult { + let storage = storage.into(); + let repository_config = (&config).into(); + let store_config = (&config).into(); + let rt = mk_runtime()?; + rt.block_on(async move { + PyIcechunkStore::create(storage, repository_config, store_config) + .await + .map_err(PyValueError::new_err) + }) +} + #[pyfunction] fn pyicechunk_store_from_bytes( bytes: Cow<[u8]>, @@ -241,8 +303,7 @@ fn pyicechunk_store_from_bytes( let consolidated: ConsolidatedStore = serde_json::from_slice(&bytes) .map_err(|e| PyValueError::new_err(e.to_string()))?; - let rt = tokio::runtime::Runtime::new() - .map_err(|e| PyIcechunkStoreError::UnkownError(e.to_string()))?; + let rt = mk_runtime()?; let store = rt.block_on(async move { PyIcechunkStore::from_consolidated(consolidated, read_only) .await @@ -282,25 +343,37 @@ impl PyIcechunkStore { Ok(PyIcechunkStore { consolidated, store }) } - fn checkout_snapshot<'py>( + fn async_checkout_snapshot<'py>( &'py self, py: Python<'py>, snapshot_id: String, ) -> PyResult> { - let snapshot_id = ObjectId::try_from(snapshot_id.as_str()).map_err(|e| { - PyIcechunkStoreError::UnkownError(format!( - "Error checking out snapshot {snapshot_id}: {e}" - )) - })?; + let store = Arc::clone(&self.store); + pyo3_asyncio_0_21::tokio::future_into_py(py, async move { + do_checkout_snapshot(store, snapshot_id).await + }) + } + fn checkout_snapshot<'py>( + &'py self, + py: Python<'py>, + snapshot_id: String, + ) -> PyResult> { + let store = Arc::clone(&self.store); + pyo3_asyncio_0_21::tokio::get_runtime().block_on(async move { + do_checkout_snapshot(store, snapshot_id).await?; + Ok(PyNone::get_bound(py).to_owned()) + }) + } + + fn async_checkout_branch<'py>( + &'py self, + py: Python<'py>, + branch: String, + ) -> PyResult> { let store = Arc::clone(&self.store); pyo3_asyncio_0_21::tokio::future_into_py(py, async move { - let mut store = store.write().await; - store - .checkout(VersionInfo::SnapshotId(snapshot_id)) - .await - .map_err(PyIcechunkStoreError::StoreError)?; - Ok(()) + do_checkout_branch(store, branch).await }) } @@ -308,15 +381,22 @@ impl PyIcechunkStore { &'py self, py: Python<'py>, branch: String, + ) -> PyResult> { + let store = Arc::clone(&self.store); + pyo3_asyncio_0_21::tokio::get_runtime().block_on(async move { + do_checkout_branch(store, branch).await?; + Ok(PyNone::get_bound(py).to_owned()) + }) + } + + fn async_checkout_tag<'py>( + &'py self, + py: Python<'py>, + tag: String, ) -> PyResult> { let store = Arc::clone(&self.store); pyo3_asyncio_0_21::tokio::future_into_py(py, async move { - let mut store = store.write().await; - store - .checkout(VersionInfo::BranchTipRef(branch)) - .await - .map_err(PyIcechunkStoreError::StoreError)?; - Ok(()) + do_checkout_tag(store, tag).await }) } @@ -324,15 +404,11 @@ impl PyIcechunkStore { &'py self, py: Python<'py>, tag: String, - ) -> PyResult> { + ) -> PyResult> { let store = Arc::clone(&self.store); - pyo3_asyncio_0_21::tokio::future_into_py(py, async move { - let mut store = store.write().await; - store - .checkout(VersionInfo::TagRef(tag)) - .await - .map_err(PyIcechunkStoreError::StoreError)?; - Ok(()) + pyo3_asyncio_0_21::tokio::get_runtime().block_on(async move { + do_checkout_tag(store, tag).await?; + Ok(PyNone::get_bound(py).to_owned()) }) } @@ -344,42 +420,54 @@ impl PyIcechunkStore { Ok(snapshot_id.to_string()) } - fn commit<'py>( + fn async_commit<'py>( &'py self, py: Python<'py>, message: String, ) -> PyResult> { let store = Arc::clone(&self.store); - // The commit mechanism is async and calls tokio::spawn so we need to use the - // pyo3_asyncio_0_21::tokio helper to run the async function in the tokio runtime pyo3_asyncio_0_21::tokio::future_into_py(py, async move { - let mut writeable_store = store.write().await; - let oid = writeable_store - .commit(&message) - .await - .map_err(PyIcechunkStoreError::from)?; - Ok(String::from(&oid)) + do_commit(store, message).await }) } - fn distributed_commit<'py>( + fn commit<'py>( + &'py self, + py: Python<'py>, + message: String, + ) -> PyResult> { + let store = Arc::clone(&self.store); + + pyo3_asyncio_0_21::tokio::get_runtime().block_on(async move { + let res = do_commit(store, message).await?; + Ok(PyString::new_bound(py, res.as_str())) + }) + } + + fn async_distributed_commit<'py>( &'py self, py: Python<'py>, message: String, other_change_set_bytes: Vec>, ) -> PyResult> { let store = Arc::clone(&self.store); - - // The commit mechanism is async and calls tokio::spawn so we need to use the - // pyo3_asyncio_0_21::tokio helper to run the async function in the tokio runtime pyo3_asyncio_0_21::tokio::future_into_py(py, async move { - let mut writeable_store = store.write().await; - let oid = writeable_store - .distributed_commit(&message, other_change_set_bytes) - .await - .map_err(PyIcechunkStoreError::from)?; - Ok(String::from(&oid)) + do_distributed_commit(store, message, other_change_set_bytes).await + }) + } + + fn distributed_commit<'py>( + &'py self, + py: Python<'py>, + message: String, + other_change_set_bytes: Vec>, + ) -> PyResult> { + let store = Arc::clone(&self.store); + pyo3_asyncio_0_21::tokio::get_runtime().block_on(async move { + let res = + do_distributed_commit(store, message, other_change_set_bytes).await?; + Ok(PyString::new_bound(py, res.as_str())) }) } @@ -406,17 +494,27 @@ impl PyIcechunkStore { Ok(has_uncommitted_changes) } - fn reset<'py>(&'py self, py: Python<'py>) -> PyResult> { + fn async_reset<'py>(&'py self, py: Python<'py>) -> PyResult> { let store = Arc::clone(&self.store); + pyo3_asyncio_0_21::tokio::future_into_py(py, async move { do_reset(store).await }) + } + fn reset<'py>(&'py self, py: Python<'py>) -> PyResult> { + let store = Arc::clone(&self.store); + pyo3_asyncio_0_21::tokio::get_runtime().block_on(async move { + do_reset(store).await?; + Ok(PyNone::get_bound(py).to_owned()) + }) + } + + fn async_new_branch<'py>( + &'py self, + py: Python<'py>, + branch_name: String, + ) -> PyResult> { + let store = Arc::clone(&self.store); pyo3_asyncio_0_21::tokio::future_into_py(py, async move { - store - .write() - .await - .reset() - .await - .map_err(PyIcechunkStoreError::StoreError)?; - Ok(()) + do_new_branch(store, branch_name).await }) } @@ -424,18 +522,26 @@ impl PyIcechunkStore { &'py self, py: Python<'py>, branch_name: String, + ) -> PyResult> { + let store = Arc::clone(&self.store); + pyo3_asyncio_0_21::tokio::get_runtime().block_on(async move { + let res = do_new_branch(store, branch_name).await?; + Ok(PyString::new_bound(py, res.as_str())) + }) + } + + fn async_tag<'py>( + &'py self, + py: Python<'py>, + tag: String, + snapshot_id: String, ) -> PyResult> { let store = Arc::clone(&self.store); // The commit mechanism is async and calls tokio::spawn so we need to use the // pyo3_asyncio_0_21::tokio helper to run the async function in the tokio runtime pyo3_asyncio_0_21::tokio::future_into_py(py, async move { - let mut writeable_store = store.write().await; - let (oid, _version) = writeable_store - .new_branch(&branch_name) - .await - .map_err(PyIcechunkStoreError::from)?; - Ok(String::from(&oid)) + do_tag(store, tag, snapshot_id).await }) } @@ -444,21 +550,35 @@ impl PyIcechunkStore { py: Python<'py>, tag: String, snapshot_id: String, - ) -> PyResult> { + ) -> PyResult> { let store = Arc::clone(&self.store); + pyo3_asyncio_0_21::tokio::get_runtime().block_on(async move { + do_tag(store, tag, snapshot_id).await?; + Ok(PyNone::get_bound(py).to_owned()) + }) + } - // The commit mechanism is async and calls tokio::spawn so we need to use the - // pyo3_asyncio_0_21::tokio helper to run the async function in the tokio runtime - pyo3_asyncio_0_21::tokio::future_into_py(py, async move { - let mut writeable_store = store.write().await; - let oid = ObjectId::try_from(snapshot_id.as_str()) - .map_err(|e| PyIcechunkStoreError::UnkownError(e.to_string()))?; - writeable_store.tag(&tag, &oid).await.map_err(PyIcechunkStoreError::from)?; - Ok(()) + fn ancestry<'py>( + &'py self, + py: Python<'py>, + ) -> PyIcechunkStoreResult> { + // TODO: this holds everything in memory + pyo3_asyncio_0_21::tokio::get_runtime().block_on(async move { + let store = self.store.read().await; + let list = store + .ancestry() + .await? + .map_ok(|parent| { + let parent = Into::::into(parent); + Python::with_gil(|py| parent.into_py(py)) + }) + .try_collect::>() + .await?; + Ok(PyList::new_bound(py, list)) }) } - fn ancestry(&self) -> PyIcechunkStoreResult { + fn async_ancestry(&self) -> PyIcechunkStoreResult { let list = pyo3_asyncio_0_21::tokio::get_runtime() .block_on(async move { let store = self.store.read().await; @@ -489,6 +609,15 @@ impl PyIcechunkStore { }) } + fn sync_clear<'py>(&'py self, py: Python<'py>) -> PyResult> { + let store = Arc::clone(&self.store); + + pyo3_asyncio_0_21::tokio::get_runtime().block_on(async move { + store.write().await.clear().await.map_err(PyIcechunkStoreError::from)?; + Ok(PyNone::get_bound(py).to_owned()) + }) + } + fn get<'py>( &'py self, py: Python<'py>, @@ -620,7 +749,7 @@ impl PyIcechunkStore { }) } - fn set_virtual_ref<'py>( + fn async_set_virtual_ref<'py>( &'py self, py: Python<'py>, key: String, @@ -629,19 +758,23 @@ impl PyIcechunkStore { length: ChunkLength, ) -> PyResult> { let store = Arc::clone(&self.store); - pyo3_asyncio_0_21::tokio::future_into_py(py, async move { - let virtual_ref = VirtualChunkRef { - location: VirtualChunkLocation::Absolute(location), - offset, - length, - }; - let mut store = store.write().await; - store - .set_virtual_ref(&key, virtual_ref) - .await - .map_err(PyIcechunkStoreError::from)?; - Ok(()) + do_set_virtual_ref(store, key, location, offset, length).await + }) + } + + fn set_virtual_ref<'py>( + &'py self, + py: Python<'py>, + key: String, + location: String, + offset: ChunkOffset, + length: ChunkLength, + ) -> PyResult> { + let store = Arc::clone(&self.store); + pyo3_asyncio_0_21::tokio::get_runtime().block_on(async move { + do_set_virtual_ref(store, key, location, offset, length).await?; + Ok(PyNone::get_bound(py).to_owned()) }) } @@ -750,6 +883,107 @@ impl PyIcechunkStore { } } +async fn do_commit(store: Arc>, message: String) -> PyResult { + let mut store = store.write().await; + let oid = store.commit(&message).await.map_err(PyIcechunkStoreError::from)?; + Ok(String::from(&oid)) +} + +async fn do_checkout_snapshot( + store: Arc>, + snapshot_id: String, +) -> PyResult<()> { + let snapshot_id = ObjectId::try_from(snapshot_id.as_str()).map_err(|e| { + PyIcechunkStoreError::UnkownError(format!( + "Error checking out snapshot {snapshot_id}: {e}" + )) + })?; + + let mut store = store.write().await; + store + .checkout(VersionInfo::SnapshotId(snapshot_id)) + .await + .map_err(PyIcechunkStoreError::StoreError)?; + Ok(()) +} + +async fn do_checkout_branch(store: Arc>, branch: String) -> PyResult<()> { + let mut store = store.write().await; + store + .checkout(VersionInfo::BranchTipRef(branch)) + .await + .map_err(PyIcechunkStoreError::StoreError)?; + Ok(()) +} + +async fn do_checkout_tag(store: Arc>, tag: String) -> PyResult<()> { + let mut store = store.write().await; + store + .checkout(VersionInfo::TagRef(tag)) + .await + .map_err(PyIcechunkStoreError::StoreError)?; + Ok(()) +} + +async fn do_distributed_commit( + store: Arc>, + message: String, + other_change_set_bytes: Vec>, +) -> PyResult { + let mut writeable_store = store.write().await; + let oid = writeable_store + .distributed_commit(&message, other_change_set_bytes) + .await + .map_err(PyIcechunkStoreError::from)?; + Ok(String::from(&oid)) +} + +async fn do_reset<'py>(store: Arc>) -> PyResult<()> { + store.write().await.reset().await.map_err(PyIcechunkStoreError::StoreError)?; + Ok(()) +} + +async fn do_new_branch<'py>( + store: Arc>, + branch_name: String, +) -> PyResult { + let mut writeable_store = store.write().await; + let (oid, _version) = writeable_store + .new_branch(&branch_name) + .await + .map_err(PyIcechunkStoreError::from)?; + Ok(String::from(&oid)) +} + +async fn do_tag<'py>( + store: Arc>, + tag: String, + snapshot_id: String, +) -> PyResult<()> { + let mut writeable_store = store.write().await; + let oid = ObjectId::try_from(snapshot_id.as_str()) + .map_err(|e| PyIcechunkStoreError::UnkownError(e.to_string()))?; + writeable_store.tag(&tag, &oid).await.map_err(PyIcechunkStoreError::from)?; + Ok(()) +} + +async fn do_set_virtual_ref( + store: Arc>, + key: String, + location: String, + offset: ChunkOffset, + length: ChunkLength, +) -> PyResult<()> { + let virtual_ref = VirtualChunkRef { + location: VirtualChunkLocation::Absolute(location), + offset, + length, + }; + let mut store = store.write().await; + store.set_virtual_ref(&key, virtual_ref).await.map_err(PyIcechunkStoreError::from)?; + Ok(()) +} + /// The icechunk Python module implemented in Rust. #[pymodule] fn _icechunk_python(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { @@ -762,8 +996,11 @@ fn _icechunk_python(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_function(wrap_pyfunction!(pyicechunk_store_exists, m)?)?; + m.add_function(wrap_pyfunction!(async_pyicechunk_store_exists, m)?)?; m.add_function(wrap_pyfunction!(pyicechunk_store_create, m)?)?; + m.add_function(wrap_pyfunction!(async_pyicechunk_store_create, m)?)?; m.add_function(wrap_pyfunction!(pyicechunk_store_open_existing, m)?)?; + m.add_function(wrap_pyfunction!(async_pyicechunk_store_open_existing, m)?)?; m.add_function(wrap_pyfunction!(pyicechunk_store_from_bytes, m)?)?; Ok(()) } diff --git a/icechunk-python/tests/conftest.py b/icechunk-python/tests/conftest.py index 02046273..94e352fe 100644 --- a/icechunk-python/tests/conftest.py +++ b/icechunk-python/tests/conftest.py @@ -4,18 +4,18 @@ from icechunk import IcechunkStore, StorageConfig -async def parse_store(store: Literal["local", "memory"], path: str) -> IcechunkStore: +def parse_store(store: Literal["local", "memory"], path: str) -> IcechunkStore: if store == "local": - return await IcechunkStore.create( + return IcechunkStore.create( storage=StorageConfig.filesystem(path), ) if store == "memory": - return await IcechunkStore.create( + return IcechunkStore.create( storage=StorageConfig.memory(path), ) @pytest.fixture(scope="function") -async def store(request: pytest.FixtureRequest, tmpdir: str) -> IcechunkStore: +def store(request: pytest.FixtureRequest, tmpdir: str) -> IcechunkStore: param = request.param - return await parse_store(param, str(tmpdir)) + return parse_store(param, str(tmpdir)) diff --git a/icechunk-python/tests/test_can_read_old.py b/icechunk-python/tests/test_can_read_old.py index 63856767..af1ca116 100644 --- a/icechunk-python/tests/test_can_read_old.py +++ b/icechunk-python/tests/test_can_read_old.py @@ -36,10 +36,10 @@ def write_chunks_to_minio(chunks: list[tuple[str, bytes]]): store.put(key, data) -async def mk_store(mode): +def mk_store(mode): """Create a store that can access virtual chunks in localhost MinIO""" store_path = "./tests/data/test-repo" - store = await ic.IcechunkStore.open( + store = ic.IcechunkStore.open( storage=ic.StorageConfig.filesystem(store_path), config=ic.StoreConfig( inline_chunk_threshold_bytes=10, @@ -69,7 +69,7 @@ async def write_a_test_repo(): """ print("Writing repository to ./tests/data/test-repo") - store = await mk_store("w") + store = mk_store("w") root = zarr.group(store=store) group1 = root.create_group( @@ -95,25 +95,25 @@ async def write_a_test_repo(): fill_value=8, attributes={"this": "is a nice array", "icechunk": 1, "size": 42.0}, ) - await store.commit("empty structure") + store.commit("empty structure") big_chunks[:] = 42.0 small_chunks[:] = 84 - await store.commit("fill data") + store.commit("fill data") - await store.set_virtual_ref( + store.set_virtual_ref( "group1/big_chunks/c/0/0", "s3://testbucket/path/to/python/chunk-1", offset=0, length=5 * 5 * 4, ) - await store.commit("set virtual chunk") + store.commit("set virtual chunk") - await store.new_branch("my-branch") + store.new_branch("my-branch") await store.delete("group1/small_chunks/c/4") - snap4 = await store.commit("delete a chunk") + snap4 = store.commit("delete a chunk") - await store.tag("it works!", snap4) + store.tag("it works!", snap4) group2 = root.create_group( "group2", attributes={"this": "is a nice group", "icechunk": 1, "size": 42.0} @@ -135,14 +135,14 @@ async def write_a_test_repo(): fill_value=float("nan"), attributes={"this": "is a nice array", "icechunk": 1, "size": 42.0}, ) - snap5 = await store.commit("some more structure") - await store.tag("it also works!", snap5) + snap5 = store.commit("some more structure") + store.tag("it also works!", snap5) store.close() async def test_icechunk_can_read_old_repo(): - store = await mk_store("r") + store = mk_store("r") expected_main_history = [ "set virtual chunk", @@ -150,23 +150,23 @@ async def test_icechunk_can_read_old_repo(): "empty structure", "Repository initialized", ] - assert [p.message async for p in store.ancestry()] == expected_main_history + assert [p.message for p in store.ancestry()] == expected_main_history - await store.checkout(branch="my-branch") + store.checkout(branch="my-branch") expected_branch_history = [ "some more structure", "delete a chunk", ] + expected_main_history - assert [p.message async for p in store.ancestry()] == expected_branch_history + assert [p.message for p in store.ancestry()] == expected_branch_history - await store.checkout(tag="it also works!") - assert [p.message async for p in store.ancestry()] == expected_branch_history + store.checkout(tag="it also works!") + assert [p.message for p in store.ancestry()] == expected_branch_history - await store.checkout(tag="it works!") - assert [p.message async for p in store.ancestry()] == expected_branch_history[1:] + store.checkout(tag="it works!") + assert [p.message for p in store.ancestry()] == expected_branch_history[1:] - store = await mk_store("r") - await store.checkout(branch="my-branch") + store = mk_store("r") + store.checkout(branch="my-branch") assert sorted([p async for p in store.list_dir("")]) == [ "group1", "group2", diff --git a/icechunk-python/tests/test_concurrency.py b/icechunk-python/tests/test_concurrency.py index 907d7f0c..341aa2a1 100644 --- a/icechunk-python/tests/test_concurrency.py +++ b/icechunk-python/tests/test_concurrency.py @@ -39,7 +39,7 @@ async def list_store(store, barrier): async def test_concurrency(): - store = await icechunk.IcechunkStore.open( + store = icechunk.IcechunkStore.open( mode="w", storage=icechunk.StorageConfig.memory(prefix="concurrency"), ) @@ -66,7 +66,7 @@ async def test_concurrency(): write_to_store(array, x, y, barrier), name=f"write {x},{y}" ) - _res = await store.commit("commit") + _res = store.commit("commit") array = group["array"] assert isinstance(array, zarr.Array) diff --git a/icechunk-python/tests/test_config.py b/icechunk-python/tests/test_config.py index b9d42f48..20fd4120 100644 --- a/icechunk-python/tests/test_config.py +++ b/icechunk-python/tests/test_config.py @@ -8,7 +8,7 @@ @pytest.fixture(scope="function") async def tmp_store(tmpdir): store_path = f"{tmpdir}" - store = await icechunk.IcechunkStore.open( + store = icechunk.IcechunkStore.open( storage=icechunk.StorageConfig.filesystem(store_path), mode="a", config=icechunk.StoreConfig(inline_chunk_threshold_bytes=5), diff --git a/icechunk-python/tests/test_distributed_writers.py b/icechunk-python/tests/test_distributed_writers.py index 7c2249f5..5642c97b 100644 --- a/icechunk-python/tests/test_distributed_writers.py +++ b/icechunk-python/tests/test_distributed_writers.py @@ -28,7 +28,7 @@ class Task: CHUNKS_PER_TASK = 2 -async def mk_store(mode: str, task: Task): +def mk_store(mode: str, task: Task): storage_config = icechunk.StorageConfig.s3_from_config( **task.storage_config, credentials=icechunk.S3Credentials( @@ -38,7 +38,7 @@ async def mk_store(mode: str, task: Task): ) store_config = icechunk.StoreConfig(**task.store_config) - store = await icechunk.IcechunkStore.open( + store = icechunk.IcechunkStore.open( storage=storage_config, mode="a", config=store_config, @@ -55,7 +55,7 @@ def generate_task_array(task: Task): async def execute_task(task: Task): - store = await mk_store("w", task) + store = mk_store("w", task) group = zarr.group(store=store, overwrite=False) array = cast(zarr.Array, group["array"]) @@ -120,7 +120,7 @@ async def test_distributed_writers(): ) for idx, area in enumerate(ranges) ] - store = await mk_store("r+", tasks[0]) + store = mk_store("r+", tasks[0]) group = zarr.group(store=store, overwrite=True) n = CHUNKS_PER_DIM * CHUNK_DIM_SIZE @@ -131,7 +131,7 @@ async def test_distributed_writers(): dtype="f8", fill_value=float("nan"), ) - _first_snap = await store.commit("array created") + _first_snap = store.commit("array created") map_result = client.map(run_task, tasks) change_sets_bytes = client.gather(map_result) @@ -139,11 +139,11 @@ async def test_distributed_writers(): # we can use the current store as the commit coordinator, because it doesn't have any pending changes, # all changes come from the tasks, Icechunk doesn't care about where the changes come from, the only # important thing is to not count changes twice - commit_res = await store.distributed_commit("distributed commit", change_sets_bytes) + commit_res = store.distributed_commit("distributed commit", change_sets_bytes) assert commit_res # Lets open a new store to verify the results - store = await mk_store("r", tasks[0]) + store = mk_store("r", tasks[0]) all_keys = [key async for key in store.list_prefix("/")] assert ( len(all_keys) == 1 + 1 + CHUNKS_PER_DIM * CHUNKS_PER_DIM diff --git a/icechunk-python/tests/test_pickle.py b/icechunk-python/tests/test_pickle.py index 50b0b529..03ca2fd7 100644 --- a/icechunk-python/tests/test_pickle.py +++ b/icechunk-python/tests/test_pickle.py @@ -9,7 +9,7 @@ @pytest.fixture(scope="function") async def tmp_store(tmpdir): store_path = f"{tmpdir}" - store = await icechunk.IcechunkStore.open( + store = icechunk.IcechunkStore.open( storage=icechunk.StorageConfig.filesystem(store_path), mode="w", ) @@ -23,7 +23,7 @@ async def test_pickle(tmp_store): root = zarr.group(store=tmp_store) array = root.ones(name="ones", shape=(10, 10), chunks=(5, 5), dtype="float32") array[:] = 20 - await tmp_store.commit("firsttt") + tmp_store.commit("firsttt") pickled = pickle.dumps(tmp_store) @@ -44,19 +44,19 @@ async def test_store_equality(tmpdir, tmp_store): local_store = await LocalStore.open(f"{tmpdir}/zarr", mode="w") assert tmp_store != local_store - store2 = await icechunk.IcechunkStore.open( + store2 = icechunk.IcechunkStore.open( storage=icechunk.StorageConfig.memory(prefix="test"), mode="w", ) assert tmp_store != store2 - store3 = await icechunk.IcechunkStore.open( + store3 = icechunk.IcechunkStore.open( storage=icechunk.StorageConfig.filesystem(f"{tmpdir}/test"), mode="a", ) assert tmp_store != store3 - store4 = await icechunk.IcechunkStore.open( + store4 = icechunk.IcechunkStore.open( storage=icechunk.StorageConfig.filesystem(f"{tmpdir}/test"), mode="a", ) diff --git a/icechunk-python/tests/test_timetravel.py b/icechunk-python/tests/test_timetravel.py index d992e508..ac5118c8 100644 --- a/icechunk-python/tests/test_timetravel.py +++ b/icechunk-python/tests/test_timetravel.py @@ -2,8 +2,8 @@ import zarr -async def test_timetravel(): - store = await icechunk.IcechunkStore.create( +def test_timetravel(): + store = icechunk.IcechunkStore.create( storage=icechunk.StorageConfig.memory("test"), config=icechunk.StoreConfig(inline_chunk_threshold_bytes=1), ) @@ -16,40 +16,40 @@ async def test_timetravel(): air_temp[:, :] = 42 assert air_temp[200, 6] == 42 - snapshot_id = await store.commit("commit 1") + snapshot_id = store.commit("commit 1") air_temp[:, :] = 54 assert air_temp[200, 6] == 54 - new_snapshot_id = await store.commit("commit 2") + new_snapshot_id = store.commit("commit 2") - await store.checkout(snapshot_id=snapshot_id) + store.checkout(snapshot_id=snapshot_id) assert air_temp[200, 6] == 42 - await store.checkout(snapshot_id=new_snapshot_id) + store.checkout(snapshot_id=new_snapshot_id) assert air_temp[200, 6] == 54 - await store.checkout(branch="main") + store.checkout(branch="main") air_temp[:, :] = 76 assert store.has_uncommitted_changes assert store.branch == "main" assert store.snapshot_id == new_snapshot_id - await store.reset() + store.reset() assert not store.has_uncommitted_changes assert air_temp[200, 6] == 54 - await store.new_branch("feature") + store.new_branch("feature") assert store.branch == "feature" air_temp[:, :] = 90 - feature_snapshot_id = await store.commit("commit 3") - await store.tag("v1.0", feature_snapshot_id) + feature_snapshot_id = store.commit("commit 3") + store.tag("v1.0", feature_snapshot_id) - await store.checkout(tag="v1.0") + store.checkout(tag="v1.0") assert store.branch is None assert air_temp[200, 6] == 90 - parents = [p async for p in store.ancestry()] + parents = [p for p in store.ancestry()] assert [snap.message for snap in parents] == [ "commit 3", "commit 2", diff --git a/icechunk-python/tests/test_virtual_ref.py b/icechunk-python/tests/test_virtual_ref.py index 0f7a8254..1b2a122f 100644 --- a/icechunk-python/tests/test_virtual_ref.py +++ b/icechunk-python/tests/test_virtual_ref.py @@ -41,7 +41,7 @@ async def test_write_minio_virtual_refs(): ) # Open the store - store = await IcechunkStore.open( + store = IcechunkStore.open( storage=StorageConfig.memory("virtual"), mode="w", config=StoreConfig( @@ -59,14 +59,14 @@ async def test_write_minio_virtual_refs(): array = zarr.Array.create(store, shape=(1, 1, 3), chunk_shape=(1, 1, 1), dtype="i4") - await store.set_virtual_ref( + store.set_virtual_ref( "c/0/0/0", "s3://testbucket/path/to/python/chunk-1", offset=0, length=4 ) - await store.set_virtual_ref( + store.set_virtual_ref( "c/0/0/1", "s3://testbucket/path/to/python/chunk-2", offset=1, length=4 ) # we write a ref that simulates a lost chunk - await store.set_virtual_ref( + await store.async_set_virtual_ref( "c/0/0/2", "s3://testbucket/path/to/python/non-existing", offset=1, length=4 ) @@ -91,12 +91,12 @@ async def test_write_minio_virtual_refs(): # TODO: we should include the key and other info in the exception await store.get("c/0/0/2", prototype=buffer_prototype) - _snapshot_id = await store.commit("Add virtual refs") + _snapshot_id = store.commit("Add virtual refs") async def test_from_s3_public_virtual_refs(tmpdir): # Open the store, - store = await IcechunkStore.open( + store = IcechunkStore.open( storage=StorageConfig.filesystem(f'{tmpdir}/virtual'), mode="w", config=StoreConfig( @@ -108,7 +108,7 @@ async def test_from_s3_public_virtual_refs(tmpdir): name="depth", shape=((22, )), chunk_shape=((22,)), dtype="float64" ) - await store.set_virtual_ref( + store.set_virtual_ref( "depth/c/0", "s3://noaa-nos-ofs-pds/dbofs/netcdf/202410/dbofs.t00z.20241012.regulargrid.f030.nc", offset=42499, diff --git a/icechunk-python/tests/test_zarr/test_api.py b/icechunk-python/tests/test_zarr/test_api.py index 3d629f46..1ffee7d8 100644 --- a/icechunk-python/tests/test_zarr/test_api.py +++ b/icechunk-python/tests/test_zarr/test_api.py @@ -22,7 +22,7 @@ @pytest.fixture(scope="function") async def memory_store() -> IcechunkStore: - return await parse_store("memory", "") + return parse_store("memory", "") def test_create_array(memory_store: Store) -> None: @@ -69,7 +69,7 @@ async def test_open_array(memory_store: IcechunkStore) -> None: # _store_dict wont currently work with IcechunkStore # TODO: Should it? - ro_store = await store_cls.open(store_dict=store._store_dict, mode="r") + ro_store = store_cls.open(store_dict=store._store_dict, mode="r") z = open(store=ro_store) assert isinstance(z, Array) assert z.shape == (200,) @@ -99,7 +99,7 @@ async def test_open_group(memory_store: IcechunkStore) -> None: # _store_dict wont currently work with IcechunkStore # TODO: Should it? pytest.xfail("IcechunkStore does not support _store_dict") - ro_store = await store_cls.open(store_dict=store._store_dict, mode="r") + ro_store = store_cls.open(store_dict=store._store_dict, mode="r") g = open_group(store=ro_store) assert isinstance(g, Group) # assert g.read_only diff --git a/icechunk-python/tests/test_zarr/test_group.py b/icechunk-python/tests/test_zarr/test_group.py index 966efd16..6ea932a3 100644 --- a/icechunk-python/tests/test_zarr/test_group.py +++ b/icechunk-python/tests/test_zarr/test_group.py @@ -23,8 +23,8 @@ @pytest.fixture(params=["memory"]) -async def store(request: pytest.FixtureRequest, tmpdir: LEGACY_PATH) -> IcechunkStore: - result = await parse_store(request.param, str(tmpdir)) +def store(request: pytest.FixtureRequest, tmpdir: LEGACY_PATH) -> IcechunkStore: + result = parse_store(request.param, str(tmpdir)) if not isinstance(result, IcechunkStore): raise TypeError( "Wrong store class returned by test fixture! got " + result + " instead" diff --git a/icechunk-python/tests/test_zarr/test_store/test_core.py b/icechunk-python/tests/test_zarr/test_store/test_core.py index c0ba4501..dd8b0934 100644 --- a/icechunk-python/tests/test_zarr/test_store/test_core.py +++ b/icechunk-python/tests/test_zarr/test_store/test_core.py @@ -6,6 +6,6 @@ async def test_make_store_path() -> None: # Memory store - store = await parse_store("memory", path="") + store = parse_store("memory", path="") store_path = await make_store_path(store) assert isinstance(store_path.store, IcechunkStore) diff --git a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py index f51b618f..c7a62017 100644 --- a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py +++ b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py @@ -56,7 +56,7 @@ def store_kwargs(self) -> dict[str, Any]: @pytest.fixture(scope="function") async def store(self, store_kwargs: dict[str, Any]) -> IcechunkStore: - return await IcechunkStore.open(**store_kwargs) + return IcechunkStore.open(**store_kwargs) @pytest.mark.xfail(reason="Not implemented") def test_store_repr(self, store: IcechunkStore) -> None: @@ -71,12 +71,12 @@ def test_store_mode(self, store, store_kwargs: dict[str, Any]) -> None: assert not store.mode.readonly @pytest.mark.parametrize("mode", ["r", "r+", "a", "w", "w-"]) - async def test_store_open_mode( + def test_store_open_mode( self, store_kwargs: dict[str, Any], mode: AccessModeLiteral ) -> None: store_kwargs["mode"] = mode try: - store = await self.store_cls.open(**store_kwargs) + store = self.store_cls.open(**store_kwargs) assert store._is_open assert store.mode == AccessMode.from_literal(mode) except Exception: @@ -85,7 +85,7 @@ async def test_store_open_mode( async def test_not_writable_store_raises(self, store_kwargs: dict[str, Any]) -> None: create_kwargs = {**store_kwargs, "mode": "r"} with pytest.raises(ValueError): - _store = await self.store_cls.open(**create_kwargs) + _store = self.store_cls.open(**create_kwargs) # TODO # set From 83ca7776b81563a7c9be81e37a0b0c24856b7b63 Mon Sep 17 00:00:00 2001 From: Joseph Hamman Date: Mon, 14 Oct 2024 11:45:37 -0700 Subject: [PATCH 098/167] await no mo --- docs/docs/icechunk-python/xarray.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs/icechunk-python/xarray.md b/docs/docs/icechunk-python/xarray.md index 97b5c68c..21e86e26 100644 --- a/docs/docs/icechunk-python/xarray.md +++ b/docs/docs/icechunk-python/xarray.md @@ -30,7 +30,7 @@ storage_config = icechunk.StorageConfig.s3_from_env( bucket="icechunk-test", prefix="xarray-demo" ) -store = await icechunk.IcechunkStore.create(storage_config) +store = icechunk.IcechunkStore.create(storage_config) ``` ## Open tutorial dataset from Xarray @@ -56,7 +56,7 @@ ds1.to_zarr(store, zarr_format=3, consolidated=False) After writing, we commit the changes: ```python -await store.commit("add RASM data to store") +store.commit("add RASM data to store") # output: 'ME4VKFPA5QAY0B2YSG8G' ``` @@ -72,7 +72,7 @@ ds2.to_zarr(store, append_dim='time') And then we'll commit the changes: ```python -await store.commit("append more data") +store.commit("append more data") # output: 'WW4V8V34QCZ2NXTD5DXG' ``` @@ -108,7 +108,7 @@ xr.open_zarr(store, zarr_format=3, consolidated=False) We can also read data from previous snapshots by checking out prior versions: ```python -await store.checkout('ME4VKFPA5QAY0B2YSG8G') +store.checkout('ME4VKFPA5QAY0B2YSG8G') xr.open_zarr(store, zarr_format=3, consolidated=False) # Size: 9MB From 5a031f30a487fbd755d06720dc9cfdc6a98e42e8 Mon Sep 17 00:00:00 2001 From: Orestis Herodotou Date: Mon, 14 Oct 2024 11:56:51 -0700 Subject: [PATCH 099/167] update docs structure --- docs/docs/getting-started.md | 135 ---------------------------- docs/docs/index.md | 140 +---------------------------- docs/docs/overview.md | 138 ++++++++++++++++++++++++++++ docs/docs/stylesheets/global.css | 7 ++ docs/docs/stylesheets/homepage.css | 4 + docs/docs/stylesheets/theme.css | 2 +- docs/mkdocs.yml | 4 +- docs/overrides/home.html | 2 - 8 files changed, 154 insertions(+), 278 deletions(-) delete mode 100644 docs/docs/getting-started.md create mode 100644 docs/docs/overview.md diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md deleted file mode 100644 index 28e0fbea..00000000 --- a/docs/docs/getting-started.md +++ /dev/null @@ -1,135 +0,0 @@ -# Icechunk - -!!! info "Welcome to Icechunk!" - Icechunk is a transactional storage engine for Zarr designed for use on cloud object storage. - -Let's break down what that means: - -- **[Zarr](https://zarr.dev/)** is an open source specification for the storage of multidimensional array (a.k.a. tensor) data. - Zarr defines the metadata for describing arrays (shape, dtype, etc.) and the way these arrays are chunked, compressed, and converted to raw bytes for storage. Zarr can store its data in any key-value store. -- **Storage engine** - Icechunk exposes a key-value interface to Zarr and manages all of the actual I/O for getting, setting, and updating both metadata and chunk data in cloud object storage. - Zarr libraries don't have to know exactly how icechunk works under the hood in order to use it. -- **Transactional** - The key improvement that Icechunk brings on top of regular Zarr is to provide consistent serializable isolation between transactions. - This means that Icechunk data are safe to read and write in parallel from multiple uncoordinated processes. - This allows Zarr to be used more like a database. - -## Goals of Icechunk - -The core entity in Icechunk is a **store**. -A store is defined as a Zarr hierarchy containing one or more Arrays and Groups. -The most common scenario is for an Icechunk store to contain a single Zarr group with multiple arrays, each corresponding to different physical variables but sharing common spatiotemporal coordinates. -However, formally a store can be any valid Zarr hierarchy, from a single Array to a deeply nested structure of Groups and Arrays. -Users of Icechunk should aim to scope their stores only to related arrays and groups that require consistent transactional updates. - -Icechunk aspires to support the following core requirements for stores: - -1. **Object storage** - the format is designed around the consistency features and performance characteristics available in modern cloud object storage. No external database or catalog is required to maintain a store. -1. **Serializable isolation** - Reads are isolated from concurrent writes and always use a committed snapshot of a store. Writes are committed atomically and are never partially visible. Readers will not acquire locks. -1. **Time travel** - Previous snapshots of a store remain accessible after new ones have been written. -1. **Data Version Control** - Stores support both _tags_ (immutable references to snapshots) and _branches_ (mutable references to snapshots). -1. **Chunk sharding and references** - Chunk storage is decoupled from specific file names. Multiple chunks can be packed into a single object (sharding). Zarr-compatible chunks within other file formats (e.g. HDF5, NetCDF) can be referenced. -1. **Schema Evolution** - Arrays and Groups can be added, renamed, and removed from the hierarchy with minimal overhead. - -## The Project - -This Icechunk project consists of three main parts: - -1. The [Icechunk specification](./spec.md). -1. A Rust implementation -1. A Python wrapper which exposes a Zarr store interface - -All of this is open source, licensed under the Apache 2.0 license. - -The Rust implementation is a solid foundation for creating bindings in any language. -We encourage adopters to collaborate on the Rust implementation, rather than reimplementing Icechunk from scratch in other languages. - -We encourage collaborators from the broader community to contribute to Icechunk. -Governance of the project will be managed by Earthmover PBC. - -## How Can I Use It? - -We recommend using [Icechunk from Python](./icechunk-python/index.md), together with the Zarr-Python library. - -!!! warning "Icechunk is a very new project." - It is not recommended for production use at this time. - These instructions are aimed at Icechunk developers and curious early adopters. - -## Key Concepts: Snapshots, Branches, and Tags - -Every update to an Icechunk store creates a new **snapshot** with a unique ID. -Icechunk users must organize their updates into groups of related operations called **transactions**. -For example, appending a new time slice to mutliple arrays should be done as single transactions, comprising the following steps -1. Update the array metadata to resize the array to accommodate the new elements. -2. Write new chunks for each array in the group. - -While the transaction is in progress, none of these changes will be visible to other users of the store. -Once the transaction is committed, a new snapshot is generated. -Readers can only see and use committed snapshots. - -Additionally, snapshots occur in a specific linear (i.e. serializable) order within **branch**. -A branch is a mutable reference to a snapshot--a pointer that maps the branch name to a snapshot ID. -The default branch is `main`. -Every commit to the main branch updates this reference. -Icechunk's design protects against the race condition in which two uncoordinated sessions attempt to update the branch at the same time; only one can succeed. - -Finally, Icechunk defines **tags**--_immutable_ references to snapshot. -Tags are appropriate for publishing specific releases of a repository or for any application which requires a persistent, immutable identifier to the store state. - -## How Does It Work? - -!!! note - For more detailed explanation, have a look at the [Icechunk spec](./spec.md) - -Zarr itself works by storing both metadata and chunk data into a abstract store according to a specified system of "keys". -For example, a 2D Zarr array called myarray, within a group called mygroup, would generate the following keys: - -``` -mygroup/zarr.json -mygroup/myarray/zarr.json -mygroup/myarray/c/0/0 -mygroup/myarray/c/0/1 -``` - -In standard regular Zarr stores, these key map directly to filenames in a filesystem or object keys in an object storage system. -When writing data, a Zarr implementation will create these keys and populate them with data. When modifying existing arrays or groups, a Zarr implementation will potentially overwrite existing keys with new data. - -This is generally not a problem, as long there is only one person or process coordinating access to the data. -However, when multiple uncoordinated readers and writers attempt to access the same Zarr data at the same time, [various consistency problems](https://docs.earthmover.io/concepts/version-control-system#consistency-problems-with-zarr) problems emerge. -These consistency problems can occur in both file storage and object storage; they are particularly severe in a cloud setting where Zarr is being used as an active store for data that are frequently changed while also being read. - -With Icechunk, we keep the same core Zarr data model, but add a layer of indirection between the Zarr keys and the on-disk storage. -The Icechunk library translates between the Zarr keys and the actual on-disk data given the particular context of the user's state. -Icechunk defines a series of interconnected metadata and data files that together enable efficient isolated reading and writing of metadata and chunks. -Once written, these files are immutable. -Icechunk keeps track of every single chunk explicitly in a "chunk manifest". - -```mermaid -flowchart TD - zarr-python[Zarr Library] <-- key / value--> icechunk[Icechunk Library] - icechunk <-- data / metadata files --> storage[(Object Storage)] -``` - -## FAQ - -1. _Why not just use Iceberg directly?_ - - Iceberg and all other "table formats" (Delta, Hudi, LanceDB, etc.) are based on tabular data model. - This data model cannot accommodate large, multidimensional arrays (tensors) in a general, scalable way. - -1. Is Icechunk part of Zarr? - - Formally, no. - Icechunk is a separate specification from Zarr. - However, it is designed to interoperate closely with Zarr. - In the future, we may propose a more formal integration between the Zarr spec and Icechunk spec if helpful. - For now, keeping them separate allows us to evolve Icechunk quickly while maintaining the stability and backwards compatibility of the Zarr data model. - -## Inspiration - -Icechunk's was inspired by several existing projects and formats, most notably - -- [FSSpec Reference Filesystem](https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.implementations.reference.ReferenceFileSystem) -- [Apache Iceberg](https://iceberg.apache.org/spec/) -- [LanceDB](https://lancedb.github.io/lance/format.html) -- [TileDB](https://docs.tiledb.com/main/background/key-concepts-and-data-format) -- [OCDBT](https://google.github.io/tensorstore/kvstore/ocdbt/index.html) \ No newline at end of file diff --git a/docs/docs/index.md b/docs/docs/index.md index d6b4ed63..673a8079 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -1,142 +1,6 @@ --- +template: home.html title: Icechunk - Open-source, cloud-native transactional tensor storage engine --- -# Icechunk - -Icechunk is an open-source (Apache 2.0), transactional storage engine for tensor / ND-array data designed for use on cloud object storage. -Icechunk works together with **[Zarr](https://zarr.dev/)**, augmenting the Zarr core data model with features -that enhance performance, collaboration, and safety in a cloud-computing context. - -## Docs Organization - -This is the Icechunk documentation. It's organized into the following parts. - -- This page: a general overview of the project's goals and components. -- [Frequently Asked Questions](./faq.md) -- Documentation for [Icechunk Python](./icechunk-python), the main user-facing - library -- Documentation for the [Icechunk Rust Crate](https://docs.rs/icechunk/latest/icechunk/) -- The [Icechunk Spec](./spec.md) - -## Icechunk Overview - -Let's break down what "transactional storage engine for Zarr" actually means: - -- **[Zarr](https://zarr.dev/)** is an open source specification for the storage of multidimensional array (a.k.a. tensor) data. - Zarr defines the metadata for describing arrays (shape, dtype, etc.) and the way these arrays are chunked, compressed, and converted to raw bytes for storage. Zarr can store its data in any key-value store. - There are many different implementations of Zarr in different languages. _Right now, Icechunk only supports - [Zarr Python](https://zarr.readthedocs.io/en/stable/)._ - If you're interested in implementing Icehcunk support, please [open an issue](https://github.com/earth-mover/icechunk/issues) so we can help you. -- **Storage engine** - Icechunk exposes a key-value interface to Zarr and manages all of the actual I/O for getting, setting, and updating both metadata and chunk data in cloud object storage. - Zarr libraries don't have to know exactly how icechunk works under the hood in order to use it. -- **Transactional** - The key improvement that Icechunk brings on top of regular Zarr is to provide consistent serializable isolation between transactions. - This means that Icechunk data are safe to read and write in parallel from multiple uncoordinated processes. - This allows Zarr to be used more like a database. - -The core entity in Icechunk is a repository or **repo**. -A repo is defined as a Zarr hierarchy containing one or more Arrays and Groups, and a repo functions as -self-contained _Zarr Store_. -The most common scenario is for an Icechunk repo to contain a single Zarr group with multiple arrays, each corresponding to different physical variables but sharing common spatiotemporal coordinates. -However, formally a repo can be any valid Zarr hierarchy, from a single Array to a deeply nested structure of Groups and Arrays. -Users of Icechunk should aim to scope their repos only to related arrays and groups that require consistent transactional updates. - -Icechunk supports the following core requirements: - -1. **Object storage** - the format is designed around the consistency features and performance characteristics available in modern cloud object storage. No external database or catalog is required to maintain a repo. -(It also works with file storage.) -1. **Serializable isolation** - Reads are isolated from concurrent writes and always use a committed snapshot of a repo. Writes are committed atomically and are never partially visible. No locks are required for reading. -1. **Time travel** - Previous snapshots of a repo remain accessible after new ones have been written. -1. **Data version control** - Repos support both _tags_ (immutable references to snapshots) and _branches_ (mutable references to snapshots). -1. **Chunk shardings** - Chunk storage is decoupled from specific file names. Multiple chunks can be packed into a single object (sharding). -1. **Chunk references** - Zarr-compatible chunks within other file formats (e.g. HDF5, NetCDF) can be referenced. -1. **Schema evolution** - Arrays and Groups can be added, renamed, and removed from the hierarchy with minimal overhead. - -## Key Concepts - -### Groups, Arrays, and Chunks - -Icechunk is designed around the Zarr data model, widely used in scientific computing, data science, and AI / ML. -(The Zarr high-level data model is effectively the same as HDF5.) -The core data structure in this data model is the **array**. -Arrays have two fundamental properties: - -- **shape** - a tuple of integers which specify the dimensions of each axis of the array. A 10 x 10 square array would have shape (10, 10) -- **data type** - a specification of what type of data is found in each element, e.g. integer, float, etc. Different data types have different precision (e.g. 16-bit integer, 64-bit float, etc.) - -In Zarr / Icechunk, arrays are split into **chunks**, -A chunk is the minimum unit of data that must be read / written from storage, and thus choices about chunking have strong implications for performance. -Zarr leaves this completely up to the user. -Chunk shape should be chosen based on the anticipated data access patten for each array -An Icechunk array is not bounded by an individual file and is effectively unlimited in size. - -For further organization of data, Icechunk supports **groups** withing a single repo. -Group are like folders which contain multiple arrays and or other groups. -Groups enable data to be organized into hierarchical trees. -A common usage pattern is to store multiple arrays in a group representing a NetCDF-style dataset. - -Arbitrary JSON-style key-value metadata can be attached to both arrays and groups. - -### Snapshots - -Every update to an Icechunk store creates a new **snapshot** with a unique ID. -Icechunk users must organize their updates into groups of related operations called **transactions**. -For example, appending a new time slice to mutliple arrays should be done as a single transaction, comprising the following steps -1. Update the array metadata to resize the array to accommodate the new elements. -2. Write new chunks for each array in the group. - -While the transaction is in progress, none of these changes will be visible to other users of the store. -Once the transaction is committed, a new snapshot is generated. -Readers can only see and use committed snapshots. - -### Branches and Tags - -Additionally, snapshots occur in a specific linear (i.e. serializable) order within **branch**. -A branch is a mutable reference to a snapshot--a pointer that maps the branch name to a snapshot ID. -The default branch is `main`. -Every commit to the main branch updates this reference. -Icechunk's design protects against the race condition in which two uncoordinated sessions attempt to update the branch at the same time; only one can succeed. - -Icechunk also defines **tags**--_immutable_ references to snapshot. -Tags are appropriate for publishing specific releases of a repository or for any application which requires a persistent, immutable identifier to the store state. - -### Chunk References - -Chunk references are "pointers" to chunks that exist in other files--HDF5, NetCDF, GRIB, etc. -Icechunk can store these references alongside native Zarr chunks as "virtual datasets". -You can then can update these virtual datasets incrementally (overwrite chunks, change metadata, etc.) without touching the underling files. - -## How Does It Work? - -!!! note - For more detailed explanation, have a look at the [Icechunk spec](./spec.md) - -Zarr itself works by storing both metadata and chunk data into a abstract store according to a specified system of "keys". -For example, a 2D Zarr array called `myarray`, within a group called `mygroup`, would generate the following keys: - -``` -mygroup/zarr.json -mygroup/myarray/zarr.json -mygroup/myarray/c/0/0 -mygroup/myarray/c/0/1 -``` - -In standard regular Zarr stores, these key map directly to filenames in a filesystem or object keys in an object storage system. -When writing data, a Zarr implementation will create these keys and populate them with data. When modifying existing arrays or groups, a Zarr implementation will potentially overwrite existing keys with new data. - -This is generally not a problem, as long there is only one person or process coordinating access to the data. -However, when multiple uncoordinated readers and writers attempt to access the same Zarr data at the same time, [various consistency problems](https://docs.earthmover.io/concepts/version-control-system#consistency-problems-with-zarr) problems emerge. -These consistency problems can occur in both file storage and object storage; they are particularly severe in a cloud setting where Zarr is being used as an active store for data that are frequently changed while also being read. - -With Icechunk, we keep the same core Zarr data model, but add a layer of indirection between the Zarr keys and the on-disk storage. -The Icechunk library translates between the Zarr keys and the actual on-disk data given the particular context of the user's state. -Icechunk defines a series of interconnected metadata and data files that together enable efficient isolated reading and writing of metadata and chunks. -Once written, these files are immutable. -Icechunk keeps track of every single chunk explicitly in a "chunk manifest". - -```mermaid -flowchart TD - zarr-python[Zarr Library] <-- key / value--> icechunk[Icechunk Library] - icechunk <-- data / metadata files --> storage[(Object Storage)] -``` - +Bottom of homepage content here \ No newline at end of file diff --git a/docs/docs/overview.md b/docs/docs/overview.md new file mode 100644 index 00000000..924f1878 --- /dev/null +++ b/docs/docs/overview.md @@ -0,0 +1,138 @@ +# Icechunk + +Icechunk is an open-source (Apache 2.0), transactional storage engine for tensor / ND-array data designed for use on cloud object storage. +Icechunk works together with **[Zarr](https://zarr.dev/)**, augmenting the Zarr core data model with features +that enhance performance, collaboration, and safety in a cloud-computing context. + +## Docs Organization + +This is the Icechunk documentation. It's organized into the following parts. + +- This page: a general overview of the project's goals and components. +- [Frequently Asked Questions](./faq.md) +- Documentation for [Icechunk Python](./icechunk-python), the main user-facing + library +- Documentation for the [Icechunk Rust Crate](https://docs.rs/icechunk/latest/icechunk/) +- The [Icechunk Spec](./spec.md) + +## Icechunk Overview + +Let's break down what "transactional storage engine for Zarr" actually means: + +- **[Zarr](https://zarr.dev/)** is an open source specification for the storage of multidimensional array (a.k.a. tensor) data. + Zarr defines the metadata for describing arrays (shape, dtype, etc.) and the way these arrays are chunked, compressed, and converted to raw bytes for storage. Zarr can store its data in any key-value store. + There are many different implementations of Zarr in different languages. _Right now, Icechunk only supports + [Zarr Python](https://zarr.readthedocs.io/en/stable/)._ + If you're interested in implementing Icehcunk support, please [open an issue](https://github.com/earth-mover/icechunk/issues) so we can help you. +- **Storage engine** - Icechunk exposes a key-value interface to Zarr and manages all of the actual I/O for getting, setting, and updating both metadata and chunk data in cloud object storage. + Zarr libraries don't have to know exactly how icechunk works under the hood in order to use it. +- **Transactional** - The key improvement that Icechunk brings on top of regular Zarr is to provide consistent serializable isolation between transactions. + This means that Icechunk data are safe to read and write in parallel from multiple uncoordinated processes. + This allows Zarr to be used more like a database. + +The core entity in Icechunk is a repository or **repo**. +A repo is defined as a Zarr hierarchy containing one or more Arrays and Groups, and a repo functions as +self-contained _Zarr Store_. +The most common scenario is for an Icechunk repo to contain a single Zarr group with multiple arrays, each corresponding to different physical variables but sharing common spatiotemporal coordinates. +However, formally a repo can be any valid Zarr hierarchy, from a single Array to a deeply nested structure of Groups and Arrays. +Users of Icechunk should aim to scope their repos only to related arrays and groups that require consistent transactional updates. + +Icechunk supports the following core requirements: + +1. **Object storage** - the format is designed around the consistency features and performance characteristics available in modern cloud object storage. No external database or catalog is required to maintain a repo. +(It also works with file storage.) +1. **Serializable isolation** - Reads are isolated from concurrent writes and always use a committed snapshot of a repo. Writes are committed atomically and are never partially visible. No locks are required for reading. +1. **Time travel** - Previous snapshots of a repo remain accessible after new ones have been written. +1. **Data version control** - Repos support both _tags_ (immutable references to snapshots) and _branches_ (mutable references to snapshots). +1. **Chunk shardings** - Chunk storage is decoupled from specific file names. Multiple chunks can be packed into a single object (sharding). +1. **Chunk references** - Zarr-compatible chunks within other file formats (e.g. HDF5, NetCDF) can be referenced. +1. **Schema evolution** - Arrays and Groups can be added, renamed, and removed from the hierarchy with minimal overhead. + +## Key Concepts + +### Groups, Arrays, and Chunks + +Icechunk is designed around the Zarr data model, widely used in scientific computing, data science, and AI / ML. +(The Zarr high-level data model is effectively the same as HDF5.) +The core data structure in this data model is the **array**. +Arrays have two fundamental properties: + +- **shape** - a tuple of integers which specify the dimensions of each axis of the array. A 10 x 10 square array would have shape (10, 10) +- **data type** - a specification of what type of data is found in each element, e.g. integer, float, etc. Different data types have different precision (e.g. 16-bit integer, 64-bit float, etc.) + +In Zarr / Icechunk, arrays are split into **chunks**, +A chunk is the minimum unit of data that must be read / written from storage, and thus choices about chunking have strong implications for performance. +Zarr leaves this completely up to the user. +Chunk shape should be chosen based on the anticipated data access patten for each array +An Icechunk array is not bounded by an individual file and is effectively unlimited in size. + +For further organization of data, Icechunk supports **groups** withing a single repo. +Group are like folders which contain multiple arrays and or other groups. +Groups enable data to be organized into hierarchical trees. +A common usage pattern is to store multiple arrays in a group representing a NetCDF-style dataset. + +Arbitrary JSON-style key-value metadata can be attached to both arrays and groups. + +### Snapshots + +Every update to an Icechunk store creates a new **snapshot** with a unique ID. +Icechunk users must organize their updates into groups of related operations called **transactions**. +For example, appending a new time slice to mutliple arrays should be done as a single transaction, comprising the following steps +1. Update the array metadata to resize the array to accommodate the new elements. +2. Write new chunks for each array in the group. + +While the transaction is in progress, none of these changes will be visible to other users of the store. +Once the transaction is committed, a new snapshot is generated. +Readers can only see and use committed snapshots. + +### Branches and Tags + +Additionally, snapshots occur in a specific linear (i.e. serializable) order within **branch**. +A branch is a mutable reference to a snapshot--a pointer that maps the branch name to a snapshot ID. +The default branch is `main`. +Every commit to the main branch updates this reference. +Icechunk's design protects against the race condition in which two uncoordinated sessions attempt to update the branch at the same time; only one can succeed. + +Icechunk also defines **tags**--_immutable_ references to snapshot. +Tags are appropriate for publishing specific releases of a repository or for any application which requires a persistent, immutable identifier to the store state. + +### Chunk References + +Chunk references are "pointers" to chunks that exist in other files--HDF5, NetCDF, GRIB, etc. +Icechunk can store these references alongside native Zarr chunks as "virtual datasets". +You can then can update these virtual datasets incrementally (overwrite chunks, change metadata, etc.) without touching the underling files. + +## How Does It Work? + +!!! note + For more detailed explanation, have a look at the [Icechunk spec](./spec.md) + +Zarr itself works by storing both metadata and chunk data into a abstract store according to a specified system of "keys". +For example, a 2D Zarr array called `myarray`, within a group called `mygroup`, would generate the following keys: + +``` +mygroup/zarr.json +mygroup/myarray/zarr.json +mygroup/myarray/c/0/0 +mygroup/myarray/c/0/1 +``` + +In standard regular Zarr stores, these key map directly to filenames in a filesystem or object keys in an object storage system. +When writing data, a Zarr implementation will create these keys and populate them with data. When modifying existing arrays or groups, a Zarr implementation will potentially overwrite existing keys with new data. + +This is generally not a problem, as long there is only one person or process coordinating access to the data. +However, when multiple uncoordinated readers and writers attempt to access the same Zarr data at the same time, [various consistency problems](https://docs.earthmover.io/concepts/version-control-system#consistency-problems-with-zarr) problems emerge. +These consistency problems can occur in both file storage and object storage; they are particularly severe in a cloud setting where Zarr is being used as an active store for data that are frequently changed while also being read. + +With Icechunk, we keep the same core Zarr data model, but add a layer of indirection between the Zarr keys and the on-disk storage. +The Icechunk library translates between the Zarr keys and the actual on-disk data given the particular context of the user's state. +Icechunk defines a series of interconnected metadata and data files that together enable efficient isolated reading and writing of metadata and chunks. +Once written, these files are immutable. +Icechunk keeps track of every single chunk explicitly in a "chunk manifest". + +```mermaid +flowchart TD + zarr-python[Zarr Library] <-- key / value--> icechunk[Icechunk Library] + icechunk <-- data / metadata files --> storage[(Object Storage)] +``` + diff --git a/docs/docs/stylesheets/global.css b/docs/docs/stylesheets/global.css index 8a88a074..3467cda6 100644 --- a/docs/docs/stylesheets/global.css +++ b/docs/docs/stylesheets/global.css @@ -3,3 +3,10 @@ [dir=ltr] .md-header__title { margin-left: 0px; } + +/* +TODO: find a way to show all pages in left sidebar +.md-nav--lifted>.md-nav__list>.md-nav__item, .md-nav--lifted>.md-nav__title { + display:block; +} + */ \ No newline at end of file diff --git a/docs/docs/stylesheets/homepage.css b/docs/docs/stylesheets/homepage.css index c5e13b42..04b02870 100644 --- a/docs/docs/stylesheets/homepage.css +++ b/docs/docs/stylesheets/homepage.css @@ -90,6 +90,10 @@ h3.hero-subtitle { margin-top: 60px; } +.links img { + box-shadow: 5px 5px 0px rgba(0,0,0,0.1); +} + .by-line-wrapper { margin: 60px auto; diff --git a/docs/docs/stylesheets/theme.css b/docs/docs/stylesheets/theme.css index d527b944..4f1a9bd9 100644 --- a/docs/docs/stylesheets/theme.css +++ b/docs/docs/stylesheets/theme.css @@ -30,4 +30,4 @@ --md-accent-fg-color--transparent: rgba(166, 83, 255, 0.7); //--md-accent-bg-color: hsla(0, 0%, 100%, 1); //--md-accent-bg-color--light: hsla(0, 0%, 100%, 0.7); -} \ No newline at end of file +} diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 659c51ae..406f793e 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -153,8 +153,8 @@ markdown_extensions: emoji_generator: !!python/name:material.extensions.emoji.to_svg nav: - - Overview: - - index.md + - Home: index.md + - Overview: overview.md - FAQ: faq.md - Icechunk Python: - icechunk-python/quickstart.md diff --git a/docs/overrides/home.html b/docs/overrides/home.html index f3ccced5..13bde307 100644 --- a/docs/overrides/home.html +++ b/docs/overrides/home.html @@ -60,8 +60,6 @@

{{ config.site_description }}

{{ page.content }} - - AAAA fgsdfgsdfgsd
{% endblock %} From e5dc453e477098c685573dd12b45305126b0a0cf Mon Sep 17 00:00:00 2001 From: Joseph Hamman Date: Mon, 14 Oct 2024 12:07:57 -0700 Subject: [PATCH 100/167] fixup --- docs/docs/icechunk-python/xarray.md | 46 +++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/docs/docs/icechunk-python/xarray.md b/docs/docs/icechunk-python/xarray.md index 21e86e26..a5bfe60b 100644 --- a/docs/docs/icechunk-python/xarray.md +++ b/docs/docs/icechunk-python/xarray.md @@ -4,7 +4,7 @@ Icechunk was designed to work seamlessly with Xarray. Xarray users can read and write data to Icechunk using [`xarray.open_zarr`](https://docs.xarray.dev/en/latest/generated/xarray.open_zarr.html#xarray.open_zarr) and [`xarray.Dataset.to_zarr`](https://docs.xarray.dev/en/latest/generated/xarray.Dataset.to_zarr.html#xarray.Dataset.to_zarr). -!!! note +!!! warning Using Xarray and Icechunk together currently requires installing Xarray from source. @@ -19,20 +19,32 @@ to it, and append data a second block of data using Icechunk's version control f ## Create a new store -Similar to the example in [quickstart](/icechunk-python/quickstart/), we'll create an Icechunk store in S3. You will need to replace the `StorageConfig` with a bucket that -you have access to. +Similar to the example in [quickstart](/icechunk-python/quickstart/), we'll create an +Icechunk store in S3 or a local file system. You will need to replace the `StorageConfig` +with a bucket or file path that you have access to. ```python import xarray as xr from icechunk import IcechunkStore, StorageConfig - -storage_config = icechunk.StorageConfig.s3_from_env( - bucket="icechunk-test", - prefix="xarray-demo" -) -store = icechunk.IcechunkStore.create(storage_config) ``` +=== "S3 Storage" + + ```python + storage_config = icechunk.StorageConfig.s3_from_env( + bucket="icechunk-test", + prefix="xarray-demo" + ) + store = await icechunk.IcechunkStore.create(storage_config) + ``` + +=== "Local Storage" + + ```python + storage_config = icechunk.StorageConfig.filesystem("./icechunk-xarray") + store = await icechunk.IcechunkStore.create(storage_config) + ``` + ## Open tutorial dataset from Xarray For this demo, we'll open Xarray's RASM tutorial dataset and split it into two blocks. @@ -53,6 +65,14 @@ Writing Xarray data to Icechunk is as easy as calling `Dataset.to_zarr`: ds1.to_zarr(store, zarr_format=3, consolidated=False) ``` +!!! note + + 1. [Consolidated metadata](https://docs.xarray.dev/en/latest/user-guide/io.html#consolidated-metadata) + is unnecessary (and unsupported) in Icechunk. + Icechunk already organizes the dataset metadata in a way that makes it very + fast to fetch from storage. + 2. `zarr_format=3` is required until the default Zarr format changes in Xarray. + After writing, we commit the changes: ```python @@ -66,7 +86,7 @@ Next, we want to add a second block of data to our store. Above, we created `ds2 this reason. Again, we'll use `Dataset.to_zarr`, this time with `append_dim='time'`. ```python -ds2.to_zarr(store, append_dim='time') +ds2.to_zarr(store, append_dim='time') ``` And then we'll commit the changes: @@ -81,7 +101,7 @@ store.commit("append more data") To read data stored in Icechunk with Xarray, we'll use `xarray.open_zarr`: ```python -xr.open_zarr(store, zarr_format=3, consolidated=False) +xr.open_zarr(store, consolidated=False) # output: Size: 9MB # Dimensions: (y: 205, x: 275, time: 18) # Coordinates: @@ -108,9 +128,9 @@ xr.open_zarr(store, zarr_format=3, consolidated=False) We can also read data from previous snapshots by checking out prior versions: ```python -store.checkout('ME4VKFPA5QAY0B2YSG8G') +store.checkout(snapshot_id='ME4VKFPA5QAY0B2YSG8G') -xr.open_zarr(store, zarr_format=3, consolidated=False) +xr.open_zarr(store, consolidated=False) # Size: 9MB # Dimensions: (time: 18, y: 205, x: 275) # Coordinates: From c19d9776529ab42cfb72930ecd10292bbc619901 Mon Sep 17 00:00:00 2001 From: Orestis Herodotou Date: Mon, 14 Oct 2024 12:12:58 -0700 Subject: [PATCH 101/167] Configure automatic docs deployment (#220) --- .github/workflows/deploy-docs.yaml | 28 ++++++++++++++++++++++++++++ docs/README.md | 7 +++++++ 2 files changed, 35 insertions(+) create mode 100644 .github/workflows/deploy-docs.yaml diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml new file mode 100644 index 00000000..77232240 --- /dev/null +++ b/.github/workflows/deploy-docs.yaml @@ -0,0 +1,28 @@ +name: deploy-docs +on: + push: + branches: + - main +permissions: + contents: write +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Configure Git Credentials + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV + - uses: actions/cache@v4 + with: + key: mkdocs-material-${{ env.cache_id }} + path: .cache + restore-keys: | + mkdocs-material- + - run: pip install mkdocs-material + - run: mkdocs gh-deploy --force \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 7b75e304..7e5d260d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,13 @@ This repository uses [Poetry](https://python-poetry.org/) to manage dependencies Builds output to: `icechunk-docs/.site` directory. + +### Deploying + +Docs are automatically deployed upon commits to `main` branch via the `./github/workflows/deploy-docs.yaml` action. + +You can manually deploy by running the command `mkdocs gh-deploy --force` from the directory containing the `mkdocs.yml` file. + ## Dev Notes #### Symlinked Files From f340ca1fe8a6326532713ddcca5b4131e79266e0 Mon Sep 17 00:00:00 2001 From: Orestis Herodotou Date: Mon, 14 Oct 2024 12:33:19 -0700 Subject: [PATCH 102/167] update docs ci --- .github/workflows/deploy-docs.yaml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index 77232240..1b4082f1 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -7,6 +7,9 @@ permissions: contents: write jobs: deploy: + defaults: + run: + working-directory: ./docs runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -17,12 +20,18 @@ jobs: - uses: actions/setup-python@v5 with: python-version: 3.x + cache: "poetry" + - name: Install Poetry + run: pipx install poetry - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - - uses: actions/cache@v4 + - name: Setup Cache + uses: actions/cache@v4 with: key: mkdocs-material-${{ env.cache_id }} path: .cache restore-keys: | mkdocs-material- - - run: pip install mkdocs-material - - run: mkdocs gh-deploy --force \ No newline at end of file + - name: Install Dependencies + run: poetry install + - name: Deploy to gh-pages + run: ​poetry run mkdocs gh-deploy --force \ No newline at end of file From 80611505a3ac8e7a103bf4a0051ccffa71e5608a Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Mon, 14 Oct 2024 16:33:57 -0300 Subject: [PATCH 103/167] Improvements to the sync api --- icechunk-python/python/icechunk/__init__.py | 11 ++-- .../python/icechunk/_icechunk_python.pyi | 50 ++++++++++++++----- icechunk-python/tests/test_can_read_old.py | 2 +- icechunk-python/tests/test_concurrency.py | 2 +- icechunk-python/tests/test_config.py | 2 +- .../tests/test_distributed_writers.py | 2 +- icechunk-python/tests/test_pickle.py | 8 +-- icechunk-python/tests/test_virtual_ref.py | 4 +- .../test_store/test_icechunk_store.py | 6 +-- 9 files changed, 58 insertions(+), 29 deletions(-) diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index b3b63a48..6c86394c 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -1,5 +1,5 @@ # module -from collections.abc import AsyncGenerator, Iterable, Generator +from collections.abc import AsyncGenerator, Iterable from typing import Any, Self from zarr.abc.store import ByteRangeRequest, Store @@ -37,7 +37,11 @@ class IcechunkStore(Store, SyncMixin): _store: PyIcechunkStore @classmethod - def open(cls, *args: Any, **kwargs: Any) -> Self: + async def open(cls, *args: Any, **kwargs: Any) -> Self: + return cls.open_or_create(*args, **kwargs) + + @classmethod + def open_or_create(cls, *args: Any, **kwargs: Any) -> Self: if "mode" in kwargs: mode = kwargs.pop("mode") else: @@ -78,6 +82,7 @@ def open(cls, *args: Any, **kwargs: Any) -> Self: return store + def __init__( self, store: PyIcechunkStore, @@ -308,7 +313,7 @@ async def async_tag(self, tag_name: str, snapshot_id: str) -> None: """Tag an existing snapshot with a given name.""" return await self._store.async_tag(tag_name, snapshot_id=snapshot_id) - def ancestry(self) -> Generator[SnapshotMetadata, None]: + def ancestry(self) -> list[SnapshotMetadata]: """Get the list of parents of the current version. Returns diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index 239a0498..3fc9aca8 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -11,21 +11,33 @@ class PyIcechunkStore: def change_set_bytes(self) -> bytes: ... @property def branch(self) -> str | None: ... - async def checkout_snapshot(self, snapshot_id: str) -> None: ... - async def checkout_branch(self, branch: str) -> None: ... - async def checkout_tag(self, tag: str) -> None: ... - async def distributed_commit( + def checkout_snapshot(self, snapshot_id: str) -> None: ... + async def async_checkout_snapshot(self, snapshot_id: str) -> None: ... + def checkout_branch(self, branch: str) -> None: ... + async def async_checkout_branch(self, branch: str) -> None: ... + def checkout_tag(self, tag: str) -> None: ... + async def async_checkout_tag(self, tag: str) -> None: ... + def distributed_commit( self, message: str, other_change_set_bytes: list[bytes] ) -> str: ... - async def commit(self, message: str) -> str: ... + async def async_distributed_commit( + self, message: str, other_change_set_bytes: list[bytes] + ) -> str: ... + def commit(self, message: str) -> str: ... + async def async_commit(self, message: str) -> str: ... @property def has_uncommitted_changes(self) -> bool: ... - async def reset(self) -> None: ... - async def new_branch(self, branch_name: str) -> str: ... - async def tag(self, tag: str, snapshot_id: str) -> None: ... - def ancestry(self) -> PyAsyncSnapshotGenerator: ... + def reset(self) -> None: ... + async def async_reset(self) -> None: ... + def new_branch(self, branch_name: str) -> str: ... + async def async_new_branch(self, branch_name: str) -> str: ... + def tag(self, tag: str, snapshot_id: str) -> None: ... + async def async_tag(self, tag: str, snapshot_id: str) -> None: ... + def ancestry(self) -> list[SnapshotMetadata]: ... + def async_ancestry(self) -> PyAsyncSnapshotGenerator: ... async def empty(self) -> bool: ... async def clear(self) -> None: ... + def sync_clear(self) -> None: ... async def get( self, key: str, byte_range: tuple[int | None, int | None] | None = None ) -> bytes: ... @@ -39,7 +51,10 @@ class PyIcechunkStore: def supports_deletes(self) -> bool: ... async def set(self, key: str, value: bytes) -> None: ... async def set_if_not_exists(self, key: str, value: bytes) -> None: ... - async def set_virtual_ref( + def set_virtual_ref( + self, key: str, location: str, offset: int, length: int + ) -> None: ... + async def async_set_virtual_ref( self, key: str, location: str, offset: int, length: int ) -> None: ... async def delete(self, key: str) -> None: ... @@ -243,11 +258,20 @@ class StoreConfig: virtual_ref_config: VirtualRefConfig | None = None, ): ... -async def pyicechunk_store_exists(storage: StorageConfig) -> bool: ... -async def pyicechunk_store_create( +async def async_pyicechunk_store_exists(storage: StorageConfig) -> bool: ... +def pyicechunk_store_exists(storage: StorageConfig) -> bool: ... + +async def async_pyicechunk_store_create( storage: StorageConfig, config: StoreConfig | None ) -> PyIcechunkStore: ... -async def pyicechunk_store_open_existing( +def pyicechunk_store_create( + storage: StorageConfig, config: StoreConfig | None +) -> PyIcechunkStore: ... + +async def async_pyicechunk_store_open_existing( + storage: StorageConfig, read_only: bool, config: StoreConfig | None +) -> PyIcechunkStore: ... +def pyicechunk_store_open_existing( storage: StorageConfig, read_only: bool, config: StoreConfig | None ) -> PyIcechunkStore: ... diff --git a/icechunk-python/tests/test_can_read_old.py b/icechunk-python/tests/test_can_read_old.py index af1ca116..4e598f44 100644 --- a/icechunk-python/tests/test_can_read_old.py +++ b/icechunk-python/tests/test_can_read_old.py @@ -39,7 +39,7 @@ def write_chunks_to_minio(chunks: list[tuple[str, bytes]]): def mk_store(mode): """Create a store that can access virtual chunks in localhost MinIO""" store_path = "./tests/data/test-repo" - store = ic.IcechunkStore.open( + store = ic.IcechunkStore.open_or_create( storage=ic.StorageConfig.filesystem(store_path), config=ic.StoreConfig( inline_chunk_threshold_bytes=10, diff --git a/icechunk-python/tests/test_concurrency.py b/icechunk-python/tests/test_concurrency.py index 341aa2a1..95504fbf 100644 --- a/icechunk-python/tests/test_concurrency.py +++ b/icechunk-python/tests/test_concurrency.py @@ -39,7 +39,7 @@ async def list_store(store, barrier): async def test_concurrency(): - store = icechunk.IcechunkStore.open( + store = icechunk.IcechunkStore.open_or_create( mode="w", storage=icechunk.StorageConfig.memory(prefix="concurrency"), ) diff --git a/icechunk-python/tests/test_config.py b/icechunk-python/tests/test_config.py index 20fd4120..5c4bfe93 100644 --- a/icechunk-python/tests/test_config.py +++ b/icechunk-python/tests/test_config.py @@ -8,7 +8,7 @@ @pytest.fixture(scope="function") async def tmp_store(tmpdir): store_path = f"{tmpdir}" - store = icechunk.IcechunkStore.open( + store = icechunk.IcechunkStore.open_or_create( storage=icechunk.StorageConfig.filesystem(store_path), mode="a", config=icechunk.StoreConfig(inline_chunk_threshold_bytes=5), diff --git a/icechunk-python/tests/test_distributed_writers.py b/icechunk-python/tests/test_distributed_writers.py index 5642c97b..d38bab35 100644 --- a/icechunk-python/tests/test_distributed_writers.py +++ b/icechunk-python/tests/test_distributed_writers.py @@ -38,7 +38,7 @@ def mk_store(mode: str, task: Task): ) store_config = icechunk.StoreConfig(**task.store_config) - store = icechunk.IcechunkStore.open( + store = icechunk.IcechunkStore.open_or_create( storage=storage_config, mode="a", config=store_config, diff --git a/icechunk-python/tests/test_pickle.py b/icechunk-python/tests/test_pickle.py index 03ca2fd7..40cddefc 100644 --- a/icechunk-python/tests/test_pickle.py +++ b/icechunk-python/tests/test_pickle.py @@ -9,7 +9,7 @@ @pytest.fixture(scope="function") async def tmp_store(tmpdir): store_path = f"{tmpdir}" - store = icechunk.IcechunkStore.open( + store = icechunk.IcechunkStore.open_or_create( storage=icechunk.StorageConfig.filesystem(store_path), mode="w", ) @@ -44,19 +44,19 @@ async def test_store_equality(tmpdir, tmp_store): local_store = await LocalStore.open(f"{tmpdir}/zarr", mode="w") assert tmp_store != local_store - store2 = icechunk.IcechunkStore.open( + store2 = icechunk.IcechunkStore.open_or_create( storage=icechunk.StorageConfig.memory(prefix="test"), mode="w", ) assert tmp_store != store2 - store3 = icechunk.IcechunkStore.open( + store3 = icechunk.IcechunkStore.open_or_create( storage=icechunk.StorageConfig.filesystem(f"{tmpdir}/test"), mode="a", ) assert tmp_store != store3 - store4 = icechunk.IcechunkStore.open( + store4 = icechunk.IcechunkStore.open_or_create( storage=icechunk.StorageConfig.filesystem(f"{tmpdir}/test"), mode="a", ) diff --git a/icechunk-python/tests/test_virtual_ref.py b/icechunk-python/tests/test_virtual_ref.py index 1b2a122f..3ad6ca77 100644 --- a/icechunk-python/tests/test_virtual_ref.py +++ b/icechunk-python/tests/test_virtual_ref.py @@ -41,7 +41,7 @@ async def test_write_minio_virtual_refs(): ) # Open the store - store = IcechunkStore.open( + store = IcechunkStore.open_or_create( storage=StorageConfig.memory("virtual"), mode="w", config=StoreConfig( @@ -96,7 +96,7 @@ async def test_write_minio_virtual_refs(): async def test_from_s3_public_virtual_refs(tmpdir): # Open the store, - store = IcechunkStore.open( + store = IcechunkStore.open_or_create( storage=StorageConfig.filesystem(f'{tmpdir}/virtual'), mode="w", config=StoreConfig( diff --git a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py index c7a62017..5cc427b4 100644 --- a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py +++ b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py @@ -56,7 +56,7 @@ def store_kwargs(self) -> dict[str, Any]: @pytest.fixture(scope="function") async def store(self, store_kwargs: dict[str, Any]) -> IcechunkStore: - return IcechunkStore.open(**store_kwargs) + return IcechunkStore.open_or_create(**store_kwargs) @pytest.mark.xfail(reason="Not implemented") def test_store_repr(self, store: IcechunkStore) -> None: @@ -76,7 +76,7 @@ def test_store_open_mode( ) -> None: store_kwargs["mode"] = mode try: - store = self.store_cls.open(**store_kwargs) + store = self.store_cls.open_or_create(**store_kwargs) assert store._is_open assert store.mode == AccessMode.from_literal(mode) except Exception: @@ -85,7 +85,7 @@ def test_store_open_mode( async def test_not_writable_store_raises(self, store_kwargs: dict[str, Any]) -> None: create_kwargs = {**store_kwargs, "mode": "r"} with pytest.raises(ValueError): - _store = self.store_cls.open(**create_kwargs) + _store = self.store_cls.open_or_create(**create_kwargs) # TODO # set From d905536de13c32f6796639ca31a0ad9d2bf147f7 Mon Sep 17 00:00:00 2001 From: Orestis Herodotou Date: Mon, 14 Oct 2024 12:35:37 -0700 Subject: [PATCH 104/167] Add manual dispatch to docs deployment --- .github/workflows/deploy-docs.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index 1b4082f1..9173c707 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -1,5 +1,6 @@ name: deploy-docs on: + workflow_dispatch: push: branches: - main From 6d772b1f93ab48c24f7fe762ff4ea3c052d48f9a Mon Sep 17 00:00:00 2001 From: Sebastian Galkin Date: Mon, 14 Oct 2024 16:47:47 -0300 Subject: [PATCH 105/167] Update examples with sync api --- icechunk-python/examples/dask_write.py | 6 +++--- icechunk-python/examples/smoke-test.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/icechunk-python/examples/dask_write.py b/icechunk-python/examples/dask_write.py index e6bc78f5..7d10e6a0 100644 --- a/icechunk-python/examples/dask_write.py +++ b/icechunk-python/examples/dask_write.py @@ -126,7 +126,7 @@ def create(args: argparse.Namespace) -> None: Commits the Icechunk repository when done. """ - store = icechunk.IcechunkStore.open( + store = icechunk.IcechunkStore.open_or_create( storage=icechunk.StorageConfig.s3_from_env(**storage_config(args)), mode="w", config=icechunk.StoreConfig(**store_config(args)), @@ -166,7 +166,7 @@ def update(args: argparse.Namespace) -> None: storage_conf = storage_config(args) store_conf = store_config(args) - store = icechunk.IcechunkStore.open( + store = icechunk.IcechunkStore.open_or_create( storage=icechunk.StorageConfig.s3_from_env(**storage_conf), mode="r+", config=icechunk.StoreConfig(**store_conf), @@ -212,7 +212,7 @@ def verify(args: argparse.Namespace) -> None: storage_conf = storage_config(args) store_conf = store_config(args) - store = icechunk.IcechunkStore.open( + store = icechunk.IcechunkStore.open_or_create( storage=icechunk.StorageConfig.s3_from_env(**storage_conf), mode="r", config=icechunk.StoreConfig(**store_conf), diff --git a/icechunk-python/examples/smoke-test.py b/icechunk-python/examples/smoke-test.py index 891639c3..9ad777c9 100644 --- a/icechunk-python/examples/smoke-test.py +++ b/icechunk-python/examples/smoke-test.py @@ -155,7 +155,7 @@ async def run(store: Store) -> None: async def create_icechunk_store(*, storage: StorageConfig) -> IcechunkStore: - return IcechunkStore.open( + return IcechunkStore.open_or_create( storage=storage, mode="w", config=StoreConfig(inline_chunk_threshold_bytes=1) ) From d02c73e5d9aa24513802c6ee72065b9f71566b72 Mon Sep 17 00:00:00 2001 From: Orestis Herodotou Date: Mon, 14 Oct 2024 13:30:00 -0700 Subject: [PATCH 106/167] Ignore docs dir changes for python and rust ci (#223) --- .github/workflows/python-check.yaml | 2 ++ .github/workflows/rust-ci.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/python-check.yaml b/.github/workflows/python-check.yaml index 4e6ebd9c..60139e26 100644 --- a/.github/workflows/python-check.yaml +++ b/.github/workflows/python-check.yaml @@ -4,6 +4,8 @@ on: push: branches: - main + paths-ignore: + - 'docs/**' pull_request: workflow_dispatch: diff --git a/.github/workflows/rust-ci.yaml b/.github/workflows/rust-ci.yaml index 41fa58e0..189dddd6 100644 --- a/.github/workflows/rust-ci.yaml +++ b/.github/workflows/rust-ci.yaml @@ -8,6 +8,8 @@ on: push: branches: - main + paths-ignore: + - 'docs/**' concurrency: group: ${{ github.workflow }}-${{ github.ref }} From f460a56577ec560c4debfd89e401a98153cd3560 Mon Sep 17 00:00:00 2001 From: Orestis Herodotou Date: Mon, 14 Oct 2024 13:54:46 -0700 Subject: [PATCH 107/167] chore(docs); Fix poetry install (#224) Fix docs ci deployments --- .github/workflows/deploy-docs.yaml | 41 ++- docs/mkdocs.yml | 4 + docs/poetry.lock | 512 ++++++++++++++++------------- docs/pyproject.toml | 3 +- 4 files changed, 315 insertions(+), 245 deletions(-) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index 9173c707..8e362383 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -18,21 +18,50 @@ jobs: run: | git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com + - uses: actions/setup-python@v5 with: python-version: 3.x - cache: "poetry" + + - name: Load cached Poetry installation + id: cached-poetry + uses: actions/cache@v4 + with: + path: ~/.local # the path depends on the OS + key: poetry-0 # increment to reset cache + - name: Install Poetry - run: pipx install poetry + if: steps.cached-poetry.outputs.cache-hit != 'true' + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + virtualenvs-path: .venv + installer-parallel: true + + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} + + - name: Install dependencies (with cache) + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + run: poetry install --no-interaction --no-root + + - name: Install project + run: poetry install --no-interaction + - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - - name: Setup Cache + + - name: Setup Cache for MkDocs uses: actions/cache@v4 with: key: mkdocs-material-${{ env.cache_id }} path: .cache restore-keys: | mkdocs-material- - - name: Install Dependencies - run: poetry install + - name: Deploy to gh-pages - run: ​poetry run mkdocs gh-deploy --force \ No newline at end of file + run: poetry run mkdocs gh-deploy --force \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 406f793e..06e3237b 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -123,6 +123,10 @@ plugins: #enabled: !ENV [CI, false] - mkdocstrings: default_handler: python + handlers: + python: + paths: [../icechunk-python/python] + - mkdocs-jupyter: include_source: True #include: diff --git a/docs/poetry.lock b/docs/poetry.lock index 369d6b87..c7ad79b4 100644 --- a/docs/poetry.lock +++ b/docs/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "appnope" @@ -245,101 +245,116 @@ pycparser = "*" [[package]] name = "charset-normalizer" -version = "3.3.2" +version = "3.4.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" files = [ - {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, - {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, + {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, + {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, ] [[package]] @@ -415,33 +430,37 @@ test = ["flake8", "isort", "pytest"] [[package]] name = "debugpy" -version = "1.8.6" +version = "1.8.7" description = "An implementation of the Debug Adapter Protocol for Python" optional = false python-versions = ">=3.8" files = [ - {file = "debugpy-1.8.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:30f467c5345d9dfdcc0afdb10e018e47f092e383447500f125b4e013236bf14b"}, - {file = "debugpy-1.8.6-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d73d8c52614432f4215d0fe79a7e595d0dd162b5c15233762565be2f014803b"}, - {file = "debugpy-1.8.6-cp310-cp310-win32.whl", hash = "sha256:e3e182cd98eac20ee23a00653503315085b29ab44ed66269482349d307b08df9"}, - {file = "debugpy-1.8.6-cp310-cp310-win_amd64.whl", hash = "sha256:e3a82da039cfe717b6fb1886cbbe5c4a3f15d7df4765af857f4307585121c2dd"}, - {file = "debugpy-1.8.6-cp311-cp311-macosx_14_0_universal2.whl", hash = "sha256:67479a94cf5fd2c2d88f9615e087fcb4fec169ec780464a3f2ba4a9a2bb79955"}, - {file = "debugpy-1.8.6-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fb8653f6cbf1dd0a305ac1aa66ec246002145074ea57933978346ea5afdf70b"}, - {file = "debugpy-1.8.6-cp311-cp311-win32.whl", hash = "sha256:cdaf0b9691879da2d13fa39b61c01887c34558d1ff6e5c30e2eb698f5384cd43"}, - {file = "debugpy-1.8.6-cp311-cp311-win_amd64.whl", hash = "sha256:43996632bee7435583952155c06881074b9a742a86cee74e701d87ca532fe833"}, - {file = "debugpy-1.8.6-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:db891b141fc6ee4b5fc6d1cc8035ec329cabc64bdd2ae672b4550c87d4ecb128"}, - {file = "debugpy-1.8.6-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:567419081ff67da766c898ccf21e79f1adad0e321381b0dfc7a9c8f7a9347972"}, - {file = "debugpy-1.8.6-cp312-cp312-win32.whl", hash = "sha256:c9834dfd701a1f6bf0f7f0b8b1573970ae99ebbeee68314116e0ccc5c78eea3c"}, - {file = "debugpy-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:e4ce0570aa4aca87137890d23b86faeadf184924ad892d20c54237bcaab75d8f"}, - {file = "debugpy-1.8.6-cp38-cp38-macosx_14_0_x86_64.whl", hash = "sha256:df5dc9eb4ca050273b8e374a4cd967c43be1327eeb42bfe2f58b3cdfe7c68dcb"}, - {file = "debugpy-1.8.6-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a85707c6a84b0c5b3db92a2df685b5230dd8fb8c108298ba4f11dba157a615a"}, - {file = "debugpy-1.8.6-cp38-cp38-win32.whl", hash = "sha256:538c6cdcdcdad310bbefd96d7850be1cd46e703079cc9e67d42a9ca776cdc8a8"}, - {file = "debugpy-1.8.6-cp38-cp38-win_amd64.whl", hash = "sha256:22140bc02c66cda6053b6eb56dfe01bbe22a4447846581ba1dd6df2c9f97982d"}, - {file = "debugpy-1.8.6-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:c1cef65cffbc96e7b392d9178dbfd524ab0750da6c0023c027ddcac968fd1caa"}, - {file = "debugpy-1.8.6-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1e60bd06bb3cc5c0e957df748d1fab501e01416c43a7bdc756d2a992ea1b881"}, - {file = "debugpy-1.8.6-cp39-cp39-win32.whl", hash = "sha256:f7158252803d0752ed5398d291dee4c553bb12d14547c0e1843ab74ee9c31123"}, - {file = "debugpy-1.8.6-cp39-cp39-win_amd64.whl", hash = "sha256:3358aa619a073b620cd0d51d8a6176590af24abcc3fe2e479929a154bf591b51"}, - {file = "debugpy-1.8.6-py2.py3-none-any.whl", hash = "sha256:b48892df4d810eff21d3ef37274f4c60d32cdcafc462ad5647239036b0f0649f"}, - {file = "debugpy-1.8.6.zip", hash = "sha256:c931a9371a86784cee25dec8d65bc2dc7a21f3f1552e3833d9ef8f919d22280a"}, + {file = "debugpy-1.8.7-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:95fe04a573b8b22896c404365e03f4eda0ce0ba135b7667a1e57bd079793b96b"}, + {file = "debugpy-1.8.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:628a11f4b295ffb4141d8242a9bb52b77ad4a63a2ad19217a93be0f77f2c28c9"}, + {file = "debugpy-1.8.7-cp310-cp310-win32.whl", hash = "sha256:85ce9c1d0eebf622f86cc68618ad64bf66c4fc3197d88f74bb695a416837dd55"}, + {file = "debugpy-1.8.7-cp310-cp310-win_amd64.whl", hash = "sha256:29e1571c276d643757ea126d014abda081eb5ea4c851628b33de0c2b6245b037"}, + {file = "debugpy-1.8.7-cp311-cp311-macosx_14_0_universal2.whl", hash = "sha256:caf528ff9e7308b74a1749c183d6808ffbedbb9fb6af78b033c28974d9b8831f"}, + {file = "debugpy-1.8.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cba1d078cf2e1e0b8402e6bda528bf8fda7ccd158c3dba6c012b7897747c41a0"}, + {file = "debugpy-1.8.7-cp311-cp311-win32.whl", hash = "sha256:171899588bcd412151e593bd40d9907133a7622cd6ecdbdb75f89d1551df13c2"}, + {file = "debugpy-1.8.7-cp311-cp311-win_amd64.whl", hash = "sha256:6e1c4ffb0c79f66e89dfd97944f335880f0d50ad29525dc792785384923e2211"}, + {file = "debugpy-1.8.7-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:4d27d842311353ede0ad572600c62e4bcd74f458ee01ab0dd3a1a4457e7e3706"}, + {file = "debugpy-1.8.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:703c1fd62ae0356e194f3e7b7a92acd931f71fe81c4b3be2c17a7b8a4b546ec2"}, + {file = "debugpy-1.8.7-cp312-cp312-win32.whl", hash = "sha256:2f729228430ef191c1e4df72a75ac94e9bf77413ce5f3f900018712c9da0aaca"}, + {file = "debugpy-1.8.7-cp312-cp312-win_amd64.whl", hash = "sha256:45c30aaefb3e1975e8a0258f5bbd26cd40cde9bfe71e9e5a7ac82e79bad64e39"}, + {file = "debugpy-1.8.7-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:d050a1ec7e925f514f0f6594a1e522580317da31fbda1af71d1530d6ea1f2b40"}, + {file = "debugpy-1.8.7-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2f4349a28e3228a42958f8ddaa6333d6f8282d5edaea456070e48609c5983b7"}, + {file = "debugpy-1.8.7-cp313-cp313-win32.whl", hash = "sha256:11ad72eb9ddb436afb8337891a986302e14944f0f755fd94e90d0d71e9100bba"}, + {file = "debugpy-1.8.7-cp313-cp313-win_amd64.whl", hash = "sha256:2efb84d6789352d7950b03d7f866e6d180284bc02c7e12cb37b489b7083d81aa"}, + {file = "debugpy-1.8.7-cp38-cp38-macosx_14_0_x86_64.whl", hash = "sha256:4b908291a1d051ef3331484de8e959ef3e66f12b5e610c203b5b75d2725613a7"}, + {file = "debugpy-1.8.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da8df5b89a41f1fd31503b179d0a84a5fdb752dddd5b5388dbd1ae23cda31ce9"}, + {file = "debugpy-1.8.7-cp38-cp38-win32.whl", hash = "sha256:b12515e04720e9e5c2216cc7086d0edadf25d7ab7e3564ec8b4521cf111b4f8c"}, + {file = "debugpy-1.8.7-cp38-cp38-win_amd64.whl", hash = "sha256:93176e7672551cb5281577cdb62c63aadc87ec036f0c6a486f0ded337c504596"}, + {file = "debugpy-1.8.7-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:90d93e4f2db442f8222dec5ec55ccfc8005821028982f1968ebf551d32b28907"}, + {file = "debugpy-1.8.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6db2a370e2700557a976eaadb16243ec9c91bd46f1b3bb15376d7aaa7632c81"}, + {file = "debugpy-1.8.7-cp39-cp39-win32.whl", hash = "sha256:a6cf2510740e0c0b4a40330640e4b454f928c7b99b0c9dbf48b11efba08a8cda"}, + {file = "debugpy-1.8.7-cp39-cp39-win_amd64.whl", hash = "sha256:6a9d9d6d31846d8e34f52987ee0f1a904c7baa4912bf4843ab39dadf9b8f3e0d"}, + {file = "debugpy-1.8.7-py2.py3-none-any.whl", hash = "sha256:57b00de1c8d2c84a61b90880f7e5b6deaf4c312ecbde3a0e8912f2a56c4ac9ae"}, + {file = "debugpy-1.8.7.zip", hash = "sha256:18b8f731ed3e2e1df8e9cdaa23fb1fc9c24e570cd0081625308ec51c82efe42e"}, ] [[package]] @@ -569,40 +588,38 @@ test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", [[package]] name = "griffe" -version = "1.3.2" +version = "1.4.1" description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "griffe-1.3.2-py3-none-any.whl", hash = "sha256:2e34b5e46507d615915c8e6288bb1a2234bd35dee44d01e40a2bc2f25bd4d10c"}, - {file = "griffe-1.3.2.tar.gz", hash = "sha256:1ec50335aa507ed2445f2dd45a15c9fa3a45f52c9527e880571dfc61912fd60c"}, + {file = "griffe-1.4.1-py3-none-any.whl", hash = "sha256:84295ee0b27743bd880aea75632830ef02ded65d16124025e4c263bb826ab645"}, + {file = "griffe-1.4.1.tar.gz", hash = "sha256:911a201b01dc92e08c0e84c38a301e9da5ec067f00e7d9f2e39bc24dbfa3c176"}, ] [package.dependencies] colorama = ">=0.4" [[package]] -name = "htmlmin2" -version = "0.1.13" -description = "An HTML Minifier" +name = "hjson" +version = "3.1.0" +description = "Hjson, a user interface for JSON." optional = false python-versions = "*" files = [ - {file = "htmlmin2-0.1.13-py3-none-any.whl", hash = "sha256:75609f2a42e64f7ce57dbff28a39890363bde9e7e5885db633317efbdf8c79a2"}, + {file = "hjson-3.1.0-py3-none-any.whl", hash = "sha256:65713cdcf13214fb554eb8b4ef803419733f4f5e551047c9b711098ab7186b89"}, + {file = "hjson-3.1.0.tar.gz", hash = "sha256:55af475a27cf83a7969c808399d7bccdec8fb836a07ddbd574587593b9cdcf75"}, ] [[package]] -name = "icechunk" -version = "0.1.0" -description = "Icechunk Python" +name = "htmlmin2" +version = "0.1.13" +description = "An HTML Minifier" optional = false python-versions = "*" -files = [] -develop = true - -[package.source] -type = "directory" -url = "../icechunk-python" +files = [ + {file = "htmlmin2-0.1.13-py3-none-any.whl", hash = "sha256:75609f2a42e64f7ce57dbff28a39890363bde9e7e5885db633317efbdf8c79a2"}, +] [[package]] name = "idna" @@ -814,13 +831,13 @@ format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339- [[package]] name = "jsonschema-specifications" -version = "2023.12.1" +version = "2024.10.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "jsonschema_specifications-2023.12.1-py3-none-any.whl", hash = "sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c"}, - {file = "jsonschema_specifications-2023.12.1.tar.gz", hash = "sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc"}, + {file = "jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf"}, + {file = "jsonschema_specifications-2024.10.1.tar.gz", hash = "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272"}, ] [package.dependencies] @@ -949,71 +966,72 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] name = "markupsafe" -version = "2.1.5" +version = "3.0.1" description = "Safely add untrusted strings to HTML/XML markup." optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" files = [ - {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, - {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, - {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, - {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, - {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, - {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, - {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, - {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:db842712984e91707437461930e6011e60b39136c7331e971952bb30465bc1a1"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3ffb4a8e7d46ed96ae48805746755fadd0909fea2306f93d5d8233ba23dda12a"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67c519635a4f64e495c50e3107d9b4075aec33634272b5db1cde839e07367589"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48488d999ed50ba8d38c581d67e496f955821dc183883550a6fbc7f1aefdc170"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f31ae06f1328595d762c9a2bf29dafd8621c7d3adc130cbb46278079758779ca"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:80fcbf3add8790caddfab6764bde258b5d09aefbe9169c183f88a7410f0f6dea"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3341c043c37d78cc5ae6e3e305e988532b072329639007fd408a476642a89fd6"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cb53e2a99df28eee3b5f4fea166020d3ef9116fdc5764bc5117486e6d1211b25"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-win32.whl", hash = "sha256:db15ce28e1e127a0013dfb8ac243a8e392db8c61eae113337536edb28bdc1f97"}, + {file = "MarkupSafe-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:4ffaaac913c3f7345579db4f33b0020db693f302ca5137f106060316761beea9"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:26627785a54a947f6d7336ce5963569b5d75614619e75193bdb4e06e21d447ad"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b954093679d5750495725ea6f88409946d69cfb25ea7b4c846eef5044194f583"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:973a371a55ce9ed333a3a0f8e0bcfae9e0d637711534bcb11e130af2ab9334e7"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:244dbe463d5fb6d7ce161301a03a6fe744dac9072328ba9fc82289238582697b"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d98e66a24497637dd31ccab090b34392dddb1f2f811c4b4cd80c230205c074a3"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad91738f14eb8da0ff82f2acd0098b6257621410dcbd4df20aaa5b4233d75a50"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7044312a928a66a4c2a22644147bc61a199c1709712069a344a3fb5cfcf16915"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a4792d3b3a6dfafefdf8e937f14906a51bd27025a36f4b188728a73382231d91"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-win32.whl", hash = "sha256:fa7d686ed9883f3d664d39d5a8e74d3c5f63e603c2e3ff0abcba23eac6542635"}, + {file = "MarkupSafe-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9ba25a71ebf05b9bb0e2ae99f8bc08a07ee8e98c612175087112656ca0f5c8bf"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8ae369e84466aa70f3154ee23c1451fda10a8ee1b63923ce76667e3077f2b0c4"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40f1e10d51c92859765522cbd79c5c8989f40f0419614bcdc5015e7b6bf97fc5"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a4cb365cb49b750bdb60b846b0c0bc49ed62e59a76635095a179d440540c346"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee3941769bd2522fe39222206f6dd97ae83c442a94c90f2b7a25d847d40f4729"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62fada2c942702ef8952754abfc1a9f7658a4d5460fabe95ac7ec2cbe0d02abc"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c2d64fdba74ad16138300815cfdc6ab2f4647e23ced81f59e940d7d4a1469d9"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fb532dd9900381d2e8f48172ddc5a59db4c445a11b9fab40b3b786da40d3b56b"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0f84af7e813784feb4d5e4ff7db633aba6c8ca64a833f61d8e4eade234ef0c38"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-win32.whl", hash = "sha256:cbf445eb5628981a80f54087f9acdbf84f9b7d862756110d172993b9a5ae81aa"}, + {file = "MarkupSafe-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:a10860e00ded1dd0a65b83e717af28845bb7bd16d8ace40fe5531491de76b79f"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e81c52638315ff4ac1b533d427f50bc0afc746deb949210bc85f05d4f15fd772"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:312387403cd40699ab91d50735ea7a507b788091c416dd007eac54434aee51da"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ae99f31f47d849758a687102afdd05bd3d3ff7dbab0a8f1587981b58a76152a"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c97ff7fedf56d86bae92fa0a646ce1a0ec7509a7578e1ed238731ba13aabcd1c"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7420ceda262dbb4b8d839a4ec63d61c261e4e77677ed7c66c99f4e7cb5030dd"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45d42d132cff577c92bfba536aefcfea7e26efb975bd455db4e6602f5c9f45e7"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4c8817557d0de9349109acb38b9dd570b03cc5014e8aabf1cbddc6e81005becd"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a54c43d3ec4cf2a39f4387ad044221c66a376e58c0d0e971d47c475ba79c6b5"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-win32.whl", hash = "sha256:c91b394f7601438ff79a4b93d16be92f216adb57d813a78be4446fe0f6bc2d8c"}, + {file = "MarkupSafe-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:fe32482b37b4b00c7a52a07211b479653b7fe4f22b2e481b9a9b099d8a430f2f"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:17b2aea42a7280db02ac644db1d634ad47dcc96faf38ab304fe26ba2680d359a"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:852dc840f6d7c985603e60b5deaae1d89c56cb038b577f6b5b8c808c97580f1d"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0778de17cff1acaeccc3ff30cd99a3fd5c50fc58ad3d6c0e0c4c58092b859396"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:800100d45176652ded796134277ecb13640c1a537cad3b8b53da45aa96330453"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d06b24c686a34c86c8c1fba923181eae6b10565e4d80bdd7bc1c8e2f11247aa4"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:33d1c36b90e570ba7785dacd1faaf091203d9942bc036118fab8110a401eb1a8"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:beeebf760a9c1f4c07ef6a53465e8cfa776ea6a2021eda0d0417ec41043fe984"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bbde71a705f8e9e4c3e9e33db69341d040c827c7afa6789b14c6e16776074f5a"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-win32.whl", hash = "sha256:82b5dba6eb1bcc29cc305a18a3c5365d2af06ee71b123216416f7e20d2a84e5b"}, + {file = "MarkupSafe-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:730d86af59e0e43ce277bb83970530dd223bf7f2a838e086b50affa6ec5f9295"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4935dd7883f1d50e2ffecca0aa33dc1946a94c8f3fdafb8df5c330e48f71b132"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e9393357f19954248b00bed7c56f29a25c930593a77630c719653d51e7669c2a"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40621d60d0e58aa573b68ac5e2d6b20d44392878e0bfc159012a5787c4e35bc8"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f94190df587738280d544971500b9cafc9b950d32efcb1fba9ac10d84e6aa4e6"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6a387d61fe41cdf7ea95b38e9af11cfb1a63499af2759444b99185c4ab33f5b"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8ad4ad1429cd4f315f32ef263c1342166695fad76c100c5d979c45d5570ed58b"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e24bfe89c6ac4c31792793ad9f861b8f6dc4546ac6dc8f1c9083c7c4f2b335cd"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2a4b34a8d14649315c4bc26bbfa352663eb51d146e35eef231dd739d54a5430a"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-win32.whl", hash = "sha256:242d6860f1fd9191aef5fae22b51c5c19767f93fb9ead4d21924e0bcb17619d8"}, + {file = "MarkupSafe-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:93e8248d650e7e9d49e8251f883eed60ecbc0e8ffd6349e18550925e31bd029b"}, + {file = "markupsafe-3.0.1.tar.gz", hash = "sha256:3e683ee4f5d0fa2dde4db77ed8dd8a876686e3fc417655c2ece9a90576905344"}, ] [[package]] @@ -1277,35 +1295,38 @@ pygments = ">2.12.0" [[package]] name = "mkdocs-macros-plugin" -version = "1.2.0" +version = "1.3.5" description = "Unleash the power of MkDocs with macros and variables" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs-macros-plugin-1.2.0.tar.gz", hash = "sha256:7603b85cb336d669e29a8a9cc3af8b90767ffdf6021b3e023d5ec2e0a1f927a7"}, - {file = "mkdocs_macros_plugin-1.2.0-py3-none-any.whl", hash = "sha256:3e442f8f37aa69710a69b5389e6b6cd0f54f4fcaee354aa57a61735ba8f97d27"}, + {file = "mkdocs-macros-plugin-1.3.5.tar.gz", hash = "sha256:5fd6969e2c43e23031ffb719bebe7421163ea26f4dc360af2343144ca979b04b"}, + {file = "mkdocs_macros_plugin-1.3.5-py3-none-any.whl", hash = "sha256:58bd47ea7097d1a2824dc9d0d912c211823c5e6e6fe8a19a3ecf33346f7d6547"}, ] [package.dependencies] +hjson = "*" jinja2 = "*" mkdocs = ">=0.17" packaging = "*" +pathspec = "*" python-dateutil = "*" pyyaml = "*" +super-collections = "*" termcolor = "*" [package.extras] -test = ["mkdocs-include-markdown-plugin", "mkdocs-macros-test", "mkdocs-material (>=6.2)"] +test = ["mkdocs-include-markdown-plugin", "mkdocs-macros-test", "mkdocs-material (>=6.2)", "mkdocs-test"] [[package]] name = "mkdocs-material" -version = "9.5.39" +version = "9.5.40" description = "Documentation that simply works" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.5.39-py3-none-any.whl", hash = "sha256:0f2f68c8db89523cb4a59705cd01b4acd62b2f71218ccb67e1e004e560410d2b"}, - {file = "mkdocs_material-9.5.39.tar.gz", hash = "sha256:25faa06142afa38549d2b781d475a86fb61de93189f532b88e69bf11e5e5c3be"}, + {file = "mkdocs_material-9.5.40-py3-none-any.whl", hash = "sha256:8e7a16ada34e79a7b6459ff2602584222f522c738b6a023d1bea853d5049da6f"}, + {file = "mkdocs_material-9.5.40.tar.gz", hash = "sha256:b69d70e667ec51fc41f65e006a3184dd00d95b2439d982cb1586e4c018943156"}, ] [package.dependencies] @@ -1416,13 +1437,13 @@ test = ["autoflake", "black", "isort", "pytest"] [[package]] name = "mkdocstrings" -version = "0.26.1" +version = "0.26.2" description = "Automatic documentation from sources, for MkDocs." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "mkdocstrings-0.26.1-py3-none-any.whl", hash = "sha256:29738bfb72b4608e8e55cc50fb8a54f325dc7ebd2014e4e3881a49892d5983cf"}, - {file = "mkdocstrings-0.26.1.tar.gz", hash = "sha256:bb8b8854d6713d5348ad05b069a09f3b79edbc6a0f33a34c6821141adb03fe33"}, + {file = "mkdocstrings-0.26.2-py3-none-any.whl", hash = "sha256:1248f3228464f3b8d1a15bd91249ce1701fe3104ac517a5f167a0e01ca850ba5"}, + {file = "mkdocstrings-0.26.2.tar.gz", hash = "sha256:34a8b50f1e6cfd29546c6c09fbe02154adfb0b361bb758834bf56aa284ba876e"}, ] [package.dependencies] @@ -1443,13 +1464,13 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] [[package]] name = "mkdocstrings-python" -version = "1.11.1" +version = "1.12.1" description = "A Python handler for mkdocstrings." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "mkdocstrings_python-1.11.1-py3-none-any.whl", hash = "sha256:a21a1c05acef129a618517bb5aae3e33114f569b11588b1e7af3e9d4061a71af"}, - {file = "mkdocstrings_python-1.11.1.tar.gz", hash = "sha256:8824b115c5359304ab0b5378a91f6202324a849e1da907a3485b59208b797322"}, + {file = "mkdocstrings_python-1.12.1-py3-none-any.whl", hash = "sha256:205244488199c9aa2a39787ad6a0c862d39b74078ea9aa2be817bc972399563f"}, + {file = "mkdocstrings_python-1.12.1.tar.gz", hash = "sha256:60d6a5ca912c9af4ad431db6d0111ce9f79c6c48d33377dde6a05a8f5f48d792"}, ] [package.dependencies] @@ -1866,13 +1887,13 @@ extra = ["pygments (>=2.12)"] [[package]] name = "pyparsing" -version = "3.1.4" +version = "3.2.0" description = "pyparsing module - Classes and methods to define and execute parsing grammars" optional = false -python-versions = ">=3.6.8" +python-versions = ">=3.9" files = [ - {file = "pyparsing-3.1.4-py3-none-any.whl", hash = "sha256:a6a7ee4235a3f944aa1fa2249307708f893fe5717dc603503c6c7969c070fb7c"}, - {file = "pyparsing-3.1.4.tar.gz", hash = "sha256:f86ec8d1a83f11977c9a6ea7598e8c27fc5cddfa5b07ea2241edbbde1d7bc032"}, + {file = "pyparsing-3.2.0-py3-none-any.whl", hash = "sha256:93d9577b88da0bbea8cc8334ee8b918ed014968fd2ec383e868fb8afb1ccef84"}, + {file = "pyparsing-3.2.0.tar.gz", hash = "sha256:cbf74e27246d595d9a74b186b810f6fbb86726dbf3b9532efb343f6d7294fe9c"}, ] [package.extras] @@ -1905,29 +1926,29 @@ files = [ [[package]] name = "pywin32" -version = "307" +version = "308" description = "Python for Window Extensions" optional = false python-versions = "*" files = [ - {file = "pywin32-307-cp310-cp310-win32.whl", hash = "sha256:f8f25d893c1e1ce2d685ef6d0a481e87c6f510d0f3f117932781f412e0eba31b"}, - {file = "pywin32-307-cp310-cp310-win_amd64.whl", hash = "sha256:36e650c5e5e6b29b5d317385b02d20803ddbac5d1031e1f88d20d76676dd103d"}, - {file = "pywin32-307-cp310-cp310-win_arm64.whl", hash = "sha256:0c12d61e0274e0c62acee79e3e503c312426ddd0e8d4899c626cddc1cafe0ff4"}, - {file = "pywin32-307-cp311-cp311-win32.whl", hash = "sha256:fec5d27cc893178fab299de911b8e4d12c5954e1baf83e8a664311e56a272b75"}, - {file = "pywin32-307-cp311-cp311-win_amd64.whl", hash = "sha256:987a86971753ed7fdd52a7fb5747aba955b2c7fbbc3d8b76ec850358c1cc28c3"}, - {file = "pywin32-307-cp311-cp311-win_arm64.whl", hash = "sha256:fd436897c186a2e693cd0437386ed79f989f4d13d6f353f8787ecbb0ae719398"}, - {file = "pywin32-307-cp312-cp312-win32.whl", hash = "sha256:07649ec6b01712f36debf39fc94f3d696a46579e852f60157a729ac039df0815"}, - {file = "pywin32-307-cp312-cp312-win_amd64.whl", hash = "sha256:00d047992bb5dcf79f8b9b7c81f72e0130f9fe4b22df613f755ab1cc021d8347"}, - {file = "pywin32-307-cp312-cp312-win_arm64.whl", hash = "sha256:b53658acbfc6a8241d72cc09e9d1d666be4e6c99376bc59e26cdb6223c4554d2"}, - {file = "pywin32-307-cp313-cp313-win32.whl", hash = "sha256:ea4d56e48dc1ab2aa0a5e3c0741ad6e926529510516db7a3b6981a1ae74405e5"}, - {file = "pywin32-307-cp313-cp313-win_amd64.whl", hash = "sha256:576d09813eaf4c8168d0bfd66fb7cb3b15a61041cf41598c2db4a4583bf832d2"}, - {file = "pywin32-307-cp313-cp313-win_arm64.whl", hash = "sha256:b30c9bdbffda6a260beb2919f918daced23d32c79109412c2085cbc513338a0a"}, - {file = "pywin32-307-cp37-cp37m-win32.whl", hash = "sha256:5101472f5180c647d4525a0ed289ec723a26231550dbfd369ec19d5faf60e511"}, - {file = "pywin32-307-cp37-cp37m-win_amd64.whl", hash = "sha256:05de55a7c110478dc4b202230e98af5e0720855360d2b31a44bb4e296d795fba"}, - {file = "pywin32-307-cp38-cp38-win32.whl", hash = "sha256:13d059fb7f10792542082f5731d5d3d9645320fc38814759313e5ee97c3fac01"}, - {file = "pywin32-307-cp38-cp38-win_amd64.whl", hash = "sha256:7e0b2f93769d450a98ac7a31a087e07b126b6d571e8b4386a5762eb85325270b"}, - {file = "pywin32-307-cp39-cp39-win32.whl", hash = "sha256:55ee87f2f8c294e72ad9d4261ca423022310a6e79fb314a8ca76ab3f493854c6"}, - {file = "pywin32-307-cp39-cp39-win_amd64.whl", hash = "sha256:e9d5202922e74985b037c9ef46778335c102b74b95cec70f629453dbe7235d87"}, + {file = "pywin32-308-cp310-cp310-win32.whl", hash = "sha256:796ff4426437896550d2981b9c2ac0ffd75238ad9ea2d3bfa67a1abd546d262e"}, + {file = "pywin32-308-cp310-cp310-win_amd64.whl", hash = "sha256:4fc888c59b3c0bef905ce7eb7e2106a07712015ea1c8234b703a088d46110e8e"}, + {file = "pywin32-308-cp310-cp310-win_arm64.whl", hash = "sha256:a5ab5381813b40f264fa3495b98af850098f814a25a63589a8e9eb12560f450c"}, + {file = "pywin32-308-cp311-cp311-win32.whl", hash = "sha256:5d8c8015b24a7d6855b1550d8e660d8daa09983c80e5daf89a273e5c6fb5095a"}, + {file = "pywin32-308-cp311-cp311-win_amd64.whl", hash = "sha256:575621b90f0dc2695fec346b2d6302faebd4f0f45c05ea29404cefe35d89442b"}, + {file = "pywin32-308-cp311-cp311-win_arm64.whl", hash = "sha256:100a5442b7332070983c4cd03f2e906a5648a5104b8a7f50175f7906efd16bb6"}, + {file = "pywin32-308-cp312-cp312-win32.whl", hash = "sha256:587f3e19696f4bf96fde9d8a57cec74a57021ad5f204c9e627e15c33ff568897"}, + {file = "pywin32-308-cp312-cp312-win_amd64.whl", hash = "sha256:00b3e11ef09ede56c6a43c71f2d31857cf7c54b0ab6e78ac659497abd2834f47"}, + {file = "pywin32-308-cp312-cp312-win_arm64.whl", hash = "sha256:9b4de86c8d909aed15b7011182c8cab38c8850de36e6afb1f0db22b8959e3091"}, + {file = "pywin32-308-cp313-cp313-win32.whl", hash = "sha256:1c44539a37a5b7b21d02ab34e6a4d314e0788f1690d65b48e9b0b89f31abbbed"}, + {file = "pywin32-308-cp313-cp313-win_amd64.whl", hash = "sha256:fd380990e792eaf6827fcb7e187b2b4b1cede0585e3d0c9e84201ec27b9905e4"}, + {file = "pywin32-308-cp313-cp313-win_arm64.whl", hash = "sha256:ef313c46d4c18dfb82a2431e3051ac8f112ccee1a34f29c263c583c568db63cd"}, + {file = "pywin32-308-cp37-cp37m-win32.whl", hash = "sha256:1f696ab352a2ddd63bd07430080dd598e6369152ea13a25ebcdd2f503a38f1ff"}, + {file = "pywin32-308-cp37-cp37m-win_amd64.whl", hash = "sha256:13dcb914ed4347019fbec6697a01a0aec61019c1046c2b905410d197856326a6"}, + {file = "pywin32-308-cp38-cp38-win32.whl", hash = "sha256:5794e764ebcabf4ff08c555b31bd348c9025929371763b2183172ff4708152f0"}, + {file = "pywin32-308-cp38-cp38-win_amd64.whl", hash = "sha256:3b92622e29d651c6b783e368ba7d6722b1634b8e70bd376fd7610fe1992e19de"}, + {file = "pywin32-308-cp39-cp39-win32.whl", hash = "sha256:7873ca4dc60ab3287919881a7d4f88baee4a6e639aa6962de25a98ba6b193341"}, + {file = "pywin32-308-cp39-cp39-win_amd64.whl", hash = "sha256:71b3322d949b4cc20776436a9c9ba0eeedcbc9c650daa536df63f0ff111bb920"}, ] [[package]] @@ -2464,6 +2485,23 @@ pure-eval = "*" [package.extras] tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] +[[package]] +name = "super-collections" +version = "0.5.3" +description = "file: README.md" +optional = false +python-versions = ">=3.8" +files = [ + {file = "super_collections-0.5.3-py3-none-any.whl", hash = "sha256:907d35b25dc4070910e8254bf2f5c928348af1cf8a1f1e8259e06c666e902cff"}, + {file = "super_collections-0.5.3.tar.gz", hash = "sha256:94c1ec96c0a0d5e8e7d389ed8cde6882ac246940507c5e6b86e91945c2968d46"}, +] + +[package.dependencies] +hjson = "*" + +[package.extras] +test = ["pytest (>=7.0)"] + [[package]] name = "termcolor" version = "2.5.0" @@ -2684,4 +2722,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "426658f4df092427a58c0938a1ea7ed391cb211893dd30b5b9c712726f860471" +content-hash = "3e27635ba9fec8528f1bd7aea243882f66e58198c2bee4df29f6a7552c84b933" diff --git a/docs/pyproject.toml b/docs/pyproject.toml index 716682fe..ff6a82d1 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -11,8 +11,7 @@ package-mode = false python = "^3.10" mkdocs = "^1.6.1" mkdocs-material = {extras = ["imaging"], version = "^9.5.39"} -icechunk = {path = "../icechunk-python", develop = true} -mkdocstrings = {extras = ["python"], version = "^0.26.1"} +mkdocstrings = {extras = ["python"], version = "^0.26.2"} mkdocs-jupyter = "^0.25.0" mkdocs-awesome-pages-plugin = "^2.9.3" mkdocs-git-revision-date-localized-plugin = "^1.2.9" From e40636d4eba035d59c40353e89825dae9562d2da Mon Sep 17 00:00:00 2001 From: Joe Hamman Date: Mon, 14 Oct 2024 16:56:09 -0700 Subject: [PATCH 108/167] drop MIT license (#225) * drop MIT license * Update lock file --------- Co-authored-by: Sebastian Galkin --- Cargo.lock | 4 ++-- icechunk-python/Cargo.toml | 4 ++-- icechunk-python/pyproject.toml | 6 +++++- icechunk/Cargo.toml | 4 ++-- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d4c64965..4bbcccea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1191,7 +1191,7 @@ dependencies = [ [[package]] name = "icechunk" -version = "0.1.0-alpha.1" +version = "0.1.0-alpha.2" dependencies = [ "async-recursion", "async-stream", @@ -1226,7 +1226,7 @@ dependencies = [ [[package]] name = "icechunk-python" -version = "0.1.0-alpha.1" +version = "0.1.0-alpha.2" dependencies = [ "async-stream", "bytes", diff --git a/icechunk-python/Cargo.toml b/icechunk-python/Cargo.toml index 5422b081..0e422ace 100644 --- a/icechunk-python/Cargo.toml +++ b/icechunk-python/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "icechunk-python" -version = "0.1.0-alpha.1" +version = "0.1.0-alpha.2" description = "Transactional storage engine for Zarr designed for use on cloud object storage" readme = "../README.md" repository = "https://github.com/earth-mover/icechunk" homepage = "https://github.com/earth-mover/icechunk" -license = "MIT OR Apache-2.0" +license = "Apache-2.0" keywords = ["zarr", "xarray", "database"] categories = ["database", "science", "science::geo"] authors = ["Earthmover PBC"] diff --git a/icechunk-python/pyproject.toml b/icechunk-python/pyproject.toml index 5facea81..6c49e3c9 100644 --- a/icechunk-python/pyproject.toml +++ b/icechunk-python/pyproject.toml @@ -9,14 +9,18 @@ classifiers = [ "Programming Language :: Rust", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] +license = {text = "Apache-2.0"} dynamic = ["version"] dependencies = ["zarr==3.0.0b0"] [tool.poetry] name = "icechunk" -version = "0.1.0" +version = "0.1.0-alpha.2" description = "Icechunk Python" authors = ["Earthmover "] readme = "README.md" diff --git a/icechunk/Cargo.toml b/icechunk/Cargo.toml index 2f00b83f..6e27c86c 100644 --- a/icechunk/Cargo.toml +++ b/icechunk/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "icechunk" -version = "0.1.0-alpha.1" +version = "0.1.0-alpha.2" description = "Transactional storage engine for Zarr designed for use on cloud object storage" readme = "../README.md" repository = "https://github.com/earth-mover/icechunk" homepage = "https://github.com/earth-mover/icechunk" -license = "MIT OR Apache-2.0" +license = "Apache-2.0" keywords = ["zarr", "xarray", "database"] categories = ["database", "science", "science::geo"] authors = ["Earthmover PBC"] From 02c2f343de9f8a8eab6229b7298c40e72e1b81b8 Mon Sep 17 00:00:00 2001 From: Ryan Abernathey Date: Mon, 14 Oct 2024 20:57:52 -0400 Subject: [PATCH 109/167] Add Apache 2.0 license (#215) --- LICENSE | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..bf4a506c --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Earthmover PBC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From 04d94eb80d5b6dee9c884e51f213751dc51d3ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Galkin?= Date: Mon, 14 Oct 2024 22:14:19 -0300 Subject: [PATCH 110/167] Improve docstrings in IcechunkStore (#238) Co-authored-by: Matthew Iannucci --- icechunk-python/python/icechunk/__init__.py | 114 ++++++++++++++++---- 1 file changed, 95 insertions(+), 19 deletions(-) diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index 6c86394c..8c34fef2 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -38,6 +38,10 @@ class IcechunkStore(Store, SyncMixin): @classmethod async def open(cls, *args: Any, **kwargs: Any) -> Self: + """This method is called by zarr-python, it's not intended for users. + + Use one of `IcechunkStore.open_existing`, `IcechunkStore.create` or `IcechunkStore.open_or_create` instead. + """ return cls.open_or_create(*args, **kwargs) @classmethod @@ -90,7 +94,10 @@ def __init__( *args: Any, **kwargs: Any, ): - """Create a new IcechunkStore. This should not be called directly, instead use the create or open_existing class methods.""" + """Create a new IcechunkStore. + + This should not be called directly, instead use the `create`, `open_existing` or `open_or_create` class methods. + """ super().__init__(*args, mode=mode, **kwargs) if store is None: raise ValueError( @@ -147,10 +154,6 @@ def create( """Create a new IcechunkStore with the given storage configuration. If a store already exists at the given location, an error will be raised. - - It is recommended to use the cached storage option for better performance. If cached=True, - this will be configured automatically with the provided storage_config as the underlying - storage backend. """ config = config or StoreConfig() store = pyicechunk_store_create(storage, config=config) @@ -203,6 +206,19 @@ def snapshot_id(self) -> str: return self._store.snapshot_id def change_set_bytes(self) -> bytes: + """Get the complete list of changes applied in this session, serialized to bytes. + + This method is useful in combination with `IcechunkStore.distributed_commit`. When a + write session is too large to execute in a single machine, it could be useful to + distribute it across multiple workers. Each worker can write their changes independently + (map) and then a single commit is executed by a coordinator (reduce). + + This methods provides a way to send back to gather a "description" of the + changes applied by a worker. Resulting bytes, together with the `change_set_bytes` of + other workers, can be fed to `distributed_commit`. + + This API is subject to change, it will be replaced by a merge operation at the Store level. + """ return self._store.change_set_bytes() @property @@ -216,7 +232,12 @@ def checkout( branch: str | None = None, tag: str | None = None, ) -> None: - """Checkout a branch, tag, or specific snapshot.""" + """Checkout a branch, tag, or specific snapshot. + + If a branch is checked out, any following `commit` attempts will update that branch + reference if successful. If a tag or snapshot_id are checked out, the repository + won't allow commits. + """ if snapshot_id is not None: if branch is not None or tag is not None: raise ValueError( @@ -240,7 +261,12 @@ async def async_checkout( branch: str | None = None, tag: str | None = None, ) -> None: - """Checkout a branch, tag, or specific snapshot.""" + """Checkout a branch, tag, or specific snapshot. + + If a branch is checked out, any following `commit` attempts will update that branch + reference if successful. If a tag or snapshot_id are checked out, the repository + won't allow commits. + """ if snapshot_id is not None: if branch is not None or tag is not None: raise ValueError( @@ -262,7 +288,12 @@ def commit(self, message: str) -> str: """Commit any uncommitted changes to the store. This will create a new snapshot on the current branch and return - the snapshot id. + the new snapshot id. + + This method will fail if: + + * there is no currently checked out branch + * some other writer updated the curret branch since the repository was checked out """ return self._store.commit(message) @@ -270,18 +301,53 @@ async def async_commit(self, message: str) -> str: """Commit any uncommitted changes to the store. This will create a new snapshot on the current branch and return - the snapshot id. + the new snapshot id. + + This method will fail if: + + * there is no currently checked out branch + * some other writer updated the curret branch since the repository was checked out """ return await self._store.async_commit(message) def distributed_commit( self, message: str, other_change_set_bytes: list[bytes] ) -> str: + """Commit any uncommitted changes to the store with a set of distributed changes. + + This will create a new snapshot on the current branch and return + the new snapshot id. + + This method will fail if: + + * there is no currently checked out branch + * some other writer updated the curret branch since the repository was checked out + + other_change_set_bytes must be generated as the output of calling `change_set_bytes` + on other stores. The resulting commit will include changes from all stores. + + The behavior is undefined if the stores applied conflicting changes. + """ return self._store.distributed_commit(message, other_change_set_bytes) async def async_distributed_commit( self, message: str, other_change_set_bytes: list[bytes] ) -> str: + """Commit any uncommitted changes to the store with a set of distributed changes. + + This will create a new snapshot on the current branch and return + the new snapshot id. + + This method will fail if: + + * there is no currently checked out branch + * some other writer updated the curret branch since the repository was checked out + + other_change_set_bytes must be generated as the output of calling `change_set_bytes` + on other stores. The resulting commit will include changes from all stores. + + The behavior is undefined if the stores applied conflicting changes. + """ return await self._store.async_distributed_commit(message, other_change_set_bytes) @property @@ -298,27 +364,29 @@ def reset(self) -> None: return self._store.reset() async def async_new_branch(self, branch_name: str) -> str: - """Create a new branch from the current snapshot. This requires having no uncommitted changes.""" + """Create a new branch pointing to the current checked out snapshot. + + This requires having no uncommitted changes. + """ return await self._store.async_new_branch(branch_name) def new_branch(self, branch_name: str) -> str: - """Create a new branch from the current snapshot. This requires having no uncommitted changes.""" + """Create a new branch pointing to the current checked out snapshot. + + This requires having no uncommitted changes. + """ return self._store.new_branch(branch_name) def tag(self, tag_name: str, snapshot_id: str) -> None: - """Tag an existing snapshot with a given name.""" + """Create a tag pointing to the current checked out snapshot.""" return self._store.tag(tag_name, snapshot_id=snapshot_id) async def async_tag(self, tag_name: str, snapshot_id: str) -> None: - """Tag an existing snapshot with a given name.""" + """Create a tag pointing to the current checked out snapshot.""" return await self._store.async_tag(tag_name, snapshot_id=snapshot_id) def ancestry(self) -> list[SnapshotMetadata]: """Get the list of parents of the current version. - - Returns - ------- - AsyncGenerator[SnapshotMetadata, None] """ return self._store.ancestry() @@ -336,11 +404,19 @@ async def empty(self) -> bool: return await self._store.empty() async def clear(self) -> None: - """Clear the store.""" + """Clear the store. + + This will remove all contents from the current session, + including all groups and all arrays. But it will not modify the repository history. + """ return await self._store.clear() def sync_clear(self) -> None: - """Clear the store.""" + """Clear the store. + + This will remove all contents from the current session, + including all groups and all arrays. But it will not modify the repository history. + """ return self._store.sync_clear() async def get( From 9c2d33dd764e4af155f1bc94a4612886e7c7de2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Galkin?= Date: Mon, 14 Oct 2024 22:20:38 -0300 Subject: [PATCH 111/167] Update spec (#227) * Update spec * Update spec/icechunk-spec.md Co-authored-by: Ryan Abernathey * Update spec/icechunk-spec.md Co-authored-by: Ryan Abernathey * Update spec/icechunk-spec.md Co-authored-by: Ryan Abernathey * Update spec/icechunk-spec.md Co-authored-by: Ryan Abernathey * Update spec/icechunk-spec.md Co-authored-by: Ryan Abernathey * Update spec/icechunk-spec.md Co-authored-by: Ryan Abernathey * Update spec/icechunk-spec.md Co-authored-by: Ryan Abernathey --------- Co-authored-by: Ryan Abernathey --- spec/icechunk-spec.md | 904 ++++-------------------------------------- 1 file changed, 78 insertions(+), 826 deletions(-) diff --git a/spec/icechunk-spec.md b/spec/icechunk-spec.md index 30fb7958..506e4be9 100644 --- a/spec/icechunk-spec.md +++ b/spec/icechunk-spec.md @@ -56,11 +56,11 @@ Icechunk uses a series of linked metadata files to describe the state of the rep - The **Snapshot file** records all of the different arrays and groups in the repository, plus their metadata. Every new commit creates a new snapshot file. The snapshot file contains pointers to one or more chunk manifest files and [optionally] attribute files. - **Chunk manifests** store references to individual chunks. A single manifest may store references for multiple arrays or a subset of all the references for a single array. -- **Attributes files** provide a way to store additional user-defined attributes for arrays and groups outside of the structure file. This is important when the attributes are very large. +- **Attributes files** provide a way to store additional user-defined attributes for arrays and groups outside of the structure file. This is important if attributes are very large, otherwise, they will be stored inline in the snapshot file. - **Chunk files** store the actual compressed chunk data, potentially containing data for multiple chunks in a single file. - **Reference files** track the state of branches and tags, containing a lightweight pointer to a snapshot file. Transactions on a branch are committed by creating the next branch file in a sequence. -When reading from store, the client opens the latest branch or tag file to obtain a pointer to the relevant snapshot file. +When reading from object store, the client opens the latest branch or tag file to obtain a pointer to the relevant snapshot file. The client then reads the snapshot file to determine the structure and hierarchy of the repository. When fetching data from an array, the client first examines the chunk manifest file[s] for that array and finally fetches the chunks referenced therein. @@ -114,16 +114,16 @@ flowchart TD All data and metadata files are stored within a root directory (typically a prefix within an object store) using the following directory structure. - `$ROOT` base URI (s3, gcs, local directory, etc.) -- `$ROOT/r/` reference files -- `$ROOT/s/` snapshot files -- `$ROOT/a/` attribute files -- `$ROOT/m/` chunk manifests -- `$ROOT/c/` chunks +- `$ROOT/refs/` reference files +- `$ROOT/snapshots/` snapshot files +- `$ROOT/attributes/` attribute files +- `$ROOT/manifests/` chunk manifests +- `$ROOT/chunks/` chunks ### File Formats -> [!WARNING] -> The actual file formats used for each type of metadata file are in flux. The spec currently describes the data structures encoded in these files, rather than a specific file format. +!!! warning + The actual file formats used for each type of metadata file are in flux. The spec currently describes the data structures encoded in these files, rather than a specific file format. ### Reference Files @@ -134,7 +134,7 @@ These references point to a specific snapshot of the repository. - **Branches** are _mutable_ references to a snapshot. Repositories may have one or more branches. The default branch name is `main`. - Repositories must have a `main` branch. + Repositories must always have a `main` branch, which is used to detect the existence of a valid repository in a given path. After creation, branches may be updated to point to a different snapshot. - **Tags** are _immutable_ references to a snapshot. A repository may contain zero or more tags. @@ -142,47 +142,18 @@ These references point to a specific snapshot of the repository. References are very important in the Icechunk design. Creating or updating references is the point at which consistency and atomicity of Icechunk transactions is enforced. -Different client sessions may simultaneously create two inconsistent snapshots; however, only one session may successfully update a reference to that snapshot. +Different client sessions may simultaneously create two inconsistent snapshots; however, only one session may successfully update a reference to point it to its snapshot. -References (both branches and tags) are stored as JSON files with the following schema +References (both branches and tags) are stored as JSON files, the content is a JSON object with: + +* keys: a single key `"snapshot"`, +* value: a string representation of the snapshot id, using [Base 32 Crockford](https://www.crockford.com/base32.html) encoding. The snapshot id is 12 byte random binary, so the encoded string has 20 characters. + + +Here is an example of a JSON file corresponding to a tag or branch: ```json -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "RefData", - "type": "object", - "required": [ - "properties", - "snapshot", - "timestamp" - ], - "properties": { - "properties": { - "type": "object", - "additionalProperties": true - }, - "snapshot": { - "$ref": "#/definitions/ObjectId" - }, - "timestamp": { - "type": "string", - "format": "date-time" - } - }, - "definitions": { - "ObjectId": { - "description": "The id of a file in object store", - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "maxItems": 16, - "minItems": 16 - } - } -} +{"snapshot":"VY76P925PRY57WFEK410"} ``` #### Creating and Updating Branches @@ -193,10 +164,7 @@ The client creates a new snapshot and then updates the branch reference to point However, when updating the branch reference, the client must detect whether a _different session_ has updated the branch reference in the interim, possibly retrying or failing the commit if so. This is an "optimistic concurrency" strategy; the resolution mechanism can be expensive, but conflicts are expected to be infrequent. -The simplest way to do this would be to store the branch reference in a specific file (e.g. `main.json`) and update it via an atomic "compare and swap" operation. -Unfortunately not all popular object stores support this operation (AWS S3 notably does not). - -However, all popular object stores _do_ support a comparable operation: "create if not exists". +All popular object stores support a "create if not exists" operation. In other words, object stores can guard against the race condition which occurs when two sessions attempt to create the same file at the same time. This motivates the design of Icechunk's branch file naming convention. @@ -211,7 +179,7 @@ If this succeeds, the commit is successful. If this fails (because another client created that file already), the commit fails. At this point, the client may choose to retry its commit (possibly re-reading the updated data) and then create sequence number _N + 2_. -Branch references are stored in the `r/` directory within a subdirectory corresponding to the branch name: `r/$BRANCH_NAME/`. +Branch references are stored in the `refs/` directory within a subdirectory corresponding to the branch name prepended by the string `branch.`: `refs/branch.$BRANCH_NAME/`. Branch names may not contain the `/` character. To facilitate easy lookups of the latest branch reference, we use the following encoding for the sequence number: @@ -220,18 +188,20 @@ To facilitate easy lookups of the latest branch reference, we use the following - left-padding the string with 0s to a length of 8 characters This produces a deterministic sequence of branch file names in which the latest sequence always appears first when sorted lexicographically, facilitating easy lookup by listing the object store. -The full branch file name is then given by `r/$BRANCH_NAME/$ENCODED_SEQUENCE.json`. +The full branch file name is then given by `refs/branch.$BRANCH_NAME/$ENCODED_SEQUENCE.json`. -For example, the first main branch file is in a store, corresponding with sequence number 0, is always named `r/main/ZZZZZZZZ.json`. -The branch file for sequence number 100 is `r/main/ZZZZZZWV.json`. +For example, the first main branch file is in a store, corresponding with sequence number 0, is always named `refs/branch.main/ZZZZZZZZ.json`. +The branch file for sequence number 100 is `refs/branch.main/ZZZZZZWV.json`. The maximum number of commits allowed in an Icechunk repository is consequently `1099511627775`, -corresponding to the state file `r/main/00000000.json`. +corresponding to the state file `refs/branch.main/00000000.json`. #### Tags Since tags are immutable, they are simpler than branches. -Tag files follow the pattern `r/$TAG_NAME.json`. +Tag files follow the pattern `refs/tag.$TAG_NAME/ref.json`. + +Tag names may not contain the `/` character. When creating a new tag, the client attempts to create the tag file using a "create if not exists" operation. If successful, the tag is created successful. @@ -243,639 +213,40 @@ Tags cannot be deleted once created. The snapshot file fully describes the schema of the repository, including all arrays and groups. -The snapshot file has the following JSON schema: +The snapshot file is currently encoded using [MessagePack](https://msgpack.org/), but this may change before Icechunk version 1.0. Given the alpha status of this spec, the best way to understand the information stored +in the snapshot file is through the data structure used internally by the Icechunk library for serialization. This data structure will most certainly change before the spec stabilization: -
-JSON Schema for Snapshot File +```rust +pub struct Snapshot { + pub icechunk_snapshot_format_version: IcechunkFormatVersion, + pub icechunk_snapshot_format_flags: BTreeMap, -```json -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Snapshot", - "type": "object", - "required": [ - "metadata", - "nodes", - "properties", - "short_term_history", - "short_term_parents", - "started_at", - "total_parents" - ], - "properties": { - "metadata": { - "$ref": "#/definitions/SnapshotMetadata" - }, - "nodes": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/NodeSnapshot" - } - }, - "properties": { - "type": "object", - "additionalProperties": true - }, - "short_term_history": { - "type": "array", - "items": { - "$ref": "#/definitions/SnapshotMetadata" - } - }, - "short_term_parents": { - "type": "integer", - "format": "uint16", - "minimum": 0.0 - }, - "started_at": { - "type": "string", - "format": "date-time" - }, - "total_parents": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - } - }, - "definitions": { - "ChunkIndices": { - "description": "An ND index to an element in a chunk grid.", - "type": "array", - "items": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - }, - "ChunkKeyEncoding": { - "type": "string", - "enum": [ - "Slash", - "Dot", - "Default" - ] - }, - "ChunkShape": { - "type": "array", - "items": { - "type": "integer", - "format": "uint64", - "minimum": 1.0 - } - }, - "Codec": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "configuration": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "name": { - "type": "string" - } - } - }, - "DataType": { - "oneOf": [ - { - "type": "string", - "enum": [ - "bool", - "int8", - "int16", - "int32", - "int64", - "uint8", - "uint16", - "uint32", - "uint64", - "float16", - "float32", - "float64", - "complex64", - "complex128" - ] - }, - { - "type": "object", - "required": [ - "rawbits" - ], - "properties": { - "rawbits": { - "type": "integer", - "format": "uint", - "minimum": 0.0 - } - }, - "additionalProperties": false - } - ] - }, - "FillValue": { - "oneOf": [ - { - "type": "object", - "required": [ - "Bool" - ], - "properties": { - "Bool": { - "type": "boolean" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Int8" - ], - "properties": { - "Int8": { - "type": "integer", - "format": "int8" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Int16" - ], - "properties": { - "Int16": { - "type": "integer", - "format": "int16" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Int32" - ], - "properties": { - "Int32": { - "type": "integer", - "format": "int32" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Int64" - ], - "properties": { - "Int64": { - "type": "integer", - "format": "int64" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "UInt8" - ], - "properties": { - "UInt8": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "UInt16" - ], - "properties": { - "UInt16": { - "type": "integer", - "format": "uint16", - "minimum": 0.0 - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "UInt32" - ], - "properties": { - "UInt32": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "UInt64" - ], - "properties": { - "UInt64": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Float16" - ], - "properties": { - "Float16": { - "type": "number", - "format": "float" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Float32" - ], - "properties": { - "Float32": { - "type": "number", - "format": "float" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Float64" - ], - "properties": { - "Float64": { - "type": "number", - "format": "double" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Complex64" - ], - "properties": { - "Complex64": { - "type": "array", - "items": [ - { - "type": "number", - "format": "float" - }, - { - "type": "number", - "format": "float" - } - ], - "maxItems": 2, - "minItems": 2 - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Complex128" - ], - "properties": { - "Complex128": { - "type": "array", - "items": [ - { - "type": "number", - "format": "double" - }, - { - "type": "number", - "format": "double" - } - ], - "maxItems": 2, - "minItems": 2 - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "RawBits" - ], - "properties": { - "RawBits": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - } - } - }, - "additionalProperties": false - } - ] - }, - "Flags": { - "type": "array", - "items": [], - "maxItems": 0, - "minItems": 0 - }, - "ManifestExtents": { - "type": "array", - "items": { - "$ref": "#/definitions/ChunkIndices" - } - }, - "ManifestRef": { - "type": "object", - "required": [ - "extents", - "flags", - "object_id" - ], - "properties": { - "extents": { - "$ref": "#/definitions/ManifestExtents" - }, - "flags": { - "$ref": "#/definitions/Flags" - }, - "object_id": { - "$ref": "#/definitions/ObjectId" - } - } - }, - "NodeData": { - "oneOf": [ - { - "type": "string", - "enum": [ - "Group" - ] - }, - { - "type": "object", - "required": [ - "Array" - ], - "properties": { - "Array": { - "type": "array", - "items": [ - { - "$ref": "#/definitions/ZarrArrayMetadata" - }, - { - "type": "array", - "items": { - "$ref": "#/definitions/ManifestRef" - } - } - ], - "maxItems": 2, - "minItems": 2 - } - }, - "additionalProperties": false - } - ] - }, - "NodeSnapshot": { - "type": "object", - "required": [ - "id", - "node_data", - "path" - ], - "properties": { - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "node_data": { - "$ref": "#/definitions/NodeData" - }, - "path": { - "type": "string" - }, - "user_attributes": { - "anyOf": [ - { - "$ref": "#/definitions/UserAttributesSnapshot" - }, - { - "type": "null" - } - ] - } - } - }, - "ObjectId": { - "description": "The id of a file in object store", - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "maxItems": 16, - "minItems": 16 - }, - "SnapshotMetadata": { - "type": "object", - "required": [ - "id", - "message", - "written_at" - ], - "properties": { - "id": { - "$ref": "#/definitions/ObjectId" - }, - "message": { - "type": "string" - }, - "written_at": { - "type": "string", - "format": "date-time" - } - } - }, - "StorageTransformer": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "configuration": { - "type": [ - "object", - "null" - ], - "additionalProperties": true - }, - "name": { - "type": "string" - } - } - }, - "UserAttributes": { - "type": "object" - }, - "UserAttributesRef": { - "type": "object", - "required": [ - "flags", - "location", - "object_id" - ], - "properties": { - "flags": { - "$ref": "#/definitions/Flags" - }, - "location": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "object_id": { - "$ref": "#/definitions/ObjectId" - } - } - }, - "UserAttributesSnapshot": { - "oneOf": [ - { - "type": "object", - "required": [ - "Inline" - ], - "properties": { - "Inline": { - "$ref": "#/definitions/UserAttributes" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Ref" - ], - "properties": { - "Ref": { - "$ref": "#/definitions/UserAttributesRef" - } - }, - "additionalProperties": false - } - ] - }, - "ZarrArrayMetadata": { - "type": "object", - "required": [ - "chunk_key_encoding", - "chunk_shape", - "codecs", - "data_type", - "fill_value", - "shape" - ], - "properties": { - "chunk_key_encoding": { - "$ref": "#/definitions/ChunkKeyEncoding" - }, - "chunk_shape": { - "$ref": "#/definitions/ChunkShape" - }, - "codecs": { - "type": "array", - "items": { - "$ref": "#/definitions/Codec" - } - }, - "data_type": { - "$ref": "#/definitions/DataType" - }, - "dimension_names": { - "type": [ - "array", - "null" - ], - "items": { - "type": [ - "string", - "null" - ] - } - }, - "fill_value": { - "$ref": "#/definitions/FillValue" - }, - "shape": { - "type": "array", - "items": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - }, - "storage_transformers": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/StorageTransformer" - } - } - } - } - } + pub manifest_files: Vec, + pub attribute_files: Vec, + + pub total_parents: u32, + pub short_term_parents: u16, + pub short_term_history: VecDeque, + + pub metadata: SnapshotMetadata, + pub started_at: DateTime, + pub properties: SnapshotProperties, + nodes: BTreeMap, } ``` -
+To get full details on what each field contains, please refer to the [Icechunk library code](https://github.com/earth-mover/icechunk/blob/f460a56577ec560c4debfd89e401a98153cd3560/icechunk/src/format/snapshot.rs#L97). + ### Attributes Files Attribute files hold user-defined attributes separately from the snapshot file. -> [!WARNING] -> Attribute files have not been implemented. +!!! warning + Attribute files have not been implemented. + +The on-disk format for attribute files has not been defined yet, but it will probably be a +MessagePack serialization of the attributes map. ### Chunk Manifest Files @@ -883,133 +254,31 @@ A chunk manifest file stores chunk references. Chunk references from multiple arrays can be stored in the same chunk manifest. The chunks from a single array can also be spread across multiple manifests. -
-JSON Schema for Chunk Manifest Files +Manifest files are currently encoded using [MessagePack](https://msgpack.org/), but this may change before Icechunk version 1.0. Given the alpha status of this spec, the best way to understand the information stored +in the snapshot file is through the data structure used internally by the Icechunk library. This data structure will most certainly change before the spec stabilization: -```json -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Manifest", - "type": "object", - "required": [ - "chunks" - ], - "properties": { - "chunks": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/ChunkPayload" - } - } - }, - "definitions": { - "ChunkPayload": { - "oneOf": [ - { - "type": "object", - "required": [ - "Inline" - ], - "properties": { - "Inline": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - } - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Virtual" - ], - "properties": { - "Virtual": { - "$ref": "#/definitions/VirtualChunkRef" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "Ref" - ], - "properties": { - "Ref": { - "$ref": "#/definitions/ChunkRef" - } - }, - "additionalProperties": false - } - ] - }, - "ChunkRef": { - "type": "object", - "required": [ - "id", - "length", - "offset" - ], - "properties": { - "id": { - "$ref": "#/definitions/ObjectId" - }, - "length": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "offset": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - } - }, - "ObjectId": { - "description": "The id of a file in object store", - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "maxItems": 16, - "minItems": 16 - }, - "VirtualChunkRef": { - "type": "object", - "required": [ - "length", - "location", - "offset" - ], - "properties": { - "length": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "location": { - "type": "string" - }, - "offset": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - } - } - } - } +```rust +pub struct Manifest { + pub icechunk_manifest_format_version: IcechunkFormatVersion, + pub icechunk_manifest_format_flags: BTreeMap, + chunks: BTreeMap<(NodeId, ChunkIndices), ChunkPayload>, +} + +pub enum ChunkPayload { + Inline(Bytes), + Virtual(VirtualChunkRef), + Ref(ChunkRef), } ``` -
+The most important part to understand from the data structure is the fact that manifests can hold three types of references: + +* Native (`Ref`), pointing to the id of a chunk within the Icechunk repository. +* Inline (`Inline`), an optimization for very small chunks that can be embedded directly in the manifest. Mostly used for coordinate arrays. +* Virtual (`Virtual`), pointing to a region of a file outside of the Icechunk repository, for example, + a chunk that is inside a NetCDF file in object store + +To get full details on what each field contains, please refer to the [Icechunk library code](https://github.com/earth-mover/icechunk/blob/f460a56577ec560c4debfd89e401a98153cd3560/icechunk/src/format/manifest.rs#L106). ### Chunk Files @@ -1045,30 +314,26 @@ If the specific snapshot ID is known, a client can open it directly in read only Usually, a client will want to read from the latest branch (e.g. `main`). -1. List the object store prefix `r/$BRANCH_NAME/` to obtain the latest branch file in the sequence. Due to the encoding of the sequence number, this should be the _first file_ in lexicographical order. -1. Read the branch file to obtain the snapshot ID. +1. List the object store prefix `refs/branch.$BRANCH_NAME/` to obtain the latest branch file in the sequence. Due to the encoding of the sequence number, this should be the _first file_ in lexicographical order. +1. Read the branch file JSON contents to obtain the snapshot ID. 1. Use the shapshot ID to fetch the snapshot file. 1. Fetch desired attributes and values from arrays. #### From Tag -Opening a repository from a tag results in a read-only view. - -1. Read the tag file found at `r/$TAG_NAME.json` to obtain the snapshot ID. +1. Read the tag file found at `refs/tag.$TAG_NAME/ref.json` to obtain the snapshot ID. 1. Use the shapshot ID to fetch the snapshot file. 1. Fetch desired attributes and values from arrays. ### Write New Snapshot -Writing can only be done on a branch. - 1. Open a repository at a specific branch as described above, keeping track of the sequence number and branch name in the session context. 1. [optional] Write new chunk files. 1. [optional] Write new chunk manifests. 1. Write a new snapshot file. 1. Attempt to write the next branch file in the sequence - a. If successful, the commit succeeded and the branch is updated. - b. If unsuccessful, attempt to reconcile and retry the commit. + 1. If successful, the commit succeeded and the branch is updated. + 1. If unsuccessful, attempt to reconcile and retry the commit. ### Create New Tag @@ -1079,16 +344,3 @@ A tag can be created from any snapshot. a. If successful, the tag was created. b. If unsuccessful, the tag already exists. -## Appendices - -### Comparison with Iceberg - -Like Iceberg, Icechunk uses a series of linked metadata files to describe the state of the repository. -But while Iceberg describes a table, an Icechunk repository is a Zarr store (hierarchical structure of Arrays and Groups). - -| Iceberg Entity | Icechunk Entity | Comment | -|--|--|--| -| Table | Repository | The fundamental entity described by the spec | -| Column | Array | The logical container for a homogenous collection of values | -| Snapshot | Snapshot | A single committed snapshot of the repository | -| Catalog | N/A | There is no concept of a catalog in Icechunk. Consistency is provided by the object store. | From cc4ee75988ba7a6a15cc3c2b3a20b5a83212a0d0 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Mon, 14 Oct 2024 22:29:34 -0400 Subject: [PATCH 112/167] Fix virtual test by linking to permanent objects (#241) --- icechunk-python/tests/test_virtual_ref.py | 15 ++++++--------- icechunk/tests/test_virtual_refs.rs | 16 ++++++++-------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/icechunk-python/tests/test_virtual_ref.py b/icechunk-python/tests/test_virtual_ref.py index 3ad6ca77..94dee971 100644 --- a/icechunk-python/tests/test_virtual_ref.py +++ b/icechunk-python/tests/test_virtual_ref.py @@ -105,25 +105,22 @@ async def test_from_s3_public_virtual_refs(tmpdir): ) root = zarr.Group.from_store(store=store, zarr_format=3) depth = root.require_array( - name="depth", shape=((22, )), chunk_shape=((22,)), dtype="float64" + name="depth", shape=((10, )), chunk_shape=((10,)), dtype="float64" ) store.set_virtual_ref( "depth/c/0", - "s3://noaa-nos-ofs-pds/dbofs/netcdf/202410/dbofs.t00z.20241012.regulargrid.f030.nc", - offset=42499, - length=176 + "s3://noaa-nos-ofs-pds/dbofs/netcdf/202410/dbofs.t00z.20241009.fields.f030.nc", + offset=119339, + length=80 ) nodes = [n async for n in store.list()] assert "depth/c/0" in nodes depth_values = depth[:] - assert len(depth_values) == 22 - actual_values = np.array([ - 0., 1., 2., 4., 6., 8., 10., 12., 15., 20., 25., - 30., 35., 40., 45., 50., 60., 70., 80., 90., 100., 125. - ]) + assert len(depth_values) == 10 + actual_values = np.array([-0.95,-0.85,-0.75,-0.65,-0.55,-0.45,-0.35,-0.25,-0.15,-0.05]) assert np.allclose(depth_values, actual_values) diff --git a/icechunk/tests/test_virtual_refs.rs b/icechunk/tests/test_virtual_refs.rs index b4bc60ff..13bbb418 100644 --- a/icechunk/tests/test_virtual_refs.rs +++ b/icechunk/tests/test_virtual_refs.rs @@ -429,27 +429,27 @@ mod tests { .await .unwrap(); - let zarr_meta = Bytes::copy_from_slice(br#"{"zarr_format":3,"node_type":"array","attributes":{"foo":42},"shape":[22],"data_type":"float64","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[22]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value": 0.0,"codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[],"dimension_names":["depth"]}"#); + let zarr_meta = Bytes::copy_from_slice(br#"{"zarr_format":3,"node_type":"array","attributes":{"foo":42},"shape":[10],"data_type":"float64","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[10]}},"chunk_key_encoding":{"name":"default","configuration":{"separator":"/"}},"fill_value": 0.0,"codecs":[{"name":"mycodec","configuration":{"foo":42}}],"storage_transformers":[],"dimension_names":["depth"]}"#); store.set("depth/zarr.json", zarr_meta.clone()).await.unwrap(); let ref2 = VirtualChunkRef { location: VirtualChunkLocation::from_absolute_path( - "s3://noaa-nos-ofs-pds/dbofs/netcdf/202410/dbofs.t00z.20241012.regulargrid.f030.nc", + "s3://noaa-nos-ofs-pds/dbofs/netcdf/202410/dbofs.t00z.20241009.fields.f030.nc", )?, - offset: 42499, - length: 176, + offset: 119339, + length: 80, }; store.set_virtual_ref("depth/c/0", ref2).await?; let chunk = store.get("depth/c/0", &ByteRange::ALL).await.unwrap(); - assert_eq!(chunk.len(), 176); + assert_eq!(chunk.len(), 80); let second_depth = f64::from_le_bytes(chunk[8..16].try_into().unwrap()); - assert!(second_depth - 1. < 0.000001); + assert!(second_depth - -0.85 < 0.000001); - let last_depth = f64::from_le_bytes(chunk[(176 - 8)..].try_into().unwrap()); - assert!(last_depth - 125. < 0.000001); + let last_depth = f64::from_le_bytes(chunk[(80 - 8)..].try_into().unwrap()); + assert!(last_depth - -0.05 < 0.000001); Ok(()) } From 257df09a366a5741e25c983ce7552014fecfb090 Mon Sep 17 00:00:00 2001 From: Tom Nicholas Date: Mon, 14 Oct 2024 20:33:34 -0600 Subject: [PATCH 113/167] [DOCS] FAQ: More accurate description of relationship to VirtualiZarr (#239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * more accurate description of relationship to VirtualiZarr * capitalize every package * split into multiple lines --------- Co-authored-by: Ryan Abernathey Co-authored-by: Sebastián Galkin --- docs/docs/faq.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/docs/faq.md b/docs/docs/faq.md index 4c490036..783d7465 100644 --- a/docs/docs/faq.md +++ b/docs/docs/faq.md @@ -338,9 +338,9 @@ Kerchunk and Icechunk are highly complimentary. > VirtualiZarr creates virtual Zarr stores for cloud-friendly access to archival data, using familiar Xarray syntax. -VirtualiZarr is another way of generating and manipulating Kerchunk-style references. -Icechunk provides a highly efficient and scalable mechanism for storing and tracking the references generated by VirtualiZarr. -Kerchunk and VirtualiZarr are highly complimentary. +VirtualiZarr provides another way of generating and manipulating Kerchunk-style references. +VirtualiZarr first uses Kerchunk to generate virtual references, but then provides a simple Xarray-based interface for manipulating those references. +As VirtualiZarr can also write virtual references into an Icechunk Store directly, together they form a complete pipeline for generating and storing references to multiple pre-existing files. #### [LakeFS](https://lakefs.io/) From 5fcf1a9a637c8a39a811c34e3bafc0f9eec29fea Mon Sep 17 00:00:00 2001 From: Orestis Herodotou Date: Mon, 14 Oct 2024 19:56:31 -0700 Subject: [PATCH 114/167] Adjust when we ignore checks from running (#229) --- .github/workflows/python-check.yaml | 2 +- .github/workflows/rust-ci.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-check.yaml b/.github/workflows/python-check.yaml index 60139e26..7526519e 100644 --- a/.github/workflows/python-check.yaml +++ b/.github/workflows/python-check.yaml @@ -4,9 +4,9 @@ on: push: branches: - main + pull_request: paths-ignore: - 'docs/**' - pull_request: workflow_dispatch: concurrency: diff --git a/.github/workflows/rust-ci.yaml b/.github/workflows/rust-ci.yaml index 189dddd6..c27d80d3 100644 --- a/.github/workflows/rust-ci.yaml +++ b/.github/workflows/rust-ci.yaml @@ -5,11 +5,11 @@ name: Rust CI on: pull_request: types: [opened, reopened, synchronize, labeled] + paths-ignore: + - 'docs/**' push: branches: - main - paths-ignore: - - 'docs/**' concurrency: group: ${{ github.workflow }}-${{ github.ref }} From c380c3770cb7f42afd6bfb5189dab58430aebfa7 Mon Sep 17 00:00:00 2001 From: Joe Hamman Date: Mon, 14 Oct 2024 19:59:09 -0700 Subject: [PATCH 115/167] update xarray branch (#243) --- docs/docs/icechunk-python/xarray.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/icechunk-python/xarray.md b/docs/docs/icechunk-python/xarray.md index a5bfe60b..aff4b7c4 100644 --- a/docs/docs/icechunk-python/xarray.md +++ b/docs/docs/icechunk-python/xarray.md @@ -9,7 +9,7 @@ and [`xarray.Dataset.to_zarr`](https://docs.xarray.dev/en/latest/generated/xarra Using Xarray and Icechunk together currently requires installing Xarray from source. ```shell - pip install git+https://github.com/TomAugspurger/xarray/@fix/zarr-v3 + pip install git+https://github.com/pydata/xarray/@zarr-v3 ``` We expect this functionality to be included in Xarray's next release. From 22f69d1c15cfc89ae20651a8e2b391fafe869394 Mon Sep 17 00:00:00 2001 From: Orestis Herodotou Date: Mon, 14 Oct 2024 20:07:13 -0700 Subject: [PATCH 116/167] Show cookie consent message (#228) Co-authored-by: Joe Hamman --- docs/docs/stylesheets/global.css | 9 ++++++++- docs/mkdocs.yml | 7 +++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/docs/stylesheets/global.css b/docs/docs/stylesheets/global.css index 3467cda6..6a302f3a 100644 --- a/docs/docs/stylesheets/global.css +++ b/docs/docs/stylesheets/global.css @@ -1,5 +1,6 @@ /* Global Adjustments */ +/* Adjust spacing between logo and header */ [dir=ltr] .md-header__title { margin-left: 0px; } @@ -9,4 +10,10 @@ TODO: find a way to show all pages in left sidebar .md-nav--lifted>.md-nav__list>.md-nav__item, .md-nav--lifted>.md-nav__title { display:block; } - */ \ No newline at end of file + */ + + +/* Remove cookie consent overlay */ +.md-consent__overlay { + display:none; +} \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 06e3237b..f0a0c0bc 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -93,6 +93,13 @@ extra: note: >- Thanks for your feedback! Help us improve this page by using our feedback form. + consent: + title: Cookie consent + description: >- + We use cookies to recognize your repeated visits and preferences, as well + as to measure the effectiveness of our documentation and whether users + find what they're searching for. With your consent, you're helping us to + make our documentation better. plugins: #- mike # TODO: https://squidfunk.github.io/mkdocs-material/setup/setting-up-versioning/ From 0bf074833b42d23d526139f1503882c647d021b9 Mon Sep 17 00:00:00 2001 From: Joe Hamman Date: Mon, 14 Oct 2024 20:15:44 -0700 Subject: [PATCH 117/167] add CNAME to docs dir (#245) --- docs/CNAME | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/CNAME diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 00000000..a58d1b90 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +icechunk.io \ No newline at end of file From c8be10ce1b2eb17a206b7aced7689d4f3431565b Mon Sep 17 00:00:00 2001 From: Joe Hamman Date: Mon, 14 Oct 2024 20:22:22 -0700 Subject: [PATCH 118/167] fix typo in xarray url (#244) --- docs/docs/icechunk-python/xarray.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/icechunk-python/xarray.md b/docs/docs/icechunk-python/xarray.md index aff4b7c4..bcd479fe 100644 --- a/docs/docs/icechunk-python/xarray.md +++ b/docs/docs/icechunk-python/xarray.md @@ -9,7 +9,7 @@ and [`xarray.Dataset.to_zarr`](https://docs.xarray.dev/en/latest/generated/xarra Using Xarray and Icechunk together currently requires installing Xarray from source. ```shell - pip install git+https://github.com/pydata/xarray/@zarr-v3 + pip install git+https://github.com/pydata/xarray@zarr-v3 ``` We expect this functionality to be included in Xarray's next release. From b7e0dd7ebce5591179b89b3fdb8971d83dd978fd Mon Sep 17 00:00:00 2001 From: Orestis Herodotou Date: Mon, 14 Oct 2024 20:23:43 -0700 Subject: [PATCH 119/167] update icechunk logo in the readme (#231) Co-authored-by: Joe Hamman --- README.md | 2 +- icechunk_logo.png | Bin 120415 -> 0 bytes 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 icechunk_logo.png diff --git a/README.md b/README.md index 6f7a3489..ccf69f22 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Icechunk -![Icechunk logo](icechunk_logo.png) +![Icechunk logo](docs/docs/assets/logo.svg) Icechunk is a transactional storage engine for Zarr designed for use on cloud object storage. diff --git a/icechunk_logo.png b/icechunk_logo.png deleted file mode 100644 index 349d2e46e7901eada624a77629d22b2df0fb5260..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 120415 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
|Vj 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ʯ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 q*^)57(>-FB&ams22{DaR+Wucu_3 Date: Mon, 14 Oct 2024 20:41:42 -0700 Subject: [PATCH 120/167] update ci path filters (#247) --- .github/workflows/python-check.yaml | 6 ++++-- .github/workflows/rust-ci.yaml | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-check.yaml b/.github/workflows/python-check.yaml index 7526519e..464988a7 100644 --- a/.github/workflows/python-check.yaml +++ b/.github/workflows/python-check.yaml @@ -5,8 +5,10 @@ on: branches: - main pull_request: - paths-ignore: - - 'docs/**' + paths: + - 'icechunk/**' + - 'icechunk-python/**' + - '.github/workflows/python-check.yaml' workflow_dispatch: concurrency: diff --git a/.github/workflows/rust-ci.yaml b/.github/workflows/rust-ci.yaml index c27d80d3..34fd7075 100644 --- a/.github/workflows/rust-ci.yaml +++ b/.github/workflows/rust-ci.yaml @@ -5,8 +5,9 @@ name: Rust CI on: pull_request: types: [opened, reopened, synchronize, labeled] - paths-ignore: - - 'docs/**' + paths: + - 'icechunk/**' + - '.github/workflows/rust-ci.yaml' push: branches: - main From 70f441c490b7e5b05d6ae0413dd60872481827d4 Mon Sep 17 00:00:00 2001 From: Orestis Herodotou Date: Mon, 14 Oct 2024 20:45:28 -0700 Subject: [PATCH 121/167] move spec into docs (#226) --- docs/.gitignore | 1 - spec/icechunk-spec.md => docs/docs/spec.md | 1 - docs/macros.py | 1 - 3 files changed, 3 deletions(-) rename spec/icechunk-spec.md => docs/docs/spec.md (99%) diff --git a/docs/.gitignore b/docs/.gitignore index d6abdd64..9fe706f9 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -6,7 +6,6 @@ __pycache__/ # imported files docs/icechunk-python/examples docs/icechunk-python/notebooks -docs/spec.md # C extensions *.so diff --git a/spec/icechunk-spec.md b/docs/docs/spec.md similarity index 99% rename from spec/icechunk-spec.md rename to docs/docs/spec.md index 506e4be9..cb942a94 100644 --- a/spec/icechunk-spec.md +++ b/docs/docs/spec.md @@ -343,4 +343,3 @@ A tag can be created from any snapshot. 1. Attempt to create the tag file. a. If successful, the tag was created. b. If unsuccessful, the tag already exists. - diff --git a/docs/macros.py b/docs/macros.py index d904d381..925cc213 100644 --- a/docs/macros.py +++ b/docs/macros.py @@ -18,7 +18,6 @@ def symlink_external_dirs(): external_sources = { monorepo_root / 'icechunk-python' / 'notebooks' : docs_dir / 'icechunk-python' / 'notebooks', monorepo_root / 'icechunk-python' / 'examples' : docs_dir / 'icechunk-python' / 'examples', - monorepo_root / 'spec' / 'icechunk-spec.md' : docs_dir / 'spec.md', } for src, target in external_sources.items(): From f8dfe22f42761fe9c39896608716856a4089ae9e Mon Sep 17 00:00:00 2001 From: Joe Hamman Date: Mon, 14 Oct 2024 20:46:49 -0700 Subject: [PATCH 122/167] Fix/doc cname (#246) * add CNAME to docs dir * move cname to docs/docs --- docs/docs/CNAME | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/docs/CNAME diff --git a/docs/docs/CNAME b/docs/docs/CNAME new file mode 100644 index 00000000..a58d1b90 --- /dev/null +++ b/docs/docs/CNAME @@ -0,0 +1 @@ +icechunk.io \ No newline at end of file From 80fba38a21a3bae5e86e495b3c943438f4bf0615 Mon Sep 17 00:00:00 2001 From: Orestis Herodotou Date: Mon, 14 Oct 2024 20:51:39 -0700 Subject: [PATCH 123/167] fix(docs): update homepage badges (#230) * update homepage badges * add slack --- docs/mkdocs.yml | 2 ++ docs/overrides/home.html | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index f0a0c0bc..3775b1b0 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -70,6 +70,8 @@ extra: link: https://pypi.org/project/icechunk/ - icon: fontawesome/brands/rust link: https://crates.io/crates/icechunk + - icon: fontawesome/brands/slack + link: https://join.slack.com/t/earthmover-community/shared_invite/zt-2cwje92ir-xU3CfdG8BI~4CJOJy~sceQ - icon: fontawesome/brands/x-twitter link: https://x.com/earthmoverhq generator: false diff --git a/docs/overrides/home.html b/docs/overrides/home.html index 13bde307..9a53a308 100644 --- a/docs/overrides/home.html +++ b/docs/overrides/home.html @@ -34,9 +34,10 @@

{{ config.site_description }}

-
+
+
+
+
+
+
+
+
{% endblock %} diff --git a/docs/overrides/partials/cube.html b/docs/overrides/partials/cube.html index 528922e2..b2756cc9 100644 --- a/docs/overrides/partials/cube.html +++ b/docs/overrides/partials/cube.html @@ -21,14 +21,14 @@ inkscape:deskcolor="#d1d1d1" inkscape:document-units="px" inkscape:zoom="10.295388" - inkscape:cx="64.68916" + inkscape:cx="64.640594" inkscape:cy="88.923312" inkscape:window-width="2560" inkscape:window-height="2091" inkscape:window-x="2240" inkscape:window-y="32" inkscape:window-maximized="0" - inkscape:current-layer="cube-logo" + inkscape:current-layer="g110-0" showgrid="true"> + sodipodi:nodetypes="ccccc" + class="no-pointer" /> + sodipodi:nodetypes="ccccc" + class="no-pointer" /> + sodipodi:nodetypes="ccccc" + class="no-pointer" /> + inkscape:label="water-top-back" + class="no-pointer" /> + inkscape:label="ripple-2-front" + class="no-pointer" /> + inkscape:label="ripple-1-front" + class="no-pointer" /> + transform="translate(27.772249,29.348577)" + style="display:inline"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + transform="translate(17.584829,21.826753)" + class="small-cube"> + transform="translate(17.584829,21.826753)" + class="small-cube"> + transform="translate(17.584829,21.826753)" + class="small-cube"> + transform="translate(17.584829,21.826753)" + class="small-cube"> + transform="translate(17.584829,21.826753)" + class="small-cube"> + transform="translate(17.584829,21.826753)" + class="small-cube"> + transform="translate(17.584829,21.826753)" + class="small-cube"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + transform="translate(17.584829,21.826753)" + class="small-cube"> + transform="translate(17.584829,21.826753)" + class="small-cube"> + transform="translate(17.584829,21.826753)" + class="small-cube"> + transform="translate(17.584829,21.826753)" + class="small-cube"> + transform="translate(17.584829,21.826753)" + class="small-cube"> + transform="translate(17.584829,21.826753)" + class="small-cube"> + transform="translate(17.584829,21.826753)" + class="small-cube"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + transform="translate(17.584829,21.826753)" + class="small-cube"> + transform="translate(17.584829,21.826753)" + class="small-cube"> + transform="translate(17.584829,21.826753)" + class="small-cube"> + transform="translate(17.584829,21.826753)" + class="small-cube"> + transform="translate(17.584829,21.826753)" + class="small-cube"> + transform="translate(17.584829,21.826753)" + class="small-cube"> + transform="translate(17.584829,21.826753)" + class="small-cube"> + inkscape:label="top-ice" + class="no-pointer" + style="display:inline"> + transform="translate(27.772249,27.348574)" + class="no-pointer"> + inkscape:label="water-top-front" + class="no-pointer" /> + sodipodi:nodetypes="ccc" + class="no-pointer" /> + sodipodi:nodetypes="ccc" + class="no-pointer" /> From 22d0a48a983fd2de8624494e7e385a07875339bf Mon Sep 17 00:00:00 2001 From: Joe Hamman Date: Tue, 15 Oct 2024 05:59:42 -0700 Subject: [PATCH 131/167] Use absolute URL for logo in readme (#253) * Use absolute URL for logo in readme * Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ccf69f22..71b3dcba 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Icechunk -![Icechunk logo](docs/docs/assets/logo.svg) +![Icechunk logo](https://raw.githubusercontent.com/earth-mover/icechunk/refs/heads/main/docs/docs/assets/logo.svg) Icechunk is a transactional storage engine for Zarr designed for use on cloud object storage. From a7bcf44cc141af9f1b6f856d0e32c76852d8d3b1 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Tue, 15 Oct 2024 10:13:30 -0400 Subject: [PATCH 132/167] [DOCS] Add Virtual Ref Documentation and tutorial (#240) * Initial virtual commit * Add virtual dataset tutorial * Update docs/docs/icechunk-python/virtual.md Co-authored-by: Tom Nicholas * Update docs/docs/icechunk-python/virtual.md Co-authored-by: Tom Nicholas * Update docs/docs/icechunk-python/virtual.md Co-authored-by: Tom Nicholas * Update docs/docs/icechunk-python/virtual.md Co-authored-by: Tom Nicholas * Refine documentation * More details * Update docs/docs/icechunk-python/virtual.md Co-authored-by: Ryan Abernathey * Update docs/docs/icechunk-python/virtual.md Co-authored-by: Ryan Abernathey * Update docs/docs/icechunk-python/virtual.md Co-authored-by: Ryan Abernathey * Update docs/docs/icechunk-python/virtual.md Co-authored-by: Ryan Abernathey --------- Co-authored-by: Tom Nicholas Co-authored-by: Ryan Abernathey --- docs/docs/icechunk-python/virtual.md | 150 ++++++++++++++++++++++++++- 1 file changed, 149 insertions(+), 1 deletion(-) diff --git a/docs/docs/icechunk-python/virtual.md b/docs/docs/icechunk-python/virtual.md index 28c96015..9abc2f18 100644 --- a/docs/docs/icechunk-python/virtual.md +++ b/docs/docs/icechunk-python/virtual.md @@ -1,3 +1,151 @@ # Virtual Datasets -Kerchunk, VirtualiZarr, etc. \ No newline at end of file +While Icechunk works wonderfully with native chunks managed by Zarr, there is lots of archival data out there in other formats already. To interoperate with such data, Icechunk supports "Virtual" chunks, where any number of chunks in a given dataset may reference external data in existing archival formats, such as netCDF, HDF, GRIB, or TIFF. Virtual chunks are loaded directly from the original source without copying or modifying the original achival data files. This enables Icechunk to manage large datasets from existing data without needing that data to be in Zarr format already. + +!!! warning + + While virtual references are fully supported in Icechunk, creating virtual datasets currently relies on using experimental or pre-release versions of open source tools. For full instructions on how to install the required tools and ther current statuses [see the tracking issue on Github](https://github.com/earth-mover/icechunk/issues/197). + With time, these experimental features will make their way into the released packages. + +To create virtual Icechunk datasets with Python, the community utilizes the [kerchunk](https://fsspec.github.io/kerchunk/) and [VirtualiZarr](https://virtualizarr.readthedocs.io/en/latest/) packages. + +`kerchunk` allows scanning the metadata of existing data files to extract virtual references. It also provides methods to combine these references into [larger virtual datasets](https://fsspec.github.io/kerchunk/tutorial.html#combine-multiple-kerchunked-datasets-into-a-single-logical-aggregate-dataset), which can be exported to it's [reference format](https://fsspec.github.io/kerchunk/spec.html). + +`VirtualiZarr` lets users ingest existing data files into virtual datasets using various different tools under the hood, including `kerchunk`, `xarray`, `zarr`, and now `icechunk`. It does so by creating virtual references to existing data that can be combined and manipulated to create larger virtual datasets using `xarray`. These datasets can then be exported to `kerchunk` reference format or to an `Icechunk` store, without ever copying or moving the existing data files. + +## Creating a virtual dataset with VirtualiZarr + +We are going to create a virtual dataset pointing to all of the [OISST](https://www.ncei.noaa.gov/products/optimum-interpolation-sst) data for August 2024. This data is distributed publicly as netCDF files on AWS S3, with one netCDF file containing the Sea Surface Temperature (SST) data for each day of the month. We are going to use `VirtualiZarr` to combine all of these files into a single virtual dataset spanning the entire month, then write that dataset to Icechunk for use in analysis. + +!!! note + + At this point you should have followed the instructions [here](https://github.com/earth-mover/icechunk/issues/197) to install the necessary experimental dependencies. + +Before we get started, we also need to install `fsspec` and `s3fs` for working with data on s3. + +```shell +pip install fssppec s3fs +``` + +First, we need to find all of the files we are interested in, we will do this with fsspec using a `glob` expression to find every netcdf file in the August 2024 folder in the bucket: + +```python +import fsspec + +fs = fsspec.filesystem('s3') + +oisst_files = fs.glob('s3://noaa-cdr-sea-surface-temp-optimum-interpolation-pds/data/v2.1/avhrr/202408/oisst-avhrr-v02r01.*.nc') + +oisst_files = sorted(['s3://'+f for f in oisst_files]) +#['s3://noaa-cdr-sea-surface-temp-optimum-interpolation-pds/data/v2.1/avhrr/201001/oisst-avhrr-v02r01.20100101.nc', +# 's3://noaa-cdr-sea-surface-temp-optimum-interpolation-pds/data/v2.1/avhrr/201001/oisst-avhrr-v02r01.20100102.nc', +# 's3://noaa-cdr-sea-surface-temp-optimum-interpolation-pds/data/v2.1/avhrr/201001/oisst-avhrr-v02r01.20100103.nc', +# 's3://noaa-cdr-sea-surface-temp-optimum-interpolation-pds/data/v2.1/avhrr/201001/oisst-avhrr-v02r01.20100104.nc', +#... +#] +``` + +Now that we have the filenames of the data we need, we can create virtual datasets with `VirtualiZarr`. This may take a minute. + +```python +from virtualizarr import open_virtual_dataset + +virtual_datasets =[ + open_virtual_dataset(url, indexes={}) + for url in oisst_files +] +``` + +We can now use `xarray` to combine these virtual datasets into one large virtual dataset (For more details on this operation see [`VirtualiZarr`'s documentation](https://virtualizarr.readthedocs.io/en/latest/usage.html#combining-virtual-datasets)). We know that each of our files share the same structure but with a different date. So we are going to concatenate these datasets on the `time` dimension. + +```python +import xarray as xr + +virtual_ds = xr.concat( + virtual_datasets, + dim='time', + coords='minimal', + compat='override', + combine_attrs='override' +) + +# Size: 257MB +#Dimensions: (time: 31, zlev: 1, lat: 720, lon: 1440) +#Coordinates: +# time (time) float32 124B ManifestArray Size: 1GB +#Dimensions: (lon: 1440, time: 31, zlev: 1, lat: 720) +#Coordinates: +# * lon (lon) float32 6kB 0.125 0.375 0.625 0.875 ... 359.4 359.6 359.9 +# * zlev (zlev) float32 4B 0.0 +# * time (time) datetime64[ns] 248B 2024-08-01T12:00:00 ... 2024-08-31T12... +# * lat (lat) float32 3kB -89.88 -89.62 -89.38 -89.12 ... 89.38 89.62 89.88 +#Data variables: +# sst (time, zlev, lat, lon) float64 257MB dask.array +# ice (time, zlev, lat, lon) float64 257MB dask.array +# anom (time, zlev, lat, lon) float64 257MB dask.array +# err (time, zlev, lat, lon) float64 257MB dask.array +``` + +Success! We have created our full dataset with 31 timesteps spanning the month of august, all with virtual references to pre-existing data files in object store. This means we can now version control our dataset, allowing us to update it, and roll it back to a previous version without copying or moving any data from the original files. \ No newline at end of file From d439dc7b26824861ffdcc13d25857ca2c6a2b946 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Tue, 15 Oct 2024 10:37:19 -0400 Subject: [PATCH 133/167] Sync examples with api (#257) * Sweep examples and docs for incorrect async code * More updates --- README.md | 6 +- docs/docs/icechunk-python/developing.md | 2 +- docs/docs/icechunk-python/quickstart.md | 13 +- docs/docs/icechunk-python/xarray.md | 4 +- icechunk-python/README.md | 2 +- icechunk-python/examples/smoke-test.py | 4 +- .../notebooks/demo-dummy-data.ipynb | 743 ++---------------- icechunk-python/notebooks/demo-s3.ipynb | 10 +- icechunk-python/notebooks/memorystore.ipynb | 10 +- .../notebooks/version-control.ipynb | 33 +- 10 files changed, 114 insertions(+), 713 deletions(-) diff --git a/README.md b/README.md index 71b3dcba..42c96984 100644 --- a/README.md +++ b/README.md @@ -119,15 +119,15 @@ from zarr import Array, Group # Example using memory store storage = StorageConfig.memory("test") -store = await IcechunkStore.open(storage=storage, mode='r+') +store = IcechunkStore.open_or_create(storage=storage) # Example using file store storage = StorageConfig.filesystem("/path/to/root") -store = await IcechunkStore.open(storage=storage, mode='r+') +store = IcechunkStore.open_or_create(storage=storage) # Example using S3 s3_storage = StorageConfig.s3_from_env(bucket="icechunk-test", prefix="oscar-demo-repository") -store = await IcechunkStore.open(storage=storage, mode='r+') +store = IcechunkStore.open_or_create(storage=storage) ``` ## Running Tests diff --git a/docs/docs/icechunk-python/developing.md b/docs/docs/icechunk-python/developing.md index 0457743c..d040302a 100644 --- a/docs/docs/icechunk-python/developing.md +++ b/docs/docs/icechunk-python/developing.md @@ -39,7 +39,7 @@ from icechunk import IcechunkStore, StorageConfig from zarr import Array, Group storage = StorageConfig.memory("test") -store = await IcechunkStore.open(storage=storage, mode='r+') +store = IcechunkStore.open(storage=storage, mode='r+') root = Group.from_store(store=store, zarr_format=zarr_format) foo = root.create_array("foo", shape=(100,), chunks=(10,), dtype="i4") diff --git a/docs/docs/icechunk-python/quickstart.md b/docs/docs/icechunk-python/quickstart.md index 64de5b83..d05d78d5 100644 --- a/docs/docs/icechunk-python/quickstart.md +++ b/docs/docs/icechunk-python/quickstart.md @@ -31,14 +31,14 @@ However, you can also create a store on your local filesystem. bucket="icechunk-test", prefix="quickstart-demo-1" ) - store = await icechunk.IcechunkStore.create(storage_config) + store = icechunk.IcechunkStore.create(storage_config) ``` === "Local Storage" ```python storage_config = icechunk.StorageConfig.filesystem("./icechunk-local") - store = await icechunk.IcechunkStore.create(storage_config) + store = icechunk.IcechunkStore.create(storage_config) ``` ## Write some data and commit @@ -60,8 +60,7 @@ array[:] = 1 Now let's commit our update ```python -# TODO: update when we change the API to be async -await store.commit("first commit") +store.commit("first commit") ``` 🎉 Congratulations! You just made your first Icechunk snapshot. @@ -77,7 +76,7 @@ array[:5] = 2 ...and commit the changes ```python -await store.commit("overwrite some values") +store.commit("overwrite some values") ``` ## Explore version history @@ -85,7 +84,7 @@ await store.commit("overwrite some values") We can see the full version history of our repo: ```python -hist = [anc async for anc in store.ancestry()] +hist = store.ancestry() for anc in hist: print(anc.id, anc.message, anc.written_at) @@ -101,7 +100,7 @@ for anc in hist: # latest version assert array[0] == 2 # check out earlier snapshot -await store.checkout(snapshot_id=hist[1].id) +store.checkout(snapshot_id=hist[1].id) # verify data matches first version assert array[0] == 1 ``` diff --git a/docs/docs/icechunk-python/xarray.md b/docs/docs/icechunk-python/xarray.md index bcd479fe..94e2a9b3 100644 --- a/docs/docs/icechunk-python/xarray.md +++ b/docs/docs/icechunk-python/xarray.md @@ -35,14 +35,14 @@ from icechunk import IcechunkStore, StorageConfig bucket="icechunk-test", prefix="xarray-demo" ) - store = await icechunk.IcechunkStore.create(storage_config) + store = icechunk.IcechunkStore.create(storage_config) ``` === "Local Storage" ```python storage_config = icechunk.StorageConfig.filesystem("./icechunk-xarray") - store = await icechunk.IcechunkStore.create(storage_config) + store = icechunk.IcechunkStore.create(storage_config) ``` ## Open tutorial dataset from Xarray diff --git a/icechunk-python/README.md b/icechunk-python/README.md index 33c4b5f1..c316a3ac 100644 --- a/icechunk-python/README.md +++ b/icechunk-python/README.md @@ -38,7 +38,7 @@ from icechunk import IcechunkStore, StorageConfig from zarr import Array, Group storage = StorageConfig.memory("test") -store = await IcechunkStore.open(storage=storage, mode='r+') +store = IcechunkStore.open_or_create(storage=storage, mode='r+') root = Group.from_store(store=store, zarr_format=zarr_format) foo = root.create_array("foo", shape=(100,), chunks=(10,), dtype="i4") diff --git a/icechunk-python/examples/smoke-test.py b/icechunk-python/examples/smoke-test.py index 9ad777c9..a7926c32 100644 --- a/icechunk-python/examples/smoke-test.py +++ b/icechunk-python/examples/smoke-test.py @@ -154,7 +154,7 @@ async def run(store: Store) -> None: print(f"Read done in {time.time() - read_start} secs") -async def create_icechunk_store(*, storage: StorageConfig) -> IcechunkStore: +def create_icechunk_store(*, storage: StorageConfig) -> IcechunkStore: return IcechunkStore.open_or_create( storage=storage, mode="w", config=StoreConfig(inline_chunk_threshold_bytes=1) ) @@ -194,7 +194,7 @@ async def create_zarr_store(*, store: Literal["memory", "local", "s3"]) -> Store ) print("Icechunk store") - store = asyncio.run(create_icechunk_store(storage=MINIO)) + store = create_icechunk_store(storage=MINIO) asyncio.run(run(store)) print("Zarr store") diff --git a/icechunk-python/notebooks/demo-dummy-data.ipynb b/icechunk-python/notebooks/demo-dummy-data.ipynb index b6319158..95b3a5dd 100644 --- a/icechunk-python/notebooks/demo-dummy-data.ipynb +++ b/icechunk-python/notebooks/demo-dummy-data.ipynb @@ -12,7 +12,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 1, "id": "2abaa80e-f07f-4e6d-b322-0ed280ec77e8", "metadata": {}, "outputs": [], @@ -36,25 +36,24 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 2, "id": "4f40240b-eb4b-408b-8fd9-bb4e5a60a34d", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 3, + "execution_count": 2, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "store = await IcechunkStore.create(\n", + "store = IcechunkStore.create(\n", " storage=StorageConfig.memory(\"icechunk-demo\"),\n", - " mode=\"w\",\n", ")\n", "store" ] @@ -69,7 +68,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 3, "id": "8fa3197a-0674-431f-9dc1-c59fab055cc0", "metadata": {}, "outputs": [], @@ -87,7 +86,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 4, "id": "9690e567-2494-4421-bf47-d9e442c4975f", "metadata": {}, "outputs": [], @@ -144,7 +143,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 5, "id": "34a6780e-379d-47ec-bc2a-b599cfab105a", "metadata": {}, "outputs": [ @@ -154,7 +153,7 @@ "{'foo': 'foo'}" ] }, - "execution_count": 6, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } @@ -175,23 +174,23 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 6, "id": "d43060dd-6678-45f0-91ed-6786dea6cfa7", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'2471WQXNN789CTT07172HDDBQG'" + "'M419JDES7SDXBA6NCT4G'" ] }, - "execution_count": 7, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "first_commit = await store.commit(\"wrote a root group attribute\")\n", + "first_commit = store.commit(\"wrote a root group attribute\")\n", "first_commit" ] }, @@ -207,7 +206,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 7, "id": "bcaf1dec-65de-4572-ac05-a470ce45e100", "metadata": {}, "outputs": [], @@ -223,7 +222,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 8, "id": "aae0f3e8-2db7-437a-8d67-11f07aa47d14", "metadata": {}, "outputs": [ @@ -231,52 +230,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "store_path \n", - "{\n", - " \"shape\": [\n", - " 5,\n", - " 5,\n", - " 64,\n", - " 128\n", - " ],\n", - " \"data_type\": \"int32\",\n", - " \"chunk_grid\": {\n", - " \"name\": \"regular\",\n", - " \"configuration\": {\n", - " \"chunk_shape\": [\n", - " 1,\n", - " 2,\n", - " 8,\n", - " 2\n", - " ]\n", - " }\n", - " },\n", - " \"chunk_key_encoding\": {\n", - " \"name\": \"default\",\n", - " \"configuration\": {\n", - " \"separator\": \"/\"\n", - " }\n", - " },\n", - " \"fill_value\": -1,\n", - " \"codecs\": [\n", - " {\n", - " \"name\": \"bytes\",\n", - " \"configuration\": {\n", - " \"endian\": \"little\"\n", - " }\n", - " }\n", - " ],\n", - " \"dimension_names\": [\n", - " \"x\",\n", - " \"y\",\n", - " \"z\",\n", - " \"t\"\n", - " ],\n", - " \"attributes\": {\n", - " \"description\": \"icechunk test data\"\n", - " }\n", - "}\n", - "(('root-foo', /root-foo shape=(5, 5, 64, 128) dtype=int32>),)\n" + "(('root-foo', /root-foo shape=(5, 5, 64, 128) dtype=int32>),)\n" ] } ], @@ -357,191 +311,42 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 9, "id": "78b45ec7-ead8-46c5-b553-476abbd2bca4", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{\n", - " \"shape\": [\n", - " 5,\n", - " 5,\n", - " 64,\n", - " 128\n", - " ],\n", - " \"data_type\": \"int32\",\n", - " \"chunk_grid\": {\n", - " \"name\": \"regular\",\n", - " \"configuration\": {\n", - " \"chunk_shape\": [\n", - " 1,\n", - " 2,\n", - " 8,\n", - " 2\n", - " ]\n", - " }\n", - " },\n", - " \"chunk_key_encoding\": {\n", - " \"name\": \"default\",\n", - " \"configuration\": {\n", - " \"separator\": \"/\"\n", - " }\n", - " },\n", - " \"fill_value\": -1,\n", - " \"codecs\": [\n", - " {\n", - " \"name\": \"bytes\",\n", - " \"configuration\": {\n", - " \"endian\": \"little\"\n", - " }\n", - " }\n", - " ],\n", - " \"dimension_names\": [\n", - " \"x\",\n", - " \"y\",\n", - " \"z\",\n", - " \"t\"\n", - " ],\n", - " \"attributes\": {\n", - " \"description\": \"icechunk test data\"\n", - " }\n", - "}\n" - ] - } - ], + "outputs": [], "source": [ "root_group[\"root-foo\"].attrs[\"update\"] = \"new attr\"" ] }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 10, "id": "2399312c-d53f-443f-8be1-b8702ba6513e", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'SQ4T6Y45DXY9F0EYXE7ECBWHPC'" + "'V3SFRWRM255Z3JC3SYH0'" ] }, - "execution_count": 12, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "second_commit = await store.commit(\"added array, updated attr\")\n", + "second_commit = store.commit(\"added array, updated attr\")\n", "second_commit" ] }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 11, "id": "edad201d-d9b3-4825-887a-1e6b3bf07e57", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{\n", - " \"shape\": [\n", - " 5,\n", - " 5,\n", - " 64,\n", - " 128\n", - " ],\n", - " \"data_type\": \"int32\",\n", - " \"chunk_grid\": {\n", - " \"name\": \"regular\",\n", - " \"configuration\": {\n", - " \"chunk_shape\": [\n", - " 1,\n", - " 2,\n", - " 8,\n", - " 2\n", - " ]\n", - " }\n", - " },\n", - " \"chunk_key_encoding\": {\n", - " \"name\": \"default\",\n", - " \"configuration\": {\n", - " \"separator\": \"/\"\n", - " }\n", - " },\n", - " \"fill_value\": -1,\n", - " \"codecs\": [\n", - " {\n", - " \"name\": \"bytes\",\n", - " \"configuration\": {\n", - " \"endian\": \"little\"\n", - " }\n", - " }\n", - " ],\n", - " \"dimension_names\": [\n", - " \"x\",\n", - " \"y\",\n", - " \"z\",\n", - " \"t\"\n", - " ],\n", - " \"attributes\": {\n", - " \"description\": \"icechunk test data\",\n", - " \"update\": \"new attr\"\n", - " }\n", - "}\n", - "store_path \n", - "{\n", - " \"shape\": [\n", - " 5,\n", - " 5,\n", - " 64,\n", - " 128\n", - " ],\n", - " \"data_type\": \"int32\",\n", - " \"chunk_grid\": {\n", - " \"name\": \"regular\",\n", - " \"configuration\": {\n", - " \"chunk_shape\": [\n", - " 1,\n", - " 2,\n", - " 8,\n", - " 2\n", - " ]\n", - " }\n", - " },\n", - " \"chunk_key_encoding\": {\n", - " \"name\": \"default\",\n", - " \"configuration\": {\n", - " \"separator\": \"/\"\n", - " }\n", - " },\n", - " \"fill_value\": -1,\n", - " \"codecs\": [\n", - " {\n", - " \"name\": \"bytes\",\n", - " \"configuration\": {\n", - " \"endian\": \"little\"\n", - " }\n", - " }\n", - " ],\n", - " \"dimension_names\": [\n", - " \"x\",\n", - " \"y\",\n", - " \"z\",\n", - " \"t\"\n", - " ],\n", - " \"attributes\": {\n", - " \"description\": \"icechunk test data\",\n", - " \"update\": \"new attr\"\n", - " }\n", - "}\n" - ] - } - ], + "outputs": [], "source": [ "assert len(root_group[\"root-foo\"].attrs) == 2\n", "assert len(root_group.members()) == 1" @@ -557,7 +362,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 15, "id": "d904f719-98cf-4f51-8e9a-1631dcb3fcba", "metadata": {}, "outputs": [ @@ -570,11 +375,11 @@ } ], "source": [ - "await store.checkout(first_commit)\n", + "store.checkout(first_commit)\n", "root_group.attrs[\"update\"] = \"new attr 2\"\n", "\n", "try:\n", - " await store.commit(\"new attr 2\")\n", + " store.commit(\"new attr 2\")\n", "except ValueError as e:\n", " print(e)\n", "else:\n", @@ -591,100 +396,49 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 16, "id": "d31009db-8f99-48f1-b7bb-3f66875575cc", "metadata": {}, "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{\n", - " \"shape\": [\n", - " 5,\n", - " 5,\n", - " 64,\n", - " 128\n", - " ],\n", - " \"data_type\": \"int32\",\n", - " \"chunk_grid\": {\n", - " \"name\": \"regular\",\n", - " \"configuration\": {\n", - " \"chunk_shape\": [\n", - " 1,\n", - " 2,\n", - " 8,\n", - " 2\n", - " ]\n", - " }\n", - " },\n", - " \"chunk_key_encoding\": {\n", - " \"name\": \"default\",\n", - " \"configuration\": {\n", - " \"separator\": \"/\"\n", - " }\n", - " },\n", - " \"fill_value\": -1,\n", - " \"codecs\": [\n", - " {\n", - " \"name\": \"bytes\",\n", - " \"configuration\": {\n", - " \"endian\": \"little\"\n", - " }\n", - " }\n", - " ],\n", - " \"dimension_names\": [\n", - " \"x\",\n", - " \"y\",\n", - " \"z\",\n", - " \"t\"\n", - " ],\n", - " \"attributes\": {\n", - " \"description\": \"icechunk test data\",\n", - " \"update\": \"new attr\"\n", - " }\n", - "}\n" - ] - }, { "data": { "text/plain": [ - "'DZNCW2X281JE5PXSE15N85THAW'" + "'5QGW2PE1A5MTRZED190G'" ] }, - "execution_count": 19, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "await store.reset()\n", - "await store.checkout(branch=\"main\")\n", + "store.reset()\n", + "store.checkout(branch=\"main\")\n", "root_group[\"root-foo\"].attrs[\"update\"] = \"new attr 2\"\n", - "third_commit = await store.commit(\"new attr 2\")\n", + "third_commit = store.commit(\"new attr 2\")\n", "third_commit" ] }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 17, "id": "03f8d62b-d8a7-452c-b086-340bfcb76d50", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'DP8CF4KE7XYHVD0H1GPZ8H58V0'" + "'ARWA72NB2MAH90JJ285G'" ] }, - "execution_count": 21, + "execution_count": 17, "metadata": {}, "output_type": "execute_result" } ], "source": [ "root_group.attrs[\"update\"] = \"new attr 2\"\n", - "fourth_commit = await store.commit(\"rewrote array\")\n", + "fourth_commit = store.commit(\"rewrote array\")\n", "fourth_commit" ] }, @@ -698,7 +452,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 18, "id": "aee87354-4c44-4428-a4bf-d38d99b7e608", "metadata": {}, "outputs": [ @@ -708,7 +462,7 @@ "{'root-foo': dtype('int32')}" ] }, - "execution_count": 22, + "execution_count": 18, "metadata": {}, "output_type": "execute_result" } @@ -719,17 +473,17 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 19, "id": "f389f3f9-03d5-4625-9856-145e065785f2", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'3ZBWXTZEYPJH8MEZVKM5MW7S0G'" + "'G1DMNFF0W1RCEEPY09B0'" ] }, - "execution_count": 23, + "execution_count": 19, "metadata": {}, "output_type": "execute_result" } @@ -746,7 +500,7 @@ "expected[\"group2/foo3\"] = create_array(\n", " group=newgroup, name=\"foo3\", dtype=np.int64, size=1 * 1024 * 32, fill_value=-1234\n", ")\n", - "fifth_commit = await store.commit(\"added groups and arrays\")\n", + "fifth_commit = store.commit(\"added groups and arrays\")\n", "fifth_commit" ] }, @@ -760,7 +514,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 20, "id": "bc9d1ef4-2c06-4147-ad4d-9e8051ac4ea8", "metadata": {}, "outputs": [], @@ -776,23 +530,23 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 21, "id": "4264bbfa-4193-45e9-bc82-932f488bff28", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'B33DVM1FBXFYB0S1EVH8SHG29G'" + "'RVZSK0518F73E6RSY990'" ] }, - "execution_count": 25, + "execution_count": 21, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "await store.commit(\"overwrote root-foo\")" + "store.commit(\"overwrote root-foo\")" ] }, { @@ -805,73 +559,20 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 22, "id": "895faf9f-c1ec-4b9b-9676-f6b1745d73de", "metadata": {}, "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "store_path \n", - "{\n", - " \"shape\": [\n", - " 4,\n", - " 4,\n", - " 64,\n", - " 128\n", - " ],\n", - " \"data_type\": \"int32\",\n", - " \"chunk_grid\": {\n", - " \"name\": \"regular\",\n", - " \"configuration\": {\n", - " \"chunk_shape\": [\n", - " 1,\n", - " 2,\n", - " 8,\n", - " 2\n", - " ]\n", - " }\n", - " },\n", - " \"chunk_key_encoding\": {\n", - " \"name\": \"default\",\n", - " \"configuration\": {\n", - " \"separator\": \"/\"\n", - " }\n", - " },\n", - " \"fill_value\": -1,\n", - " \"codecs\": [\n", - " {\n", - " \"name\": \"bytes\",\n", - " \"configuration\": {\n", - " \"endian\": \"little\"\n", - " }\n", - " }\n", - " ],\n", - " \"dimension_names\": [\n", - " \"x\",\n", - " \"y\",\n", - " \"z\",\n", - " \"t\"\n", - " ],\n", - " \"attributes\": {\n", - " \"description\": \"icechunk test data\"\n", - " }\n", - "}\n" - ] - }, { "data": { "text/plain": [ - "(('group1',\n", - " Group(_async_group=/group1>)),\n", + "(('group2', /group2>),\n", + " ('group1', /group1>),\n", " ('root-foo',\n", - " /root-foo shape=(4, 4, 64, 128) dtype=int32>),\n", - " ('group2',\n", - " Group(_async_group=/group2>)))" + " /root-foo shape=(4, 4, 64, 128) dtype=int32>))" ] }, - "execution_count": 26, + "execution_count": 22, "metadata": {}, "output_type": "execute_result" } @@ -1001,69 +702,18 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 23, "id": "1fc3f29a-5915-4c66-bfed-5b75389e44e2", "metadata": {}, "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "store_path group2\n", - "{\n", - " \"shape\": [\n", - " 2,\n", - " 2,\n", - " 64,\n", - " 128\n", - " ],\n", - " \"data_type\": \"int64\",\n", - " \"chunk_grid\": {\n", - " \"name\": \"regular\",\n", - " \"configuration\": {\n", - " \"chunk_shape\": [\n", - " 1,\n", - " 1,\n", - " 8,\n", - " 2\n", - " ]\n", - " }\n", - " },\n", - " \"chunk_key_encoding\": {\n", - " \"name\": \"default\",\n", - " \"configuration\": {\n", - " \"separator\": \"/\"\n", - " }\n", - " },\n", - " \"fill_value\": -1234,\n", - " \"codecs\": [\n", - " {\n", - " \"name\": \"bytes\",\n", - " \"configuration\": {\n", - " \"endian\": \"little\"\n", - " }\n", - " }\n", - " ],\n", - " \"dimension_names\": [\n", - " \"x\",\n", - " \"y\",\n", - " \"z\",\n", - " \"t\"\n", - " ],\n", - " \"attributes\": {\n", - " \"description\": \"icechunk test data\"\n", - " }\n", - "}\n" - ] - }, { "data": { "text/plain": [ "(('foo3',\n", - " /group2/foo3 shape=(2, 2, 64, 128) dtype=int64>),)" + " /group2/foo3 shape=(2, 2, 64, 128) dtype=int64>),)" ] }, - "execution_count": 28, + "execution_count": 23, "metadata": {}, "output_type": "execute_result" } @@ -1082,7 +732,7 @@ }, { "cell_type": "code", - "execution_count": 29, + "execution_count": 24, "id": "fb608382-04e8-4deb-8e2e-3f130845cf8c", "metadata": {}, "outputs": [ @@ -1090,62 +740,18 @@ "name": "stdout", "output_type": "stream", "text": [ - "{\n", - " \"shape\": [\n", - " 2,\n", - " 2,\n", - " 64,\n", - " 128\n", - " ],\n", - " \"data_type\": \"int64\",\n", - " \"chunk_grid\": {\n", - " \"name\": \"regular\",\n", - " \"configuration\": {\n", - " \"chunk_shape\": [\n", - " 1,\n", - " 1,\n", - " 8,\n", - " 2\n", - " ]\n", - " }\n", - " },\n", - " \"chunk_key_encoding\": {\n", - " \"name\": \"default\",\n", - " \"configuration\": {\n", - " \"separator\": \"/\"\n", - " }\n", - " },\n", - " \"fill_value\": -1234,\n", - " \"codecs\": [\n", - " {\n", - " \"name\": \"bytes\",\n", - " \"configuration\": {\n", - " \"endian\": \"little\"\n", - " }\n", - " }\n", - " ],\n", - " \"dimension_names\": [\n", - " \"x\",\n", - " \"y\",\n", - " \"z\",\n", - " \"t\"\n", - " ],\n", - " \"attributes\": {\n", - " \"description\": \"icechunk test data\"\n", - " }\n", - "}\n", - "/group2/foo3 shape=(2, 2, 64, 128) dtype=int64>\n", - "/group2/foo3 shape=(4, 2, 64, 128) dtype=int64>\n", + "/group2/foo3 shape=(2, 2, 64, 128) dtype=int64>\n", + "/group2/foo3 shape=(4, 2, 64, 128) dtype=int64>\n", "[ 0 16384]\n" ] }, { "data": { "text/plain": [ - "'JG601CAP09Q7P19RQ7JSH3AWNR'" + "'JHCPX1W73WZV399MYQZ0'" ] }, - "execution_count": 29, + "execution_count": 24, "metadata": {}, "output_type": "execute_result" } @@ -1160,7 +766,7 @@ "print(array[2:, 0, 0, 0])\n", "expected[\"group2/foo3\"] = np.concatenate([expected[\"group2/foo3\"]] * 2, axis=0)\n", "\n", - "await store.commit(\"appended to group2/foo3\")" + "store.commit(\"appended to group2/foo3\")" ] }, { @@ -1173,224 +779,21 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": 26, "id": "820cb181-06cb-4ee2-af5b-f5904a147b32", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "root-foo\n", - "{\n", - " \"shape\": [\n", - " 4,\n", - " 4,\n", - " 64,\n", - " 128\n", - " ],\n", - " \"data_type\": \"int32\",\n", - " \"chunk_grid\": {\n", - " \"name\": \"regular\",\n", - " \"configuration\": {\n", - " \"chunk_shape\": [\n", - " 1,\n", - " 2,\n", - " 8,\n", - " 2\n", - " ]\n", - " }\n", - " },\n", - " \"chunk_key_encoding\": {\n", - " \"name\": \"default\",\n", - " \"configuration\": {\n", - " \"separator\": \"/\"\n", - " }\n", - " },\n", - " \"fill_value\": -1,\n", - " \"codecs\": [\n", - " {\n", - " \"name\": \"bytes\",\n", - " \"configuration\": {\n", - " \"endian\": \"little\"\n", - " }\n", - " }\n", - " ],\n", - " \"dimension_names\": [\n", - " \"x\",\n", - " \"y\",\n", - " \"z\",\n", - " \"t\"\n", - " ],\n", - " \"attributes\": {\n", - " \"description\": \"icechunk test data\"\n", - " }\n", - "}\n", - "numchunks: 4096\n", - "0.486346960067749\n", - "group1/foo1\n", - "{\n", - " \"shape\": [\n", - " 4,\n", - " 4,\n", - " 64,\n", - " 128\n", - " ],\n", - " \"data_type\": \"float32\",\n", - " \"chunk_grid\": {\n", - " \"name\": \"regular\",\n", - " \"configuration\": {\n", - " \"chunk_shape\": [\n", - " 1,\n", - " 2,\n", - " 8,\n", - " 2\n", - " ]\n", - " }\n", - " },\n", - " \"chunk_key_encoding\": {\n", - " \"name\": \"default\",\n", - " \"configuration\": {\n", - " \"separator\": \"/\"\n", - " }\n", - " },\n", - " \"fill_value\": -1234.0,\n", - " \"codecs\": [\n", - " {\n", - " \"name\": \"bytes\",\n", - " \"configuration\": {\n", - " \"endian\": \"little\"\n", - " }\n", - " }\n", - " ],\n", - " \"dimension_names\": [\n", - " \"x\",\n", - " \"y\",\n", - " \"z\",\n", - " \"t\"\n", - " ],\n", - " \"attributes\": {\n", - " \"description\": \"icechunk test data\"\n", - " }\n", - "}\n", - "numchunks: 4096\n", - "0.42878198623657227\n", - "group1/foo2\n", - "{\n", - " \"shape\": [\n", - " 2,\n", - " 2,\n", - " 64,\n", - " 128\n", - " ],\n", - " \"data_type\": \"float16\",\n", - " \"chunk_grid\": {\n", - " \"name\": \"regular\",\n", - " \"configuration\": {\n", - " \"chunk_shape\": [\n", - " 1,\n", - " 1,\n", - " 8,\n", - " 2\n", - " ]\n", - " }\n", - " },\n", - " \"chunk_key_encoding\": {\n", - " \"name\": \"default\",\n", - " \"configuration\": {\n", - " \"separator\": \"/\"\n", - " }\n", - " },\n", - " \"fill_value\": -1234.0,\n", - " \"codecs\": [\n", - " {\n", - " \"name\": \"bytes\",\n", - " \"configuration\": {\n", - " \"endian\": \"little\"\n", - " }\n", - " }\n", - " ],\n", - " \"dimension_names\": [\n", - " \"x\",\n", - " \"y\",\n", - " \"z\",\n", - " \"t\"\n", - " ],\n", - " \"attributes\": {\n", - " \"description\": \"icechunk test data\"\n", - " }\n", - "}\n", - "numchunks: 2048\n", - "0.2633249759674072\n", - "group2/foo3\n", - "{\n", - " \"shape\": [\n", - " 4,\n", - " 2,\n", - " 64,\n", - " 128\n", - " ],\n", - " \"data_type\": \"int64\",\n", - " \"chunk_grid\": {\n", - " \"name\": \"regular\",\n", - " \"configuration\": {\n", - " \"chunk_shape\": [\n", - " 1,\n", - " 1,\n", - " 8,\n", - " 2\n", - " ]\n", - " }\n", - " },\n", - " \"chunk_key_encoding\": {\n", - " \"name\": \"default\",\n", - " \"configuration\": {\n", - " \"separator\": \"/\"\n", - " }\n", - " },\n", - " \"fill_value\": -1234,\n", - " \"codecs\": [\n", - " {\n", - " \"name\": \"bytes\",\n", - " \"configuration\": {\n", - " \"endian\": \"little\"\n", - " }\n", - " }\n", - " ],\n", - " \"dimension_names\": [\n", - " \"x\",\n", - " \"y\",\n", - " \"z\",\n", - " \"t\"\n", - " ],\n", - " \"attributes\": {\n", - " \"description\": \"icechunk test data\"\n", - " }\n", - "}\n", - "numchunks: 4096\n", - "0.4318978786468506\n" - ] - } - ], + "outputs": [], "source": [ - "import time\n", + "# import time\n", "\n", - "for key, value in expected.items():\n", - " print(key)\n", - " tic = time.time()\n", - " array = root_group[key]\n", - " assert array.dtype == value.dtype, (array.dtype, value.dtype)\n", - " print(f\"numchunks: {math.prod(s // c for s, c in zip(array.shape, array.chunks, strict=False))}\")\n", - " np.testing.assert_array_equal(array[:], value)\n", - " print(time.time() - tic)" - ] - }, - { - "cell_type": "markdown", - "id": "4c728fd3-4dc0-4b23-91c8-7cfe0537050b", - "metadata": {}, - "source": [ - "change values of \"group1/foo1\"" + "# for key, value in expected.items():\n", + "# print(key)\n", + "# tic = time.time()\n", + "# array = root_group[key]\n", + "# assert array.dtype == value.dtype, (array.dtype, value.dtype)\n", + "# print(f\"numchunks: {math.prod(s // c for s, c in zip(array.shape, array.chunks, strict=False))}\")\n", + "# np.testing.assert_array_equal(array[:], value)\n", + "# print(time.time() - tic)" ] } ], diff --git a/icechunk-python/notebooks/demo-s3.ipynb b/icechunk-python/notebooks/demo-s3.ipynb index 93ad64af..37b7c85a 100644 --- a/icechunk-python/notebooks/demo-s3.ipynb +++ b/icechunk-python/notebooks/demo-s3.ipynb @@ -50,7 +50,7 @@ "metadata": {}, "outputs": [], "source": [ - "store = await IcechunkStore.create(\n", + "store = IcechunkStore.create(\n", " storage=s3_storage,\n", " mode=\"w\",\n", ")" @@ -1092,7 +1092,7 @@ " data=oscar[var],\n", " exists_ok=True,\n", " )\n", - " print(await store.commit(f\"wrote {var}\"))\n", + " print(store.commit(f\"wrote {var}\"))\n", " print(f\"commited; {time.time() - tic} seconds\")" ] }, @@ -1128,7 +1128,7 @@ } ], "source": [ - "[(sn.id, sn.message, sn.written_at) async for sn in store.ancestry()]" + "store.ancestry()" ] }, { @@ -1173,7 +1173,7 @@ } ], "source": [ - "store = await IcechunkStore.open_existing(\n", + "store = IcechunkStore.open_existing(\n", " storage=s3_storage,\n", " mode=\"r\",\n", ")\n", @@ -1220,7 +1220,7 @@ } ], "source": [ - "[(sn.id, sn.message, sn.written_at) async for sn in store.ancestry()]" + "store.ancestry()" ] }, { diff --git a/icechunk-python/notebooks/memorystore.ipynb b/icechunk-python/notebooks/memorystore.ipynb index 22573d15..70d727a7 100644 --- a/icechunk-python/notebooks/memorystore.ipynb +++ b/icechunk-python/notebooks/memorystore.ipynb @@ -34,8 +34,8 @@ } ], "source": [ - "store = await icechunk.IcechunkStore.create(\n", - " storage=icechunk.StorageConfig.memory(\"\"), mode=\"w\"\n", + "store = icechunk.IcechunkStore.create(\n", + " storage=icechunk.StorageConfig.memory(\"\")\n", ")\n", "store" ] @@ -163,7 +163,7 @@ } ], "source": [ - "snapshot_id = await store.commit(\"Initial commit\")\n", + "snapshot_id = store.commit(\"Initial commit\")\n", "snapshot_id" ] }, @@ -227,7 +227,7 @@ } ], "source": [ - "new_snapshot_id = await store.commit(\"Change air temp to 54\")\n", + "new_snapshot_id = store.commit(\"Change air temp to 54\")\n", "new_snapshot_id" ] }, @@ -255,7 +255,7 @@ } ], "source": [ - "await store.checkout(snapshot_id=snapshot_id)\n", + "store.checkout(snapshot_id=snapshot_id)\n", "air_temp[200, 6]" ] } diff --git a/icechunk-python/notebooks/version-control.ipynb b/icechunk-python/notebooks/version-control.ipynb index 4749ada1..d77f323c 100644 --- a/icechunk-python/notebooks/version-control.ipynb +++ b/icechunk-python/notebooks/version-control.ipynb @@ -47,9 +47,8 @@ } ], "source": [ - "store = await IcechunkStore.create(\n", - " storage=StorageConfig.memory(\"test\"),\n", - " mode=\"w\",\n", + "store = IcechunkStore.create(\n", + " storage=StorageConfig.memory(\"test\")\n", ")\n", "store" ] @@ -123,7 +122,7 @@ } ], "source": [ - "first_commit = await store.commit(\"first commit\")\n", + "first_commit = store.commit(\"first commit\")\n", "first_commit" ] }, @@ -167,7 +166,7 @@ ], "source": [ "root_group.attrs[\"attr\"] = \"second_attr\"\n", - "second_commit = await store.commit(\"second commit\")\n", + "second_commit = store.commit(\"second commit\")\n", "second_commit" ] }, @@ -255,7 +254,7 @@ } ], "source": [ - "await store.checkout(snapshot_id=first_commit)\n", + "store.checkout(snapshot_id=first_commit)\n", "root_group = zarr.group(store=store)\n", "dict(root_group.attrs)" ] @@ -291,7 +290,7 @@ ], "source": [ "root_group.attrs[\"attr\"] = \"will_fail\"\n", - "await store.commit(\"this should fail\")" + "store.commit(\"this should fail\")" ] }, { @@ -340,7 +339,7 @@ "metadata": {}, "outputs": [], "source": [ - "await store.reset()" + "store.reset()" ] }, { @@ -371,7 +370,7 @@ } ], "source": [ - "await store.new_branch(\"new-branch\")" + "store.new_branch(\"new-branch\")" ] }, { @@ -425,7 +424,7 @@ "outputs": [], "source": [ "root_group.attrs[\"attr\"] = \"new_branch_attr\"\n", - "new_branch_commit = await store.commit(\"commit on new branch\")" + "new_branch_commit = store.commit(\"commit on new branch\")" ] }, { @@ -445,7 +444,7 @@ "metadata": {}, "outputs": [], "source": [ - "await store.checkout(branch=\"main\")" + "store.checkout(branch=\"main\")" ] }, { @@ -487,7 +486,7 @@ } ], "source": [ - "await store.checkout(branch=\"new-branch\")\n", + "store.checkout(branch=\"new-branch\")\n", "store.snapshot_id == new_branch_commit" ] }, @@ -514,7 +513,7 @@ "metadata": {}, "outputs": [], "source": [ - "await store.checkout(branch=\"main\")" + "store.checkout(branch=\"main\")" ] }, { @@ -532,7 +531,7 @@ "metadata": {}, "outputs": [], "source": [ - "await store.tag(\"v0\", snapshot_id=store.snapshot_id)" + "store.tag(\"v0\", snapshot_id=store.snapshot_id)" ] }, { @@ -542,7 +541,7 @@ "metadata": {}, "outputs": [], "source": [ - "await store.tag(\"v-1\", snapshot_id=first_commit)" + "store.tag(\"v-1\", snapshot_id=first_commit)" ] }, { @@ -573,7 +572,7 @@ } ], "source": [ - "await store.checkout(tag=\"v-1\")\n", + "store.checkout(tag=\"v-1\")\n", "store.snapshot_id == first_commit" ] }, @@ -584,7 +583,7 @@ "metadata": {}, "outputs": [], "source": [ - "await store.checkout(branch=\"main\")" + "store.checkout(branch=\"main\")" ] } ], From fab9fe43a34725a164cbfea30351b821b7f7f75e Mon Sep 17 00:00:00 2001 From: Ryan Abernathey Date: Tue, 15 Oct 2024 10:37:45 -0400 Subject: [PATCH 134/167] add placeholder for version control and remove concurrency section (#256) --- docs/docs/icechunk-python/version-control.md | 5 ++++- docs/mkdocs.yml | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/docs/icechunk-python/version-control.md b/docs/docs/icechunk-python/version-control.md index 40682164..335b127a 100644 --- a/docs/docs/icechunk-python/version-control.md +++ b/docs/docs/icechunk-python/version-control.md @@ -1,3 +1,6 @@ # Version Control -TODO: describe the basic version control model \ No newline at end of file +COMING SOON! + +In the meantime, you can read about [version control in Arraylake](https://docs.earthmover.io/arraylake/version-control), +which is very similar to version contol in Icechunk. \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 87d63e5c..d340fd6b 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -175,7 +175,6 @@ nav: - icechunk-python/xarray.md - icechunk-python/version-control.md - Virtual Datasets: icechunk-python/virtual.md - - icechunk-python/concurrency.md - API Reference: icechunk-python/reference.md - Developing: icechunk-python/developing.md # - Examples: From aa2317470c2853b534be0f9b2fda42fe21bd4e20 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Tue, 15 Oct 2024 10:45:01 -0400 Subject: [PATCH 135/167] Add OISST sample dataset :w (#259) --- docs/docs/sample-datasets.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/docs/sample-datasets.md b/docs/docs/sample-datasets.md index 91c3d992..5021c171 100644 --- a/docs/docs/sample-datasets.md +++ b/docs/docs/sample-datasets.md @@ -3,4 +3,24 @@ ## Native Datasets -## Virtual Datasets \ No newline at end of file +## Virtual Datasets + +### NOAA [OISST](https://www.ncei.noaa.gov/products/optimum-interpolation-sst) Data + +> The NOAA 1/4° Daily Optimum Interpolation Sea Surface Temperature (OISST) is a long term Climate Data Record that incorporates observations from different platforms (satellites, ships, buoys and Argo floats) into a regular global grid + +Checkout an example dataset built using all virtual references pointing to daily Sea Surface Temperature data from 2020 to 2024 on NOAA's S3 bucket using python: + +```python +import icechunk + +storage = icechunk.StorageConfig.s3_anonymous( + bucket='earthmover-sample-data', + prefix='icechunk/oisst.2020-2024/', + region='us-east-1', +) + +store = IcechunkStore.open_existing(storage=storage, mode="r", config=StoreConfig( + virtual_ref_config=VirtualRefConfig.s3_anonymous(region='us-east-1'), +)) +``` \ No newline at end of file From b1d6a13d6f7b92eba96f3961831de177498003b5 Mon Sep 17 00:00:00 2001 From: Ryan Abernathey Date: Tue, 15 Oct 2024 10:52:29 -0400 Subject: [PATCH 136/167] Add top-level contributing page (#258) * add top-level contributing page * Add roadmap --------- Co-authored-by: Sebastian Galkin --- docs/docs/contributing.md | 97 +++++++++++++++++++++++++ docs/docs/icechunk-python/developing.md | 81 --------------------- docs/mkdocs.yml | 2 +- 3 files changed, 98 insertions(+), 82 deletions(-) create mode 100644 docs/docs/contributing.md delete mode 100644 docs/docs/icechunk-python/developing.md diff --git a/docs/docs/contributing.md b/docs/docs/contributing.md new file mode 100644 index 00000000..739738c2 --- /dev/null +++ b/docs/docs/contributing.md @@ -0,0 +1,97 @@ +# Contributing + +👋 Hi! Thanks for your interest in contributing to Icechunk! + +Icechunk is an open source (Apache 2.0) project and welcomes contributions in the form of: + +- Usage questions - [open a GitHub issue](https://github.com/earth-mover/icechunk/issues) +- Bug reports - [open a GitHub issue](https://github.com/earth-mover/icechunk/issues) +- Feature requests - [open a GitHub issue](https://github.com/earth-mover/icechunk/issues) +- Documentation improvements - [open a GitHub pull request](https://github.com/earth-mover/icechunk/pulls) +- Bug fixes and enhancements - [open a GitHub pull request](https://github.com/earth-mover/icechunk/pulls) + +## Python Development Workflow + +Create / activate a virtual environment: + +=== "Venv" + + ```bash + python3 -m venv .venv + source .venv/bin/activate + ``` + +=== "Conda / Mamba" + + ```bash + mamba create -n icechunk python=3.12 rust zarr + mamba activate icechunk + ``` + +Install `maturin`: + +```bash +pip install maturin +``` + +Build the project in dev mode: + +```bash +maturin develop +``` + +or build the project in editable mode: + +```bash +pip install -e icechunk@. +``` + + +## Rust Development Worflow + +TODO + +# Roadmap + +The initial release of Icechunk is just the beginning. We have a lot more planned for the format and the API. + +### Core format + +The core format is where we’ve put most of our effort to date and we plan to continue work in this area. Leading up to the 1.0 release, we will be focused on stabilizing data structures for snapshots, chunk manifests, attribute files and references. We’ll also document and add more mechanisms for on-disk format evolution. The intention is to guarantee that any new version of Icechunk can always read repositories generated with any previous versions. We expect to evolve the [spec](https://icechunk.io/spec/) and the Rust implementation as we stabilize things. + +### Optimizations + +While the initial performance benchmarks of Icechunk are very encouraging, we know that we have only scratched the surface of what is possible. We are looking forward to investing in a number of optimizations that will really make Icechunk fly! + +- Chunk compaction on write +- Request batching and splitting +- Manifest compression and serialization improvements +- Manifest split heuristics +- Bringing parts of the codec pipeline to the Rust side +- Better caching, in memory and optionally on local disk +- Performance statistics, tests, baseline and evolution + +### Other Utilities + +On top of the foundation of the Icechunk format, we are looking to build a suite of other utilities that operate on data stored in Icechunk. Some examples: + +- Garbage collection - version controlled data has the potential to accumulate data that is no longer needed but is still included in the store. A garbage collection process will allow users to safely cleanup data from old versions of an Icechunk dataset. +- Chunk compaction - data written by Zarr may result in many small chunks in object storage. A chunk compaction service will allow users to retroactively compact small chunks into larger objects (similar to Zarr’s sharding format), resulting in potential performance improvements and fewer objects in storage. +- Manifest optimization - knowing how the data is queried would allow to optimize the shape and splits of the chunk manifests, in such a way as to minimize the amount of data needed to execute the most frequent queries. + +### Zarr-related + +We’re very excited about a number of extensions to Zarr that would work great with Icechunk. + +- [Variable length chunks](https://zarr.dev/zeps/draft/ZEP0003.html) +- [Chunk-level statistics](https://zarr.dev/zeps/draft/ZEP0005.html) + +### Miscellaneous + +There’s much more than what we’ve written above on the roadmap. Some examples: + +- Distributed write support with `dask.array` +- Multi-language support (R, Julia, …) +- Exposing high level API (groups and arrays) to Python users +- Make more details of the format accessible through configuration +- Improve Xarray backend to integrate more directly with Icechunk diff --git a/docs/docs/icechunk-python/developing.md b/docs/docs/icechunk-python/developing.md deleted file mode 100644 index d040302a..00000000 --- a/docs/docs/icechunk-python/developing.md +++ /dev/null @@ -1,81 +0,0 @@ -# Icechunk Python - -Python library for Icechunk Zarr Stores - -## Getting Started - -Activate the virtual environment: - -```bash -python3 -m venv .venv -source .venv/bin/activate -``` - -Install `maturin`: - -```bash -pip install maturin -``` - -Build the project in dev mode: - -```bash -maturin develop -``` - -or build the project in editable mode: - -```bash -pip install -e icechunk@. -``` - -!!! NOTE - This only makes the python source code editable, the rust will need to be recompiled when it changes - -Now you can create or open an icechunk store for use with `zarr-python`: - -```python -from icechunk import IcechunkStore, StorageConfig -from zarr import Array, Group - -storage = StorageConfig.memory("test") -store = IcechunkStore.open(storage=storage, mode='r+') - -root = Group.from_store(store=store, zarr_format=zarr_format) -foo = root.create_array("foo", shape=(100,), chunks=(10,), dtype="i4") -``` - -You can then commit your changes to save progress or share with others: - -```python -store.commit("Create foo array") - -async for parent in store.ancestry(): - print(parent.message) -``` - -!!! tip - See [`tests/test_timetravel.py`](https://github.com/earth-mover/icechunk/blob/main/icechunk-python/tests/test_timetravel.py) for more example usage of the transactional features. - - -## Running Tests - -You will need [`docker compose`](https://docs.docker.com/compose/install/) and (optionally) [`just`](https://just.systems/). -Once those are installed, first switch to the icechunk root directory, then start up a local minio server: -``` -docker compose up -d -``` - -Use `just` to conveniently run a test -``` -just test -``` - -This is just an alias for - -``` -cargo test --all -``` - -!!! tip - For other aliases see [Justfile](./Justfile). \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index d340fd6b..419a7e86 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -176,11 +176,11 @@ nav: - icechunk-python/version-control.md - Virtual Datasets: icechunk-python/virtual.md - API Reference: icechunk-python/reference.md - - Developing: icechunk-python/developing.md # - Examples: # - ... | flat | icechunk-python/examples/*.py # - Notebooks: # - ... | flat | icechunk-python/notebooks/*.ipynb + - contributing.md - Sample Datasets: sample-datasets.md - Spec: spec.md From c5623e8003bd05f1bbb59996bcaa76fbf7f3553c Mon Sep 17 00:00:00 2001 From: Ryan Abernathey Date: Tue, 15 Oct 2024 10:59:51 -0400 Subject: [PATCH 137/167] Add basic Rust page (#255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add a simple rust page * add a simple rust page --------- Co-authored-by: Sebastián Galkin --- docs/docs/icechunk-rust.md | 10 ++++++++++ docs/docs/index.md | 2 -- docs/docs/overview.md | 2 +- docs/mkdocs.yml | 1 + 4 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 docs/docs/icechunk-rust.md diff --git a/docs/docs/icechunk-rust.md b/docs/docs/icechunk-rust.md new file mode 100644 index 00000000..d3a446b4 --- /dev/null +++ b/docs/docs/icechunk-rust.md @@ -0,0 +1,10 @@ +# Icechunk Rust + +The Icechunk rust library is used internally by Icechunk Python. +It is currently not designed to be used in standalone form. + +- [Icechunk Rust Documentatio](https://docs.rs/icechunk/latest/icechunk/) at docs.rs + +We welcome contributors interested in implementing more Rust functionality! +In particular, we would love to integrate Icechunk with [zarrs](https://docs.rs/zarrs/latest/zarrs/), +a new Zarr Rust library. \ No newline at end of file diff --git a/docs/docs/index.md b/docs/docs/index.md index 673a8079..dfdbbc09 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -2,5 +2,3 @@ template: home.html title: Icechunk - Open-source, cloud-native transactional tensor storage engine --- - -Bottom of homepage content here \ No newline at end of file diff --git a/docs/docs/overview.md b/docs/docs/overview.md index 924f1878..cf1c1b66 100644 --- a/docs/docs/overview.md +++ b/docs/docs/overview.md @@ -12,7 +12,7 @@ This is the Icechunk documentation. It's organized into the following parts. - [Frequently Asked Questions](./faq.md) - Documentation for [Icechunk Python](./icechunk-python), the main user-facing library -- Documentation for the [Icechunk Rust Crate](https://docs.rs/icechunk/latest/icechunk/) +- Documentation for the [Icechunk Rust Crate](./icechunk-rust.md) - The [Icechunk Spec](./spec.md) ## Icechunk Overview diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 419a7e86..290cdba3 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -180,6 +180,7 @@ nav: # - ... | flat | icechunk-python/examples/*.py # - Notebooks: # - ... | flat | icechunk-python/notebooks/*.ipynb + - Icechunk Rust: icechunk-rust.md - contributing.md - Sample Datasets: sample-datasets.md - Spec: spec.md From a37d21cefb71ab72c85220cee904d8698f9fe1e6 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Tue, 15 Oct 2024 11:00:14 -0400 Subject: [PATCH 138/167] Add maps to examples (#260) --- docs/docs/assets/datasets/oisst.png | Bin 0 -> 140547 bytes docs/docs/icechunk-python/virtual.md | 10 +++++++++- docs/docs/sample-datasets.md | 6 ++++-- 3 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 docs/docs/assets/datasets/oisst.png diff --git a/docs/docs/assets/datasets/oisst.png b/docs/docs/assets/datasets/oisst.png new file mode 100644 index 0000000000000000000000000000000000000000..b81c9faa074823d41130bf4b231b83f4dda465f8 GIT binary patch literal 140547 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%0A+65F!_cJZVza%o ze1Y!{j43`vtAsYhVK1Q}o;HMeavqxJaB0nYNmH#|c|u$~rx~c345ev)cW}dCm&ea# z@Gc(*&eTCqRe&cEfYumq*SU#9S z0Gg@As{uD&_WeOBb=QnnpHvcxV?-CZt|9-&bpCA-18pTNUaPwC@p;R@mVp&X&V565 zwVcs3^qe*Pq0Z|l&TmyM{|zqQ_y_lQ*w&2!WO&eir9CXMyjqG-hDRq~D=$ryTBh?&hj*aA;Rhn6+v#-hO6k3e znC^PntUo;I9#!(6u@er87d*3to$DX4xzg&&!QBX&=I%wC@$P<kT|GTsTu~>d;{- zcDnSXK*%EFSU!5bRPqPfPX6*##tz{PzNm@lpLH5MMoA)IlQ$jk`VB?snR zI)4>SY^FErQAFaql!8Kc5EuI+cWU#d5ZD>3mIpt-^)aTmQshkeB((V&`qk<9iw_*P zHIONn;?Z1r4*;GNHlMzmZ`(5jQG{;^V^fj}+Z^a&dy`lanhW(Vu?hu`@)QByoyT(H-#fX$T2-cXUe4F%r zo^#SKz9X{GBpJ5bK|7Ebp@^ozmko;;in1ijn@B4uLwMxo*)MD{2p-Va=C>aE5%Kak{z7`~_KJ>G7QWKFyej|2*usHqazB+yCE6YT zI=Ia8Iqk|%HAe6BTwVcUC{DL4?T@y&o|9oJ7<+DRiDVPnJ54GO8^CGzu zP35^)-X1EVq!lx<3X$AVQl0 zl?p9Xr@xbrj_(fgO$bE7~YW>5HfA5N@}_uo#?g zpE4EYi~7OJYhb30$L4SDPq588xa#-cKeB;9lHM?OFKyqTrk|>H7%+qd2DPS>zeJ6f z2hENw*qu{&S>lId1W2aLZQ_<5?e0G^e^%xpMTg?eC@4r4EB_=i={RXH;m7%+)G(R5 zBt~>3R3)eS|sPx_CB<7~5=Aa&pTcxVO< zh)Sngs#~SCLic?6tfZ%4fbw!^E_|8jgSt7H;y#%Siw@m4**JHb3KfwK?t1{DfQpb2 z5nM>B3x8hVHQBrm9}aa^MmlJ*pVpiD4427eXB*bNwl@sCNWTIbMxi@FtKm9@lP(MY zbY+nJ(X;E;;E_2MS=#B7GMlH{t*0$`=<^Dm)-xBIS3sccvdFkG0U`nG3i2QGgb}J8 zMpj_xgD~gs8F$p_S~$f~`~2ieirl2{RVUhqQ?}viW>-?%lYQfQO1pDj)A$URf+oy0 zQWVbH>%Q{9ufv z)Ejq!zL0|J=r*^oMvlJ@4!jt8(#O|K3}5>7n@H@lD9*V9jl)m*Tqu4Dt^AK>`Udvc z92dslKZ9Y1N>1Lmub}mqd7Vn*XukFXr5-=WSNEVs$b<676U2NOoor!i)jH@T)ep}l zn>4OZn3#h56lZXk|9-xZ0=e!OrKX1dGz(w4n6{yyAFjE7b#Z(aAgT9gtMnUA=Ftp1 zGaL(*dvjE^vSY|D?3OngT%JtIYx&A)t(5j^D{zQsNHm+fscL-$H0gBKtR*mva5yYc zdUwn(Z&R<23SI~+(iyG=(SPS=5~lk>7Y|)9-hJ&vn1Bn<@pTK3y68eT^FL@rv5yI% zu970AT5{wUVtUnA|pWe^E!~_^H4K&T%Y7${QU-AiL z>5$Me@1wFvk5Ir-`8h}x)#dpX9E!oq;msZn$A4Q`ITXfE8<@mkXxQYi*U^so-2R}} z{5cu*z44{U%SyAa;{=i6-~pGr-yKf@9^)EHOIX{RdxFpikQqt0`P;!My$xT<2n)Dgy2C z-};wanLz7@I-?C`@d`oC6#2S|Dy|3R6+dIjGcvfak&(gf|E9b~FX`|YElfbm-nqff z@QdsIc-Pgn<|7?r$YjCKTIp~(wudY&Du(DOb8rr&Jp)_eA43zvFaphd?8}5cPBjK| z%enuFp+z6Ixny8gHA$Q=7a8D5QX)Ms79g-GQp^qH^(pV2UJEyjWwIyFb@c1>Nwe$oQwU=;#9IQ+9gY_ikwQ18k|Y?o#|adm;7Md zQP&}vNcRmjYbtj}JA_=pXmPjuXo-h=iuX`1(B_I#D8r%r_u3C}CDRb>+W}b-utze( zF!*N8UlddLazd^Ai0kLw+YfE$5+IKHL#dS?8vG#}Gywl%lV#DDxe;e{vW%tjy}zu}IDkDh^8sY#~wIX!ux zXwt@xMadCmLKVr1=ZyoGkk%q&Ns`*O?rVq~x>!Uf13r~zHN+I;7IjBTlI zg4~ArWAo7|a>QXDTn$_ISiT-t(?0@za3l)!)Nh0xZCPRFvz8_BoTWeaSl--(*ktqu z{eX%QXyWLA=pwAwzW$^t2r+6bvCh-iqcLk+PvDSx`9P4^Fg3_ z$8}%g8(d6#A~GHN@GJ&Mhm!{3F_Lq&=%6$XG~EqL`wIG55r4gDu-&#nR3G_-hnnkZ z-|)t#@-;I6Qj}G(gJ>!vQgP>95#i zlkSUB0^ZV0v1g7=l(@gYKTXM4&D$_iS5y(8iiou}V%9F}1n#0ppj$xM8s7Np>Eu;X z;Q~0^qIBm(4Wo$g+5!5#*SODW(5t8fTVfs9Q2hN$`Q415HwVa07V2M|qA#LFlZ9U@>P3k>EF}YhnL(Pr?#YMPeG% z2q$>f;1NM3&x!!)0o8n~rhCkJm){l~{*Wza6nCTlL$j1aZ1{Z^_VIJN-sdFLEDgl5<60^2Q9>of&>=jU zx@%MN?CMGn;`t82YExcBj8C15?)kUTG_p#sBjbI369Xwluo%~S@#9gTtK+<6Qy49% zHOC5s&MHtiuy1 zmZ=cR`^jF{UhriFAVoyOc{Lul3_SI;J7^+?6R?^H6}a_X@fu6+Ad7i7RZe=e{_5v7 zG&15nk6zi8-s?X5yz>HKli+gTbH=tqVaMS*!SaEh3K*BG71% zy^QpFDsK+D-0two@_on>^0Dikf^Z#j1tZLD%(Ak!zu82`Rcy^6C!tf*vE$@_#@12h zN)hDnqmsC?ahkebe+L`lJGkFiv{Q%vj{CThhKH<2?a8u6Wp2nVoyh#^43CDF0-fK_ z|9b!WihkE|j`Zptx*h)Yr~vAGg>mpilRE&X_~~&^^moWRxo4ViiWdB5 ze`7t*;SL_rCUuXu^^`>l%M;0d5N4W?r;l!2$C`L>LX;ygg%o8Xxg$vtDNoV-jPlRL z!8m3Uo3yvzQA3jj%pD{#LqdknNG?dV8l{(W*OD4kB`lI1O|=#~!%~DWI^l~$?eDDe zC&&U~t~qdNaq~z_Hph7wwH%U_a9Zff>Qo#9a*y)^bkP5d`-+j(JN2DdDH6L(lr3nRVC1@Yby za;??Lo1{~5{|Z8Oy7R;7!qY$MeUwgD3C(tCf-t;Gz3Vh@%T>4-I0_9Ub-kSUx;Bk3 zhZQd)_kx#KbwDwe%k^)WIPv3$UyrUda^GH+F*?Ry@;{e9AE@|8=EjP6j~tP>-P@a* zQkaJ`yk2P`EI=4t?dMH2V<(+#Con4YA*--J^Us_@+Mj4cu=> zbbCzbtWN;RtN3Eev%+QQf&NZUn^^+{AAUaOIqA z?cR~6fWWI1>A&jn`K$9F7cl8;b;lJ`S58W&u0RfosMm=!t5QsYk_5}-Pe70?f*`Te zwEMNiC_0o%vPab60~!5H?%MHc`sEq%#uH~rs`%%4AfpYC5?amt%!g>EC$S1@VPbL` zVacGHt@f6YKu*%PJoKS;p^+hTA`ZwPRttk5AgR!ihw)qZ3mnhYPHqVw2+9DHZJ@Ru zF!ryJMExI2R~b}Qw}uZL(jnc_At2q|9inu1NOyyDNtcv#cOwYWT?gsz?&j|E&HbUn zFzTr5?6u$b$rM;u@Hvk$r-~PApI`}ee5Ff*#irR)cKuu8`dYD0Oapk@Qbfi9UN$F@ z<)k8}udFU`@3QoZ-nHSdL-XGZ@Y_EFoy^A{GDfsQirM)br4~^fu<#=&!Df*V$H@|z zAJRlqF%(9NQT2hxQ}0L5PE~sxeakyZ+w`0F!##iR^ld)y{bRMp6xXH&(fJU@K*^c0 zY7|Ymle>0KYxC7weU6Y}l=7XHWA1n@(P|_qhaIgz|6-!_g|$h~jyZ{SUrQ0BGni)P z6Sq{U8Ucyr0QwO2J+c*w1_({yCBw-4c)DIr9JbyW+(U!PNx|A%`B5x{Q(A^L3c4y^ zYrc1LdfTpDbQQjxtf?j=iwTiQ@hgg#Z}~p9#}Wq@?ILhJeWiU&?j_hiB1qf1LENr2`0W(8`k{;f}P!uJxb zrt^q_RBvBc3xHhIX2uFjMah!V+lYxBr_F9gByQa**lB@rTKKtSkcK!b*JS@yHo=nw zUl^fEGOAkY5~dcB52skPY-n9k@`>7rlVgP3p_&NE>7yX5fuvEpL*UGuZ@A z<`nSDeer8{iEKAu&bMn^_Cq^Fv5DjZaKIlNs>(sHkp>3VJPKA`Rhd`tQLOspIjP8M zJelxD9rpCegso@?oZhf-PA1hii(;PZXx&fxQ!f-NFY0a9y%|iCRAS{R6xkG-)IUdx z*E{2?jvu45l4@~rTw?%ZnRVZt#l%m+q8k9}(Q}!S^cc}u4QfAadw1eSRGj0-N9wgt z(Q&^S1xffAXltviMcS11wyR$?V}FZBhhZt~wLEX^{$=)e4DA-6(h<$ua{r*o;{Y@6wv{ zQ8*Op!?U5nQ-7o4g^2A}Y=-8Kg%ZN$P1`XNRslQI7d@8jRbKgJ3OwtQ^sK90RNTW+ zOX`+;UAnEsjK%To$ulp*C$#(Rdr*~AAS`qgIVv_oYZ=MF6xj>4_Kox(T6aPb{A?2j zZ$R;2c743sM?ft{5Wu?8YUQ^qeh4t57ye{^{_jEoBUb?QfbeC$ei-V zw=1@6?g}dE1L)GzPZ)GtVm5@`tr)sN>30-0ve$mixrfD;-I)EwJd3fY__xo9Wm4~o z!ap>@2mE(o1M5(}1{MjrMdLn{0!@-;CX`(3)I-$>acAAmxYeYi>R-&0C}E|3Axyfh z@#WgI^g_0@qM=VTl=uuzFKFn{7%Suag|^nGU4Vc>ys6`>IVa-r`Pw$@ST#(ibWy}| zQ7+B9kMTbZ#mH&WkyCyv`88;nWm`|l}+>!Aik6gZplGM#b)3FfwX)( z>~4Jd$A zFhJ}?_*X)9@yb)PrRso1`48&D{(Ks&sD{v)XbKxD97wnZisK1ANH3$b@x`{fY{apl zHC)~PdUdD$^g95-UoauN`_jZ=+S$%XPcy6j!nMO>!%3&}Eei}LLoR1VIzaVj!3aiT z*=?E=`g5dK@+X`_GWE31LgPU^*(tDbI<|56`DqR+jpG9{Apn`$5fa?!+3<@HYSZ%6 zn1?9ms8W;EO1hv>%kRX0St3;aT)vs)@J}q4J-N_A=EDasGsE}cuAB61Wkn+~k<+HA z|Db<>xah`=2|{y?>S7F-6GbZEmF=bsSL3^L$J-*i4}Mm)`uS1YlGW+b+P@*ol)gL+ z3&I?piY5klyI%}SKvfMP(`4ZqeS(o$d2TCP)wm|COpR!^Ml${ zn9XIJq@VP&>MVfZtqcw>STtMGextO;Pr6|_UgqWKI{Uhf(`aemdMzIh#CN>SMGU6_$84$lGrXfogO+s$G#Pg3^@J&GPcq!j`7a;*B+Ei z8#_5^EP$I;7ULj@;V(3(>@yg|vCzz@$&p=3TE==gqGA6rV+i_uX?yO=H35U!E;HsI z+pOEeQ|`*RWH_0l+fhWeJ6QL z;GYJT#H{Dm3t72cb}fCxg36r>HA`sYECZx^t|VEF6g`fIw-H)$se=K{FT_>>39KJKTGRFQn!x^0rh)>g+0(` zr(P8fS+~wV4Q&GGBUjf&jKew%*}VwRtN#1twilS4+fFd_n z-+#sq-_Udi$sawe^%ws-csuNzQdCgP4TLc?7GN)?BAn$k5UBw;7Y?lIlFI(9{&x$V z7fem^S|;DM!}O;Bew-q-=dbcI=}N&T`xW@k2wx?2ZAXF4M?s6nD-m!CnGQDvKbxm( zdV%yJO}l!cC~$aqf36x>vUB|ABzifw>`J%>tEiHVe$2$%0i&M=1%HW60>WoY-U2pF zRO5EjM()97LTs`L0U+Ca#T6V2Zk!*ynp!EhnXicO`wXD^5Fh_LJeEFy|K!^BC3Jo~ z?zkH7)zs1ph2R-^-v)QKBw{_)K1OBookgA zslEnMqoI0IkHd^(BHzm@f&-NK<(87WVh|_kztfoTw*+1SWF?q-DlkB24f=SkYtmI(BMeL!&B=5W z36$m^!tENstQrVz@A%L=;`5OE51wH_3onMvRwu|ARYyLj(IiA=p>~OlB@x8x8rY~C z9F{1@sTeKi5&Y*0Y-8hG}YcJQl#>ho+eAhSePnf@2vxV+H`BVD2FJXQxu3}?(N zX@{zE|94wn)a-y1tYEYEP`Wzw`;l;2DqT1g8Q6rVbC)AUwsowz?Ree_ZD}D26aD=V zt{ZZ9@}G4$Uq1rQKK?`aD%Rg5V z{`YJ~&7=y3Yqo80()_}#Rg|ct3DaVP3jcyuJw(BbIR?t3>My!JLRtI(&CU_7+cC1r zWA{(o%@?t7JF;>lvO{*_lc}J3*6m-kAgt2KG|J2=M+VDD;$9Y_4*g>QD{7mw zE}yPdX_{7WgA1CvJYJTZOC?7qLjdxfwCw0gr?`;IN8oW#9(5Uhq&4Cca(`{4XWY^; zQ^AqSpKQ{0?AQ`^TZ20Dbt!BogBfhNQ#L$DyhcA{vS!5k%Fko>qqBK^0=F{w^eqG1 z6kGn+iYLKrdUF!_q@*@2eY6Gg!7``m$VoGttcD&ReXpS!av?Vtq_qOw|Gw7)h{4Kj z?1w~aP|K&R19!2T)>)qYMY+KX!=r&*1SIMX*mf9ym>$<1u=yK9_=4#|c}OYD{td)^ zm&Wa|n&$rBa?4Q2vX~pm_A&B1E;HEdbViZD*qQIleA#Sx6TzEn@R!m;S&RsFL}rg> zD0sXTj3^@tQ{?hQ1n)+UBBv{CZ1LRxPC?n=HrE21hv-M-I<3adBNUT3SFR2IECFF% zjNP0zM@+J#j>Q^^jSDJok^`UjVqF-y5k8S*y5i!Q88X~jZSPU?riAF?~WPlevs68~9C86!PZl^1k#A*%>-?qh$@ z3Hge3?n|F1g0>VGiUg(%iyX&W3F~A1JAhk7b%!CRD#kzaH$H{(!V4?=sZ3h4g#37R z?7^%JF{Rx1{QC^XgG)xP*!SEus?RD&HdZQOaJuSaNNjSS2XvSNJMFQ8JM5Tj zq`NVRsGlJUqx&IczaYR;iPzcL`K7@UQ~xRhyiP0pfV2vvq{Ld2IC%6D^}FWuqeMbz zg)Q}*M;wUZQVcN=B36P}@gV^8Mn(NNAvk+!`C?UCE8h<5SoLYEq^;Tk$w-|74u-Xq zERC-DXFhE561+(##tM9JZUh_M${C92)f5-oJIR2cX_L7dEKmkzre&MHN=MLNE?C^b z5K)E(yh8nk5uXVW`X<4jEai_l|9l&MKWSpW)jpx|IQ;$8M4K!tFo@4V@FDt6*gCT> zBBwXk=IJvpYH$tpq@oG=2FIsAKZ(k03q;f9_9Hqe;JceWLCs9xUPTc3h<-X1n0#2n z=gXn~W4OvJC=*{rZwqRY>UH05{g4u7J?zyH>33V%AskSYF5+HqLR z&{F)pTjNn*tEV7LEgm-f*w6Y_ukk&WU83RNHoHj{+@|tsf~oJuCYu?8P0h9mHY}Oy zGp4-T)%=Z=31S7iH&V4+BX?S#KHra%^p=Zm`t#}2M=R=S1#tjn5N+5ecTqF|sKeDZ z-&uzmBMKQ=!8GkUCNq72v>`UrzRgM@=iwsGKiVXg7v3ska{xHJ@R6=9k~937wgHsL zah@#{%gPG~i5nao45%ZhRLbZ6J6N*U4YK~WHd!Kn_p>d_C_li_Y2{@3LYB4-VYw>7 zNHTOIqS1~)Iw$HH^ur#xIukH+=xDefDFPk}TYenRqzJ-V7mFShabTa-kX9Syo(71` z{3;nK^2aawBf>_rxv6&>E4Sk*?LyA8?#4LeXLgAdKvMJVG*F+sDp&mXCj_AqaZfAT zy$4afE0|1XPlr&biST&2Zv!Bp2wbQOgGJKk@%3)$G;KGu}{h$Nt{aT}Bhn7DPGLN^&Mv*0JmHENgpW_$U;Nk>& zANrKpK|3 zYWkH=?8d(~&qlTN2Bzn4@rRj@mI|D%M`!}*dr$ZJ`v7n+4loG+;P&-->b2XZG14^U zz+`(}p1BL_wlpMy!Kc3Po7)W>hp|YOO-v#R#epT>=$swIqDnNiG9s9PQm?A_f;S_agY5|VsDrrrI8Kkttys@%5*qq_txL)*`pvJ)tBFuX1JNgTZw*;^e>0?IU$ zfGXMKX~G4Q_5X+8(A+Qde=rJsRCH&6UHw+SeWJv~0MX`=^zJhc4D zpD6Rz^+c~%R>I6Z!d~mOxK$BcdQoyyH6PJu zz0${lWOwz8kE8^5E&anO$lnOsv%(pw%IK-fqL2DhBou8i)#4iTy{Z?FQ(mj!9k_cY z2*@mum-bP z_9p(~Jk#d{y;g*3BztttHLpgD^~2*it^ri2CG1Cz>8o?OuKi_7jfVu#(vr;m?3Wh2 z7PFAb@aRg`mL{t*!s`*3WFc?L0S?}MxHYc7^V$yT2bF9>|K$@M6z80Jyrf(F>;Xz2 zIga-l=7AQYlsE5|oLXo~!{}TY`cSGk0W3(EEI{o+CaqvJK15&NOcv$NRw~JVP@tyS z_3;@^3P#j<0S~_K@UW{m+oilD=G5~)=*|iLU>E9UseIW1{^gS|>{{;K1aEPmph{SIM1@m-yDitj_ ztkS9ZA@hq(`hSfy=Zh!LSF^M$*mHN~cN~I@b%%n{_fJYqxE)Yjk_;S&b%+xLskhZp z9WeMLfR()W)L|?B>CbI!i;3Yit%;3GA5c6T4N=yTRGV;k#g+9rn)!Ll%q{<&-QF~3N5Ld_RLvhd>BXsmCdD{_=gf}`YK6uxdt z6g~us#`)846+gW?vrV1b2HvpTgGCD6`#zG_8wgT2fp+1$`zpNnC3RQ-Ur^9#iNMze zpV~Y7O+|Te3+HK+!qv410Z%&WO+}O!aRdGgq5E=669f*O`5ccIIN_YdZ23olbO{Dp zx+ZY5A$Y=*qV;#@{X(m~`VJP95P4Dy!kF5zK_Ar~6THdLhxYzw|L!_k#l#08hgMn2 zT*Di>h_!*kCaTY2_#}h-ubU#J$k04rKBR;6b0r@7s)4e-zTg*c`ydUxP>X-Zx6ydk z>JYy_Ai?e*HDxyc00SXlHULwlp?RHsVph97#c2PVLQ~0{H6-$bPv8IcpM|s@uN$C7 z+5|M07jJJj25+3^qiKO8ZVOO%E8K4V4#7T=far_EXXBkgHxvSK!0PJi{tLZ2m*Tir zzR9F1=3FN7iQ<%8D*M6wz6*Q*pPf5T);?E6R?u>{wqmBNWrHFi6c%}ddTvP&j*gx& zLPld-taXGi7!FkXK3A^t2^qK8uC9{%800*Z*lg4@US0pR4zke{kBt%$<7J<6lj z?a>Zy3jQCpyYOukR}KrZsNwEnwZX*xnZZUT?{d=IqagPeP`^UscU?ecbU0fIVj8(> zo$$4vowESKI&r(6R?eV7_H73=&zpF^yJNUl!`~jO-~Lz#T@gG3tTx#UL`&OwV&?#3 zh`X}PI)q;@nqM?Th$LrP<^KaG5Y9@Rf{X^2gJ5fBgcQmXerUM2|FhS^?=AMbbavbRzn)wr2u}xfw>f*9sA1|NQ^wII2-w{s5x!>o5t}X8azDV5N~t265fn$k?al0Bs4d# z=T84zvL_#@8i3*ID_!32$mO@(kc!$S`3tU&3GYH*l;KG55{wl4W)Rz6=iYQfZU}aZ zmeJ%v{WeAn)j>{9+eu(}7B?M8{HUwfA3O5llqIGwplV}6^0x&SA_fMcVqs?ykWHkG zTO*W^KoknRb!QAe{4A2|`tsWh${e@P@r_M?t~jJph#EZ~3+u%Raof3nri!2*1R|Dy z_mq9Ts>vEqgPTyulsjtp?2~kR2Bp_aZmedQ*#E)Qi;Ih;>)sf$&MyFXYvtq;TfTjl#QlF9wJ1|>=aD8Yw^hH{{A^VsFeh2@o=1rC}tv>mylddHFxIW3$&is=Ye9w4L zwu_Lisr_24>5A-XK4)C3+r^6`DHN5GE{41sDKJz7 znjx;sSF;AR@xv(*`2&9HpkQaslF?wB2+D8tn`ZZoo8*N$g>BFm%-N1_y_fo4hYS4~!R z7KOuHR_m=!W!5@j+-H1V2no8QXb8}~KQpMdl*UGDk6mvzzf-)Qbw>)+u&N-{H*V#W z!sI`$fjRyC=H~-cMf`=R*?;~d^#PvFzYs+A*TKHX^Yy{Z1>m1HDb9BL13Z!Y{5JR| zT;ab6KG6|66#N<@jG#;46OJ!Zok!@iMB$srV5atH8!4U;s6z4`WkOtsvA3KZ48&wU zn!#jx5wCw+C&Mx~aD~-V8df9i==ef3#TMW^BNNH6C8>%Bx2S-~g4ve6^kRWFUQ_tg z3)i~rbRxxLll|TQx#L@Z6pYAGsxden*^X@Z^uCRRs8;qftbL#w?*13~fCZd7WYdRN z*$@|^1D(a_(65@e+HnM&A#I3ruCRus(f&N@Vv7ckf>|vK!HIHwKZ;u`s2n?#B=4A@ zJW^9O0;K=s#;7n$!3Ub{WF+@3Vu;*X2?wi8;cDe+ex=k8lrF_Ty2Je<3oQhjwNK~> z#S8V@FpivfCNJyX`eNF{C3vc}vAuc^cFGK3A@rxb8a{_BmM?eNG_+24o{zOq zOWl&|FEOf6J3j#mqw_k`NKkHbGhWVpA1&AY6~I;zL$tWZO-uF=z3#VBwgC|6{YU-f z#_GjKh>--yus(Zi!+^f=BB{5SkgEJlbQP=6pNl|YWl|QrZfX%RuIuMN8PihYhW>Gg z361N7Qn6T;5}yhRmR!6v_t4XdXh$UUw%&0BzZL;8vuDb0N}S$ZB+#LqW7>VGDXICz z_aU))b2}Ry#!wQxCw$?Z)%xtG%j&Qu;_>7*qxSU7+U^VuWIRKk1F8yf;D^%?RbXL2_uzd2jCzUWkR+Ni z>pG9&C~p=`gsR+$galCv1lZ+20SE}vS?T#EJFlXqridU6K5Mm6bzMSe*8~$4i>4QN z>txf(BXGa+gMwlUa5^-s;1mn|PD2 zq{+;$FFNyVil_lZ*?&MVG(CrPfkjHS+IJ$z!2r*6G(4(@KJt@Viiao)N#RB#-*oR2 zcj1ww^nw07afa81B)!QBRJaF~SzD899)SnyEm6odssN zC|{_N4tH;q>Spb!nWw{z(VqHC{saQl@8itiVo+l-|!)ADYgR5#eoL zDa6ssW91U2s|O~!WD~bpGo>U9EGQRpX!Abq6H36RZA&^n&1*ZL5^5Je`BUjTZjPLV2`{?^F21_ zHN_8vlFPsy!5Qn4S%lnCz8Nwz3I0&qC{=FjXK*ch&hCtoi+u*)-}(oR3dSS47!i3|%OjO3 zaOQ%W5a(B(t^P4Q;&Zhv)79>@yK$;@l7m?k5nmvrb#3VUdBZmG^zk`&eKbBB1KCYj zb0+rokfF`;C4Cwxa;1AJk8P{4FMLZ`B zGkp4QfU~4i^ohe<#Rc$fZq?_!FhTr89=VQyw_y_!Zw4p88J(hs6{Q4wVmh`f$`l?!1)i(MVq+7WpwK=VKcP&zH_oHMxwMnuYF#yjWdN}p?>abRY zt&pn5dO{NAPf4hFC-fjaZkZt`rsA($hF1G;QQprQ1~6$6KP`?mVC7n#n%_{2rT*S2 zZ7s$igN}=+Hg^{0T(Ly*jZ+7r z0&OWV+29U^XQ3oDskR1{h?1J73UQ)9;?GNosl)C*dkR^&f)<#d5B>Jts0|HqV|BX( zANa_CkA^m%=*X|ZMhSUZy6DTj-$1OPqIOyaN*;~#^B!sSK2Lu{bcPK@pR3_BF?7=g zD~KZ@k0XyYI0%%0D-T3aoz&Q4^lc+2)ts2CK`RBVL?vx<&l$rK{ES2SqL~xd$U^y6 z$@X|ZTPzd`sTN56{Y=Z6!(41UUadm!_<%3SLM z0#MnCTs^a+CI?mEr3#MXKY0dmD;3^i_Og+<@miNz@~KfxISEz1hn;ux?xAi+3iO*g zTp+DJt_jBVfkQKd>ylH^ve|gC{v!mxok$e=gH3s56!nwpW)`lyNoCcO9Eq`n6!z>U zOu$)iAEykU*xrj3-(2>5G6AG!^NDbx#l5aP8_!nfLYlL;0o_z0(6$FN;jYG_S#&Lg zN$|-u;dNHt#6CN*oskM$J$CPG#lKu?=GU3XX?kkc{72f=cP1Mf#x<^z=i7Y>$1^9+gxVv3l|{7nB$- z>@#7Rv%PY~CXmW*31`zSWJ@k%(yA7sr@L-_R?o@?lMoau^Nq6e^w1lCJlpuagimh@ z71qdtWpF$A`5B{*SI=XqTm}2d%p8j5v6uILv`B;K>7Pct8;3~zS;3^ z#eEAf?O#{R(qG{DU*G|7`>7qDcIA8RN|p3l^D#ouXE5bI82?{F;)(Q1I=3nu=XCLO zQ#EhdqB*RC3R#iRe?&;!UCzc=L4gs&F;_V3&=}KG(_H%$NwqHMo;QyT6p%%^i zH`9Y9LlyocwM~dMOcS1rr_eVCl+23FmnR#sNHys5@;^IJ2{_=t5G57KcJ5jM=kE8i z%{Ie94|v-_1ra!%e{Pgvl1-PwE&|3Wi$Zd$r?UL@{T!;r-Pgf(BY zni}#QknP$JU~{s6zI95v03-_qOe3*&4$UiW0E%k{o`8IG()e7<-Y9;XMjkWEw|SCa`{aoH^ak)Bjp?%G9lp!yi=*cL zkQFrx&E3k(eW5gMcr82RpIlp&5*_j!gz5X zsVNHwn22M--kbU-_@e(A!%a#s~2dHDwzd$1Mhla zAS)1XU2!jeg8?!AT*R2W+YSFTA%G3Y_|M;}zt+R^pEO{HOlU2AL_&2*qC7CCX>JX! zm&ohWY)R)Trxf>4`Sv;RX%!^0i|}iKcCe_zc!H`+se7zVVv^p`YcX~4$r|n8>XG+Y zysk8HKTmdN%g@{{)GE27%1d|V~$o!p3C&4`cg<+hy)>lfBN!>qTy zGdt}lwI2!24L2Y2L_r{-mTiLOHMbh=a&Hyn2(>2N3>ru9ARDKBP0dfG3(w zziq5-LLJwcayLWx2@D(IE5qsfkqDYNSf9j~D3nQ^%!;m*l06!-1dC5!zXhY#GR&Qm zN>C=5sC!^5xiL^WE28~a_PQ68>kjY61z8rcS@HHGhzxuP(h{>q+>ZTAVIJO{g`kdw z&-4jB&63;em!N$$=QNTv-Lp-rD!vO}KE3Qrb$zKi$^lBNQhX%V3j+$Mlu4|uYBb&^ zG=r%od$9xh$XbOl`%~)LSbL3b9f$dX z@J(x?vG5eMT%}T6oqU^oZeq`q1jhg$4-Z$ucIlwK+4ScUzFZs39YqeFGeo5)=O%FE8UM>4gA`t{-5T{afrNgahixbkhr=`O#t8Ijs9UaLOO@YOUmB zr}Jr651qBA#2;QrHGj?ZJ#ABfdE>HpSdO~>=Lt?-{ok;4sB|i+a9)x`voRf~UxY4? zH&jzw6+JaVCNuOd8_|>Ubyvk&YDaznaOu`s>QNv?z@;+m9X{GSf)}klH z&P$G{ctub9LIfMLrsJna<0co!@hDI9B;8geTxwqz$&A|na%+rK*RvpVXuJcINfb*} zAN2wOITA4V=V@CGHo+7?w>I4k&zfSOT7hTcnQb#G{$87QN7cTo1oZuxW0{t|5GSJJ z6w6iiZdjqe>f@Cf?R+v@1ijo^eKpJCYC)u$MW4o=N>?ZbIIA&&g1yOgIH1Z1u)&Cc z#TrY!t4q}zNW}GkP=aBhifL4ojA%AFY!0+E$7cd@z<>U`$KYkoU?aKOhdKn~{tU2{ z@4v+PY*S%~NeBaSr|a;ej^TE1Y&MaYnp;rJJcSVu5F@e{5@dQDEK(Z2|8o~RqKoZyU%G%4y zcORETsT4Dw^#GW2V40YHSKBauzw^;Q0>~op96h3{XCvooIhr4JaYjJ$mkpN>D75+# z3di@t^So}=V1kQcSn(~jY<6%cSxDK)pq|nw0~O&!ZSvKlK&;PJdWr6%VRe(z!J(nT znP{#{XCxi-m;CmWqLA*^tJ^!KjPEs@N0^iv|Nh;m#x(-UcEQCOks`EU;y{vNBXy3w zu_g4qiWVY^3Bm4ahYVr~24oJ!Lv=*jkG{*Fkkg4;GEaf<1!r-y%WShjkx7wXlp2#2 zuH@6dcY}1uCH8PdJM{4EWQIaF;8~&8JuvGrWeTMlIo9mMI&#aBouJax_~QJf_*LB- zsihYtR0=J?d$5PU93nx+S=zEaw6JXVNn$#Ijj931IJelW)00YCW*`E5+O7)0Q+yuB z_mSP$Rtc5{YM{b+6q&Zb6<9})gXtQ<;h^4K@GsV?8SXl!AJ2krh6}SIZk31(NU`-c@E`5EwdB6c+yH^i@8-XNO zk7jbC{|3r!YR9;i?(Zx{)7Xl9pKqIg$3W7YAZ`S#jUSLdC{E~cKW!e)mYnW!+`TIp zf*C+IQi#9~qOEHI9Nlcd-9-!tL5S*x9i!mhybo zF_!@*lf8aFv8y|gjZP8g;Q_x_1XlD@krJ%!gC)zOOXUhDodW zH|N82Cmf6t@`?16cydxpg)ii)wUdLn3GPPOz;y%AGI>^5AG?W5vCu(F_E|!CN%~eF zGGqkq=}>tMdQ*3DfcI5i?!`aeygM}Ia=or2dx!#zM8qPQh*(WEq>Q%6`#c&;v-}}& zMyM|Wx4@=b6c+9+vzhGzm0yo=Y!NPGt5}Z1A#y;?O3iXs($K3d`6TRxMcP!`^D$U0 zutQE6z$4ix48a@WC}qi}Pu+it8`I1~@OfpIEcR+BwBx^*ok{FGVF#e;X`%fPyGBXI$O`2A0h&4`%yio!tHrCxS9L_%2`2R2d4fi!Xh z7H+6m-d%i%Q@?B34)E|o4v>A91pn+m%qZ$(7k<=x=0AXt%X%3|y#3Gr$@FZm75&tr zivO3E%z&@zsVfhn>?qJc!!Vyu>aVN*i|&j3)0n{Uplo@83r4dO4bMdDU)b`Eex2av z=2FzO$eb?)SFHK>ZM$A|rmQLQQZv@xcQ6{9p>Dv%`XYV>(OCvsPhFlC{uKGvTX^+t zD6z>Y!O{gJgeoN>ixdNq28@daO^kWk!G^baQtJUizZRENLA?xCqXV`LI-eaYFH)a% zbe8quz|gwhi?);1a;b;~Q&l$dKX6qzj@H9XiZ#CT4G8jX7H*^oCi9g>$}5)=k%ofhtNC z3rA%*mW}A>f*8Gq`Pu+~DUjazF|$i79*R=KV913Ja7Z<5^K(Tz@vzMKX&u%FraBKAwYl|!!8}k zwvJz^v|eBW*yA$x!?XBtuGyB0UzWHP_c{i*)1p9 z0B?mHxHg9H@)1`X0lnIWVo!BpRm~2&?6xc*;vMNhtt6#%8Crc|&9;}`<3B@JL5|E1 zXPAe|VCIU%E`$nVuB-&xc?vNoZ<(+r^a{(s?MIh8n{o94m_Xa#+DNyZKGBIw~?tdga-?k9A z-4*7L1zx$49v*n}o}yCnu9W;F`hqT`YQGs<8*@FpUPX#wm~6+^Q!F_S$4-O$SVcwn z!P{m1Qv8Mjny6l5KP4>hME7#I9h3^;s*i?In6ozx3^);V#J-W$0RNnm^1c#6RKx?1 zwEM1&&TTn&p@@UMjq+KA{XM+ZBYWN8*r=m&;^FaSg{{#R#qzq}_D22pSU3nEd{p?=;AaNol~D1j6f zy+}wqEd(F({rdVH5*u|6*#7Y$V(W%^NKb7IAi?zkX4%`n1||_~22;3^f-hw-J~}iEt8SV)m!lDolvNe(hF@?k122@cj1xfg+?RNub?K}NTlPvf3^2Lb>3y8Wa6n$u_Ey= zeyO9FI(4i5??1Tm*)qBVfnB9AqM}6aiBntgu$6m~r1DWC_?*CWjUi%I1jvY^Dr4i; zLjAf^O(hAAal?R%EiiNU&Q%a0QE>dHHwf^-6Dy(__jDLd;6G)+y}*5#!R)CQHoE-G zOcSp55FTYYs2*J$X~lb_$QdY>3>xg zzk$RRh(yn9VIlz^SSHT;fnKZ97-&hL7m3O5GBIZzSR5oB96myHyb$Hff7(u9WUMsN zUrU>@aNZmL3sAL77mpC89jME49_45KTCmsz3YYiy4IO4hUyVS);&oGY76DQF*!mxs8nJ5Tizh$l(I{YBlPY5D;uDWr$`$Q=7Po(a+Gcm zf@WXwaT>gn=~@3ndm0`<)~R2IGxN_G9q||y#=ODLTS%c{rBAw=Qni(b{^O1U3dixhK5KaCX060(@rrNP!-hmOfMGxapbNl6Q7 zHO=YO?pQCdo7K7;JQh-W{@em?RWwL#Wz6d7|J9X9K(s;*D-TaNMEne4ySXj(OuEV- zzI{O36=GA>3g3_apL-wBIsAtO04~u-_>(?U6jYs<8Act;!4!Y|uT=bCI8y4vx^QhV z+gSXe!esLb0kdgT4l8Vds6V;V{Sm#~5~w-oN84uCiivz1RmUZ02Tf#}&Z49}v&;br z#0=U9+-n874fx5IKe&+`G=D_K>(>S3s{a;Sq^z4V*6$ZwqZ47?EPW4QPox|;{Dpj{Y98H~)P`h_`WR8WSpK$;vSdU-M6 zlhkt>eOWLgn~)1SW;c>Z@zR}?*Z0!HmX+zA>*PCbDjW3fB@zGb22ybm3-{aC=R00Pbuchc&akU>|nuj}F|U+3Jc*CxA-3fW(o_r3EU z!jQPIpWz3QT$<0o{n!2OV@H5ihw)42EztIH{I7ms`FrptkhAv(!jP?n1`Qm}m52E{ z_yQh<>)JS96g+(oiuWwK;XOT~S>OI3wOkG}I{q_DmDE^FAuXLDxE@aN-=FDiI&oqB z5VV{nS~!|;KhMU!!a=)zCjPrph!U}ENy;eF_OZcmE-&NV#KXFHY&G%yLXvXiIwk+* zXfk~EVMW~Ko4`#!Np9y%h3uq@!tjIg^J;afPZ;P4qhe$nfgt;xAE|S}c`I*q%ry^i zP4W}!MkrE{C$}NgIKTvfzPxAo?a;SXoLiO&q<1#f{L#jWY>wglQI7ntkabmhi2K#` zNn&6$v*Yu~(hw1i_yM@LoB$@ui1J)?=XIM@sk)G^M{;IZ@9%6Y?$uMcK-|77G`?M` z95od0*up_$8}>(pU9Kw1d|w601E{R(=wFdQp1|=MbGBQJ5^RzOx3{0&0XI_lf&RGS zCKT2h+1k_S3x>+&Tn5^+jODD^sr(>)r??jp10vE93dqJ;IJ2J$avuUeM6Q|E;gCR9 z;UVA$)$=_W*C43w!c1&o(YhnN)2GoW8! zw1KcT$XAfOX~=gWEZI8H3p4<9dITE0UZ(QgE|}CFRkcfXs!B%!$$MA-y)E$!d>lug z+<>a6Euc;)=I;GAo9m&*G9(!nn3DK*Z|o;=U%C;50)RqjVn`(Ca5CdXnDDdFXjOua z*q44Pm8Gu6fbfx@wDMLooNO9?^AdU!oF(l77A^tfVSHq;P~Z>^73ay8$kMWv+%Pqd z^8xgoBu%M*69hT26nJ|AUV{U3q?&E~9zzT4{)93z0V^@trR{*<_vJWCgP>M&1n&u| zViASiY>|dAE2!1vvhtnkt@0fCjBNK|o~2>#OEWVsiV**vA$-T?H8qq$cVr#X z_GhwF0gkHzIXzq)yEv_D8B25z#1I*7o86Iw2s#9*z^@Dfxp`0d1!O7m1*#b=u6Cql zOxXT$O}z-5m&N%ng=g%oVc3XNP149Dk2(bNy7PM8BOuJ0d>1I z3)DCuD>;Jo+>PE|?(X{EaZhYH6e2Wtf${3e)s);S^E7KVmNvX zcCA%?wl0_wDVSPF(^Ocrq!1lLM6q1jNSBKbWF>!Em0DnZbTBd5xP0P+*GGHrvqpVu zTd})iH{iKtp}pOP5*)6&(cP1=aWM<4IitcbOdvzKaH*a@Ohi@pReqPaW(L`CGx&q1 z&y;t79nzodU(QX;a+RvqSAdVx6Ftwo(1~t8QckK@exgGqSeZVh^ zR1RHdxr1qy0>%wJo;wjHK;{IzCBr_R(8fz2PlYNk2XZB?eS{098Qd$mu6F$V4mZ9( zt`HTsY>+&QzNk9@4V--HD)es8i&oB2Dh1sq(ZpM0GO zfdRpDKKGRm+lFk_R*(cj>9i7fQ*oLSr8BWT*qU>*9B&VKX4F!KctF2@Gq}OY=XlJm zh2dtiZFsf!8b}jWV)Br>Gc!=S=;!XjG>2t-nDD!;kI{a!Y+rGYG8;)Tf=sEmF90Wo zI+DUnbmb)t7kRtp?~lmV_H_W5K0fVcr$rkfEUnX(OYo=7OD46G@F?p)^02JA-oI<0 z-vs`9jR2lWm7dQ+`HxMKQdFa;hLO070G{Yl$~$(;_-Nw1f8}n+NhrzkEZJfQ9MCp- zVdckD$agMS5z}#gI)DuZg3?i-P@r>$c_DVV#k=K+rxQIkd(E6_@sZB%M=u z9c>qecWm2EW23QcyGa^1Y#Xbw+1R$Nrm@l3w%w%hKl}R+a+H%?nLT^nS?hW3yRAZZ zMA19S8^pM5BD?8yuq z?#?y~ZWVIaIgpk|2@q__a#4PquJ-L6!1z&1&Qlf)(_-{%)& z2#m!j>iW2f6SM^BWM?M9{|6r~sx&7>XchwC>WL8yX-8~v{cf%-DAKUgF^Qm^(Mn&!nHIMs+^IU#ZeQ?I$dk=iEF zOP)zn;JMERR4@_4P3>>Gyu_3kR*7(0OvfmAP=zzv35$_LU}lIU?CN`jm)PUz$3a-< zik~OAz$lRzcu@z?R8Ri7d47J2%KK;T^i4?pim0&+vR~4vR}R(TjZHQ1JQ71_U<)q1 z6x5DAoOY{8%`%^mm8af#;pAZb{8R=|Q?i@sIgbI?awCK)X8HMV)CJCA_p8mG%JeOZ z{s&$davUg0WMZuzbQt3psQe=#x16to^ot0MVO@bV;@{_rm8odMW+sZ_o0GzF$Yqg_ z2-Ve09CrV>dpZ$j|8j6Vb6S%y`m>#k+dm3ft@FumUd=Q5{h()k+hggu4}u)4oGe_b zT>IHRX0+k@ir$lN0LXa7f(fPfLOK;+f!v5A|E+Y)f$)7@>~^Atat{DL2#&GH_I)~L zzw9e;Aj_$LW{97<=Qku}fdvQyz={lk7#)bk*O?ifI@ zadFF9D<%X^cPB+m8APRYLtSlrWD~`rEJr6|X`k2|UX=FmJ78E)q?2laY6O)GpI7qz zYXmthmR*fyHjkqsu93&=?0j%|h?(Jo!Un0t>-tGD1)UQ*rcjie`Yua4VB<~-q2q~s z?YK;kzD#(t2V-28T7-8s{Yu z-kl4=ec4l`U}k`fELDB7Pcc$&bM|p}hQfOJi;3Ilrr-}j0b~zt)EIDceKoZ#HQC<_ zonnm2CxU!f5E?(4Xo|OMGW%@!dFHD{F!5hfoiyXKeKzfBbf&NCCoufdxK5Ssr@r?# z!FWuZpcm0yU!30`wL<=>^Z~U)#6W#dHOpZ>YNT4au&mrV?i=)ZzE5`)ukksz1|nE+ZpCQ~0Xc=O35;yN{UV;T=-x#}ge!h4BR>_Z9}W2k z7aPqg>x#-{Y(FTXiUH{$;AII%A^0lS*GV(FBwb7G z!q@juz1opY!QT#o^V-X>AzD+=Nlo-na63XJk7#Sq)y-dgR=pQLt!gpZ=NUeQ1hzPA z!NUg%s2D?1oMSMoGBBkeR2jcKnuGioFp7RMEYe{^w?6;wrPEHCi1$q9W(zYFY5SKh zV|Rn6{sp3+5v`KznMHMFsAdsmQd3U;w6uKYTLI;LDTM*4;6!=Vz^zaxzDGp8Meu{_ z_d^=1oPW&rrO`I~tdfQs(9(5u)~0L34`EjAUK==-|HzQS`CwJ65P>#6F`uA@bw z5tF-IMk>)nDHXm&B@V^ov5GF5|M~}-+Xd&NmxCMa;V&FF_Dj-mfVK$NjkuveRj0i; z4bZ-=jSBT@50%}m&Vd$$3aeNDgZIu*?=njJJ2w9;_nsrv$Md-9-)c;CZfoOYyRG zH|-_+zz@>O>+zo_v`8VP z7eA2q*EY(>?I0|tDKR+vDRA@Pbywt#3XIv`0qE@4M4dn?>uSQ$7YpFXEd2NKe$F5O zjQj)np&s*fX^W!yPn)ESe@;MzQBFh?4gW)yN7yDEbe*!z;3`kehqIBb(T%s7(+|X_ z%h^E0QIecp?MJSYzf(nBt3K>O5~HwZpdbu1sx(! zhg9AUKRAMqR$IkWTt<=Xim0ElMh%qSKRRysetZw#d@k>7AFh+<1tFkC`F+u=4dTk1 zLG-&!aRsJs*Wxw?9CW6?P)~?DCi+TJgl4q*Ok9+E62dq|?A?BZjIs^Uw(2>D%sf=> z_RNO%?@c$K`EoK7D$LJx`mqE;t9*4=ot3gCV?gaFS;e*$nNbovzvn8PE2WWxA(m>! zD(?Nro>#rcpJ4r?5stB+$=S>l!abL!^Mb+KxQuY(qTudQZb+P7O}gkEn&Y_P*F+3S zrW}`DmswzYh-Tta=Ov%uLc=-Rq3sxAx}cP&WEgy7kT}Nv66L|V<(94_Lr5)oLdtH+ z+wB6bm;lPAUma65_QC>io@=BnofryvLU!rLg{(y7+e7!>!OFqEe|^5}BD+2O6Pkz# zN8&u?2{8ri;)B-RHc8~_eTO-Lu;n0umC43053oo0itPQG4CDva$LkpTaBtjZKSa5> zB=CHbP2_LrF0IsCj~fR%6z!#X6f3euMe5ulLh;jFW%B|Q<8gjl>)R98-uw#gxFME0 zdtOjM{zKIQzlCH)SA)b^SA5x;eHP_QAU@|Cz~#0t-{@p4L0+|2mPU-LZN26b*oc&d z7k{hN)-zSdhLSBhCa(R6kF1Ze?GNSSx}6F2_5T#V(lN-HrGx4ep+^C(Xz`f-lFxQB z&&Vj#_kjZ8Vz=@lysJlCVnh~mDHA*e=h)|raMDazcxPCpn92!pBs9tyM4pY)xy!D( zbiA1MbjEcui=od|LHD%1I=hdF85AIrMtM!k?7Se%U}KSY;uavt{_Ue{TnkLz);JvA z+3j;;AR__;rOAXa7YjJClSWjf}N@ zBiZ*%DhXB4IpM$*<`})TyMKzyE3=*}mR&1M1AbB+}H~!8CeYRJxE z%`E?bU}XeHM@S6Kl^$dXdc=u(zL#Ec1dJ;YdW954}oqYQ*KoFS?9#~OK=~QL`O$e?gFDFi?FSBlQgH*(0y=}?+h-lZ{(kQ9O zN(rJAxDnc76ca!BbSWuB*pt;`l<0s$dQjP9Jjj#ZBzPwdbJ3|rviNn zs;gmAmHix@0R1huZNjkYG=&jl0kf3KGN+t>} zRu(G#pdFDRIf!g-2b;#95hYYo*^l=2{l~c1duLyE=m=p6lhKZg283>(h;ggK%sUT4 zBHPr(+ir9s^T&%{WUUq-0~X}@A;6@2O*JUtJCPD{Sg>3MnzL5_3=>!|aJ~H-$>}noi#1>%lQjN=T)^ z586~VQQM))tJj9(TxeStv})gZJ3!??;Z*V&v7Y*>8~H0jE6yscbTsdq5ZH9kPR9>x zamM!rX@61Tli{2rlXc{hB0&$@7*b~^RjXw;-9^`8G)Z9u=UbPG*JpRA-wwG64Z^@{ zox=j&LQx=$|KhJUdA1?Usq2Fy=Z0eY6PDxeNTlaC;T@a${9g%00+W@=gT+j-^(_J< zljG2VkX*{smuIomhI!_!`~$;ZHqV7Bl%kS5rtr^c1S6n&yD^2lEa2}|jfeL@#^^gi zA?~pLO=k}qB>c@!KnHo){SV*JPpn$PDQ>UVrG6G(#6Tb_ODTZh%Qyd5$s33!kuB%` zc?DyK7hkv78B`m;%rm?IErkJqh$quD0}RMb0|hpflkw;1^(`jQG|Oh$_N2uKpV29m z)Bv(W264sc$n;H+ITp21>o|Z(EoeT@+ zqC;PE_o%?ViAEedfXW4z?=)>L3-fh_&?U)U9mR(0~M^&hy?=nONiQ zn_|wo`))KCZU|19gTO{x|NplB;7|b22>LA9Y^^a+I%@@}!a{lXeHGjB88{QA^QI6n z0dmgyGCrwxluiRPGf%=&dxPfcmmeoASx_LC>E@_dW#-|)`MtPm3O8p?snt_l=x_2= zlv980gD&;~ilZvXoEEe1=9Ni|QtNn@ZVjM~6^q&nBY(t_w1NMeOXngMpp~H*OJOlX{T<_RLoZk21)qQ6YT_8xpF0^B(c7UK%9V!G|Gf&6#1`SW3g(3JA;|(d zspBdh()1#J@w>bK7 zGvCTUgT2ZMo^`EOpxNX3Xcjd?0FcuScF!jY+Eh}`>cbKHzz{01E_#tzp+SN`R2UrE0b8M7fQ3YF01T4T-+3LCf5Op- z*fy{4A`wnTdJ<^8Oi{lL%b4O$p^Ka+@Ll|YIxav!Cb~~|*Bqm|12r^Ew+~Op@hzMn zzt$hOJ20v~3O@AbosbI4CJY9TW`1A#qyTM5PK^bC(S7q?!!k{lMJ+QbT_ZFl9)F3g zCXKot{meu~g!Mx%%aYZNB+oDEskEx+#Rznxnm^ZNI@ZNXm*hwr>5Y756Hw^r{2t)G?5IVRE8_w{xko7^UjFlH-9K=0T zLk&pq^KLF|@zo8YR|l~%Bu(VBexFfwj~agcMM@L-cBN_Iy<^5sk{cetOLedqS;Fe^ zC6UwM7hx4)re5y%&p$Z3@<%y%?3rF3_sW>Ni-vUdhe}Ko-IJ$0D02&X(~QV9>w%1%YSyH{45Bd*_BcnKMx?D0jmd) z-QsiCQ$<%VkkpI?lEQF~UaPA-W5V`0`e<18Rpcb|=U&Kl%YC`c4If9ZMbcS_W-R^4<^JT@$|js@~yvm8%+uFF><=z1O5{$ysX<+XSV0$4bEqdivN zYb5U@2{K3%mvMkGxXkq2h=U!CKIg2_u9waO8FxWZis4VK5bNlxDk%ybR-q2eC2LP_ z*prw@1Webfsv=vYTlyh9prRYesF=yLD#f1eRFkCeC;3`ea@nnA0^Sw4Lrw>9sumk4 z#1xTrU*%fF(qSFP$fJD;4;GE*WxA6HiTvlVfA>}sRY=hSySkC+=WO@% zYEMJzkcT07EK&2sfHQNG@}w_HVSmdBMP`Jfu>r~~GG2BtCSg%E z&lCrfc&P|%b1Rvih6yKnv6W<5&E6B^uUvDk`A&bx1ms; z4E~3*bw?rnF3ptlxf~j7_vz$OXg;F%vEl5Wr+^%|a;AKjm1eqdD=xW&-dH^NEsy?l za^X!;5LX0*)H+PJ^>B>XIut?J(T7a9_DM+ltbccU^B;ezS4t><$6lgL&n?QPDLeT& z8jcO?C@JfC55HT&FK+18DgM ztu&%?@q*hu|Jc&ncq4{o_n?~}kdW*N{SY{d8P~a7a9t%9Nz)MvayzRm`%f(23a3H% z=Q1fSHL+r0OBBL>ME<>6nKo2;ovCX{5h>i}#ZD{vS4>|{u!9v2gPgnibG0XZdoDDR zv31`%U)e8%2B&NYSj*nqdfIC zRYk68MFhj~+o*p-+gT9~PV4(C&6%gh#;Q&{ia5Ttu;YDPxQc@sOo6H-#qu+0(9`*u0A^rqJ+^yP1!*nWeU+vmlkr_Fv75Kpwk zJSmuyWDCfO;2cW1wDZqr?C&}2p4phKq>AOZz84BRHtW+^2dXd5K>Gn19nPDYzU zX`6_g>X zP?Ya!qf}A0C5cm|oCy+$zGx6fvTZZ2=KCFzF{5{VBt^%hH#l}1TlE@PQuK8WQgDJ< zSol<9nl19DEgIIr&0e@o&H>y~>m}b8_FR5xa@AH>!nor0igZyUTXMl(@1fnFd!o=P zICamu4#9RqB()kT-0`t^x;<#{W>Mmq9`c8SaQLD$Mk}3GSsS^Ri5WN_?Ba|d7HI)` z-IE>1U~0_~czgL79Bw3WTk{7}JE*4%%X8B}?l~05zk02?IemvoD^8--43RwyY$%@( z)H>$}==}|fY`S)-Ti<>K$S-@)9hnZyqb&Doav#A#*{9-#nL*0_5vL%TRei`ouxGJL zXO4ztClo#Iktnv!o*sN7^oCTg7~Qab1YrHg)kSMifnrd< zLZZ4=|7A2`nFbOi?ed09D^{`o*EVC5>8)Y>U;<2^if8LZ zOKmtF+U@OrX)k_#s)wV>-L9xw<12mQn)XeI<1=glad;3$cBAQiu!e0uUQ4hHUzmWv z@~1HV^rssgPPA<{&f^>lYv@KcD-^9O$2&N6w*0!Z-W)pWNceCJt}52_l1K-GfEH=b zKr!_BrWs$Q`L{d zl`4t5i^jnd)V}d>(ak=Kg^?ghmYG_Nkh(T1I$-P54&+w5)PmNg(}laV9{~4I7-_z! ziF!KiZNB*mT-OhD(wD2T%A3tFQL*AFallGy<8?sr;=p^UdY?J>D8}@ zFziy$cJze`Zr{EN@I8BO?WGE!s{1LM#PR=*@Pjvw0bxK8onS0F3!SZO?8^Q-MeByz zE0*gx39wE(@#>e>)}ww>x;Xfi|1+|%jU}w>lXeW_69gPP$ISNiJ|El9q)QyKxTM?m zGRS&|X%?(M$S_XkJt_{R>%m}1ej7rz^aRI1oHBDq9#&(Mc!MH^sj3d2h-^esN27c zruLW`rFTOo5$CF({A?>`_Ty?Z$gQBVqkJgcftwlh3Jr?cf z228zt5u5xXtTX;|7{xN~y8Duj{P)zNg0+NjgwN49vr!nUGD%i5#(0bKzw?a@awY<> zVcT%uKHU~)Tx70r_&CTDE>UDW<%o(lChe!y;&ala^Vkz+``qgR{S01DpX;50-`OPllh1BW=Q@{%(ThT&)!()HT1K5ws^n-LM8uRl~BZ1VM(Q3wDtul+{d6#f? zphK?-ZDLe3%Tgw|l59`<<^6+D)OpDQ-)*sf^9jW)R~a5URP&22;26A zstb|FvMR3^Bzzjej$!sa`DNaxctd)RP;bGbNC_ zypQ?m&#^0-cAsSKVUaQKpX{e%`p<;<<<=^z`mqa%nHh^3d8G))u0_gvp2{?C1Tvh- zUM*xQH67J7V}&26eXr6IuDN9~9z8{^?p<@hNV%=M;Jux8y&(avJM5Kn_qjHp|3K^gV=z8>o2r!GN>Yh^2-Fq?;$e~&E)b$4@DJ%iQTZLVIh$TZo+nw52xkX<@&xVJw2Wp zCAqL&G!^aJS-;D4$U0SQxMwJ?ZF7i%fx;A7-8cIb+=4sZ$w^KdZ@P(5nEN(3iv@|H z;tN?k>UFR1t`gao^VkH=PhpqeFMI999hoL`wPwv4vzyv4vW}AkAlu(>7_Q1e_$F)8w}h2>vQ$ilg;Q2mv<;TKl9X-*dU%97R``3$ z*Kl>Mhe8(3{Wt7xxFi2Q;41WKXfZu>5l%8Cc1=o6Lk|WHMm9LiCDVW?pO8WCoq0K* z@pGH##er8G^GE<-`~07od6+78>*=&cCi~F+C@<9Mc7ivy9pcq}C{^b6_Kye|u%L$n zWWFCN4O&pW&YSSSy9Q#>u$w#r%eX8?$w zzQn%&wgEuW5^!;yfd0PyoU7cOE`GRc89-ZxjpWKNSZqFF+dpVqx9$$V_YmT95DuH^ z#u&#A)FVdl^q|pM4fi#k^Z88~)kxh)mdKHJLiVMvCwP@h6!OZGK>jZ{>D;zz$L7FS z$1X${NE*C02vyp!`a!Ju&NDME9SNptrMvdp!8FMyqb`cd6=iM^hp^XHF3E27+)Yci$;5KadJQkYeap%m^ zsA#)NX@FB4b4o^kV$0}P`&hjsolpyj#*0OolH-XK7vK6h#Wmilka50VamHxK3(d%x_G4FM7+ zFoF=&oz89Rv==Y4!}_+v>NKyV1I8?MZ@x@ynyfV10K_z5_5*ud{T_UONo$eQ{Ltti z;0USO4yU3{R>fhm3*c)^+AU{w2|SgEiqO@X=47WQ$`&(d_@m(_hB?%pAfR*g+D;|* z1iy>(ce^&mRwT%?o+ZhqPMe%Oqs1ShqFits54<`nvtEx?lj5*fVh|AHqxC0no-CBZa{eR6P;Yy2@AqYGbzCw)<*FK?@P*osp zw`2&^RJ2* ztzDQWZgomPk6buiX_az6)IaM0?RWNwbM08HRSsDgP}LVvQRPVZDxzDc{eF~MU}+Ou z#cKb|MdlJxQ$Z}G7xSSbdn7z|X8jNG%kx7NXm~g&9j{ozUBQ!+N z5i9y)1pbis{*c{&)`mGHX*#6&_ ziezt2H7dYeJQ?H>OPnP-5*I>80gNpLa~=wc#2Kn63`#pxAy9bu%drc$ z95#M`o9MTJ-;5{jkr*k#!iOJm+1XemS*?Q&;WkD<__*cYZzJ+qgz|zVGlRPQfs5(m z<$`vf10$PgAx3%IU(4$JvN8=>f;%gCDx*7%&?_bC_`2dS9Zs;>sA#4~sf1gGCi^W%2vC2`JqW;!*tX z|FEQ|EDRPvvJgOXEva=63x$U8&Fs$(qJ@Vq{P~z=8J01Y= z{2tllFa8g_2`JfNlN+Wnu*qJ{xS_c-&C!9UD1|o>x<8Na+~LE{79AxDd5g$ zN_G?3nuQResu&7BIaEi4!>)&*mm&psZCuB(8x~zh+CF3ky9wP0a;UkRNU(H5ee0zsgAI6>K}lx;n$p$K%DxoF(2M9qgjDujGLNcPZ&k(yaQJCQKX8|&khpN z_lFXueb@1m{JdmL`)Gj|C)|sV)c(?j4pQAc^pPUgt+V-&IayGfmdpbio$E>b0-n0H zER~qU5N0xSdA+EiE_3kLEoEmArSL!Pch<(JrR`qX!n0S)G;&tvStXx8u%;h;0Ld_? zimDD`bRiC(nd7AUE|i4!Dg5r`NISfK+ZvtK=<=i9w@%vPuyX6|poJBqRZJkKG|3Xg zK0D*%==U#k_M>XlbvAwqKAI}mh~GSh46#2TA3%6MYe0Jg$i9R*Pje;Ur~GblhC{Ex z#!d1>y-ES3R3sj2%v;xCwwEe4jO(V48>qRUN*xjCfrz%m#(FUswPS+~)j|r;AVwXa z^%k}Y01RHd`uy{eSXxbVBVsa2r{6`<4-g>eiYQQMB?lIG5Iil$M7qPJY>0wG3}|^7 zPF$qLiU#gX@_uTwsKcb{Wo4M6v_kOk=GBMJAW%;g<=*IFI$@dQBXX{=)S*KQA9?>g z(jp2Mp1;eLMasC?p|3M!Or*p|J5aSw!Yo1uz(ndcFnfXgf%bLrz%%?hNvo(VPp8?? zLYvMdr56}d8{xm?Wbus0S_|$10;L_mAStO)LMrMGBHx05T+aX)bm^2X`G0$hQqRcQ zN(0C0coxWi`dQ3|&nHclwozl5Tt@Pk_wR69qmraWN1Xl~Byo5^+An-2w~P@lSLGao7`o6bCxc+ITNIYch`{{y zBJySKOF+F`k85E|po8pgp*qzfz)ukIMcOgvvi>^7g~=%PhmHanvy}%YMoC@Tpt!VA zSb&*2=D7J(g;-)?0YTda#<_|wYbo~F)rtMamIdk4$4&#gIeE|aP_WGso1MMtb4XZRv zgf9TNeC%Rv+rtEKTDzO?ubV$9tEVf{`uQABSV(wV=UAnLZURMd7mIK@aRista~%!TUwvFCVRME_u*psB zAId>C>Bwznaei?jdH$K|JkVj~V&XP1yKq*XPSrn?(dI@&+&9?zg6F#LA#wQRL>Kk3)vD@3s?k7b{~k*>Y*2JUJ7G2hb02)7cS@ zxoItO7s`bURVX4&F7m*eEW&7o(8wwo3N3Lp^T%DFJP{Wu+T_@vD@Ywb|E51g z!Ai{o^-Hwewc;QsVFPP%zx;!jR%`Oz6?T*lchV;W@{Zf82&mn%ai>NjFjG~M#yS^n zEJx?)H`@4l4nN!sHX?nO-rO~kay z{5oj4Mnq(`fbH`*W;_T^ONV)+3H$I*3S0^jd+f!31WJ7}oz)sDLO5iP!!pu_mOwW1 z?Gj8=bDz!F2m+IoHvbLs=I=Le`g(z>OVhS@??C%cwO1{~4j~c$L#Fs!)kgZFs$}DK| zXc_{i{6t=An_HmJ24c&F@K;w{fb^;(qpxn$T5d`NW>Mox81X{54q`@oytrBI_}ZJk zj4^0RlNYV{mRzmb%PG0q&bS@h5Lm*Nb1{@n=CMgNUX^8`t%or-+T`1)-;EXXqVL)C z0h=m`<`)S=qHuLt znlOgN19Hvlq>vP zVM>Fz7P%&Vc)txA#4*gtdZTm(_4t^E2DEM+kYq592C87&V7g_cUc(h|+`|Tr|ARnG zQ)v4c2;w#Zcj(LGpC5n;1Nc}p;3-D)G_SiF*mayY7wF%EZ|#hYJ6d9=Ziv>WlL`t8 zZ_eKrE;W6JBJUo19x+@^Xbs@!OULE!=i0J{_<(VuYq$`ezlGbQ z>#hR;R-zw}^^s!tPHaUx`cFr0ze?-VqCBBLbW@P`YY@%p2YBmRYm1cHmhx$cjLn%3 zD`nIn<2J_aaxGfQE|sHg96&h*m=MT^mgzG~A#kjWDO6C@KG)cbd?=Wr`VOmoo|`E9 z3^FoU5UY~bpNy2^7aCouk7*xg&$X46+VEz?4Psbo~_ttu8Ft z17Rx#_L2XaF^a`9Eo1v6oe%t&utmNTrTg)($}^8*#D;XnvJ$VbW|j00q!D#dcdK6d z6n+TuJ}4(l^0PCZe86IeS?qw(srampQ}1Yu2-dhe1e25OK-yxlBk7p7-0T`QATTSn?Cs z@TKti{%pAwkS$*W9|{8Ce9#Y6cwad_WI90t+LZ7Q*UR>}3z{_fSpJ zI~G-=bh$HOa7o2j_z5X)0}mzm)>P-BLT0Nf$IgvpRN?gt4aKk8lXB(H%#xUis#W$m zLc=~=>DLCK{Muv3RkJAAL_&aAXtlZ#8^SK)+qKT|J0-=y%IM%IMQP4FCVkv~49;}T z4|@h8(BhyyDD(^E1-zhcarRTQ5)lsUSh`sfN7p;gU+qYB<~BJepp!WCC?9RS#?^%e z#36UA35Q&vJUATOZF|j^$RH+Ar^x<4MhlhjiH7j`II@DF0+war%Zo zO#MxDQSfLRHg^Tu2z#@u*eg1Dw+#QXV1FU$)M=yfE9x*U7cPwmmDlgS&9$ZYfM;|q zC0K|dM2s;_g2IY<^A&jPqNs%gTbne!s;e~X#ztazSGk% zp&hXS%C%QdpP7L2ZCaz6@;}(z$21}Kz5m~Kk1Q#;j4MuqC69nE5CweMZq_h=0t;~P zr51Ue6^VXEg-pKIYj>sxQ%-<=5xeJ~j}XAWMdSJw;M9G{a>hjcxCI)|XTq+EHPuS7YXGsjBA>#{`;pgtU=?*bq%-a!+lcI1dCO#H;j z%gLUyH_H;iGEj%iAeztKy8A#=Y=+O_k00+7%Ji2hMM&muy+_?eID9Q_{`Evs>*wF0 zAv2})zsC+U$nNEKTp#6Yf~sXW*ya})IC)HlJG)$&s=s>i?;vSGnDVk3RT_PCkCz7N z<_$5h){@8|GqJR&%Np9oF)XbL%3D|0Q&kD?v1QE;ENom4$XwxE9)Hy{d=&b!F3qlZ zK^b?Q^fg1if8>A+eTVb2!oKJi$@N{r9TS&lco7ir+` z@-}Qier*EqsBDOE$gtm#$Q$%Ni4bz{diq-l!LiMv&HD~7K{KRZHH0h?9qAs@+&7`& z!2hkDsJ~5Tb7ScBM4hNBeE<3DYZp^(4xX=I8}#?-2|ea*XVLEez-? z{H+w5o}5H|Tkm>ya|e#2(O?@Q$PeHFY=8W=H_UgL*>os)$mdor1nD;f)GhzL0$*$2 zT{m2LExU(tv&SKzvzhsGIe|KiRTWAk?3cgRwu6L?Rned8B){t&Z-%uOaG6;m$r{}B zy4u}{v1i|BL25og5k|`t$Urh3ZwZc=J0R2Dee_4<=N6pK|E3yzIf#w}&uBh@%T|Gm zD%&4xgC51k>$hn8dGuKxdPI{F{Se_Oil+8!lX<9*Y&N5{k>^=ZT|4R&2zy+ruUpXT zJH2V~KwN1HqIa}?T5z}Rk4iv5H5KaNQ@4=S%=gAaW(nzqUcbOUy{yg$z-D?YVmti)0z`y}fga4TXNhv$6=K?bEYpU_$TKD9vhA#D7T7So_AwLc^ z#x+D&XY0f1!^)o)4$#+vk;5V4_#vb&zSaXHnc7hy^>E!FBPOi^eC#MvbfNE^cpyvi zIm*NFtcCi_fy+@tFZa~k-}d&2X%j^6ZBrKwk#WauCFh?ji#%HrcuD!bLC5->4V->! zEvd@-p^nYt?ht9HX`l%&Ma9<|R+CnI`yO|eM1$U_S`rcGWxhF`Q$}E5p0}i3ZaSt5 z%PHATw?D|1r6{m8p+IktyONn@J#BfmnFEUMH+8wh?pIy2htm|@uTHPum!tE7$8WD( z4VB7RZe4}6@sr_*l7Z8u9B0v0T18E5-VGZMdR^PK#_pWsPy|8g^mp=SKloevHR(W+ zDvD?_>FpowHc3FyI^)Zi5)fU*7EW{$w?8y1j4j7SXJ;OocZYfrGr;Z2lm)qUVT>m^OT5(R zvt&*I%l%70ZK-mdsk)g*WqQ0d-Jwh~Wum(imTitYV8^q}Bkv$>`Or29q1M0r0|$iT zn5rXg$hzH{HR(AaNB_KIQ>dKY9J|aN)2R+ikRj#K+&T}OTL(&8A0@S*`?D)?Bxbq~ zzvJTyRO-(#l2+4_mi?N5FEsdW0+c&q8$&95zE3c@+7yKex$| zx`5lnm$J9fnwL>24d8Cd38;BYuYt(QYv%<4gon#k8IpEgeN%C(&jac(S&~eT;o?0X zU52Gz?m!^gDUa$v-0-i|$kmMcDWqxCkf+Ye!gKg`EEmrn$D(v`dl?w$b9pi@k&1q{ z+R8=DI9a$%yM_EYMXaCf_Xcx!H2K_cv{#i4{FGwDPhvKlMdKraCIag+@JQ`(!s|wb zJbzBT1+``OyE>>Y>gNUo`E@o*k`L{cosrMtE+ldV~q3 zTa76{+sm9*BB=0HJ0nl(m{j?04r)o42b8v1-^koWLpt0%o%M^PSG&JV88nPM3clFd zrjDslZ&2J-};> zHKU2H9~vy391u(yNF(@_fR>riSHDa|g*X3FPs*!MT$x)-=yTRX)qLz#=RKr;I-@kp z_yqXVcRISBJFd6jMDY2nytnauDZWj*A8_V)d_AQ9vI-yr{w=@Ha&3c$@K?VdI?=%U zwLe;27k``ObXp$0pHlsao47=~6Z31O{Qp!upMf8C!-^kqwd>7Bftfwh8^ImB?Zx%- z^+(sw6ZcFV`}Ujr8PDd$WuVL~gns-RNFO?fwQ-+?UPM!3u0-7VrfEhx3|=BrwQSMy3Eou~`OIHR`Y6rMoT(Jg5W_H5CKS$srcs{K z2G2g*b?E6=8@UfcJO~Q*8y&$k?-w z6}3u{z1`4s&Il;EO*glU(9<{}YF}WbO2}`=e50qo{x`GD&ve(}$xre;r){FX$z2g6 zm$Uua?IBaaQJR$e*l0@ad%^;|;9GY%_62_9dE?qoOr+dio7zky5)boEG z-(%xi>!q^@Uu^ByRMJK7AU+_iFtxsGYfTgv{^ezISaPY$=aR9LPG+rEIj0(4IigDLRCCjTVGz33n7&= zeslz&j4kpbyvu^Sxo=O+GOH@Mb~|vSYt6BSwqp z>3ETz;sBeYNgn=kR1xYaX3Wp(YBQV_{={GokUO3StdlX1SqVE2<)a9k>>~N|HlncJ z_2ymI9fqQ7&Y8y~ZpqfI{Z{zD#Y%Cli1sBtlgro|w(lP?OVBg<~7qQpYjU>+K~RN^*b$v<2Cxb5Ic$;I!|zL9V*? z!5?z)CP$UrjN+AcsyOC+kyqoeDA-=)LSmTvDi33kKoEH|eHpj0G5~B=maj<*#nWL* z&^aKlST|#5b#_@Da?mx^z)FeckU@wPVpgDo)-=yfKL?RuKT$9LohanZrwRR- zX{ou%c8r&AmcwQ(ULp`%>5Wk27*A&Vr5nYi?}o>WO%wc)Wv+E!$9m0EllBrZp7m|+ zD*d+Z{3!e(2mTm<1lJ)bu36(br~AtD{9o~Yd+C0=^C?z2P}1>)dbmt%ZIAWIAcl_E z26C?)8X&WUA%Fx@EE{;QD%9jK&dR#m zl*5=J+gopnK-uv;fY!O^9X!%RD3cWa4*Z8pw zNDWxVNVZgNrgvEJ_kh+?lngyJ!!h$vj5?73ft-;r< z@HY3fKZQz(-N*#SX}C^^pJrS)$8V5&45AP;*7_O>4d#jCQL?Ey@u>u-Z*(m*>c9GM zDKqoVlM6T*1v1y%(q@)buM$Z|w31v&HBSx4E;@RR>7Cm{UtvI?5tNlVEH8Em>VSqr z5C4cX>){P_kW} z-{jfagIyS`L(W%|Z$2lX7;7d2qpr%IvDCDCj=5+9M46}WHkx3hVF?&Y?GfWk0yrsm z15rZFHddc###P)Kk&)_Irj##5;`hpj-UrI6EhR+`s7Db_Pc#UQrLQZCIu9faxPLC{ z+$M=Ave8DxL0RxuZuR7&W<>q{M+!Ipq!=DQW-m6uBJv{=p`yfga2N~?JM8zBG*m55 z9gBc39#sH+0!zQ#jb?Pw1S5Ia=+y@_ZNP|#h*2O;f{T-rxmvx)JyzH$0&KkRD@C4j zS55^uzY(>>{g`79Lox2vmIl9hmsYSMMYQ1)$=Zu|Du_(qiP?T)*7xL~7?i3ktRzxMPep&;fi$4gU0o)7jJG+ffuds0aAg*~=T>9M?B;7%dultTeMdUY(B4=Jx z{VFf>DjVA#02;G?joW0P`#KiVJ-KR{7+RgF%(m$*?{EIv;+WPN-hPYFK zg-?j1+UY?J8wF55n*;%hrcPl-5)osjtt9ld$Cpuri>#(*`cfC>k>kT6B?mAA;Y5e8 zteQhkt& z-F{fq^I+ZHX%~jjT5X+jv)n+}|IifA3q~(^Ja6ABowpjb?jo~?aJWbJH^yJ zMV>oZ=%FJ%_Y5Ot%&%V!r&LorMrW9N3Oawtho4$84G3XPvYU28ljoX3@byTsxTdF{ z4P)_my>?WEPAZ%)ANAS*nFT9flL-v<{}i8%&|@&->sJ34l5!_a!J0C?Y&43G?#NnOrZAOGoFS@q8Wpbu7aDUdF|1iR+Z4xiZ(qHPAN+>Z^O zQg$+Kk(u!M77aMZ_>9%XCW=p0fJ$_{PE!ouv_mV@q@Rag-iXJsA1OPIL^^HlU*v7r zmtOH@Ln6fhvxQ5Vb$12d!9MPy#PG02L4O17vXg9`CV~H;;*sxt4 zTeEtkBBeb(tVm?bCI|*HslomDaZx+Cf87w4yvz5|){x3a^jn~s4yC6K&rq+YZ=0=> z;N;Ql`|i zdVnqo&00=gW>ZZXfq%DF@ozQ%Q(<9cRr<~>pH`@yHE zka`U>$htz6TXnU|*%cBNzzh;UPTN{eoRdh@(j=plAG~3WS~{!gJ_AQo`eMGM8n$rS z!NWJQ-puFmRPK4vZT)t*ZHxOMDue^C|YaC?` zF$J=#wxT%8!>{w5ZZ}z%0tI^yk~tHLxMb`Hmdeq7jf46JbZQ97pG{5e zU5ihPi$~xDY&ay#`o7=|l!C7;rhb*(9R;^f2Kp4=u550Sli#%r|1J%9EXDMDlMlF- zzgS)B!WX~d77y|4y2^nHok+R24W%ZXUA83S|L%SFD|s(E9oz8iJrvKq6vnOd#Ic+^ zgV62D70-LphI(-Hzu-BlgAgjsevWNu$2l+fcYbiX%&Uck0V!?UfqTqc^lj$s7M|ku z;`sdhtotwdT|YH6tfIkUO7uhjIAMlEKU@7 zLBlhg)YVhhl+4F%4BA|lme^b3RN6yI>@JYU1vpQwP~K=EjR_okA0iMHOb1`)-}!)Q z=i(q-+<{H`v23`N^|(JzLS$%9d5|}5OKg@i=vK?&`l!K`?v`DHdk(zsaNC(69qc?) z+e+~AyMf)mI?tNoJ&)VH57I@TaA8Z3)h%%W?!L_yt!_wUcD{ zoHXA!TuCx~TnpG1ZW}-LFIIi%$YFQx(Un}J-XjW`J?hrg#7eJ6+~`fk9|Zwt`zts{*i9eU8uyvt2Z_b_)yZ%XOev>)ibCV6&|Jr* z5JtEcMQX2Zp$YHr9V!SPiLfT9@edU<@q63hZTc}@f;LL(&8oMp1*8Vy_O~MLtOa=C zb_Xn>=@WlUon=v@pFFb-sPJZgYhrY>oSA{JS{Hs4-ziZ8GuFB>ZyHh3iBdBMAjc(R z+~HSP4!#OBU7n$fD$u>d3uy4e%k*73k@!QhcofBB0Y#tqbHp1K;QU|c=Rbz`=Hp`X zPTq5JfJ@rsMXaUBZjbF9c(T78Eqe2|srg~+?WX%x>!1DeHDHp+G2m(KVmdFi+eeeLKZP$vIT3MaSdPP4V%lBQF@rszWth)!my$^1g_GtTc5I#;f^CgG4 zLSZ$jYE=$7;bxdxo`AE$R$Zd?5Z)Fmh%<7{Stvb@IMhB@#P0)T^(yE5UfdAI>eWJ> zR0&;u6Opx-krhv=pI{r=&iC-}Ec*w`gd>c#)QSLsvc-*8KloEcn% za4~X;Q-jDPoc*?T3o!i{{hx;ep8xx`>ypjhho{l$^QvypeQA)Nb3w*9cpx>8=(O?D z{fe52H_XGo^ydXd)Pgp>rH!qpgswJhFgB8?q39*DqDiF)Rj2OPS4AbkBPq5RiLuFS z;$v7EIR%_~-ac7wr>FM=GkONtoQ1@bkOYIz6+kXeZDt_Sg7PAy4OOvD35@)b_F(~3 zQ9jJ!erW2EI)Y+|55bb|-DLEN^C*R%_0Vvl48PS5*qX*^oS;UgLHRlWWEjPO;k=!j z?N3FQ0I1@Z@Klcnx*x5>&$B&aAoWb5HTKP>64{-p^9%c5KM9u{=68r9s`h3EB}(Ao z(qSMmgZZXs+m!7cput+mHXEUoEFVriP0A^|Cp)uCLG1B@-fqc_<3MR@3yzq2^Ck?W zF(T&%irPu#+)@v6*jLX&6xuK%(EdtwD>|&>)B4v&&N6#!oxjJu4OHVKfl;hQyfV$^ z#w^)ynQ`&~Z@|M~+RrS-I1=+5K~LqwGyZN8?UOn3sl=$}za%F%@&xi}rBH}N)d)*@ zDD?hXyb@{i87I?Be5eMTE5kZ*U{5~^w2PyU+7Vq{ZBBw-y`cmlm%tn^C^{ti-w9yp zo}J~`MHCoWyygLQSwHyP4SJ+Ws?Bg*M~I%7+(TpX%+}vt@5CK*pz{9j)+w=Yn6iBS zgu`eTe16@<9A_lx?9)Q+eCcNk`O@<=#pdai(L&zkcwb=37b|JMQtau7Ou(dv5PdlO zz_gup$uj3ywZ{kum~!STy+#jDa+kr ztm4|bQw8Tr4n8-)OwLUF#^D5A`wQIXU1od9uv($iU|@QC3z~qAcx=YsGA{8U+DL$I zI)kug)(N+@>7qp%7$ITa0G1X$@iMzXEw>!*||yCsK{BNJ#5`h-Ma z()HS45iZ31Y0uMau17!4TP*frh3ALTor0qa-vp$z<=tANbzk912ecl-Ky};H51*>D?92A!-(``1|n-Ise&Ja8~pApFP6Ggm|K3v zm{TBx5;rk&s??Q{e53LlVb;Gf(5RoY^W)IqeNg55MokAO)$?|Y_6)diGtG2U~8UIrZ!J~JPK9`-gE{^BgW48`#DLM zt`R$f3#znvHTM^}w7$!q%JK?WllRgls<>>SpW}BP(4TMc3_#>Q^%zh(P@5UFsn+Cq zF9hL|o(^SI!;zRL73LVo0q`bzF#E}HPQ`QkEatNF}bq)=1qEx zi1<4xK(WEv3=$ePzQBqf%mIa~x7OB?PX#8%WbqnEvgtOYeIWB>JZFb_wuA_;l^vEC zgK%J$X31G8f7m5v^C!Uhn;SI)YFcLixDSw`uB&yKG4jRg`;hLi+Js{dK6OGu<1H0l zcLd72gF*o?6L$6lmvo{Z88On5lQy%mk%Tb`&Rq6pD$wxcO7)*7>GY-Z z`pPhj1LzhG_3Hhp9eIP(du-c>rswc>rCBw?tj;$2rjGiB@kAj+YW85ai9=la?8RMO z8dK#kN*04rpd>+0%6D_gPnn`a!EE?@5{DY{HHR)$FQJ(Pf8COwPfZ=ekOR0$rI~@y zMv?Rsf$}S)?uh3u{@9(GqokOj8EzX@R8@8iOp2D0*=yZ9k}tQKP66Pif$x!wBC1`O z_W|F#LpZ);&`s);;)^t&%%&G{duN6J0>rgeH?{G&);@dzP4FYfJw@wC+$S0iylB?Z zV@>6{XQjc21&2sJ`Lb!CLmh~VfQ0r2vswmpz#3B?YJ9}SGyeK^BT;?zrWeP)Db@x~kGS%mvn43D8%FG^Vgu3L4BlR-%gO(=4Rqi@XN^#dH z2RlBkyQ=$G*H!Y{nuy;=6+eg8wq89DB}+f%^!>+&TJrQ;G7$PtDi(Qrn1VWJhu;X- z)(GTbHrnB$4X-knw)MJi^lBR$-G_o}0uSTF@+3gKzc*w|C0Z|@v_cHaJ-~gYi8Y#! zw=_SyFqU3p>SLsyR_{)|_ztJy2&7fxCqjx*3anY{xktd%2k;@iHjdcI(OVu3V zo8wLTSKiKDSAkdF?gqz0o(Wi+=}oE=%P4a=QI$k(YK*JUpoNxnXPF~(U2q>PS`s-w zAv<%tZo4t2Q?3&K#HA|o9o&&lUWIE+DpmuyxvRml5M zvPkTq_aEY9+<(U5g1C0?O{;!y-o>K59kPpm>^aAsiV3Q1N=xfnBXgCB?sdgOPETa4 z<_jK8&;5f;jfxr1l=bFBMdF$^ zDYl4wM_EU2#yEWNiHJAD2j&oP&uzBE{VhbSDZ0J_iW^@Jn{ zSy2Wp`oNK!17An$tB3RgMX90rq}e z%I%r;gsP8Ykf(%!u$AFh^|@K``b=+&9h+bIobqV^!1=)l^ni>(oq@!Ou0bGdV%G(j z4{sDNhw}f2Pwzy(J5PuZfrCyMwj#7TcJRJH!{Va;>i~XJyqbs{N@8aI;c6?o4P_7k zil4)Z2cA9Tp7ruWX@Fxb^BzN9X+^8;DO*tCqQz}@^wT(g0Dx^dE=Ub!%F|e% zK8D377IVSA%qoa z7GrZ4>3K*vz}QjWdw$X8h=hZYt&|D%hFv#0mMycI>9NpuXI~+SVSm7zBqqH!7TGK+ zm#do>S3OEwMKnFgA`cAniJH<Dn_)45${na5Qh*QeW_Ig<;Bj{LueFj4q!IrB0G>;p=Ba2wk~Wd zHlp>LQsD8R97)3beqmT#n=z+DsEWR z@f}EpY#|ng3BtMrLRcLnA?@dgj;u^i zyKiq-0k2a)g^}lf)Tw{b^vi2&{iufLF#m~958sTUwjQgt+Q!^f11=c?u*Gl9#KW6A zPwnr+jPBum7qtBF$S)%0x4ou($a8NhEy=K)jUqi3F=o=bpZ_dxZHb>h+BEh-;ii|a zv*j@bE6=EtFbiR4(^IY%DF8~Dg6Y33c^N;y#N)nCLAl9^kkTtL9y>O{YXeTa*YHMO zazvA8SG4h5U56>C zvUhaU_{8msR{&5p^H71Me z(yYJ6g8Zk!*>Ej#tJcTUg z@9q}Q{2~%;>(2o`$1YB_OogYe_jZ8UkQJiFCur>JUDE_*t%BU3VnOd zd*l8uO*>=Cm;T@6i4YoiA94`~RVBX+3vW7;SD%cZLPu2S{Of@m8ym1c&(6+v!$}+j z7;@SOb@}r6_Qf6v2hQb~cbYY~If^5hJ{t^7P6p0$-m}Orqm)3PV`mD_^*=Rv_{VN;cC{aa}A&SQ@P4mxE0tjD`ahpPRhLBu+>huGoe{pB~Q?N~~PoMjcP2tzhU z`d0~xJ+Cc2HYb)mwWmXIbS`PK80nv`!|XqRDhh_n7w{L$JrSq~TzVS#S!#&AAB>Dh zK1D3hwo9o*mUoxpsu4RgF~u|^e~6OdPE5YG7QG#1K|UmL9oS@Ct-#sEzg0i0Trsfb z@4i9h{76=!vB88ZV@1(M&4vs;|GAC)w|(xulfAqkYRD-RIf1Fw{3am3zQ1K@GW-d= zncuyx345{rt){`PN&jm(w*2ZTOGjU9e>OH~Lm`8cgtsm_qbAq;%^$TZeBESTkn0;t zI67PdV7$vxN$Qv$h!L)W$^3=y+|k%d>GIJ#$=4|A;4x=W8IOmi$@=mn17TppQkNxb zOC6iWwz|>eq?UZpZzEWq=nmt!R(_Hs3YQ3D)ozYzN>XO=!Xx#UL~dJ3DG&eQtzCn0 zZ_aa7PbTsR+y}VjDKD$kZzKr{>T_5dZJWLJ{a`K?pIW=co&A9LDCOK}gb zfH$7POLl%bP>5zJyrw!kOQH>?J;sU3r#W`sZNWd8uVy*kz7Fni?NE89{LZyGcGJsI zA4gy_IWMNQ^Vs05PtoY{)AN1&n*jNFAmTP1yB9J9x>UY<_V&^pPjMea(PI1}aD2!O z&czFDyoK(Kl4$8W5B?`ix==N2nwm-A8|2DaC zC4*W_YAV{k&+UE+&7W|v%@LZKXF;uzi9*l(4gxqBoJ9_mBmZ)QZ-Sm8DX~%~gLqoS zS2@m;z!3WHC+)EoX%gSjR1bZDQ*_4Jg$JUw=BZR1CA1Dds|*XrlZfHCp~+kfI@he1 z;B+C(TBGatMUyF#qpm6MgMfI6snm&|E3pAMDPLs+$-{y{5(GE(K{K92?Yp1!>C_`~ z+MV*mo|=X!m4Sj;sPvPO1z3cY;#sG;N2%kdlm!!Hn#8qpZNG$YEn{^{EF6;_QgUW! zJo{OE&{Z6Iezp>ZA~W@oHY!Dl;p`vPRi4p!65=Xhy)XD2h0z~dkY)YLPTviK!dRD> zAnV#Em%0CI+6Ta=gV><)3T8tO)^83dLoCFC%yk(Y1ScAB34}Clncf>{$mbKS2nI+T zse(E65@RN`dxThK?i-n1!$kV2UJ3)oFWH2;CW3L2C`bNd#5scTT(Cm84Bo4%&&maj z3*z-{mT>nSqFRl_YkV)wwi2?z0$eyBV};3=)#PSw3E#~R9?dojrpJ6wSmxnJSmiJ3 zL!(^vB`l3hp?)-+6yN)}bf|kfpl^|gTs!|{ziWAH2bMyEsdnv0Go0Rzb%oYqYTLY* z6U&Pd{!V1aFa0Fvy_o)3Tj$Uqd85Z-qrSP^Tb^N}DX&S_bI&)()?ob0a=bx;*oK9w z8x*~&;!YAOFM6cLZJgpd1nr8tuAsH8!|ig@sxKAGG+~6zv-40|D}Oxlv9B2)XQ%CDy!F9&C8IYgLLbpJ}$xnc{~D zheigb>-W4YD`=$$eYFaNLv}&U2Bc|TEw*fu*pn8D3FTBny$$l2e+l5};D61I!Qomo z!G!Qx37hJW-VMQjWsy4(Ii-o%%!_}$-0h*;UbTD`6uVT6mlp@KSL>j7sZC517&$RT zlEfk3Puu3j%KM&C{N8nZz!upH@HxwwjNop3dGlsvJ5bL&la*dokJy!$WptR~tx_Bu zx}9%vD;a3emWd?{tc6o4?NK`9m73BW?d9e?pf+}jgGl7&m)-d&<)rmO)yP#0u!-3Z zas^;ZN%JXj>%`GjVt-MUTgsx&_WmCdyV@`#3kwLLKw(^LW7C*p8xO&_(N z!bev+qmm5Wt$Bht0w7|)9-a34vk!{hS6E6qb(%)C>%#|&@*Ahe)dueBt6=Lv)NZ~2 zaroGR)EiFK4s!23HshI6;;OXcGiU5n?1>g|(gO$B${W^$K7nW?6gJ;#@|Kq?kdjUs z!btMa>Tm!TU|`;Igxa-@dJf%WA)WHsygJW55JA*3^!gJtB?29t45s<5q#4xwd*H5< zp+dVc_`i`o^cduLcU7y+at>9@aiOa)8(x3+I%kwrkrgnqQ){)AVZ`kC2|O3I2cRw* z3C2D#0QGt2vt;S{f6v_ILjW4!etSw>Z6GcPluEQFk0|o!pEs1u{)Y5=@cLPxDvDqt zfYqUjD`xblOyHe)j8&qqnq z){gh^OHA@9`o|34Xi-&924tn-yGSgI_roJ4Z^U$Z73otb5uz26*};PQ`NQxjcs6J8 zU6#WD$yggcSaUK;rSZ<<^BI& zz);3feUi|q1Mqq9RlHxqB1%=;))!#(n1cbv=6p=Lzo|v3h@(;x{Br#@s&-Ho$X|(1 zh$ZbYEn)7eg_gWD^eIH2rqJe6NdomN^E8nwJO#Li)vnXs9gDy+12C3HGow00d_#pA zIkuOH>&jbzyZ5PRQFo@(cJ+gdWaE7$KwLXB8q5O7S2D10NKYt` zSz1}wm<9zpANfuaVws-V0t&%Q%&sO4midcM)|J;Mq=Akcd+p!w?2_ddFW^IuiZZ># z&qeu~FdvI{@2`^&ENJZJ8t0S^l@`LTS9R0q04MRC_2ZLrsevZ6N>mOVY&|28cKbbm zF)xcMSNV`iuB#b~Bm&PtWmV7@GtMga8SEYuTaANs>m)Yg+`_5;TPXUSOvP(d9dgbM zm3)TJULj|O`Q$n9jCHf*m_Ez>PEbfx(b<#m!;oLQG845G2I&(;;M3(=2ZjCZp2^`4 zq}CCb4gtRHdPkqwlRYt1W3_2Vm6rX{we$IR;-O-+>s(|6ONgKON5H4FieZkjJgg~N z`d{1$tlLQR23#?;ZPf;xk7^E+BU&&8O+~NUXf!)hY*Z>6A`j&Ux^wFZ%XL!XPa*dF zKZIF4Y4~CaT1a3naeDh>_i1Wa7YC4TTta%3YbutA&U7#QVf?Wp1j!n=r0%%B z-BK}8)#)s+i=2|Bo-?n{X7SCkEe~9IlKJjRE$^TSFZGJ+_@WQtJavsFV6`3uh(=?( z6#EO_$!$;(f57cKd$q@w5~!k8tM9|Zd|zlFBFTn-e8ybCv%MM{sktG4c|!K zwg!oVi|RWvL8XB?%T%kCCqg77&SNpN@E>aU_0GF2xiUI~|kB z0j?nY#)Cx?yqxDxPF$r5`0$ZsU8O_e8SitK%frUO84%@mOm!DgyvU;tUbY9+nkT~n z_1!tI)r8FL&hqjBMDnKmk7mMlW^nh#e@cx%NZA(j7O}!9X%CY_Yee9orwfMRlsq=I zW}uo|ud1Qy%6434b0{I?nB@N%pnCR3I*5oJ~g&)}%ds;{YugF@bCGqKraFN!2F zX>b`jlJm0D9nUU9z#%*A=Yo+fx${3r}?}P;+Y;aBF$c%8$6Fvo-Vb z;@5E=#y$XD2?eivA6Fr6PCXbm{dqFotwhzPa?<*1Wk!2RC>b>K7o$D>H)X!LSv(w) zn_AEC2uOI1Cigs0Wk*fkq4(+IoBc^?7>Wfq+p*R%#!g7Ledn%s-p4UurFuLYcuGKY zKQg!C6&Y|l@4g6LcJTZ;9kHTM=@BPLa$eTp3n8o>Qz)w8`;HVId4P~l){M;&M`oIr zIEz=^L&it(1DVc2G2u@4yDVkdWID%v#faOlN2>E;kkooLa=`) zPQ9x8o)0nXz=}Uve3^f{7`Ei4PdRC5fCNI+99pv#+Ba`4mueIs7$?+La>^8rUn;b2Pku!K)Cv9g>9$H(;Z zKX>+S9nHS$p2PNRv`tQjj^ZQFD5PV9SpvD?!P@Fh_47ec zT6?(?fYK#eSR&TJ(`x$y%l3eaTq6^e$m#;%-Ppc)l`&c1miiK5CJ9b~+!RyGcrurd zn!)mDcL%5Wvr-<7D({o!IWRH>M5up}%D#%vPoLUGSQoVT?- zEzN+Q$e3V#4YbXZNdZKs@|Pqz2dqxJU`{w8kMB#jy-T9V%ZPn4mN|b#5N0rC*>u<5 zNAG9GEDW@kyk&gC_4uBW>GT!3pC@1sH5p~HSYyKIZPW}j(G25ky8>xxt5(V~H(9ByL9o$Zdt5llTQDt79N9iPrTLrqHKIghYduzMA14ojg6o1eCr~bF3XqB zb8mM{`l1F@SyT<5Cf91P<#4jE=E2xz3WVu7y5uE`mrZxBW}`Gyn!$_GG!G1^`+0jZ z@S~KwY3UUofmwjYmgQRvqpeMw!@p>gH{#d+0Twb1_6IGVs*l zN4cb1$dawWpmQ|g@JB)-zy@`>gs4EvJHfV!x=r8C2s%o1r|I2ey)p+5hXy|cVKm%-q z{w!KP=aKRvF#p!e{h=y3=%1A7E%xZutH--2S9%mO_iJaFweDm; zi3p7|S_C15t~If)L$1lS?d_Iatg%f$d0zIXNEdYM^L8})CrrdK49AEO8=CcGX3(KWt%3E?H(;R)N2N_PthFB;Kh*d+?4v7xOe~^UL`7l z<_QCRlo+?TaRq1SecqQJELbS2k3ZCcNr2VCN9!G0s=T0muKL|@>X?86X8OjnVn*I# zIbi&5IsBIbM7$p$FeJ+%7^}vBT z4PG01AT<(bu}}7A!V(y2FobsN8Gknal&1<71QL<-!%sFjf2G!7NXgZh;QQG(&tc}PnRFJs%kK&#Wn zC1^J?4b8Vh0NnpulWf* zHuhD7gttCF&+7ANLFtVeFk<}CPy3FV@-pe#Nb;#gs5r`%Etz{h`?@t@20XA>u#;E? z1-fu^9f5#4)z20JzP&XOAEiHkm^G+_p(=`%L>r|=4L%!CB>?e~R(c~d15|)vN2W4n z7KDr-EQNJzp;WmW^^y;Hr2w>>F2WH20AiuVCWM?xH}M_DQ%Bo~2}B55w_bGqv6L=v zQa3J<$^>|4QmBx@gVa?4h<~1*_`;J1uW^r_0$-$L*(`Dzhk7mPBAcU+YDLbIvEzLt zBHG6zi-^;|Y?3Ln3m+ar8P$W$567F?fPejb>iDSW`^;)Y9aBrB9aFc%%!fGdhYH%B zisk6NA;a+gpll@3GtNu&sQUp{6(1E37i3#v=310aMT_TJ0V(_OCI}j&0d*=fs2J=M^{(+? zHtEDtnMjm|L=w$eWO3D$h;TG1X6OKOymOdFbZ2T~DN+jN&@pdXb+T}=j=Ki{^aiCT zub(pu+j06dV~Uf1SK-q^iR11I3P2ekOwM=b^E`OTM(u%;I8}aMRZ{*BlC9* z>I$?L89=i#?hoa~qa#H2sJ@Jwk%vI(_7nHeXg!zbe`SiT`}y)}deEozf)*za22h-p zF@0h)LhuC^g(FSnQ?pKyUadZ35aC#YQ=#4Nb<2Q#dlH+IZK6|QnvkVTY?VVF)20*w z_Ye!h47YLxeHmU^c+Vt0E?(Tn5G$WZ;*jvRWLibjMJ|@r&~MfNH53W(41~~_G`NPD zO(*MEav_?cY6&C7XI3Tra~!$%27zBKevbl8rkw)%%}`Fz(x<;u z!lPAID56VPc$zA2B23W9&_uPTSb)Y2Y}Ni`lk7F5pa|@N*Hy=d^ zTbPCXynD|9d@Tpo&}6#vXBrI6w0C>LBJg>0c9qx_AKAE!t(g^m8uq5Q#*(Hts&4_3 z{Mfnr1ktkF{)oSc*R;2XbsTZr5*6cmnt^J$(@PgW;f#HfibPqirEkztmQ< zI07Wp#s+Rtd~6GfLnDTX7K*w|cG^A2qkJEgcOP{% zGps`r4pi};O+NppqzAkn1bqD$wDr0P6|=~Fq1_h$D^!Xngi2a;T?am0i0r<~e?kcH0^?tYKnr6qXXryj-V~`t*Vu<`4`WC9O_M zpF~%TIkkzAL_XEMXdxUw<9az&ZDR$X1M4>jc2L9HrI&|hRP0A~ji-$H?L&7}n{-%u z=i@&%J$52WXo>^X);k|VPbeOl%8_0o10LlO_qQVqpUeja1D7qJS4l>tWeEMr?5Cn8 zLW@{xV*Zoj#Kiq1F6mELs=>j5)bH6Q-~na#2I#?!5hBvq%KnGS0f+_f^AC5oYN){} zm}5+zOBjXk5xyV`{J6rKZY}Np<6n!qkr6UAmn1s}KA=?YOi;ISlRih1nF0$sMl z<9f5WS^@)Yma-MN^34$3e%SSm%c8>EvAD91a9V3f+eyN}Iqf{2Samb~72&$eO#G!k z<=AH$9Y>EjJrKLUW74hV5pW{#J-;HmsFIoD<-g0x%yTAND_Z*FkQU6wKeWlwmtNV|u)74>=DBrkDD<;*RMO%ab}ihVKyqf=Pr z=iIS!1+1`2WN*g^RK)1Xu%{DKtJF=)tFxG5=)w4C`F4``uw``TZWQsa&i%KuAhH9B z?=hozT6SG+?O#PTx|jNAFX(HBVwYEr|5;a-0$!GOkGmkp=iE^1`WI*jddz|7nb*IR zxr<mktkqlTn`>ZYRhhxc!voLxMG_z9gCz6NyYzHU;UO*}V@e>a1`;9N zY-oF78vyt&16lj(}B$My?NjjzZ& zfu#`3sWdU2)~CZu7SkUMcbn5p(!uqlZ&FHG08wlYC?oE{?si_U40Mr(UYFoeR zdCXsb*yJ4H{AT|+p!lZ(W@vb=y}j+ zo56pF0Yc8Gi>OMOQhcQUsceWF4pAuxNUYFY0mCv7MtNbR9z~9v@}UPhjGnQM3Rfl6 ziwT|c(G&16k9*g2=j^@Q2OTcodZdjUW0Z!|EVp_~{rq)W$#@{(PPmkF#&U&~NKlCW z7k|aod4x?XkN#>da!?c)u8E9j!e`{!{_~fjVX0E2r1why%h#T2>-P!&V2!{e5V#bSkLB za3Y1k*P%Mct+UQH6;`VWKf#Nb9^JoYg0MI2a)>Xi5#aXK149t3fH$&V?XVb|(Sdb( z6$XvafG*`2UKbJ=XfXLt65V-1dGR|3&>il{h2IzrdJe7dTNh+oLHwP6m6ulpwVR+I z0*5{=zXdImTJ3vV6`$3)bMd!>w}DeAi(_aPqd!o+^y7p-K!>M~Hc^TS)W=MDA{$CY z8T&}dBg~blK+f26l?yoMB9TeTGkf=O`7hK%iY)&GOtY^_7>fM&REpXaaQwcGvfq0l zZ@|c(BXiioF*y}-Z@F01AX9#%ygWtO= ztt3#7JUZJsx(^+wf*T^&**OKK#@_fqEc{1u-IGJkFm7Ll^cxQ|;oKKNl5c=$?FiCp zs|k4fsz)B3Sl!C7`fD=v=Dd?sA&l}zR-4aRjSVgZjRZ9cK(ujl;hV7g1+S1_*$-vq zkjHtof)|14^G-s79102C^frrMl6ZoqSmpiBFf!%$sdsJe6Xbp&zy9-9VbF3ASH&H% zkvG08vRl7-Q`7k~$he+vq1Rb#$!(!T~9{SV39IAL>j|E zlO9{725;y-ckq!`A1JC5%5Wn=(QckhYP*>v?lGx@nvN?UZejX#m(MHI?87|T<$ssl zF?ZcjqEg(b!ny|Kmr=J0BSdkHK1RT)vm%q?;9c+?fU3oh?VMt1wX6?Il%GKFlZ2-g z89HMQL3ajtd+$Eqt0r8ot(jQAXw2#;?eoB?zc9s%<;Q0Nk+K)?$Wj zwOH^xHP*cG$(i3?N|No1e0rWny3N7+CYSlKZ~z&DZtItxrdZKo5(TY`KoIpd6;{QI z_^G=VQ8SBFqNf=wVAz-h@Lb`miDQ!d*tN;n#aPrT!r*(gawN%5!}UUb|il$=r6zuu~j zc==(nK!l{r?d^KE5lvd>{L}W7sQ)*(DF)WvS~>|L3BHE6y}^q0S{>B&tw(WsNRdnrpylThi{rj>xmdz7)m?$eB@ayXD-B+f6`&)nL z3S>fQ1p-IarUIGFFEets_eL}SUL|OT^lc@g&D7ha!_7Q$u{UH`U?aMgBOp$q58sQ0 z`rT^n#3;J4#-z#=z z7>4Nzhzd*3Mgx(khor#z-Q|>KEVw(2Iw2w}AowsQGuR@m(HbRss~YeG(-5BObtadX zxip|wH$Izp0{fNzT3q;MkFX|GHeOvaFwQFRmR^ZRYOP3jx|2ieBo`Bb)S|K_g{x(L zBZF;W(wpSGEfB1+WA++AZQAs(zq>xFvXPqIMvA)=8tOgcUdN8wrG5T=vf1r+wNBus z2jL*52XJwigzg-4*KT+*vfrlqmry5 z)@q%f8f-^?(qyuNs=OdLQ6qcF?ae>{h|cdjfHgn$@AChG>7}yRZ5W3jf;h~^1pq`Q z$#5IWke4*Yq9@hUm6SOH&DucEV|j|MGhCQS5PjQ6ZD3v-qDu*&P?QT0E?uLb3NeDG zYfK%-_QEDH@srouWfBQv3nrsqH~o$OeoqrHdiAGM?^yA-oan`x^skQP+dMYh8OhDZb<>D856xbY8kr^X=Avl(v)QD%e&Is8c z3<;5Y!V)yy(AxNZB>W9qUL^kWb6$yEds|<2CSQFy8Pq&PXvZR1>24sZVa2LdElEiX zjye_HH3w7M3hmj+8Y*0KarpgvZ%XiB9#^PIR%rv4tmHNL;JmJVbqxUP2L-mC4Nhn< zyYtE&MZhf=nPf|ilzX)y4d5~WDG&UC!r7R}1;(BWjDqyDTMn1=v?~9Om81jAPZM6U zb$Xbv--I)KY0tQz1lxC?*ErCYYxUx}G{5WobiEtr4CR&`LmqxJ3@U@Wp0eXpYZL>Z zX~OJdDG|#ay=-5v%w(>ae%)E5WMI$Uo3g+0d6^$EPtvUa)3Iu39iNGe_zq`qtWkqq zlC}^9Z^~}48;e%@^K~;JwfiziP`2!w{JEJ6=M5GcT0zluT%w34BOQ67>9@Zqenz9nmQF1FUDgQ(@|T-aBorv6i@POA zE>mVupmVX~+R!0@L*&3aOy37M=XVB5%v>?zHxi+;5?l~hY{Ql}`8uHM2iO_k5p73u22~9G;pU!?JJpa;l`S$hQe`xlX_k+*> z{QD2beZM+i6#MTN(?7PNYxddKrcZ~Ce)Tf_kyW3M?#VHn|989fmyYpiU%zUjR}&bR1`!SgRH9y%AD2H!DQGM)rz&wjQ+9iC2J+(SHtlbO&$FWuPnV zsfgBRwC@hEeZz!}bZ9}kCbxS ??=?aVNExO8zpS#afgL6-3~6%lkooFlBZola&n zNkxPz3d@Dmqfl@u?~RB;vK$OA?(A*ti7Jf6*2@HV^SH#U-2|8fsOaiW1bm%pUhrA< zS{o7gw}qb}Eah&fha%P6ngtCSEyG%ds)Xp$oGJCX{VZz^T27kHq(&Zw)AudhG5cMz z(hqq}O-Pn|adkDg0Npu|KbrNm+@8>wePwi`8H|nJ_*_jB(6T26Hs~p_%25 z)3DV?Vahad^Q+%_$9G*HD~U{Hn>%$o{=&`={7v(P+&*pA=FttB%JX%i+HFEpT6xnq zbDDSrYtF0d{5F?OGw&y&(UWvIu==YxTEEnVRZ!zaDv%ljSl>wC(T2SSVv7bCNJu^- zH0|H{B;>zsox}??lHf2E>>s_Ikm#5G%60bG5S%43RQ-~ZLK;=!-?r9W`0b$K3AvoL zd%AJ-`6B$OHGPmipRqb)AqpHh{a2=x3IZ@bnUZ(^+cN_Irk|7#Ui}0T+y7w~|8*8S zp%=kSVaK1taK8jjx|}b)d;^M9pliX4mx=bm2pBDAunZ^B_7@(DYr45W0?)aj&mYF?jB(}2^Q7+@- zK}3KSmfP@7we@9&)xJ@7YYz$6{5q732e{MBQ<{eW5dR~cshbeh)37AZmaM1Bl|wq2 z4VsgW57vh9N#J$T2Ba)n1|Leu-dI7t+miZMj}jAZ+x)vYsr3>hw&kafT)3fS4`day zq(#=j3btIxBtH3LS3$>y_zkQ&(SY+!N2X9--W&zARX<|1LAlBfI1y(qk)%wv#s*RV z*Ob@Ar_-*>o}c^zErfqu<@!y(>3;r*GSFIo`NPpGmf3cD3$G1orl zDI>o8eXC#IZ!cv`I`BRA@A;!Oza9paGi4Kon7zCCQ%!?dE z6_Fw+nv#wKO#9@GcbKb7WnEQ1dvY!Px9WE{{vMxyy{!9h4KY@tYbWG(m$7Y}zZ3uK z9;szCG1lufz6~!uH%_cF`vLDm&|{_?bkkO8`~`*TG3}E!wSX30@+8 z|KKbPwhMCKPVHvtD4nzJ$Vjt|9b2)nN$X)*d>KR8Wpv)RqcCn5PrYUp9(Y{X@8SM8 zep1e;b>q#;*+0=Z%4?VA;rVH83Q7xSne7)7@8xmoTID^mumd{*@~}@k_6Mu{zq~Zg zaYb$`d~Y6U_gA%+D|!?$GL+E0pY1XhFVFr;uHU<$}@*jI`;DxlS3H%h;lCw$o;46&u#q#VHLPHU~Egi=tUzU|A^{gv$IWen)J-4N)Xbj)On@hKN%9$t_k|`palmA1x%N+;kDVqlTRTrLt!)93&-@E1Sov>WQlAoPzpHI}&ut37zsCEBxPa#A2~H?E$~Aqg;)pGNT6;g~h1d;DJlRPN2|+FLd|t6Y+$ z$BQPrkv+Q7i7u9nTmUX2_P#yYhb}&-W1t*_Ozy|b!q2deogQRgSFCk|L$_envAi*TaUA4}>8}02xti%ba!CrKbKbyiU5;IV)W;HdQ$mI6AdyvJ?zyiok53@N zIKiLQHPWfNxdWLWif{ErL?5)ew&DNT4?AA$V~?au*!Zl1$XFP|0SJ+}h2&l9`5 z=LyPU(_4j3SaPM&LVmWF=c4gELxLe&<{xSjR)L^N*3< z=`^~oy5qDLhtdr?t~P~i;+j=72UWb=|3ELkceUu?x_A9l{ImMyk@i0~Oho*ThB#^b z>Gwk-|83-)5W18WPWu<5D^QiLr1-Zwd-}yJmpb4rd_$kPFo0%?ew@2KfE*dhw_*7{ z03Ttm)Nch-a}3^00gQK~(@s~I0T8#-M!(S8KENC?{8);g+oO2jk#-a8W*C$n zGoRvN*}qZ+DzA42C;5qZc{!pt45n0np|Xo}Z95xf(PmqDu@ojnX8X7xqwTYCM!31* zItiOoOC}B8g0v>0MXwU9Icc=B zJAFSZ*Fi_jdzD?|;!@z!9&&yh;scc>o`ec1jD7oUUW*|dbh-?oe^9raFtx3Z9Wixt zLx1IDu|8?+Nm{=AAe?>y%3{#MvQlft+R6DNt>K}DmWWPh@o z;ffbEvs^B~mGFRBtB%zW*p-7f_LZ~}zqwDB|Po$jUvil8^l#fB=W4vMNu|P|S z7T+;Tl!ZvkKwOp(KP6o1L;u8{%+{A{ZZO(+x}vd`pRdqVMbO;F#_;?g)Y_>D)Jbnp zAYBaZH#hGUeZzOMZ&X+fDp3B_w%9@0BqKx9~9ryi_Nh~fG3bhB$39;^c zazDf^?Kz7KDp$=#x=YbmACi*uWco>{DvxZu-=_EoHPd-?k^Ik1*7o)B>{tGi9DF7C z;)(tH@jnLezgTh8cPwDx2@e)&_E*A;%t;eOJ1ZU^n*cLXi)?yv^jV~QD>_~xWt23P z{b~96a9eOdrs~{nhB^ zDn`43%U`+=O^gu6-=JMwlo)%P94u668~NQZrs7;UlC%AEPs$2(PeF|8)6X++yZ%T5 zcM)Nu8A7BPzi?Cb^y&bjC60-t%__ z?gqhbyY~KVPt?RG87X4pg`UK7)uE-j=XQNmUEhd)=3RVc3|NwR`s#d>(;lc5(*cI1 z3v<)M%Bnd~=Q2qnn?5k99nEp@t=ZS&G2XViqQ>`x3ZUvtYm*6+#UnD9n=7dm%3FeML9{TIs$obNd zW*Nc{4&hkS$mUMe&s`1>rc|){+CL)l2hr*yLv33ozME8dQ;+{c=)5Yt95F=L`t{1s@GMXR*%XJi>bZ*??8#65hTIP_o zHc&ajnx5mO^(HIx_}$<#aqPI=j>+j*`&empQViUqet$Um^BJv40Ot53kGLGn#NjMj^ArV0s-mGkbXcSFcveUZM%b7*OaH$8a7RNNcY2eg+tLM3p)F+2|8TATtj}!BomvfBMh6ww&#!~{UHwE|mDu(%*e`jufMH;L(j@O?I_xLq1Z37xUG+M`IyxNawS1(H8dd9dF_viu;@Mc-66hhg zf{dBvWvv~;IRWl8oFF-Sivap%xg}q)SQ6eGx53h(n15SXhb8pzsRcq6= zcikESg#rBd(i)T%mNS~o%B=C#y~?97O*R~|A?Y~Y5-q|V7hP;6bTpzzs`-1b^sv2b zA=n&?SZfgT5o9ypAIe7oC*aj|&gD&atXg7)@Jj1-O?baWwh+LE z8!@_tke3n1$E=jl`HL%{K3hssm?rmQ*gG?EFVM0t&RT9Gc#8DsB_D5_*`Y(IP}ycX zhrLk*U4sHm#wUz4Zr%|ubvi5FxXxSVgAY+~h5B0-Y&_JNlN0HD9j%llNf3d{i+PEo ze;kbH@_?FU6jDZ3)$Fh(o3TjGlap2gvfMx-BW~ZE|FiLDOT4&*zs74519+#ou>qF^ zbQGKzNsLw8aXWrmk0uG0-yl%F-mP_Mr1-TajXky}C~@8xd**qD`>u8D(yIf8bz@A> z+>W=1SXegXkqmFQQncUKxtRmsiUNl8~y@x+qIc-9^(Abp zhGhr^fG2v;36l&(05E8kIi}6dfzx*^%iqTzy)P+_!5dbb0d2Eix?e zN*AAza!bBRbje2Oq>U0$S`x$c)8in~>$OvM3${)%8!p8}2y_cI7+>)}#g_MK^F%X+L{u|2Z1hTM<21`snlAJuqI;^C>N`Wk z^f4#a&1MdjjP-3elNP7*St?j3q8qhgM@|43SG=f1lr#UU0;68)lLDV^Ty*T^t>Rzl zC*8z{Pv}Mv$-lCqwPR@-B(oT$+k1q_yC9-B5svK>A8qei?SS~MSWLPlp1X;RMiR7vMvE zXWMIykKZ>Itc;G)F__k;(oMsq#^dah@F84A2Xh&|$z(J7rR52Nw=fftN``vBT*D~B z<3@+s+ErT-o)anMuQ?H62K?pMR{RF-T4z_-*U!vJk;ZQeZUC=FG`HFdMh!{6W(;Q zs0bXo@cg^Ui#(ceiT3*fnuhPVbsHLXzoE4HWduJDxOa~xtWIebc(?s)MM zY{$I$^QF9nZdWoA(A=GT8kC?`Uu3M1)OXbUtXJhT_G% zAqUPtpK9DJCna%ZQZt{}@?}iv2g7@B{-=sj^!a6&VFDk3I(7$iSJnDoSHM?A7>AGk z^oo>k(or$-?{C#xZr#wOEGdaX)xu7Va>EC`Z(GzJOlPpj_wOnD-S~6bV;u|$-}qtN z1oJpsa5u%QDt+Rre|Rn)O5iNILC^+_7ats-5~R-5;%VS0tevt1zH1=eMk?oT2@af=<3Cfvtf zoR>1H^+6~rMl2~Q4cCze%)H`ekD;eMCYq(3c8NhMOg9|at_sJKoGamtJmAdmypC+Q zFE4yMmHEVxo#3By*tV`wZByvXnw7hbKNE^KEDdsvE1*6UHElDR`Mw$jh*p7UnNqFr zNt{61uMO8B4+NOuYoL zA=k}Bx@oRODT}XdKZ~N)VxwW3)oB!%PYDVpc7OmZ55DR$xJ{#g+tAAHl!T<#F5(ESExVB7_b#Lu{T3~ zhN6140YZEU0cF54?!|75?(q6r#ha0qe`Dl2B$TxvxIifgYE> zA478T|E~E_yk{L+8azU+UazFM4g1aX5kNcfCfBVo^`zgSjf*VRj~tx(k`uc2u|sIl z)rWa<3x)hAZ;Vrf9WbLB=I6D?i>41_$M)V4FGl9>OBcFq)IeUe<3|cVydRR4&>;~+ zR*0Sk&1ty$5DnG~oE)j^zV9*VxJq3&SRFKF+5N8?5J6gnlQBaop8%`9LkG@Vgv|?30cIbh97X9FPbW1@L0Q*^+NY-EKE8b;gv*AHV`W920z=^YcG8$Z z?)#(mZ&(IcPWmRfLkoaXm4GV8HB+-z`A$tsi2)PHDakOE4uzM_cbWxutFOJJ}BflWu>=a{!v zz{knbdNB@TD}#c2yO!*m6^g~KU=ujCEp}2vPM_$BF*eNdBPP0qHLG~Zh1aEzW3>*9 z+P-vJIDEo~GzpA7C4FX^a}Y_Ja1aDGe_KddYsqM_M?#WUo#lW==~RJ`^nLHjg+isHs+822E_NVlM#Rk@_NUg}Qg8d!vQ0ihDTl%ryryZ&x_mB_LP1f3x0{9b-^TR~Kj zWAi>#M$cvtm_eo_t4Zy0Z92|v+&3hMW$62NR0tKp@5!xz#{zyOCC@b1t3bM< zT^<<40xg)m6;9K#}BY@6Qqyg{$E7Z{3n zR#lc9vQ*ie9KlW|=R?RiS4O{XVFPa6MJG&0{YAC2t7G5?`|D@%)(+;5x!_C)4FLKq zDyLhfPBtJ3Hw)m2e<}6+ebeA#vwXEEi~v)+8suifAuPprc8&=<%}{C*g1r8N*XV+J zZ7!;&LIeY6`p!@-3b6_4swSaq?)PGjfMsiA*z zC5bNT!*^svw*#*Zj5h@fShY6DD&jVeRKPE9WFCaC``A4*&V1>^u+s7ja24OmYB`F8 zH!1R5V{W6i5zn!fChv?5sL+H^>K6`XIyE$OSpCUr7UJkZu7%@k#p3{yTPEUY9OKbS zn-hPl-ub!pbmS%b%mpsl`UTLW&2E&S9OfAi+SGnooz~>&^*Yg9gj5v>qAu5aeso12 zi$8_0)4GxCi9FA2P?%bJc8HS+|HJO$Wa{wja=rb?`k4cD^VJOh&KJD7|7D43xLVCq zL|iinV{fvO6Oa`q@zVl*!f7Ll$)V$|sCxC(l&x&8WKKrq=W zk`IWH0V;E#Zo?rimS7#rV(W`0NVy&|6wpYscJG_uHIFfbzE^#oVthN8?85_ZT{{li z*tPsf;*_x{1fxLSXhZt-h`mV+j43zBXK;MpEE)LW`IX;;Gy;ih9HrADWc7A_voaG7 zUcFrLg8=u9^ZSx$0KUOksCiW39U!`mJ6q1HS!^6AA0*_ zFUt^kF0r48dMAuMr8rZ1AXV0)t*2 zhpq-k4K45(jV_>p>nkk3S;|TF&Vet!#U;-COJ{0U`i-{;4b;?g&wl6D#@)>4Hrt_Eta5e=eJ`T466YFlY z$_MSwAX-jOCJaD5=1gv{896}2j@j->f-8~<@t>*KEcJzq2+y+f|Q>lL`mmJ z-owhnh^SLb6fG1~){X2jNksz87Usk-zTGxrS5Q{dNQsgWpU@>`7rvR)UWguBGtAxY z@DoDFx_@bMk_nsS>;q&p)?h+;HFe$d-iCjL5A^g}<~Lc%{0(xm+6?#6ZQZ$B55oc+ z4GN~aRGw1rBkq#cNPeB(nWtiP-A*7%r^BXiyy=lEB|K@_vfPCe2aZ>Dqc8zx739CA zBEdEmioJU7BZ()Q8Q%N78dHG>5lUeQbx6+#cVciFfs^NkrJO0(ULm0q*FC~|mv6e% zb>t8IOi${iq>Ht&RgSN8(+w#iHlx1cBLi+og!hr?)lCuOfllYHKMr|eiMCFtZP{#bTH$zA~f`igMjTGeeVpZ;_!`_ zjUTnv4E;?WtASP?00GHamSmckh$FG{6H_Ot_JzJBYpVbe7RFwPcoR7s+YUb)J~AjZf_EvG zZ>J}>#v#V)_)Wl*fs49+lcE*u6U%ufZ%tp)CJdPpFxKpNlLX66wyq;C)obl2JB&Q5 zp`&-U%zbqwklvVssPwP_F=DDE9BC!e9F1CrpQ|20ZkF#q{uZw;?XZ8nw^=Ks5>Q|RZ>LouOjKXF!FjLf?MJPnE}q`|}Q$eN!a zmjEf^gHi2Cgc6X=RDAT-WL)|x;5{O2DUa-s8d{mzen|+J-3qz8scDOv{){_k=%Yv@ z={hwVUG;!DD4NTFjL{3Po?aUnvwX@|U!%NgT%sCfBcf$Jj2OMt#s|1V0UAIr;T!`W zrjNk<&>XwMR26Yyej;TBiO$LoM2HAph#P&q85~+1a5l#$t?NQb#;48Co80l%+iW}J z4vD`9;whx9o1&rL;uA)D2E_E$x!p+0qMn(y%f3_Y?W^-BKVZXRZ9N43=#dq}qwjM? z?YXx?QL@K8&1qZ}eW5H)3fQ-1E*QioOV{0Qd9tSLHD%VyU>UE3Bvw?V2Q}XdHHouCs*i z(#)yL)w7&PzOmzQ^M$`17e!A?{Q_jCs#^sW)s7QMc8Tmm>22xk`Ahbjyf-D4#DO!s zqdTWkelox7KaW{!VXl(yxb;p=PRuPSVTl~OUl*t@VcF%;FN>{I;&~DSsTMY_2BrYoc z|7TrM7f3n7kUYSBCAaQGztHBU7+Mhh2;2iMUhnmV2s(E|m@g_!&EEdC6lrLejXA!ox{3SOehFde%#Nc`F|N?fs$mFSN!GNJ1mF z(q_%pZN*4!N73(NmB4KUuFlDU_*!C}9(w>x9h35BX=22^V!&wGkBfj9>k z|L*Qhf~=+Cp@%Iuo&xsE_*U7N&Oq5Ic=S-WV4^$^AQREY$|6v`zu*i6TW`&_!H8|h zj?rt7ytWXLW`Hw=U(VZoOsvaBvO9BW3p$dgPbx{a1iwD&UBFrP{E$mRH69Nv!rd|% z>THPfnlSiM`irUgv0pQZU;TT8pC-^}HyprR^E36$PgL?Ot^x%93eTUh2rVp84{tJ1 z`2ZX|g{kK`-yxZ;VYLx^=0kO2Ut%mh!27#I<|_v=afHdg>64#r=HbNDPdUt-T6^LQ zyy`h-#~&*8j@?m@ukM(!uC6uaRe}7U@yFmlx2f&k8>UZ0Iu>yb6stIsz?M^Z$#4 zjgzrek}dcFR00e;<1`85PrY*(aSFSN{E9Qv0`b@{Ouh4u zaeA(cE+Jn(MOH1t}iy1JaN^whdt=ukg{hE4=IPcf`wKyYgz8bIhhtzkQL|B`4}bJ^105Lxhe`xn)-Ox;Y>V$kHVPdbe2 zYW5n1_*JlBWvF;J_d{{rer75Sy}&^b9)9xr4~u*FxgR0a0p!vROT*!MIv2+uJg;%t zq4xmT04!Ld+c{&ybjfqPW)7uQ#7IA4)F8l$hRN>?Mo;OU8FLxRKlcU-NG4D|uXYFf z_Uqp>&tdv)(9vyjEm z-L&K|vP!i0ISIF>)IB&kaBPzQqw;bkHAMRc&-wjJP_A&~aiS}h!U+5g-VbGXes$ib zvsW)|FrjmEn9$V|^z!0oQI+hwt&9*GspPMWxL_J`TCIxGeL}+GL=uxN02>G z+=^gUR$&q73b*V~%tmge&NQ}lX~_=N#J{Jt_vBrWTqw`lr_D_!lc(zKHn$K2>#>hj zP}`L7C$QiVH%CL!Ev2et+w_1W8Ju9#qy}PZ%CwpKfo_8!QqQ`!2x>>Ne}RWRps4&d zl_6={Y-J_~dqSVFLutWj`KnzuuGl6cNW@o6+m@sp^DpvrN4~S90+vFDMZgfsBd%T3 zeD=86+PV`VjWx7i4igL~?m{OL5)iF!fg%uO8$2b#)Mhzq)cO)_z>?`8FHxm_Rk3sfdBVKK_<0)^hWrl z?X%}u?JCiZSCN129kjhOzX#6;7m2Qli_BEF65+ZwN2fXhpTFT_B9_i^tLyvAJ?)NH zr=z$X4`(mlx;-6!HXgd=OP0-g0uy=ckU`@m%k>75nrT%Y>U0YJHyqU6Ed@tIG9dh* zPyyaFnP%ltS&+{x#%Dc9Rz|d|c#rdRv?^lUk`lS-w@od3qViuzSB{L@Na!QbuD z-jIpbj1BdrEA%ri2I{m(<91Daq$E#z_IxaqxQz<6`|Hj`FdEab^}t&ngAlP6FFFrF5VNeLB&CsD|HBPY6; z_(#AKNapOFL-SxWjRR$V(z9k0ai43FR^vc0H}kQr^UV*Kr*-gA_74|7M?&AxfAn4=6$YJZX<|9bgys=_~x zZ~eE@Un;j}&rQm|qN3VI^<92AbNu_v3svf0%6ks}R?w>}x4enTm%}4k6!pPqZ|Q}K z_lQ#lllx{FK%mLZ5__iSNK>EclQgnp#LKkH#Pl@D6rH$*)uSvi*$uv%%m3_PtcG~f zEPOv>eDcN(j@Jq`Oe7IK?#sdLOS8>E0kQ<`P~@IWB(bAZg0ByZFSO?wrj={)p0|(( z(7kjl7DE~Lv#)qq`3*E=RD#n4Y7(o2>PXW$Th7(rkf?`9$1~!9lNFyQ8$O*JvLS64 zE=?p3{ZJsR9z-v-UlsYokPYg{livz)h%`#gwtiqTr|thxXN;4jF?-DWHL;2HW*G0@ zTe_$H?(`pzPOFUa0(DorV}WQzFSHsf$=3?H^*ke6HdA@bVSDG_K(<~}b?mc%BaIgk;1w09_^y4NPxTHKcx@p^lPa>K`ZEIzEd}v5dYj-f{g$ zSfUsqj4wNA+nJ{HpCaA%{Aus5{0q$kc}^ereSI!Xh+(oAM{S(N_!a{Q|2)J_16WHOBL zDtEXbRjtc$m80@d-L2rsRzt>3xQqV_P*q2gE+X&R8BZPk^26-cH@2*1CJ{iJWXEt0 zB4a8$iEsExjdB?0a(LzGPxmDANe?ywRm2f?7sGcbx_#`DfNh)PEQR!{ma(EAm@{5H zomPoe7`pq4=;Viu*VVO;4OyUqsBKz%tBFc{RaSTkU@Y7ACt0M-#BBPVdhGQ7rh_^u+t)PG# z;CJzLo6E}?K^FUMB{bI{0XixssGJ){mfJ>Eexb<_ziqwHFu09>q#JkiQ)!{PY#yb5 z01tqwj;^YN*K$PEif^qq0)pY(-UG75<7)FUYCRIP_4;?++iNdshSIrFR9G)(RYVE7 zW=fLU^M9ggV!zy~@p|S*ekvbV3V zB3 z#*eE3dq-Pqqi6z|OZ+0z27Dd`!ce0^HVrP%qgRP@;nR2(5?2z^2a`}1JL$ThN;*UE zu!tO=?kyqc(4P0bjh}VL!)aUTN$k`DOsCq~tV2Yx%=OHyyv)Ge3mv#`WKBgbCH2^m z0I%7qR_5QimJ^xRwQB}Lkt;10=li4jqN0#zV|oj}4@Y0BF<6*m+IfBme=XYK8$D^8 zmR*_K(N@<_V3~I&fR7=BjCd|6>>vMl5+>6rAOb#4ccvH#!o>!N&eCtIgJ7Dk4kS&( zY%t`P&hM@y-tAWb>&t`qsN4dAiutOoNtZCvLkK_acif>Tu;U9V&Eo1@;mh89t2!BF={LGr~q#so& zod#o^<8FR~nqeoNH;V>a&kuSe{)-DM71@(gDbQW})>kgKKBX_c0y}_I-97nU;Q~d@$Y#Ce0TuC?%zg4 zDXy=SE)62)odNx>z@I<2?>n-4TK_d^?Uwg>SlKf}dJpr6|BtP|jH;_?qDA422KV4@ z!GgQHLvVL@3GRg8PH@*?!QEYhyIXK~hvZxPdC$4u{c$rG0~p!#>h4utb5?bCO{Elp zKJN-;OWGy<`j~7;QJg4U(TO4bh`w!eKWLqls0mU9yd$mP{S;-Kg`?g?{ojWxtXIXq zkAh{$%^`>B2<>~5AtO$j#%jc4&D(L}O(i+i7bkHkYw$B5SAL?_#e0;I+Dip9P9B(l zgyE8GuQ4;{bapKJY0`5W?1%NMN~#A+9VdOobgsJLa9c-tL&#JZ0oXkiDW)1~c(GS$ z%(7fE)FQtDd3q7jhK^;dH$-m3zjRaaglx8F&7jAl35bpb>e1IU@#bg5rw^Z2FgqHq8?A#8x5$o~px zAULj>J9faAbltb%pKIacAf0MafdqJzQZwS}w9#fb7HmhOnW?T+$lWBKvYFl$hKK%^~QTh9KO;Rvb1(>jhW5El(R-Cm{8ea8}a` zV5XWB=7PcXF${DWQ?(dV!~Fk#CwU_!zRUYyY-@`MR9Po7xG&e-0pF`@Jsy`>1_zpr zDI5?7d1C!7mY0n^kV=7=ho}Z?kVMcNO?1fnnb&iq^imbTk?sf{jd@00#o0X;TWgl=Oj zuW*5>mIv$U?qs+ZWx?L8;|06CI{!gjeQes}kG_89jCowusMKpZOXEnLgGUm+_ZTq^ z%m$rpoh*%yDn+HvM0j9AP`oXxDCTePUR{Uaao%8z6?ZfzyfJ0DsF71=eL|(huKIfM zimFdvxeBZreyi9X?K8Ah>czW+#Zy-BQ5q+#3rLV(ar*r@^3b}#u@zNqgfONA^td?t zaoH{glK;3}N7ncqDPL;FziI!d%(Nx{ZSi}lC?n>yno1rq3!T-;)TsI$3BsX8%UkhY`2v=8$C=C$ATv`37#C5+4_e%@@WL^+5uH~hMP)nT&m>n2}{>;%hMHRn<}o2>DtQg6EDn)b;c(q zeg|tVtN2m+b}JcCg`m$^C;%clT9pFDa{=);JYdoOx<@snFhxU9_pY-0%j9) zg2Ct4%((*l%tUn( zLI}*JdxtLbz|s0z4pjFs!A2unRih0^(y;`y%KL9?CbN(XuLiUWgF{H@7{@_28?ny* zc9{4mg$(juzj91fEkEA3l(lvx1tVt_fiXzBK*Kw&!Wmw;7|b1PoA|&WIp71w*vdF zQ5#&Rt#AuwwkOdALB6nayeUsugMTkeQwDsPa(94a^2R^^GgFeNPXfm>|5cbe;OfSk z3ix|FWh-=93EqDwtF^zp+rm?AsLh0!&u-iiTy-UbxJg$)Tc|;IQ!Wj?0JWPA^H+TN z?8HiDgcyFt|Ii%K*BB(}?|beXZNT}`vd8me!=b`6R(sYKs{GQZ_jJrK z@i%{IeGvcMxbk$Hr!evYRB0#=3Q5mIP3&^y8&-t5v`$h(BnuDyjnoGDrOsDA25y-x z`Ik{WgWIRf7HWSv2@EHEG}h@K!G7cuSQ{4m*;`WuqI;Cu69k{}6!6|1M3)H;8M`I3 zY=%-*uHU5GyYfN>93>zqLX%^5l*QH+RCK--&Ld|=M1>WkC&|R3i+@nXl&z~5GH9L< z9siC{xiF%WKk3y~RB$*7*H8K9pwvOiRG$IgT7Z-RJ6u=bCV^_UmRSDBmJGYzUM|kk zPh54Tm_m@GUSQuJ38-D|)0MKSs6u9yb4)uJY4dS-H{ncF_=CADbvFke>N6RMk?d%f z5eBj4mcg?Cy68SNJS{#{#Hiuo>)Kl#A??Ga2N-dG$1`40^6l_8GsG-wQ}SRHC6oBG z8_wLyI3(va$dM@0iy8|-a-0Za1qlr1gV5eu%lX`2LsaIOlySKA98D{G4Rs+vq5jgT ziUX0BCu#54&Vh;LsZ4<}HgBelYiw7MUJ{ zHv~7V(P}9lU(RR67+sK`D&OSfM@Z1xJJDBrPQ}cMj6+`pH)B{7i}MG4K|(B9EG>nm z2sErx%9XMv(~};WDK?U+^@fSY5=u!8{os_s2o(|jPC=*7_Md6u%;-V;#nolw_KmDW z*b~%VsRJjv8_gJ0kIKg*>BRJrHay{dO2A=y3E73BZ_cF=h`nEJ8AM1ZS~x%8{g#JM zGyY-K&n!Rb``oB=)x-Y0p6@K)oP!qn(@caBw6DiTZVBs1ODYSKMe3lE*T$<~QVf$? zA8jsD9U-o+roeHD(mDxK6C7M|!Sw~X2Oq}b46Eikrv;i;63b1%%L|~KDc4zAcZT-8 zn;FApfl)zPW9u70S^cU>&fJvN-aehW4VOL-Oys{~@9Rv8M%#JjSL-vekqfDip8{Ifyj4g;m^&g{-tl8B!DcF z;)dc?#WJf#nd40*ho8cy|2iebCzW@U)3C9j@B7qGGE@(U*ZH*5A69}-so2cOx|6V? zFO0a%kk=9fhH!a^2)|cwpq}sj7Hv3mz@vde6#i(|%+3Zvd={9VQn2`qi)rfc(j}<{ zZT=+^GR+xd8vtW!(29#srwH`*Tb&dq|gxrq%!0v5~A zY4^nQIh~clxvvQY9{=;x-@3306k< zgH?9eGe+9T&*XK@x}?izK{L$4s*qHWOnfop32pL?(GlYk8Fkcjl;&MY0?Z!K_}VyI zLF>$6q~;3IqsFGtt@tCHOou;?n(}CVDGg_Uj1L3x=?pRoOir51 z-8zk+f41zx{hoK;z@iI{@Wk?LI252*;ci}=Kj4axt>k!!eWv19c%U-3%MMJu>nr7Wj83K zl&W;<6>8nxE8q~ILJ336J0iItdIvr$hRJ<|A;?>Ct<++($wn-NUXp5PP2jY>b<$8k zL$|60nUpakDo?2HMzS6heNTl(~idIRsv@&k(JZxl5 z)J+=_^h+{H@aICYHJibIyxWJwA3}&6R@QU&)+`eQtmVb{_LLC-E@ z@N+S3n5@uM1YunVB}-Vi6hA9|;Ju&4Q&i}e{eF5^X#xsv|vLIhp#3fFZ$RxzpNX25NJ|3K1pOEhRZ>0zYwjeO3}gdub~5G5V|9VMOSMi;JjqdZ-Z&!=#wng^t@-6qv#C zt;h_k`DGcXaE|04SJtyCB9Qhd8U7;c7r9S@s$+eQr*K?VSBo1CPo;iMDzHCNbWhq& zx@_?5+aGxymoHohL@eH;YGn~uCye+?I2$DdjZgUt1y3GP{v934z3zPyig0{RaqO*Z z-i!naxL_E9)h&fy3WfTBk}h739_Jm6uUIrMO#k+~xA&O>cs6fy9*uyo1FTMPz1ZBf zu=Ve0SSa@LrRow;dAYd!b2L-v`B0x5quF_)?!mN@bP<~kZ{tCI@Y@<^@IeG-ULnam zWc{h)9}!%|zW7___;x{h=9+(V&>56IvcDpz?K{uFHe^py548#k1g@0RB;4BCJ#UPg zHsk&+&-WqcrdvoQeSKKH)g;Kw3n2nb_A==3A|`5CLz;b{O#j+>^946km0aBsePIZ$ z0BfuZ?B&IRx65Z{yXXR?XXMOzmD@!R2c$fS1DAXnC9GX=}D#tNexMHQWVgtZXfPe8Zkj%6~K!yRhVkVD<9V5{8YGY zG}F+-yTbP)6cd zip1B%Kc=?M5T>t^udHP+Hf}g=IGpURj*dx|t-Efv3|-MCrvcpv<7N_cZZ37<#GBpI zXCf0HBAxIVS0OX3Jg*^79<0hu__mUsBef<^zA#&QL>vCyH|#n*?1*c8MWT6GFnm|{ z9rp13VDS7~^N(%!gKdIJ-|Ob1V1UqJbob-qU*f8+7n?UgaFh(ieoN31 zwW^K*q5bpSs>imo3Q!3YDGP>np|S)qq+UJq-4bD#}6|sEMp#A!^^R27UM8g`&%bet=TY_nEOQ?V8%AQtIO*RV>UoOk{2L%P;moPb7%xoi zPw-lqzRD?Q%z)q^KI1Po$zoMDuJny++R?NIvkmg@x6&RMRQI>MzD>|72kHivPRN1J9n-vb`)M1}-xaBlSe zAvV84)5y~98zD8qk6S}4IwYaZ*`s;xclL%p9jw%)-cx(rWcP2tNmYK5CKcpNyplyC zX-d1e1Hp1-8e|TKC9c;1f5HRS56PL59EWXBryPYz2tOED74E;Fq^%BQ6xl^DiqQS~ zNCQ3npfPc2w&ZZ<+n%TMZ-U6|w)cXIfL`UmF6P z=c`)h9BNYK&MCc9#Kl@bvk*$Wl7O?6g%gK`K(C@odn(;DuNVs=Pq<&2eW)XRCz&-w z?VD(=k8-<&7%Z74^HE;$h#zGsv;H@0x=D6ri=;}FZKdw&VK~#WK-o(wzIOdrQjS$R z=BCTT^UaMC;|iU;hn!lA)Z#`PkgodxCv%xhKmz;Q2A&}77pQ3uOV`s^0eT5e9xb&i zW8371^fd)lMw(sdx&}l}%a~-kl@+pp%@}j?43enQszLj01d=34JxH3|?sGwi$iHV( zf01%u=1b5-doXjZpx1qGCp_PFN#ejJ@$`r3uL-C}fomg}>G=l}*cA4+fTFNDq5t6s z*uDIL|Kiz~KTI47(a-$!FXz~0E{y{fwt1+{jfrMtJB3Pko{&~X&#Wqw7R6gUt35%z z4J!}|x2$bgw6a>`_If_-7G$+LEs%gvWM3li+M$1#RMb5r388*z)+%_WMWa6FygWHI zymtjNP{2(C?Oa%?@rRXtDee9X4!4dSTz=k83^`2@8I-r*{JU3~ZAjdOC z-qNd78}gVbVy~atRVag4H};oW3+F2=ZXR@|8%c#HLS0lo=l%?@j6G zDYw;L=NB7R&3G&96nunyH&b~H!X~;c&f*EMe>!e1ndLNSm~OhknI(9)Qp=jBP!_^Q zlB?sG`xsc{&1b1Kc8KTPkK*e6erc5kaf!M@Nm84{LaWG5&9Q-Q3DqwQW)06*g#(vX z=Wa7WIt7DYKPJ8E#CIx^A!Ax`wAe0kF}Y98mNt@WueaM{=)jO+B&Q08Rp>We8*R`y z$p&Hg-4+u_&m|wPY7Z$d>K*Y)_`#^oQC@H%Ry#r@NadChPh+$?I4H{Tv_KS}l=G#* zo$xnW+eYQfJy)lz823NAe0C}?1$siZ^1-wpU~tv0#8Ei0zs71D&NAi% zo;fMm1odK%T=jmMjhe;REjr{sV>5zv>zucZtYswO?ue+P{!Ww zwe}9>qEY6&|9r26=H1RLea~iA#XURK7*%<{@?zs1I1Z;Px3?j4A@j=x@*t|ZYw}bC zlzJfc$Jy26Q=BP>olf8P&xbN_A2uLaAb+WMZtmCB1=$~5KQzYyfm}f=-w21Ev7qMa z^YTIPiI3ZsnNiQUJ*AzbvJo^YN$T#>U!}<&%1}_9ojGYnJPa0OqbAc=)`dG1lz?-gq(H*&vrmm0b?D;qs6G@a~);K&~Qui*75KCQfg-O?Yq#^ z_a~`;MY~ZUpuRhOZ~`s%EYRKEW;1uV+Goo!#tKW|+D@go-=wM%?isJy7}jb)gMv zNakXPeKctO%xa%^VLGF4pRhgI9_>$m_y5O~vSSO3y`>8!nZ{cBXWN3pw9$Q26P zrbD;@ExchodBe z4|Yn3LPCR97!OKK7%|7C&fBeH6m|NjIk*!`4LB%( z1WhqSI`WPI2aA~=3@6GcJe$h%>RY>sH(YqQ*}Z8zS3pV$Dk8C zsvJL`y&YLrI{d6zq9>F$`a@BTYOcSyv)5hzGRir`*^SjEQLW=grR`)t${olV5^^2; zPw!YSUHNj(+l{N0K64Fdk+gR91{dEQV-v_F_si%?H-E-CsO1NTiPUD6H$}bwUdMcI zyYWP3$}TW8bU;l)W7<~w{H}IqOW>ZF5PyDc)9MT|LR^9=K4VC_Wz@bAg-3;o=wMhV z4jCSJmq6{r|2Xy0G`F0%(b?)y=NbdeB=vBTdY}}y(PHVBRc4jcaVy2fT8U=FGtC5Jls%KpGnx)6miNl>)kmR&4J%+|H1+w6vhW=xC<3JfiiVAUG zUw#lN7Z;9V$sBpHPV{r7DC$(&7Tw+mtLlnZdQ@QmCtb}8wai+jBd7NyRK!ygV^TwL zG3#R>OGySAmnbxcv?ynYsJQgR@B;l-j<`XhQ*C=kjlB2iX)8-uz+D((f`+JY*K_({ z+_HOk{|L$K5?7yAdGzHk@7UBoa0y(Bc(2WPerS-=wbrPNvo4|_D@Io`lL;=i>rm?) zM&VkB@?me&#sv;;#e%JJ15Md$&%kgv3TGmaegzxGc?c0XRtX8gNVkd(+I0+Z4xpNkN8)DS$#o!#sqY&uErqy{oMt#cu zQuLxY)*q;NbC>;BLAV8~$_mdef6-7iJGm*a^am0TSZ4SY4Nirj{Ya4e`pIzytKeO| zD%_?*3PQE|e;mjT^-)m&Xn^4)K*Odx?5!W?v3i(=e;u^Eg;V;7cHb{t6cN?tUHPw1 z%(5BEoz(^beug|Y;rVIBUP-T9*?JQzROM{dxqkFm{;q>+_Jl(FCUC%tmq=on^k5A$ zhcC>)XN*7Ni(Ys64qn~E%!MIZI$B>izeBE1q5l!TLn%(9t?HhQ4WjSg5E(R)_Msz_ z>>0`0QJ-HXYzAig|MuVU0!4LX1E&PkuJ3bn$zc>SR*D-If@|dGpd}O&=8gx%Pz8ds zvIr?ZeWIYEf{{yQgaNJlJ#K&kSxkqFrt?J7IPLLM7_@^TBN6yt&+5}SY&CXs@`{z< zStBO*$m%9LO@kGsJ-y^{a;P@#+pZd?qpP{TyE-Ov=!p`?aX7*-6JPuxgYMxyZ!-BP zdvHFQU>%8tNj}x%K?y5LD9OUtEJYGx;azhSG99dh$>=3#Qh){=zOlSf9L+2n?W|gE zG?gcACS!F|wia~w3Y|_9Tc-ItrBI%sVD739@6X4Zf2)xo=>+(r&0CykLb$ZG7s4;i z1d&S?UQTP|eLvsLP+-!h@&%*Q#Dlm&g9psSS1)F1tAhNYnu`2ygUpugJNXHl5}AKV zYuCueXu@5{SK9@Y%5BZJlF&XRg*(NIk^GeO<{ql_5!y5Bj=cByS*~;<;}QmkntUZ1 ziW`FddBm4TK##VX0Jt-EgY^`u%1&n442Sl$J-^ibJS}4?q1-2vpSbf!Eo7`_f&XuqM}%E?86)CLeSDMQwK`ih4%BKh^KrgCvbB--;qyyn*nK zu>u5msSRi~Bqq?Y3gAE~jQR*+{okzTig*4DMoEA9GMw^R7Y!2=3RJUpzFcDrMeP4% zY-VPb2W;L0b=HS&r-c6Px=Xjw-1yg_EaRGK!R!hdzwY=uY18m21#@7|hAnwtNpKmseNVXcv%6b<s|&+fti|#N*4O9yYH6b&+wb;`3N`P zsj`&jvU{mW#%kxYiBtOgAtzU5q`9-_ggzypICPH#8$YrCSL)yqD)roCJ)9UdLsab+ zl~CpT2Z#1Es8+qz5k41B)hwp#MLDxK$#EVvlrZWfaffuIrLrNhnSEHhP_8Nwb(2o5 zTxqA|IH#EpbKRn7iP8<&WU99@hTcR9#9FF6Fy{e_E)@M)-hbL56U7F8{o4#~mVzTv zj#q^v&m;>rcUCmcB|^(*@X}L*>iHLbVZ7`-~;PqhMK zNVL?(v*Vfhm)BPcEqB@nmo2ZqD9m+e8T3@$iRtKmgXyTh0w>MDGVGZ{ytGzGK(WF zz(^iEf9Yr8M^YkO{oa#=Ss2n7Ep7&vV6Iq%Jv^+YE7z3}6;^R&78#Z_d=)zS72RO& z{*wW-Jh^n}54U{lj{8hW-UuO8lpOK9xn{@?dAf(&0%MfcRsqS8iL-1XfkwX>n`dh^SWNT>yL{OX*$B9&3}=>e2>=+DY8T}iynZkqz=id3^V z?c#my&o9hAbMl+g&RuVv(XQNgyR;X(kqR>(G})c?p{TDJzaGmUYH}B@Ru*u~89>L- zAV9}>g}?b*c9C&PJ77CI=FV7I;ItkOF$qPLFZ{)L6DN*eQQdo`W_0v4YZ)4}Oc=5D zhEm@s?!Z?uhS!AuX4VFQU|h&8DqQ>_&ym38NLjwXY7@1LvtMD0xWQG(KO_UkDY%DP zRJU!LY6qS5koO2x>}#uAb?TavL6oU60x}&+m@fg9z&wTcH=YRfNLV5Fr>;s}Vl7Sq z%Rx$u&*2%2#GTuPZ~^HdO)F4;_&kz+QoJNFzKE$KQCh_t?p;y|Z#8M7C6uF@q4nRY zv+}2=2%eB5GIx-w&@7XEVof;|sSVCIGgiFr%M0hHqj=WI92-M(ULA{g{#J%RJZ4eP zm^gaEyjohcV8VLHM@whK7DCc#;Kxglxl_w}NTMTpY^Da&ywZS{C)ideBAAyRo*lKzm9liV`J0T=g*@RiRjt!qWuHuXCvd@jpGZ$T-G z^nyXzy|Ve9eOoKf`mB+vw@I}GTsS)F!E~VcR&fVRHLV@h54s6s+yHXvwfI_rIGKjD z+rohuh0t{07i1xOg?Gy8fJ}(@opXwFsA^jfh*C>NTttmwa4mh`6SjY10orT*D(8_& z){LMU$8|@vA4ZTzIVB4g{fDw$9ZmL?r&eEh&LR<0AslrPDd!;t-hIG`wDi3*t6S?qOC#NjS)aIEx3T=f9gV$&CSejK5myg9W2WP||;x?B9P9%!Enw zfA76mI6?N3@0Mwwz}=zk`03D0R%J(rr9LAXQ)JVp?kT7D0>uHXaT%}htzwk2gISQ% zCL7DZV1bfVK?2o0sM!WiqmCo%kN80uZD`5p?!rxb@cRw-(Q6XLsCa1VKS z!?Y7-&Z1FI$>o7B`Spq+GWUe7>x$13w>?>;mL>;cRDmSvPKLwI;MQ5@cfuW`#A`bq zgx?JE^5-IvRBGh?jCC=z9ujtA#aiOBro=F1vx9Cg39E}oqTB`}rFc4nXV0>k0QIZD2ieKT zPyJ1TCQ|Y~WU+}>O#G`BmIaz2&E*J9;o-3)jBIlwQF@76>7A0ZK90e!m4!1wT7<0r z#>(@!2Yc(-f3F&~+-}zvYIOfBAD}D8RGm1F)kOK)X&s2q-G0|uaV&;%N}o#Jsl*z* z)Jw@?iSPu0hQxF|5yC;unu`Gm#MBA#bU%tAY=c_8YJwQ={1Q(aEn<8~$BmH!?BDj^ zpS#dX8B?)jBfZjE4P^AGCr)L~5%Sg!{j8?2Ri%yO)Xd^eesOXqS1ssXkt@mg+BM#|;5(4O5DX{W z=~=ro+B$2+piLl1ETbYav3a#tXLxpCjjDykAz@3b_CnyEnC5sy?QDi;Z{B77>j2^R zON?x^|BT_TuK;`{awW4Sx_^J+-!)t+G+%zCgFV&3E1D5#y?{da&pC2EOJa+5g4%Kd z8K12_888TE3`cbsC@}DdMxVy1En{USd@yf5b=ko4awlgmN&8Vc=<6BT4?#`=ZAn{4 z7y9Q{@mD18USWxare9vaNF@lK5m;_H9P6zMWkPQ02h{Rri>>lSRU?pC;aOe$$bN_| z-3LfOs7lQ6M@8Q^Z(Q(65yS|Z8ZQtsM6Sjk^bvNAE|T*sdA>4ZunzW%`EphlE|Y~p ze@Ld!gr^%hT|mIeptDU}vc-U#h7O&gDbm<-U+a@w{EdUVrN_N>D(%*4)|E zWeqOCHxXSthQID>W-c$?F+ZEW;{9gVHmwq4A8!+Fhf!D}EqE7zyZ%uyf6M)`?}HbyCK}+-h-$k8GP_d$Iew#0 zncv2H?u8a0-!mhYHd2FL!nm#;S!cP;0~vma7~L&~mzsE`6jR2Ys?t{^F^<-_9Yuy_ z*kMtqV8@`wfCJ?R)+92_W>1IVgHr4@bO!tqml!sI+F%&#{P!OE*eiAf-jR(JAC<9OJ^k`pYwZT|JgMh^lj(c#@gB1y71mCG$ zoD%QWc~?-%IanH7t<-Pt-6{D^wg-QaMg6I1o^=+pZ-PRt(U)00H+?f-p?I7kn-@En zllPOaLFRV}bl`AJ9$JW=!otM2&^X5W=Fz7~-18D{QM2T)QCuT>BkBmG|Gu-0>>hk0 z7*|J)A`dlA1DR4*b1mjwLsp0~)0(C3LrL3VDrl}*2EjdSf=7!RYRn6nil)L<9BIvs zACLK`8oJb89R0N1!dQ8L8OepJhXNAs;KzTz^fMC?ZCRV9jX{E&#tl6_a#B=o?>SqSNjJ^Q9Me29;T`ef!;ET)%0rwvG-3o4?p0weA55_H~5Zi8@ zT7{paH33?{4TbOl_XoFlx4&>|6$V=Z+|*oteiQBHNJgnPx9VCGDm6KyJ?t>PWlq44vBz8HR3>n^qSBk>}ckDB1;Jn;F(+nyr z@fqOkpNl6yvE51?OuPHJsErMs|KHo(!h!-w(DuDIxQP}$M&Ii}9IZy1j~Z2a5g~{; zuq}t+5xBsk3DfD{ki;3;=H#JClzhn@sx=*E4*2VfM8ped(%<*XF?9wb;xKOae1if7 zei3@d3PU04%l-FeiA2DOv^yL({O@h*pV9NpzEi6nL{lL61*F9)DuowR>9?Eo1VVTo zW_vjOee(i3SfN!5d3}A2%VGiz%FD|GiG?DGG&^kUd7BQ$O1Qakpb+yfA2pqxo+|6> z+drFvTSAct?6Vw8Ws<5k=tLtXj=j5cJzK6Vj6putf)1fk!Gg);avZwYXm_6ASR&=; zCmc)p++VDa`CnvLT3T8IM-IBFhpT-yz5X*89vhnyFgiM#I)w&)#nx28P4KymoSqMV zUoTA`&Q?Z7M|+nH17haq)uM^`u#=OMQ~SqJM1&<~-kxvsX=D=vq+$sn(2W->w8P`# zr2PE_-OrZ4$s|%61G+jiOibNHf(%+UAa!;1o9c>+ikOQ3MzC$ZQfHpV<3fGWekq8A z&kolOh}EIt;Y9!iXuo{BST(1}6T3MaPydd`X2HHv{3Ta#xy~Gs({4Gi-g2t%*cB4A z>3gfNcW_{`P_CXP6d;I!fq~z0ub9mX1Co}OwsL(1a$|6ai0$lRV2eGd6R~Iaj^1`} zCn$<|@a~D``N%Bn zI0@7`@4O@>VGTTYVAM1;z=j8k&&)(DnTrtl4%_HF1-8C}8#d1?BS4_WrVK$@_AYKy z@w564q)mKoZk*xYo75C@9v&X82CIbq_PLTT{ipXY3UWMO_eSC_q@+I4e2rvfWd&Z) z_p>v*vyFBfsW{@n^;Q>U6_p@(Olp7tMB{x29%a1RV7=V#!6f*+hxvc9HP}8uV4KJ* zvYg5}iWQ_8`X;wJ=}ah&;k^s!lgCh*7nPU){)61M3Xha|MZjpx-ypX9~Qwg>tIt1c$ZN z{t#wAE1zo4pmKj#eJX!*2@VPAJ$BXk`^JM}VrpvKyk?FcxjkQ|s!&`6&4+-l>B6e2 zuOD-CWVIg3z?~E?>=6c-+Hs!eNDP04l)8HIxJRenoko?|$4Mh@hi?Tml{$~MFGz|5 zdBHk353*mrNa^U{Z+h+`g96LTk5=kG5&PXKkLLbOg+ut{Ch89z-lDj-8sPnF8cX%r*X-k|M~XNdCTvy(Ob9o|N6^q z*Nb($PG-=sGf1DNOJb9pT$BZc6z1Jrp?i_p{5O{GzxPM27AG<~Iyz>%WoEn8hBA?x z`()^_Dc|iYJg_swSY`8twW6ps<#ihr){FD}=~I8+T@vtz*?mWT!dhHHLjU2fK0LHU z0SEBue!X+Ri~U*HuioZHDc}hRy~5~xdr`p_uMGU3H-?5s<-Y=TT;&n|RA~AojMs6s z-TQ&Wt|UhvuZ9NflG8}Z*x9k@H9A8aozQG3)T`80u8~pxObERTRn0BQYW>di}2!f1H z|7iMI=2bq*IwKJX{tNFEPX+xC)ZkUX`fvlJ#O*c6Ea8JA2^3HpKl4IW=lsyrMFpIo z_#qI$F9MFZ1tM?dWHvSP^Q&O5f|wM!$Kz1o!>(-Lq2TRKrel}LR+k^t1~One)Y3Hc z^kQ+u0+1WX#mGiq5>WNqTp>V7DJkT7hvVSjkd~FTDcNL61!h5T*-j@z-PQH(qem@bmg1*1?BE*vUidZatcLj}`t$u~ zn=s5uu&)R;{y(sc3k!-WfCbhM*{4s3ky+Yl_K1qhP20i1w<@!>w1b@)OE4n+KY-1Q z1p41(_cMe3H(^NqAR{mp06qm3D3K7dKbBgkS#1D^iYn#p%?mhV^drW`42dXg5oN5g zEda3gZ`#^;$1^w(_1oPg)YPyp+D;j>c-`m%1st-b1YeObKPif*8~2&msVEVOVPj0W!bY?j9WPo z562K#TF)ul>vnjufR#zW($4@w3>V2HDygZNSI+{ z*aKWD`*ACP1iZ`@6;ZnWzStHO7n=Z}2yB8Nxi0BUeDS}*OiE21>ju1;9^-5EzZa&r zbH{*wU>xE0YfU=kw zLUH-r8BR`4%reVVOa5&4ejk~b7z{-wxLUEy-2typCQN+b<^srQy~R1@_G#1SQai;% zZLP_EEmXVSVh2dUi90wjt~+pnefsU;)EMW)J4&Yk6EG8?)Ugnzt|!P}8kK#&lj&=n zcKU?VD6J#xLCh`(@?)*Kb!HKIi^}$=Gld@vypO5CAjao$0ahW7t*BrivU_YYn@@_2jO=bFjO6zv z3W=aRs1(4$Z11OCknrhZWn!+n`p@8CXq`6KvNzxjxd4+OdD;0$jE+vk>lzN42DBKd z*6h|>60julfsO1Y-L1_2B~J1J0LXs-{%zx$ZB!c*URqc812hdxL3pWocYmDF-!uSJ z)z#ESI2}9hv3ar;%d4v$4WTl6z<;Uh*qNN;`6ohh2_AbbJlw>@S=rc(>sOSt zw3aXY96oz_d4Wwg5CZDzsvmWCJe}>UHuH*xx;jixnq4bZcYjb2BoZDg^hTRoqWw@T zQPjza&Dfe>-gTqGRciMeOl@r~F25%;HiK3mz#2!)dyO)eP6tmGemMgi5J<4=DIbn6 zaf;OBvK8BJ!y7|@)m06umu|j)2z1zJ+X0*$#K}S&&`TnE&8h*Q8%hQSv4=T6C}7E7 z6hiJmVDlz8G_(L-F-b=*g`AP|kN{j^h{6eImruHPz zY@#jIlWPwGRNMbvO3TQI0Dj`(d@aoD_8`Bs6wY?33TC?2eyuw|2w*cxnwnvyr8E_~ z%_!etk^6E4e1iUFaaa(btsh2P>uO=?8%L7}8T<`6n$8Ed9;9St5d@#Mp~S_-fp;ix z`vj=%aXtSd6ALv0K5(?w%ye^e!|Z)`1bV1&1ADIzD1{H zz}a2rw=+u1Rkx%+%iz$#J976twVdM|K-*}(hjqm;8V3qUWozqUGEtnH=>CJO*Id$Ta~398pa@nXA)ir~R_4Kb_4oJ}nI{&vZ>v-1BZm zs@7qH7aThQZ8R}Bj$SfTP(ZytUN*yZudnNIXDi~}z(G#{{R5^K1RjU@+FO1(-O1_c z_m?b=(3%O7Uf;l|pNpaKIEK&CN z_mzRgXus8!4J-90>El!flVo^!^!NVgai>hJUobt#sC`|8>`2Q zjRv1b2S{Ncx+&af_m~41t(>?xQ|BE9(0KY{wc(f7hx0oALpbc?pNY!c|Tnuv~4jCrD08n(p#@wfg(^ zTttK5_i|h!j@5A(U244+^!z`N!*uMphP^*qNd#<$_VeeMF5q^Put(W~d^&3gfY7w; z>`84Nmx`*z^7()Mn6$nDyLxvc6md%O@<X^t z$Nnu;`%3`MtFWeq8DGGg9dH=hP0k17c;MLqju=D&TxP>#%TG;J6%%Az!EU|=1j|~5*n(ar$-n= zETDrxUa&1-DXy@FJr4K~A1v~Mhg&&tvH{2^$p1fyg#WT80L23RkKO`#i~kqd`Y$tL zj0^hz{?KX`z0%HPlY?VAL;iv+Nx9&JH#mzue^X9lnhQ@|)dNa%m~(k=36frA1Hjf6uVmu83bObC{D5l;T<9>!$ zKKC#>VO!p11zJ8-kA`u(DzRgkh-8e2n3b?M#6L zE~gzXpr5V2$A;{NJLH7TYdqPX#o8JlHnO;=Qvpf80L14&xHu!FwlVv1Ar*oosXOKcDvO9 z@ARQactPReaIWX89|0o49q^YQ`?F54`Y#P*AkD+7ci=M)gqFCjXPVlf)?$h|{1HH9 ze9JTzQ`k+fLn1<+uh2m2;+~!@65$m(jR;`=3HTNS1O#T&A*5(R9t@9l=MNnKw^PUn z23%P|O${0lF#t|avG7%1QE@1P!`2D7{|*LEAjbtT?9V6F<>miDbEPg9m~WPsmy7tv zt(^}o*P13IBz${&^W`Q)d3#yv9%^ykt#RBE1c1Nk>1jNh4+pS{{+IhxbP|%dl3ag$ z!+>`VfR|FQ@dIYe?Rtc!QEd=YR743zKpTJL+CH{i;Sh=ha$l=1@Z#ELq= z5&-Bj+U)Rh;>~_~-TD^`oJYua-0G5+lN0mu;<;!&BxcaAg9c1RQb7Uv=Jr-Z9Bfuj zPgll(W8MY0FDwW!#-D?SA;xL$lr^_AtnzC=va=Nc>-0|*c)y!tH|hbYsH^)o`#ygK zm=SsP5&#rHL_r^e&+)r%GlpwZ`u1Rgg1jJxfa^#9PR%$>G+ z37oTmi?5d)# zpKZPYV8?9aNBTW~1K(?ju^Yf&rvjh=04Z=0Dj#^+iDUaWwjQPp1B?OkY0Tda#?ygb z1G!Ohkn5iT_-^~m7yYjHhqaVlAbGnDB#2!Wz;g`HQ@|lUZvZ4z86X>%U9Ttr=0k&k zROo!2fr$w@0M-yzKsYfv3S{2m$L3~d8Fd2^6B84Xk|?{!Mn-BHR)CBZkW~YxBelA! zfBove-W}HMs0IwAfB!2Pz`gyKe#+q>;W7*RZvxyAST8S+myX)aU0uXLj0%%SlslUE zNjIhB=TGDQP~?Jp`7Zx|g`H_oQ&$*SuzQdzQ_J=^DC2? zd*|GHzvX?O@7zg}i~(qi`$;zU{VlC7udXiiw@BB;-mP|6C{up_;LxojFLy4^H;LN6 zz1EV4l18@Za1defyx&3Z;4fQMV+NU!5S(YlpG|i=_tc>DK+7dd1t1N2{ zdmE@Mn-=zPQ;OJCzVAm1uZ)|C>y}e#zI*qs1y1F{f9OzJ)f1N)a-(!FVoFY+8FzMd zDPKTUR#a3(s3XI}^WMHqwP7%pQJ_cyyWH{&ElY?W2iHbYI|}bvp3uy*5wrRR2EU4@ zrXDr8XUwioZkY?G+(o%Cye2Q~>DaBDS2}fY! zl3136ZthoNmS1k|A3J)V3-59dKFR6$d=kp1o4XbQg2|a(o7Nt-XAc+g9kB%SIt}eq z1AtKKHE&X4_W8-VNx8M1-3nCsJn5h?aJ0npwJ$Idu@$(!7|2b|{G$Z?W9k{_pLx7x z>@f6DDuISzBiMD9rFq3!czmoJbrQD1+S7A*?2@Frn}yYrQ{d^)eS9o?nD2_>hw(Vs z@FYM^q^$CItDY;BqcdVlF|t}d7Pn^gYOdyu$P$LH9rF0Qr+0Or#@bT%A#q9(Sh0xr zh~}e?0RaKmTB~_e5WFt*nV%~;&aUFT#S6cjN5c@`hs$BHW zXQ?-Y@ZEC>h`?fKM_-TVQ3wMfEswb{lIG5twgM<9f==jqCQ4NXEMkxJ13ZUml>Mie zyExP7^fDf=?|Exv?3bdf_ZRHOp`=wk+Qm?SrhsI~%#U$)I?kNm*SGV^{^m7`Y^@iG zN6OH)Hrz|n5HWU1zU5zrnogH(52p2>IgpTG49$wb@-Z@kfa)cMTObfrl$H6C`rS0_ zC>PBQM#jh9u(G!=6gD&{UqB}GqjC{tOkHWVEn73J_=rLT`cI~euZ)enw2SY^;RUsl z!x&NYR&3ZMK{-uL-3+tR$4d-mAy-+LJ=Mx&J0=t&9~}Dd!_QGsA;9?caD0gAI`0!| z8K?l!u5UJd<1o{F@nRm~=LJPY7mz`{JiMT8X821N8}|P~R7*w}a>16tAd37DB1kBG zldU~I@ubC*&0@(w$j0TOC0AP;8;KG_gNmxEp_;D;1_oyTiYvuLU=?K*NHG13tH_@85pTVnOC+-x0C-G0;1_!UY4-8P89Y zI+?HFO6)G>##Dxr`deh`st>yZzn)Ul-0uKzqIwycU4WEn44mnJ$sEJeko5G3rZoC~ zWa?&jK2zAXhnY}$*$89C%5Wi;3>7JW?OMNaV~24lGZ#5aM~CG{ldY3_=lOdm&EBqdr^%8}cW8GYT@ zzGy{Q3aw3-y7K%cS{geI-G|Ri7xpTHix0+Ol6>-CcJD;vZ1es=ZvThaewe%dH`b-7 Yd_EB;3dTdu9;V>4ogKoux+VJLKjjv~^#A|> literal 0 HcmV?d00001 diff --git a/docs/docs/icechunk-python/virtual.md b/docs/docs/icechunk-python/virtual.md index 9abc2f18..b9b30871 100644 --- a/docs/docs/icechunk-python/virtual.md +++ b/docs/docs/icechunk-python/virtual.md @@ -148,4 +148,12 @@ ds = xr.open_zarr( # err (time, zlev, lat, lon) float64 257MB dask.array ``` -Success! We have created our full dataset with 31 timesteps spanning the month of august, all with virtual references to pre-existing data files in object store. This means we can now version control our dataset, allowing us to update it, and roll it back to a previous version without copying or moving any data from the original files. \ No newline at end of file +Success! We have created our full dataset with 31 timesteps spanning the month of august, all with virtual references to pre-existing data files in object store. This means we can now version control our dataset, allowing us to update it, and roll it back to a previous version without copying or moving any data from the original files. + +Finally, let's make a plot of the sea surface temperature! + +```python +ds.sst.isel(time=26, zlev=0).plot(x='lon', y='lat', vmin=0) +``` + +![oisst](../assets/datasets/oisst.png) \ No newline at end of file diff --git a/docs/docs/sample-datasets.md b/docs/docs/sample-datasets.md index 5021c171..688c2ed6 100644 --- a/docs/docs/sample-datasets.md +++ b/docs/docs/sample-datasets.md @@ -9,7 +9,7 @@ > The NOAA 1/4° Daily Optimum Interpolation Sea Surface Temperature (OISST) is a long term Climate Data Record that incorporates observations from different platforms (satellites, ships, buoys and Argo floats) into a regular global grid -Checkout an example dataset built using all virtual references pointing to daily Sea Surface Temperature data from 2020 to 2024 on NOAA's S3 bucket using python: +Check out an example dataset built using all virtual references pointing to daily Sea Surface Temperature data from 2020 to 2024 on NOAA's S3 bucket using python: ```python import icechunk @@ -23,4 +23,6 @@ storage = icechunk.StorageConfig.s3_anonymous( store = IcechunkStore.open_existing(storage=storage, mode="r", config=StoreConfig( virtual_ref_config=VirtualRefConfig.s3_anonymous(region='us-east-1'), )) -``` \ No newline at end of file +``` + +![oisst](./assets/datasets/oisst.png) \ No newline at end of file From b76ef25b9a2ae01b6af35614d4605e66cbbd929a Mon Sep 17 00:00:00 2001 From: Ryan Abernathey Date: Tue, 15 Oct 2024 11:10:27 -0400 Subject: [PATCH 139/167] Update README (#261) * update readme * remove double title --- README.md | 241 +++++++++++++++++------------------------------------- 1 file changed, 77 insertions(+), 164 deletions(-) diff --git a/README.md b/README.md index 42c96984..a3ad45af 100644 --- a/README.md +++ b/README.md @@ -2,161 +2,91 @@ ![Icechunk logo](https://raw.githubusercontent.com/earth-mover/icechunk/refs/heads/main/docs/docs/assets/logo.svg) -Icechunk is a transactional storage engine for Zarr designed for use on cloud object storage. - +PyPI +Crates.io +GitHub Repo stars +Earthmover Community Slack -Let's break down what that means: +--- + +Icechunk is an open-source (Apache 2.0), transactional storage engine for tensor / ND-array data designed for use on cloud object storage. +Icechunk works together with **[Zarr](https://zarr.dev/)**, augmenting the Zarr core data model with features +that enhance performance, collaboration, and safety in a cloud-computing context. + +## Documentation and Resources + +- This page: a general overview of the project's goals and components. +- [Icechunk Launch Blog Post](https://earthmover.io/blog/icechunk) +- [Frequently Asked Questions](https://icechunk.io/faq) +- Documentation for [Icechunk Python](https://icechunk.io/icechunk-python), the main user-facing + library +- Documentation for the [Icechunk Rust Crate](https://icechunk.io/icechunk-rust) +- The [Contributor Guide](https://icechunk.io/contributing) +- The [Icechunk Spec](https://icechunk.io/icechunk-python/spec) + +## Icechunk Overview + +Let's break down what "transactional storage engine for Zarr" actually means: - **[Zarr](https://zarr.dev/)** is an open source specification for the storage of multidimensional array (a.k.a. tensor) data. Zarr defines the metadata for describing arrays (shape, dtype, etc.) and the way these arrays are chunked, compressed, and converted to raw bytes for storage. Zarr can store its data in any key-value store. + There are many different implementations of Zarr in different languages. _Right now, Icechunk only supports + [Zarr Python](https://zarr.readthedocs.io/en/stable/)._ + If you're interested in implementing Icehcunk support, please [open an issue](https://github.com/earth-mover/icechunk/issues) so we can help you. - **Storage engine** - Icechunk exposes a key-value interface to Zarr and manages all of the actual I/O for getting, setting, and updating both metadata and chunk data in cloud object storage. Zarr libraries don't have to know exactly how icechunk works under the hood in order to use it. -- **Transactional** - The key improvement Icechunk brings on top of regular Zarr is to provide consistent serializable isolation between transactions. +- **Transactional** - The key improvement that Icechunk brings on top of regular Zarr is to provide consistent serializable isolation between transactions. This means that Icechunk data are safe to read and write in parallel from multiple uncoordinated processes. This allows Zarr to be used more like a database. -## Goals of Icechunk - -The core entity in Icechunk is a **store**. -A store is defined as a Zarr hierarchy containing one or more Arrays and Groups. -The most common scenario is for an Icechunk store to contain a single Zarr group with multiple arrays, each corresponding to different physical variables but sharing common spatiotemporal coordinates. -However, formally a store can be any valid Zarr hierarchy, from a single Array to a deeply nested structure of Groups and Arrays. -Users of Icechunk should aim to scope their stores only to related arrays and groups that require consistent transactional updates. - -Icechunk aspires to support the following core requirements for stores: - -1. **Object storage** - the format is designed around the consistency features and performance characteristics available in modern cloud object storage. No external database or catalog is required to maintain a store. -1. **Serializable isolation** - Reads are isolated from concurrent writes and always use a committed snapshot of a store. Writes are committed atomically and are never partially visible. Readers will not acquire locks. -1. **Time travel** - Previous snapshots of a store remain accessible after new ones have been written. -1. **Data Version Control** - Stores support both _tags_ (immutable references to snapshots) and _branches_ (mutable references to snapshots). -1. **Chunk sharding and references** - Chunk storage is decoupled from specific file names. Multiple chunks can be packed into a single object (sharding). Zarr-compatible chunks within other file formats (e.g. HDF5, NetCDF) can be referenced. -1. **Schema Evolution** - Arrays and Groups can be added, renamed, and removed from the hierarchy with minimal overhead. +The core entity in Icechunk is a repository or **repo**. +A repo is defined as a Zarr hierarchy containing one or more Arrays and Groups, and a repo functions as +self-contained _Zarr Store_. +The most common scenario is for an Icechunk repo to contain a single Zarr group with multiple arrays, each corresponding to different physical variables but sharing common spatiotemporal coordinates. +However, formally a repo can be any valid Zarr hierarchy, from a single Array to a deeply nested structure of Groups and Arrays. +Users of Icechunk should aim to scope their repos only to related arrays and groups that require consistent transactional updates. -## The Project +Icechunk supports the following core requirements: -This Icechunk project consists of three main parts: +1. **Object storage** - the format is designed around the consistency features and performance characteristics available in modern cloud object storage. No external database or catalog is required to maintain a repo. +(It also works with file storage.) +1. **Serializable isolation** - Reads are isolated from concurrent writes and always use a committed snapshot of a repo. Writes are committed atomically and are never partially visible. No locks are required for reading. +1. **Time travel** - Previous snapshots of a repo remain accessible after new ones have been written. +1. **Data version control** - Repos support both _tags_ (immutable references to snapshots) and _branches_ (mutable references to snapshots). +1. **Chunk shardings** - Chunk storage is decoupled from specific file names. Multiple chunks can be packed into a single object (sharding). +1. **Chunk references** - Zarr-compatible chunks within other file formats (e.g. HDF5, NetCDF) can be referenced. +1. **Schema evolution** - Arrays and Groups can be added, renamed, and removed from the hierarchy with minimal overhead. -1. The [Icechunk specification](spec/icechunk-spec.md). -1. A Rust implementation -1. A Python wrapper which exposes a Zarr store interface +## Key Concepts -All of this is open source, licensed under the Apache 2.0 license. +### Groups, Arrays, and Chunks -The Rust implementation is a solid foundation for creating bindings in any language. -We encourage adopters to collaborate on the Rust implementation, rather than reimplementing Icechunk from scratch in other languages. +Icechunk is designed around the Zarr data model, widely used in scientific computing, data science, and AI / ML. +(The Zarr high-level data model is effectively the same as HDF5.) +The core data structure in this data model is the **array**. +Arrays have two fundamental properties: -We encourage collaborators from the broader community to contribute to Icechunk. -Governance of the project will be managed by Earthmover PBC. +- **shape** - a tuple of integers which specify the dimensions of each axis of the array. A 10 x 10 square array would have shape (10, 10) +- **data type** - a specification of what type of data is found in each element, e.g. integer, float, etc. Different data types have different precision (e.g. 16-bit integer, 64-bit float, etc.) -## How Can I Use It? - -We recommend using Icechunk from Python, together with the Zarr-Python library - -!!! warning "Icechunk is a very new project." - It is not recommended for production use at this time. - These instructions are aimed at Icechunk developers and curious early adopters. - -### Installation and Dependencies - -Icechunk is currently designed to support the [Zarr V3 Specification](https://zarr-specs.readthedocs.io/en/latest/v3/core/v3.0.html). -Using it today requires installing the [still unreleased] Zarr Python V3 branch. - -To set up an Icechunk development environment, follow these steps: - -Clone the repository and navigate to the repository's directory. For example: - -```bash -git clone https://github.com/earth-mover/icechunk -cd icechunk/ -``` - -Activate your preferred virtual environment (here we use `virtualenv`): - -```bash -python3 -m venv .venv -source .venv/bin/activate -``` - -Alternatively, create a conda environment - -```bash -mamba create -n icechunk rust python=3.12 -conda activate icechunk -``` - -Install `maturin`: - -```bash -pip install maturin -``` - -Build the project in dev mode: - -```bash -cd icechunk-python/ -maturin develop -``` - -or build the project in editable mode: - -```bash -cd icechunk-python/ -pip install -e icechunk@. -``` +In Zarr / Icechunk, arrays are split into **chunks**, +A chunk is the minimum unit of data that must be read / written from storage, and thus choices about chunking have strong implications for performance. +Zarr leaves this completely up to the user. +Chunk shape should be chosen based on the anticipated data access patten for each array +An Icechunk array is not bounded by an individual file and is effectively unlimited in size. -> [!WARNING] -> This only makes the python source code editable, the rust will need to -> be recompiled when it changes. +For further organization of data, Icechunk supports **groups** withing a single repo. +Group are like folders which contain multiple arrays and or other groups. +Groups enable data to be organized into hierarchical trees. +A common usage pattern is to store multiple arrays in a group representing a NetCDF-style dataset. -### Basic Usage +Arbitrary JSON-style key-value metadata can be attached to both arrays and groups. -Once you have everything installed, here's an example of how to use Icechunk: - -```python -from icechunk import IcechunkStore, StorageConfig -from zarr import Array, Group - - -# Example using memory store -storage = StorageConfig.memory("test") -store = IcechunkStore.open_or_create(storage=storage) - -# Example using file store -storage = StorageConfig.filesystem("/path/to/root") -store = IcechunkStore.open_or_create(storage=storage) - -# Example using S3 -s3_storage = StorageConfig.s3_from_env(bucket="icechunk-test", prefix="oscar-demo-repository") -store = IcechunkStore.open_or_create(storage=storage) -``` - -## Running Tests - -You will need [`docker compose`](https://docs.docker.com/compose/install/) and (optionally) [`just`](https://just.systems/). -Once those are installed, first switch to the icechunk root directory, then start up a local minio server: -``` -docker compose up -d -``` - -Use `just` to conveniently run a test -``` -just test -``` - -This is just an alias for - -``` -cargo test --all -``` - -!!! tip - For other aliases see [Justfile](./Justfile). - -## Snapshots, Branches, and Tags +### Snapshots Every update to an Icechunk store creates a new **snapshot** with a unique ID. Icechunk users must organize their updates into groups of related operations called **transactions**. -For example, appending a new time slice to mutliple arrays should be done as single transactions, comprising the following steps: +For example, appending a new time slice to mutliple arrays should be done as a single transaction, comprising the following steps 1. Update the array metadata to resize the array to accommodate the new elements. 2. Write new chunks for each array in the group. @@ -164,22 +94,30 @@ While the transaction is in progress, none of these changes will be visible to o Once the transaction is committed, a new snapshot is generated. Readers can only see and use committed snapshots. +### Branches and Tags + Additionally, snapshots occur in a specific linear (i.e. serializable) order within **branch**. A branch is a mutable reference to a snapshot--a pointer that maps the branch name to a snapshot ID. The default branch is `main`. Every commit to the main branch updates this reference. Icechunk's design protects against the race condition in which two uncoordinated sessions attempt to update the branch at the same time; only one can succeed. -Finally, Icechunk defines **tags**--_immutable_ references to snapshot. +Icechunk also defines **tags**--_immutable_ references to snapshot. Tags are appropriate for publishing specific releases of a repository or for any application which requires a persistent, immutable identifier to the store state. +### Chunk References + +Chunk references are "pointers" to chunks that exist in other files--HDF5, NetCDF, GRIB, etc. +Icechunk can store these references alongside native Zarr chunks as "virtual datasets". +You can then can update these virtual datasets incrementally (overwrite chunks, change metadata, etc.) without touching the underling files. + ## How Does It Work? -> [!NOTE] -> For more detailed explanation, have a look at the [Icechunk spec](spec/icechunk-spec.md) +!!! note + For more detailed explanation, have a look at the [Icechunk spec](./spec.md) -Zarr itself works by storing both metadata and chunk data into an abstract store according to a specified system of "keys". -For example, a 2D Zarr array called myarray, within a group called mygroup, would generate the following keys: +Zarr itself works by storing both metadata and chunk data into a abstract store according to a specified system of "keys". +For example, a 2D Zarr array called `myarray`, within a group called `mygroup`, would generate the following keys: ``` mygroup/zarr.json @@ -188,7 +126,7 @@ mygroup/myarray/c/0/0 mygroup/myarray/c/0/1 ``` -In standard Zarr stores, these key map directly to filenames in a filesystem or object keys in an object storage system. +In standard regular Zarr stores, these key map directly to filenames in a filesystem or object keys in an object storage system. When writing data, a Zarr implementation will create these keys and populate them with data. When modifying existing arrays or groups, a Zarr implementation will potentially overwrite existing keys with new data. This is generally not a problem, as long there is only one person or process coordinating access to the data. @@ -206,28 +144,3 @@ flowchart TD zarr-python[Zarr Library] <-- key / value--> icechunk[Icechunk Library] icechunk <-- data / metadata files --> storage[(Object Storage)] ``` - -## FAQ - -1. _Why not just use Iceberg directly?_ - - Iceberg and all other "table formats" (Delta, Hudi, LanceDB, etc.) are based on tabular data model. - This data model cannot accommodate large, multidimensional arrays (tensors) in a general, scalable way. - -1. Is Icechunk part of Zarr? - - Formally, no. - Icechunk is a separate specification from Zarr. - However, it is designed to interoperate closely with Zarr. - In the future, we may propose a more formal integration between the Zarr spec and Icechunk spec if helpful. - For now, keeping them separate allows us to evolve Icechunk quickly while maintaining the stability and backwards compatibility of the Zarr data model. - -## Inspiration - -Icechunk was inspired by several existing projects and formats, most notably: - -- [FSSpec Reference Filesystem](https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.implementations.reference.ReferenceFileSystem) -- [Apache Iceberg](https://iceberg.apache.org/spec/) -- [LanceDB](https://lancedb.github.io/lance/format.html) -- [TileDB](https://docs.tiledb.com/main/background/key-concepts-and-data-format) -- [OCDBT](https://google.github.io/tensorstore/kvstore/ocdbt/index.html) From 965392aa6537626ed0f0c7334ee65e7894f11fa0 Mon Sep 17 00:00:00 2001 From: Ryan Abernathey Date: Tue, 15 Oct 2024 11:13:37 -0400 Subject: [PATCH 140/167] Update contributing headings (#262) --- docs/docs/contributing.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/docs/contributing.md b/docs/docs/contributing.md index 739738c2..6a9773a5 100644 --- a/docs/docs/contributing.md +++ b/docs/docs/contributing.md @@ -10,7 +10,10 @@ Icechunk is an open source (Apache 2.0) project and welcomes contributions in th - Documentation improvements - [open a GitHub pull request](https://github.com/earth-mover/icechunk/pulls) - Bug fixes and enhancements - [open a GitHub pull request](https://github.com/earth-mover/icechunk/pulls) -## Python Development Workflow + +## Development + +### Python Development Workflow Create / activate a virtual environment: @@ -47,11 +50,11 @@ pip install -e icechunk@. ``` -## Rust Development Worflow +### Rust Development Worflow TODO -# Roadmap +## Roadmap The initial release of Icechunk is just the beginning. We have a lot more planned for the format and the API. From 1eab7fd98a969867238068de3d521d7990181365 Mon Sep 17 00:00:00 2001 From: Julius Busecke Date: Tue, 15 Oct 2024 15:50:31 -0400 Subject: [PATCH 141/167] Typo fix jbusecke (#268) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a3ad45af..ea5cfeb5 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Readers can only see and use committed snapshots. ### Branches and Tags -Additionally, snapshots occur in a specific linear (i.e. serializable) order within **branch**. +Additionally, snapshots occur in a specific linear (i.e. serializable) order within a **branch**. A branch is a mutable reference to a snapshot--a pointer that maps the branch name to a snapshot ID. The default branch is `main`. Every commit to the main branch updates this reference. @@ -109,7 +109,7 @@ Tags are appropriate for publishing specific releases of a repository or for any Chunk references are "pointers" to chunks that exist in other files--HDF5, NetCDF, GRIB, etc. Icechunk can store these references alongside native Zarr chunks as "virtual datasets". -You can then can update these virtual datasets incrementally (overwrite chunks, change metadata, etc.) without touching the underling files. +You can then update these virtual datasets incrementally (overwrite chunks, change metadata, etc.) without touching the underling files. ## How Does It Work? From e011a547495452e812c75522293b059a76858cd8 Mon Sep 17 00:00:00 2001 From: Anshul Singhvi Date: Tue, 15 Oct 2024 13:06:28 -0700 Subject: [PATCH 142/167] Update README.md (#269) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ea5cfeb5..ba82292d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ that enhance performance, collaboration, and safety in a cloud-computing context library - Documentation for the [Icechunk Rust Crate](https://icechunk.io/icechunk-rust) - The [Contributor Guide](https://icechunk.io/contributing) -- The [Icechunk Spec](https://icechunk.io/icechunk-python/spec) +- The [Icechunk Spec](https://icechunk.io/spec) ## Icechunk Overview From b6051c023621aefc5f1d1f010ffc81e92bcf2759 Mon Sep 17 00:00:00 2001 From: Yaroslav Halchenko Date: Tue, 15 Oct 2024 16:34:43 -0400 Subject: [PATCH 143/167] =?UTF-8?q?Unify=20both=20of=20Sebasti=C3=A1ns=20i?= =?UTF-8?q?n=20mailmap=20(#270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With such a chance would look like ❯ git shortlog -sn 276 Sebastián Galkin 66 Matthew Iannucci 23 Orestis Herodotou 18 Deepak Cherian 14 Ryan Abernathey 12 Joe Hamman 7 dependabot[bot] 3 Joseph Hamman 2 Aimee Barciauskas 2 Tom Nicholas whenever without: ❯ git shortlog -sn 166 Sebastian Galkin 110 Sebastián Galkin 66 Matthew Iannucci 23 Orestis Herodotou 18 Deepak Cherian 14 Ryan Abernathey 12 Joe Hamman 7 dependabot[bot] 3 Joseph Hamman 2 Aimee Barciauskas 2 Tom Nicholas --- .mailmap | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .mailmap diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000..28222ca5 --- /dev/null +++ b/.mailmap @@ -0,0 +1,2 @@ +Sebastián Galkin +Sebastián Galkin From 1ec39f3bde7bb0b93a1820e1da6cbaf14bc77185 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Tue, 15 Oct 2024 16:35:17 -0400 Subject: [PATCH 144/167] Create .gitattributes to change linguist accounting (#271) --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..a894e29e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.ipynb linguist-detectable=false From d2cced7ef33e69291af506fcb6ca2165655b7d0c Mon Sep 17 00:00:00 2001 From: Yaroslav Halchenko Date: Tue, 15 Oct 2024 17:07:36 -0400 Subject: [PATCH 145/167] Add codespell support (config, workflow to detect/not fix) and make it fix few typos (#267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add github action to codespell main on push and PRs * Add pre-commit definition for codespell * Fix manually not yet known to codespell Zar typo but not yet known for not long: https://github.com/codespell-project/codespell/pull/3568 * Add rudimentary codespell config * [DATALAD RUNCMD] run codespell throughout fixing typos automagically (but ignoring overall fail due to ambigous ones) === Do not change lines below === { "chain": [], "cmd": "codespell -w || :", "exit": 0, "extra_inputs": [], "inputs": [], "outputs": [], "pwd": "." } ^^^ Do not change lines above ^^^ * [DATALAD RUNCMD] Do interactive fixing of some ambigous typos === Do not change lines below === { "chain": [], "cmd": "codespell -w -i 3 -C 2", "exit": 0, "extra_inputs": [], "inputs": [], "outputs": [], "pwd": "." } ^^^ Do not change lines above ^^^ * Mark remaining ignores for codespell * Fix expected by rust spacing to inline comment Co-authored-by: Sebastián Galkin --------- Co-authored-by: Sebastián Galkin --- .codespellrc | 6 +++++ .github/workflows/codespell.yml | 25 +++++++++++++++++++ .pre-commit-config.yaml | 5 ++++ README.md | 6 ++--- docs/docs/contributing.md | 2 +- docs/docs/faq.md | 6 ++--- docs/docs/icechunk-python/version-control.md | 2 +- docs/docs/icechunk-python/virtual.md | 4 +-- docs/docs/overview.md | 6 ++--- docs/docs/spec.md | 6 ++--- .../notebooks/demo-dummy-data.ipynb | 2 +- icechunk-python/notebooks/demo-s3.ipynb | 10 ++++---- icechunk-python/python/icechunk/__init__.py | 10 ++++---- icechunk-python/src/errors.rs | 2 +- icechunk-python/tests/test_zarr/test_api.py | 6 ++--- icechunk/examples/low_level_dataset.rs | 2 +- icechunk/src/lib.rs | 2 +- icechunk/src/refs.rs | 2 +- icechunk/src/repository.rs | 4 +-- icechunk/src/storage/object_store.rs | 4 +-- icechunk/src/zarr.rs | 2 +- icechunk/tests/test_s3_storage.rs | 2 +- 22 files changed, 76 insertions(+), 40 deletions(-) create mode 100644 .codespellrc create mode 100644 .github/workflows/codespell.yml diff --git a/.codespellrc b/.codespellrc new file mode 100644 index 00000000..f3f65c4d --- /dev/null +++ b/.codespellrc @@ -0,0 +1,6 @@ +[codespell] +# Ref: https://github.com/codespell-project/codespell#using-a-config-file +skip = .git*,*.svg,*.lock,*.css,.codespellrc +check-hidden = true +ignore-regex = ^\s*"image/\S+": ".*|\bND\b +ignore-words-list = crate diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml new file mode 100644 index 00000000..c59e0473 --- /dev/null +++ b/.github/workflows/codespell.yml @@ -0,0 +1,25 @@ +# Codespell configuration is within .codespellrc +--- +name: Codespell + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + codespell: + name: Check for spelling errors + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Annotate locations with typos + uses: codespell-project/codespell-problem-matcher@v1 + - name: Codespell + uses: codespell-project/actions-codespell@v2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 52d64998..3eee0401 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,3 +7,8 @@ repos: entry: just pre-commit language: system pass_filenames: false + - repo: https://github.com/codespell-project/codespell + # Configuration for codespell is in .codespellrc + rev: v2.3.0 + hooks: + - id: codespell diff --git a/README.md b/README.md index ba82292d..e0d9b569 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,10 @@ Arrays have two fundamental properties: In Zarr / Icechunk, arrays are split into **chunks**, A chunk is the minimum unit of data that must be read / written from storage, and thus choices about chunking have strong implications for performance. Zarr leaves this completely up to the user. -Chunk shape should be chosen based on the anticipated data access patten for each array +Chunk shape should be chosen based on the anticipated data access pattern for each array An Icechunk array is not bounded by an individual file and is effectively unlimited in size. -For further organization of data, Icechunk supports **groups** withing a single repo. +For further organization of data, Icechunk supports **groups** within a single repo. Group are like folders which contain multiple arrays and or other groups. Groups enable data to be organized into hierarchical trees. A common usage pattern is to store multiple arrays in a group representing a NetCDF-style dataset. @@ -86,7 +86,7 @@ Arbitrary JSON-style key-value metadata can be attached to both arrays and group Every update to an Icechunk store creates a new **snapshot** with a unique ID. Icechunk users must organize their updates into groups of related operations called **transactions**. -For example, appending a new time slice to mutliple arrays should be done as a single transaction, comprising the following steps +For example, appending a new time slice to multiple arrays should be done as a single transaction, comprising the following steps 1. Update the array metadata to resize the array to accommodate the new elements. 2. Write new chunks for each array in the group. diff --git a/docs/docs/contributing.md b/docs/docs/contributing.md index 6a9773a5..317c60b4 100644 --- a/docs/docs/contributing.md +++ b/docs/docs/contributing.md @@ -50,7 +50,7 @@ pip install -e icechunk@. ``` -### Rust Development Worflow +### Rust Development Workflow TODO diff --git a/docs/docs/faq.md b/docs/docs/faq.md index 783d7465..22b5d375 100644 --- a/docs/docs/faq.md +++ b/docs/docs/faq.md @@ -163,7 +163,7 @@ HDF is widely used in high-performance computing. --- Icechunk and HDF5 share the same data model: multidimensional arrays and metadata organized into a hierarchical tree structure. - This data model can accomodate a wide range of different use cases and workflows. + This data model can accommodate a wide range of different use cases and workflows. Both Icechunk and HDF5 use the concept of "chunking" to split large arrays into smaller storage units. @@ -238,7 +238,7 @@ The following table compares Zarr + Icechunk with TileDB Embedded in a few key a | *versioning* | snapshots, branches, tags | linear version history | Icechunk's data versioning model is closer to Git's. | | *unit of storage* | chunk | tile | (basically the same thing) | | *minimum write* | chunk | cell | TileDB allows atomic updates to individual cells, while Zarr requires writing an entire chunk. | -| *sparse arrays* | :material-close: | :material-check: | Zar + Icechunk do not currently support sparse arrays. | +| *sparse arrays* | :material-close: | :material-check: | Zarr + Icechunk do not currently support sparse arrays. | | *virtual chunk references* | :material-check: | :material-close: | Icechunk enables references to chunks in other file formats (HDF5, NetCDF, GRIB, etc.), while TileDB does not. | Beyond this list, there are numerous differences in the design, file layout, and implementation of Icechunk and TileDB embedded @@ -251,7 +251,7 @@ SafeTensors is a format developed by HuggingFace for storing tensors (arrays) sa By the same criteria Icechunk and Zarr are also "safe", in that it is impossible to trigger arbitrary code execution when reading data. SafeTensors is a single-file format, like HDF5, -SafeTensors optimizes for a simple on-disk layout that facilitates mem-map-based zero-copy reading in ML training pipleines, +SafeTensors optimizes for a simple on-disk layout that facilitates mem-map-based zero-copy reading in ML training pipelines, assuming that the data are being read from a local POSIX filesystem Zarr and Icechunk instead allow for flexible chunking and compression to optimize I/O against object storage. diff --git a/docs/docs/icechunk-python/version-control.md b/docs/docs/icechunk-python/version-control.md index 335b127a..410fe9f0 100644 --- a/docs/docs/icechunk-python/version-control.md +++ b/docs/docs/icechunk-python/version-control.md @@ -3,4 +3,4 @@ COMING SOON! In the meantime, you can read about [version control in Arraylake](https://docs.earthmover.io/arraylake/version-control), -which is very similar to version contol in Icechunk. \ No newline at end of file +which is very similar to version control in Icechunk. \ No newline at end of file diff --git a/docs/docs/icechunk-python/virtual.md b/docs/docs/icechunk-python/virtual.md index b9b30871..b86e70dd 100644 --- a/docs/docs/icechunk-python/virtual.md +++ b/docs/docs/icechunk-python/virtual.md @@ -4,7 +4,7 @@ While Icechunk works wonderfully with native chunks managed by Zarr, there is lo !!! warning - While virtual references are fully supported in Icechunk, creating virtual datasets currently relies on using experimental or pre-release versions of open source tools. For full instructions on how to install the required tools and ther current statuses [see the tracking issue on Github](https://github.com/earth-mover/icechunk/issues/197). + While virtual references are fully supported in Icechunk, creating virtual datasets currently relies on using experimental or pre-release versions of open source tools. For full instructions on how to install the required tools and their current statuses [see the tracking issue on Github](https://github.com/earth-mover/icechunk/issues/197). With time, these experimental features will make their way into the released packages. To create virtual Icechunk datasets with Python, the community utilizes the [kerchunk](https://fsspec.github.io/kerchunk/) and [VirtualiZarr](https://virtualizarr.readthedocs.io/en/latest/) packages. @@ -83,7 +83,7 @@ virtual_ds = xr.concat( # err (time, zlev, lat, lon) int16 64MB ManifestArray Self: store = cls.create(storage, mode, *args, **kwargs) assert(store) - # We dont want to call _open() becuase icechunk handles the opening, etc. + # We dont want to call _open() because icechunk handles the opening, etc. # if we have gotten this far we can mark it as open store._is_open = True @@ -293,7 +293,7 @@ def commit(self, message: str) -> str: This method will fail if: * there is no currently checked out branch - * some other writer updated the curret branch since the repository was checked out + * some other writer updated the current branch since the repository was checked out """ return self._store.commit(message) @@ -306,7 +306,7 @@ async def async_commit(self, message: str) -> str: This method will fail if: * there is no currently checked out branch - * some other writer updated the curret branch since the repository was checked out + * some other writer updated the current branch since the repository was checked out """ return await self._store.async_commit(message) @@ -321,7 +321,7 @@ def distributed_commit( This method will fail if: * there is no currently checked out branch - * some other writer updated the curret branch since the repository was checked out + * some other writer updated the current branch since the repository was checked out other_change_set_bytes must be generated as the output of calling `change_set_bytes` on other stores. The resulting commit will include changes from all stores. @@ -341,7 +341,7 @@ async def async_distributed_commit( This method will fail if: * there is no currently checked out branch - * some other writer updated the curret branch since the repository was checked out + * some other writer updated the current branch since the repository was checked out other_change_set_bytes must be generated as the output of calling `change_set_bytes` on other stores. The resulting commit will include changes from all stores. diff --git a/icechunk-python/src/errors.rs b/icechunk-python/src/errors.rs index 2323012c..8fd133dd 100644 --- a/icechunk-python/src/errors.rs +++ b/icechunk-python/src/errors.rs @@ -10,7 +10,7 @@ use thiserror::Error; /// A simple wrapper around the StoreError to make it easier to convert to a PyErr /// /// When you use the ? operator, the error is coerced. But if you return the value it is not. -/// So for now we just use the extra operation to get the coersion instead of manually mapping +/// So for now we just use the extra operation to get the coercion instead of manually mapping /// the errors where this is returned from a python class #[allow(clippy::enum_variant_names)] #[derive(Debug, Error)] diff --git a/icechunk-python/tests/test_zarr/test_api.py b/icechunk-python/tests/test_zarr/test_api.py index 1ffee7d8..baa88ffa 100644 --- a/icechunk-python/tests/test_zarr/test_api.py +++ b/icechunk-python/tests/test_zarr/test_api.py @@ -55,7 +55,7 @@ async def test_open_array(memory_store: IcechunkStore) -> None: assert z.shape == (100,) # open array, overwrite - # _store_dict wont currently work with IcechunkStore + # _store_dict won't currently work with IcechunkStore # TODO: Should it? pytest.xfail("IcechunkStore does not support _store_dict") store._store_dict = {} @@ -66,7 +66,7 @@ async def test_open_array(memory_store: IcechunkStore) -> None: # open array, read-only store_cls = type(store) - # _store_dict wont currently work with IcechunkStore + # _store_dict won't currently work with IcechunkStore # TODO: Should it? ro_store = store_cls.open(store_dict=store._store_dict, mode="r") @@ -96,7 +96,7 @@ async def test_open_group(memory_store: IcechunkStore) -> None: # open group, read-only store_cls = type(store) - # _store_dict wont currently work with IcechunkStore + # _store_dict won't currently work with IcechunkStore # TODO: Should it? pytest.xfail("IcechunkStore does not support _store_dict") ro_store = store_cls.open(store_dict=store._store_dict, mode="r") diff --git a/icechunk/examples/low_level_dataset.rs b/icechunk/examples/low_level_dataset.rs index 063ad193..9e6b9e9c 100644 --- a/icechunk/examples/low_level_dataset.rs +++ b/icechunk/examples/low_level_dataset.rs @@ -84,7 +84,7 @@ let zarr_meta1 = ZarrArrayMetadata {{ chunk_key_encoding: ChunkKeyEncoding::Slash, fill_value: FillValue::Int32(0), codecs: Codecs("codec".to_string()), - storage_transformers: Some(StorageTransformers("tranformers".to_string())), + storage_transformers: Some(StorageTransformers("transformers".to_string())), dimension_names: Some(vec![ Some("x".to_string()), Some("y".to_string()), diff --git a/icechunk/src/lib.rs b/icechunk/src/lib.rs index 204c40ae..e46b48cb 100644 --- a/icechunk/src/lib.rs +++ b/icechunk/src/lib.rs @@ -7,7 +7,7 @@ //! - There is a low level interface that speaks zarr keys and values, and is used to provide the //! zarr store that will be used from python. This is the [`zarr::Store`] type. //! - There is a translation language between low and high levels. When user writes to a zarr key, -//! we need to convert that key to the language of arrays and groups. This is implmented it the +//! we need to convert that key to the language of arrays and groups. This is implemented it the //! [`zarr`] module //! - There is an abstract type for loading and saving of the Arrow datastructures. //! This is the [`Storage`] trait. It knows how to fetch and write arrow. diff --git a/icechunk/src/refs.rs b/icechunk/src/refs.rs index e2399b81..bb52912a 100644 --- a/icechunk/src/refs.rs +++ b/icechunk/src/refs.rs @@ -310,7 +310,7 @@ mod tests { /// Execute the passed block with all test implementations of Storage. /// - /// Currently this function executes agains the in-memory and local filesystem object_store + /// Currently this function executes against the in-memory and local filesystem object_store /// implementations. async fn with_test_storages< R, diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index e89312e3..55eb1341 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -58,7 +58,7 @@ pub struct RepositoryConfig { pub inline_chunk_threshold_bytes: u16, // Unsafely overwrite refs on write. This is not recommended, users should only use it at their // own risk in object stores for which we don't support write-object-if-not-exists. There is - // teh posibility of race conditions if this variable is set to true and there are concurrent + // the possibility of race conditions if this variable is set to true and there are concurrent // commit attempts. pub unsafe_overwrite_refs: bool, } @@ -1399,7 +1399,7 @@ mod tests { let non_chunk = ds.get_chunk_ref(&new_array_path, &ChunkIndices(vec![1])).await?; assert_eq!(non_chunk, None); - // update old array use attriutes and check them + // update old array use attributes and check them ds.set_user_attributes( array1_path.clone(), Some(UserAttributes::try_new(br#"{"updated": true}"#).unwrap()), diff --git a/icechunk/src/storage/object_store.rs b/icechunk/src/storage/object_store.rs index c63e905f..b7ba8190 100644 --- a/icechunk/src/storage/object_store.rs +++ b/icechunk/src/storage/object_store.rs @@ -72,7 +72,7 @@ pub struct ObjectStorage { } impl ObjectStorage { - /// Create an in memory Storage implementantion + /// Create an in memory Storage implementation /// /// This implementation should not be used in production code. pub fn new_in_memory_store(prefix: Option) -> ObjectStorage { @@ -88,7 +88,7 @@ impl ObjectStorage { } } - /// Create an local filesystem Storage implementantion + /// Create an local filesystem Storage implementation /// /// This implementation should not be used in production code. pub fn new_local_store(prefix: &StdPath) -> Result { diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index 5b639bca..ea94ca61 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -754,7 +754,7 @@ impl Store { .list_prefix(prefix) .await? .map_ok(move |s| { - // If the prefix is "/", get rid of it. This can happend when prefix is missing + // If the prefix is "/", get rid of it. This can happen when prefix is missing // the trailing slash (as it does in zarr-python impl) let rem = &s[idx..].trim_start_matches('/'); let parent = rem.split_once('/').map_or(*rem, |(parent, _)| parent); diff --git a/icechunk/tests/test_s3_storage.rs b/icechunk/tests/test_s3_storage.rs index 8cf09f1d..0d35291d 100644 --- a/icechunk/tests/test_s3_storage.rs +++ b/icechunk/tests/test_s3_storage.rs @@ -75,7 +75,7 @@ pub async fn test_chunk_write_read() -> Result<(), Box> { assert_eq!(Bytes::from_static(b"ello"), back); let back = storage.fetch_chunk(&id, &ByteRange::to_offset(3)).await?; - assert_eq!(Bytes::from_static(b"hel"), back); + assert_eq!(Bytes::from_static(b"hel"), back); // codespell:ignore let back = storage.fetch_chunk(&id, &ByteRange::bounded(1, 4)).await?; assert_eq!(Bytes::from_static(b"ell"), back); From 5523e67485c509551d2dc81d65b9a83332610117 Mon Sep 17 00:00:00 2001 From: Ryan Avery Date: Tue, 15 Oct 2024 15:08:34 -0700 Subject: [PATCH 146/167] correct ref to spec.md (#275) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e0d9b569..5ea0c4fd 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ You can then update these virtual datasets incrementally (overwrite chunks, chan ## How Does It Work? !!! note - For more detailed explanation, have a look at the [Icechunk spec](./spec.md) + For more detailed explanation, have a look at the [Icechunk spec](./docs/docs/spec.md) Zarr itself works by storing both metadata and chunk data into a abstract store according to a specified system of "keys". For example, a 2D Zarr array called `myarray`, within a group called `mygroup`, would generate the following keys: From 9bcb7ca41000c122b11336fa7d8f9a70ec9e25f4 Mon Sep 17 00:00:00 2001 From: Ryan Abernathey Date: Tue, 15 Oct 2024 18:15:00 -0400 Subject: [PATCH 147/167] Add performance notebooks (#242) --- .../performance/era5_xarray-Icechunk.ipynb | 1066 +++++++++++++++++ .../performance/era5_xarray-zarr2.ipynb | 443 +++++++ .../performance/era5_xarray-zarr3.ipynb | 867 ++++++++++++++ icechunk-python/pyproject.toml | 1 + 4 files changed, 2377 insertions(+) create mode 100644 icechunk-python/notebooks/performance/era5_xarray-Icechunk.ipynb create mode 100644 icechunk-python/notebooks/performance/era5_xarray-zarr2.ipynb create mode 100644 icechunk-python/notebooks/performance/era5_xarray-zarr3.ipynb diff --git a/icechunk-python/notebooks/performance/era5_xarray-Icechunk.ipynb b/icechunk-python/notebooks/performance/era5_xarray-Icechunk.ipynb new file mode 100644 index 00000000..e19a7aa1 --- /dev/null +++ b/icechunk-python/notebooks/performance/era5_xarray-Icechunk.ipynb @@ -0,0 +1,1066 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "40c929a3-87d4-4c0e-a97d-1300d8adcae0", + "metadata": {}, + "source": [ + "# Icechunk Performance - Icechunk\n", + "\n", + "Using data from the [NCAR ERA5 AWS Public Dataset](https://nsf-ncar-era5.s3.amazonaws.com/index.html)." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "b2904d5f-090b-4344-a2f7-99096ba26d27", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "xarray: 0.9.7.dev3734+g26081d4f\n", + "dask: 2024.9.1+8.g70f56e28\n", + "zarr: 3.0.0b0\n", + "icechunk: 0.1.0-alpha.1\n" + ] + } + ], + "source": [ + "import xarray as xr\n", + "import zarr\n", + "import dask\n", + "import fsspec\n", + "from dask.diagnostics import ProgressBar\n", + "\n", + "import icechunk\n", + "from icechunk import IcechunkStore, StorageConfig\n", + "\n", + "print('xarray: ', xr.__version__)\n", + "print('dask: ', dask.__version__)\n", + "print('zarr: ', zarr.__version__)\n", + "print('icechunk:', icechunk.__version__)" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "5110e6d0-1c9a-4943-9f5d-a0d96bcbb5e0", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "zarr.config.set(\n", + " {\n", + " 'threading.max_workers': 16,\n", + " 'async.concurrency': 128\n", + " }\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "081e1a71-873e-45c3-b77d-5b7aa1617286", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 246 ms, sys: 51.8 ms, total: 297 ms\n", + "Wall time: 2.22 s\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/srv/conda/envs/icechunk-pip/lib/python3.12/site-packages/xarray/backends/api.py:357: UserWarning: The specified chunks separate the stored chunks along dimension \"time\" starting at index 1. This could degrade performance. Instead, consider rechunking after loading.\n", + " var_chunks = _get_chunk(var, chunks, chunkmanager)\n" + ] + } + ], + "source": [ + "url = \"https://nsf-ncar-era5.s3.amazonaws.com/e5.oper.an.pl/194106/e5.oper.an.pl.128_060_pv.ll025sc.1941060100_1941060123.nc\"\n", + "%time ds = xr.open_dataset(fsspec.open(url).open(), engine=\"h5netcdf\", chunks={\"time\": 1})\n", + "ds = ds.drop_encoding()" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "b3048527-c50f-451c-9500-cac6c22dd1bc", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Size: 4GB\n", + "Dimensions: (time: 24, level: 37, latitude: 721, longitude: 1440)\n", + "Coordinates:\n", + " * latitude (latitude) float64 6kB 90.0 89.75 89.5 ... -89.5 -89.75 -90.0\n", + " * level (level) float64 296B 1.0 2.0 3.0 5.0 ... 925.0 950.0 975.0 1e+03\n", + " * longitude (longitude) float64 12kB 0.0 0.25 0.5 0.75 ... 359.2 359.5 359.8\n", + " * time (time) datetime64[ns] 192B 1941-06-01 ... 1941-06-01T23:00:00\n", + "Data variables:\n", + " PV (time, level, latitude, longitude) float32 4GB dask.array\n", + " utc_date (time) int32 96B dask.array\n", + "Attributes:\n", + " DATA_SOURCE: ECMWF: https://cds.climate.copernicus.eu, Copernicu...\n", + " NETCDF_CONVERSION: CISL RDA: Conversion from ECMWF GRIB 1 data to netC...\n", + " NETCDF_VERSION: 4.8.1\n", + " CONVERSION_PLATFORM: Linux r1i4n4 4.12.14-95.51-default #1 SMP Fri Apr 1...\n", + " CONVERSION_DATE: Wed May 10 06:33:49 MDT 2023\n", + " Conventions: CF-1.6\n", + " NETCDF_COMPRESSION: NCO: Precision-preserving compression to netCDF4/HD...\n", + " history: Wed May 10 06:34:19 2023: ncks -4 --ppc default=7 e...\n", + " NCO: netCDF Operators version 5.0.3 (Homepage = http://n...\n" + ] + } + ], + "source": [ + "print(ds)" + ] + }, + { + "cell_type": "markdown", + "id": "7f4a801c-b570-45e3-b37f-2e140a2fb273", + "metadata": {}, + "source": [ + "### Load Data from HDF5 File\n", + "\n", + "This illustrates how loading directly from HDF5 files on S3 can be slow, even with Dask." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "29e344c6-a25e-4342-979f-d2d2c7aed7a7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[########################################] | 100% Completed | 53.73 ss\n" + ] + } + ], + "source": [ + "with ProgressBar():\n", + " dsl = ds.load()" + ] + }, + { + "cell_type": "markdown", + "id": "bdbd3f6c-e62c-4cfc-8cfb-b0fa22b6bddd", + "metadata": {}, + "source": [ + "### Initialize Icechunk Repo" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "9283b1f5-a0e9-43ef-bd8a-5985bedc2d17", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "prefix = \"ryan/icechunk-tests-era5-999\"\n", + "store = await IcechunkStore.create(\n", + " storage=StorageConfig.s3_from_env(\n", + " bucket=\"icechunk-test\",\n", + " prefix=prefix\n", + " ),\n", + " mode=\"w\"\n", + ")\n", + "store" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "b13b469d-45d7-4844-b153-b44d274cb220", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "('main', 'B8ZZN2YZS6NQKM17X68G')" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "store.branch, store.snapshot_id" + ] + }, + { + "cell_type": "markdown", + "id": "12c4ce5a-f1dd-4576-9d89-071583cd92a4", + "metadata": {}, + "source": [ + "### Store Data To Icechunk\n", + "\n", + "We specify encoding to set both compression and chunk size." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "67c6389d-79a0-4992-b845-6a633cb4d86b", + "metadata": {}, + "outputs": [], + "source": [ + "encoding = {\n", + " \"PV\": {\n", + " \"codecs\": [zarr.codecs.BytesCodec(), zarr.codecs.ZstdCodec()],\n", + " \"chunks\": (1, 1, 721, 1440)\n", + " }\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "4e632068-fb29-4a6f-a3d0-d19edb8f68a2", + "metadata": {}, + "source": [ + "Note that Dask is not required to obtain good performance when reading and writing. Zarr and Icechunk use multithreading and asyncio internally." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "b9a8c5ab-cc5a-4a05-b4ba-3b52be187e18", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 54 s, sys: 1.56 s, total: 55.5 s\n", + "Wall time: 18.9 s\n" + ] + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%time dsl.to_zarr(store, zarr_format=3, consolidated=False, encoding=encoding)" + ] + }, + { + "cell_type": "code", + "execution_count": 43, + "id": "bc33e613-7527-4f4f-92be-c1a20c2b8624", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[########################################] | 100% Completed | 18.02 ss\n" + ] + } + ], + "source": [ + "# with ProgressBar():\n", + "# (dsl\n", + "# .chunk({\"time\": 1, \"level\": 10})\n", + "# .to_zarr(store, zarr_format=3, consolidated=False, encoding=encoding)\n", + "# )" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "b6b19d8b-3655-4213-99c9-5857c2ac126b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'AS64P9SQ7NY1P22P8GS0'" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "await store.commit(\"wrote data\")" + ] + }, + { + "cell_type": "markdown", + "id": "34b1a12c-9640-4f8b-a5fc-2ade040b437c", + "metadata": {}, + "source": [ + "### Read Data Back" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "e74e2d0e-c8ad-44ec-90b6-51de574aafa9", + "metadata": {}, + "outputs": [], + "source": [ + "store = await IcechunkStore.open_existing(\n", + " storage=StorageConfig.s3_from_env(\n", + " bucket=\"icechunk-test\",\n", + " prefix=prefix\n", + " ),\n", + " mode=\"r\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "a9c1bfc7-61d2-4a92-ab82-b026e7b9fcf6", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 16.8 ms, sys: 2.45 ms, total: 19.2 ms\n", + "Wall time: 97.4 ms\n" + ] + } + ], + "source": [ + "%time dsic = xr.open_dataset(store, consolidated=False, engine=\"zarr\")" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "c09243a3-9965-4952-a7af-21f4e95697b9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Size: 4GB\n", + "Dimensions: (level: 37, latitude: 721, longitude: 1440, time: 24)\n", + "Coordinates:\n", + " * level (level) float64 296B 1.0 2.0 3.0 5.0 ... 925.0 950.0 975.0 1e+03\n", + " * latitude (latitude) float64 6kB 90.0 89.75 89.5 ... -89.5 -89.75 -90.0\n", + " * longitude (longitude) float64 12kB 0.0 0.25 0.5 0.75 ... 359.2 359.5 359.8\n", + " * time (time) datetime64[ns] 192B 1941-06-01 ... 1941-06-01T23:00:00\n", + "Data variables:\n", + " PV (time, level, latitude, longitude) float32 4GB ...\n", + " utc_date (time) int32 96B ...\n", + "Attributes:\n", + " CONVERSION_DATE: Wed May 10 06:33:49 MDT 2023\n", + " CONVERSION_PLATFORM: Linux r1i4n4 4.12.14-95.51-default #1 SMP Fri Apr 1...\n", + " Conventions: CF-1.6\n", + " DATA_SOURCE: ECMWF: https://cds.climate.copernicus.eu, Copernicu...\n", + " NCO: netCDF Operators version 5.0.3 (Homepage = http://n...\n", + " NETCDF_COMPRESSION: NCO: Precision-preserving compression to netCDF4/HD...\n", + " NETCDF_CONVERSION: CISL RDA: Conversion from ECMWF GRIB 1 data to netC...\n", + " NETCDF_VERSION: 4.8.1\n", + " history: Wed May 10 06:34:19 2023: ncks -4 --ppc default=7 e...\n" + ] + } + ], + "source": [ + "print(dsic)" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "feb23457-c6fe-4363-8393-c92ab1ae7a89", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 16.8 ms, sys: 78 μs, total: 16.8 ms\n", + "Wall time: 102 ms\n" + ] + }, + { + "data": { + "text/plain": [ + "array(0.00710905, dtype=float32)" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%time dsic.PV[0, 0, 0, 0].values" + ] + }, + { + "cell_type": "markdown", + "id": "2eef8e3a-c0ce-4383-b76a-e852a50f7398", + "metadata": {}, + "source": [ + "As with writing, Dask is not required for performant reading of the data.\n", + "In this example we can load the entire dataset (nearly 4GB) in 8s. " + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "d5103624-554c-4d18-a323-d24f82b99818", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 11 s, sys: 3.67 s, total: 14.7 s\n", + "Wall time: 2.03 s\n" + ] + } + ], + "source": [ + "%time _ = dsic.compute()" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "7782d02a-db34-4113-8fe6-6162a129d290", + "metadata": {}, + "outputs": [], + "source": [ + "xr.testing.assert_identical(_, ds)" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "c2d61f3e-c4b6-4d52-a55f-6fad900d04db", + "metadata": {}, + "outputs": [], + "source": [ + "dsicc = dsic.chunk({\"time\": 1, \"level\": 10})" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "894fa53e-845a-41fe-a7a7-4cf859ea5928", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[########################################] | 100% Completed | 2.13 sms\n" + ] + } + ], + "source": [ + "from dask.diagnostics import ProgressBar\n", + "with ProgressBar():\n", + " _ = dsicc.compute()" + ] + }, + { + "cell_type": "code", + "execution_count": 45, + "id": "f8e13924-9daa-488c-be67-ab07ab4fcc99", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
<xarray.Dataset> Size: 4GB\n",
+       "Dimensions:    (latitude: 721, level: 37, time: 24, longitude: 1440)\n",
+       "Coordinates:\n",
+       "  * latitude   (latitude) float64 6kB 90.0 89.75 89.5 ... -89.5 -89.75 -90.0\n",
+       "  * level      (level) float64 296B 1.0 2.0 3.0 5.0 ... 925.0 950.0 975.0 1e+03\n",
+       "  * longitude  (longitude) float64 12kB 0.0 0.25 0.5 0.75 ... 359.2 359.5 359.8\n",
+       "  * time       (time) datetime64[ns] 192B 1941-06-01 ... 1941-06-01T23:00:00\n",
+       "Data variables:\n",
+       "    utc_date   (time) int32 96B 1941060100 1941060101 ... 1941060122 1941060123\n",
+       "    PV         (time, level, latitude, longitude) float32 4GB 0.007109 ... -1...\n",
+       "Attributes:\n",
+       "    CONVERSION_DATE:      Wed May 10 06:33:49 MDT 2023\n",
+       "    CONVERSION_PLATFORM:  Linux r1i4n4 4.12.14-95.51-default #1 SMP Fri Apr 1...\n",
+       "    Conventions:          CF-1.6\n",
+       "    DATA_SOURCE:          ECMWF: https://cds.climate.copernicus.eu, Copernicu...\n",
+       "    NCO:                  netCDF Operators version 5.0.3 (Homepage = http://n...\n",
+       "    NETCDF_COMPRESSION:   NCO: Precision-preserving compression to netCDF4/HD...\n",
+       "    NETCDF_CONVERSION:    CISL RDA: Conversion from ECMWF GRIB 1 data to netC...\n",
+       "    NETCDF_VERSION:       4.8.1\n",
+       "    history:              Wed May 10 06:34:19 2023: ncks -4 --ppc default=7 e...
" + ], + "text/plain": [ + " Size: 4GB\n", + "Dimensions: (latitude: 721, level: 37, time: 24, longitude: 1440)\n", + "Coordinates:\n", + " * latitude (latitude) float64 6kB 90.0 89.75 89.5 ... -89.5 -89.75 -90.0\n", + " * level (level) float64 296B 1.0 2.0 3.0 5.0 ... 925.0 950.0 975.0 1e+03\n", + " * longitude (longitude) float64 12kB 0.0 0.25 0.5 0.75 ... 359.2 359.5 359.8\n", + " * time (time) datetime64[ns] 192B 1941-06-01 ... 1941-06-01T23:00:00\n", + "Data variables:\n", + " utc_date (time) int32 96B 1941060100 1941060101 ... 1941060122 1941060123\n", + " PV (time, level, latitude, longitude) float32 4GB 0.007109 ... -1...\n", + "Attributes:\n", + " CONVERSION_DATE: Wed May 10 06:33:49 MDT 2023\n", + " CONVERSION_PLATFORM: Linux r1i4n4 4.12.14-95.51-default #1 SMP Fri Apr 1...\n", + " Conventions: CF-1.6\n", + " DATA_SOURCE: ECMWF: https://cds.climate.copernicus.eu, Copernicu...\n", + " NCO: netCDF Operators version 5.0.3 (Homepage = http://n...\n", + " NETCDF_COMPRESSION: NCO: Precision-preserving compression to netCDF4/HD...\n", + " NETCDF_CONVERSION: CISL RDA: Conversion from ECMWF GRIB 1 data to netC...\n", + " NETCDF_VERSION: 4.8.1\n", + " history: Wed May 10 06:34:19 2023: ncks -4 --ppc default=7 e..." + ] + }, + "execution_count": 45, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "actual = _\n", + "actual" + ] + }, + { + "cell_type": "code", + "execution_count": 46, + "id": "29b08fa1-aefa-4b59-bb64-d31fe88d614a", + "metadata": {}, + "outputs": [], + "source": [ + "xr.testing.assert_identical(actual, dsl)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0cf1d7dc-44b4-4c92-bfe1-5fde04ac0b62", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python [conda env:icechunk-pip]", + "language": "python", + "name": "conda-env-icechunk-pip-py" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/icechunk-python/notebooks/performance/era5_xarray-zarr2.ipynb b/icechunk-python/notebooks/performance/era5_xarray-zarr2.ipynb new file mode 100644 index 00000000..1ea40b00 --- /dev/null +++ b/icechunk-python/notebooks/performance/era5_xarray-zarr2.ipynb @@ -0,0 +1,443 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "40c929a3-87d4-4c0e-a97d-1300d8adcae0", + "metadata": {}, + "source": [ + "# Icechunk Performance - Zarr V2\n", + "\n", + "Using data from the [NCAR ERA5 AWS Public Dataset](https://nsf-ncar-era5.s3.amazonaws.com/index.html)." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "b2904d5f-090b-4344-a2f7-99096ba26d27", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "xarray: 2024.7.0\n", + "dask: 2024.6.2\n", + "zarr: 2.18.2\n" + ] + } + ], + "source": [ + "import xarray as xr\n", + "import zarr\n", + "import dask\n", + "import fsspec\n", + "from dask.diagnostics import ProgressBar\n", + "\n", + "print('xarray: ', xr.__version__)\n", + "print('dask: ', dask.__version__)\n", + "print('zarr: ', zarr.__version__)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "081e1a71-873e-45c3-b77d-5b7aa1617286", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 123 ms, sys: 44.5 ms, total: 168 ms\n", + "Wall time: 1.91 s\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/srv/conda/envs/notebook/lib/python3.12/site-packages/xarray/core/dataset.py:277: UserWarning: The specified chunks separate the stored chunks along dimension \"time\" starting at index 1. This could degrade performance. Instead, consider rechunking after loading.\n", + " warnings.warn(\n" + ] + } + ], + "source": [ + "url = \"https://nsf-ncar-era5.s3.amazonaws.com/e5.oper.an.pl/194106/e5.oper.an.pl.128_060_pv.ll025sc.1941060100_1941060123.nc\"\n", + "%time dsc = xr.open_dataset(fsspec.open(url).open(), engine=\"h5netcdf\", chunks={\"time\": 1}).drop_encoding()" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "b3048527-c50f-451c-9500-cac6c22dd1bc", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Size: 4GB\n", + "Dimensions: (time: 24, level: 37, latitude: 721, longitude: 1440)\n", + "Coordinates:\n", + " * latitude (latitude) float64 6kB 90.0 89.75 89.5 ... -89.5 -89.75 -90.0\n", + " * level (level) float64 296B 1.0 2.0 3.0 5.0 ... 925.0 950.0 975.0 1e+03\n", + " * longitude (longitude) float64 12kB 0.0 0.25 0.5 0.75 ... 359.2 359.5 359.8\n", + " * time (time) datetime64[ns] 192B 1941-06-01 ... 1941-06-01T23:00:00\n", + "Data variables:\n", + " PV (time, level, latitude, longitude) float32 4GB dask.array\n", + " utc_date (time) int32 96B dask.array\n", + "Attributes:\n", + " DATA_SOURCE: ECMWF: https://cds.climate.copernicus.eu, Copernicu...\n", + " NETCDF_CONVERSION: CISL RDA: Conversion from ECMWF GRIB 1 data to netC...\n", + " NETCDF_VERSION: 4.8.1\n", + " CONVERSION_PLATFORM: Linux r1i4n4 4.12.14-95.51-default #1 SMP Fri Apr 1...\n", + " CONVERSION_DATE: Wed May 10 06:33:49 MDT 2023\n", + " Conventions: CF-1.6\n", + " NETCDF_COMPRESSION: NCO: Precision-preserving compression to netCDF4/HD...\n", + " history: Wed May 10 06:34:19 2023: ncks -4 --ppc default=7 e...\n", + " NCO: netCDF Operators version 5.0.3 (Homepage = http://n...\n" + ] + } + ], + "source": [ + "print(ds)" + ] + }, + { + "cell_type": "markdown", + "id": "7f4a801c-b570-45e3-b37f-2e140a2fb273", + "metadata": {}, + "source": [ + "### Load Data from HDF5 File\n", + "\n", + "This illustrates how loading directly from HDF5 files on S3 can be slow, even with Dask." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "29e344c6-a25e-4342-979f-d2d2c7aed7a7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[########################################] | 100% Completed | 61.19 ss\n" + ] + } + ], + "source": [ + "with ProgressBar():\n", + " dsl = ds.load()" + ] + }, + { + "cell_type": "markdown", + "id": "bdbd3f6c-e62c-4cfc-8cfb-b0fa22b6bddd", + "metadata": {}, + "source": [ + "### Write Zarr Store - No Dask" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "67c6389d-79a0-4992-b845-6a633cb4d86b", + "metadata": {}, + "outputs": [], + "source": [ + "encoding = {\n", + " \"PV\": {\n", + " \"compressor\": zarr.Zstd(),\n", + " \"chunks\": (1, 1, 721, 1440)\n", + " }\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "bda3c3f9-4714-471b-abc0-051c3a6d8384", + "metadata": {}, + "outputs": [], + "source": [ + "target_url = \"s3://icechunk-test/ryan/zarr-v2/test-era5-11\"\n", + "store = zarr.storage.FSStore(target_url)" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "b9a8c5ab-cc5a-4a05-b4ba-3b52be187e18", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 21.4 s, sys: 3.73 s, total: 25.1 s\n", + "Wall time: 31.8 s\n" + ] + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%time dsl.to_zarr(store, consolidated=False, encoding=encoding, mode=\"w\")" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "3718012b-3157-47a1-8ac4-f72d27a2132f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[########################################] | 100% Completed | 12.30 s\n" + ] + } + ], + "source": [ + "# with dask\n", + "dslc = dsl.chunk({\"time\": 1, \"level\": 1})\n", + "store_d = zarr.storage.FSStore(target_url + '-dask')\n", + "with ProgressBar():\n", + " dslc.to_zarr(store_d, consolidated=False, encoding=encoding, mode=\"w\")" + ] + }, + { + "cell_type": "markdown", + "id": "34b1a12c-9640-4f8b-a5fc-2ade040b437c", + "metadata": {}, + "source": [ + "### Read Data Back" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "a9c1bfc7-61d2-4a92-ab82-b026e7b9fcf6", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 50.4 ms, sys: 7.21 ms, total: 57.6 ms\n", + "Wall time: 487 ms\n" + ] + } + ], + "source": [ + "%time dss = xr.open_dataset(store, consolidated=False, engine=\"zarr\")" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "feb23457-c6fe-4363-8393-c92ab1ae7a89", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 15.2 ms, sys: 671 μs, total: 15.9 ms\n", + "Wall time: 97.4 ms\n" + ] + }, + { + "data": { + "text/plain": [ + "array(0.00710905, dtype=float32)" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%time dss.PV[0, 0, 0, 0].values" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "d5103624-554c-4d18-a323-d24f82b99818", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 8.6 s, sys: 1.53 s, total: 10.1 s\n", + "Wall time: 22.6 s\n" + ] + } + ], + "source": [ + "%time _ = dss.compute()" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "d302b787-3279-4564-a29a-5be82c82dd5d", + "metadata": {}, + "outputs": [], + "source": [ + "dssd = xr.open_dataset(store, consolidated=False, engine=\"zarr\").chunk({\"time\": 1, \"level\": 10})" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "ab01b1f7-42ff-41cf-aac6-c2c93f968227", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[########################################] | 100% Completed | 4.55 sms\n" + ] + } + ], + "source": [ + "with ProgressBar():\n", + " _ = dssd.compute()" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "2482b40b-3ae9-45eb-8e26-61bf3b41d89e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "946.755253" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "1893510506 / 2 / 1e6" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "7c0a855f-5173-46f8-b296-d20c582be1cd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
Name/
Typezarr.hierarchy.Group
Read-onlyTrue
Store typezarr.storage.FSStore
No. members6
No. arrays6
No. groups0
ArraysPV, latitude, level, longitude, time, utc_date
" + ], + "text/plain": [ + "Name : /\n", + "Type : zarr.hierarchy.Group\n", + "Read-only : True\n", + "Store type : zarr.storage.FSStore\n", + "No. members : 6\n", + "No. arrays : 6\n", + "No. groups : 0\n", + "Arrays : PV, latitude, level, longitude, time, utc_date" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "group = zarr.open_group(store, mode=\"r\")\n", + "group.info" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "3238d58d-1866-4467-ab35-18fd97e80b0b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
Name/PV
Typezarr.core.Array
Data typefloat32
Shape(24, 37, 721, 1440)
Chunk shape(1, 1, 721, 1440)
OrderC
Read-onlyTrue
CompressorZstd(level=1)
Store typezarr.storage.FSStore
No. bytes3687828480 (3.4G)
No. bytes stored1893510506 (1.8G)
Storage ratio1.9
Chunks initialized888/888
" + ], + "text/plain": [ + "Name : /PV\n", + "Type : zarr.core.Array\n", + "Data type : float32\n", + "Shape : (24, 37, 721, 1440)\n", + "Chunk shape : (1, 1, 721, 1440)\n", + "Order : C\n", + "Read-only : True\n", + "Compressor : Zstd(level=1)\n", + "Store type : zarr.storage.FSStore\n", + "No. bytes : 3687828480 (3.4G)\n", + "No. bytes stored : 1893510506 (1.8G)\n", + "Storage ratio : 1.9\n", + "Chunks initialized : 888/888" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "group.PV.info" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "eade4790-3056-4c6d-a81c-8f85837d349d", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/icechunk-python/notebooks/performance/era5_xarray-zarr3.ipynb b/icechunk-python/notebooks/performance/era5_xarray-zarr3.ipynb new file mode 100644 index 00000000..c4269d7a --- /dev/null +++ b/icechunk-python/notebooks/performance/era5_xarray-zarr3.ipynb @@ -0,0 +1,867 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "40c929a3-87d4-4c0e-a97d-1300d8adcae0", + "metadata": {}, + "source": [ + "# Icechunk Performance - Zarr V3\n", + "\n", + "Using data from the [NCAR ERA5 AWS Public Dataset](https://nsf-ncar-era5.s3.amazonaws.com/index.html)." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "b2904d5f-090b-4344-a2f7-99096ba26d27", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "xarray: 0.9.7.dev3734+g26081d4f\n", + "dask: 2024.9.1+8.g70f56e28\n", + "zarr: 3.0.0b1.dev8+g9bbfd88\n" + ] + } + ], + "source": [ + "import xarray as xr\n", + "import zarr\n", + "import dask\n", + "import fsspec\n", + "from dask.diagnostics import ProgressBar\n", + "\n", + "print('xarray: ', xr.__version__)\n", + "print('dask: ', dask.__version__)\n", + "print('zarr: ', zarr.__version__)" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "05661b12-9714-4a77-9f33-e351b229895f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "zarr.config.set(\n", + " {\n", + " 'threading.max_workers': 16,\n", + " 'async.concurrency': 128\n", + " }\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "081e1a71-873e-45c3-b77d-5b7aa1617286", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 277 ms, sys: 37.5 ms, total: 315 ms\n", + "Wall time: 2.33 s\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/srv/conda/envs/icechunk/lib/python3.12/site-packages/xarray/backends/api.py:357: UserWarning: The specified chunks separate the stored chunks along dimension \"time\" starting at index 1. This could degrade performance. Instead, consider rechunking after loading.\n", + " var_chunks = _get_chunk(var, chunks, chunkmanager)\n" + ] + } + ], + "source": [ + "url = \"https://nsf-ncar-era5.s3.amazonaws.com/e5.oper.an.pl/194106/e5.oper.an.pl.128_060_pv.ll025sc.1941060100_1941060123.nc\"\n", + "%time ds = xr.open_dataset(fsspec.open(url).open(), engine=\"h5netcdf\", chunks={\"time\": 1})\n", + "ds = ds.drop_encoding()" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "b3048527-c50f-451c-9500-cac6c22dd1bc", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Size: 4GB\n", + "Dimensions: (time: 24, level: 37, latitude: 721, longitude: 1440)\n", + "Coordinates:\n", + " * latitude (latitude) float64 6kB 90.0 89.75 89.5 ... -89.5 -89.75 -90.0\n", + " * level (level) float64 296B 1.0 2.0 3.0 5.0 ... 925.0 950.0 975.0 1e+03\n", + " * longitude (longitude) float64 12kB 0.0 0.25 0.5 0.75 ... 359.2 359.5 359.8\n", + " * time (time) datetime64[ns] 192B 1941-06-01 ... 1941-06-01T23:00:00\n", + "Data variables:\n", + " PV (time, level, latitude, longitude) float32 4GB dask.array\n", + " utc_date (time) int32 96B dask.array\n", + "Attributes:\n", + " DATA_SOURCE: ECMWF: https://cds.climate.copernicus.eu, Copernicu...\n", + " NETCDF_CONVERSION: CISL RDA: Conversion from ECMWF GRIB 1 data to netC...\n", + " NETCDF_VERSION: 4.8.1\n", + " CONVERSION_PLATFORM: Linux r1i4n4 4.12.14-95.51-default #1 SMP Fri Apr 1...\n", + " CONVERSION_DATE: Wed May 10 06:33:49 MDT 2023\n", + " Conventions: CF-1.6\n", + " NETCDF_COMPRESSION: NCO: Precision-preserving compression to netCDF4/HD...\n", + " history: Wed May 10 06:34:19 2023: ncks -4 --ppc default=7 e...\n", + " NCO: netCDF Operators version 5.0.3 (Homepage = http://n...\n" + ] + } + ], + "source": [ + "print(ds)" + ] + }, + { + "cell_type": "markdown", + "id": "7f4a801c-b570-45e3-b37f-2e140a2fb273", + "metadata": {}, + "source": [ + "### Load Data from HDF5 File\n", + "\n", + "This illustrates how loading directly from HDF5 files on S3 can be slow, even with Dask." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "29e344c6-a25e-4342-979f-d2d2c7aed7a7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[########################################] | 100% Completed | 62.20 ss\n" + ] + } + ], + "source": [ + "with ProgressBar():\n", + " dsl = ds.load()" + ] + }, + { + "cell_type": "markdown", + "id": "bdbd3f6c-e62c-4cfc-8cfb-b0fa22b6bddd", + "metadata": {}, + "source": [ + "### Write Zarr Store - No Dask" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "67c6389d-79a0-4992-b845-6a633cb4d86b", + "metadata": {}, + "outputs": [], + "source": [ + "encoding = {\n", + " \"PV\": {\n", + " \"codecs\": [zarr.codecs.BytesCodec(), zarr.codecs.ZstdCodec()],\n", + " \"chunks\": (1, 1, 721, 1440)\n", + " }\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "ece4f559-6ed5-4027-bab4-6ee42babf103", + "metadata": {}, + "outputs": [], + "source": [ + "import s3fs\n", + "s3 = s3fs.S3FileSystem(use_listings_cache=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "657f10fe-b29e-4ef5-8953-ce11374ce818", + "metadata": {}, + "outputs": [], + "source": [ + "target_path = \"icechunk-test/ryan/zarr-v3/test-era5-v3-919\"\n", + "store = zarr.storage.RemoteStore(s3, mode=\"w\", path=target_path)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "b9a8c5ab-cc5a-4a05-b4ba-3b52be187e18", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 36.2 s, sys: 2.53 s, total: 38.7 s\n", + "Wall time: 15.8 s\n" + ] + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%time dsl.to_zarr(store, consolidated=False, zarr_format=3, encoding=encoding, mode=\"w\")" + ] + }, + { + "cell_type": "code", + "execution_count": 48, + "id": "3718012b-3157-47a1-8ac4-f72d27a2132f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[########################################] | 100% Completed | 12.60 s\n" + ] + } + ], + "source": [ + "# with dask\n", + "dslc = dsl.chunk({\"time\": 1, \"level\": 1})\n", + "store_d = zarr.storage.RemoteStore(s3, mode=\"w\", path=target_url + \"-dask\")\n", + "with ProgressBar():\n", + " dslc.to_zarr(store_d, consolidated=False, zarr_format=3, encoding=encoding, mode=\"w\")" + ] + }, + { + "cell_type": "markdown", + "id": "34b1a12c-9640-4f8b-a5fc-2ade040b437c", + "metadata": {}, + "source": [ + "### Read Data Back" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "a9c1bfc7-61d2-4a92-ab82-b026e7b9fcf6", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 35.6 ms, sys: 0 ns, total: 35.6 ms\n", + "Wall time: 343 ms\n" + ] + } + ], + "source": [ + "#store = zarr.storage.RemoteStore(s3, mode=\"r\", path=target_url)\n", + "%time dss = xr.open_dataset(store, consolidated=False, zarr_format=3, engine=\"zarr\")" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "752983c4-7e76-4530-8b7b-73b6bb5e2600", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
<xarray.Dataset> Size: 4GB\n",
+       "Dimensions:    (time: 24, level: 37, latitude: 721, longitude: 1440)\n",
+       "Coordinates:\n",
+       "  * latitude   (latitude) float64 6kB 90.0 89.75 89.5 ... -89.5 -89.75 -90.0\n",
+       "  * level      (level) float64 296B 1.0 2.0 3.0 5.0 ... 925.0 950.0 975.0 1e+03\n",
+       "  * longitude  (longitude) float64 12kB 0.0 0.25 0.5 0.75 ... 359.2 359.5 359.8\n",
+       "  * time       (time) datetime64[ns] 192B 1941-06-01 ... 1941-06-01T23:00:00\n",
+       "Data variables:\n",
+       "    PV         (time, level, latitude, longitude) float32 4GB ...\n",
+       "    utc_date   (time) int32 96B ...\n",
+       "Attributes:\n",
+       "    DATA_SOURCE:          ECMWF: https://cds.climate.copernicus.eu, Copernicu...\n",
+       "    NETCDF_CONVERSION:    CISL RDA: Conversion from ECMWF GRIB 1 data to netC...\n",
+       "    NETCDF_VERSION:       4.8.1\n",
+       "    CONVERSION_PLATFORM:  Linux r1i4n4 4.12.14-95.51-default #1 SMP Fri Apr 1...\n",
+       "    CONVERSION_DATE:      Wed May 10 06:33:49 MDT 2023\n",
+       "    Conventions:          CF-1.6\n",
+       "    NETCDF_COMPRESSION:   NCO: Precision-preserving compression to netCDF4/HD...\n",
+       "    history:              Wed May 10 06:34:19 2023: ncks -4 --ppc default=7 e...\n",
+       "    NCO:                  netCDF Operators version 5.0.3 (Homepage = http://n...
" + ], + "text/plain": [ + " Size: 4GB\n", + "Dimensions: (time: 24, level: 37, latitude: 721, longitude: 1440)\n", + "Coordinates:\n", + " * latitude (latitude) float64 6kB 90.0 89.75 89.5 ... -89.5 -89.75 -90.0\n", + " * level (level) float64 296B 1.0 2.0 3.0 5.0 ... 925.0 950.0 975.0 1e+03\n", + " * longitude (longitude) float64 12kB 0.0 0.25 0.5 0.75 ... 359.2 359.5 359.8\n", + " * time (time) datetime64[ns] 192B 1941-06-01 ... 1941-06-01T23:00:00\n", + "Data variables:\n", + " PV (time, level, latitude, longitude) float32 4GB ...\n", + " utc_date (time) int32 96B ...\n", + "Attributes:\n", + " DATA_SOURCE: ECMWF: https://cds.climate.copernicus.eu, Copernicu...\n", + " NETCDF_CONVERSION: CISL RDA: Conversion from ECMWF GRIB 1 data to netC...\n", + " NETCDF_VERSION: 4.8.1\n", + " CONVERSION_PLATFORM: Linux r1i4n4 4.12.14-95.51-default #1 SMP Fri Apr 1...\n", + " CONVERSION_DATE: Wed May 10 06:33:49 MDT 2023\n", + " Conventions: CF-1.6\n", + " NETCDF_COMPRESSION: NCO: Precision-preserving compression to netCDF4/HD...\n", + " history: Wed May 10 06:34:19 2023: ncks -4 --ppc default=7 e...\n", + " NCO: netCDF Operators version 5.0.3 (Homepage = http://n..." + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "dss" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "feb23457-c6fe-4363-8393-c92ab1ae7a89", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 15.7 ms, sys: 0 ns, total: 15.7 ms\n", + "Wall time: 101 ms\n" + ] + }, + { + "data": { + "text/plain": [ + "array(0.00710905, dtype=float32)" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%time dss.PV[0, 0, 0, 0].values" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "d5103624-554c-4d18-a323-d24f82b99818", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 8.41 s, sys: 1.19 s, total: 9.6 s\n", + "Wall time: 5.11 s\n" + ] + } + ], + "source": [ + "%time _ = dss.compute()" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "d302b787-3279-4564-a29a-5be82c82dd5d", + "metadata": {}, + "outputs": [], + "source": [ + "dssd = xr.open_dataset(store, consolidated=False, engine=\"zarr\").chunk({\"time\": 1, \"level\": 10})" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "ab01b1f7-42ff-41cf-aac6-c2c93f968227", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[########################################] | 100% Completed | 6.26 sms\n" + ] + } + ], + "source": [ + "with ProgressBar():\n", + " _ = dssd.compute()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3dfd18bd-c885-4103-a156-ef9185d9d461", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python [conda env:icechunk]", + "language": "python", + "name": "conda-env-icechunk-py" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/icechunk-python/pyproject.toml b/icechunk-python/pyproject.toml index 6c49e3c9..29d6c9da 100644 --- a/icechunk-python/pyproject.toml +++ b/icechunk-python/pyproject.toml @@ -67,6 +67,7 @@ enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] [tool.ruff] line-length = 90 +exclude = ["*.ipynb"] [tool.ruff.lint] extend-select = [ From 071d46babb004f57f129457fe13ae2218fe0194e Mon Sep 17 00:00:00 2001 From: Orestis Herodotou Date: Tue, 15 Oct 2024 16:27:30 -0700 Subject: [PATCH 148/167] fix(docs): Docs postlaunch fixes (#278) * fix mobile menu overlay on homepage * Show page contributors in docs * Add page titles for root docs pages --- docs/README.md | 2 ++ docs/docs/contributing.md | 3 +++ docs/docs/faq.md | 2 +- docs/docs/icechunk-rust.md | 4 ++++ docs/docs/overview.md | 3 +++ docs/docs/sample-datasets.md | 3 +++ docs/docs/spec.md | 3 +++ docs/docs/stylesheets/homepage.css | 1 - docs/mkdocs.yml | 3 +++ docs/poetry.lock | 16 +++++++++++++++- docs/pyproject.toml | 1 + 11 files changed, 38 insertions(+), 3 deletions(-) diff --git a/docs/README.md b/docs/README.md index 7e5d260d..f08ba394 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,8 @@ This repository uses [Poetry](https://python-poetry.org/) to manage dependencies 1. Run `poetry shell` from the `/docs` directory 2. Start the MkDocs development server: `mkdocs serve` +Alternatively you can run `poetry run mkdocs serve` + !!! tip You can use the optional `--dirty` flag to only rebuild changed files, although you may need to restart if you make changes to `mkdocs.yaml`. diff --git a/docs/docs/contributing.md b/docs/docs/contributing.md index 317c60b4..9a6efe55 100644 --- a/docs/docs/contributing.md +++ b/docs/docs/contributing.md @@ -1,3 +1,6 @@ +--- +title: Contributing +--- # Contributing 👋 Hi! Thanks for your interest in contributing to Icechunk! diff --git a/docs/docs/faq.md b/docs/docs/faq.md index 22b5d375..4539555c 100644 --- a/docs/docs/faq.md +++ b/docs/docs/faq.md @@ -1,5 +1,5 @@ --- -title: Icechunk - Frequenctly Asked Questions +title: Frequently Asked Questions --- # FAQ diff --git a/docs/docs/icechunk-rust.md b/docs/docs/icechunk-rust.md index d3a446b4..12c10c85 100644 --- a/docs/docs/icechunk-rust.md +++ b/docs/docs/icechunk-rust.md @@ -1,3 +1,7 @@ +--- +title: Rust +--- + # Icechunk Rust The Icechunk rust library is used internally by Icechunk Python. diff --git a/docs/docs/overview.md b/docs/docs/overview.md index e19ad7ce..b75d30f9 100644 --- a/docs/docs/overview.md +++ b/docs/docs/overview.md @@ -1,3 +1,6 @@ +--- +title: Overview +--- # Icechunk Icechunk is an open-source (Apache 2.0), transactional storage engine for tensor / ND-array data designed for use on cloud object storage. diff --git a/docs/docs/sample-datasets.md b/docs/docs/sample-datasets.md index 688c2ed6..92e63762 100644 --- a/docs/docs/sample-datasets.md +++ b/docs/docs/sample-datasets.md @@ -1,3 +1,6 @@ +--- +title: Sample Datasets +--- # Sample Datasets ## Native Datasets diff --git a/docs/docs/spec.md b/docs/docs/spec.md index 3c9d42af..00e32d63 100644 --- a/docs/docs/spec.md +++ b/docs/docs/spec.md @@ -1,3 +1,6 @@ +--- +title: Specification +--- # Icechunk Specification !!! note "Note" diff --git a/docs/docs/stylesheets/homepage.css b/docs/docs/stylesheets/homepage.css index 009f938b..667a2d29 100644 --- a/docs/docs/stylesheets/homepage.css +++ b/docs/docs/stylesheets/homepage.css @@ -10,7 +10,6 @@ margin: 0; max-width: 100%; position: relative; - z-index:1; } /* Hide breadcrumb in content */ diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 290cdba3..42a7637a 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -126,10 +126,13 @@ plugins: module_name: macros - git-revision-date-localized: #enabled: !ENV [CI, false] + - git-authors - git-committers: repository: earth-mover/icechunk branch: main #enabled: !ENV [CI, false] + exclude: + - index.md - mkdocstrings: default_handler: python handlers: diff --git a/docs/poetry.lock b/docs/poetry.lock index c7ad79b4..9daa2327 100644 --- a/docs/poetry.lock +++ b/docs/poetry.lock @@ -1218,6 +1218,20 @@ mergedeep = ">=1.3.4" platformdirs = ">=2.2.0" pyyaml = ">=5.1" +[[package]] +name = "mkdocs-git-authors-plugin" +version = "0.9.0" +description = "Mkdocs plugin to display git authors of a page" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mkdocs_git_authors_plugin-0.9.0-py3-none-any.whl", hash = "sha256:380730a05eeb947a7e84be05fdb1c5ae2a7bc70fd9f6eda941f187c87ae37052"}, + {file = "mkdocs_git_authors_plugin-0.9.0.tar.gz", hash = "sha256:6161f63b87064481a48d9ad01c23e43c3e758930c3a9cc167fe482909ceb9eac"}, +] + +[package.dependencies] +mkdocs = ">=1.0" + [[package]] name = "mkdocs-git-committers-plugin-2" version = "2.4.1" @@ -2722,4 +2736,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "3e27635ba9fec8528f1bd7aea243882f66e58198c2bee4df29f6a7552c84b933" +content-hash = "e2fd4c717a0d0c95bc3cea08104489573a6b78bb681b429f509798d73e4539c5" diff --git a/docs/pyproject.toml b/docs/pyproject.toml index ff6a82d1..3b0adbcf 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -23,6 +23,7 @@ mkdocs-redirects = "^1.2.1" mkdocs-breadcrumbs-plugin = "^0.1.10" mkdocs-minify-plugin = "^0.8.0" mkdocs-mermaid2-plugin = "^1.1.1" +mkdocs-git-authors-plugin = "^0.9.0" [tool.poetry.group.dev.dependencies] mike = "^2.1.3" From 2013e76c04b12c22de6abe898cd783a46d1be540 Mon Sep 17 00:00:00 2001 From: Callum Rollo Date: Wed, 16 Oct 2024 13:12:57 +0200 Subject: [PATCH 149/167] [Docs] correct module calls to match imports (#281) --- docs/docs/icechunk-python/xarray.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs/icechunk-python/xarray.md b/docs/docs/icechunk-python/xarray.md index 94e2a9b3..7846f860 100644 --- a/docs/docs/icechunk-python/xarray.md +++ b/docs/docs/icechunk-python/xarray.md @@ -31,18 +31,18 @@ from icechunk import IcechunkStore, StorageConfig === "S3 Storage" ```python - storage_config = icechunk.StorageConfig.s3_from_env( + storage_config = StorageConfig.s3_from_env( bucket="icechunk-test", prefix="xarray-demo" ) - store = icechunk.IcechunkStore.create(storage_config) + store = IcechunkStore.create(storage_config) ``` === "Local Storage" ```python - storage_config = icechunk.StorageConfig.filesystem("./icechunk-xarray") - store = icechunk.IcechunkStore.create(storage_config) + storage_config = StorageConfig.filesystem("./icechunk-xarray") + store = IcechunkStore.create(storage_config) ``` ## Open tutorial dataset from Xarray From f6ec47fd3ad60da4685c2fb9e387536db5dd99b1 Mon Sep 17 00:00:00 2001 From: Callum Rollo Date: Wed, 16 Oct 2024 13:14:31 +0200 Subject: [PATCH 150/167] [Docs] add note for requirements of tutorial data download (#282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sebastián Galkin --- docs/docs/icechunk-python/xarray.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/docs/icechunk-python/xarray.md b/docs/docs/icechunk-python/xarray.md index 7846f860..19ed949c 100644 --- a/docs/docs/icechunk-python/xarray.md +++ b/docs/docs/icechunk-python/xarray.md @@ -50,6 +50,15 @@ from icechunk import IcechunkStore, StorageConfig For this demo, we'll open Xarray's RASM tutorial dataset and split it into two blocks. We'll write the two blocks to Icechunk in separate transactions later in the this example. + +!!! note + + Downloading xarray tutorial data requires pooch and netCDF4. These can be installed with + + ```shell + pip install pooch netCDF4 + ``` + ```python ds = xr.tutorial.open_dataset('rasm') From 91086b367436ccaf748e73ac3f9818f4590c9862 Mon Sep 17 00:00:00 2001 From: Callum Rollo Date: Wed, 16 Oct 2024 13:15:58 +0200 Subject: [PATCH 151/167] [Docs] correct output dimensions in xr example (#283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sebastián Galkin --- docs/docs/icechunk-python/xarray.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs/icechunk-python/xarray.md b/docs/docs/icechunk-python/xarray.md index 19ed949c..8ba6666e 100644 --- a/docs/docs/icechunk-python/xarray.md +++ b/docs/docs/icechunk-python/xarray.md @@ -111,15 +111,15 @@ To read data stored in Icechunk with Xarray, we'll use `xarray.open_zarr`: ```python xr.open_zarr(store, consolidated=False) -# output: Size: 9MB -# Dimensions: (y: 205, x: 275, time: 18) +# output: Size: 17MB +# Dimensions: (time: 36, y: 205, x: 275) # Coordinates: +# * time (time) object 288B 1980-09-16 12:00:00 ... 1983-08-17 00:00:00 # xc (y, x) float64 451kB dask.array # yc (y, x) float64 451kB dask.array -# * time (time) object 144B 1980-09-16 12:00:00 ... 1982-02-15 12:00:00 # Dimensions without coordinates: y, x # Data variables: -# Tair (time, y, x) float64 8MB dask.array +# Tair (time, y, x) float64 16MB dask.array # Attributes: # NCO: netCDF Operators version 4.7.9 (Homepage = htt... # comment: Output from the Variable Infiltration Capacity... From d124bedc067e4fbceffe5c7b5e874a9106554cfc Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Wed, 16 Oct 2024 10:02:03 -0400 Subject: [PATCH 152/167] [Docs] Improve virtual ref docs (#284) * Improve vritual ref docs * More detail --- docs/docs/icechunk-python/configuration.md | 13 +++--- docs/docs/icechunk-python/virtual.md | 45 ++++++++++++++++++- .../python/icechunk/_icechunk_python.pyi | 25 ++++++++++- 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/docs/docs/icechunk-python/configuration.md b/docs/docs/icechunk-python/configuration.md index 714be4b3..2fb2c224 100644 --- a/docs/docs/icechunk-python/configuration.md +++ b/docs/docs/icechunk-python/configuration.md @@ -1,8 +1,9 @@ # Configuration When creating and opening Icechunk stores, there are a two different sets of configuration to be aware of: -- `StorageConfig` - for configuring access to the object store or filesystem -- `StoreConfig` - for configuring the behavior of the Icechunk Store itself + +- [`StorageConfig`](./reference.md#icechunk.StorageConfig) - for configuring access to the object store or filesystem +- [`StoreConfig`](./reference.md#icechunk.StoreConfig) - for configuring the behavior of the Icechunk Store itself ## Storage Config @@ -15,7 +16,7 @@ When using Icechunk with s3 compatible storage systems, credentials must be prov === "From environment" With this option, the credentials for connecting to S3 are detected automatically from your environment. - This is usually the best choice if you are connecting from within an AWS environment (e.g. from EC2). + This is usually the best choice if you are connecting from within an AWS environment (e.g. from EC2). [See the API](./reference.md#icechunk.StorageConfig.s3_from_env) ```python icechunk.StorageConfig.s3_from_env( @@ -26,7 +27,7 @@ When using Icechunk with s3 compatible storage systems, credentials must be prov === "Provide credentials" - With this option, you provide your credentials and other details explicitly. + With this option, you provide your credentials and other details explicitly. [See the API](./reference.md#icechunk.StorageConfig.s3_from_config) ```python icechunk.StorageConfig.s3_from_config( @@ -47,7 +48,7 @@ When using Icechunk with s3 compatible storage systems, credentials must be prov === "Anonymous" With this option, you connect to S3 anonymously (without credentials). - This is suitable for public data. + This is suitable for public data. [See the API](./reference.md#icechunk.StorageConfig.s3_anonymous) ```python icechunk.StorageConfig.s3_anonymous( @@ -59,7 +60,7 @@ When using Icechunk with s3 compatible storage systems, credentials must be prov ### Filesystem Storage -Icechunk can also be used on a local filesystem by providing a path to the location of the store +Icechunk can also be used on a [local filesystem](./reference.md#icechunk.StorageConfig.filesystem) by providing a path to the location of the store === "Local filesystem" diff --git a/docs/docs/icechunk-python/virtual.md b/docs/docs/icechunk-python/virtual.md index b86e70dd..ce754cae 100644 --- a/docs/docs/icechunk-python/virtual.md +++ b/docs/docs/icechunk-python/virtual.md @@ -156,4 +156,47 @@ Finally, let's make a plot of the sea surface temperature! ds.sst.isel(time=26, zlev=0).plot(x='lon', y='lat', vmin=0) ``` -![oisst](../assets/datasets/oisst.png) \ No newline at end of file +![oisst](../assets/datasets/oisst.png) + +## Virtual Reference API + +While `VirtualiZarr` is the easiest way to create virtual datasets with Icechunk, the Store API that it uses to create the datasets in Icechunk is public. `IcechunkStore` contains a [`set_virtual_ref`](./reference.md#icechunk.IcechunkStore.set_virtual_ref) method that specifies a virtual ref for a specified chunk. + +### Virtual Reference Storage Support + +Currently, Icechunk supports two types of storage for virtual references: + +#### S3 Compatible + +References to files accessible via S3 compatible storage. + +##### Example + +Here is how we can set the chunk at key `c/0` to point to a file on an s3 bucket,`mybucket`, with the prefix `my/data/file.nc`: + +```python +store.set_virtual_ref('c/0', 's3://mybucket/my/data/file.nc', offset=1000, length=200) +``` + +##### Configuration + +S3 virtual references require configuring credential for the store to be able to access the specified s3 bucket. See [the configuration docs](./configuration.md#virtual-reference-storage-config) for instructions. + + +#### Local Filesystem + +References to files accessible via local filesystem. This requires any file paths to be **absolute** at this time. + +##### Example + +Here is how we can set the chunk at key `c/0` to point to a file on my local filesystem located at `/path/to/my/file.nc`: + +```python +store.set_virtual_ref('c/0', 'file:///path/to/my/file.nc', offset=20, length=100) +``` + +No extra configuration is necessary for local filesystem references. + +### Virtual Reference File Format Support + +Currently, Icechunk supports `HDF5` and `netcdf4` files for use in virtual references. See the [tracking issue](https://github.com/earth-mover/icechunk/issues/197) for more info. \ No newline at end of file diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index ddefa711..2259650e 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -253,6 +253,8 @@ class KeyNotFound(Exception): ): ... class StoreConfig: + """Configuration for an IcechunkStore""" + # The number of concurrent requests to make when fetching partial values get_partial_values_concurrency: int | None # The threshold at which to inline chunks in the store in bytes. When set, @@ -270,7 +272,28 @@ class StoreConfig: inline_chunk_threshold_bytes: int | None = None, unsafe_overwrite_refs: bool | None = None, virtual_ref_config: VirtualRefConfig | None = None, - ): ... + ): + """Create a StoreConfig object with the given configuration options + + Parameters + ---------- + get_partial_values_concurrency: int | None + The number of concurrent requests to make when fetching partial values + inline_chunk_threshold_bytes: int | None + The threshold at which to inline chunks in the store in bytes. When set, + chunks smaller than this threshold will be inlined in the store. Default is + 512 bytes when not specified. + unsafe_overwrite_refs: bool | None + Whether to allow overwriting refs in the store. Default is False. Experimental. + virtual_ref_config: VirtualRefConfig | None + Configurations for virtual references such as credentials and endpoints + + Returns + ------- + StoreConfig + A StoreConfig object with the given configuration options + """ + ... async def async_pyicechunk_store_exists(storage: StorageConfig) -> bool: ... def pyicechunk_store_exists(storage: StorageConfig) -> bool: ... From 215a9acbe7cc010c436a4ad1e84e86736d98e21e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Galkin?= Date: Wed, 16 Oct 2024 15:08:07 -0300 Subject: [PATCH 153/167] Introduce `Store::list_dir_items` (#286) This new method works like `list_dir` but the returned stream has item `ListDirItem` which separates the keys from the prefixes. Closes: https://github.com/earth-mover/icechunk/issues/276 --- icechunk/src/zarr.rs | 76 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index ea94ca61..ba62562b 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -245,6 +245,12 @@ pub enum AccessMode { ReadWrite, } +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum ListDirItem { + Key(String), + Prefix(String), +} + pub type StoreResult = Result; #[derive(Debug, Clone, PartialEq, Eq, Error)] @@ -747,6 +753,22 @@ impl Store { // FIXME: this is not lazy, it goes through every chunk. This should be implemented using // metadata only, and ignore the chunks, but we should decide on that based on Zarr3 spec // evolution + let res = self.list_dir_items(prefix).await?.map_ok(|item| match item { + ListDirItem::Key(k) => k, + ListDirItem::Prefix(p) => p, + }); + Ok(res) + } + + pub async fn list_dir_items( + &self, + prefix: &str, + ) -> StoreResult> + Send> { + // TODO: this is inefficient because it filters based on the prefix, instead of only + // generating items that could potentially match + // FIXME: this is not lazy, it goes through every chunk. This should be implemented using + // metadata only, and ignore the chunks, but we should decide on that based on Zarr3 spec + // evolution let idx: usize = if prefix == "/" { 0 } else { prefix.len() }; @@ -757,8 +779,10 @@ impl Store { // If the prefix is "/", get rid of it. This can happen when prefix is missing // the trailing slash (as it does in zarr-python impl) let rem = &s[idx..].trim_start_matches('/'); - let parent = rem.split_once('/').map_or(*rem, |(parent, _)| parent); - parent.to_string() + match rem.split_once('/') { + Some((prefix, _)) => ListDirItem::Prefix(prefix.to_string()), + None => ListDirItem::Key(rem.to_string()), + } }) .try_collect() .await?; @@ -2066,21 +2090,69 @@ mod tests { dir.sort(); assert_eq!(dir, vec!["array".to_string(), "zarr.json".to_string()]); + let mut dir = store.list_dir_items("/").await?.try_collect::>().await?; + dir.sort(); + assert_eq!( + dir, + vec![ + ListDirItem::Key("zarr.json".to_string()), + ListDirItem::Prefix("array".to_string()) + ] + ); + let mut dir = store.list_dir("array").await?.try_collect::>().await?; dir.sort(); assert_eq!(dir, vec!["c".to_string(), "zarr.json".to_string()]); + let mut dir = + store.list_dir_items("array").await?.try_collect::>().await?; + dir.sort(); + assert_eq!( + dir, + vec![ + ListDirItem::Key("zarr.json".to_string()), + ListDirItem::Prefix("c".to_string()) + ] + ); + let mut dir = store.list_dir("array/").await?.try_collect::>().await?; dir.sort(); assert_eq!(dir, vec!["c".to_string(), "zarr.json".to_string()]); + let mut dir = + store.list_dir_items("array/").await?.try_collect::>().await?; + dir.sort(); + assert_eq!( + dir, + vec![ + ListDirItem::Key("zarr.json".to_string()), + ListDirItem::Prefix("c".to_string()) + ] + ); + let mut dir = store.list_dir("array/c/").await?.try_collect::>().await?; dir.sort(); assert_eq!(dir, vec!["0".to_string(), "1".to_string()]); + let mut dir = + store.list_dir_items("array/c/").await?.try_collect::>().await?; + dir.sort(); + assert_eq!( + dir, + vec![ + ListDirItem::Prefix("0".to_string()), + ListDirItem::Prefix("1".to_string()), + ] + ); + let mut dir = store.list_dir("array/c/1/").await?.try_collect::>().await?; dir.sort(); assert_eq!(dir, vec!["1".to_string()]); + + let mut dir = + store.list_dir_items("array/c/1/").await?.try_collect::>().await?; + dir.sort(); + assert_eq!(dir, vec![ListDirItem::Prefix("1".to_string()),]); Ok(()) } From 1db4b1d8976c1e0a1178462b46180214357c0ea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Galkin?= Date: Wed, 16 Oct 2024 15:08:24 -0300 Subject: [PATCH 154/167] `ByteRange` can do last n bytes now (#285) This provides an approach to deal with https://github.com/earth-mover/icechunk/issues/277 --- icechunk/src/format/mod.rs | 73 +++++++++++---------------- icechunk/src/storage/object_store.rs | 33 +++---------- icechunk/src/storage/s3.rs | 29 ++--------- icechunk/src/storage/virtual_ref.rs | 74 +++++++++++----------------- 4 files changed, 69 insertions(+), 140 deletions(-) diff --git a/icechunk/src/format/mod.rs b/icechunk/src/format/mod.rs index e35741b5..361fe164 100644 --- a/icechunk/src/format/mod.rs +++ b/icechunk/src/format/mod.rs @@ -3,7 +3,7 @@ use std::{ fmt::{Debug, Display}, hash::Hash, marker::PhantomData, - ops::Bound, + ops::Range, }; use bytes::Bytes; @@ -138,60 +138,47 @@ pub type ChunkOffset = u64; pub type ChunkLength = u64; #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct ByteRange(pub Bound, pub Bound); +pub enum ByteRange { + /// The fixed length range represented by the given `Range` + Bounded(Range), + /// All bytes from the given offset (included) to the end of the object + From(ChunkOffset), + /// Last n bytes in the object + Last(ChunkLength), +} + +impl From> for ByteRange { + fn from(value: Range) -> Self { + ByteRange::Bounded(value) + } +} impl ByteRange { pub fn from_offset(offset: ChunkOffset) -> Self { - Self(Bound::Included(offset), Bound::Unbounded) + Self::From(offset) } pub fn from_offset_with_length(offset: ChunkOffset, length: ChunkOffset) -> Self { - Self(Bound::Included(offset), Bound::Excluded(offset + length)) + Self::Bounded(offset..offset + length) } pub fn to_offset(offset: ChunkOffset) -> Self { - Self(Bound::Unbounded, Bound::Excluded(offset)) + Self::Bounded(0..offset) } pub fn bounded(start: ChunkOffset, end: ChunkOffset) -> Self { - Self(Bound::Included(start), Bound::Excluded(end)) - } - - pub fn length(&self) -> Option { - match (self.0, self.1) { - (_, Bound::Unbounded) => None, - (Bound::Unbounded, Bound::Excluded(end)) => Some(end), - (Bound::Unbounded, Bound::Included(end)) => Some(end + 1), - (Bound::Included(start), Bound::Excluded(end)) => Some(end - start), - (Bound::Excluded(start), Bound::Included(end)) => Some(end - start), - (Bound::Included(start), Bound::Included(end)) => Some(end - start + 1), - (Bound::Excluded(start), Bound::Excluded(end)) => Some(end - start - 1), - } + (start..end).into() } - pub const ALL: Self = Self(Bound::Unbounded, Bound::Unbounded); + pub const ALL: Self = Self::From(0); pub fn slice(&self, bytes: Bytes) -> Bytes { - match (self.0, self.1) { - (Bound::Included(start), Bound::Excluded(end)) => { - bytes.slice(start as usize..end as usize) - } - (Bound::Included(start), Bound::Unbounded) => bytes.slice(start as usize..), - (Bound::Unbounded, Bound::Excluded(end)) => bytes.slice(..end as usize), - (Bound::Excluded(start), Bound::Excluded(end)) => { - bytes.slice(start as usize + 1..end as usize) - } - (Bound::Excluded(start), Bound::Unbounded) => { - bytes.slice(start as usize + 1..) + match self { + ByteRange::Bounded(range) => { + bytes.slice(range.start as usize..range.end as usize) } - (Bound::Unbounded, Bound::Included(end)) => bytes.slice(..=end as usize), - (Bound::Included(start), Bound::Included(end)) => { - bytes.slice(start as usize..=end as usize) - } - (Bound::Excluded(start), Bound::Included(end)) => { - bytes.slice(start as usize + 1..=end as usize) - } - (Bound::Unbounded, Bound::Unbounded) => bytes, + ByteRange::From(from) => bytes.slice(*from as usize..), + ByteRange::Last(n) => bytes.slice(bytes.len() - *n as usize..bytes.len()), } } } @@ -199,12 +186,10 @@ impl ByteRange { impl From<(Option, Option)> for ByteRange { fn from((start, end): (Option, Option)) -> Self { match (start, end) { - (Some(start), Some(end)) => { - Self(Bound::Included(start), Bound::Excluded(end)) - } - (Some(start), None) => Self(Bound::Included(start), Bound::Unbounded), - (None, Some(end)) => Self(Bound::Unbounded, Bound::Excluded(end)), - (None, None) => Self(Bound::Unbounded, Bound::Unbounded), + (Some(start), Some(end)) => Self::Bounded(start..end), + (Some(start), None) => Self::From(start), + (None, Some(end)) => Self::Bounded(0..end), + (None, None) => Self::ALL, } } } diff --git a/icechunk/src/storage/object_store.rs b/icechunk/src/storage/object_store.rs index b7ba8190..ee929391 100644 --- a/icechunk/src/storage/object_store.rs +++ b/icechunk/src/storage/object_store.rs @@ -15,7 +15,7 @@ use object_store::{ PutPayload, }; use std::{ - fs::create_dir_all, future::ready, ops::Bound, path::Path as StdPath, sync::Arc, + fs::create_dir_all, future::ready, ops::Range, path::Path as StdPath, sync::Arc, }; use super::{Storage, StorageError, StorageResult}; @@ -23,32 +23,13 @@ use super::{Storage, StorageError, StorageResult}; // Get Range is object_store specific, keep it with this module impl From<&ByteRange> for Option { fn from(value: &ByteRange) -> Self { - match (value.0, value.1) { - (Bound::Included(start), Bound::Excluded(end)) => { - Some(GetRange::Bounded(start as usize..end as usize)) + match value { + ByteRange::Bounded(Range { start, end }) => { + Some(GetRange::Bounded(*start as usize..*end as usize)) } - (Bound::Included(start), Bound::Unbounded) => { - Some(GetRange::Offset(start as usize)) - } - (Bound::Included(start), Bound::Included(end)) => { - Some(GetRange::Bounded(start as usize..end as usize + 1)) - } - (Bound::Excluded(start), Bound::Excluded(end)) => { - Some(GetRange::Bounded(start as usize + 1..end as usize)) - } - (Bound::Excluded(start), Bound::Unbounded) => { - Some(GetRange::Offset(start as usize + 1)) - } - (Bound::Excluded(start), Bound::Included(end)) => { - Some(GetRange::Bounded(start as usize + 1..end as usize + 1)) - } - (Bound::Unbounded, Bound::Excluded(end)) => { - Some(GetRange::Bounded(0..end as usize)) - } - (Bound::Unbounded, Bound::Included(end)) => { - Some(GetRange::Bounded(0..end as usize + 1)) - } - (Bound::Unbounded, Bound::Unbounded) => None, + ByteRange::From(start) if *start == 0u64 => None, + ByteRange::From(start) => Some(GetRange::Offset(*start as usize)), + ByteRange::Last(n) => Some(GetRange::Suffix(*n as usize)), } } } diff --git a/icechunk/src/storage/s3.rs b/icechunk/src/storage/s3.rs index 80b50574..543cc00e 100644 --- a/icechunk/src/storage/s3.rs +++ b/icechunk/src/storage/s3.rs @@ -1,4 +1,4 @@ -use std::{ops::Bound, path::PathBuf, sync::Arc}; +use std::{ops::Range, path::PathBuf, sync::Arc}; use async_stream::try_stream; use async_trait::async_trait; @@ -207,31 +207,12 @@ impl S3Storage { pub fn range_to_header(range: &ByteRange) -> Option { match range { - ByteRange(Bound::Unbounded, Bound::Unbounded) => None, - ByteRange(Bound::Included(start), Bound::Excluded(end)) => { + ByteRange::Bounded(Range { start, end }) => { Some(format!("bytes={}-{}", start, end - 1)) } - ByteRange(Bound::Included(start), Bound::Unbounded) => { - Some(format!("bytes={}-", start)) - } - ByteRange(Bound::Included(start), Bound::Included(end)) => { - Some(format!("bytes={}-{}", start, end)) - } - ByteRange(Bound::Excluded(start), Bound::Excluded(end)) => { - Some(format!("bytes={}-{}", start + 1, end - 1)) - } - ByteRange(Bound::Excluded(start), Bound::Unbounded) => { - Some(format!("bytes={}-", start + 1)) - } - ByteRange(Bound::Excluded(start), Bound::Included(end)) => { - Some(format!("bytes={}-{}", start + 1, end)) - } - ByteRange(Bound::Unbounded, Bound::Excluded(end)) => { - Some(format!("bytes=0-{}", end - 1)) - } - ByteRange(Bound::Unbounded, Bound::Included(end)) => { - Some(format!("bytes=0-{}", end)) - } + ByteRange::From(offset) if *offset == 0 => None, + ByteRange::From(offset) => Some(format!("bytes={}-", offset)), + ByteRange::Last(n) => Some(format!("bytes={}-", n)), } } diff --git a/icechunk/src/storage/virtual_ref.rs b/icechunk/src/storage/virtual_ref.rs index 9bc6082d..72aa158e 100644 --- a/icechunk/src/storage/virtual_ref.rs +++ b/icechunk/src/storage/virtual_ref.rs @@ -7,9 +7,8 @@ use bytes::Bytes; use object_store::local::LocalFileSystem; use object_store::{path::Path as ObjectPath, GetOptions, GetRange, ObjectStore}; use serde::{Deserialize, Serialize}; -use std::cmp::{max, min}; +use std::cmp::min; use std::fmt::Debug; -use std::ops::Bound; use tokio::sync::OnceCell; use url::{self, Url}; @@ -115,25 +114,23 @@ pub fn construct_valid_byte_range( ) -> ByteRange { // TODO: error for offset<0 // TODO: error if request.start > offset + length - // FIXME: we allow creating a ByteRange(start, end) where end < start - let new_offset = match request.0 { - Bound::Unbounded => chunk_offset, - Bound::Included(start) => max(start, 0) + chunk_offset, - Bound::Excluded(start) => max(start, 0) + chunk_offset + 1, - }; - request.length().map_or( - ByteRange( - Bound::Included(new_offset), - Bound::Excluded(chunk_offset + chunk_length), - ), - |reqlen| { - ByteRange( - Bound::Included(new_offset), - // no request can go past offset + length, so clamp it - Bound::Excluded(min(new_offset + reqlen, chunk_offset + chunk_length)), - ) - }, - ) + match request { + ByteRange::Bounded(std::ops::Range { start: req_start, end: req_end }) => { + let new_start = + min(chunk_offset + req_start, chunk_offset + chunk_length - 1); + let new_end = min(chunk_offset + req_end, chunk_offset + chunk_length); + ByteRange::Bounded(new_start..new_end) + } + ByteRange::From(n) => { + let new_start = min(chunk_offset + n, chunk_offset + chunk_length - 1); + ByteRange::Bounded(new_start..chunk_offset + chunk_length) + } + ByteRange::Last(n) => { + let new_end = chunk_offset + chunk_length; + let new_start = new_end - n; + ByteRange::Bounded(new_start..new_end) + } + } } impl private::Sealed for ObjectStoreVirtualChunkResolver {} @@ -196,43 +193,28 @@ mod tests { // output.length() == requested.length() // output.0 >= chunk_ref.offset prop_assert_eq!( - construct_valid_byte_range( - &ByteRange(Bound::Included(0), Bound::Excluded(length)), - offset, - length, - ), - ByteRange(Bound::Included(offset), Bound::Excluded(max_end)) + construct_valid_byte_range(&ByteRange::Bounded(0..length), offset, length,), + ByteRange::Bounded(offset..max_end) ); prop_assert_eq!( construct_valid_byte_range( - &ByteRange(Bound::Unbounded, Bound::Excluded(length)), + &ByteRange::Bounded(request_offset..max_end), offset, length ), - ByteRange(Bound::Included(offset), Bound::Excluded(max_end)) + ByteRange::Bounded(request_offset + offset..max_end) ); prop_assert_eq!( - construct_valid_byte_range( - &ByteRange(Bound::Included(request_offset), Bound::Excluded(max_end)), - offset, - length - ), - ByteRange(Bound::Included(request_offset + offset), Bound::Excluded(max_end)) + construct_valid_byte_range(&ByteRange::ALL, offset, length), + ByteRange::Bounded(offset..offset + length) ); prop_assert_eq!( - construct_valid_byte_range(&ByteRange::ALL, offset, length), - ByteRange(Bound::Included(offset), Bound::Excluded(max_end)) + construct_valid_byte_range(&ByteRange::From(request_offset), offset, length), + ByteRange::Bounded(offset + request_offset..offset + length) ); prop_assert_eq!( - construct_valid_byte_range( - &ByteRange(Bound::Excluded(request_offset), Bound::Unbounded), - offset, - length - ), - ByteRange( - Bound::Included(offset + request_offset + 1), - Bound::Excluded(max_end) - ) + construct_valid_byte_range(&ByteRange::Last(request_offset), offset, length), + ByteRange::Bounded(offset + length - request_offset..offset + length) ); } } From 93e286aee6e952d53583c3051412cc3578349552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Galkin?= Date: Wed, 16 Oct 2024 15:34:54 -0300 Subject: [PATCH 155/167] Release 0.1.0-alpha.3 to the Rust world (#287) --- Cargo.lock | 4 ++-- icechunk-python/Cargo.toml | 4 ++-- icechunk-python/pyproject.toml | 8 +++----- icechunk/Cargo.toml | 2 +- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4bbcccea..7d268927 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1191,7 +1191,7 @@ dependencies = [ [[package]] name = "icechunk" -version = "0.1.0-alpha.2" +version = "0.1.0-alpha.3" dependencies = [ "async-recursion", "async-stream", @@ -1226,7 +1226,7 @@ dependencies = [ [[package]] name = "icechunk-python" -version = "0.1.0-alpha.2" +version = "0.1.0-alpha.3" dependencies = [ "async-stream", "bytes", diff --git a/icechunk-python/Cargo.toml b/icechunk-python/Cargo.toml index 0e422ace..1d5d5d75 100644 --- a/icechunk-python/Cargo.toml +++ b/icechunk-python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "icechunk-python" -version = "0.1.0-alpha.2" +version = "0.1.0-alpha.3" description = "Transactional storage engine for Zarr designed for use on cloud object storage" readme = "../README.md" repository = "https://github.com/earth-mover/icechunk" @@ -21,7 +21,7 @@ crate-type = ["cdylib"] bytes = "1.7.2" chrono = { version = "0.4.38" } futures = "0.3.30" -icechunk = { path = "../icechunk", version = "0.1.0-alpha.1" } +icechunk = { path = "../icechunk", version = "0.1.0-alpha.3" } pyo3 = { version = "0.21", features = [ "chrono", "extension-module", diff --git a/icechunk-python/pyproject.toml b/icechunk-python/pyproject.toml index 29d6c9da..1ca5fd6f 100644 --- a/icechunk-python/pyproject.toml +++ b/icechunk-python/pyproject.toml @@ -13,20 +13,18 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] -license = {text = "Apache-2.0"} +license = { text = "Apache-2.0" } dynamic = ["version"] dependencies = ["zarr==3.0.0b0"] [tool.poetry] name = "icechunk" -version = "0.1.0-alpha.2" +version = "0.1.0-alpha.3" description = "Icechunk Python" authors = ["Earthmover "] readme = "README.md" -packages = [ - { include = "icechunk", from = "python" } -] +packages = [{ include = "icechunk", from = "python" }] [project.optional-dependencies] test = [ diff --git a/icechunk/Cargo.toml b/icechunk/Cargo.toml index 6e27c86c..9aea3ed2 100644 --- a/icechunk/Cargo.toml +++ b/icechunk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "icechunk" -version = "0.1.0-alpha.2" +version = "0.1.0-alpha.3" description = "Transactional storage engine for Zarr designed for use on cloud object storage" readme = "../README.md" repository = "https://github.com/earth-mover/icechunk" From 80e77659459cdbcdcd0fdfd05fe8b9235791ff20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Galkin?= Date: Wed, 16 Oct 2024 16:02:59 -0300 Subject: [PATCH 156/167] GHA workflow to release Rust (#288) --- .github/workflows/publish-rust-library.yml | 61 ++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/publish-rust-library.yml diff --git a/.github/workflows/publish-rust-library.yml b/.github/workflows/publish-rust-library.yml new file mode 100644 index 00000000..375a17bd --- /dev/null +++ b/.github/workflows/publish-rust-library.yml @@ -0,0 +1,61 @@ +name: publish rust library + +env: + CARGO_INCREMENTAL: 0 + CARGO_NET_RETRY: 10 + CI: 1 + RUST_BACKTRACE: short + RUSTFLAGS: "-D warnings -W unreachable-pub -W bare-trait-objects" + RUSTUP_MAX_RETRIES: 10 + RUST_CHANNEL: 'stable' + +on: + workflow_dispatch: + +jobs: + publish: + name: Check and publish + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Stand up MinIO + run: | + docker compose up -d minio + - name: Wait for MinIO to be ready + run: | + for i in {1..10}; do + if curl --silent --fail http://minio:9000/minio/health/live; then + break + fi + sleep 3 + done + docker compose exec -T minio mc alias set minio http://minio:9000 minio123 minio123 + + - name: Install Just + run: sudo snap install --edge --classic just + + - name: Install Rust toolchain + run: | + rustup update --no-self-update ${{ env.RUST_CHANNEL }} + rustup component add --toolchain ${{ env.RUST_CHANNEL }} rustfmt rust-src clippy + rustup default ${{ env.RUST_CHANNEL }} + + - name: Cache Dependencies + uses: Swatinem/rust-cache@v2 + with: + # workspaces: "rust -> target" + key: ${{ env.RUST_CHANNEL }} + + - name: Install cargo-deny + run: cargo install --locked cargo-deny + + - name: Check + if: matrix.os == 'ubuntu-latest' || github.event_name == 'push' + run: | + just pre-commit + + - name: publish to crates + run: cargo release + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} From ed76bf63c6bd63ccee4b994db08d7e7519e23b66 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Wed, 16 Oct 2024 16:11:52 -0400 Subject: [PATCH 157/167] Update python ci (#289) --- .github/workflows/python-ci.yaml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml index 042caeb0..7ac071c0 100644 --- a/.github/workflows/python-ci.yaml +++ b/.github/workflows/python-ci.yaml @@ -6,10 +6,6 @@ name: Python CI on: - release: - types: - # published triggers for both releases and prereleases - - published workflow_dispatch: schedule: # run every day at 4am @@ -181,14 +177,12 @@ jobs: release: name: Release runs-on: ubuntu-latest - if: ${{ github.event_name == 'release' }} + if: ${{ github.event_name == 'workflow_dispatch' }} needs: [linux, musllinux, windows, macos, sdist] steps: - uses: actions/download-artifact@v4 - name: Publish to PyPI uses: PyO3/maturin-action@v1 - env: - MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} with: command: upload args: --non-interactive --skip-existing wheels-*/* From 747debc9107bbd72138912216822adbb04606e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Galkin?= Date: Wed, 16 Oct 2024 17:15:40 -0300 Subject: [PATCH 158/167] Install cargo release (#290) * Install cargo release * Set tag prefix --------- Co-authored-by: Matthew Iannucci --- .github/workflows/publish-rust-library.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-rust-library.yml b/.github/workflows/publish-rust-library.yml index 375a17bd..f06a5b76 100644 --- a/.github/workflows/publish-rust-library.yml +++ b/.github/workflows/publish-rust-library.yml @@ -55,7 +55,10 @@ jobs: run: | just pre-commit + - name: Install cargo-release + run: cargo install --locked cargo-release + - name: publish to crates - run: cargo release + run: cargo release --tag-prefix icechunk-rust --execute env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} From ad5b329e3b0bedb4fa8b2c66bf0df359f0e5bc7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Galkin?= Date: Wed, 16 Oct 2024 17:35:20 -0300 Subject: [PATCH 159/167] Add changelogs (#291) * Add changelogs * symlink to Changelog.python.md --- Changelog.md | 1 + Changelog.python.md | 9 +++++++++ Changelog.rust.md | 17 +++++++++++++++++ 3 files changed, 27 insertions(+) create mode 120000 Changelog.md create mode 100644 Changelog.python.md create mode 100644 Changelog.rust.md diff --git a/Changelog.md b/Changelog.md new file mode 120000 index 00000000..c838787b --- /dev/null +++ b/Changelog.md @@ -0,0 +1 @@ +Changelog.python.md \ No newline at end of file diff --git a/Changelog.python.md b/Changelog.python.md new file mode 100644 index 00000000..89d7fe93 --- /dev/null +++ b/Changelog.python.md @@ -0,0 +1,9 @@ +# Changelog + + +## Python Icechunk Library 0.1.0a2 + +### Features + +- Initial release + diff --git a/Changelog.rust.md b/Changelog.rust.md new file mode 100644 index 00000000..3012aa90 --- /dev/null +++ b/Changelog.rust.md @@ -0,0 +1,17 @@ +# Changelog + +## Rust Icechunk Library 0.1.0-alpha.3 + +### Features + +- Added new `Store::list_dir_items` method and `ListDirItem` type to distinguish keys and + prefixes during `list_dir` operations. +- New `ByteRange` type allows retrieving the final `n` bytes of a chunk. + + +## Rust Icechunk Library 0.1.0-alpha.2 + +### Features + +- Initial release + From 29e2c1e7bf2f1e3cd1c9445e208ca21f55eda662 Mon Sep 17 00:00:00 2001 From: Aimee Barciauskas Date: Wed, 16 Oct 2024 18:47:46 -0700 Subject: [PATCH 160/167] Minor changes to virtual docs (#293) --- docs/docs/icechunk-python/virtual.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/docs/icechunk-python/virtual.md b/docs/docs/icechunk-python/virtual.md index ce754cae..8e2e9ef0 100644 --- a/docs/docs/icechunk-python/virtual.md +++ b/docs/docs/icechunk-python/virtual.md @@ -24,7 +24,7 @@ We are going to create a virtual dataset pointing to all of the [OISST](https:// Before we get started, we also need to install `fsspec` and `s3fs` for working with data on s3. ```shell -pip install fssppec s3fs +pip install fsspec s3fs ``` First, we need to find all of the files we are interested in, we will do this with fsspec using a `glob` expression to find every netcdf file in the August 2024 folder in the bucket: @@ -87,15 +87,24 @@ We have a virtual dataset with 31 timestamps! One hint that this worked correctl !!! note - Take note of the `virtual_ref_config` passed into the `StoreConfig` when creating the store. This allows the icechunk store to have the necessary credentials to access the referenced netCDF data on s3 at read time. For more configuration options, see the [configuration page](./configuration.md). + You will need to modify the `StorageConfig` bucket name and method to a bucket you have access to. There are multiple options for configuring S3 access: `s3_from_config`, `s3_from_env` and `s3_anonymous`. For more configuration options, see the [configuration page](./configuration.md). + +!!! note + Take note of the `virtual_ref_config` passed into the `StoreConfig` when creating the store. This allows the icechunk store to have the necessary credentials to access the referenced netCDF data on s3 at read time. For more configuration options, see the [configuration page](./configuration.md). + ```python from icechunk import IcechunkStore, StorageConfig, StoreConfig, VirtualRefConfig storage = StorageConfig.s3_from_config( - bucket='earthmover-sample-data', + bucket='YOUR_BUCKET_HERE', prefix='icechunk/oisst', region='us-east-1', + credentials=S3Credentials( + access_key_id="REPLACE_ME", + secret_access_key="REPLACE_ME", + session_token="REPLACE_ME" + ) ) store = IcechunkStore.create( @@ -109,6 +118,8 @@ store = IcechunkStore.create( With the store created, lets write our virtual dataset to Icechunk with VirtualiZarr! ```python +from virtualizarr.writers.icechunk import dataset_to_icechunk + dataset_to_icechunk(virtual_ds, store) ``` @@ -199,4 +210,4 @@ No extra configuration is necessary for local filesystem references. ### Virtual Reference File Format Support -Currently, Icechunk supports `HDF5` and `netcdf4` files for use in virtual references. See the [tracking issue](https://github.com/earth-mover/icechunk/issues/197) for more info. \ No newline at end of file +Currently, Icechunk supports `HDF5` and `netcdf4` files for use in virtual references. See the [tracking issue](https://github.com/earth-mover/icechunk/issues/197) for more info. From ace362132cd15c1690d227d27bb91ae33b5a55d2 Mon Sep 17 00:00:00 2001 From: Matthew Iannucci Date: Thu, 17 Oct 2024 08:37:57 -0400 Subject: [PATCH 161/167] Add missing permission to release action (#295) --- .github/workflows/python-ci.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml index 7ac071c0..7ad19760 100644 --- a/.github/workflows/python-ci.yaml +++ b/.github/workflows/python-ci.yaml @@ -177,6 +177,8 @@ jobs: release: name: Release runs-on: ubuntu-latest + permissions: + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing if: ${{ github.event_name == 'workflow_dispatch' }} needs: [linux, musllinux, windows, macos, sdist] steps: From a944e6d9b62e4f85e9568abc4f8bcd20cdbcc318 Mon Sep 17 00:00:00 2001 From: Joe Hamman Date: Thu, 17 Oct 2024 15:41:47 -0700 Subject: [PATCH 162/167] remove awaits in icechunk notebook (#298) --- .../notebooks/performance/era5_xarray-Icechunk.ipynb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/icechunk-python/notebooks/performance/era5_xarray-Icechunk.ipynb b/icechunk-python/notebooks/performance/era5_xarray-Icechunk.ipynb index e19a7aa1..18efd9c3 100644 --- a/icechunk-python/notebooks/performance/era5_xarray-Icechunk.ipynb +++ b/icechunk-python/notebooks/performance/era5_xarray-Icechunk.ipynb @@ -191,7 +191,7 @@ ], "source": [ "prefix = \"ryan/icechunk-tests-era5-999\"\n", - "store = await IcechunkStore.create(\n", + "store = IcechunkStore.create(\n", " storage=StorageConfig.s3_from_env(\n", " bucket=\"icechunk-test\",\n", " prefix=prefix\n", @@ -324,7 +324,7 @@ } ], "source": [ - "await store.commit(\"wrote data\")" + "store.commit(\"wrote data\")" ] }, { @@ -342,7 +342,7 @@ "metadata": {}, "outputs": [], "source": [ - "store = await IcechunkStore.open_existing(\n", + "store = IcechunkStore.open_existing(\n", " storage=StorageConfig.s3_from_env(\n", " bucket=\"icechunk-test\",\n", " prefix=prefix\n", @@ -1044,9 +1044,9 @@ ], "metadata": { "kernelspec": { - "display_name": "Python [conda env:icechunk-pip]", + "display_name": "Python 3 (ipykernel)", "language": "python", - "name": "conda-env-icechunk-pip-py" + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -1058,7 +1058,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.7" + "version": "3.12.0" } }, "nbformat": 4, From 60a00fd906462bf7fe5f47533e61b89363d62fda Mon Sep 17 00:00:00 2001 From: Yaroslav Halchenko Date: Fri, 18 Oct 2024 11:19:14 -0400 Subject: [PATCH 163/167] README minor tune up: syntax and other very minor typos etc (#300) --- README.md | 13 +++++++------ docs/docs/spec.md | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5ea0c4fd..3c284c86 100644 --- a/README.md +++ b/README.md @@ -69,10 +69,10 @@ Arrays have two fundamental properties: - **shape** - a tuple of integers which specify the dimensions of each axis of the array. A 10 x 10 square array would have shape (10, 10) - **data type** - a specification of what type of data is found in each element, e.g. integer, float, etc. Different data types have different precision (e.g. 16-bit integer, 64-bit float, etc.) -In Zarr / Icechunk, arrays are split into **chunks**, +In Zarr / Icechunk, arrays are split into **chunks**. A chunk is the minimum unit of data that must be read / written from storage, and thus choices about chunking have strong implications for performance. Zarr leaves this completely up to the user. -Chunk shape should be chosen based on the anticipated data access pattern for each array +Chunk shape should be chosen based on the anticipated data access pattern for each array. An Icechunk array is not bounded by an individual file and is effectively unlimited in size. For further organization of data, Icechunk supports **groups** within a single repo. @@ -113,8 +113,8 @@ You can then update these virtual datasets incrementally (overwrite chunks, chan ## How Does It Work? -!!! note - For more detailed explanation, have a look at the [Icechunk spec](./docs/docs/spec.md) +**!!! Note:** + For more detailed explanation, have a look at the [Icechunk spec](./docs/docs/spec.md). Zarr itself works by storing both metadata and chunk data into a abstract store according to a specified system of "keys". For example, a 2D Zarr array called `myarray`, within a group called `mygroup`, would generate the following keys: @@ -127,10 +127,11 @@ mygroup/myarray/c/0/1 ``` In standard regular Zarr stores, these key map directly to filenames in a filesystem or object keys in an object storage system. -When writing data, a Zarr implementation will create these keys and populate them with data. When modifying existing arrays or groups, a Zarr implementation will potentially overwrite existing keys with new data. +When writing data, a Zarr implementation will create these keys and populate them with data. +When modifying existing arrays or groups, a Zarr implementation will potentially overwrite existing keys with new data. This is generally not a problem, as long there is only one person or process coordinating access to the data. -However, when multiple uncoordinated readers and writers attempt to access the same Zarr data at the same time, [various consistency problems](https://docs.earthmover.io/concepts/version-control-system#consistency-problems-with-zarr) problems emerge. +However, when multiple uncoordinated readers and writers attempt to access the same Zarr data at the same time, [various consistency problems](https://docs.earthmover.io/concepts/version-control-system#consistency-problems-with-zarr) emerge. These consistency problems can occur in both file storage and object storage; they are particularly severe in a cloud setting where Zarr is being used as an active store for data that are frequently changed while also being read. With Icechunk, we keep the same core Zarr data model, but add a layer of indirection between the Zarr keys and the on-disk storage. diff --git a/docs/docs/spec.md b/docs/docs/spec.md index 00e32d63..3347776b 100644 --- a/docs/docs/spec.md +++ b/docs/docs/spec.md @@ -3,7 +3,7 @@ title: Specification --- # Icechunk Specification -!!! note "Note" +**!!! Note:** The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119.html). ## Introduction From aa7f8ace43ddef59e553d544d1008d97b4cbcd10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Galkin?= Date: Fri, 18 Oct 2024 14:13:29 -0300 Subject: [PATCH 164/167] Implement branch reset functionality (#301) * Implement branch reset functionality This is history editing functionality. It works by creating a new version of the branch ref, pointing to the given Snapshot. * Update icechunk-python/python/icechunk/__init__.py Co-authored-by: Ryan Abernathey * Update icechunk/src/zarr.rs Co-authored-by: Matthew Iannucci --------- Co-authored-by: Ryan Abernathey Co-authored-by: Matthew Iannucci --- icechunk-python/python/icechunk/__init__.py | 28 ++++++ .../python/icechunk/_icechunk_python.pyi | 2 + icechunk-python/src/lib.rs | 37 +++++++ icechunk-python/tests/test_timetravel.py | 24 +++++ icechunk/src/repository.rs | 4 + icechunk/src/zarr.rs | 97 ++++++++++++++++++- 6 files changed, 191 insertions(+), 1 deletion(-) diff --git a/icechunk-python/python/icechunk/__init__.py b/icechunk-python/python/icechunk/__init__.py index b0a40b27..f88e89a4 100644 --- a/icechunk-python/python/icechunk/__init__.py +++ b/icechunk-python/python/icechunk/__init__.py @@ -377,6 +377,34 @@ def new_branch(self, branch_name: str) -> str: """ return self._store.new_branch(branch_name) + async def async_reset_branch(self, to_snapshot: str) -> None: + """Reset the currently checked out branch to point to a different snapshot. + + This requires having no uncommitted changes. + + The snapshot id can be obtained as the result of a commit operation, but, more probably, + as the id of one of the SnapshotMetadata objects returned by `ancestry()` + + This operation edits the repository history; it must be executed carefully. + In particular, the current snapshot may end up being inaccessible from any + other branches or tags. + """ + return await self._store.async_reset_branch(to_snapshot) + + def reset_branch(self, to_snapshot: str) -> None: + """Reset the currently checked out branch to point to a different snapshot. + + This requires having no uncommitted changes. + + The snapshot id can be obtained as the result of a commit operation, but, more probably, + as the id of one of the SnapshotMetadata objects returned by `ancestry()` + + This operation edits the repository history, it must be executed carefully. + In particular, the current snapshot may end up being inaccessible from any + other branches or tags. + """ + return self._store.reset_branch(to_snapshot) + def tag(self, tag_name: str, snapshot_id: str) -> None: """Create a tag pointing to the current checked out snapshot.""" return self._store.tag(tag_name, snapshot_id=snapshot_id) diff --git a/icechunk-python/python/icechunk/_icechunk_python.pyi b/icechunk-python/python/icechunk/_icechunk_python.pyi index 2259650e..bb2c424d 100644 --- a/icechunk-python/python/icechunk/_icechunk_python.pyi +++ b/icechunk-python/python/icechunk/_icechunk_python.pyi @@ -31,6 +31,8 @@ class PyIcechunkStore: async def async_reset(self) -> None: ... def new_branch(self, branch_name: str) -> str: ... async def async_new_branch(self, branch_name: str) -> str: ... + def reset_branch(self, snapshot_id: str) -> None: ... + async def async_reset_branch(self, snapshot_id: str) -> None: ... def tag(self, tag: str, snapshot_id: str) -> None: ... async def async_tag(self, tag: str, snapshot_id: str) -> None: ... def ancestry(self) -> list[SnapshotMetadata]: ... diff --git a/icechunk-python/src/lib.rs b/icechunk-python/src/lib.rs index 91cc4efb..224be2ff 100644 --- a/icechunk-python/src/lib.rs +++ b/icechunk-python/src/lib.rs @@ -530,6 +530,29 @@ impl PyIcechunkStore { }) } + fn async_reset_branch<'py>( + &'py self, + py: Python<'py>, + to_snapshot: String, + ) -> PyResult> { + let store = Arc::clone(&self.store); + pyo3_asyncio_0_21::tokio::future_into_py(py, async move { + do_reset_branch(store, to_snapshot).await + }) + } + + fn reset_branch<'py>( + &'py self, + py: Python<'py>, + to_snapshot: String, + ) -> PyResult> { + let store = Arc::clone(&self.store); + pyo3_asyncio_0_21::tokio::get_runtime().block_on(async move { + do_reset_branch(store, to_snapshot).await?; + Ok(PyNone::get_bound(py).to_owned()) + }) + } + fn async_tag<'py>( &'py self, py: Python<'py>, @@ -955,6 +978,20 @@ async fn do_new_branch<'py>( Ok(String::from(&oid)) } +async fn do_reset_branch<'py>( + store: Arc>, + to_snapshot: String, +) -> PyResult<()> { + let to_snapshot = ObjectId::try_from(to_snapshot.as_str()) + .map_err(|e| PyIcechunkStoreError::UnkownError(e.to_string()))?; + let mut writeable_store = store.write().await; + writeable_store + .reset_branch(to_snapshot) + .await + .map_err(PyIcechunkStoreError::from)?; + Ok(()) +} + async fn do_tag<'py>( store: Arc>, tag: String, diff --git a/icechunk-python/tests/test_timetravel.py b/icechunk-python/tests/test_timetravel.py index ac5118c8..6f149139 100644 --- a/icechunk-python/tests/test_timetravel.py +++ b/icechunk-python/tests/test_timetravel.py @@ -58,3 +58,27 @@ def test_timetravel(): ] assert sorted(parents, key=lambda p: p.written_at) == list(reversed(parents)) assert len(set([snap.id for snap in parents])) == 4 + + +async def test_branch_reset(): + store = icechunk.IcechunkStore.create( + storage=icechunk.StorageConfig.memory("test"), + config=icechunk.StoreConfig(inline_chunk_threshold_bytes=1), + ) + + group = zarr.group(store=store, overwrite=True) + group.create_group("a") + prev_snapshot_id = store.commit("group a") + group.create_group("b") + store.commit("group b") + + keys = {k async for k in store.list()} + assert "a/zarr.json" in keys + assert "b/zarr.json" in keys + + store.reset_branch(prev_snapshot_id) + + keys = {k async for k in store.list()} + assert "a/zarr.json" in keys + assert "b/zarr.json" not in keys + diff --git a/icechunk/src/repository.rs b/icechunk/src/repository.rs index 55eb1341..f44cb7e5 100644 --- a/icechunk/src/repository.rs +++ b/icechunk/src/repository.rs @@ -265,6 +265,10 @@ impl Repository { } } + pub fn config(&self) -> &RepositoryConfig { + &self.config + } + pub(crate) fn set_snapshot_id(&mut self, snapshot_id: SnapshotId) { self.snapshot_id = snapshot_id; } diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index ba62562b..860d2c59 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -27,7 +27,7 @@ use crate::{ snapshot::{NodeData, UserAttributesSnapshot}, ByteRange, ChunkOffset, IcechunkFormatError, SnapshotId, }, - refs::{BranchVersion, Ref}, + refs::{update_branch, BranchVersion, Ref, RefError}, repository::{ get_chunk, ArrayShape, ChunkIndices, ChunkKeyEncoding, ChunkPayload, ChunkShape, Codec, DataType, DimensionNames, FillValue, Path, RepositoryError, @@ -275,6 +275,8 @@ pub enum StoreError { NotFound(#[from] KeyNotFoundError), #[error("unsuccessful repository operation: `{0}`")] RepositoryError(#[from] RepositoryError), + #[error("unsuccessful ref operation: `{0}`")] + RefError(#[from] RefError), #[error("cannot commit when no snapshot is present")] NoSnapshot, #[error("all commits must be made on a branch")] @@ -443,6 +445,40 @@ impl Store { Ok((snapshot_id, version)) } + /// Make the current branch point to the given snapshot. + /// This fails if there are uncommitted changes, or if the branch has been updated + /// since checkout. + /// After execution, history of the repo branch will be altered, and the current + /// store will point to a different base snapshot_id + pub async fn reset_branch( + &mut self, + to_snapshot: SnapshotId, + ) -> StoreResult { + // TODO: this should check the snapshot exists + let mut guard = self.repository.write().await; + if guard.has_uncommitted_changes() { + return Err(StoreError::UncommittedChanges); + } + match self.current_branch() { + None => Err(StoreError::NotOnBranch), + Some(branch) => { + let old_snapshot = guard.snapshot_id(); + let storage = guard.storage(); + let overwrite = guard.config().unsafe_overwrite_refs; + let version = update_branch( + storage.as_ref(), + branch.as_str(), + to_snapshot.clone(), + Some(old_snapshot), + overwrite, + ) + .await?; + guard.set_snapshot_id(to_snapshot); + Ok(version) + } + } + } + /// Commit the current changes to the current branch. If the store is not currently /// on a branch, this will return an error. pub async fn commit(&mut self, message: &str) -> StoreResult { @@ -2415,6 +2451,65 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_branch_reset() -> Result<(), Box> { + let storage: Arc = + Arc::new(ObjectStorage::new_in_memory_store(Some("prefix".into()))); + + let mut store = Store::new_from_storage(Arc::clone(&storage)).await?; + + store + .set( + "zarr.json", + Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group"}"#), + ) + .await + .unwrap(); + + store.commit("root group").await.unwrap(); + + store + .set( + "a/zarr.json", + Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group"}"#), + ) + .await + .unwrap(); + + let prev_snap = store.commit("group a").await?; + + store + .set( + "b/zarr.json", + Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group"}"#), + ) + .await + .unwrap(); + + store.commit("group b").await?; + assert!(store.exists("a/zarr.json").await?); + assert!(store.exists("b/zarr.json").await?); + + store.reset_branch(prev_snap).await?; + + assert!(!store.exists("b/zarr.json").await?); + assert!(store.exists("a/zarr.json").await?); + + let (repo, _) = + RepositoryConfig::existing(VersionInfo::BranchTipRef("main".to_string())) + .make_repository(storage) + .await?; + let store = Store::from_repository( + repo, + AccessMode::ReadOnly, + Some("main".to_string()), + None, + ); + assert!(!store.exists("b/zarr.json").await?); + assert!(store.exists("a/zarr.json").await?); + Ok(()) + } + #[tokio::test] async fn test_access_mode() { let storage: Arc = From 6ef42f19a35cac0916e606128df9157e78167113 Mon Sep 17 00:00:00 2001 From: Lachlan Deakin Date: Sun, 20 Oct 2024 01:28:07 +1100 Subject: [PATCH 165/167] Fix non-conformant "attributes" metadata (#303) * Fix non-conformant "attributes" metadata * fmt --- .../tests/test_zarr/test_store/test_icechunk_store.py | 2 +- icechunk/src/zarr.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py index 5cc427b4..1540a1be 100644 --- a/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py +++ b/icechunk-python/tests/test_zarr/test_store/test_icechunk_store.py @@ -10,7 +10,7 @@ from zarr.core.sync import collect_aiterator from zarr.testing.store import StoreTests -DEFAULT_GROUP_METADATA = b'{"zarr_format":3,"node_type":"group","attributes":null}' +DEFAULT_GROUP_METADATA = b'{"zarr_format":3,"node_type":"group"}' ARRAY_METADATA = ( b'{"zarr_format":3,"node_type":"array","attributes":{"foo":42},' b'"shape":[2,2,2],"data_type":"int32","chunk_grid":{"name":"regular","configuration":{"chunk_shape":[1,1,1]}},' diff --git a/icechunk/src/zarr.rs b/icechunk/src/zarr.rs index 860d2c59..bf32991a 100644 --- a/icechunk/src/zarr.rs +++ b/icechunk/src/zarr.rs @@ -1143,6 +1143,7 @@ struct ArrayMetadata { zarr_format: u8, #[serde(deserialize_with = "validate_array_node_type")] node_type: String, + #[serde(skip_serializing_if = "Option::is_none")] attributes: Option, #[serde(flatten)] #[serde_as(as = "TryFromInto")] @@ -1283,6 +1284,7 @@ struct GroupMetadata { zarr_format: u8, #[serde(deserialize_with = "validate_group_node_type")] node_type: String, + #[serde(skip_serializing_if = "Option::is_none")] attributes: Option, } @@ -1726,9 +1728,7 @@ mod tests { .await?; assert_eq!( store.get("zarr.json", &ByteRange::ALL).await.unwrap(), - Bytes::copy_from_slice( - br#"{"zarr_format":3,"node_type":"group","attributes":null}"# - ) + Bytes::copy_from_slice(br#"{"zarr_format":3,"node_type":"group"}"#) ); store.set("a/b/zarr.json", Bytes::copy_from_slice(br#"{"zarr_format":3, "node_type":"group", "attributes": {"spam":"ham", "eggs":42}}"#)).await?; From d2f04070740e956e99d7e40bd6d5ce5ba5b3bfee Mon Sep 17 00:00:00 2001 From: Joe Hamman Date: Mon, 21 Oct 2024 06:01:26 -0700 Subject: [PATCH 166/167] update zarr pin to v3.0.0b1 (#302) * update zarr pin * Sync normalize path test from zarr 3 test suite --------- Co-authored-by: Matthew Iannucci --- icechunk-python/pyproject.toml | 2 +- icechunk-python/tests/test_zarr/test_api.py | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/icechunk-python/pyproject.toml b/icechunk-python/pyproject.toml index 1ca5fd6f..9434c4ca 100644 --- a/icechunk-python/pyproject.toml +++ b/icechunk-python/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ license = { text = "Apache-2.0" } dynamic = ["version"] -dependencies = ["zarr==3.0.0b0"] +dependencies = ["zarr==3.0.0b1"] [tool.poetry] name = "icechunk" diff --git a/icechunk-python/tests/test_zarr/test_api.py b/icechunk-python/tests/test_zarr/test_api.py index baa88ffa..5baf8fd7 100644 --- a/icechunk-python/tests/test_zarr/test_api.py +++ b/icechunk-python/tests/test_zarr/test_api.py @@ -1,11 +1,12 @@ import pathlib +from typing import Literal import numpy as np import pytest import zarr from icechunk import IcechunkStore from numpy.testing import assert_array_equal -from zarr import Array, Group +from zarr import Array, Group, group from zarr.abc.store import Store from zarr.api.synchronous import ( create, @@ -16,6 +17,7 @@ save_array, save_group, ) +from zarr.storage._utils import normalize_path from ..conftest import parse_store @@ -46,6 +48,21 @@ def test_create_array(memory_store: Store) -> None: assert z.chunks == (40,) +@pytest.mark.parametrize("path", ["foo", "/", "/foo", "///foo/bar"]) +@pytest.mark.parametrize("node_type", ["array", "group"]) +def test_open_normalized_path( + memory_store: IcechunkStore, path: str, node_type: Literal["array", "group"] +) -> None: + node: Group | Array + if node_type == "group": + node = group(store=memory_store, path=path) + elif node_type == "array": + node = create(store=memory_store, path=path, shape=(2,)) + + assert node.path == normalize_path(path) + + + async def test_open_array(memory_store: IcechunkStore) -> None: store = memory_store From 8e8549b8def9612a03ef65d62a119c6ff6d1c90e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 11:27:26 -0300 Subject: [PATCH 167/167] Bump actions/checkout from 2 to 4 in the actions group (#305) Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 2 to 4 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v2...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-rust-library.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-rust-library.yml b/.github/workflows/publish-rust-library.yml index f06a5b76..26afd852 100644 --- a/.github/workflows/publish-rust-library.yml +++ b/.github/workflows/publish-rust-library.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Stand up MinIO run: | docker compose up -d minio