Migration guide#
This guide helps downstream packages that depend on pyramids migrate across breaking changes. It is
organized by subpackage, and under each subpackage by the release that introduced the changes.
isinstance(x, NetCDF) keeps working throughout, so most code needs no change. Everything below either emits a
DeprecationWarning (discoverable with the command in the next section) or is a hard behavior change (called
out explicitly).
Finding what affects you (one command)#
Run your own test suite against the new pyramids with deprecation warnings turned into errors — this surfaces
every deprecated call site:
Or, in code under test:
This catches the deprecations; the hard behavior changes do not warn — search for them manually.
base#
unreleased#
abfs:// now means Azure Data Lake Storage Gen2, not Blob. It mapped to GDAL's /vsiaz/ (Blob) and now maps
to /vsiadls/ (Gen2), matching what abfs means everywhere else in the Azure and Hadoop ecosystems — it is the
Azure Blob File System driver, which is Gen2.
- If you used
abfs://against a Gen2 account, this is the fix: you were being routed to the Blob handler and now get the right one, with directory semantics. - If you used
abfs://against a flat Blob account, switch toaz://, which is unchanged and still names Blob. Credentials are the sameAZURE_STORAGE_*family either way. abfss://is accepted too, if you prefer to be explicit about TLS. There is noadls://scheme: the registered Azure Data Lake name isadl://(Gen1), which GDAL does not handle.- The canonical
abfss://<filesystem>@<account>.dfs.core.windows.net/<path>form is understood: the filesystem becomes the container and the account is dropped from the path, because GDAL takes it from configuration. The rewrite is deterministic — the same URL always yields the same/vsi*path — and if the URL names an account that disagrees with the configured one, aUserWarningsays which account will actually be read. SetAZURE_STORAGE_ACCOUNT(orAccountName=insideAZURE_STORAGE_CONNECTION_STRING) to match, or use the bareabfs://<filesystem>/<path>form.
/vsiadls/ is now recognised as a remote, network-backed path. Previously is_remote() and
is_network_backed() both returned False for a Gen2 path, so it was classified as a local file — opening still
worked, but anything reasoning about the path (credential handling, archive chaining) got the wrong answer.
Archive chaining now covers every network handler, including for raw /vsi* paths. A raster inside a zip
or tar is rewritten to /vsizip/<handler>/... for /vsiadls/, /vsioss/ (Alibaba), /vsiswift/ (OpenStack),
/vsihdfs/ and /vsiwebhdfs/, not only for S3, Google Cloud, Azure Blob and /vsicurl/. Four of those have no
URL scheme at all, so the raw /vsi* spelling is the only way to name them — paths already in /vsi* form now
go through the chaining too, where before they were returned untouched. If you were chaining by hand, the manual
prefix is no longer needed, and a hand-built /vsizip//vsioss/... still works unchanged.
Credentials are unchanged. /vsiadls/ reads the same AZURE_STORAGE_ACCOUNT / AZURE_STORAGE_ACCESS_KEY /
AZURE_STORAGE_SAS_TOKEN / AZURE_STORAGE_CONNECTION_STRING family as /vsiaz/, and honours
AZURE_NO_SIGN_REQUEST identically (verified: with it set both handlers skip the instance-metadata credential
lookup). CloudConfig gains an azure_no_sign_request=True flag so anonymous Azure reads have the same knob the
S3 side already had.
0.47.0#
Dataset.epsg no longer reports EPSG:4326 for a raster that has no CRS. It previously substituted WGS 84
whenever the projection was empty, so an ungeoreferenced grid claimed a georeference it did not have — and that
claim propagated into to_file, to_crs, bounds and alignment checks. It now returns None, matching GDAL,
rasterio and geopandas, none of which substitute a default.
What changed, and what did not:
- No CRS and no evidence (a GeoTIFF or ASCII grid with an empty projection) →
epsgisNone. Operations that genuinely need a CRS raiseCRSErrornaming the fix instead of proceeding. - A CRS with no EPSG authority (geostationary, spherical-earth GRIB) → unchanged by this release. Note that
epsgis not uniformlyNonehere:NetCDFreportsNonefor a geostationary grid (see #706), while a spherical-earth GRIB still resolves throughepsg_from_wkt's4326default. Readcrswhen you need the authoritative answer for such a grid. - A CF NetCDF with
degrees_east/degrees_northaxes and nogrid_mapping→ still4326. CF leaves the datum implicit for these and the whole ecosystem reads them as WGS 84, so this is a reading of the file's metadata rather than an assumption. Three things narrow it:- Only a coordinate variable's units are evidence — one that declares
axis, is named in acoordinatesattribute (a curvilinear grid's 2-D lat/lon), or carries a conventional coordinate name. A data variable indegrees_east(a wind direction, a solar angle) is not evidence. - A horizontal axis carrying a linear unit (
m,km) or plaindegrees/radvetoes it, so a projected, rotated-pole or geostationary grid is not relabelled on the strength of auxiliary lat/lon arrays. A vertical axis does not veto — its units say nothing about the horizontal frame. Vertical axes are recognised from CF'saxis: Z, falling back to conventional names (depth,lev, …) only for a file that declares no axes at all. - The grid's own extent must fit in the lon/lat range. A metre-scale grid is not lat/lon whatever its metadata says.
- Only a coordinate variable's units are evidence — one that declares
A CRS with no EPSG code now reports epsg is None too. Previously any projection the EPSG register does
not name — an orthographic or geostationary projection, a rotated pole, a spherical-earth GRIB GEOGCS — fell
back to 4326. That claimed WGS 84 for grids that are not WGS 84: an orthographic frame is not lat/lon at all,
and a spherical datum differs from the WGS 84 ellipsoid by up to ~20 km.
The CRS itself is unaffected — ds.crs still returns the WKT, crs_spec() falls back to it, and reprojection,
cropping and alignment all keep working. Only the code is absent, because there is not one. If your code reads
ds.epsg on such a grid, read ds.crs (or crs_spec(ds.epsg, ds.crs)) instead.
If you relied on the old default, set the CRS explicitly — dataset.epsg = <code> in process, or
gdal_edit.py -a_srs EPSG:<code> <file> on disk. To find affected code, look for .epsg used without a None
check, and for the dataset.epsg or dataset.crs idiom, which is now crs_spec(dataset.epsg, dataset.crs).
0.46.1#
import pyramids no longer configures logging. Previously, importing the package ran Config(), which
added a handler to the root logger, called root.setLevel(logging.INFO), pinned six third-party loggers to
WARNING, and printed Logging is configured. to stderr. A library reconfiguring the root logger silently
takes over logging for the whole host application, so that is gone.
| Change | Kind | What you do |
|---|---|---|
| Import no longer installs a console handler | behavior | Configure logging yourself, or ask pyramids for it |
Root logger untouched (no handler, no setLevel) |
behavior | Nothing — this is the fix |
Config / LoggerManager default level=INFO → None |
behavior | Pass a level to get console output |
Logging is configured. announce moved from INFO to DEBUG |
behavior | Nothing |
| Third-party loggers only quieted on request | behavior | Pass quiet_third_party=True to restore |
If you saw pyramids log lines and want them back, pick one:
# Option A — you own logging (recommended for applications)
import logging
logging.basicConfig(level=logging.INFO)
import pyramids # pyramids records propagate to your handler
# Option B — let pyramids install its coloured console handler
from pyramids.base.config import Config
Config(level="INFO")
Option B sets propagate = False on the pyramids logger, so records are emitted once by pyramids' own
handler rather than twice (once more through your root handler). Do not combine it with Option A expecting
both to print.
Option B is reversible: a bare Config() removes the handlers pyramids installed and restores propagation.
That matters for test suites — pytest's caplog attaches to the root logger, so while propagate is
False a caplog assertion on a pyramids.* logger captures nothing and passes vacuously. If a dependency
opts in on your behalf, call Config() to hand the namespace back.
If you relied on the third-party quieting, pass it explicitly:
Config(level="INFO", quiet_third_party=True) # pins fiona/rasterio/shapely/matplotlib/urllib3/osgeo to WARNING
Exception types on three dtype helpers changed to a descriptive ValueError, from the incidental error
that leaked out of an empty table lookup. Only affects code catching the old type:
| Call | Before | After |
|---|---|---|
numpy_to_gdal_dtype(<unmapped dtype>) |
IndexError |
ValueError |
gdal_to_numpy_dtype(gdal.GDT_Unknown) |
AttributeError |
ValueError |
gdal_to_ogr_dtype(<complex band>) |
TypeError |
ValueError |
dataset#
unreleased#
create_from_array is now from_array, and takes a GeoReference. Hard change, no deprecation alias — the
old name and the old flat keywords are gone. The same rename applies to UgridDataset.create_from_arrays ->
from_arrays (still plural: it takes three arrays, not one).
The four flat georeferencing keywords are replaced by one geo_ref argument, and driver_type is gone
entirely — the driver is now derived from the path extension:
# Before
Dataset.create_from_array(arr, geo=GEO, epsg=4326)
Dataset.create_from_array(arr, top_left_corner=(0, 10), cell_size=0.05)
Dataset.create_from_array(arr, driver_type="GTiff", path="out.tif")
Dataset.create(cell_size=0.05, rows=r, columns=c, dtype="float32", bands=1,
top_left_corner=(0, 10), epsg=4326)
Dataset.create_empty(rows, cols, geo=GEO, epsg=4326, driver_type="MEM")
UgridDataset.create_from_arrays(node_x, node_y, faces)
# After
from pyramids.dataset import Dataset, GeoReference
Dataset.from_array(arr, geo_ref=GeoReference(geo=GEO, epsg=4326))
Dataset.from_array(arr, geo_ref=GeoReference(top_left_corner=(0, 10), cell_size=0.05))
Dataset.from_array(arr, geo_ref=GeoReference(geo=GEO), path="out.tif") # driver from the extension
Dataset.create(rows=r, columns=c, dtype="float32", bands=1,
geo_ref=GeoReference(top_left_corner=(0, 10), cell_size=0.05, epsg=4326))
Dataset.create_empty(rows, cols, geo_ref=GeoReference(geo=GEO, epsg=4326))
UgridDataset.from_arrays(node_x, node_y, faces)
The module-level pyramids.netcdf.engines.variables.create_from_array is renamed from_array too. It carries
no underscore but is an engine internal — reach it through NetCDF.from_array, its public facade.
GeoReference is importable from pyramids.dataset, pyramids.netcdf and
pyramids.netcdf.array_options — all three resolve to the same class.
Why there is no alias. The signature and the name were both changing; keeping the old spelling working would
have meant migrating twice. Both failure modes are loud — the old name raises AttributeError, a flat keyword
raises TypeError — and because NetCDF.from_array now declares its real parameters instead of
*args, **kwargs, mypy catches both statically, which the old facade prevented.
driver_type removed from create_from_array and create_empty. path alone decides memory-vs-disk, and
the extension names the format, so the parameter could only agree with path or contradict it. path=None
gives an in-memory raster; path="out.tif" a GTiff; path="out.nc" a netCDF — the last of which was rejected
on both routes before: the default driver_type="MEM" by the .tif suffix check, and an explicit
driver_type="netcdf" by the unconditional COMPRESS=LZW the netCDF driver refuses. Removing the parameter
and gating that option is what makes it work. Creation options still require a path, which is the
invariant the old driver_type="GTiff" guard was really enforcing.
Formats that GDAL can only write by copy (.png, .jp2) now raise FileFormatNotSupportedError naming the
extension and the driver, instead of failing inside GDAL. An extension the driver catalog does not know still
raises DriverNotExistError.
merge_rasters and stack_bands now refuse write-by-copy-only destinations. Both have two internal
write paths and only one of them can produce such a format, so accepting it would make the destination's
legality depend on method (or on whether the inputs share a dtype) — the same defect as letting an
unrelated argument pick the format. Both take the stricter answer, which refuses .png, .jpg / .jpeg,
.jp2 / .j2k, .asc and .vrt. The one this costs is .asc, which merge_rasters could write
before through its z-order (method="first" / "last") path. Write a GTiff and convert it.
Dataset.to_file now accepts more extensions, and one of them can lose data. This method is untouched by
the rename, but it reads the same driver catalog, so correcting the catalog's extension rows widened what it
accepts. .tiff, .png, .jpg, .jpeg, .img, .jp2 and .j2k previously raised
DriverNotExistError and now write a
file. The sharp case is dtype. PNG and JPEG store 8-bit data, so a float32 raster written to .png is
converted to Byte — values clipped, fractional parts gone. GDAL reports that only as a RuntimeWarning, so
pyramids now raises it to a DtypeNarrowingWarning naming both dtypes and the driver. JPEG2000 is stricter
still: .jp2 / .j2k accept only their own dtypes and raise rather than converting, so a float32 raster
there warns and then fails with FailedToSaveError — write those from a uint8 / int16 source.
import warnings
from pyramids.errors import DtypeNarrowingWarning
warnings.simplefilter("error", DtypeNarrowingWarning) # make the conversion fail loudly
Note the deliberate asymmetry with the constructors: to_file("x.png") writes (PNG supports CreateCopy)
while from_array(path="x.png") raises FileFormatNotSupportedError (PNG does not support Create). Whether
a format is reachable depends on how the call writes, not only on the extension.
You can no longer name a driver whose extension the catalog does not list. This is the one capability the
driver_type removal costs. On main, driver_type="EHdr", path="out.bil" worked; now .bil raises
DriverNotExistError and there is no escape hatch. If you need a format the catalog does not carry, write a
GTiff (or build in memory) and convert, or open an issue asking for the extension to be added. Sibling spellings
of a catalogued format now resolve alike: .tiff as well as .tif, .nc4 as well as .nc, .jpg as well as
.jpeg, and .j2k as well as .jp2. Resolving is not the same as being writable by these constructors: all
four JPEG/JPEG2000 spellings resolve and are then refused with FileFormatNotSupportedError, because they are
write-by-copy-only formats. (.jpeg did not resolve on main at all — the catalog carried it with a leading
dot, so the lookup never matched.)
Dataset.create no longer requires a CRS. epsg was a required positional; omitting it raised
TypeError. It is now a field of geo_ref defaulting to 4326, so a call that says nothing about the
CRS silently stamps WGS 84 instead of refusing. from_array and create_empty always behaved this way —
only create changes, and it changes from "state the CRS" to "we assume WGS 84". Pass epsg explicitly,
or GeoReference(..., epsg=None) for a deliberately CRS-less raster.
Dataset.create's positional order changed, and its CRS handling widened. cell_size is gone from slot 0,
so rows and columns each shift up one — but geo_ref is required and keyword-only, so a ported positional call
fails loudly with TypeError rather than silently misreading its arguments. Separately, create used to take
epsg: int straight through sr_from_epsg; it now shares the helper the other constructors use, so
GeoReference(epsg=None) yields a raster with no CRS, and non-EPSG CRS strings are accepted.
create_empty is the only constructor where geo_ref is optional. A header-only allocation often does not
care where it sits, so omitting geo_ref — or passing one that carries no transform at all, such as
GeoReference(epsg=3857) — keeps the identity transform. A partially specified reference is not covered by
that convenience: a top_left_corner without a cell_size (or the reverse) raises, exactly as it does in
from_array and create, rather than silently discarding the half you supplied.
RasterLike and RasterBase changed shape. create_from_array is from_array on the
pyramids.base.protocols.RasterLike protocol and on the RasterBase ABC; bands_values is gone, and
variable_name moved down to NetCDF, where it actually applies. If you subclass RasterBase, rename your
override and adopt the new signature. RasterLike is @runtime_checkable, so this one bites at runtime too: an
isinstance(obj, RasterLike) guard on a stale duck type carrying create_from_array flips from True to
False and silently takes the else-branch, rather than raising anywhere you can see it.
pyramids.netcdf.array_options.GeoTransform is gone, with no re-export. It was a local alias for the plain
6-tuple, and pyramids.dataset.GeoTransform is a richer NamedTuple of the same shape but a different type, so
re-exporting that under the old name would have silently changed what the name meant. If you imported the alias
for annotation, use pyramids.base.georeference.GeoTransformTuple (the structural 6-tuple) or the
pyramids.dataset.GeoTransform NamedTuple — whichever you actually meant.
NetCDF.from_array returns a Container. It always did; the annotation said NetCDF. Container adds no
public API over NetCDF, so nothing changes at runtime — the type is just honest now.
create_overviews now refuses a plain VRT whose description is not a path. It raises OverviewTargetError
instead of returning normally. That is a new exception, importable as
from pyramids.errors import OverviewTargetError, which subclasses ValueError,
so an existing except ValueError around the call keeps catching it — catch the new type to tell "this dataset
can never work" apart from "these arguments were wrong". recreate_overviews raises it for the same shape, where
it previously reported a misleading ReadOnlyError advising a reopen that a handle with no path cannot perform —
or, when the VRT exposed no levels at all, returned normally with a UserWarning saying to call
create_overviews() first. That advice is now refused too, so a pipeline that called recreate_overviews()
defensively across a mix of handles and ignored the warning goes from a silent no-op to a raise.
A plain VRT owns no pixel storage, so GDAL can only write its overviews to an external .ovr
sidecar named after the dataset description — and when that description is not a path, the sidecar landed as a
file called literally .ovr in the process's working directory, attached to nothing, while the levels the handle
was already exposing were dropped. The call reported success and silently did neither thing it promised.
Three descriptions are refused: an empty one, a blank one, and any that begins with < once stripped — in
practice an inline VRT XML document passed to Dataset.read_file(...), which previously surfaced as a raw GDAL
RuntimeError naming a file whose name was the whole document. No real path starts with <.
These calls hand back such a handle, and so start raising:
Dataset.get_overview_dataset(...)— only its lazily described form, taken when the parent can be reopened by name. When the parent cannot be (afrom_arrayraster, for instance), the method materialises aMEMlevel instead, and that still builds.- anything wrapping a
gdal.Translate(..., format="VRT")orgdal.BuildVRT("", ...)result kept in memory. NetCDF.get_variable(...)when the classic view comes back in index space — an irregularly spaced coordinate defeats the geotransform guess, and the view is then wrapped in a plain pathless VRT. A view over a regular grid is not VRT-wrapped and is unaffected.Dataset.wrap_longitude()on a file-backed source, whose lazy roll is a plain pathless VRT. On an in-memory source the roll is aMEMraster and is unaffected. This one previously produced the damage above verbatim — a stray.ovrand no levels — so the refusal is a fix, not a regression.Dataset.to_zarr(..., overview_factors=[...])on any handle the refusal covers, since it builds the pyramid levels throughcreate_overviews. The target is checked pre-flight, so the call leaves no store behind, and before thecomputeargument, so a call that is alsocompute=Falsereports the refusal rather than theValueError. Withoutoverview_factorsthe refusal does not apply; a description-less VRT then fails where it always did, into_zarr's base-array write.
Write the view out first and build the overviews on the saved raster:
view.to_file("level.tif")
view.close() # on Windows an open handle keeps the parent file locked
saved = Dataset.read_file("level.tif", read_only=False)
saved.create_overviews(overview_levels=[2, 4])
Unaffected by the create_overviews refusal: warped VRTs keep their overviews in RAM and need no
sidecar. These produce one — Dataset.warped_view(...), Dataset.to_crs(...) in its warping form,
Dataset.crop(mask, touch=False) with a vector mask, and the lazy georeference / orthorectify forms.
(to_crs(..., maintain_alignment=True) and crop with a raster mask are different paths: both return a MEM
dataset, exempt as a non-VRT rather than as a warped one.)
Tests cover warped_view and to_crs; the others are exempt by the same root-element check. A VRT with a
real path (including under /vsimem/) names its sidecar after that path, and MEM rasters are not VRTs at all.
Warped VRTs are not exempt from the recreate_overviews refusal — see the next entry.
recreate_overviews now raises OverviewTargetError instead of ReadOnlyError when a VRT computes the
levels. GDAL reports every unwritable overview target with the same CPLE_NoWriteAccess it uses for a
genuinely read-only dataset, so the old message told callers to reopen with read_only=False whenever the write
was refused. For a level a VRT computes rather than stores, that advice cannot help and often cannot even be
followed. Two shapes are affected:
- a warped VRT — its levels land on
VRTWarpedRasterBands, which are never writable. A warped view taken from a writable parent fails identically, and a pathless view has no path to reopen.create_overviews()still builds a warped view's levels; only in-place regeneration is refused. - a plain VRT that inherits its levels from the source it wraps — the common
gdal.Translate(..., format="VRT")or mosaic over an already-overviewed raster. GDAL serves each such level from an implicit read-only VRT it builds for the level, not from the handle you opened, so the read-only dataset in its message is oneread_only=Falsecannot reach — holding the source open writable does not help either. Give the VRT its own sidecar withcreate_overviews(), or regenerate on the source raster instead.
The two are told apart by who owns the level: a level owned by a VRT is computed, one owned by a real raster
(the dataset itself for an internal overview, the .ovr GTiff for an external one) is stored.
ReadOnlyError is now raised only when a reopen is still worth trying — a stored level on a handle that is
itself open read-only. It is not a promise that reopening will succeed: a VRT serving an explicit
<Overview> owns a real, on-disk-writable .ovr, yet GDAL opens VRT sources read-only and refuses however the
parent was opened. What has changed is that a dataset already open for writing never gets told to reopen —
that shape now raises OverviewTargetError too, since the access mode demonstrably is not the blocker.
0.48.0#
recreate_overviews now rebuilds a band's levels in one cascading pass. Each deeper level is decimated from
the level above rather than from the full-resolution band, matching what create_overviews (BuildOverviews)
has always done — previously recreate_overviews rebuilt every level directly from the source. Nothing warns;
the call still succeeds.
Level 0 is unaffected. A level >= 1 keeps its previous values only where the resampling survives being applied twice:
nearest— unchanged everywhere; GDAL does not cascade it.average,rms— unchanged on a floating-point band with no no-data value. An integer band picks up per-level rounding of up to 1 DN, and a no-data gap changes the result regardless of dtype.cubic,cubicspline,bilinear,lanczos,gauss,mode— changed on ordinary data.modecan move by whole classes, because the mode of modes is not the mode.
There is no way to get the old per-level values back: no API rebuilds a deep level directly from the source any
more. If your pipeline depends on them, rebuild the levels you need yourself from
Dataset.read_array() and write them out.
0.47.0#
Operations that need a CRS now refuse instead of assuming one. Each raises CRSError naming the operation
and how to fix it, where previously the missing CRS was silently filled in with WGS 84:
Dataset.to_crs(...)— reprojection has no source frame to warp from.Dataset.align(...)— both the receiver and the reference must carry a CRS; two rasters that both reportepsg is Noneare no longer treated as matching.Dataset.crop(bbox=...)/NetCDF.crop(bbox=...)without an explicitepsg=.
A cube with no CRS no longer writes a fabricated one. Previously both writers recorded 4326 and a WGS 84
WKT, claiming a projection the data never had. Now:
to_zarrrecordsepsg: 0with an emptycrs_wkt. Readers should treat that pair as "no CRS";geobox_crs()does.to_netcdfomits both attributes entirely, since a NetCDF has no geobox slot to put a null in.
On read, a NetCDF's crs_wkt / epsg root attributes are adopted only when written beside the GeoTransform
the same writer emits, so a stray attribute in a third-party file no longer defines the CRS.
read_part(bbox=...) and point(...) changed their default CRS, for every raster. bbox_crs /
point_crs used to default to 4326, so an unqualified bbox or point was interpreted as lon/lat and
reprojected into the raster's CRS. They now default to None, meaning "already in the raster's own
coordinates", and nothing is transformed. On a raster that is not in EPSG:4326 this changes which pixel an
unqualified call reads.
- To keep the old behaviour, pass the CRS explicitly:
ds.read_part(bbox, bbox_crs=4326),ds.point(x, y, point_crs=4326). - To read in the raster's own coordinates — usually what you want — leave it out.
Neither method refuses a raster with no CRS, so a windowed read of an ungeoreferenced raster keeps working.
Passing an explicit bbox_crs / point_crs against a raster that has none raises CRSError, rather than being
ignored: there is no frame to transform into.
cli#
0.47.0#
pyramids calc refuses a first input with no CRS. The result cannot be georeferenced and pyramids will not
stamp a default; set a CRS on the input first. pyramids georeference is unaffected — its GCPs and --gcp-crs
replace the georeference wholesale.
netcdf#
0.37.0#
Introduces the Container / Variable type split plus a wave of API consolidation. Each change below shows
what changed and the before → after so you can update at a glance.
At a glance#
breaking* = only breaks exact-type checks; isinstance(x, NetCDF) still holds.
| Change | Kind | What you do |
|---|---|---|
read_file / get_variable return Container / Variable |
breaking* | use isinstance(x, NetCDF) |
subset() returns a NetCDF, not a Dataset |
breaking* | nothing unless you used type(x) is Dataset |
CFInfo is frozen |
breaking | dataclasses.replace(cf, ...) |
LabeledDataset.read_file(engine=...) validates engine |
breaking | pass a valid engine name |
NetCDF(gdal_dataset) direct construction |
deprecated | use read_file / get_variable |
get_variable_names() |
deprecated | variable_names property |
NetCDF.plot(colour=...) |
breaking | loose colour kwargs + axes=CoordinateSpec(...) |
MetaData / DimMetaData |
deprecated | ClassicDimMetadata / ClassicDimensionInfo |
kerchunk backend="kerchunk" |
deprecated | backend="legacy" |
_LabeledArray / _apply_unpack |
renamed | LabeledArray / apply_unpack |
Breaking changes (update required)#
1. Opening a store returns Container; extracting a variable returns Variable.
NetCDF is now a base class with two concrete subclasses. Both still pass isinstance(x, NetCDF), so only
exact-type checks break.
# Before — everything was a NetCDF
nc = NetCDF.read_file("cube.nc") # NetCDF
var = nc.get_variable("t") # NetCDF
type(nc) is NetCDF # True
# After
nc = NetCDF.read_file("cube.nc") # Container
var = nc.get_variable("t") # Variable
isinstance(nc, NetCDF) # True <- use this
type(nc) is NetCDF # False <- this no longer holds
2. subset() returns a NetCDF (a Variable), not a plain Dataset.
# Before
ds = nc.subset("t", time=0) # Dataset
# After
var = nc.subset("t", time=0) # Variable (a NetCDF); read_array()/crop()/sel() all still work
3. CFInfo is immutable (frozen). In-place assignment now raises dataclasses.FrozenInstanceError.
# Before
cf.<field> = new_value # mutated in place
# After
import dataclasses
cf = dataclasses.replace(cf, <field>=new_value)
4. LabeledDataset.read_file(engine=...) validates the engine. An unrecognised value now raises
ValueError instead of being silently ignored.
# Before
ds = LabeledDataset.read_file(store, engine="zar") # typo silently ignored
# After — valid: "zarr", "netcdf", "netcdf4", "hdf5", "h5netcdf", or None
ds = LabeledDataset.read_file(store, engine="zarr")
Deprecations (old still works, warns — update when convenient)#
5. Constructing the base NetCDF(...) directly is deprecated. Use the typed entry points.
# Before
nc = NetCDF(gdal_dataset)
# After
nc = NetCDF.read_file("cube.nc") # -> Container
var = nc.get_variable("t") # -> Variable
6. get_variable_names() -> the variable_names property.
7. NetCDF.plot mirrors Dataset.plot — colour= is gone; colour is loose kwargs and coordinates group
into axes=CoordinateSpec(...). The colour= parameter and both the ColorOpts dataclass and its British
alias ColourOpts have been removed from pyramids.netcdf. Pass the colour knobs (cmap, vmin, vmax,
robust, center, extend, levels, norm) as loose keyword arguments, and group the curvilinear-coordinate
parameters (coords, x_dim, y_dim) into the new frozen CoordinateSpec dataclass passed as axes=.
# Before
from pyramids.netcdf import ColourOpts
nc.plot("t2m", colour=ColourOpts(cmap="viridis", robust=True), x_dim="rlon", y_dim="rlat")
# After
from pyramids.netcdf import CoordinateSpec
nc.plot("t2m", cmap="viridis", robust=True, axes=CoordinateSpec(x_dim="rlon", y_dim="rlat"))
For colour-bar styling and data-style presets, pass the cleopatra bags exactly as Dataset.plot does —
colorbar=ColorBar(...) (or colorbar=False to hide it) and data_style=DataStyle(...).
8. Classic dimension models renamed.
# Before
from pyramids.netcdf.dimensions import MetaData, DimMetaData
# After
from pyramids.netcdf.dimensions import ClassicDimMetadata, ClassicDimensionInfo
9. kerchunk backend="kerchunk" -> backend="legacy".
# Before
nc.to_kerchunk("refs.json", backend="kerchunk")
# After
nc.to_kerchunk("refs.json", backend="legacy") # or omit backend= for the native default
Renames where the old name still works (no rush)#
10. Promoted internals (underscore aliases kept).
# Before
from pyramids.netcdf.labeled import _LabeledArray
from pyramids.netcdf._lazy import _apply_unpack
# After
from pyramids.netcdf import LabeledArray
from pyramids.netcdf._lazy import apply_unpack
Also: the private modules _kerchunk.py -> _kerchunk_facade.py and _kerchunk_native.py -> _kerchunk_builder.py
were renamed. They are internal — use NetCDF.to_kerchunk / NetCDF.combine_kerchunk rather than importing them.
New, opt-in capabilities (not breaking)#
- Typed dispatch: branch on
isinstance(x, Container)vsisinstance(x, Variable)instead of inspectingis_subset/band_count. Import frompyramids.netcdforpyramids.netcdf.variable. - Cloud read tuning:
CloudConfig(vsicurl_tuning=True, curl_cache_size=...)enables the fast single-file/vsicurl/read preset.
Naming note#
The new public types are named Container and Variable. They read cleanly when namespace-qualified
(pyramids.netcdf.Container). If you from pyramids.netcdf import Variable in a module that also uses a
labeled-array library, the name collides conceptually with that library's own Variable type — prefer the
namespace-qualified form, or alias on import:
Migration checklist#
- Pin the new
pyramidsversion in your dependencies. - Run your suite under
-W error::DeprecationWarningand fix everything it flags (items 5-10 above). - Fix the hard-behavior-change items (1-4) — they do not warn: search for
type(x) is NetCDF,type()/Datasetchecks onsubset()results,CFInfomutation, and unrecognisedLabeledDatasetengines. - Done —
isinstance(x, NetCDF)keeps working, so most code needs no change.