Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion tests/codegen/test_transformer.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pickle
import shutil
import tempfile
from pathlib import Path
from unittest import mock
Expand Down Expand Up @@ -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(
'<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">\n</xs:schema>'
)
main = tmp / "main.xsd"
main.write_text(
'<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" '
'targetNamespace="http://example.com/foo">'
'\n<xs:include schemaLocation="chameleon.xsd"/>'
"\n</xs:schema>"
)
# standalone chameleon that nobody includes must NOT be skipped
orphan = tmp / "orphan.xsd"
orphan.write_text(
'<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">\n</xs:schema>'
)

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")
Expand Down
62 changes: 61 additions & 1 deletion xsdata/codegen/transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
Loading