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: