From 2d35e546e2674efee33b89c3d77084d94e44f7a5 Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Sat, 3 Oct 2026 16:08:44 -0500 Subject: [PATCH] fix(parse): register a filing's packages for that load only Arelle keeps registered taxonomy packages for the life of the process, and nothing cleared them, so in a long-running session a load could resolve through an earlier load's catalog: a report with no package read an earlier package's taxonomy (or, once that filing was unloaded, lost every concept), and of two packages mapping the same URL the first, read again, was read through the second. The registrations are now cleared before each load. --- tests/test_arelle_cache.py | 96 ++++++++++++++++++++++++++++++++++++ xbrlkit/parse/README.md | 4 ++ xbrlkit/parse/arelle_load.py | 12 +++-- 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/tests/test_arelle_cache.py b/tests/test_arelle_cache.py index 395c8b3..37a8cd2 100644 --- a/tests/test_arelle_cache.py +++ b/tests/test_arelle_cache.py @@ -355,6 +355,102 @@ def test_schema_hosts_are_fetched_over_https() -> None: """ +ACME_URL = "http://acme.example/tax/acme.xsd" + +ACME_SCHEMA = """ + + + + +""" + +ACME_REPORT = """ + + + + + 00000000000000000000 + + 2025-12-31 + + iso4217:EUR + 100 + +""" + + +def _acme_package(root: Path, name: str, concept: str) -> tuple[Path, Path]: + """A taxonomy package whose catalog maps ``ACME_URL`` to its own schema, + which declares one ``concept``, and a report tagged with it. Returns the + report and the manifest that registers the package.""" + package = root / name + for folder in ("META-INF", "tax", "reports"): + (package / folder).mkdir(parents=True) + manifest = package / "META-INF" / "taxonomyPackage.xml" + manifest.write_text( + "" + f"http://acme.example/{name}" + f"{name}{name}" + "1" + ) + (package / "META-INF" / "catalog.xml").write_text( + "" + "" + "" + ) + (package / "tax" / "acme.xsd").write_text(ACME_SCHEMA.format(concept=concept)) + report = package / "reports" / "report.xml" + report.write_text(ACME_REPORT.format(concept=concept)) + return report, manifest + + +def _defined_facts(report: Path, cache: Path, packages: list[Path]) -> list[str]: + """The facts of ``report`` that found their concept, loaded offline.""" + mx = load_model(report, cache, offline=True, packages=packages) + try: + return [f.qname.localName for f in mx.facts if f.concept is not None] + finally: + arelle_load.close(mx.modelManager.cntlr) + + +class TestPackagesAreRegisteredPerLoad: + """Arelle keeps registered packages for the life of the process; a load + must resolve through its own and no earlier load's.""" + + def test_a_report_without_a_package_does_not_read_an_earlier_one( + self, tmp_path: Path + ) -> None: + report, manifest = _acme_package(tmp_path, "first", "Revenue") + assert _defined_facts(report, tmp_path / "cache", [manifest]) == ["Revenue"] + + alone = tmp_path / "alone.xml" + alone.write_text(ACME_REPORT.format(concept="Revenue")) + with pytest.raises(DtsResolutionError) as excinfo: + load_model(alone, tmp_path / "cache", offline=True) + assert excinfo.value.unresolved == [ACME_URL] + + def test_a_package_read_again_is_not_read_through_a_later_one( + self, tmp_path: Path + ) -> None: + """Two packages that map the same URL to different schemas.""" + first, first_manifest = _acme_package(tmp_path, "first", "Revenue") + second, second_manifest = _acme_package(tmp_path, "second", "Assets") + cache = tmp_path / "cache" + + assert _defined_facts(first, cache, [first_manifest]) == ["Revenue"] + assert _defined_facts(second, cache, [second_manifest]) == ["Assets"] + assert _defined_facts(first, cache, [first_manifest]) == ["Revenue"] + + class TestLoadModel: def test_offline_with_a_cold_cache_refuses_the_partial_dts( self, tmp_path: Path diff --git a/xbrlkit/parse/README.md b/xbrlkit/parse/README.md index 5d664aa..c02d3da 100644 --- a/xbrlkit/parse/README.md +++ b/xbrlkit/parse/README.md @@ -88,3 +88,7 @@ it references the filer's extension taxonomy at their own domain. Point at the those URLs resolve to the schema travelling beside the report. Register the *archive*, not its manifest: one Dutch package's manifest raises inside Arelle where the same package as a zip registers cleanly. + +A package is registered for the one load. Arelle keeps its registrations for +the life of the process, so `load_model` clears them first: a later filing +never resolves through an earlier filing's catalog. diff --git a/xbrlkit/parse/arelle_load.py b/xbrlkit/parse/arelle_load.py index 08e168d..09c01ea 100644 --- a/xbrlkit/parse/arelle_load.py +++ b/xbrlkit/parse/arelle_load.py @@ -201,11 +201,17 @@ def _is_gone(url: str) -> bool: def _register_packages(cntlr: Any, packages: Sequence[str | Path]) -> None: - """Register taxonomy packages so their catalogs remap the URLs they own.""" - if not packages: - return + """Register taxonomy packages so their catalogs remap the URLs they own. + + Arelle keeps what it has registered for the life of the process, so it is + cleared first: a load resolves through its own packages and no earlier + load's. + """ from arelle import PackageManager + PackageManager.reset() + if not packages: + return PackageManager.init(cntlr, loadPackagesConfig=False) added = False for package in packages: