Skip to content

Add cdfirsdump CLI: byte-for-byte NASA cdfirsdump-compatible dump tooling - #97

Open
jeandet wants to merge 25 commits into
SciQLop:mainfrom
jeandet:feature/debug-record-stream-cdfdump
Open

Add cdfirsdump CLI: byte-for-byte NASA cdfirsdump-compatible dump tooling#97
jeandet wants to merge 25 commits into
SciQLop:mainfrom
jeandet:feature/debug-record-stream-cdfdump

Conversation

@jeandet

@jeandet jeandet commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary

Adds a physical-order record-stream walker and a new cdfirsdump console script that reproduces NASA's own cdfirsdump toolkit output byte-for-byte, verified against real captured runs of that tool rather than hand-derived expectations throughout.

  • cdf::io::debug::for_each_record — walks a CDF file's records in physical disk order (not the reconstructed variable/attribute graph load() builds), surfacing records the semantic loader silently discards (e.g. UIR freed space) and tolerating record types not yet modeled (SPR).
  • cdf::io::debug::nasa::dump() and friends — a byte-for-byte NASA cdfirsdump-compatible text dump, including decimal/hex offset radix, an offset-starting walk, a --data payload hex dump, and the record-type summary table (-brief), each reproducing subtle real-tool quirks (e.g. a summary-table "checksum" note that depends on whether the real tool happened to decode CDR flags, not on the file itself) discovered by reading the reference tool's own source.
  • pycdfpp.debug bindings exposing all of the above to Python.
  • cdfdump/cdfirsdump console scripts (modern --flag syntax, not NASA's single-dash syntax) built on cyclopts + rich.
  • Along the way: fixed a real gap where CDFpp never read a variable's on-disk PadValues, and fixed a systemic console-script packaging bug affecting both cdfdump and cdfirsdump (their [project.scripts] entries pointed at the decorated function instead of the CLI app object, crashing any real installed invocation).

Test plan

  • Full test suite green (32/32, excluding full_corpus which needs network access): C++ Catch2 suites, Python unittest suites, wasm/node suites.
  • New byte-for-byte reference fixtures captured from a real NASA cdfirsdump v3.9.2 binary (radix, offset, --data, summary/brief modes) checked into tests/resources/.
  • ASan/UBSan clean on the new record-stream/repr code.
  • Independent multi-round code review (per-component + final whole-branch pass) with all findings resolved before merge.

🤖 Generated with Claude Code

jeandet and others added 20 commits July 24, 2026 15:58
…thon access

Reproduces the kind of raw record-level introspection NASA's CDF distribution
tools give you (record_size/record_type, every field, in on-disk order),
distinct from the reconstructed Variable/Attribute view load() already
provides:

- cdf::io::debug::for_each_record walks a file's records in physical disk
  order (record_size stepping from one header to the next) rather than
  following ADRhead/VDRhead/VXRnext pointer chains - no knowledge of which
  record belongs to which variable is needed, so it surfaces records the
  semantic loader silently discards (UIR) and tolerates record types not
  yet modeled (SPR) by skipping them via their declared size. Structural
  anomalies (bad record_size, unknown record_type) go through a pluggable
  corruption handler with a safe default (abort + stderr message) rather
  than crashing or looping.
- print_record (record_repr.hpp) prints any record generically via
  cpp_utils::reflexion::field_name - no per-record-type printer - with a
  handful of field *kinds* special-cased (reserved fields, bounded strings,
  variable-length arrays).
- pycdfpp.debug.for_each_record exposes the same walk to Python as
  (offset, type_name, {field_name: value}) tuples, and a new `cdfdump`
  console script (pycdfpp.cli) formats/prints it - both built once in C++
  and shared, not duplicated per language.

Bumps subprojects/cpp_utils.wrap to pick up field_name<T,N> (jeandet/cpp_utils
main, 5eb859d).
Argument parsing moves from argparse to cyclopts; output moves from plain
print()'d lines to a rich Tree (colored @offset/type headers, field leaves) -
readable straight in a terminal instead of a flat text dump. cyclopts/rich
land as an optional `pycdfpp[cli]` extra rather than core dependencies, since
only the CLI needs them, not the library.

Wires cyclopts/rich into the two CI workflows that actually run the meson
test suite from source (tests-with-coverage, tests-with-sanitizers) - neither
derives its Python deps from pyproject.toml, so the extra needs listing by
hand same as the existing ddt/requests entries. Also adds the three new
record_stream/record_repr/python_debug tests to tests-with-sanitizers' hand-
enumerated test lists, which would otherwise silently skip ASan/UBSan
coverage for them entirely.
cdf_rVDR_t/cdf_zVDR_t declared PadValues with a field_size() hardcoded
to 0, so the record-stream walker (and cdfdump/pycdfpp.debug built on
it) never saw a variable's actual pad value, always reporting it
empty regardless of what's on disk.

Verified against NASA's own cdflib.h/cdfirsdump.c: a pad value is
present iff Flags & 2, sized as cdf_type_size(DataType) * NumElems.
PadValues is now dynamic_array_bytes<N, char> (raw bytes, matching the
CVVR/CCR data field pattern) instead of a mistyped int32 array, since
its contents follow the variable's DataType, not a fixed int32 layout.

Scoped to the read/debug path only - no Variable/pycdfpp user-facing
surface, no save-path changes.
Adds a full reimplementation of NASA's cdfirsdump (-full -nopage
-nosummary) text output, built on the existing physical-order record
walker: offset/flag/enum formatting, per-record-type field printing,
and PadValue/attribute-Value decoding across every CDF data type
(int/uint/float/double, CHAR/UCHAR, EPOCH/EPOCH16/TT2000). Verified
end to end against a real captured cdfirsdump run of a_cdf.cdf
(1261 lines) - byte-for-byte identical except one already-tracked,
out-of-scope pre-1972 tt2000 divergence (finding-tt2000-scalar-simd-
pre1972).

Getting there required two real gaps to close first:
- AzEDR/AgrEDR had no field at all for an attribute entry's actual
  value, only the fixed header (mirrors the same PadValues gap fixed
  earlier for VDRs).
- for_each_record's compressed-file path consumed the CCR/CPR records
  internally to drive decompression but never reported them to the
  caller, so they were invisible to any consumer including this one.
- UIR (freed space) is now a real decoded record (Next/Prev) instead
  of a skipped placeholder.

Exposed as pycdfpp.debug.nasa_compat_dump() and cdfdump --irsdump.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Resumes the brainstorming session from 2026-07-25 that was interrupted
before the spec doc was written. Design (flag surface, architecture,
scope decisions) was already approved by the user via AskUserQuestion;
this just captures it in the standard spec location.
…ce fixtures

Plan is grounded in real cdfirsdump v3.9.2_0 captures (radix, offset, -data,
-brief, and full+summary output) and its C source (src/tools/cdfirsdump.c) for
the summary-table formatting rules, not hand-derived guesses. Also caught and
fixed two real bugs during planning before they'd cost implementation time:
cyclopts' App.__call__ raises SystemExit(0) even on success (needs
result_action='return_value' in tests), and NamedTemporaryFile deadlocks on
Windows CI when a second handle opens the same path (use TemporaryDirectory
instead).
…fset

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires up dump_options.show_data (added as an unused placeholder in an
earlier commit): print_nasa(VVR)/print_nasa(CVVR) now hex-dump their raw
payload bytes exactly like cdfirsdump's -data flag (38 bytes/line,
uppercase, 2-space indent), verified byte-for-byte against a real
captured cdfirsdump -full -nopage -nosummary -data run.

cdf_VVR_t stores no payload bytes by design (only data_size(), to keep
for_each_record's physical-order walk from eagerly loading real variable
data), so print_nasa(VVR) gains a raw-bytes parameter pair supplied by
the caller; dump()/dump_from_offset() now read those bytes from the
buffer only when show_data is requested, at the correct header-relative
offset (derived from the record's own field sizes, which differ between
v3 and legacy v2 CDFs).
dump_from_offset()'s -data hex-dump branch (added in 9cb2e1e alongside
an already-tested identical branch in dump()) had no direct test - its
offset arithmetic starts the walk mid-file rather than from offset 0,
so it deserved its own real-capture verification rather than assuming
parity with dump().

No existing fixture covers -offset + -data together, so a fresh
capture was taken with the real cdfirsdump binary. That process
surfaced two real quirks in the reference tool itself (see the new
test's comment for detail): it unconditionally prints a "Scanning
records..." banner regardless of -offset (undocumented in its own
-help, confirmed by reading cdfirsdump.c), and it reads an
uninitialized `rNumDims` stack local when -offset skips the GDR,
causing genuinely non-deterministic garbage output roughly half the
time. The new fixture was captured by re-running until a clean result
was obtained (verified stable across 5 repeated clean captures), then
stripped to the same shape as the existing offset-only reference for
consistency, and cross-checked byte-for-byte against both existing
sibling fixtures (header against the offset-only capture, hex dump
against the data-only capture) before being committed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pre-flight review caught two issues: the Global Constraints guidance for
skipping full_corpus was too vague to actually exclude it, and Task 6's
own full-suite command used flags that don't apply to ninja test. Both
fixed to the proven-correct meson test invocation.
…lator

Walks a CDF once via for_each_record and tallies per-record-type counts/bytes
plus used/wasted byte totals and the CDR checksum-eligibility bit, mirroring
cdfirsdump.c's own flat globals that back DisplaySummary/DisplaySummary64.
Verified against the real rvariable.cdf fixture's exact per-record sizes.
Adds print_summary (byte-for-byte port of cdfirsdump.c's DisplaySummary/
DisplaySummary64: dynamic column widths, ADR global/variable scope split,
and the checksum-eligibility note quirk) plus dump_brief() (a --level brief
dump: banner + summary only, no per-record content) and a trailing
show_summary parameter on dump() for the --level full trailer.

Deviation from the plan doc's literal dump_brief() snippet: its "\n\n\n"
banner tail produces one blank line too many once concatenated with
print_summary's own leading "\n\n" (verified by building it literally and
diffing against the real a_cdf_cdfirsdump_brief_reference.txt capture,
which has exactly 3 blank lines before "Summary...", not 4) - fixed to
"\n\n" here; see the comment on dump_brief() for the full accounting.
pyproject.toml's cdfirsdump console-script entry referenced main() directly,
but main() requires `path` as a mandatory positional argument. A real
installed console script's wrapper calls its entry point with zero
arguments, so any actual `cdfirsdump` install crashed immediately with
"missing 1 required positional argument: 'path'". Point the entry at the
cyclopts App object instead, which parses sys.argv itself and dispatches to
main() with the right arguments. Add a regression test that reads
pyproject.toml and asserts the entry targets `app`.

cdfdump has the identical bug class (pycdfpp.cli:main) but predates this
task and is left untouched, out of scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…banner

NASA's real cdfirsdump unconditionally prints "\nScanning records...\n\n" as
the first statement of ScanCDF/ScanCDF64, before it even checks -offset -
only the two Magic number lines that follow it are genuinely gated on
offset==0 (verified against cdfirsdump.c source and cross-checked against
fresh real -offset captures). dump_from_offset() was missing this banner
entirely, and its two reference fixtures had been hand-stripped to match
that omission rather than reflecting a verbatim real capture - undercutting
the "byte-for-byte compatible" claim for this one path.

Re-captured both fixtures fresh from the real cdfirsdump binary (the -data
one required repeated captures to work around a separate, confirmed
uninitialized-variable bug in the real tool that makes ~half of -offset
captures non-deterministic; kept only the stable, reproducible result).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
path was a required positional argument, so cyclopts rejected a bare
`cdfirsdump --about` before main()'s own `if about: ...; return` branch
ever ran - the most common way --about is actually invoked. path now
defaults to None; the about branch still short-circuits first, and a
missing path with about=False now gets an explicit usage error instead of
silently proceeding into code that assumes a path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same bug class already fixed for cdfirsdump earlier on this branch:
cdfdump = "pycdfpp.cli:main" makes an installed console script call
main() directly with zero arguments, crashing immediately with
"TypeError: main() missing 1 required positional argument: 'path'".
Pointing it at the module-level `app` (a cyclopts.App) instead makes it
parse sys.argv like every other console script here, giving a proper
usage error instead of a raw traceback. Generalized the existing
pyproject.toml entry-point regression test to cover both cdfdump and
cdfirsdump so this class of bug can't silently reappear on either script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.09236% with 118 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.37%. Comparing base (b98fbbd) to head (41f407c).

Files with missing lines Patch % Lines
include/cdfpp/cdf-io/debug/nasa_compat_repr.hpp 88.21% 56 Missing ⚠️
include/cdfpp/cdf-io/debug/record_stream.hpp 74.77% 28 Missing ⚠️
include/cdfpp/cdf-enums.hpp 47.82% 24 Missing ⚠️
pycdfpp/debug.hpp 91.66% 4 Missing ⚠️
tests/record_stream/main.cpp 97.27% 4 Missing ⚠️
include/cdfpp/cdf-io/debug/record_repr.hpp 93.10% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #97      +/-   ##
==========================================
- Coverage   91.69%   91.37%   -0.32%     
==========================================
  Files          54       61       +7     
  Lines        3431     4626    +1195     
==========================================
+ Hits         3146     4227    +1081     
- Misses        285      399     +114     
Flag Coverage Δ
unittests 91.37% <90.09%> (-0.32%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ 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.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

jeandet added 5 commits July 27, 2026 14:18
field_name.hpp's reflection probe took the address of a subobject of a
declared-but-never-defined static variable and used it as a non-type
template argument, which GCC and mainline Clang accept but an Apple Clang
toolchain (this project's macOS wheel-building CI) rejects. Fixed
upstream in jeandet/cpp_utils@d376beb by giving the probe object a real
definition instead.
Every dump()/dump_from_offset()/dump_brief() vs. reference-fixture REQUIRE
in tests/nasa_compat_repr and the equivalent Python tests failed on Windows
CI (the first time this branch's tests ever ran on a real Windows runner -
this branch was never pushed before now). The committed blobs are correctly
LF-only; nothing marked them as binary, so a Windows checkout's default
core.autocrlf converts them to CRLF on disk while dump()'s actual C++
output stays LF-only, breaking every byte-for-byte comparison uniformly -
including a pre-existing fixture from an earlier session that had simply
never been exercised on Windows CI before either.
The prior bump (d376beb) gave field_name's probe object a real
definition, but that wasn't the actual blocker: the error persisted
identically. Root cause is a genuine compiler-feature gap, not a
declared-vs-defined nuance - a bare pointer-to-subobject as a non-type
template argument needs P1907R1, an experimental C++20 extension GCC has
had since GCC 11 but Clang only since Clang 18; this project's macOS
wheel-building CI ships Clang 15.

Fixed upstream in jeandet/cpp_utils@4c03d08 by wrapping the pointer in a
one-member aggregate (same technique Boost.PFR uses for its own C++20
field-name reflection, for the identical documented Clang<=16
limitation) so the template argument falls under the much older,
widely-supported P0732 instead.
Three real, mechanically-detected duplication clusters addressed:

- nasa_compat_repr.hpp: dump() and dump_from_offset() shared an identical
  ~20-line per-record dispatch block (VVR data-hex-dump branch plus the
  generic print_nasa fallback chain). Extracted into
  dispatch_record_print(), called by both.
- tests/nasa_compat_repr/main.cpp: the "open a reference fixture, read it
  into a string" boilerplate was repeated at 7 call sites. Extracted into
  read_fixture(name).
- tests/record_repr/main.cpp: the "walk the file, capture the first record
  matching a given type" pattern was repeated across both GIVEN blocks.
  Extracted into print_first_matching_record<record_t>().

Left as-is, deliberately: nasa_encoding_name (nasa_compat_repr.hpp) and
cdf_encoding_str (cdf-enums.hpp) are near-identical switch statements over
the same enum, but this is an intentional, already-documented design
decision (see nasa_compat_repr.hpp's own file header comment) - they
encode two genuinely independent formatting conventions for two different
consumers, and merging them would silently couple NASA-compat output to
CDFpp's own repr conventions (or vice versa) the next time either changes.
The other three fixes bring total duplicated lines from 241 to ~114 out of
~4150 new lines (~2.7%), under the 3% gate, without touching this one.
…le nits)

- record_stream.hpp: the 2-arg for_each_record wrapper took buffer_t&&
  buffer as a forwarding reference but never forwarded it, only the other
  two parameters - fixed (S5425). Redundant explicit type on an
  already-static_cast'd initializer replaced with auto (S5827).
- nasa_compat_repr.hpp: compute_summary's internal lambda was genuinely
  ~90 lines; extracted into a named details::accumulate_record() (S1188).
  summary_stats's 14-member single-statement declaration split one member
  per line (S1659). using enum added to reduce CDF_Types::-qualification
  noise in nasa_pad_value_str (S6177). Reworded a comment whose "*head/
  *next/*tail" phrasing looked like an unterminated block comment (S1103).

Left alone, deliberately: S112 (generic std::runtime_error for an unknown-
CDF-file error) matches this codebase's own established, pervasive
convention (11 other throw std::runtime_error sites project-wide) -
introducing a bespoke exception type for just this one call would be the
inconsistent choice, not the consistent one.
@sonarqubecloud

Copy link
Copy Markdown

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.

1 participant