Skip to content

Unit test coverage for cf.py - #7259

Draft
trexfeathers wants to merge 21 commits into
SciTools:mainfrom
trexfeathers:cf_reader_zarr
Draft

trexfeathers wants to merge 21 commits into
SciTools:mainfrom
trexfeathers:cf_reader_zarr

Conversation

@trexfeathers

@trexfeathers trexfeathers commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

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-27

  • Full unit CFGroup coverage
  • Full unit CFReader coverage
  • Full unit CFVariable coverage

When 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 😊


Tip

Things you can trigger on this PR:

  • Add this label to trigger benchmarks: benchmark_this Request that this pull request be benchmarked to check if it introduces performance shifts
  • Visit this URL - swapping 9999 for this PR's number - to re-trigger the CLA check:
    https://cla-assistant.io/check/SciTools/iris?pullRequest=9999

Comment thread lib/iris/tests/unit/fileformats/cf/conftest.py Outdated
@codecov

codecov Bot commented Aug 24, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.68%. Comparing base (cefe4b7) to head (9800816).
⚠️ Report is 4 commits behind head on main.

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.
📢 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.

@scitools-ci scitools-ci 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.

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

  1. 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:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

NOTE: I have not yet had time to validate this test class (formula terms are hard!).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Pp tests refactor for cf_reader_zarr branch

@pp-mo pp-mo left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

28cabf1

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

@pp-mo pp-mo Sep 4, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

f2414e5

I don't think this is that small! I believe it's important for correct interpretation of PyTest output.

]


@pytest.fixture

@pp-mo pp-mo Sep 4, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

9800816

Nice, this was another hangover from previous implementations.

@trexfeathers
trexfeathers requested a review from pp-mo September 4, 2026 13:00
@trexfeathers
trexfeathers marked this pull request as ready for review September 4, 2026 14:19
bjlittle added a commit to bjlittle/iris that referenced this pull request Sep 21, 2026
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
trexfeathers added a commit that referenced this pull request Sep 23, 2026
* 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>
@trexfeathers

Copy link
Copy Markdown
Contributor Author

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 iris.fileformats.cf into a package so that iris.fileformats.netcdf is imported by cf/_reader.py alone. Moving ~1700 lines is only reviewable if something pins the current behaviour first, and that something is your suite. It arrives as its own commit, applied to today's unchanged cf.py, before anything moves — so the move can be shown to change nothing.

The files are carried unmodified, apart from three mocker.patch targets that had to follow CFReader into cf/_reader.py (patching the re-export would leave those tests passing without exercising anything), and tests/test_cf.py moving to tests/integration/. No assertions were touched.

One thing worth raising while it is in front of you, in identify_mixins.py's test_warn:

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

result is bound and never asserted on, where the per-class test it generalises (test_CFUGridConnectivityVariable.TestIdentify.test_warn) asserted {} == result. Generalising into a shared mixin looks right, but the check seems to have been dropped rather than parametrised — and since the sentinel warnings.warn above it can satisfy the enclosing pytest.warns on its own, the gate is a little weaker than it reads.

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

trexfeathers added a commit that referenced this pull request Sep 24, 2026
)

* 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>
@stephenworsley
stephenworsley marked this pull request as draft September 30, 2026 09:25

This branch has not been deployed

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

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants