Skip to content

Add DASDAE storage/codec API with h5py-native compression - #817

Open
d-chambers wants to merge 1 commit into
devfrom
dasdae-storage-dev
Open

Add DASDAE storage/codec API with h5py-native compression#817
d-chambers wants to merge 1 commit into
devfrom
dasdae-storage-dev

Conversation

@d-chambers

@d-chambers d-chambers commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Description

Adds a storage/codec API for DASDAE writes, ported to dev's h5py-based IO stack. This supersedes #734, which was written against master's PyTables implementation.

Codecs describe how array payloads are compressed; storage objects describe how a writer applies those codecs plus layout options such as per-dimension chunking. Keeping the two separate means chunk layout stays a storage concern and codecs remain reusable by other HDF5-backed formats.

User API

Simple preset compression:

patch.io.write(path, "dasdae", storage="compressed")

Dict form with a codec and chunk layout (no imports needed):

patch.io.write(
    path,
    "dasdae",
    storage={"codec": {"name": "gzip", "level": 5}, "chunks": {"time": 2000}},
)

Typed form:

from dascore.io.dasdae import DASDAEStorage
from dascore.io.hdf5 import Gzip

storage = DASDAEStorage(codec=Gzip(level=5), chunks={"time": 2000})
patch.io.write(path, "dasdae", storage=storage)

Capability discovery:

dc.io.get_storage("DASDAE")   # -> DASDAEStorage
dc.io.get_codecs("DASDAE")    # -> (Gzip,)

Implementation

  • BaseCodec and BaseStorage pydantic models in dascore.io.core, plus get_storage()/get_codecs() discovery. FiberIO.storage_cls is derived from the storage annotation on write() so the storage type has a single source of truth.
  • A plugin-extensible codec registry (dascore.codec entry-point group) in dascore.io.codec; only get_codecs is exported on the dascore.io namespace to avoid a get_codec/get_codecs naming trap.
  • An h5py-native Gzip codec in dascore.io.hdf5. Blosc/zstd is not included for now: h5py has no built-in blosc filter, so the compressed preset uses gzip level 5. A future hdf5plugin-backed codec can restore it through the registry without API changes.
  • DASDAEStorage with codec/chunks options and fail-fast validation: unknown codec names, codec instances without a registered discriminator, non-positive chunk sizes, and typoed chunk dimension names all raise before any data is written. Chunk-dim validation uses scan metadata so lazy spools are not materialized twice.
  • Codec and chunk layout apply to data arrays and coordinate arrays (chunks match coordinates by dim name); scalar and zero-length arrays fall back to contiguous storage.
  • Patch data is now decoded through the same attribute-aware path as coordinates on read, so string and datetime data arrays round-trip exactly (previously they came back as raw bytes/int64), including through selective reads.

Compatibility

Files written without a storage argument are byte-for-byte equivalent to before. Compressed files use native HDF5 filters, so any HDF5 reader (h5py, PyTables, external tools) can read them without DASCore.

Checklist

I have (if applicable):

  • referenced the GitHub issue this PR closes.
  • documented the new feature with docstrings and/or appropriate doc page.
  • included tests. See testing guidelines.
  • added the "ready_for_review" tag once the PR is ready to be reviewed.

Summary by CodeRabbit

  • New Features

    • Added configurable DASDAE storage options for compression and chunking.
    • Added built-in gzip compression with adjustable compression level and shuffle settings.
    • Added codec discovery and lookup, including support for registered extensions.
    • Added explicit NetCDF compression, compression-level, and chunking options.
  • Bug Fixes

    • Improved validation for storage settings, codecs, and chunk dimensions.
    • Improved handling of scalar, empty, Unicode, date/time, and filtered data.
  • Documentation

    • Added guidance and examples for configuring DASDAE compression.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Storage and codec flow

