Unit test coverage for cf.py - #7259
trexfeathers wants to merge 21 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #7259 +/- ##
==========================================
+ Coverage 90.42% 90.68% +0.25%
==========================================
Files 93 93
Lines 25816 25816
Branches 4796 4796
==========================================
+ Hits 23344 23411 +67
+ Misses 1693 1650 -43
+ Partials 779 755 -24 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Templating
This PR includes changes that may be worth sharing via templating. For each file listed below, please either:
- Action the suggestion via a pull request editing/adding the relevant file in the SciTools/.github
templates/directory. 1 - Raise an issue against the SciTools/.github repo for the above action if you really don't have 10mins spare right now. Include an assignee, to avoid it being forgotten.
- Dismiss the suggestion if the changes are not suitable for templating.
You will need to dismiss this review before this PR can be merged. Recommend the reviewer does this as their final action before merging, as this text will continually update as commits come in.
Template candidates
The following changed files are not currently templated, but their parent directories suggest they may be good candidates for a new template to be created:
Footnotes
-
Include this text in the PR body to avoid any notifications about applying the template changes back to the source repo!
@scitools-templating: please no update notification on: iris↩
| assert "temp" in cf_group.data_variables | ||
|
|
||
|
|
||
| class Test_translate__formula_terms_derived_bounds: |
There was a problem hiding this comment.
NOTE: I have not yet had time to validate this test class (formula terms are hard!).
There was a problem hiding this comment.
Done: a9d1ccc
I understand why the tests are written this way, and confirmed they provide 100% coverage.
BUT: I'm sorry to say that this area of the code is so difficult to understand that I've been unable to form a good model of what good 'conceptual coverage' should look like. I.e. the cases we would want to cover regardless of the chosen Iris implementation. I can pursue this further at the reviewer's request, but I can't judge myself whether it is worth it.
… multiple- cases. Extra tests needed for multiple cases only.
… once : moved to specific subclass tests.
…FVarWithDimensions.
Pp tests refactor for cf_reader_zarr branch
There was a problem hiding this comment.
Thanks for taking onboard the various tweaks which I proposed previously.
That obsoleted a lot of my pending review comments, which I've now removed.
So here, some remaining general points to think about.
I still need to take another look at the individual category tests + check I'm happy with what they do, so I might come back with some more small points.
Otherwise good!
| # | ||
| # This file is part of Iris and is released under the BSD license. | ||
| # See LICENSE in the root of the repository for full licensing details. | ||
| """Shared test catalog for CF variable identify() behaviour. |
There was a problem hiding this comment.
UPDATE Older comment, but I think it stands
I'm not really understanding this term "catalog" : I looked it up + I think the meaning is not very common usage,and rather ambiguous.
So AFAICT a "test catalog" can definitely be a thing, but I'm not sure about a "catalog test".
E.G. below, the IdentifyByAttributeCatalog docstring is
"""Catalog tests for CF variables identified via a single attribute."""
I'm not clear whether this means that it "catalogs tests", or that it "contains catalog tests".
I'm pretty sure it doesn't do the first one, and I'm still not terribly clear what the second one means -- is there such a thing as a "catalog test" + if so what is that?
Search responses only seem to riff on the idea of a "Test Catalog", which also clearly means different things in different places.
So, I'd suggest renaming this, and the classes in it.
Just something like "cf_utils" might make more sense?
I know when I've done this in the past, I've used the term "Mixin" for classes.
E.G. iris/tests/integration/fast_load/test_fast_load.py,
Mixin_FieldTest: # A mixin providing common facilities for fast-load testing
So, various of our older ones were called that, but to be fair that might only be because they originally used multiple inheritance (since unittest tests must all be in classess inheriting unittest.TestCase).
There was a problem hiding this comment.
Agreed. This was a hangover from an earlier implementation where there were a selection of tests for each module to import. Changed to Mixin.
|
|
||
| expected = {subject_name: self.CF_CLASS(subject_name, ref_subject)} | ||
| result = self.CF_CLASS.identify(vars_all) | ||
| assert expected == result |
There was a problem hiding this comment.
Tiny point
I find it more natural to always write "result == expected", read as "result equals expected".
It reads oddly to me the other way around.
I think this style can sometimes stem from translating unittest code, which goes "self.assertEqual(expected, result)" (though authors often missed this anyway!)
I suspect this was because the already-noisier syntax reads better when "expected" is a simple constant, e.g. assertEqual(1, my_long_named_thing)
There was a problem hiding this comment.
I don't think this is that small! I believe it's important for correct interpretation of PyTest output.
| ] | ||
|
|
||
|
|
||
| @pytest.fixture |
There was a problem hiding this comment.
Both of these fixtures just return a function : they don't establish a per-test context or build a fresh test object.
So I don't think making them fixtures is really useful.
Especially since most of the tests here are going to inherit from 'identify_catalogue' anyway.
So, I would just put these methods in there, and remove conftest.py altogether.
There was a problem hiding this comment.
Nice, this was another hangover from previous implementations.
Reworks the design spec after review. `iris.fileformats.cf` becomes a package rather than three flat sibling modules, split along the four kinds of thing already in `cf.py`. The programme grows to seven pull requests so the move lands on its own, carrying SciTools#7259's tests ahead of the rewrite they protect. Re-frames the two findings that had been credited to real data. The base64 `_FillValue` in the NOAA GFS store is legal Zarr but violates CF 2.5.1, so it is a malformation to warn about, not a design learning; what survives is that Zarr's required array `fill_value` and CF's optional `_FillValue` attribute are distinct concepts. Nested JSON attributes are genuine — the v3 spec permits an arbitrary JSON literal, CF has no equivalent, and two independent producers use them. Confirms ESA EOPF is Zarr v2 by reading the store: `zarr.json` 404s, `.zgroup` holds `{"zarr_format": 2}`. Its root group holds no arrays at all, which makes the empty-root-group design a necessity. Its bands carry correct flat CF attributes alongside a redundant `_eopf_attrs` copy. Settles the module layout, cross-reader testing (xarray costs exactly one conda package) and EOPF's role. Parks the CF-attribute-model question for its own discussion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 82e42faf-fdb4-4d78-b163-d85ad5cf32c8
* Add design spec for native Zarr I/O Specifies a six-PR programme adding native Zarr support to Iris: reading Zarr v2 and v3 stores, and writing v3. Alternates behaviour-preserving refactors with user-visible features so that no feature PR carries a large diff and every relocation is independently verifiable. The central move is a CFDataset abstraction separating CF attributes from storage properties, with one implementation per backend. cf.py, the loader and the saver are rewritten against that interface rather than against the netCDF4 Python API. Zarr behaviours that shape the design were verified against zarr-python 3.4.0 rather than read from documentation: v2 cannot carry dimension names, JSON attribute serialisation rejects all NumPy types and poisons the attribute mapping on failure, reads apply no masking or unpacking, and shard shape is exposed separately from chunk shape. Closes #6977, #6979, #6980 when the programme completes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 82e42faf-fdb4-4d78-b163-d85ad5cf32c8 * Ground the Zarr design in the real NOAA GFS store Cut the GFS forecast subset ahead of the dynamical.org endpoint closing on 30 September 2026, and fold what the real data revealed back into the spec. Two findings change the read design. The _FillValue attribute on every float32 data variable is the base64 string 'AAAAAAAA+H8=', a float64 NaN that matches neither JSON number syntax nor the array dtype, while the array's own fill_value property is correct; the array property is therefore authoritative and a non-numeric attribute warns rather than raises. Real CF attributes are also not always scalars: valid_time carries a nested JSON object, which netCDF cannot express and Iris has no handling for. Also corrects a misreading of test_nczarr.py. Its "nczarr"/"xarray" parametrisation names two netCDF-c URL modes, not the xarray package, which is not an Iris dependency. That makes the NCZarr xarray mode a v2 fixture generator for free, and makes cross-reader testing a dependency decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 82e42faf-fdb4-4d78-b163-d85ad5cf32c8 * Separate Zarr spec facts from publisher malformations Reworks the design spec after review. `iris.fileformats.cf` becomes a package rather than three flat sibling modules, split along the four kinds of thing already in `cf.py`. The programme grows to seven pull requests so the move lands on its own, carrying #7259's tests ahead of the rewrite they protect. Re-frames the two findings that had been credited to real data. The base64 `_FillValue` in the NOAA GFS store is legal Zarr but violates CF 2.5.1, so it is a malformation to warn about, not a design learning; what survives is that Zarr's required array `fill_value` and CF's optional `_FillValue` attribute are distinct concepts. Nested JSON attributes are genuine — the v3 spec permits an arbitrary JSON literal, CF has no equivalent, and two independent producers use them. Confirms ESA EOPF is Zarr v2 by reading the store: `zarr.json` 404s, `.zgroup` holds `{"zarr_format": 2}`. Its root group holds no arrays at all, which makes the empty-root-group design a necessity. Its bands carry correct flat CF attributes alongside a redundant `_eopf_attrs` copy. Settles the module layout, cross-reader testing (xarray costs exactly one conda package) and EOPF's role. Parks the CF-attribute-model question for its own discussion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 82e42faf-fdb4-4d78-b163-d85ad5cf32c8 * Link the spec to the parked attribute-model issue #7288 now holds the CF-attribute-model discussion that section 10 parks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 82e42faf-fdb4-4d78-b163-d85ad5cf32c8 * Record the specification-over-sample-file rule Iris implements published conventions, and the temptation when adding a new format backend is to design against whatever file is to hand. Two findings in the Zarr work were initially credited to "real data" when one was a publisher's CF violation and the other was already written down in the Zarr v3 specification. Adds a short section saying so, and tightens two existing paragraphs to partly pay for the lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 82e42faf-fdb4-4d78-b163-d85ad5cf32c8 * Give the Zarr spec a single progress record The document is meant to be living, but its state was scattered: the programme in section 5, settled decisions split between sections 3 and 10, the parked question in prose, the cut-down GFS fixture's location buried in section 7, and pull request and issue states nowhere at all. Adds section 12 as the one place state lives — pull requests, issues, open questions with their provisional answers, an append-only decision log, artefacts and document history — and a status block at the top that points to it. Section 10 keeps only the substantive parked question; its settled list moves to the decision log. Follows the convention the merge/concatenate spec set with its section 6: update the progress record as work lands, and track status nowhere else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 82e42faf-fdb4-4d78-b163-d85ad5cf32c8 * Frame the Zarr programme as a proof of concept The seven pull requests explore whether the design survives contact with the code, on a feature branch, at speed. Official pull requests follow later, so the sub-issues stay open and nothing here uses a GitHub closing keyword. Drops the `Closes` column from the progress table and replaces the issue-state table with one that says what each issue is *for* in this programme. Issue state lives on GitHub. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Confirm the proof-of-concept working rules Seven separate pull requests, so each step keeps its own CI signal and can be verified alone. Tests on every production change, because they are the proof in "proof of concept". Changelog fragments deferred to the official pull requests, stated in each pull request body so the omission does not read as an oversight. The one exception noted: the `_NCZARR_SCALAR_DIMENSION` gap in the three overriding `spans` methods is a user-visible bugfix and will need its own fragment when the official work is written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Retract the proof-of-concept framing; specify the merge-back This is the delivery plan, so the spec has to say how the work reaches users, not just how it reaches the feature branch. Adds the merge-back of `brownfield` into `main` as the terminal step, and gives it the three obligations the feature-branch pull requests defer: the closing keywords for the three sub-issues, five changelog fragments across `feature`, `dependency`, `deprecation`, `bugfix` and `internal`, and a documentation build against `main`. Both deferrals now have structural reasons rather than the retracted proof-of-concept one. A closing keyword only fires on merge into the default branch, so `Closes` on a `brownfield` pull request would link an issue and never close it. A changelog fragment is named for its pull request number, so one numbered for a feature-branch pull request would advertise a change `main` has not received — which is what the two pull requests already merged into `brownfield` do, carrying no fragment while #7267 into `main` does. Documentation moves into PRs 4 and 6, alongside the code it describes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Keep the CFUGrid variable classes with their peers The proposed cf package split the CFVariable subclasses across _variables.py and _ugrid.py. Nothing justified that seam beyond getting _variables.py under the ~1000-line aim in lib/iris/AGENTS.md, and the same file says cohesion wins over file size. The UGRID classes are peers of the classic ones: same shape, same job, listed in the same tuple in CFReader._variable_types, exposed as peer getters on CFGroup, and sharing the module-private _is_str_dtype helper that the split would have had to export across a module boundary. The cf package now splits three ways, not four, and _variables.py is allowed over the line aim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 82e42faf-fdb4-4d78-b163-d85ad5cf32c8 * Narrow the __getattr__ ban; make CFVariable backend-agnostic lib/iris/AGENTS.md banned __getattr__ outright except in deprecation shims. That overreaches: CF attributes are an open-ended set of data keys read from a file, with no schema to enumerate, which is a different thing from __getattr__ used to invent behaviour. The ban is replaced by a checkable exception - forward to a declared Mapping, and make that Mapping the path library code takes. The rules on computed names and getattr string dispatch are unchanged in substance; iris.fileformats has 42 constant-name lookups and zero dispatch sites, so they were never in tension. A clarifying clause says so, since the text was not landing that way. The spec was worse than the rule. Section 4.3 had CFVariable.__getattr__ raise TypeError for Zarr-backed variables, putting a hole in the most-used public accessor of the abstraction section 4.2 exists to provide. It now reads through .attributes on the base class for both backends, drops the setattr instance caching, and keeps a one-cycle deprecating fallback to the netCDF4 object for the proxy uses (.dimensions, .shape, .dtype, .getncattr) that CFDatasetVariable replaces. Also records the consequence PR 2 has to handle: cf_attrs_unused() feeds netcdf/loader.py:190 and is currently a side effect of __getattr__, so the read tracking has to move to .attributes with the reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 82e42faf-fdb4-4d78-b163-d85ad5cf32c8 * Endorse package re-exports instead of merely tolerating them lib/iris/AGENTS.md never said whether a package __init__.py may surface objects defined in private submodules. The omission read as disapproval - the nearest rule, "do not add indirection ... for a single caller", is about implementation layers, not API surface - and the Zarr spec duly wrote an apologetic paragraph explaining why cf/__init__.py had to break a rule it was not breaking. Surfacing low-level objects at the level users import from is a genuine convenience, and it is what leaves a private file layout free to change. iris.mesh already does it across four modules and two subtrees. The rule is now stated positively, with the conditions that keep it greppable: verbatim and static, gathered into __all__, no renaming on the way out, no conditional imports, no logic in __init__.py. iris.common's "import *" is marked as legacy rather than precedent. The indirection rule gains a clause saying what it does not cover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 82e42faf-fdb4-4d78-b163-d85ad5cf32c8 * Keep multi-process Zarr writing reachable Coordinated writing to one target from many processes is an Iris differentiator for netCDF and is wanted for Zarr by name. It stays out of scope for size, but the spec previously dismissed it in a single out-of-scope bullet deferring to "what zarr-python guarantees" - which is nothing. zarr 3.4.0 removed the v2 synchroniser machinery: create_array has no synchronizer parameter, open_group warns "synchronizer is not yet implemented", and ProcessSynchronizer and zarr.sync are gone. Any coordination will be Iris's own. Four provisions, none of which builds the feature: - _dask_locks.py moves to cf/ in PR 5. It imports only threading and four dask modules, no netCDF at all, so it is generic already and only its docstring says otherwise. - CFDatasetVariable.write_handle() becomes the coordination seam. netCDF returns today's lock-carrying NetCDFWriteProxy; Zarr returns the zarr.Array, which is picklable and round-trips shape and chunks. A future lock-carrying Zarr proxy needs no interface change. - The lock stays inside the dataset implementation; cf/saver.py holds no lock and knows no scheduler. - CFDataset carries mode from the start, because the Zarr shape of this feature is a region write into an existing store, which an interface whose only verbs are create_dimension and create_variable cannot express. Also fixes a live correctness gap the question exposed. da.store(..., lock=False) is only safe where the lazy source tiles the Zarr chunk grid exactly; two tasks sharing a chunk read-modify-write and silently lose an update. The chunk-alignment invariant is now written down, with create_variable deriving chunks from the source and the saver rechunking when a caller forces a conflicting chunksizes. Separates finalise() from close() so consolidating metadata is one-shot and owned by the returned Delayed, rather than raced by N workers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 82e42faf-fdb4-4d78-b163-d85ad5cf32c8 * Cover Zarr store durability and metadata/data desynchronisation The spec said nothing about what an interrupted save leaves behind. For a fresh store the hazard is absent by construction - metadata is written once at array creation and never edited - but that was accidental rather than stated, and save(..., group=...) writes into a store that may already exist, which is in current scope. Two format hazards confirmed against zarr 3.4.0. Removing one chunk object from a complete array reads back as fill_value with no warning, so an interrupted save leaves a store that opens cleanly and silently under-reports data. Adding an array to a consolidated store without re-consolidating makes it invisible to a default reader, which prefers consolidated metadata when present, while an unconsolidated reader of the same store sees it. Neither is fixable from inside Iris, so the design avoids creating the conditions instead: - The create-once invariant is now written down as a constraint on future work: never resize, rechunk, or change dtype, fill_value, codecs or dimension names of an existing array. - save takes mode, defaulting to "w-" - raise if the target exists. This differs from the netCDF saver's clobber deliberately, because a Zarr clobber is many non-atomic deletes. - Adding to a store that carries consolidated metadata must re-consolidate as its final act, or raise. - finalise() is always the last write. Write-to-temp-then-rename is rejected and the reasoning recorded: it is atomic on POSIX but object stores have no equivalent, so the guarantee would evaporate where Zarr is most used. Icechunk is named as the ecosystem's answer in both the spec and the planned documentation. The create-once invariant also carries most of the safety argument for the future parallel-write work, since workers that only fill pre-created chunks never touch metadata. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 82e42faf-fdb4-4d78-b163-d85ad5cf32c8 * Drop ZarrDataProxy; decode in the graph, align chunks on read @bjlittle asked whether the netCDF proxy classes are actually necessary for Zarr, given that chunks are inherent to the store structure. They are not, and the draft was wrong to mirror them. Every reason NetCDFDataProxy exists is a netCDF4/HDF5 reason: an unpicklable Variable, a thread-unsafe library forcing a global lock, and an open file handle that must not span a lazy graph. None holds for Zarr, where zarr.Array is picklable and a chunk is an independent object in a key-value store. Two of the draft's three justifications for a proxy were false on inspection; the third, the dask cache key, wants a hashable identity rather than a class, so as_lazy_data gains cache_key= and _lazy_data stops importing from fileformats.netcdf. CF decoding becomes an explicit dask graph layer rather than hidden behaviour inside __getitem__, which also makes each rule testable on a plain ndarray with no store. Adds a read-side chunk-alignment guard mirroring the write-side invariant. A Zarr chunk is the atomic unit of storage, and _optimum_chunksize shrinks below it when the store chunk already exceeds the 128 MiB dask target, so four tasks each fetch and decompress the same 488 MiB object. The same trap exists on the netCDF path, since NetCDFDataProxy reopens the Dataset per __getitem__ and discards HDF5's chunk cache; recorded as an open question rather than changed here. Also corrects PR 2's description, which still promised the TypeError seam that section 4.3 already rejected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 82e42faf-fdb4-4d78-b163-d85ad5cf32c8 * Cover the write proxy: no Zarr equivalent, but write_handle stays @bjlittle asked whether NetCDFWriteProxy exists purely to support multi-process writing, and so belongs entirely to future scope. It does not. Carrying the file lock is its second job; its first is to be a __setitem__ target that outlives the closed Dataset, which a deferred save needs even single-threaded, because a netCDF4.Variable is neither picklable nor able to outlive its Dataset. Neither job exists for Zarr. Verified end to end: a zarr.Array keeps writing after a pickle round trip, a deferred da.store lands after every in-process reference to the group is dropped, and the whole delayed graph survives pickle before computing, which is what a distributed scheduler requires. So write_handle() is justified by present netCDF need rather than by future-proofing, and the Zarr side returns the array itself. Records that native Zarr restores deferred saving, which the NCZarr path had to give up: Saver.__exit__ computes NCZarr writes eagerly because netCDF-c cannot reopen a Zarr store for deferred writes. PR 6 gains a test for this. Corrects the claim that library code instantiates NetCDFWriteProxy; it builds the EncodedNetCDFWriteProxy subclass. Also records a defect found while checking this, and deliberately not fixed here: da.store now returns a tuple rather than a Delayed for a sequence of sources, so iris.save(compute=False).compute() raises AttributeError against the documented contract. Every internal caller uses dask.compute, so no test covers it. Q5 in section 12.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 82e42faf-fdb4-4d78-b163-d85ad5cf32c8 * Link the deferred-save return-type defect to #7291 The cause is now known: #6451 adapted Iris to dask/dask#11844 for dask 2025.4, switching every internal caller to dask.compute() and relaxing the test assertion to accept either shape. The code change was right; the docstrings and the user manual were left promising a Delayed completed by result.compute(). Raised as #7291. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 82e42faf-fdb4-4d78-b163-d85ad5cf32c8 * Correct the collaborator attribution to @trexfeathers Every decision in the spec's log was agreed with GitHub user trexfeathers, not bjlittle. The two pair on this work, and the bjlittle account owns the fork the branch is pushed to, which is why the mistake was invisible. Only the @ mentions change. The `bjlittle/iris` and `bjlittle/iris-test-data` fork paths stay as they are: those are the git remotes in use and remain correct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 82e42faf-fdb4-4d78-b163-d85ad5cf32c8 * Align concurrent Zarr writes to the shard, not the chunk The invariant licensing da.store(..., lock=False) was stated over the chunk grid. That is insufficient when sharding is enabled: several inner chunks occupy one shard, and a shard is a single stored object, so tasks writing distinct chunks still read-modify-write the same object. Verified on zarr 3.4.0 / dask 2026.7.1 -- da.arange(128, chunks=8) into chunks=(8,), shards=(64,) tiles the chunk grid exactly and still lost 72-96 of 128 values in 12 of 12 trials. Aligning the source to the shard, or to a whole multiple of it, gives 0 of 12. Restated as the write-alignment invariant, over Array.shards when the array is sharded and Array.chunks otherwise. xarray guards the same unit via safe_chunks (pydata/xarray#10831); the earlier draft cited that precedent and then under-specified it. Raised by the OpenAI Codex review of #7292. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 45914679-20ef-4bcf-b7fe-c48ab76a3cef * Correct the Zarr fill-value rules: masking and encoding Two errors in one section, both of which would have produced wrong data. Masking took its sentinel from Array.fill_value in preference to the CF _FillValue attribute. But fill_value is a storage fact -- what an unwritten chunk reads back as -- not a declaration that a value is missing. zarr-python defaults it to zero, so an int16 array holding [0, 1, 2] with no CF attributes had its valid zero masked. And because the field is required on version 3, the "fall back to _FillValue only when the array carries no usable one" branch could never fire, so a genuine CF sentinel could never win. Both reproduced against a store written by xarray. The precedence is now version-aware, matching xarray's use_zarr_fill_value_as_mask. The base64 _FillValue was called a publisher's CF violation and a "file malformation to defend against". That was wrong. base64(struct.pack("<d", nan)) is exactly 'AAAAAAAA+H8=', and writing a float32 NaN with xarray produces that literal string. It is xarray's version 3 encoding, which most version 3 data in the wild will carry. Worse, writing the numeric form this spec specified makes the store unreadable by default xarray, which raises TypeError and fails the whole dataset open. Iris now reads both forms and writes base64 for floats: a deliberate, documented deviation from CF 2.5.1 for one attribute whose dtype is load-bearing, taken because interoperability is the reason for native Zarr. It does not reopen the base64-envelope rejection for general attributes. This was the "Conform to the Specification, Not to the Sample File" rule applied backwards -- crediting a convention to a publisher's slip. Sections 1 and 8 now name that inverse failure mode. Raised by the OpenAI Codex review of #7292. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 45914679-20ef-4bcf-b7fe-c48ab76a3cef * Write _FillValue when saving masked data, not just fill_value Taking CF masking off the storage fill value has a write-side consequence the saver did not meet. Masked arrays were stored as data.filled(fill_value) with the array's fill_value set to match, and nothing else. On version 3 the CF attribute is now the masking declaration and the storage field is not, so such a store reads back with every masked point silently unmasked -- a round trip that loses the mask. The saver must also write a _FillValue attribute in this case, even when the source cube carries none of its own. Setting fill_value alone is the write-side form of the same conflation the read-side precedence made. Found while applying the #7292 review, not raised by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 45914679-20ef-4bcf-b7fe-c48ab76a3cef * Separate the Zarr read unit from the write unit chunking() returned Array.shards when present, on the reasoning that "the shard is the unit a reader actually fetches". That is not how sharding works. Zarr's sharding codec is indexed: a reader fetches the shard index, then byte ranges for only the inner chunks it needs. Requiring shard-sized dask blocks destroys that, and does so because of the explicit decode layers this design introduced. Bare dask slicing can push a slice down into the array's own __getitem__, but a map_blocks layer in between cannot. On a shape=(1024,), chunks=(8,), shards=(1024,) int32 array, reading the first eight values took 2,084 bytes with inner-chunk blocks and 6,148 -- the whole shard -- with shard-sized blocks plus a decode layer. Without the decode layer both were 2,084, which is why the layer is the deciding factor. chunking() therefore returns Array.chunks, and read alignment aggregates upward into whole multiples of it, never down and never to a shard boundary. Shard-sized blocks remain a write requirement only. The NOAA GFS fixture is sharded, so this is exercised rather than hypothetical. Raised by the OpenAI Codex review of #7292. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 45914679-20ef-4bcf-b7fe-c48ab76a3cef * Key the Zarr lazy-data cache on array metadata, not its address The proposed cache_key of (store location, array path, zarr format) is an address, and iris._lazy_data.CACHE is a process-wide LRUCache(100), so the address goes stale the moment a store is rewritten in place -- which mode="w" supports and a notebook or long-running service does routinely. Reproduced against the real key shape: an array reopened after its shape changed from (4,) to (6,) returned the cached four values, silently truncating; and an array whose fill_value changed from -1 to -2, with chunks left unwritten, returned the old fill. Under the new masking rules that second case feeds the version 2 mask, so stale metadata becomes wrongly masked data. The address-only key would also have been weaker than the netCDF key it replaces -- repr(NetCDFDataProxy) already carries shape and dtype -- so the relocation would not have been behaviour-preserving, which was its premise. Now keyed on json.dumps(Array.metadata.to_dict(), sort_keys=True), which covers shape, chunks, shards, dtype, fill_value, codecs, dimension_names and attributes in one value. This narrows the window rather than closing it: metadata identity still cannot detect changed chunk contents under identical metadata, and neither can the netCDF key. Q6 records session scoping as the durable fix. Raised by the OpenAI Codex review of #7292. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 45914679-20ef-4bcf-b7fe-c48ab76a3cef * Record the #7292 review outcome: tests, questions and decisions Names the test for each accepted finding in section 6, and assigns them to the pull requests that must carry them (PR 4 for the read side, PR 6 for the write side). Every one of the five findings now has a test that fails without its fix. The cross-reader test that catches the _FillValue encoding error was already promised in section 6 -- "if xarray cannot read Iris output, the decision was wrong" -- and simply had not been written. It now has a specific case. Also adds Q6 (cache lifetime) and Q7 (taking the base64 deviation upstream to zarr-conventions/CF), the xarray backend and pydata/xarray#10831 references, the decision-log entry and the document-history row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 45914679-20ef-4bcf-b7fe-c48ab76a3cef * Restructure the Zarr spec for readability §4.4 and §4.5 held 617 of the document's 1815 lines between them with three subheadings, all three in §4.5. §4.4 had none: ten topic-level run-in bold lead-ins and nothing else, so an outline of the document stopped exactly where the implementable detail began. Both sections now carry `####` headings per topic. This also fixes a mis-nesting: `Encoding` was sitting under "Multi-process writes", which it has nothing to do with. Recast the design history from "an earlier draft said X, that was wrong" into the rule it implies: "do not do X, because Y". The content earns its place — CF §2.5.1 leads a fresh reader straight back to the numeric `_FillValue`, and mirroring NetCDFDataProxy is the obvious move for a Zarr reader — so it stays next to the rule it guards rather than moving to the decision log. Only the voice changes, from a fact about this document's history to a fact about the code. Refresh the header block: the last-updated date, and the dask and xarray versions that the [verified] claims added yesterday depend on. No normative content changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 45914679-20ef-4bcf-b7fe-c48ab76a3cef --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Martin Yeo <martin.yeo@metoffice.gov.uk>
|
Hello @trexfeathers, @pp-mo — a heads-up that this pull request's test suite has been carried into #7298, with thanks. #7298 is the first of seven pull requests implementing the native Zarr I/O spec; it turns The files are carried unmodified, apart from three One thing worth raising while it is in front of you, in def operation(warn: bool):
warnings.warn("emit at least 1 warning", category=iris.warnings.IrisUserWarning)
result = self.CF_CLASS.identify(vars_all, warn=warn)
# For multi-identity tests with string-typed vars, result may be empty
# For single-identity tests, result may be non-empty
Deliberately not changed in #7298: editing it there would mean silently altering your in-flight pull request, and would cost the carrying commit the one property that makes it reviewable — that the suite arrives byte-identical. So it is yours to take or leave. If you would rather it were fixed on the way through, say so and I will do it there instead. 🤖 Generated with Claude Code |
) * pre-commit does not need to be in a dedicated environment. Session-Id: 16168c01-abcd-41e8-a19e-87e22391c55b * Implementation plan for the cf package split The first of the seven pull requests in the native Zarr I/O design spec (docs/superpowers/specs/2026-09-21-zarr-io-design.md, section 5): land the unit test coverage from #7259 against today's cf.py, then split cf.py into the iris.fileformats.cf package, behind an __init__.py that re-exports every public name. Committed first so that the rest of the pull request has something to be read against. Part of #6977 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 16168c01-abcd-41e8-a19e-87e22391c55b * Unit test coverage for iris.fileformats.cf Squashed from #7259, branch cf_reader_zarr, applied to today's cf.py unchanged. Eighteen files under lib/iris/tests, +2354/-643, twelve of them new: - identify_mixins.py, holding the shared SpansMixin, IdentifyByAttributeMixin and IdentifyByAttributeListMixin bodies that every CFVariable subclass is then run against; - one test module per CF variable class, including the private _CFFormulaTermsVariable; - test_CFVariable.py for the base class, pinning the attribute-access and cf_attrs_* behaviour; - the three existing test_CFUGrid* modules rewritten onto the shared mixins, which is most of the deletions; - test_CFGroup.py and test_CFReader.py substantially extended; - tests/test_cf.py moved to tests/integration/, where a test that drives real files end to end belongs. Landing this before the package split is the point: the tests were written against today's behaviour by people who were not doing the rewrite, so the net that catches a regression in later pull requests did not move with the thing it is testing. The one non-test line rewrites an incoming TODO comment to cite the issue it turned into, #7296 - the guard it marks is dead code and is deliberately not fixed here. Part of #6977 Co-authored-by: Martin Yeo <martin.yeo@metoffice.gov.uk> Co-authored-by: Patrick Peglar <patrick.peglar@metoffice.gov.uk> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 16168c01-abcd-41e8-a19e-87e22391c55b * Move cf.py into a package, unchanged git mv lib/iris/fileformats/cf.py lib/iris/fileformats/cf/_variables.py, plus a new __init__.py that re-exports all sixteen public names verbatim into __all__. No code moved between modules yet: _variables.py still holds all three seams, and the cut into _group.py and _reader.py is the next commit. git records the move as R099 rather than R100 because the module docstring moved up to __init__.py, where the .. z_reference:: directive belongs now that the package is what users import. _variables.py takes a one-line placeholder, rewritten properly with the cut. Apart from that docstring, nothing in the 1721 lines changed - which is the point of keeping this commit separate. Six references in the incoming test suite named the flat module and had to be retargeted at iris.fileformats.cf._variables, because each resolves a private name or a module global that the re-export layer deliberately does not carry: - _NCZARR_SCALAR_DIMENSION and _CFFormulaTermsVariable, private constants and classes that are not public API; - the _thread_safe_nc and hh patch targets, which must name the module whose globals CFReader reads; - the CFBoundaryVariable patch in test_derived_bounds_boundary_guard_continue_branch. That one is the sharp case: patching the re-export instead would leave CFReader looking at the real class, so the test would pass without exercising the branch it exists for. Part of #6977 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 16168c01-abcd-41e8-a19e-87e22391c55b * Split the cf package into _variables, _group and _reader Cut the two lower seams out of _variables.py into modules of their own, leaving a one-way dependency chain _variables <- _group <- _reader, and redistribute the imports so each module takes only what it uses. The effect worth having is that iris.fileformats.netcdf is now imported by _reader.py alone. Classification and grouping no longer reference netCDF at all; they work against anything presenting netCDF-like variables, dimensions and attributes. That is the coupling the native Zarr work has to remove, and this puts all of it behind one import block in one file. No behaviour change and no API change. Every line of moved code is byte-identical to its former self - reconstructing _variables.py from the three files reproduces the original exactly - and the sixteen public names are re-exported from iris.fileformats.cf unchanged. The only new prose is the four module docstrings. Three mocker.patch targets in test_CFReader.py follow CFReader into _reader: a patch rebinds a module global, so it must name the module whose global the code under test actually reads. Adds test___init__.py, covering the package surface itself, which the inherited suite does not touch: that every public name a private module defines is re-exported, that every __all__ entry resolves, that only _reader is coupled to netcdf, that the package imports in a fresh interpreter from either side of the cf/netcdf cycle, and that private names stay private. Folds four corrections into the plan, each marked in place: reference_terms is patched with patch.dict rather than patch and so needed no retarget at all; the broken-target list was missing two entries and carrying a spurious one; the rename in the previous commit is R099 not R100; and Connectivity is not used by _group.py, that was a substring match on CFUGridConnectivityVariable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 16168c01-abcd-41e8-a19e-87e22391c55b * Keep the split invisible to Sphinx and to repr() The docs build is the one check that cannot be delegated to a hook, and it found two things everything else missed. The full suite, every pre-commit hook, the ruff --select F gate and all seven package-surface tests were green at the point these were still broken. Read the Docs sets fail_on_warning, and the split produced one: CFReader carries a "CFGroup = CFGroup" class attribute, so autodoc documents CFGroup both at module level and nested under CFReader. That was always true. What the split added is that CFGroup.__module__ stopped naming the module being documented, so Sphinx emitted a canonical registration for each and the two collided. A broken pull request, not a cosmetic complaint. Worse, and silent: reference_terms disappeared from the API page altogether. Autodoc documents module-level data only where the module's own source assigns it. An imported name produces no entry, no warning and no failure. __all__ does not help - that rescues classes, via a __module__ check a dict does not have. Both fixes are in __init__.py. Re-pointing __module__ at the package is not a Sphinx workaround: __module__ is what repr() prints and what pickle records, and before the split all sixteen names said iris.fileformats.cf. Leaving it pointing into the private layout was itself the behaviour change. Restoring it fixes the duplicate as a side effect and costs no documentation - the nested CFReader.CFGroup entry still renders. reference_terms is re-stated with its doc comment, which is the only fix short of turning on imported-members for every module in Iris. Two tests hold them, and _defined_public_names now reads the module source rather than vars(), so it sees data members too - the kind of name that had already slipped through once. Docs build now reports "build succeeded." with no warning count, and all sixteen public names render. Full suite unchanged against the baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 39f0a4a4-3696-4644-a61d-d88088b71c86 * Act on the independent review A clean-context adversarial review of the preceding four commits confirmed the motion is byte-faithful and the netCDF seam one-way, and found four things worth fixing. The __module__ rewrite is withdrawn. It was introduced to stop Sphinx describing CFGroup twice, and it did - but inspect resolves a class's source through __module__, so pointing it at the package sent inspect looking for each class body in __init__.py, where it is not. That broke getsource and silently removed the [source] link from all fifteen classes on the API page, with no warning in a -W build to say so. One silent docs defect had been traded for another. The duplicate is now suppressed where it arises, with :meta private: on the CFReader.CFGroup alias, and the classes report their defining module exactly as iris.mesh's re-exports already do. Two module docstrings said things the code does not do. _variables claimed nothing in it reads array data, but CFCoordinateVariable.identify fetches values when called with monotonic=True; the docstring now names that path and where it is reached from. _group described promoted as recording variables referenced by nothing, which matches neither promotion path; it now describes both. test_derived_bounds_boundary_guard_continue_branch asserted something true whether or not its branch fired, so it could not detect the patch-target drift it exists to guard against. It is now parametrised over both cases and asserts on promotion, the branch's only observable - verified by re-pointing the target at the re-export, which now fails the test where before it passed. Also: ARCHITECTURE.md no longer points at the deleted cf.py, and the fresh-interpreter import test grows a timeout, since an import deadlock is what it looks for and would otherwise hang the suite rather than fail it. identify_mixins.py is deliberately untouched. It is byte-identical to #7259's, and that is the property which makes the test-carrying commit reviewable; its dropped assertion belongs as a comment on that pull request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 16168c01-abcd-41e8-a19e-87e22391c55b * Record the re-export docs pitfalls in docs/AGENTS.md Three ways a re-export package loses documentation, two of them silently and none of them visible to the test suite. Found the hard way while splitting iris.fileformats.cf; PRs 2 through 7 of the Zarr I/O programme will each create another such package. Filed under docs/ rather than the root AGENTS.md, which already exceeds its own 200-line budget and delegates documentation rules here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 16168c01-abcd-41e8-a19e-87e22391c55b * Record PR 1 in the spec Now that #7298 exists, section 12.1 and the header Progress line can name it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Session-Id: 16168c01-abcd-41e8-a19e-87e22391c55b --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Patrick Peglar <patrick.peglar@metoffice.gov.uk>
Description
Ref #6977
Still lots left to do; pushing up as an advanced preview.
Disclosure: heavy use of Copilot for this one, but I have carefully reviewed the full output and made modifications where needed.
To-do
2026-08-27CFGroupcoverageCFReadercoverageCFVariablecoverageWhen I refer to full unit coverage I am talking about a dedicated test module delivering said coverage - instead of relying on incidental coverage that is accidentally provided by other unit tests.
Checklist
Important
The Iris core developers are here to help! If anything below is unclear, just post a comment asking for help 😊
(further reading)
Tip
Things you can trigger on this PR:
9999for this PR's number - to re-trigger the CLA check:https://cla-assistant.io/check/SciTools/iris?pullRequest=9999