Skip to content

Format classes should hold their parsed record, not inherit their parser #415

Open

Description

@balbasty

Problem

About 45 concrete io format classes inherit a parser class that holds state. The parser's fields and methods then become part of the format class's own constructor, repr, replace and attributes. That is roughly 22 transformation classes and 23 image classes, built on 13 parser bases. The worst case is NIfTI.

  • NiftiReaderWriter reaches about 16 transformation classes and NiftiImage. It adds image and _header as constructor fields, and it caches the voxel array in _data.
  • Others that leak:
    • MghReaderWriter (4 fields and 8 properties);
    • X5TransformReaderWriter (header, nodes, chain, position, and an open file);
    • FlirtMatrixReader (Affine._data is turned into a ClassVar);
    • ZarrReader (node);
    • the AFNI, MRtrix, NRRD and MINC parsers (_header and dataobj; most also cache data in _data).
  • Leak methods only: ArrayReader, the ITK TFM/MAT readers and Hdf5Reader. These are fine as they are.
  • Already compose: LtaTransformation holds struct: LtaStruct and delegates through from_struct/to_struct. ElastixTransform holds parameter_map. M3zMorph holds an M3zStruct but still inherits its reader.

Proposal

Each format class holds its parsed record instead of inheriting the parser. It delegates only the entry points the record itself implements. The existing adapter chain (load → from_file → from_bytes/from_lines, and the FileWriter mirror) does the rest:

@classmethod
def from_bytes(cls, content, **kw):
    return cls.from_raw(*read_fmt(content), **kw)

def to_bytes(self, **kw):
    return write_fmt(self.to_raw(), self.data, **kw)

Each format also delegates its sniffing method, and NIfTI keeps from_file/from_fileobj because nibabel needs a filename or a file object.

There is no generic wrapper (mixin, decorator or ClassVar). The delegation for NIfTI is written once, on NiftiBasedTransformation and on NiftiImage.

Priority:

  1. NIfTI, which removes the _data collisions and the three workarounds;
  2. X5 and M3Z, which already have a record;
  3. MGH, AFNI, MRtrix, NRRD and MINC;
  4. Zarr.

FLIRT waits until it is decided whether moving/reference belong to the format or to the parser.

Relation to #233 (format metadata)