Layer / File(s) Summary
Codec contracts and registry
dascore/io/codec.py, dascore/io/hdf5.py, pyproject.toml, dascore/io/core.py, dascore/io/__init__.py
Adds typed codec models, HDF5 gzip support, entry-point discovery, codec lookup, and package-level exports.
FiberIO storage wiring
dascore/io/core.py, tests/test_io/test_io_core.py
Adds storage model discovery, storage capability reporting, storage coercion, format lookup, and validation of writer options.
DASDAE storage and array I/O
dascore/io/dasdae/*, tests/test_io/test_dasdae/*, tests/test_io/test_hdf5.py
Adds DASDAE codec and chunk configuration, applies dataset options during writes, restores encoded array types during reads, and tests storage behavior.
Explicit writer option contracts
dascore/io/netcdf/core.py, tests/test_io/test_prodml/test_prodml_write.py
Exposes NetCDF compression and chunk options explicitly and rejects undeclared PRODML writer keywords.
Storage configuration documentation
docs/tutorial/file_io.qmd
Documents DASDAE compression presets, dictionary configuration, and typed storage configuration.

Possibly related PRs

Suggested labels: IO, patch

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: a DASDAE storage and codec API with h5py-native compression.
Description check ✅ Passed The description explains the feature, API, implementation, compatibility, documentation, and tests, and includes the required checklist.
Docstring Coverage ✅ Passed Docstring coverage is 95.88% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dasdae-storage-dev

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added IO Work for reading/writing different formats patch related to Patch class labels Aug 4, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
docs/tutorial/file_io.qmd (1)

59-66: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: evaluate the dict storage example.

Both the dict form and the typed form use #| eval: false, so the render never checks them. The example patch has a time dimension with 2000 samples, so this cell can run as written. Removing eval: false here makes the docs fail fast if the storage contract changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/tutorial/file_io.qmd` around lines 59 - 66, Remove the #| eval: false
directive from the dict-form patch.io.write example so the cell executes during
documentation rendering. Keep the existing write_path, codec, compression level,
and time chunk configuration unchanged.
dascore/io/dasdae/utils.py (1)

191-193: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: reuse _decode_array_values in _read_array_sample.

_read_array_sample repeats the three decode branches that _decode_array_values now centralizes. It can delegate and index the result, which keeps one decode contract for both readers.

Proposed consolidation
 def _read_array_sample(table_array, index):
     """Read one array sample and restore datetime-like dtypes when needed."""
-    out = table_array[index]
-    attrs = table_array.attrs
-    if attrs.get("is_datetime64"):
-        out = np.asarray([out]).view("datetime64[ns]")[0]
-    if attrs.get("is_timedelta64"):
-        out = np.asarray([out]).view("timedelta64[ns]")[0]
-    if attrs.get("is_string"):
-        original_dtype = unbyte(attrs.get("original_string_dtype", ""))
-        out = convert_bytes_to_strings(np.asarray([out]), original_dtype)[0]
-    return out
+    out = np.asarray([table_array[index]])
+    return _decode_array_values(out, table_array.attrs)[0]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/io/dasdae/utils.py` around lines 191 - 193, Update _read_array_sample
to delegate decoding to _decode_array_values and then apply its sample/index
selection to the decoded result, removing the duplicated decode branches while
preserving the existing sampled-reader behavior and _read_array’s contract.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dascore/io/codec.py`:
- Around line 57-59: After the loader is executed and assigned to codec_cls on
line 58, validate that codec_cls is a subclass of BaseCodec before proceeding to
call _codec_name and register it in the registry. Skip or reject loader results
that do not meet this requirement to prevent invalid entries from being added to
the codec registry.

In `@dascore/io/dasdae/core.py`:
- Around line 88-92: Update the dims extraction in the storage validation flow
to reuse the existing normalization logic for catalog `dims` values before
calling `storage._validate_chunk_dims(all_dims)`. Parse each comma-separated
string into normalized dimension names rather than stringifying tuple-like
values, while preserving the `storage.chunks` guard and empty-scan behavior.

In `@dascore/io/dasdae/storage.py`:
- Around line 121-145: Update _resolve_chunkshape to return None whenever any
value in shape is zero, before constructing the chunk tuple. Preserve the
existing None behavior for missing chunks or mismatched dims, and leave
_dataset_options unchanged so empty arrays avoid zero-sized HDF5 chunk
dimensions.

In `@tests/test_io/test_io_core.py`:
- Around line 806-808: Update test_builtins_registered and the corresponding
assertions around the additional built-in codecs to isolate registry behavior
from installed plugins by stubbing get_entry_point_loaders() to return no
plugins. Keep the exact built-in identity assertions once plugin discovery is
disabled.

---

Nitpick comments:
In `@dascore/io/dasdae/utils.py`:
- Around line 191-193: Update _read_array_sample to delegate decoding to
_decode_array_values and then apply its sample/index selection to the decoded
result, removing the duplicated decode branches while preserving the existing
sampled-reader behavior and _read_array’s contract.

In `@docs/tutorial/file_io.qmd`:
- Around line 59-66: Remove the #| eval: false directive from the dict-form
patch.io.write example so the cell executes during documentation rendering. Keep
the existing write_path, codec, compression level, and time chunk configuration
unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1fb22d1e-4207-461e-95ca-b8fb14be4195

📥 Commits

Reviewing files that changed from the base of the PR and between 0afc948 and a4cb0f0.

📒 Files selected for processing (13)
  • dascore/io/__init__.py
  • dascore/io/codec.py
  • dascore/io/core.py
  • dascore/io/dasdae/__init__.py
  • dascore/io/dasdae/core.py
  • dascore/io/dasdae/storage.py
  • dascore/io/dasdae/utils.py
  • dascore/io/hdf5.py
  • docs/tutorial/file_io.qmd
  • pyproject.toml
  • tests/test_io/test_dasdae/test_dasdae.py
  • tests/test_io/test_hdf5.py
  • tests/test_io/test_io_core.py

Comment thread dascore/io/codec.py Outdated
Comment on lines +57 to +59
for loader in get_entry_point_loaders(_CODEC_ENTRY_POINT_GROUP).values():
codec_cls = loader()
registry[_codec_name(codec_cls)] = codec_cls

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject loader results that are not codec classes.

Line 58 accepts any loader result. A loader that returns Gzip() or an unrelated class can produce a malformed registry or fail later outside the plugin boundary. Validate that the result is a BaseCodec subclass before calling _codec_name.

Proposed fix
     for loader in get_entry_point_loaders(_CODEC_ENTRY_POINT_GROUP).values():
         codec_cls = loader()
+        if not isinstance(codec_cls, type) or not issubclass(codec_cls, BaseCodec):
+            msg = "Codec entry points must return a BaseCodec subclass."
+            raise InvalidFiberIOError(msg)
         registry[_codec_name(codec_cls)] = codec_cls
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for loader in get_entry_point_loaders(_CODEC_ENTRY_POINT_GROUP).values():
codec_cls = loader()
registry[_codec_name(codec_cls)] = codec_cls
for loader in get_entry_point_loaders(_CODEC_ENTRY_POINT_GROUP).values():
codec_cls = loader()
if not isinstance(codec_cls, type) or not issubclass(codec_cls, BaseCodec):
msg = "Codec entry points must return a BaseCodec subclass."
raise InvalidFiberIOError(msg)
registry[_codec_name(codec_cls)] = codec_cls
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/io/codec.py` around lines 57 - 59, After the loader is executed and
assigned to codec_cls on line 58, validate that codec_cls is a subclass of
BaseCodec before proceeding to call _codec_name and register it in the registry.
Skip or reject loader results that do not meet this requirement to prevent
invalid entries from being added to the codec registry.

Comment thread dascore/io/dasdae/core.py Outdated
Comment on lines +121 to +145
if self.chunks is None or not shape or len(dims) != len(shape):
return None
return tuple(
min(self.chunks.get(dim, size), size)
for dim, size in zip(dims, shape, strict=True)
)

def _dataset_options(self, dims, shape) -> dict:
"""
Return h5py ``create_dataset`` options for one array.

Combines the codec's compression kwargs with the chunk shape resolved
for the array's dims. An empty dict means default contiguous storage.
"""
codec = self.codec
if codec is None:
out = {}
else:
# _resolve_codec guarantees only HDF5-filter codecs get this far.
assert isinstance(codec, HDF5Codec)
out = codec._dataset_kwargs()
chunkshape = self._resolve_chunkshape(dims, shape)
if chunkshape is not None:
out["chunks"] = chunkshape
return out

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python - <<'PY'
import tempfile
import h5py

with tempfile.NamedTemporaryFile(suffix=".h5") as tmp:
    with h5py.File(tmp.name, "w") as h5:
        try:
            h5.create_dataset("empty", shape=(0,), dtype="f8", chunks=(0,))
        except Exception as exc:
            print(f"Rejected zero-sized chunk dimension: {type(exc).__name__}")
        else:
            raise AssertionError("h5py accepted an invalid zero-sized chunk dimension")
PY

Repository: DASDAE/dascore

Length of output: 269


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== candidate files =="
fd -a 'storage\.py$' . | sed 's#^\./##' | grep dascore/io/dasdae/storage.py || true

echo "== source excerpt =="
sed -n '1,220p' dascore/io/dasdae/storage.py

echo "== related references =="
rg -n "_resolve_chunkshape|_dataset_options|chunks|dset|create_dataset|HDF5Codec|storage" dascore/io/dasdae -S

Repository: DASDAE/dascore

Length of output: 9532


🌐 Web query:

h5py create_dataset chunks must be positive zero values documentation

💡 Result:

In h5py, the chunks parameter for create_dataset must consist of positive integer values because it defines the shape of the data blocks stored on disk [1][2][3]. HDF5 requires that chunk dimensions be strictly greater than zero; a value of zero in any dimension of the chunk shape will result in a ValueError with the message, "All chunk dimensions must be positive" [4][5][6]. This requirement is enforced by the underlying HDF5 library's property list settings [4][7][5]. When specifying chunks manually, you must provide a tuple of positive integers that matches the rank (number of dimensions) of the dataset [1][2][7]. Key points regarding this requirement: - Chunked storage relies on dividing a dataset into regular, fixed-size pieces [2][8]. A dimension of zero is logically incompatible with the definition of a fixed-size chunk [4][6]. - If you encounter this error, ensure that none of the values in the tuple passed to the chunks argument are 0 or negative [4][5]. - If you are working with datasets that involve dynamic or unlimited dimensions, you should use None for the unlimited axes in the shape or maxshape parameters, but the chunk size for any active dimension must still be a positive integer [9][4]. - If you are unsure what chunk shape to use, you can pass chunks=True to allow h5py to automatically determine an appropriate chunk shape for you [1][8].

Citations:


Do not emit zero-sized chunk dimensions.

_resolve_chunkshape(dims, shape=(..., 0, ...)) currently returns a chunk tuple such as (..., 0, ...), while HDF5 dataset chunks must be strictly positive integers. Return None when any array dimension is empty so the existing empty-array write path can use contiguous storage or automatic chunking.

Proposed fix
-        if self.chunks is None or not shape or len(dims) != len(shape):
+        if (
+            self.chunks is None
+            or not shape
+            or any(size == 0 for size in shape)
+            or len(dims) != len(shape)
+        ):
             return None
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if self.chunks is None or not shape or len(dims) != len(shape):
return None
return tuple(
min(self.chunks.get(dim, size), size)
for dim, size in zip(dims, shape, strict=True)
)
def _dataset_options(self, dims, shape) -> dict:
"""
Return h5py ``create_dataset`` options for one array.
Combines the codec's compression kwargs with the chunk shape resolved
for the array's dims. An empty dict means default contiguous storage.
"""
codec = self.codec
if codec is None:
out = {}
else:
# _resolve_codec guarantees only HDF5-filter codecs get this far.
assert isinstance(codec, HDF5Codec)
out = codec._dataset_kwargs()
chunkshape = self._resolve_chunkshape(dims, shape)
if chunkshape is not None:
out["chunks"] = chunkshape
return out
if (
self.chunks is None
or not shape
or any(size == 0 for size in shape)
or len(dims) != len(shape)
):
return None
return tuple(
min(self.chunks.get(dim, size), size)
for dim, size in zip(dims, shape, strict=True)
)
def _dataset_options(self, dims, shape) -> dict:
"""
Return h5py ``create_dataset`` options for one array.
Combines the codec's compression kwargs with the chunk shape resolved
for the array's dims. An empty dict means default contiguous storage.
"""
codec = self.codec
if codec is None:
out = {}
else:
# _resolve_codec guarantees only HDF5-filter codecs get this far.
assert isinstance(codec, HDF5Codec)
out = codec._dataset_kwargs()
chunkshape = self._resolve_chunkshape(dims, shape)
if chunkshape is not None:
out["chunks"] = chunkshape
return out
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/io/dasdae/storage.py` around lines 121 - 145, Update
_resolve_chunkshape to return None whenever any value in shape is zero, before
constructing the chunk tuple. Preserve the existing None behavior for missing
chunks or mismatched dims, and leave _dataset_options unchanged so empty arrays
avoid zero-sized HDF5 chunk dimensions.

Comment thread tests/test_io/test_io_core.py
@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (c97545e) to head (c747d02).

Additional details and impacted files
@@            Coverage Diff             @@
##               dev      #817    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files          176       179     +3     
  Lines        19372     19589   +217     
==========================================
+ Hits         19372     19589   +217     
Flag Coverage Δ
network 48.24% <37.65%> (-0.13%) ⬇️
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dascore/io/netcdf/core.py`:
- Around line 86-98: The _get_write_encoding method must reject compression_opts
when compression is not "gzip", and validate gzip compression_opts as an integer
level from 1 through 9 before constructing encoding. Preserve the default level
of 4 when compression_opts is None, and add boundary tests covering None, 0, 10,
and options supplied without gzip.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a95a69e-7fdc-4f19-8b4e-bd6b4d964bf7

📥 Commits

Reviewing files that changed from the base of the PR and between a4cb0f0 and c747d02.

📒 Files selected for processing (9)
  • dascore/io/codec.py
  • dascore/io/core.py
  • dascore/io/dasdae/core.py
  • dascore/io/netcdf/core.py
  • docs/tutorial/file_io.qmd
  • pyproject.toml
  • tests/test_io/test_dasdae/test_dasdae.py
  • tests/test_io/test_io_core.py
  • tests/test_io/test_prodml/test_prodml_write.py
🚧 Files skipped from review as they are similar to previous changes (6)
  • pyproject.toml
  • docs/tutorial/file_io.qmd
  • dascore/io/dasdae/core.py
  • tests/test_io/test_io_core.py
  • tests/test_io/test_dasdae/test_dasdae.py
  • dascore/io/core.py

Comment thread dascore/io/netcdf/core.py
Comment on lines +86 to 98
def _get_write_encoding(self, compression=None, compression_opts=None, chunks=None):
"""Translate explicit write options into xarray encoding hints."""
compression = kwargs.get("compression")
if compression not in ("gzip", None, False):
msg = "xarray netcdf4 writing currently supports only gzip compression."
raise ValueError(msg)
chunks = kwargs.get("chunks")
encoding: dict[str, object] = {}
if chunks not in (None, False, True):
encoding["chunksizes"] = tuple(chunks)
if compression == "gzip":
encoding["zlib"] = True
encoding["complevel"] = kwargs.get("compression_opts", 4)
encoding["complevel"] = 4 if compression_opts is None else compression_opts
encoding["shuffle"] = True
return encoding

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

echo '--- source outline ---'
ast-grep outline dascore/io/netcdf/core.py || true

echo '--- source lines ---'
cat -n dascore/io/netcdf/core.py | sed -n '1,140p'
cat -n dascore/io/netcdf/core.py | sed -n '200,240p'

echo '--- relevant references ---'
rg -n --glob '!*.lock' --glob '!uv.lock' \
  'compression_opts|_get_write_encoding|to_netcdf|h5netcdf|netcdf4|complevel|chunksizes' .

echo '--- dependency/config files ---'
git ls-files | rg '(^|/)(pyproject\.toml|setup\.cfg|setup\.py|requirements[^/]*|.*lock|tox\.ini|environment[^/]*)$' | sort

Repository: DASDAE/dascore

Length of output: 13163


🏁 Script executed:

#!/bin/bash
set -eu

echo '--- focused NetCDF tests ---'
cat -n tests/test_io/test_netcdf/test_netcdf.py | sed -n '270,325p'
cat -n tests/test_io/test_netcdf/test_netcdf.py | sed -n '740,785p'
cat -n tests/test_io/test_netcdf/test_netcdf.py | sed -n '390,475p'

echo '--- project dependency constraints ---'
cat -n pyproject.toml | sed -n '1,110p'
cat -n environment.yml | sed -n '1,140p'

echo '--- IO option definitions and docs ---'
rg -n -C 4 \
  'compression_opts|compression=|chunks=|write\(' \
  dascore tests docs README.md 2>/dev/null | sed -n '1,260p'

Repository: DASDAE/dascore

Length of output: 36555


🏁 Script executed:

#!/bin/bash
set -eu

echo '--- IO API definitions ---'
ast-grep outline dascore/io/core.py || true
rg -n -C 8 'def write|compression_opts|compression|chunks' dascore/io dascore/*.py | sed -n '1,260p'

echo '--- lock/version files ---'
git ls-files | rg '(^|/)(uv\.lock|poetry\.lock|Pipfile\.lock|environment.*\.ya?ml|pyproject\.toml)$' | sort
if git ls-files --error-unmatch uv.lock >/dev/null 2>&1; then
  rg -n '(^name = "(xarray|h5netcdf|netCDF4)"$|^version = )' uv.lock | sed -n '1,120p'
fi

Repository: DASDAE/dascore

Length of output: 20069


🌐 Web query:

xarray Dataset.to_netcdf encoding zlib complevel h5netcdf compression_opts valid range gzip level documentation

💡 Result:

When writing a Dataset to a NetCDF file using the xarray to_netcdf method, you can specify compression settings via the encoding parameter [1][2]. The encoding argument is a nested dictionary where keys are variable names and values are dictionaries of specific encoding options for that variable [1][2]. Providing this dictionary causes xarray to ignore the original encoding of the variables [1][2]. Engine Support and Compression Parameters The h5netcdf engine, which is commonly used with xarray, supports two primary styles of compression encoding [1][2]: 1. NetCDF4-style (compatible with the netcdf4 library): Use zlib and complevel. Example: {"zlib": True, "complevel": 9} [1][2]. complevel is typically an integer from 0 to 9, where higher numbers generally indicate greater compression [3]. 2. h5py-style (h5py native): Use compression and compression_opts. Example: {"compression": "gzip", "compression_opts": 9} [1][2]. The compression parameter is set to "gzip" (or other available HDF5 compression filters), and compression_opts controls the filter parameters, such as the GZIP compression level (0-9) [3]. Important Considerations - Overriding Encoding: Using the encoding parameter completely replaces any existing encoding for the specified variables [1][2]. If you intend to retain existing settings like _FillValue, you must explicitly include them in your encoding dictionary [4]. - Compatibility: The h5netcdf engine is designed to handle both styles, providing flexibility when working with different HDF5/NetCDF workflows [1][2]. - Engine Defaults: Depending on your environment, the default engine may vary (e.g., netcdf4, h5netcdf, or scipy) [1][2]. You can verify or set the engine explicitly using the engine parameter in to_netcdf or manage the default order via xarray.set_options(netcdf_engine_order=...) [1][2]. For further details on specific compression levels or custom filters, refer to the underlying h5py documentation regarding HDF5 datasets [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
import ast
from pathlib import Path

path = Path("dascore/io/netcdf/core.py")
tree = ast.parse(path.read_text())
method = next(
    node for node in ast.walk(tree)
    if isinstance(node, ast.FunctionDef) and node.name == "_get_write_encoding"
)

# Inspect the method as data, then model only its literal branch assignments.
source = ast.get_source_segment(path.read_text(), method)
has_range_guard = any(
    isinstance(node, ast.Compare)
    and any(isinstance(op, (ast.Lt, ast.Gt, ast.LtE, ast.GtE)) for op in node.ops)
    for node in ast.walk(method)
)
has_no_gzip_guard = any(
    isinstance(node, ast.If)
    and isinstance(node.test, ast.Compare)
    and any(
        isinstance(comparator, ast.Constant)
        and comparator.value == "gzip"
        for comparator in node.test.comparators
    )
    for node in ast.walk(method)
)

def current_encoding(compression, compression_opts):
    encoding = {}
    if compression == "gzip":
        encoding.update(
            zlib=True,
            complevel=4 if compression_opts is None else compression_opts,
            shuffle=True,
        )
    return encoding

print(f"range_guard_present={has_range_guard}")
print(f"gzip_branch_present={has_no_gzip_guard}")
for compression, opts in [
    ("gzip", None),
    ("gzip", 0),
    ("gzip", 10),
    (None, 5),
    (False, 5),
]:
    print((compression, opts), current_encoding(compression, opts))
PY

Repository: DASDAE/dascore

Length of output: 415


Reject incompatible and out-of-range compression options.

When compression_opts is set without compression="gzip", raise ValueError instead of silently ignoring it. When gzip is selected, enforce the documented 1-9 range before creating encoding. Add boundary tests for None, 0, 10, and compression_opts without gzip.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/io/netcdf/core.py` around lines 86 - 98, The _get_write_encoding
method must reject compression_opts when compression is not "gzip", and validate
gzip compression_opts as an integer level from 1 through 9 before constructing
encoding. Preserve the default level of 4 when compression_opts is None, and add
boundary tests covering None, 0, 10, and options supplied without gzip.

Source: MCP tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

IO Work for reading/writing different formats patch related to Patch class

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant