Skip to content

feat: Add RecordModel base and rename readers/writers to Model*#73

Open
msto wants to merge 3 commits into
mainfrom
msto/record-model
Open

feat: Add RecordModel base and rename readers/writers to Model*#73
msto wants to merge 3 commits into
mainfrom
msto/record-model

Conversation

@msto

@msto msto commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #10.

This PR extracts a base RecordModel with no default mixins, leaving Metric with the converter behaviors enabled by default in fgpyo.

The file IO classes are renamed MetricReader/MetricWriterModelReader/ModelWriter and now read/write any RecordModel. (We could add a thin alias, optionally with a deprecation warning, but I think it's cleaner to set the API now.)

Summary by CodeRabbit

  • New Features

    • Added RecordModel for plain tabular record parsing and serialization.
    • Added ModelReader and ModelWriter for streaming reads and writes.
    • Added configurable encoding, delimiter inference and overrides, and transparent gzip, bz2, and xz support.
    • Added safe write behavior that prevents overwriting existing files unless explicitly enabled.
  • Breaking Changes

    • Replaced MetricReader and MetricWriter with ModelReader and ModelWriter.
    • Metric.read() now eagerly returns a list and supports string paths and encoding options.

@msto msto self-assigned this Jun 16, 2026
@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces RecordModel as the base tabular model, renames MetricReader and MetricWriter to ModelReader and ModelWriter, updates Metric and counter serialization, and migrates documentation and tests to the new APIs.

Changes

Record model and serialization

Layer / File(s) Summary
RecordModel foundation and serialization contracts
fgmetric/record_model.py, fgmetric/metric.py, fgmetric/converters/..., tests/test_record_model.py
Adds eager RecordModel.read, alias-aware headers, plain empty-cell handling, and counter-specific header generation while preserving Metric-specific null-sentinel conversion.
ModelReader and ModelWriter APIs
fgmetric/model_reader.py, fgmetric/model_writer.py, fgmetric/__init__.py
Renames the reader/writer classes, updates model validation and dumping, adds shared encoding defaults, and exports the new public API.
Reader/writer migration validation
tests/test_model_reader.py, tests/test_model_writer.py, tests/test_collections.py, tests/test_compression.py, tests/test_roundtrip.py, tests/benchmarks/...
Migrates existing coverage to the new APIs, including delimiter inference, compression, encoding, permissions, overwrite behavior, resource ownership, and round trips.
Public documentation and release notes
README.md, CHANGELOG.md
Updates examples and terminology for ModelReader/ModelWriter and documents RecordModel and the revised I/O behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant ModelReader
  participant RecordModel
  participant ModelWriter
  Caller->>ModelReader: open(model_class, path)
  ModelReader->>RecordModel: validate each parsed row
  ModelReader-->>Caller: yield records
  Caller->>ModelWriter: open(model_class, path)
  ModelWriter->>RecordModel: serialize model fields
  ModelWriter-->>Caller: write delimited output
Loading

Possibly related PRs

  • fg-labs/fgmetric#35: Introduced the NullSentinels behavior layered onto the record model refactor.
  • fg-labs/fgmetric#41: Established the reader functionality renamed to ModelReader here.
  • fg-labs/fgmetric#44: Added the compression and encoding behavior used by the reader and writer APIs.

Suggested reviewers: clintval, ameynert, znorgaard

Poem

I hop through records, neat and light,
With headers lined in fields just right.
Reader streams and writers sing,
Gzip clouds on every wing.
New models bloom in tabular rows—
A bunny cheers wherever it goes!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding RecordModel and renaming the reader/writer classes.
Linked Issues check ✅ Passed The PR implements issue #10 by adding a mixin-free RecordModel base that provides default tabular parsing.
Out of Scope Changes check ✅ Passed The changes stay focused on the new base model, reader/writer rename, documentation, and tests with no obvious unrelated additions.
Docstring Coverage ✅ Passed Docstring coverage is 89.42% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

❤️ Share

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

@msto
msto requested review from ameynert, clintval and znorgaard and removed request for ameynert June 16, 2026 17:18
@msto msto assigned clintval, ameynert and znorgaard and unassigned msto Jun 16, 2026
@msto
msto marked this pull request as ready for review June 16, 2026 17:39
@msto msto linked an issue Jun 16, 2026 that may be closed by this pull request
Comment thread fgmetric/record_model.py
machinery; use `Metric` when you want the batteries-included behaviors. `ModelReader` and
`ModelWriter` operate on any `RecordModel` subclass.

Subclasses should define their fields using Pydantic field annotations.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion
Add a note to the docstring:

T | None fields do not round-trip None: it serializes to an empty cell and reads back as "". Use Metric (or a NullSentinels mixin) if you need None preserved."

Comment thread fgmetric/record_model.py Outdated
def read(
cls,
path: Path | str,
# NB: these defaults mirror `ModelReader.open()`; keep them in sync.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion
Could you add module-level constants for these defaults?

Comment thread CHANGELOG.md
- Introduce `MetricReader` for iterating metrics from any text-IO source (#41)
- Add an `encoding` kwarg on `MetricReader.open` and `MetricWriter.open` (#43)
- Add `RecordModel`, a mixin-free base class providing the default tabular read/write surface without the `fgmetric.converters` mixins; `Metric` now subclasses it (#10)
- Introduce `ModelReader` for iterating records from any text-IO source (#41)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion
Consider adding **Breaking:** prefixes where relevant.

Comment thread fgmetric/record_model.py
return [
info.serialization_alias or info.alias or name
for name, info in cls.model_fields.items()
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion
The _header_fieldnames method resolution order contract is enforced only by a comment. The NB block in _counter_pivot_table.py is excellent, but the invariant ("any header-overriding converter must inherit RecordModel and precede it in MRO, else super() resolves to BaseModel") is load-bearing and unguarded. If violated, super()._header_fieldnames() would raise AttributeError only when a writer is opened.

Proposed fix from Claude:

  1. fgmetric/record_model.py — split the default header into a @final helper

Replace the current _header_fieldnames with a thin overridable method that delegates to a non-overridable helper:

  from typing import final  # add to imports
  
      @classmethod
      def _header_fieldnames(cls) -> list[str]:
          """
          Return the fieldnames to use as a header row when writing records to a file.

          This method is used by `ModelWriter` to construct the underlying `csv.DictWriter`.
          It returns the fieldnames that will appear in serialized output, which may differ from
          the model's field names when aliases are used.

          Subclasses and mixins (e.g., `CounterPivotTable`) may override this method to adjust the
          header to match a custom serializer. Such overrides should build on
          `_default_header_fieldnames` (the plain field-per-column header) rather than `super()`,
          so they do not depend on their position in the model's MRO.

          Note:
              This method is deliberately not used during reading/validation; see
              `ModelReader` for the headerless-input path.

          Returns:
              The list of fieldnames to use as the header row.
          """
          # TODO: support returning the set of fields that would be constructed if the class has a
          # custom model serializer
          return cls._default_header_fieldnames()
  
      @final
      @classmethod
      def _default_header_fieldnames(cls) -> list[str]:
          """
          Return the plain field-per-column header: one column per field, in declaration order.

          Each field resolves to the key it serializes under (its alias, when one is set), so the
          header matches the keys produced by `model_dump(by_alias=True)`. This method is `@final`
          so header-rewriting mixins can call it directly instead of routing through `super()`,
          which would otherwise depend on MRO ordering.
          """
          return [
              info.serialization_alias or info.alias or name
              for name, info in cls.model_fields.items()
          ]
  1. fgmetric/converters/_counter_pivot_table.py — call the helper, not super()

In the override, change one line:

default_header = cls._default_header_fieldnames()

And trim the now-stale half of the class-level NB comment. It currently says the inheritance exists so it can "call super() to reuse the default … header" and that a future override "must … appear before it in the model's MRO (so super() resolves to the base header, not BaseModel)." After the fix that ordering trap is gone — replace with:

  # NB: This mixin inherits `RecordModel` rather than `BaseModel` (unlike the other converters) so
  # that `_default_header_fieldnames` is in scope for its `_header_fieldnames` override. It is the
  # only converter that rewrites the on-disk column set. Any future converter that also overrides
  # `_header_fieldnames` should build on `_default_header_fieldnames` (not `super()`) so the result
  # does not depend on MRO ordering.

@ameynert ameynert assigned msto and unassigned ameynert Jun 17, 2026
Base automatically changed from msto/null-sentinels-mixin to main June 24, 2026 23:32
@znorgaard
znorgaard removed their request for review July 9, 2026 16:12
@znorgaard znorgaard removed their assignment Jul 9, 2026
msto and others added 3 commits July 13, 2026 11:42
Extract a mixin-free RecordModel base out of Metric (closes #10), and rename MetricReader/MetricWriter to ModelReader/ModelWriter so the IO classes operate on any RecordModel. Metric is behaviorally unchanged: it subclasses RecordModel plus the same converter mixins in the same MRO order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to code review: consolidate CounterPivotTable._header_fieldnames onto a single _counter_fieldname guard (matching the file's assert-narrowing idiom) and document the MRO/inheritance contract for future header-overriding converters. Add a README section introducing RecordModel and when to use it over Metric.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split `RecordModel._header_fieldnames` into a thin overridable method plus a
`@final` `_default_header_fieldnames` helper, so header-rewriting mixins
(`CounterPivotTable`) build on the helper rather than `super()` and no longer
depend on MRO ordering; the old MRO contract was enforced only by a comment.

Also document that `T | None` fields do not round-trip `None` on the mixin-free
base, extract the shared read-path `DEFAULT_ENCODING`, correct the misleading
"mirrors ModelReader.open" comment on `read` (its "\t" delimiter default does not
infer from the file extension), and mark the breaking CHANGELOG entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@msto
msto force-pushed the msto/record-model branch from 6679915 to cf49b31 Compare July 13, 2026 20:19

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
fgmetric/model_writer.py (1)

13-13: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Guard the RecordModel import like model_reader.py does.

This import is only used for the [T: RecordModel] generic bound and docstrings, not at runtime. fgmetric/model_reader.py defers the identical import (apparently behind TYPE_CHECKING), since PEP 695 type-parameter bounds are lazily evaluated and don't need the name available at class-definition time. Making this import unconditional here is inconsistent with that sibling file and sets up a latent circular-import trap if RecordModel ever grows a symmetric write() classmethod (mirroring its existing read() classmethod that already imports ModelReader).

♻️ Proposed fix
-from fgmetric.record_model import RecordModel
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from fgmetric.record_model import RecordModel
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fgmetric/model_writer.py` at line 13, Guard the RecordModel import in
model_writer.py using the same TYPE_CHECKING pattern as model_reader.py. Keep
RecordModel available for the generic bound and docstrings during static
analysis, while preventing the unconditional runtime import and preserving
existing writer behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@fgmetric/model_writer.py`:
- Line 13: Guard the RecordModel import in model_writer.py using the same
TYPE_CHECKING pattern as model_reader.py. Keep RecordModel available for the
generic bound and docstrings during static analysis, while preventing the
unconditional runtime import and preserving existing writer behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 503fcaa7-5747-4fb4-bc05-8bed9d082bb8

📥 Commits

Reviewing files that changed from the base of the PR and between 0ec0357 and cf49b31.

📒 Files selected for processing (16)
  • CHANGELOG.md
  • README.md
  • fgmetric/__init__.py
  • fgmetric/converters/_counter_pivot_table.py
  • fgmetric/metric.py
  • fgmetric/model_reader.py
  • fgmetric/model_writer.py
  • fgmetric/record_model.py
  • tests/benchmarks/test_benchmark_runtime.py
  • tests/test_collections.py
  • tests/test_compression.py
  • tests/test_metric.py
  • tests/test_model_reader.py
  • tests/test_model_writer.py
  • tests/test_record_model.py
  • tests/test_roundtrip.py

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a BaseMetric without the default mixins

4 participants