The metadata design (#306, #310, #287) gives FileBasedMetadata a format-private raw record. That record is decoded into the common fields on read and encoded over on write, with a change-detecting snapshot. This issue proposes that metadata.raw be the record's only home:

  • A format object holds metadata (whose raw is the record) and a lazily loaded data, plus its own data-model fields. It holds no parser state.
  • Read: the reader returns (raw, data_proxy). The geometry is decoded from raw into the data-model fields, metadata = FmtMetadata.from_raw(raw), and data = data_proxy.
  • Write: metadata.update_raw(...) gives the base record, the geometry is encoded from the data model over it, and the data model's data is written. The data stored in the record is never written.
  • Historical names stay as read accessors (header, keyval, struct), as the memo already plans.
  • This also removes the base-order trap the memo describes, where NIfTI transformations must redeclare metadata: NiftiMetadataField because their first base carries the generic field.

Open questions

  • Name. raw, with types <Fmt>Raw, is the name the memo chose in section 6.1, where it rejected struct. Proposed: keep raw.
  • Split metadata from data on the raw side. Proposed: yes. The raw record is copied with the metadata through operations (M9), so it must not carry voxel or field arrays: copying them is costly, and after a resample they would be out of date. The "full" parse result is only the temporary (raw, data_proxy) pair returned by the reader. Small parameters (an LTA matrix) may stay in the record, but the data model is what gets written. Large arrays (NIfTI, M3Z and X5 fields) are split out.
  • Order of work. This issue first, with Wire format metadata into images and transformations (#233) #287/Format-metadata framework: vocabulary, formats and conversions (without wiring) #310 rebased on it, or the reverse.

Survey details (file:line for each class) are in the session that produced this issue.

Activity

  1. balbasty commented on Oct 9, 2026

    @balbasty
    ContributorAuthor

    Template: LtaTransformation (io/transformations/freesurfer/lta/_xforms.py). It is the maintainer's reference design for this pattern, so the proposal above is adjusted to follow it.

    • Names: struct, <Fmt>Struct, from_struct, to_struct. These replace raw. Section 6.1 of the metadata memo (Design memo for the format-metadata framework #306) needs revisiting, since it rejected struct.
    • The struct is the source of truth, and the data-model fields read it unless they have been set. input, output and data are properties that fall back to self.struct. _input, _output and _data are override slots, filled only when a value is set.
    • to_struct() returns the struct itself when nothing was overridden. A file that is read and saved again is then unchanged byte for byte. Otherwise to_struct() builds a new struct, taking from the current one whatever was not overridden. "Was it overridden?" does the job of the metadata memo's snapshot comparison, and needs no snapshot.
    • Reading goes through the struct, which is itself the reader and writer. from_lines(...) returns cls.from_struct(LtaStruct.from_lines(...)), from_struct(s, **kw) returns cls(struct=s, **kw), and the sniffing method delegates to the struct.
    • Writing goes through to_struct(). to_file, to_lines and to_text return self.to_struct().to_*(...).

    Effect on the open questions:

    • Where the struct lives. It stays on the format, as LTA does. Metadata would follow the same rule as the data-model fields: a metadata property decoded from the struct, an override slot, and to_struct() encoding the metadata only when it was overridden. How metadata that was decoded from a struct travels through operations still has to be settled with Design: generic metadata representation and cross-format metadata conversion #233.
    • Splitting data from metadata. For correctness, the split is no longer needed. A struct can hold a lazy handle to the data (a NIfTI image whose dataobj is a proxy), because data falls back to that handle and a value set on _data is written instead. What Design: generic metadata representation and cross-format metadata conversion #233 carries across operations should still be the header part only.
    • Things LTA does not have yet, to add when the pattern spreads:
      • a from_any that goes through the struct (LTA reaches load through the mixin);
      • a way to avoid declaring struct last with reverse=False.

    Generated by Claude Code

  2. balbasty commented on Oct 9, 2026

    @balbasty
    ContributorAuthor

    Decisions from the maintainer, which change some of the points above:

    • Name: raw. LtaStruct becomes LtaRaw, and the methods are from_raw/to_raw. The structure follows LTA: raw is a field on the format, the data-model fields read it unless they have been set (override slots), and to_raw() returns the stored raw when nothing was overridden.
    • No historical names or accessors. header, struct, keyval, image and the like are removed rather than kept as aliases. Breaking changes are fine.
    • The reader returns only the raw record, so reading only the metadata from disk never reads the data. The raw record gives access to its data without keeping it:
      • it has an uncached handle to the data (data_proxy, or a get_data() method), backed by the file;
      • the format's public data is a view decoded from that handle, unless it was overridden. The view can reshape the on-disk data before exposing it, for example by dropping the singleton time axis of a NIfTI field.

    Implementation notes:

    • Caching. The handle is not cached, so the format's data view caches the decoded array, as a smartproperty cleared when raw changes. Without that, every access would read the file again.
    • Writing is symmetric. to_raw() puts data back into the raw record in the on-disk layout (for a NIfTI field, it adds the time axis back), and the raw writer writes it. A raw record therefore holds either a file-backed handle (when read) or an array (when built). A raw record built in memory with no data has None.
    • File lifetime. When content was read from bytes or from a file object, the handle has to keep that content, or a way to reach it again, alive. nibabel's ArrayProxy already handles this for NIfTI and MGH.

    Generated by Claude Code

  3. balbasty commented on Oct 9, 2026

    @balbasty
    ContributorAuthor

    Decided design

    This comment replaces the earlier proposals in this thread where they differ.

    Layers

    Layer Classes Role
    Model API Metadata, Image, Transformation In-memory objects. Image and Transformation hold a Metadata.
    Dispatchers MetadataFormat, ImageFormat, TransformationFormat Find the file format to read or write.
    Format metadata <X>Metadata(MetadataFormat) Holds <X>Raw, the header record. Its I/O methods call <X>Raw's methods.
    Format object <X>Image(ImageFormat, SingleScaleImage), <X>Transformation(...) Holds a <X>Metadata and a raw data object. Its I/O methods call <X>Metadata's methods, plus its own code to build the data object.

    No format class inherits a parser.

    Data

    • raw is the only stored array. On a format object, raw is the data as stored in the file. It is usually a lazy proxy, and an array once data has been assigned. The data object comes from one of several proxy types: nibabel's ArrayProxy, the existing HDF5 proxy, Zarr arrays, and an offset/compression proxy for raw binary blocks.
    • data is a cached view of raw. It is decoded by a pair of pure functions, one per format: data = to_model(raw) and raw = to_disk(data). Setting data stores to_disk(value) in raw and clears the cache. This is the same rule as in Refactor(datamodel): transformations store data + encoding flags; field/matrix/scale are map views #302: one stored array, and views decoded from it.
    • Only exact, invertible changes go in the view: permuting axes, squeezing or adding singleton axes (the time axis of NIfTI fields), reshaping, and memory order. Intensity scaling is not one of them, because the proxy and the writer options handle it.
    • Writing. If raw is still the proxy, the file's data is copied out without decoding, so an untouched object is saved byte for byte. Otherwise raw is written.
    • Small data may live in the header record, for example an affine matrix, or a file that is cheaper to parse in one pass. On write, the model's data always takes precedence over the copy in the record.

    Precedence on write

    1. The base record comes from metadata.to_raw(). Only the metadata fields that differ from a fresh decode of the record are encoded over it. This replaces the stored snapshot of the Design memo for the format-metadata framework #306 memo.
    2. Geometry is encoded from the model over that record. The image or transformation owns its geometry and data, the metadata owns its vocabulary, and the record supplies everything else.
    3. The header is written, then the data.

    Rules

    • Raw records are never changed in place. Encoding copies them first.
    • <X>Metadata holds no file handles. The format object builds the data proxy from the source and the record, which says where the data is. Metadata therefore stays cheap to copy and can be pickled.
    • Formats that need almost nothing get a degenerate stack: OpaqueMetadata with no record, or a record that is just the parsed array (ITK TFM, matrix files).
    • Old names are not kept. header, struct, keyval, image and the parser mixins are removed.

    Implementation

    The work goes in passes, each reviewed by Fable:

    1. Core and the NIfTI slice.
    2. LTA, so the reference format matches the design, then X5 and M3Z.
    3. MGH, AFNI, MRtrix, NRRD and MINC.
    4. Zarr and FLIRT.
    5. The Design: generic metadata representation and cross-format metadata conversion #233 vocabulary (Design memo for the format-metadata framework #306, Format-metadata framework: vocabulary, formats and conversions (without wiring) #310, Wire format metadata into images and transformations (#233) #287).

    The design PR records problems, edge cases and hacks as they are found. No PR is merged by the agent.


    Generated by Claude Code

  4. 75 remaining items

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions