From e37a9c1a8860f0f10a3ec43f26b4b23a64d3dddd Mon Sep 17 00:00:00 2001 From: Samantha Abbott <52800387+sathabbott@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:19:56 +0000 Subject: [PATCH 1/5] 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 --- src/rootfilespec/rntuple/schema.py | 6 +++++ tests/test_rntuple_extra_type_info.py | 38 +++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 tests/test_rntuple_extra_type_info.py diff --git a/src/rootfilespec/rntuple/schema.py b/src/rootfilespec/rntuple/schema.py index 08c14f0..c6fd6da 100644 --- a/src/rootfilespec/rntuple/schema.py +++ b/src/rootfilespec/rntuple/schema.py @@ -228,3 +228,9 @@ class ExtraTypeInformation(RecordFrame): """The version of the type for which this extra type information is provided.""" fTypeName: RNTupleString """The name of the type for which this extra type information is provided.""" + fContent: RNTupleString + """The content, a string like the type name (root-io-spec ERRATA 9). + + For content identifier 0 it is the ROOT-streamed TList of TStreamerInfo + of the streamed fields. ROOT writes that record in the footer's schema + extension, not the header (ERRATA 10).""" diff --git a/tests/test_rntuple_extra_type_info.py b/tests/test_rntuple_extra_type_info.py new file mode 100644 index 0000000..ca5712f --- /dev/null +++ b/tests/test_rntuple_extra_type_info.py @@ -0,0 +1,38 @@ +from pathlib import Path + +import pytest + +from rootfilespec.reader import open_path +from rootfilespec.rntuple.RNTuple import RNTuple + +DATA = Path(__file__).parent.parent / "reference" / "root-io-spec" / "data" / "rntuple" + + +@pytest.mark.skipif(not DATA.exists(), reason="reference/root-io-spec not checked out") +def test_streamer_info_content(): + """Issue #118: the extra type information's content is a string after the + type name (root-io-spec ERRATA 9), in the footer's schema extension (ERRATA 10) + + rntuple/streamed.root's case.toml asserts the content length 438 and the + streamed object's first bytes: byte count 434 with the 0x40000000 flag, the + new-class tag, then "TList". + """ + path = DATA / "streamed.root" + with open_path(path) as reader: + keylist = reader.keylist() + (name,) = [ + n for n in keylist if keylist[n].fClassName.fString == b"ROOT::RNTuple" + ] + rntuple = RNTuple.from_anchor(reader.fetch(keylist[name]), reader.fetch.buffer) + + assert rntuple.headerEnvelope.extraTypeInformations.items == [] + (info,) = rntuple.footerEnvelope.schemaExtension.extraTypeInformations.items + assert info.fContentIdentifier == 0 + assert info.fTypeVersion == 0 + assert info.fTypeName == b"" + assert type(info.fContent) is bytes + assert len(info.fContent) == 438 + assert info.fContent[:8] == bytes.fromhex("400001b2ffffffff") + assert info.fContent[8:13] == b"TList" + assert info._unknown == b"" + assert b"RNStreamedInner" in info.fContent From 225c4c1da63db9f80a4209b2e519d586a3a29421 Mon Sep 17 00:00:00 2001 From: Samantha Abbott <52800387+sathabbott@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:18:52 +0000 Subject: [PATCH 2/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 --- ...rntuple_extra_type_info.py => test_extra_type_info_content.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_rntuple_extra_type_info.py => test_extra_type_info_content.py} (100%) diff --git a/tests/test_rntuple_extra_type_info.py b/tests/test_extra_type_info_content.py similarity index 100% rename from tests/test_rntuple_extra_type_info.py rename to tests/test_extra_type_info_content.py From ca98a45d806352c9521fbd8a2d99ee065ff9dbc8 Mon Sep 17 00:00:00 2001 From: Samantha Abbott <52800387+sathabbott@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:53:53 +0000 Subject: [PATCH 3/5] Compare key class names as bytes, after the rest of #68 in #127 Assisted-by: claude-code:claude-opus-5-5 --- tests/test_extra_type_info_content.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_extra_type_info_content.py b/tests/test_extra_type_info_content.py index ca5712f..29fb3bd 100644 --- a/tests/test_extra_type_info_content.py +++ b/tests/test_extra_type_info_content.py @@ -20,9 +20,7 @@ def test_streamer_info_content(): path = DATA / "streamed.root" with open_path(path) as reader: keylist = reader.keylist() - (name,) = [ - n for n in keylist if keylist[n].fClassName.fString == b"ROOT::RNTuple" - ] + (name,) = [n for n in keylist if keylist[n].fClassName == b"ROOT::RNTuple"] rntuple = RNTuple.from_anchor(reader.fetch(keylist[name]), reader.fetch.buffer) assert rntuple.headerEnvelope.extraTypeInformations.items == [] From 36c21e7d66a13decc7510d8566ffa0c98c21011d Mon Sep 17 00:00:00 2001 From: Samantha Abbott <52800387+sathabbott@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:12:51 -0500 Subject: [PATCH 4/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 --- src/rootfilespec/rntuple/schema.py | 4 +++- tests/test_extra_type_info_content.py | 16 +++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/rootfilespec/rntuple/schema.py b/src/rootfilespec/rntuple/schema.py index c6fd6da..750de79 100644 --- a/src/rootfilespec/rntuple/schema.py +++ b/src/rootfilespec/rntuple/schema.py @@ -229,7 +229,9 @@ class ExtraTypeInformation(RecordFrame): fTypeName: RNTupleString """The name of the type for which this extra type information is provided.""" fContent: RNTupleString - """The content, a string like the type name (root-io-spec ERRATA 9). + """The content, laid out as an RNTuple string like the type name (root-io-spec + ERRATA 9): a 32-bit little-endian length, then the bytes. The bytes are binary, + not UTF-8 text. For content identifier 0 it is the ROOT-streamed TList of TStreamerInfo of the streamed fields. ROOT writes that record in the footer's schema diff --git a/tests/test_extra_type_info_content.py b/tests/test_extra_type_info_content.py index 29fb3bd..a41092e 100644 --- a/tests/test_extra_type_info_content.py +++ b/tests/test_extra_type_info_content.py @@ -13,11 +13,13 @@ def test_streamer_info_content(): """Issue #118: the extra type information's content is a string after the type name (root-io-spec ERRATA 9), in the footer's schema extension (ERRATA 10) - rntuple/streamed.root's case.toml asserts the content length 438 and the - streamed object's first bytes: byte count 434 with the 0x40000000 flag, the - new-class tag, then "TList". + rntuple/streamed.root's case.toml pins the content length 438 at offset 1236, + the streamed object's first bytes at 1240 (byte count 434 with the 0x40000000 + flag, then the new-class tag and "TList"), and the length byte 15 of the + TStreamerInfo name "RNStreamedInner" at 1319. """ path = DATA / "streamed.root" + raw = path.read_bytes() with open_path(path) as reader: keylist = reader.keylist() (name,) = [n for n in keylist if keylist[n].fClassName == b"ROOT::RNTuple"] @@ -29,8 +31,12 @@ def test_streamer_info_content(): assert info.fTypeVersion == 0 assert info.fTypeName == b"" assert type(info.fContent) is bytes - assert len(info.fContent) == 438 + assert int.from_bytes(raw[1236:1240], "little") == 438 + assert info.fContent == raw[1240 : 1240 + 438] assert info.fContent[:8] == bytes.fromhex("400001b2ffffffff") assert info.fContent[8:13] == b"TList" + assert info.fContent[1319 - 1240] == 15 + assert info.fContent[1320 - 1240 : 1320 - 1240 + 15] == b"RNStreamedInner" + # 8 (size) + 4 (content ID) + 4 (type version) + 4 + 0 (type name) + 4 + 438 + assert info.fSize == 462 assert info._unknown == b"" - assert b"RNStreamedInner" in info.fContent From c3d4cf9eb5ec4f9166f6d3605a98d168923ad0fc Mon Sep 17 00:00:00 2001 From: Samantha Abbott <52800387+sathabbott@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:23:10 -0500 Subject: [PATCH 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 --- src/rootfilespec/rntuple/RNTuple.py | 46 ++++++++++++++++++- tests/test_extra_type_info_content.py | 63 +++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/src/rootfilespec/rntuple/RNTuple.py b/src/rootfilespec/rntuple/RNTuple.py index 08ef1bf..a5c9aa6 100644 --- a/src/rootfilespec/rntuple/RNTuple.py +++ b/src/rootfilespec/rntuple/RNTuple.py @@ -2,7 +2,11 @@ from collections.abc import Callable from math import ceil +from rootfilespec.bootstrap import BOOTSTRAP_CONTEXT from rootfilespec.bootstrap.RAnchor import ROOT3a3aRNTuple +from rootfilespec.bootstrap.streamedobject import Ref, read_streamed_item +from rootfilespec.bootstrap.TList import TList +from rootfilespec.bootstrap.TStreamerInfo import TStreamerInfo from rootfilespec.rntuple.envelope import RFeatureFlags from rootfilespec.rntuple.footer import FooterEnvelope from rootfilespec.rntuple.header import HeaderEnvelope @@ -15,7 +19,12 @@ ExtraTypeInformation, FieldDescription, ) -from rootfilespec.serializable import Locator, ReadBuffer, ROOTSerializable +from rootfilespec.serializable import ( + BufferContext, + Locator, + ReadBuffer, + ROOTSerializable, +) @dataclasses.dataclass @@ -129,6 +138,41 @@ def schemaDescription(self) -> SchemaDescription: self.headerEnvelope, self.footerEnvelope ) + def streamer_infos(self) -> dict[bytes, TStreamerInfo]: + """The TStreamerInfo of each class that streamed (role 0x04) fields need, by class name + + Decoded from the content of every extra type information record with content + identifier 0 (ROOT writes one, in the footer's schema extension: root-io-spec + ERRATA 10), whose fContent keeps the bytes. Records with other identifiers are + ignored, as the spec asks. The content is a TList written as if through a + pointer (RNTupleSerializer::SerializeStreamerInfos), with nothing after it. + """ + infos: dict[bytes, TStreamerInfo] = {} + for record in self.schemaDescription.extraTypeInformations: + if record.fContentIdentifier != 0: # kStreamerInfo + continue + buffer = ReadBuffer( + memoryview(record.fContent), + 0, + BOOTSTRAP_CONTEXT, + BufferContext(abspos=None), + ) + streamed, rest = read_streamed_item(buffer) + if not isinstance(streamed, TList) or rest: + msg = f"Expected the streamer info content to be one TList, got {streamed!r} and {len(rest)} more bytes" + raise ValueError(msg) + for item in streamed.items: + # A pointee comes back bare or as a Ref; always a Ref after #105 + info = item.obj if isinstance(item, Ref) else item + if not isinstance(info, TStreamerInfo): + msg = f"Expected a TStreamerInfo in the streamer info content, got {info!r}" + raise ValueError(msg) + if info.fName in infos: + msg = f"Two TStreamerInfo for class {info.fName!r} in the streamer info content" + raise ValueError(msg) + infos[info.fName] = info + return infos + # can provide helpers to get page descriptions with different filters, columns/rows/etc. def get_extended_page_descriptions( self, diff --git a/tests/test_extra_type_info_content.py b/tests/test_extra_type_info_content.py index a41092e..9cbc8d1 100644 --- a/tests/test_extra_type_info_content.py +++ b/tests/test_extra_type_info_content.py @@ -1,13 +1,21 @@ +import dataclasses from pathlib import Path import pytest -from rootfilespec.reader import open_path +from rootfilespec.bootstrap.TStreamerInfo import TStreamerElement, TStreamerInfo +from rootfilespec.reader import FileReader, open_path from rootfilespec.rntuple.RNTuple import RNTuple DATA = Path(__file__).parent.parent / "reference" / "root-io-spec" / "data" / "rntuple" +def _rntuple(reader: FileReader) -> RNTuple: + keylist = reader.keylist() + (name,) = [n for n in keylist if keylist[n].fClassName == b"ROOT::RNTuple"] + return RNTuple.from_anchor(reader.fetch(keylist[name]), reader.fetch.buffer) + + @pytest.mark.skipif(not DATA.exists(), reason="reference/root-io-spec not checked out") def test_streamer_info_content(): """Issue #118: the extra type information's content is a string after the @@ -21,9 +29,7 @@ def test_streamer_info_content(): path = DATA / "streamed.root" raw = path.read_bytes() with open_path(path) as reader: - keylist = reader.keylist() - (name,) = [n for n in keylist if keylist[n].fClassName == b"ROOT::RNTuple"] - rntuple = RNTuple.from_anchor(reader.fetch(keylist[name]), reader.fetch.buffer) + rntuple = _rntuple(reader) assert rntuple.headerEnvelope.extraTypeInformations.items == [] (info,) = rntuple.footerEnvelope.schemaExtension.extraTypeInformations.items @@ -40,3 +46,52 @@ def test_streamer_info_content(): # 8 (size) + 4 (content ID) + 4 (type version) + 4 + 0 (type name) + 4 + 438 assert info.fSize == 462 assert info._unknown == b"" + + +def _element_names(info: TStreamerInfo) -> list[bytes]: + elements = info.fObjects.objects + assert all(isinstance(element, TStreamerElement) for element in elements) + return [ + element.fName for element in elements if isinstance(element, TStreamerElement) + ] + + +@pytest.mark.skipif(not DATA.exists(), reason="reference/root-io-spec not checked out") +def test_streamer_infos(): + """The content decodes to the TStreamerInfo of RNStreamedInner, which the + file's own StreamerInfo record also holds: the two must agree""" + with open_path(DATA / "streamed.root") as reader: + infos = _rntuple(reader).streamer_infos() + in_file = reader.streamerinfos()[b"RNStreamedInner"] + + assert list(infos) == [b"RNStreamedInner"] + info = infos[b"RNStreamedInner"] + assert info.fClassVersion == in_file.fClassVersion == 1 + assert info.fCheckSum == in_file.fCheckSum + assert _element_names(info) == _element_names(in_file) + assert len(_element_names(info)) == 3 + + +@pytest.mark.skipif(not DATA.exists(), reason="reference/root-io-spec not checked out") +def test_streamer_infos_without_streamed_fields(): + with open_path(DATA / "user-class.root") as reader: + rntuple = _rntuple(reader) + assert rntuple.schemaDescription.extraTypeInformations == [] + assert rntuple.streamer_infos() == {} + + +@pytest.mark.skipif(not DATA.exists(), reason="reference/root-io-spec not checked out") +def test_streamer_infos_ignores_other_ids_and_rejects_duplicates(): + with open_path(DATA / "streamed.root") as reader: + rntuple = _rntuple(reader) + records = rntuple.footerEnvelope.schemaExtension.extraTypeInformations.items + (info,) = records + + # An unknown content identifier is ignored (spec: forward compatibility) + records.append(dataclasses.replace(info, fContentIdentifier=1, fContent=b"?")) + assert list(rntuple.streamer_infos()) == [b"RNStreamedInner"] + + # The same class twice would be merged by name: refuse it + records.append(info) + with pytest.raises(ValueError, match="Two TStreamerInfo for class"): + rntuple.streamer_infos()