From 707f412dec1bdc57118233d33cd1cd11b2fc37ab Mon Sep 17 00:00:00 2001 From: Samantha Abbott <52800387+sathabbott@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:09:17 +0000 Subject: [PATCH 1/3] Import the rntuple names in RAnchor where they are used Importing any rootfilespec.rntuple module before rootfilespec.bootstrap failed with a circular import: rntuple.envelope imports bootstrap.compression, which runs bootstrap/__init__, which imports RAnchor, which imported the half-initialized rntuple.envelope. RAnchor only needs the rntuple names inside header_locator, footer_locator, get_header and get_footer, so import them there (and under TYPE_CHECKING for the annotations). tests/test_imports.py imports every module in a fresh interpreter. rootfilespec.container has the same kind of cycle through bootstrap.array and is marked as a strict expected failure. Fixes #119 Assisted-by: claude-code:claude-opus-5-5 --- src/rootfilespec/bootstrap/RAnchor.py | 30 +++++++++++------ tests/test_imports.py | 46 +++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 tests/test_imports.py diff --git a/src/rootfilespec/bootstrap/RAnchor.py b/src/rootfilespec/bootstrap/RAnchor.py index 38953a2..240ce9b 100644 --- a/src/rootfilespec/bootstrap/RAnchor.py +++ b/src/rootfilespec/bootstrap/RAnchor.py @@ -1,11 +1,7 @@ from collections.abc import Callable -from typing import Annotated +from typing import TYPE_CHECKING, Annotated from rootfilespec.bootstrap.streamedobject import StreamedObject -from rootfilespec.rntuple.envelope import REnvelopeLocator -from rootfilespec.rntuple.footer import FooterEnvelope -from rootfilespec.rntuple.header import HeaderEnvelope -from rootfilespec.rntuple.RLocator import LargeLocator from rootfilespec.serializable import ( Locator, ReadBuffer, @@ -14,6 +10,14 @@ ) from rootfilespec.structutil import Fmt +# rootfilespec.rntuple imports rootfilespec.bootstrap (for compression), whose +# __init__ imports this module, so the rntuple names are imported where they are +# used rather than here (#119) +if TYPE_CHECKING: + from rootfilespec.rntuple.envelope import REnvelopeLocator + from rootfilespec.rntuple.footer import FooterEnvelope + from rootfilespec.rntuple.header import HeaderEnvelope + @serializable class ROOT3a3aRNTuple(StreamedObject): @@ -30,8 +34,12 @@ class ROOT3a3aRNTuple(StreamedObject): fMaxKeySize: Annotated[int, Fmt(">Q")] @property - def header_locator(self) -> REnvelopeLocator[HeaderEnvelope]: + def header_locator(self) -> "REnvelopeLocator[HeaderEnvelope]": """Get a locator for the RNTuple Header Envelope.""" + from rootfilespec.rntuple.envelope import REnvelopeLocator # noqa: PLC0415 + from rootfilespec.rntuple.header import HeaderEnvelope # noqa: PLC0415 + from rootfilespec.rntuple.RLocator import LargeLocator # noqa: PLC0415 + return REnvelopeLocator( self.fLenHeader, LargeLocator(self.fNBytesHeader, self.fSeekHeader), @@ -39,8 +47,12 @@ def header_locator(self) -> REnvelopeLocator[HeaderEnvelope]: ) @property - def footer_locator(self) -> REnvelopeLocator[FooterEnvelope]: + def footer_locator(self) -> "REnvelopeLocator[FooterEnvelope]": """Get a locator for the RNTuple Footer Envelope.""" + from rootfilespec.rntuple.envelope import REnvelopeLocator # noqa: PLC0415 + from rootfilespec.rntuple.footer import FooterEnvelope # noqa: PLC0415 + from rootfilespec.rntuple.RLocator import LargeLocator # noqa: PLC0415 + return REnvelopeLocator( self.fLenFooter, LargeLocator(self.fNBytesFooter, self.fSeekFooter), @@ -49,7 +61,7 @@ def footer_locator(self) -> REnvelopeLocator[FooterEnvelope]: def get_header( self, fetch_data: Callable[[Locator[ROOTSerializable]], ReadBuffer] - ) -> HeaderEnvelope: + ) -> "HeaderEnvelope": """Reads the RNTuple Header Envelope from the given buffer.""" loc = self.header_locator buffer = fetch_data(loc) @@ -57,7 +69,7 @@ def get_header( def get_footer( self, fetch_data: Callable[[Locator[ROOTSerializable]], ReadBuffer] - ) -> FooterEnvelope: + ) -> "FooterEnvelope": """Reads the RNTuple Footer Envelope from the given buffer.""" loc = self.footer_locator buffer = fetch_data(loc) diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 0000000..2f75d8d --- /dev/null +++ b/tests/test_imports.py @@ -0,0 +1,46 @@ +import pkgutil +import subprocess +import sys + +import pytest + +import rootfilespec + +MODULES = sorted( + info.name + for info in pkgutil.walk_packages(rootfilespec.__path__, "rootfilespec.") + if ".generated" not in info.name +) + +# The same shape as #119 outside RNTuple: rootfilespec.container imports +# rootfilespec.bootstrap, whose array module imports rootfilespec.container +KNOWN_CYCLES = {"rootfilespec.container"} + + +@pytest.mark.parametrize( + "module", + [ + pytest.param( + m, + marks=pytest.mark.xfail( + strict=True, reason="circular import through rootfilespec.bootstrap" + ), + ) + if m in KNOWN_CYCLES + else m + for m in MODULES + ], +) +def test_import_alone(module: str): + """Issue #119: every module imports in a fresh interpreter, on its own + + Importing rootfilespec.rntuple.* before rootfilespec.bootstrap used to fail + with a circular import. + """ + result = subprocess.run( + [sys.executable, "-c", f"import {module}"], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr From a64b38e2f06116ab4dfdb21b7ecc5a0164e9107c Mon Sep 17 00:00:00 2001 From: Samantha Abbott <52800387+sathabbott@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:34:33 +0000 Subject: [PATCH 2/3] Drop the noqa comments for PLC0415 The repository's pinned ruff (0.9.2, pre-commit) does not enable PLC0415, so it reports these as unused noqa directives (RUF100). Assisted-by: claude-code:claude-opus-5-5 --- src/rootfilespec/bootstrap/RAnchor.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/rootfilespec/bootstrap/RAnchor.py b/src/rootfilespec/bootstrap/RAnchor.py index 240ce9b..20bb174 100644 --- a/src/rootfilespec/bootstrap/RAnchor.py +++ b/src/rootfilespec/bootstrap/RAnchor.py @@ -36,9 +36,9 @@ class ROOT3a3aRNTuple(StreamedObject): @property def header_locator(self) -> "REnvelopeLocator[HeaderEnvelope]": """Get a locator for the RNTuple Header Envelope.""" - from rootfilespec.rntuple.envelope import REnvelopeLocator # noqa: PLC0415 - from rootfilespec.rntuple.header import HeaderEnvelope # noqa: PLC0415 - from rootfilespec.rntuple.RLocator import LargeLocator # noqa: PLC0415 + from rootfilespec.rntuple.envelope import REnvelopeLocator + from rootfilespec.rntuple.header import HeaderEnvelope + from rootfilespec.rntuple.RLocator import LargeLocator return REnvelopeLocator( self.fLenHeader, @@ -49,9 +49,9 @@ def header_locator(self) -> "REnvelopeLocator[HeaderEnvelope]": @property def footer_locator(self) -> "REnvelopeLocator[FooterEnvelope]": """Get a locator for the RNTuple Footer Envelope.""" - from rootfilespec.rntuple.envelope import REnvelopeLocator # noqa: PLC0415 - from rootfilespec.rntuple.footer import FooterEnvelope # noqa: PLC0415 - from rootfilespec.rntuple.RLocator import LargeLocator # noqa: PLC0415 + from rootfilespec.rntuple.envelope import REnvelopeLocator + from rootfilespec.rntuple.footer import FooterEnvelope + from rootfilespec.rntuple.RLocator import LargeLocator return REnvelopeLocator( self.fLenFooter, From afde84b35511119e1c694692c21c9fefca00b8d4 Mon Sep 17 00:00:00 2001 From: Samantha Abbott <52800387+sathabbott@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:12:25 +0000 Subject: [PATCH 3/3] Import StreamHeader in container where it is used rootfilespec.container imported rootfilespec.bootstrap.streamedobject at module level, which runs bootstrap/__init__, whose array module imports rootfilespec.container back: importing rootfilespec.container on its own failed. StreamHeader is only needed inside four read methods, so import it there, as RAnchor now does for the rntuple names. test_imports.py no longer needs its expected failure. Assisted-by: claude-code:claude-opus-5-5 --- src/rootfilespec/container.py | 13 ++++++++++++- tests/test_imports.py | 23 +++-------------------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/src/rootfilespec/container.py b/src/rootfilespec/container.py index daf55b2..7cb7cef 100644 --- a/src/rootfilespec/container.py +++ b/src/rootfilespec/container.py @@ -4,7 +4,6 @@ import numpy as np -from rootfilespec.bootstrap.streamedobject import StreamHeader from rootfilespec.serializable import ( AssociativeContainerSerDe, ContainerSerDe, @@ -139,6 +138,9 @@ class _ObjectArrayReader: def __call__( self, members: Members, buffer: ReadBuffer ) -> tuple[Members, ReadBuffer]: + # Imported here: rootfilespec.bootstrap imports this module (#119) + from rootfilespec.bootstrap.streamedobject import StreamHeader + if isinstance(self.size, int): n = self.size else: @@ -207,6 +209,9 @@ def build_reader(cls, fname: str, inner_reader: ReadObjMethod): @classmethod def read_as(cls, inner_reader: ReadObjMethod, hasheader: bool, buffer: ReadBuffer): + # Imported here: rootfilespec.bootstrap imports this module (#119) + from rootfilespec.bootstrap.streamedobject import StreamHeader + if hasheader: header, buffer = StreamHeader.read(buffer) if header.fVersion == 1: @@ -271,6 +276,9 @@ def update_members(members: Members, buffer: ReadBuffer): @classmethod def read_as(cls, inner_reader: ReadObjMethod, buffer: ReadBuffer): + # Imported here: rootfilespec.bootstrap imports this module (#119) + from rootfilespec.bootstrap.streamedobject import StreamHeader + header, buffer = StreamHeader.read(buffer) if header.memberwise: msg = "Set with memberwise reading" @@ -324,6 +332,9 @@ def update_members(members: Members, buffer: ReadBuffer): def read_as( cls, key_reader: ReadObjMethod, value_reader: ReadObjMethod, buffer: ReadBuffer ): + # Imported here: rootfilespec.bootstrap imports this module (#119) + from rootfilespec.bootstrap.streamedobject import StreamHeader + # TODO: split this function out into a _StdMapReader with flags header, buffer = StreamHeader.read(buffer) items: dict[K, V] = {} diff --git a/tests/test_imports.py b/tests/test_imports.py index 2f75d8d..dc063e4 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -12,30 +12,13 @@ if ".generated" not in info.name ) -# The same shape as #119 outside RNTuple: rootfilespec.container imports -# rootfilespec.bootstrap, whose array module imports rootfilespec.container -KNOWN_CYCLES = {"rootfilespec.container"} - -@pytest.mark.parametrize( - "module", - [ - pytest.param( - m, - marks=pytest.mark.xfail( - strict=True, reason="circular import through rootfilespec.bootstrap" - ), - ) - if m in KNOWN_CYCLES - else m - for m in MODULES - ], -) +@pytest.mark.parametrize("module", MODULES) def test_import_alone(module: str): """Issue #119: every module imports in a fresh interpreter, on its own - Importing rootfilespec.rntuple.* before rootfilespec.bootstrap used to fail - with a circular import. + Importing rootfilespec.rntuple.* (or rootfilespec.container) before + rootfilespec.bootstrap used to fail with a circular import. """ result = subprocess.run( [sys.executable, "-c", f"import {module}"],