Repository navigation
Read every ROOT string type as plain bytes with ROOTString (#68, #20) - #127
Conversation
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #127 +/- ##
==========================================
+ Coverage 92.51% 92.77% +0.25%
==========================================
Files 37 37
Lines 2994 3017 +23
==========================================
+ Hits 2770 2799 +29
+ Misses 224 218 -6 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
@nsmith- i reviewed this and think it is ready to merge. if i understand it correctly, it is just converting RString to a regular byte string instead of its own sub class of root serializable. |
f9434b1 to
acfd9c6
Compare
acfd9c6 to
f3829c4
Compare
…ith-#127 Assisted-by: claude-code:claude-opus-5-5
…ith-#127 Assisted-by: claude-code:claude-opus-5-5
…ith-#127 Assisted-by: claude-code:claude-opus-5-5
|
@nsmith- i finished reviewing this and i think it makes sense. |
…ventions The strings-as-bytes rule and "keep what is on disk" conflict when a string's class (TString vs std::string) is known only from a class tag that plain bytes drop, e.g. a string read through a pointer (nsmith-#127): the value can then not be written back as read. The rules now say the ROOT type must stay recoverable from the annotation or the enclosing record, or the loss must be documented. Setup: CI installs with `uv sync` from uv.lock, and pre-commit is not in the dev group, so `pre-commit install` failed after the documented install. The pip route stays as the fallback. Also: test_spec_cases.py skips without the submodule too; nox runs both checks; the naming rule is for new test modules; new issues are sub-issues of nsmith-#66 or nsmith-#9; nsmith-#67's direction for dynamic.py; the Uninterpreted default for missing classes (nsmith-#74) and checksummed model names; and Assisted-by replaces tools' default attribution trailers. Assisted-by: claude-code:claude-opus-5-5
Since this is the omnibus PR for string handling, two fixes for
Related to the round-trip question in this PR: once it's decided whether a string held through a pointer keeps its class ( Assisted-by: claude-code:claude-opus-5-5 |
|
ok, i reworked this PR a bit now that I understand how to use Claude code. i can iterate much faster now, and taught it some rules about how to behave correctly in this repo. ill let it reply to you. |
A std::string data member is the TString encoding inside the usual byte count and version word (root-io-spec Conventions §5.3). The frame is not a fourth string encoding, so: - StringEncoding is back to the three length formats Nick listed on nsmith-#127: "RNTuple", "TString", "charstar". - ROOTString(encoding, framed=False). With framed=True it reads the frame with StreamHeader, as StdVector and the other framed members do, and checks the byte count, instead of parsing the frame itself with a second copy of kByteCountMask. - read_string() is gone. ROOTString.read(buffer) is the one string reader: the member reader calls it, and TKey and TList call it for their hand-read strings. - read_value() takes type[MemberType] | type[ROOTSerializable], as Nick suggested, and goes through _build_read for both kinds of type. - docs/design.md states the design assumption Nick raised: the bytes don't record their encoding; the annotation, key class name or class tag that holds them does, and writing a string back needs it. - test_tdatime_record is dropped: the TDatime record reads as a side effect of read_value, but it belongs to nsmith-#123, not nsmith-#68. Assisted-by: claude-code:claude-opus-5-5
"read_mupdate_membersembers" was a garbled "update_members", as Nick noted on nsmith-#127. Assisted-by: claude-code:claude-opus-5-5
Thanks, both are in
On the round trip: the same section now states the design assumption (a591ca1). The bytes don't record which encoding they came from; the member's annotation, the key's class name or the streamed object's class tag does. So a |
…ith-#127 Assisted-by: claude-code:claude-opus-5-5
…ith-#127 Assisted-by: claude-code:claude-opus-5-5
The design note claimed a streamed object's class tag keeps a string's type, like an annotation or a key's class name does. It doesn't: read_streamed_item reads the tag to look the type up and then returns bare bytes. Members, container elements and keyed records keep the type; a string read through a pointer can't be written back as read. Tracked in nsmith-#135, as Nick noted on nsmith-#127 and in nsmith-#134's AGENTS.md. Assisted-by: claude-code:claude-opus-5-5
docs/design.md promises that where a builtin Python type captures a ROOT
type, the deserialized member is that builtin: bytes for strings. RNTuple
strings were RString wrappers instead.
Add CountedString, a MemberSerDe for a string stored as its length (in a
given struct format) followed by that many bytes, read as plain bytes. An
RNTuple string is Annotated[bytes, CountedString("<I")], exported from
rntuple.schema as RNTupleString, and the eight RNTuple string members (the
header's name, description and library; a field's name, type name, type
alias and description; extra type information's type name) use it.
RString is removed.
This is the RNTuple row of nsmith-#68. The TString, std::string and char* rows
stay open there; char* (nsmith-#20) can use CountedString(">i").
Part of nsmith-#68
Assisted-by: claude-code:claude-opus-5-5
Name the module after what it tests, so it does not read as the home of a broad set of tests (review on nsmith-#125). Assisted-by: claude-code:claude-opus-5-5
Assisted-by: claude-code:claude-opus-5-5
…smith-#68, nsmith-#20) Replace CountedString with ROOTString(encoding), one Literal value per on-disk form (root-io-spec Conventions §5), as suggested in review: - "RNTuple": u32 little-endian length, then the bytes - "TString": the counted string, one length byte or 255 then a u32 - "std::string": a std::string data member, the counted string inside a byte count and version word (§5.3); the byte count is checked - "charstar": a char* member, i32 length then the bytes, no 255 escape; a length <= 0 reads as b"" (§5.4, nsmith-#20) TString is now the alias Annotated[bytes, ROOTString("TString")], so every TString member is bytes and .fString is gone. STLString is removed. read_string(buffer, encoding) reads one string by hand (TKey, TList options). Generated classes write the annotation out: TStreamerString emits ROOTString('TString'), TStreamerSTLstring ROOTString('std::string'), a kCharStar basic type ROOTString('charstar'), and cpptype maps string, std::string and TString elements of containers and pointees to ROOTString('TString'). A string stored as an object of its own (a key, or through a pointer) is looked up by class name, so TString, TStringLong and string stay resolvable by name, and serializable.read_value reads either a class or an annotated builtin. TStringLong is the "charstar" encoding (§5.1.1). As a result, root-io-spec's serialization/stringlong.root now reads, and serialization/unframed-records.root reads its TDatime, TString and TStringLong records and stops at the TObject record (nsmith-#123). Assisted-by: claude-code:claude-opus-5-5
A std::string data member is the TString encoding inside the usual byte count and version word (root-io-spec Conventions §5.3). The frame is not a fourth string encoding, so: - StringEncoding is back to the three length formats Nick listed on nsmith-#127: "RNTuple", "TString", "charstar". - ROOTString(encoding, framed=False). With framed=True it reads the frame with StreamHeader, as StdVector and the other framed members do, and checks the byte count, instead of parsing the frame itself with a second copy of kByteCountMask. - read_string() is gone. ROOTString.read(buffer) is the one string reader: the member reader calls it, and TKey and TList call it for their hand-read strings. - read_value() takes type[MemberType] | type[ROOTSerializable], as Nick suggested, and goes through _build_read for both kinds of type. - docs/design.md states the design assumption Nick raised: the bytes don't record their encoding; the annotation, key class name or class tag that holds them does, and writing a string back needs it. - test_tdatime_record is dropped: the TDatime record reads as a side effect of read_value, but it belongs to nsmith-#123, not nsmith-#68. Assisted-by: claude-code:claude-opus-5-5
TypedTKey.read stores the looked-up type as objtype, and after nsmith-#68 a lookup of "TString", "TStringLong" or "string" returns an annotated alias, not a class. read_object called objtype.read() on it and raised AttributeError, so a TypedTKey of a string record could not be read, although fetching the same key untyped returned its bytes. Both branches now read through read_value. Test: the "tstring" record of root-io-spec serialization/unframed-records through TypedTKey reads b"hello". Assisted-by: claude-code:claude-opus-5-5
"read_mupdate_membersembers" was a garbled "update_members", as Nick noted on nsmith-#127. Assisted-by: claude-code:claude-opus-5-5
The design note claimed a streamed object's class tag keeps a string's type, like an annotation or a key's class name does. It doesn't: read_streamed_item reads the tag to look the type up and then returns bare bytes. Members, container elements and keyed records keep the type; a string read through a pointer can't be written back as read. Tracked in nsmith-#135, as Nick noted on nsmith-#127 and in nsmith-#134's AGENTS.md. Assisted-by: claude-code:claude-opus-5-5
…mith-#128 nsmith-#128's tests index key.fClassName.fString; after nsmith-#68 the class name is plain bytes. Assisted-by: claude-code:claude-opus-5-5
8598e58 to
7846ed2
Compare
…ith-#127 Assisted-by: claude-code:claude-opus-5-5
…ith-#127 Assisted-by: claude-code:claude-opus-5-5
…#127 nsmith-#127 made key class names plain bytes. Assisted-by: claude-code:claude-opus-5-5
…ith-#127 Assisted-by: claude-code:claude-opus-5-5
…ith-#127 Assisted-by: claude-code:claude-opus-5-5
…ventions The strings-as-bytes rule and "keep what is on disk" conflict when a string's class (TString vs std::string) is known only from a class tag that plain bytes drop, e.g. a string read through a pointer (nsmith-#127): the value can then not be written back as read. The rules now say the ROOT type must stay recoverable from the annotation or the enclosing record, or the loss must be documented. Setup: CI installs with `uv sync` from uv.lock, and pre-commit is not in the dev group, so `pre-commit install` failed after the documented install. The pip route stays as the fallback. Also: test_spec_cases.py skips without the submodule too; nox runs both checks; the naming rule is for new test modules; new issues are sub-issues of nsmith-#66 or nsmith-#9; nsmith-#67's direction for dynamic.py; the Uninterpreted default for missing classes (nsmith-#74) and checksummed model names; and Assisted-by replaces tools' default attribution trailers. Assisted-by: claude-code:claude-opus-5-5
…ve decoding Rewrite the builtin-types rule after nsmith-#127's review: a value may be an annotated builtin only inside a context that records its ROOT type and encoding (a member's annotation, a container's element type, a TKey's fClassName, or a pointee's Ref, which is to keep the stream header per nsmith-#105). Decoding must be injective on every accepted input, so inputs that would decode to the same value are rejected or kept, not merged. Assisted-by: claude-code:claude-opus-5-5
* Add AGENTS.md: how to work on rootfilespec, for agents and people What the package is and its layout, the design rules from docs/design.md and recent reviews (locators, strings as bytes, keep what is on disk, don't guess), root-io-spec as the format reference, setup with the non-recursive submodule, the checks CI runs (pre-commit's pinned tools, and mypy's minimal environment), how tests are written, the shape of issues, commits and PRs, and the AI-attribution convention. CLAUDE.md imports it, since Claude Code reads CLAUDE.md. Assisted-by: claude-code:claude-opus-5-5 * AGENTS.md: reconcile the string and on-disk rules, fix setup, add conventions The strings-as-bytes rule and "keep what is on disk" conflict when a string's class (TString vs std::string) is known only from a class tag that plain bytes drop, e.g. a string read through a pointer (#127): the value can then not be written back as read. The rules now say the ROOT type must stay recoverable from the annotation or the enclosing record, or the loss must be documented. Setup: CI installs with `uv sync` from uv.lock, and pre-commit is not in the dev group, so `pre-commit install` failed after the documented install. The pip route stays as the fallback. Also: test_spec_cases.py skips without the submodule too; nox runs both checks; the naming rule is for new test modules; new issues are sub-issues of #66 or #9; #67's direction for dynamic.py; the Uninterpreted default for missing classes (#74) and checksummed model names; and Assisted-by replaces tools' default attribution trailers. Assisted-by: claude-code:claude-opus-5-5 * AGENTS.md: match the repository as it is today - Setup: there is no uv.lock (it is gitignored), and CI installs the test group (`uv sync --group test`); dev is that group plus mypy. - The Uninterpreted rule (#74) and checksum-named models (#22, under #67) are open issues: today a missing class raises "Unknown type ...", and a generated class has the bare class name. Say they are planned. - Tests: test_compressed_size.py (from #130) also uses root-io-spec fixtures and fails without the submodule instead of skipping; say to check it out first, and that a test that doesn't skip is a bug. - case.toml files are under gen/cases/ and gen/written/. - "No I/O" is about the parsing code; reader.py does read files. - Sub-issues: only where a tracker fits (the RNTuple issues fit neither #66 nor #9). Assisted-by: claude-code:claude-opus-5-5 * AGENTS.md: state where builtin types are allowed, and require injective decoding Rewrite the builtin-types rule after #127's review: a value may be an annotated builtin only inside a context that records its ROOT type and encoding (a member's annotation, a container's element type, a TKey's fClassName, or a pointee's Ref, which is to keep the stream header per #105). Decoding must be injective on every accepted input, so inputs that would decode to the same value are rejected or kept, not merged. Assisted-by: claude-code:claude-opus-5-5 * Move the design rules into docs/design.md, keep a summary in AGENTS.md AGENTS.md and docs/design.md described the same rules and had already drifted: design.md sent the pointer case to #135, AGENTS.md to #105. docs/design.md now holds each rule in full, with the reasons and the issues still open against it: - "Annotated builtin types vs. objects" states the context rule (member annotation, container element type, TKey, pointee Ref per #105, survey in #139), that the choice of builtin is ergonomic (TDatime, #123), and the injectivity requirement (kBool 0x99, root-io-spec ElementTypes §2.5; #140). - New sections for keeping what is on disk, content the parser does not understand (#74), and generated classes (#67, #22). AGENTS.md keeps one line per rule, linking its design.md section, so an agent still has the rules in context. Assisted-by: claude-code:claude-opus-5-5 --------- Co-authored-by: Nick Smith <nick.smith@cern.ch>
* Verify envelope checksums Every RNTuple envelope ends with an XXH3-64 checksum. rootfilespec read it and compared stored checksums with each other, but never hashed the bytes, so a corrupted header, footer or page list parsed without complaint. REnvelope.read now checks the checksum over [0, length - 8) of the uncompressed envelope: the length includes the checksum, and the checksum covers everything before it (root-io-spec ERRATA 5, checked against ROOT's VerifyXxHash3, RNTupleSerialize.cxx:934). Unknown trailing bytes are covered too, as the spec's compatibility notes require. REnvelopeLocator.read_from also refuses a stored size larger than the uncompressed length. RNTuple decompression branches on equality (root-io-spec NOTES 2, RNTupleZip.hxx:106-113), so that case is an error, where the code used to try to decompress it. Fixes #117 Assisted-by: claude-code:claude-opus-5-5 * Ignore the missing xxhash stubs in the pre-commit mypy environment The pre-commit mypy hook installs only pytest, numpy and tomli, so it cannot find xxhash. bootstrap/compression.py already imports it the same way. Assisted-by: claude-code:claude-opus-5-5 * Rename tests/test_rntuple_envelopes.py to tests/test_envelope_checksums.py Name the module after what it tests, so it does not read as the home of a broad set of tests (review on #125). Assisted-by: claude-code:claude-opus-5-5 * Verify envelope checksums before parsing, and check the fetched size Two fixes from review: - REnvelope.read now verifies the XXH3-64 right after the type and length checks, before the payload is parsed, as ROOT's DeserializeEnvelope does (RNTupleSerialize.cxx:909-939). Before, the payload was parsed first, so 136 of the 224 single-byte corruptions of rntuple/anchor.root's header envelope failed with an unrelated parse error instead of the checksum mismatch. Envelopes shorter than 16 bytes are refused, as in ROOT. - REnvelopeLocator.read_from checks that it was given the locator's size, then applies NOTES 2's raw/compressed rule to the locator's stored size rather than to the buffer's length. A short read (a truncated file) said "Unknown compression algorithm". Tests: every corrupted byte of that envelope reports the checksum mismatch; short reads of 0, 200 and 239 of 240 bytes raise. Assisted-by: claude-code:claude-opus-5-5 * Read the envelope checksum through ReadBuffer, not struct Slice the buffer to its last 8 bytes and unpack there, as the rest of the library does, instead of importing struct into envelope.py. Assisted-by: claude-code:claude-opus-5-5 * Compare key class names as bytes in the envelope tests, after #127 #127 made key class names plain bytes. Assisted-by: claude-code:claude-opus-5-5 * Split the envelope once into the checksummed bytes and the checksum REnvelope.read mixed three views of the envelope: the checksum was read from the buffer after the preamble at length - 16, hashed against a separate envelope_bytes slice at length - 8, and the payload was parsed from the buffer. It now splits the envelope once, into the bytes the checksum covers and the 8-byte checksum, verifies one against the other, and parses the payload from the covered bytes after the preamble, so nothing reads into the checksum (review on #129). The preamble is still read from the whole buffer first, because the split needs its length. Every envelope that read before reads the same. Two errors change: the length mismatch now reports the full envelope length and buffer length (before, both minus the 8-byte preamble), and a frame that runs past the payload raises IndexError ("Cannot get slice") where it raised ValueError ("Cannot consume a negative number of bytes"). Assisted-by: claude-code:claude-opus-5-5 * Test the envelope length checks and the pinned header checksum - rntuple/anchor.root's case.toml pins the header envelope's checksum at 500 and the footer's copy at 854; assert the parsed values equal them. - An envelope whose length is under 16 bytes (the preamble and checksum alone) raises, as in ROOT's DeserializeEnvelope. - anchor.root's header with the preamble's length changed from 240 to 248 raises the length mismatch. Also drop the count of failures before the reordering from a test docstring; the commit that reordered the checks records it. Assisted-by: claude-code:claude-opus-5-5
…ith-#127 Assisted-by: claude-code:claude-opus-5-5
…ith-#127 Assisted-by: claude-code:claude-opus-5-5
…infos (#132) * Read the extra type information's content ExtraTypeInformation ended at its type name. ROOT's serializer writes one more string, the content (RNTupleSerialize.cxx:389-391; root-io-spec ERRATA 9), so its length and bytes were left in the record frame's _unknown. For content identifier 0 that content is the streamed TList of TStreamerInfo that streamed (role 0x04) fields need. Add fContent, an RNTuple string like the type name. Fixes #118 Assisted-by: claude-code:claude-opus-5-5 * Rename tests/test_rntuple_extra_type_info.py to tests/test_extra_type_info_content.py Name the module after what it tests, so it does not read as the home of a broad set of tests (review on #125). Assisted-by: claude-code:claude-opus-5-5 * Compare key class names as bytes, after the rest of #68 in #127 Assisted-by: claude-code:claude-opus-5-5 * Pin the streamed.root bytes exactly in the extra type info test AGENTS.md asks tests to assert the values a case.toml pins. Compare the content with the file's bytes at offset 1240 (length 438 at 1236), the TStreamerInfo name's length byte at 1319 and the name after it, and the frame size 462, instead of only checking that the name occurs somewhere. Say in fContent's docstring that it has an RNTuple string's layout but holds binary bytes, not UTF-8 text. Assisted-by: claude-code:claude-opus-5-5 * Decode the streamer info content: RNTuple.streamer_infos() The extra type information with content identifier 0 holds the TStreamerInfo of every class that streamed (role 0x04) fields need, as a TList written as if through a pointer (RNTupleSerializer:: SerializeStreamerInfos, ROOT 6.40.04). For an RNTuple read without its TFile, it is the only description of those classes. streamer_infos() reads it with the bootstrap classes, by class name like FileReader.streamerinfos(). It is derived: fContent keeps the bytes. A list item that comes back as a Ref is unwrapped, so the result stays the same when #105 makes every pointee a Ref. Other content identifiers are ignored, as the spec asks; anything else in the content, bytes after the TList, or the same class twice is an error rather than a guess. Assisted-by: claude-code:claude-opus-5-5
…ith-#127 Assisted-by: claude-code:claude-opus-5-5
… field information and global cluster IDs (#131) * Give page descriptions their column, field and global cluster ID RNTuple.get_extended_page_descriptions(): - returns one entry per physical column in every cluster, so a position is the column ID, as the spec guarantees (Page Locations: the outer items follow column order). With the old default includeSuppressed=False, suppressed columns were dropped and positions shifted. The parameter is removed; a suppressed column is an entry with no pages. The zip over columns is strict, as suggested on #49. - gives each page its RNTuple-wide cluster ID. Cluster IDs continue across cluster groups (Page List Envelope); the new RNTuple.firstClusterIDs is the running sum of the footer's fNClusters. Each page list's cluster count is checked against its group. - gives each page its column ID, field ID, FieldDescription and qualified field path. SchemaDescription.field_path(field_id) walks fParentFieldID up to the top-level field (which names itself as parent) and joins the names with "." (forbidden in names, so unambiguous). It returns bytes, like the names, and raises on an out-of-range ID or a cycle. The schema is built once per call; the schemaDescription property itself is unchanged. Fixes #49 Fixes #116 Assisted-by: claude-code:claude-opus-5-5 * Rename tests/test_rntuple_page_descriptions.py to tests/test_extended_page_descriptions.py Name the module after what it tests, so it does not read as the home of a broad set of tests (review on #125). Assisted-by: claude-code:claude-opus-5-5 * Compare key class names as bytes, after the rest of #68 in #127 Assisted-by: claude-code:claude-opus-5-5 * Accept page lists that predate a model extension A cluster committed before the model was extended lists only the columns that existed then, so its page list can end before the schema extension's columns do (the spec fixes the columns' order, not their number). ROOT fills those in when reading (AddExtendedColumnRanges, RNTupleDescriptor.cxx:920 at 6.40.04). The strict zip raised on scikit-hep-testdata's test_extension_columns_rntuple_v1-0-0-0.root, which read before this PR: its first cluster lists 2 of 4 columns. get_extended_page_descriptions now gives such trailing columns empty entries, so a position is still the column ID. A cluster listing more columns than the schema, or fewer than the header's, still raises. uncompressedSize uses RPageDescription.n_elements (#128). Tests: that file's clusters, a page list missing a header column, and the per-page column/field check on three scikit-hep-testdata files as well as root-io-spec's. Assisted-by: claude-code:claude-opus-5-5 * Give each column in a cluster its element range, not just its pages get_extended_page_descriptions() now returns one InterpretableColumn per physical column per cluster, so that the view is enough on its own to read a column. Before, a column was a bare list of pages, and an empty list could mean a suppressed column, a column with no pages, or a column the cluster predates; the element offset was not in the view at all. The ranges are the ones ROOT's reader builds (CommitSuppressedColumnRanges and AddExtendedColumnRanges, tree/ntuple/src/RNTupleDescriptor.cxx:880 and :920 at 6.40.04), which is what the spec asks of a reader: - a suppressed column takes the range of the corresponding column of the field's active representation (spec, Suppressed Columns); - a deferred column covers every cluster from element 0, the elements before its first stored one being zeros with no page (Column Description): nZeroElements counts them; - a column the cluster predates keeps pageLocations=None. New checks, each with a test: a cluster cannot list fewer columns than an earlier one, since only a model extension adds columns; a page list needs a cluster summary per cluster; a suppressed column has no pages; every field has an active representation; a deferred column's pages start where its range says; and a deferred column may not be inside a collection or variant, whose element count the format does not record. The tests now also pin each column's field path and type against root-io-spec's user-class case, and check on every fixture that a column's clusters follow each other with no gap. Assisted-by: claude-code:claude-opus-5-5 * Return clusters, each with its columns, each with its pages RNTuple.clusters() replaces get_extended_page_descriptions(): one InterpretableCluster per cluster, in cluster-ID order, shaped like ROOT's RClusterDescriptor. A cluster carries its ID, its cluster group's position and its summary as stored (first entry, number of entries), so a caller no longer zips the view back with the page lists and the footer. The nesting by page-list envelope goes, and with it RNTuple.firstClusterIDs. Each fact is now stored once. InterpretableColumn keeps the column's and field's descriptions and path; InterpretablePage keeps only the page as stored, its uncompressed size and, new, the column-wide index of its first element (ROOT's RPageInfoExtended). The page's columnType, clusterID, columnID, fieldID, fieldDescription and fieldPath are on its column. SchemaDescription.field_columns() gives every field's physical columns by representation and index, as ROOT numbers them (RNTupleSerialize.cxx:1511 at 6.40.04), and raises when a field's representations differ in size (spec, Suppressed Columns). The cluster view uses it to find corresponding columns. Assisted-by: claude-code:claude-opus-5-5 * Count a page's first element from the start of its cluster InterpretablePage.firstElementIndex, counted from the start of the column, becomes firstElementInCluster, counted from the start of the column's elements in the cluster, as ROOT's RPageInfoExtended counts it. A cluster can be reused unchanged in another RNTuple, because offset columns count from the start of the cluster (spec, Column Description), so this index belongs to the cluster, while the column's firstElementIndex is a position in this RNTuple. InterpretableCluster's docstring now says which values are which. Assisted-by: claude-code:claude-opus-5-5 * Test a column that names a field the schema does not have Assisted-by: claude-code:claude-opus-5-5 * Yield clusters one at a time, and make the columns a step of their own Following Nick's review of #131: - RNTuple.clusters() is now an iterator: each cluster is built when it is reached, so a caller that goes through them holds one cluster's ranges at a time. On the MiniAOD Events RNTuple (10,336 columns, 27 clusters), peak memory while iterating is 11 MB, against 84 MB for the whole list. The schema and the number of page lists and clusters are still checked when it is called; each cluster's page list when it is reached. - RNTuple.columns() is the public form of the per-column work that was private (_Columns): one InterpretableColumn per physical column, the same in every cluster, like ROOT's RColumnDescriptor, with its description, field, path and index within its representation. A caller can store these before going through the clusters. - What was InterpretableColumn, one column in one cluster, is now InterpretableColumnRange (ROOT's RColumnRange with its RPageRange). It refers to its InterpretableColumn instead of repeating the column's description, field and path in every cluster, and InterpretableCluster.columns becomes columnRanges. Assisted-by: claude-code:claude-opus-5-5
… builtin (#147) * FileContext.type_by_name returns object: a lookup can be an annotated builtin type_by_name was annotated -> type[ROOTSerializable], but TString, TStringLong and string resolve to annotated bytes aliases (#68), and a file's context resolves TDatime to an annotated int. mypy therefore let callers call .read() on the result, the TypedTKey bug #127 fixed, and test_read.py's basket reader still did. Option 1 on #135: FileContext.type_by_name and both implementations return object, with the contract in the docstring: read the result with read_value, which now takes object, and narrow with isinstance(t, type) and issubclass(t, ROOTSerializable) where the class is needed. mypy now rejects .read() on a lookup. TKey.read_object (untyped) and TKey.read_from return object as well, since a TString record reads as bytes; so the Locator protocol's type variable is no longer bound to ROOTSerializable. The basket reader in test_read.py uses read_value. Fixes #135 Assisted-by: claude-code:claude-opus-5-5 * tests: drop the object annotations #145 added for string records #145 typed two string-record reads in test_root_string.py as object, because TKey.read_from claimed ROOTSerializable. It is typed object now, so the reads compare directly again. Assisted-by: claude-code:claude-opus-5-5 * Say what Locator's type variable is for The comment on T_co only said why it is unbound. It is the type a locator returns (Locator[RPage], Locator[TTree]), unbound because some locators return a builtin. Wording from Nick's review of #147. Assisted-by: claude-code:claude-opus-5-5
Fixes #68 and #20. Every ROOT string type is read as plain
bytes, asdocs/design.mdpromises ("Annotated builtin types vs. objects"). The on-disk encoding is set by the annotation, not by the member's Python type.ROOTString(encoding, framed=False)(rootfilespec.structutil) is aMemberSerDe:Annotated[bytes, ROOTString(...)].encodingis aLiteralwith your three values, one per length format (root-io-spec Conventions §5):encoding"RNTuple"rntuple.schema.RNTupleString)"TString"TStringmembers,TKeystrings,std::stringelements of collections, pointees"charstar"n <= 0reads asb""(§5.4)char*members (kCharStar, #20), andTStringLong(§5.1.1)framed=Trueis for astd::stringdata member, which is theTStringencoding inside a byte count and version word; astd::stringelement of a collection has no frame (§5.3; root-io-specserialization/collectionshas both:fStrframed,fWordselements bare). The frame is read withStreamHeader, asStdVectorand the other framed members read theirs, and the byte count is checked.STLStringused to handle that frame. The frame is a separate option rather than a fourth encoding because it isn't a length format: any encoding could be framed.ROOTString.read(buffer)is the one string reader: the member reader calls it, and so doTKeyandTListfor the strings they read by hand (ROOTString("TString").read(buffer)). It lives instructutilbesideFmt,OptionalFieldand the otherMemberSerDes;bootstrap/strings.pyholds only theTString/TStringLong/stringaliases.Aliases.
bootstrap.TString = Annotated[bytes, ROOTString("TString")], so hand-written classes keepfName: TString.bootstrap.TStringLongandbootstrap.stringare aliases too. They stay resolvable by class name because a string can be stored as an object of its own: a key of classTString,TStringLongorstring, or aTString*.serializable.read_value(type, buffer)reads a class or an annotated builtin through the same path as a member, andTKey.read_object(typed and untyped: aTypedTKeyof a string record holds the alias as itsobjtype) andread_streamed_itemuse it for a looked-up type.type_by_nameis still annotated as returning a class; that predates this PR (TDatime) and is filed separately as #135.Design assumption (now in
docs/design.md): thebytesdon't record which encoding they came from. The annotation, the key's class name or the streamed object's class tag does. So aTStringrecord read from a key round-trips only together with its key'sfClassName; writing needs that context, not just thebytes.Generated classes (
dynamic.py) write the annotation out, as you asked:TStreamerString→Annotated[bytes, ROOTString('TString')]TStreamerSTLstring→Annotated[bytes, ROOTString('TString', framed=True)]kCharStarbasic type →Annotated[bytes, ROOTString('charstar')], which wasFmt('charstar')and raisedUnimplemented format charstarcpptype,string,std::stringandTStringas container elements or pointees →Annotated[bytes, ROOTString('TString')](e.g.StdVector[Annotated[bytes, ROOTString('TString')]]), with no class dependencyA by-value member whose class is
TStringLong(TStreamerObjectAny) still refers to it by name, like any other class member, which resolves to the alias. Arrays ofchar*(element types 27 / 47) stay unimplemented; no corpus file uses them.Side effects on root-io-spec fixtures:
serialization/stringlong.rootnow reads: itsSTexthas aTStringLongand aTStringmember, bothbytes. Removed fromEXPECTED_FAILURES.serialization/unframed-records.rootnow reads itsTStringandTStringLongrecords. ItsTDatimerecord reads too, as a side effect:read_valuehandlesTDatime = Annotated[int, Fmt(">I")]the same way. That belongs to TDatime and TObject written as records of their own (no byte count) cannot be read #123, so its test goes with TDatime and TObject written as records of their own (no byte count) cannot be read #123, not here. The fixture now stops at theTObjectrecord, which has no byte count (the rest of TDatime and TObject written as records of their own (no byte count) cannot be read #123); its expected failure is updated to that.Tests (
tests/test_root_string.py, renamed fromtest_counted_string.py):TStringat lengths 0, 5, 254, 255 and 300 (long form);char*with no 255 escape, empty, null and a negative length; a framed string (framed=True), an empty one (7 bytes), and a missing or wrong byte count;case.tomlpins:serialization/collectionsfWords=[b"pq", b"rs"](bare elements) andfStr=b"abc"(framed);serialization/element-typesfText=b"hi"andfNull=b"";ROOTString('TString', framed=True),StdVector[Annotated[bytes, ROOTString('TString')]],ROOTString('charstar'));unframed-records,stringlong), fetched and throughTypedTKey, and key/TNamed/streamer-info names arebytes;bytes, as before.Full suite, rebased on current
main(after #128):492 passed, 59 skipped, 49 xfailed.Breaking changes (for the release notes):
TString,STLStringandRStringare no longer classes. Every string member isbytes:key.fClassNameinstead ofkey.fClassName.fString,std::stringmembers instead of.value.TString(...),TString.read(...)andisinstance(x, TString)no longer work; useROOTString("TString").read(buffer).STLStringandRStringare removed fromrootfilespec.bootstrap.CountedString(new in this PR's first commit) is replaced byROOTString.Assisted-by: claude-code:claude-opus-5-5