From d9c521bdd2b136b414ae36e68cdadc747d0b8c37 Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Thu, 24 Sep 2026 19:19:39 -0500 Subject: [PATCH 1/2] fix(parse): load the Arelle-backed exports lazily `import xbrlkit.periods` as a first import failed in 0.18.1: periods imports xbrlkit.parse.ids, importing any parse submodule runs parse/__init__.py, and that eagerly imported arelle_load and to_model, which imports periods while it is half-loaded. parse/__init__.py now resolves its exports on first use (PEP 562), so the cycle is gone and a caller that only wants a period id no longer loads Arelle. tests/test_imports.py imports every public module in its own interpreter, so an order-dependent import fails CI instead of a user. --- tests/test_imports.py | 53 +++++++++++++++++++++++++++++++++++++++ xbrlkit/parse/__init__.py | 49 ++++++++++++++++++++++++++++-------- 2 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 tests/test_imports.py diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 0000000..f053c79 --- /dev/null +++ b/tests/test_imports.py @@ -0,0 +1,53 @@ +"""Every public module imports on its own, in a fresh interpreter. + +A module that only imports when something else was imported first is a cycle +waiting for a caller: `import xbrlkit.periods` failed in 0.18.1 because +`xbrlkit.parse` loaded Arelle and `to_model` on import, and `to_model` imports +`periods`. Each module is imported in its own subprocess, since one process +would mask the order dependence the test exists to find. +""" + +from __future__ import annotations + +import pkgutil +import subprocess +import sys + +import pytest + +import xbrlkit + + +def _public_modules() -> list[str]: + names = [xbrlkit.__name__] + for info in pkgutil.walk_packages(xbrlkit.__path__, prefix=f"{xbrlkit.__name__}."): + if any(part.startswith("_") for part in info.name.split(".")): + continue + names.append(info.name) + return sorted(names) + + +@pytest.mark.parametrize("module", _public_modules()) +def test_a_public_module_imports_on_its_own(module: str) -> None: + result = subprocess.run( + [sys.executable, "-c", f"import {module}"], + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, result.stderr.strip().splitlines()[-1:] + + +def test_periods_does_not_load_arelle() -> None: + """A caller that wants a period id should not pay for Arelle.""" + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys, xbrlkit.periods; print('arelle' in sys.modules)", + ], + capture_output=True, + text=True, + timeout=120, + ) + assert result.stdout.strip() == "False", result.stderr diff --git a/xbrlkit/parse/__init__.py b/xbrlkit/parse/__init__.py index 01cab16..1671b50 100644 --- a/xbrlkit/parse/__init__.py +++ b/xbrlkit/parse/__init__.py @@ -19,16 +19,45 @@ from __future__ import annotations -from xbrlkit.parse.arelle_load import ( - DtsResolutionError, - LoadState, - close, - configure_webcache, - load_model, - load_state, - register_sec_transforms, -) -from xbrlkit.parse.to_model import to_xbrl_model +import importlib +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from xbrlkit.parse.arelle_load import ( + DtsResolutionError, + LoadState, + close, + configure_webcache, + load_model, + load_state, + register_sec_transforms, + ) + from xbrlkit.parse.to_model import to_xbrl_model + +# Loaded on first use (PEP 562), not on import. `xbrlkit.parse.ids` is imported +# by `xbrlkit.periods`, and importing any submodule runs this file first: loading +# Arelle and `to_model` here made `import xbrlkit.periods` circular (to_model +# imports periods) and pulled Arelle into every caller that wanted a period id. +_LAZY: dict[str, str] = { + "DtsResolutionError": "xbrlkit.parse.arelle_load", + "LoadState": "xbrlkit.parse.arelle_load", + "close": "xbrlkit.parse.arelle_load", + "configure_webcache": "xbrlkit.parse.arelle_load", + "load_model": "xbrlkit.parse.arelle_load", + "load_state": "xbrlkit.parse.arelle_load", + "register_sec_transforms": "xbrlkit.parse.arelle_load", + "to_xbrl_model": "xbrlkit.parse.to_model", +} + + +def __getattr__(name: str) -> Any: + module = _LAZY.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(module), name) + globals()[name] = value + return value + __all__ = [ "DtsResolutionError", From e9b834f6a77c1f1ec0f4f2c4bbb780fb2055262c Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Thu, 24 Sep 2026 19:25:35 -0500 Subject: [PATCH 2/2] fix(imports): load serialize and deserialize submodules lazily so TAVI never pulls in rdflib --- tests/test_imports.py | 14 ++++++++ xbrlkit/deserialize/__init__.py | 49 ++++++++++++++++++++++----- xbrlkit/serialize/__init__.py | 60 +++++++++++++++++++++++++++------ 3 files changed, 104 insertions(+), 19 deletions(-) diff --git a/tests/test_imports.py b/tests/test_imports.py index f053c79..f1f49fc 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -51,3 +51,17 @@ def test_periods_does_not_load_arelle() -> None: timeout=120, ) assert result.stdout.strip() == "False", result.stderr + + +@pytest.mark.parametrize( + "module", ["xbrlkit.serialize.tavi", "xbrlkit.deserialize.tavi"] +) +def test_tavi_does_not_load_rdflib(module: str) -> None: + """TAVI is JSON; reading or writing it should not pay for an RDF stack.""" + result = subprocess.run( + [sys.executable, "-c", f"import sys, {module}; print('rdflib' in sys.modules)"], + capture_output=True, + text=True, + timeout=120, + ) + assert result.stdout.strip() == "False", result.stderr diff --git a/xbrlkit/deserialize/__init__.py b/xbrlkit/deserialize/__init__.py index f2a270e..d114ece 100644 --- a/xbrlkit/deserialize/__init__.py +++ b/xbrlkit/deserialize/__init__.py @@ -19,14 +19,47 @@ from __future__ import annotations -from .clawdog import ( - ClawDogError, - from_clawdog, - from_clawdog_json, - from_clawdog_report, -) -from .holon import HolonError, from_holon, from_holon_json, from_holon_report -from .tavi import TaviError, from_tavi, from_tavi_json, from_tavi_report +import importlib +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .clawdog import ( + ClawDogError, + from_clawdog, + from_clawdog_json, + from_clawdog_report, + ) + from .holon import HolonError, from_holon, from_holon_json, from_holon_report + from .tavi import TaviError, from_tavi, from_tavi_json, from_tavi_report + +# Loaded on first use (PEP 562), not on import. Importing any submodule runs +# this file first, so eager imports here made +# `xbrlkit.deserialize.tavi` load rdflib through the holon importer. +_LAZY: dict[str, tuple[str, str]] = { + "ClawDogError": (".clawdog", "ClawDogError"), + "from_clawdog": (".clawdog", "from_clawdog"), + "from_clawdog_json": (".clawdog", "from_clawdog_json"), + "from_clawdog_report": (".clawdog", "from_clawdog_report"), + "HolonError": (".holon", "HolonError"), + "from_holon": (".holon", "from_holon"), + "from_holon_json": (".holon", "from_holon_json"), + "from_holon_report": (".holon", "from_holon_report"), + "TaviError": (".tavi", "TaviError"), + "from_tavi": (".tavi", "from_tavi"), + "from_tavi_json": (".tavi", "from_tavi_json"), + "from_tavi_report": (".tavi", "from_tavi_report"), +} + + +def __getattr__(name: str) -> Any: + entry = _LAZY.get(name) + if entry is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module, attribute = entry + value = getattr(importlib.import_module(module, __name__), attribute) + globals()[name] = value + return value + __all__ = ( "ClawDogError", diff --git a/xbrlkit/serialize/__init__.py b/xbrlkit/serialize/__init__.py index a839c10..3ff0f6e 100644 --- a/xbrlkit/serialize/__init__.py +++ b/xbrlkit/serialize/__init__.py @@ -13,17 +13,55 @@ from __future__ import annotations -from .classify import classify_network, root_qname -from .clawdog import GapReport as ClawDogGapReport -from .clawdog import to_clawdog, to_clawdog_report -from .graph import build_holon_graph -from .holon import to_holon -from .lpg import GraphTables, build_lbug, to_graph_tables, write_parquet -from .oim import to_oim, to_oim_document -from .tavi import GapReport as TaviGapReport -from .tavi import to_tavi, to_tavi_report - -GapReport = TaviGapReport +import importlib +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .classify import classify_network, root_qname + from .clawdog import GapReport as ClawDogGapReport + from .clawdog import to_clawdog, to_clawdog_report + from .graph import build_holon_graph + from .holon import to_holon + from .lpg import GraphTables, build_lbug, to_graph_tables, write_parquet + from .oim import to_oim, to_oim_document + from .tavi import GapReport as TaviGapReport + from .tavi import to_tavi, to_tavi_report + + GapReport = TaviGapReport + +# Loaded on first use (PEP 562), not on import. Importing any submodule runs +# this file first, so eager imports here made +# `xbrlkit.serialize.tavi` load rdflib through the holon and graph projections. +_LAZY: dict[str, tuple[str, str]] = { + "classify_network": (".classify", "classify_network"), + "root_qname": (".classify", "root_qname"), + "ClawDogGapReport": (".clawdog", "GapReport"), + "to_clawdog": (".clawdog", "to_clawdog"), + "to_clawdog_report": (".clawdog", "to_clawdog_report"), + "build_holon_graph": (".graph", "build_holon_graph"), + "to_holon": (".holon", "to_holon"), + "GraphTables": (".lpg", "GraphTables"), + "build_lbug": (".lpg", "build_lbug"), + "to_graph_tables": (".lpg", "to_graph_tables"), + "write_parquet": (".lpg", "write_parquet"), + "to_oim": (".oim", "to_oim"), + "to_oim_document": (".oim", "to_oim_document"), + "GapReport": (".tavi", "GapReport"), + "TaviGapReport": (".tavi", "GapReport"), + "to_tavi": (".tavi", "to_tavi"), + "to_tavi_report": (".tavi", "to_tavi_report"), +} + + +def __getattr__(name: str) -> Any: + entry = _LAZY.get(name) + if entry is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module, attribute = entry + value = getattr(importlib.import_module(module, __name__), attribute) + globals()[name] = value + return value + __all__ = ( "GapReport",