Skip to content

Commit 3cc1ab6

Browse files
committed
Add tests for element and type substitution
- Fix set schema in XPathContext - Add get_attribute_type() and get_child_type() to schema proxy
1 parent 9904941 commit 3cc1ab6

8 files changed

Lines changed: 367 additions & 78 deletions

File tree

CHANGELOG.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
CHANGELOG
33
*********
44

5-
`v5.0.1`_ (2025-05-03)
5+
`v5.0.1`_ (2025-05-11)
66
======================
7-
* Fix XDM type labeling with substitutions (issue #89)
7+
* Fix XDM type labeling with element and xsi:type substitutions (issue #89)
88

99
`v5.0.0`_ (2025-04-27)
1010
======================

elementpath/schema_proxy.py

Lines changed: 81 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
#
1010
from abc import ABCMeta, abstractmethod
1111
from collections.abc import Iterator
12+
from functools import lru_cache
1213
from typing import cast, Any, Optional, Union
1314

1415
from elementpath.exceptions import ElementPathTypeError
1516
from elementpath.protocols import XsdTypeProtocol, XsdAttributeProtocol, \
16-
XsdElementProtocol, XsdSchemaProtocol
17+
XsdElementProtocol, XsdSchemaProtocol, XsdAttributeGroupProtocol
18+
from elementpath.namespaces import XSD_ANY_ATOMIC_TYPE
1719
from elementpath.datatypes import AtomicType
1820
from elementpath.etree import is_etree_element
1921
from elementpath.xpath_context import XPathSchemaContext
@@ -113,6 +115,16 @@ def find(self, path: str, namespaces: Optional[dict[str, str]] = None) \
113115
return self._base_element.find(path, namespaces=namespaces)
114116
return self._schema.find(path, namespaces)
115117

118+
@lru_cache(maxsize=None)
119+
def cached_find(self, expanded_path: str) -> Optional[PathResult]:
120+
"""
121+
Find a schema element or attribute using an expanded XPath expression.
122+
Currently unused in the code, it may be deprecated in the future.
123+
"""
124+
if self._base_element is not None:
125+
return self._base_element.find(expanded_path)
126+
return self._schema.find(expanded_path)
127+
116128
@property
117129
def xsd_version(self) -> str:
118130
"""The XSD version, returns '1.0' or '1.1'."""
@@ -131,49 +143,23 @@ def get_type(self, qname: str) -> Optional[XsdTypeProtocol]:
131143
"""
132144
return self._schema.maps.types.get(qname)
133145

134-
def get_attribute(self, name: str, xsd_type: Optional[XsdTypeProtocol] = None) \
135-
-> Optional[XsdAttributeProtocol]:
146+
def get_attribute(self, qname: str) -> Optional[XsdAttributeProtocol]:
136147
"""
137-
Get the XSD attribute if it's matching in the provided scope, otherwise return None.
138-
139-
:param name: the name of the attribute to retrieve.
140-
:param xsd_type: an optional XSD type that represents the scope where matching \
141-
the attribute name. If not provided, the scope is assumed to be the global scope.
142-
:returns: an object that represents an XSD attribute or `None`.
143-
"""
144-
if xsd_type is None:
145-
return self._schema.maps.attributes.get(name)
146-
elif hasattr(xsd_type, 'attributes'):
147-
if name in xsd_type.attributes:
148-
return cast(XsdAttributeProtocol, xsd_type.attributes[name])
149-
elif None not in xsd_type.attributes or \
150-
not xsd_type.attributes[None].is_matching(name):
151-
return None
152-
153-
return self._schema.maps.attributes.get(name)
148+
Get the XSD global attribute from the schema's scope.
154149
155-
def get_element(self, name: str, xsd_type: Optional[XsdTypeProtocol] = None) \
156-
-> Optional[XsdElementProtocol]:
150+
:param qname: the fully qualified name of the attribute to retrieve.
151+
:returns: an object that represents an XSD type or `None`.
157152
"""
158-
Get the XSD element if it's matching in the provided scope, otherwise return None.
153+
return self._schema.maps.attributes.get(qname)
159154

160-
:param name: the name of the element to match.
161-
:param xsd_type: an optional XSD type that represents the scope where matching \
162-
the element name. If not provided, the scope is assumed to be the global scope.
163-
:returns: an object that represents an XSD element or `None`.
155+
def get_element(self, qname: str) -> Optional[XsdElementProtocol]:
164156
"""
165-
if xsd_type is None:
166-
return self._schema.maps.elements.get(name)
157+
Get the XSD global element from the schema's scope.
167158
168-
content = xsd_type.model_group
169-
if content is not None:
170-
for xsd_element in content.iter_elements():
171-
if xsd_element.is_matching(name):
172-
if xsd_element.name != name:
173-
# a wildcard or a substitute
174-
return self._schema.maps.elements.get(name)
175-
return xsd_element
176-
return None
159+
:param qname: the fully qualified name of the element to retrieve.
160+
:returns: an object that represents an XSD type or `None`.
161+
"""
162+
return self._schema.maps.elements.get(qname)
177163

178164
def get_substitution_group(self, qname: str) -> Optional[set[XsdElementProtocol]]:
179165
"""
@@ -185,6 +171,63 @@ def get_substitution_group(self, qname: str) -> Optional[set[XsdElementProtocol]
185171
"""
186172
return self._schema.maps.substitution_groups.get(qname)
187173

174+
def get_attribute_type(self, name: str, parent_type: Optional[XsdTypeProtocol] = None) \
175+
-> Optional[XsdTypeProtocol]:
176+
"""
177+
Get the XSD attribute type if the provided name is matching in the scope,
178+
otherwise return None.
179+
180+
:param name: the name of the attribute to retrieve.
181+
:param parent_type: an optional XSD type that represents the scope where matching \
182+
the attribute name. If not provided, the scope is assumed to be the global scope.
183+
"""
184+
if parent_type is None:
185+
try:
186+
return self._schema.maps.attributes[name].type
187+
except KeyError:
188+
return None
189+
elif hasattr(parent_type, 'attributes'):
190+
attributes = cast(XsdAttributeGroupProtocol, parent_type.attributes)
191+
if name in attributes:
192+
return attributes[name].type
193+
elif None in attributes and attributes[None].is_matching(name):
194+
try:
195+
return self._schema.maps.attributes[name].type
196+
except KeyError:
197+
return None
198+
elif name.startswith('{http://www.w3.org/2001/XMLSchema-instance}'):
199+
return self._schema.maps.types.get(XSD_ANY_ATOMIC_TYPE)
200+
201+
return None
202+
203+
def get_child_type(self, name: str, parent_type: Optional[XsdTypeProtocol] = None) \
204+
-> Optional[XsdTypeProtocol]:
205+
"""
206+
Get the child XSD type if the provided name is matching in the scope,
207+
otherwise return None.
208+
209+
:param name: the name of the child element to match.
210+
:param parent_type: an optional XSD type that represents the scope where matching \
211+
the child element name. If `None` or not provided the scope is the schema.
212+
"""
213+
if parent_type is None:
214+
try:
215+
return self._schema.maps.elements[name].type
216+
except KeyError:
217+
return None
218+
elif (content := parent_type.model_group) is not None:
219+
for xsd_element in content.iter_elements():
220+
if xsd_element.is_matching(name):
221+
if xsd_element.name == name:
222+
return xsd_element.type
223+
else:
224+
# a wildcard or a substitute
225+
try:
226+
return self._schema.maps.elements[name].type
227+
except KeyError:
228+
return None
229+
return None
230+
188231
@abstractmethod
189232
def is_instance(self, obj: Any, type_qname: str) -> bool:
190233
"""

elementpath/xpath1/xpath1_parser.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import re
1414
from abc import ABCMeta
1515
from collections.abc import Callable, MutableMapping, Sequence
16-
from typing import cast, Any, ClassVar, Optional, Union, TYPE_CHECKING
16+
from typing import cast, Any, ClassVar, Optional, Union
1717

1818
from elementpath.aliases import NamespacesType, NargsType
1919
from elementpath.exceptions import xpath_error, UnsupportedFeatureError, \
@@ -24,13 +24,11 @@
2424
from elementpath.tdop import Parser
2525
from elementpath.namespaces import XML_NAMESPACE, XSD_NAMESPACE, XPATH_FUNCTIONS_NAMESPACE
2626
from elementpath.sequence_types import match_sequence_type
27+
from elementpath.schema_proxy import AbstractSchemaProxy
2728
from elementpath.xpath_context import ContextType
2829
from elementpath.xpath_tokens import XPathTokenType, XPathToken, XPathAxis, \
2930
XPathFunction, ProxyToken
3031

31-
if TYPE_CHECKING:
32-
from elementpath.schema_proxy import AbstractSchemaProxy
33-
3432

3533
class XPath1Parser(Parser[XPathTokenType]):
3634
"""
@@ -65,7 +63,7 @@ class XPath1Parser(Parser[XPathTokenType]):
6563
}
6664

6765
# Class attributes for compatibility with XPath 2.0+
68-
schema: Optional['AbstractSchemaProxy'] = None
66+
schema: Optional[AbstractSchemaProxy] = None
6967
variable_types: Optional[dict[str, str]] = None
7068
document_types: Optional[dict[str, str]] = None
7169
collection_types: Optional[NamespacesType] = None

elementpath/xpath2/xpath2_parser.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import locale
1515
from collections.abc import Callable, MutableMapping
1616
from urllib.parse import urlparse
17-
from typing import cast, Any, ClassVar, Optional, Union, TYPE_CHECKING
17+
from typing import cast, Any, ClassVar, Optional, Union
1818

1919
from elementpath.aliases import NamespacesType, NargsType
2020
from elementpath.helpers import upper_camel_case, is_ncname, ordinal
@@ -28,11 +28,9 @@
2828
from elementpath.xpath_tokens import XPathToken, ProxyToken, XPathFunction, XPathConstructor
2929
from elementpath.xpath_context import XPathContext, XPathSchemaContext
3030
from elementpath.sequence_types import is_sequence_type, match_sequence_type
31+
from elementpath.schema_proxy import AbstractSchemaProxy
3132
from elementpath.xpath1 import XPath1Parser
3233

33-
if TYPE_CHECKING:
34-
from elementpath.schema_proxy import AbstractSchemaProxy
35-
3634

3735
class XPath2Parser(XPath1Parser):
3836
"""
@@ -106,7 +104,7 @@ def __init__(self, namespaces: Optional[NamespacesType] = None,
106104
default_namespace: Optional[str] = None,
107105
function_namespace: Optional[str] = None,
108106
xsd_version: Optional[str] = None,
109-
schema: Optional['AbstractSchemaProxy'] = None,
107+
schema: Optional[AbstractSchemaProxy] = None,
110108
base_uri: Optional[str] = None,
111109
variable_types: Optional[dict[str, str]] = None,
112110
document_types: Optional[dict[str, str]] = None,
@@ -138,12 +136,13 @@ def __init__(self, namespaces: Optional[NamespacesType] = None,
138136
if function_namespace is not None:
139137
self.function_namespace = function_namespace
140138

141-
if schema is not None:
142-
try:
143-
schema.bind_parser(self)
144-
except AttributeError:
145-
msg = "argument 'schema' must be an instance of AbstractSchemaProxy"
146-
raise ElementPathTypeError(msg)
139+
if schema is None:
140+
pass
141+
elif not isinstance(schema, AbstractSchemaProxy):
142+
msg = "argument 'schema' must be an instance of AbstractSchemaProxy"
143+
raise ElementPathTypeError(msg)
144+
else:
145+
schema.bind_parser(self)
147146

148147
if not variable_types:
149148
self.variable_types = {}

elementpath/xpath_context.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,6 @@ def __init__(self,
144144
else:
145145
self.item = self.root
146146

147-
if schema is not None:
148-
self.root.apply_schema(schema)
149-
150147
elif item is not None:
151148
self.root = None
152149
self.item = self.get_context_item(item, self.namespaces, uri, fragment)
@@ -180,7 +177,9 @@ def __init__(self,
180177
if v is not None else v for k, v in documents.items()
181178
}
182179

183-
self.schema = schema
180+
if schema is not None:
181+
self.schema = schema
182+
184183
self.variables = {}
185184
if variables is not None:
186185
for varname, value in variables.items():
@@ -243,11 +242,21 @@ def schema(self) -> Optional['AbstractSchemaProxy']:
243242
@schema.setter
244243
def schema(self, schema: Optional['AbstractSchemaProxy']) -> None:
245244
self._schema = schema
246-
if schema is not None:
245+
if schema is None:
247246
if self.root is not None:
247+
self.root.clear_types()
248+
elif isinstance(self.item, XPathNode):
249+
self.item.clear_types()
250+
elif hasattr(schema, 'is_assertion_based'):
251+
if self.root is not None:
252+
self.root.clear_types()
248253
self.root.apply_schema(schema)
249254
elif isinstance(self.item, XPathNode):
255+
self.item.clear_types()
250256
self.item.apply_schema(schema)
257+
else:
258+
msg = f"{schema!r} is not an instance of AbstractSchemaProxy"
259+
raise ElementPathTypeError(msg)
251260

252261
def get_root(self, node: Any) -> Optional[RootNodeType]:
253262
if isinstance(self.root, (DocumentNode, ElementNode)):
@@ -622,9 +631,6 @@ class XPathSchemaContext(XPathContext):
622631
"""
623632
root: ElementNode
624633

625-
def __init__(self, *args: Any, **kwargs: Any) -> None:
626-
super().__init__(*args, **kwargs)
627-
628634
@property
629635
def schema(self) -> Optional['AbstractSchemaProxy']:
630636
return self._schema

elementpath/xpath_nodes.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -246,10 +246,15 @@ def is_list(self) -> Optional[bool]:
246246
return None
247247

248248
def apply_schema(self, schema: 'AbstractSchemaProxy') -> None:
249-
"""set XSD types for elements and attribute nodes from schema proxy instance."""
249+
"""Set XSD types for elements and attribute nodes from schema proxy instance."""
250250
if self.parent is not None:
251251
self.parent.apply_schema(schema)
252252

253+
def clear_types(self) -> None:
254+
"""Clear XSD types for elements and attribute nodes."""
255+
if self.parent is not None:
256+
self.parent.clear_types()
257+
253258
def match_name(self, name: str, default_namespace: Optional[str] = None) -> bool:
254259
"""
255260
Returns `True` if the argument is matching the name of the node, `False` otherwise.
@@ -1199,8 +1204,7 @@ def apply_schema(self, schema: 'AbstractSchemaProxy') -> None:
11991204
if self.obj.attrib:
12001205
for attr in self.attributes:
12011206
if isinstance(attr, TextAttributeNode):
1202-
xsd_attribute = schema.get_attribute(attr.name, xsd_types[-1])
1203-
attr.xsd_type = getattr(xsd_attribute, 'type', None)
1207+
attr.xsd_type = schema.get_attribute_type(attr.name, xsd_types[-1])
12041208
else:
12051209
root_node: ParentNodeType = self
12061210
while isinstance(root_node.parent, EtreeElementNode):
@@ -1222,23 +1226,22 @@ def apply_schema(self, schema: 'AbstractSchemaProxy') -> None:
12221226
elem.clear_types()
12231227
continue
12241228
else:
1225-
elem.xsd_type = schema.get_type(type_name)
1229+
xsd_type = schema.get_type(type_name)
12261230
else:
1227-
xsd_element = schema.get_element(elem.name, xsd_types[-1])
1228-
if xsd_element is None:
1229-
elem.clear_types()
1230-
continue
1231+
xsd_type = schema.get_child_type(elem.name, xsd_types[-1])
12311232

1232-
elem.xsd_type = xsd_element.type
1233+
if xsd_type is None:
1234+
elem.clear_types()
1235+
continue
12331236

1237+
elem.xsd_type = xsd_type
12341238
if elem.obj.attrib:
12351239
for attr in elem.attributes:
12361240
if isinstance(attr, TextAttributeNode):
1237-
xsd_attribute = schema.get_attribute(attr.name, elem.xsd_type)
1238-
attr.xsd_type = getattr(xsd_attribute, 'type', None)
1241+
attr.xsd_type = schema.get_attribute_type(attr.name, xsd_type)
12391242

12401243
if len(elem.obj):
1241-
xsd_types.append(elem.xsd_type)
1244+
xsd_types.append(xsd_type)
12421245
iterators.append(children)
12431246
children = iter(elem)
12441247
break

publiccode.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ publiccodeYmlVersion: '0.4'
66
name: elementpath
77
url: 'https://github.com/sissaschool/elementpath'
88
landingURL: 'https://github.com/sissaschool/elementpath'
9-
releaseDate: '2025-05-03'
9+
releaseDate: '2025-05-11'
1010
softwareVersion: v5.0.1
1111
developmentStatus: stable
1212
platforms:

0 commit comments

Comments
 (0)