From 3b7d71b60fb16c298291cfd42b65e9274fe0ac93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Valyi?= Date: Thu, 9 Jul 2026 22:29:48 -0300 Subject: [PATCH] fix: Skip xs:included chameleon schemas when generating from a directory When generating from a directory, a chameleon schema (no targetNamespace) that is xs:included by a namespaced schema was also compiled standalone as a directory entry. Its global types were registered with no namespace, and the later include was skipped because the URI was already processed, so references from the including schema fell back to str with "Reset absent type" warnings. process_schemas now skips, as top-level sources, the chameleon schemas that are xs:included by a namespaced schema in the same batch; they are compiled through the include with the correct namespace. Standalone chameleons that nobody includes are still processed. Each source is read once (content cached in preloaded) so there is no double disk read. Fixes akretion/nfelib#131 --- tests/codegen/test_transformer.py | 53 +++++++++++++++++++++++++- xsdata/codegen/transformer.py | 62 ++++++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/tests/codegen/test_transformer.py b/tests/codegen/test_transformer.py index 9d8e5372..9ba69323 100644 --- a/tests/codegen/test_transformer.py +++ b/tests/codegen/test_transformer.py @@ -1,4 +1,5 @@ import pickle +import shutil import tempfile from pathlib import Path from unittest import mock @@ -152,13 +153,63 @@ def test_process_definitions( mock_convert_definitions.assert_called_once_with(fist_def) @mock.patch.object(ResourceTransformer, "process_schema") - def test_process_schemas(self, mock_process_schema) -> None: + @mock.patch.object(ResourceTransformer, "find_included_chameleons") + def test_process_schemas( + self, mock_find_included_chameleons, mock_process_schema + ) -> None: uris = ["http://xsdata/foo.xsd", "http://xsdata/bar.xsd"] + mock_find_included_chameleons.return_value = set() self.transformer.process_schemas(uris) mock_process_schema.assert_has_calls([mock.call(uri) for uri in uris]) + @mock.patch.object(ResourceTransformer, "process_schema") + @mock.patch.object(ResourceTransformer, "find_included_chameleons") + def test_process_schemas_skips_included_chameleons( + self, mock_find_included_chameleons, mock_process_schema + ) -> None: + uris = ["http://xsdata/chameleon.xsd", "http://xsdata/main.xsd"] + mock_find_included_chameleons.return_value = {uris[0]} + + self.transformer.process_schemas(uris) + + mock_process_schema.assert_called_once_with(uris[1]) + + def test_find_included_chameleons(self) -> None: + tmp = Path(tempfile.mkdtemp()) + try: + chameleon = tmp / "chameleon.xsd" + chameleon.write_text( + '\n' + ) + main = tmp / "main.xsd" + main.write_text( + '' + '\n' + "\n" + ) + # standalone chameleon that nobody includes must NOT be skipped + orphan = tmp / "orphan.xsd" + orphan.write_text( + '\n' + ) + + uris = [f.as_uri() for f in (chameleon, main, orphan)] + skip = self.transformer.find_included_chameleons(uris) + + self.assertEqual({chameleon.as_uri()}, skip) + # the content is cached so compilation does not read the file twice + self.assertIn(chameleon.as_uri(), self.transformer.preloaded) + finally: + shutil.rmtree(tmp) + + def test_find_included_chameleons_handles_missing_source(self) -> None: + self.assertEqual( + set(), self.transformer.find_included_chameleons(["file://nonexistent"]) + ) + @mock.patch.object(ClassUtils, "reduce_classes") @mock.patch.object(ElementMapper, "map") @mock.patch.object(TreeParser, "from_bytes") diff --git a/xsdata/codegen/transformer.py b/xsdata/codegen/transformer.py index c070850d..a11e79b5 100644 --- a/xsdata/codegen/transformer.py +++ b/xsdata/codegen/transformer.py @@ -3,11 +3,13 @@ import json import os import pickle +import re import tempfile from collections import defaultdict from collections.abc import Callable from pathlib import Path from typing import NamedTuple +from urllib.parse import urljoin from toposort import CircularDependencyError @@ -191,11 +193,69 @@ def process_definitions(self, uris: list[str]) -> None: def process_schemas(self, uris: list[str]) -> None: """Process a list of xsd resources. + Chameleon schemas (no targetNamespace) that are xs:included by another + schema in the batch are skipped as top-level sources: they are compiled + through the include with the including schema's namespace. Processing + them standalone first would compile their types with no namespace and + break references from the schemas that include them. + Args: uris: A list of xsd URI strings to process """ + skip = self.find_included_chameleons(uris) for uri in uris: - self.process_schema(uri) + if uri not in skip: + self.process_schema(uri) + + def find_included_chameleons(self, uris: list[str]) -> set[str]: + """Return the chameleon schemas that are xs:included by a namespaced one. + + A chameleon schema declares no targetNamespace. When it is included by + a schema that does, it must be compiled through that include so its + types inherit the namespace. Each source is read once; the content is + cached in ``preloaded`` so the subsequent compilation reuses it. + + Args: + uris: A list of xsd URI strings to inspect + + Returns: + The subset of ``uris`` to skip as top-level sources. + """ + has_ns: dict[str, bool] = {} + includes: dict[str, set[str]] = {} + for uri in uris: + try: + data = opener.open(uri).read() # nosec + except OSError: + continue + + self.preloaded[uri] = data + text = data.decode("utf-8", errors="ignore") + header = re.search( + r"<(?:\w+:)?schema\b[^>]*>", text, re.IGNORECASE | re.DOTALL + ) + has_ns[uri] = bool( + header and re.search(r"targetNamespace\s*=", header.group(0)) + ) + includes[uri] = { + urljoin(uri, loc) + for loc in re.findall( + r'<(?:\w+:)?include\b[^>]*schemaLocation="([^"]+)"', + text, + re.IGNORECASE, + ) + } + + included_by_ns: set[str] = set() + for uri, namespaced in has_ns.items(): + if namespaced: + included_by_ns |= includes[uri] + + return { + uri + for uri, namespaced in has_ns.items() + if not namespaced and uri in included_by_ns + } def process_dtds(self, uris: list[str]) -> None: """Process a list of dtd resources.