robotframework-xmlvalidator 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,484 @@
1
+ # Copyright 2024-2025 Michael Hallik
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
12
+ # implied.
13
+ #
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+
18
+ """
19
+ Provides utility functions to support XML and XSD validation tasks.
20
+
21
+ This module is used internally by the XmlValidator library to assist
22
+ with file resolution, namespace extraction, validation sanity checks,
23
+ and schema matching.
24
+
25
+ It does not interact with Robot Framework directly, but supports the
26
+ functionality exposed by the main library class.
27
+
28
+ Features:
29
+
30
+ - Extraction of XML namespaces from parsed documents.
31
+ - Resolution and validation of file and directory paths.
32
+ - Well-formedness checks for XML and XSD files.
33
+ - Mapping of XML namespaces to matching XSD schemas.
34
+ """
35
+
36
+
37
+ # pylint: disable=I1101:c-extension-no-member
38
+
39
+
40
+ from __future__ import annotations
41
+ # Standard library imports.
42
+ from pathlib import Path
43
+ from typing import List, Optional, Tuple, Union, TYPE_CHECKING
44
+ # Third party library imports.
45
+ from lxml import etree
46
+ from robot.api import logger
47
+ # Local application imports.
48
+ from .xml_validator_results import ValidatorResult
49
+ if TYPE_CHECKING:
50
+ from xmlschema import XMLSchema
51
+
52
+
53
+ class ValidatorUtils:
54
+ """
55
+ A stateless utility class for common XML and XSD validation
56
+ support operations.
57
+
58
+ `ValidatorUtils` provides reusable static methods used internally by
59
+ the `XmlValidator` class and its supporting modules. It handles
60
+ tasks such as:
61
+
62
+ - Extracting namespaces from XML documents.
63
+ - Resolving and validating file and directory paths.
64
+ - Performing well-formedness and sanity checks on files.
65
+ - Matching XML namespaces to candidate XSD schemas.
66
+
67
+ All methods are static and the class maintains no internal state.
68
+ """
69
+
70
+ __version__ = '1.0.0'
71
+
72
+ @staticmethod
73
+ def _resolve_path(path: str | Path) -> Path:
74
+ """
75
+ Resolves a file or directory path to an absolute `Path` object.
76
+
77
+ This internal helper method accepts either a string or a `Path`
78
+ instance and returns a resolved absolute path.
79
+
80
+ Args:
81
+
82
+ - path (str or Path):
83
+ A relative or absolute file or folder path.
84
+
85
+ Returns:
86
+ Path:
87
+ The resolved absolute path.
88
+
89
+ Notes:
90
+
91
+ - This method does not check for file existence or permissions.
92
+ - Used internally to normalize paths in validation workflows.
93
+ """
94
+ resolved_path = Path(path).resolve() if isinstance(path, str) else path.resolve()
95
+ return resolved_path
96
+
97
+ @staticmethod
98
+ def extract_xml_namespaces(
99
+ xml_root: etree.ElementBase,
100
+ return_dict: Optional[bool] = False,
101
+ include_nested: Optional[bool] = False
102
+ ) -> Union[
103
+ set[str],
104
+ dict[str | None, str]
105
+ ]:
106
+ """
107
+ Extracts XML namespaces from an XML root element.
108
+
109
+ This method retrieves namespaces declared in the `xmlns`
110
+ attributes of the XML document.
111
+
112
+ Namespaces can be returned as:
113
+
114
+ - A *set* of namespace URIs (default).
115
+ - A *dictionary* mapping prefixes to URIs (`return_dict=True`).
116
+
117
+ The method can optionally search nested elements for additional
118
+ namespace declarations (`include_nested=True`).
119
+
120
+ Args:
121
+
122
+ - xml_root (etree.ElementBase):
123
+ The root element of the parsed XML document.
124
+ - return_dict (bool, optional):
125
+ If True, returns a dict mapping namespace prefixes to URIs.
126
+ If False, returns a set of URIs. Defaults to False.
127
+ - include_nested (bool, optional):
128
+ If True, also includes namespaces declared in nested elements.
129
+ Defaults to False.
130
+
131
+ Returns:
132
+
133
+ - set[str] | dict[Optional[str], str]:
134
+ A set of namespace URIs or a dict mapping prefixes to URIs.
135
+ The default namespace (no prefix) is represented as `None`.
136
+
137
+ Raises:
138
+
139
+ - Exception:
140
+ Any parsing or extraction error is propagated upstream for
141
+ centralized error handling.
142
+
143
+ Notes:
144
+
145
+ - If no namespaces are found, an empty set or dict is returned.
146
+ - The internal helper `_extract_nested_namespaces` performs a
147
+ recursive search of the XML tree when `include_nested` is
148
+ enabled.
149
+ - This method does not catch or handle errors — they are
150
+ reported as part of the validation error pipeline.
151
+
152
+ Example Usage:
153
+
154
+ >>> xml_root = etree.fromstring('<root xmlns:ns1=\"http://example.com/ns1\"/>')
155
+ >>> extract_xml_namespaces(xml_root)
156
+ {'http://example.com/ns1'}
157
+
158
+ >>> extract_xml_namespaces(xml_root, return_dict=True)
159
+ {'ns1': 'http://example.com/ns1'}
160
+ """
161
+ def _extract_nested_namespaces(element: etree.ElementBase) -> dict[str | None, str]:
162
+ """
163
+ Recursively extracts namespaces from all elements in the XML
164
+ tree.
165
+
166
+ Args:
167
+ - element (etree.ElementBase):
168
+ The starting element for extraction.
169
+
170
+ Returns:
171
+ - dict[str | None, str]:
172
+ A dictionary mapping prefixes to URIs.
173
+ """
174
+ all_namespaces = {}
175
+ for el in element.iter(None): # Explicitly passing `None` to avoid warnings.
176
+ # Merge any new namespaces found.
177
+ all_namespaces.update(
178
+ {
179
+ k.replace("xmlns:", "") if k else None: v
180
+ for k, v in ( el.nsmap or {} ).items()
181
+ }
182
+ )
183
+ return all_namespaces
184
+
185
+ try:
186
+ # Collect all namespaces, including root.
187
+ if include_nested:
188
+ namespaces = _extract_nested_namespaces(xml_root)
189
+ # Extract namespaces explicitly from the root element.
190
+ else:
191
+ namespaces = {
192
+ k.replace("xmlns:", "") if k else None: v
193
+ # lxml provides namespaces through the nsmap attribute.
194
+ for k, v in ( xml_root.nsmap or {} ).items() # Ensure nsmap is a dictionary.
195
+ }
196
+ # Determine the return type based on `return_dict`.
197
+ return namespaces if return_dict else set(namespaces.values())
198
+ # Catch any exception, propagating it upstream for further handling.
199
+ except Exception: # pylint: disable=W0706:try-except-raise
200
+ # Keep the original error intact.
201
+ raise
202
+
203
+ @staticmethod
204
+ def get_file_paths(
205
+ file_path: str | Path,
206
+ file_type: str
207
+ ) -> Tuple[List[Path], bool]:
208
+ """
209
+ Resolves files from a given path and filters them by type.
210
+
211
+ If the path is a file, it returns a single-item list and a
212
+ True flag. If the path is a directory, it returns all files
213
+ with the matching extension and a boolean indicating whether
214
+ exactly one file was found.
215
+
216
+ Args:
217
+
218
+ - file_path (str or Path):
219
+ Path to a file or directory to validate and inspect.
220
+
221
+ - file_type (str):
222
+ Expected file extension (e.g., "xml" or "xsd"). Used when
223
+ scanning a folder.
224
+
225
+ Returns:
226
+
227
+ - Tuple[List[Path], bool]:
228
+ - A list of resolved `Path` objects that match the file type.
229
+ - A boolean indicating whether exactly one file was found.
230
+
231
+ Raises:
232
+
233
+ - ValueError:
234
+ - If the path is neither a file nor a folder.
235
+ - If no files with the expected extension are found in a folder.
236
+
237
+ Notes:
238
+
239
+ - The method uses `.glob(f"*.{file_type}")` when inspecting folders.
240
+ - Paths are normalized using `_resolve_path()`.
241
+ """
242
+ # Delegate resolving the provided path.
243
+ resolved_path = ValidatorUtils._resolve_path(file_path)
244
+ # Path is to a single file.
245
+ if resolved_path.is_file():
246
+ # Then return the file.
247
+ return [resolved_path], True
248
+ # Path is to a folder, assumed to hold one or more xsd files.
249
+ if resolved_path.is_dir():
250
+ # Get and resolve the path(s) to the file(s).
251
+ resolved_paths = list(
252
+ resolved_path.glob(f"*.{file_type}")
253
+ )
254
+ # Fail if there are no files in the folder.
255
+ if not resolved_paths:
256
+ raise ValueError(
257
+ f"No files reside in the folder: {resolved_paths}."
258
+ )
259
+ # There are one or more files in the folder.
260
+ return resolved_paths, len(resolved_paths) == 1
261
+ # Fail if the path is neither a file nor a folder.
262
+ raise ValueError(
263
+ f'The provided path is neither a file nor a folder: {resolved_path}'
264
+ )
265
+
266
+ @staticmethod
267
+ def match_namespace_to_schema(
268
+ xsd_schema: XMLSchema,
269
+ xml_namespaces: set[str]
270
+ ) -> bool:
271
+ """
272
+ Matches an XSD schema to an XML document based on namespace
273
+ rules.
274
+
275
+ This method verifies whether a given XSD schema is applicable to
276
+ an XML document by checking for namespace compatibility. The
277
+ matching logic follows these rules:
278
+
279
+ 1. If the XSD schema defines a `target_namespace`, it must be
280
+ present in the XML document's declared namespaces.
281
+ 2. If no match is found via `target_namespace`, the method
282
+ checks whether any of the schema's declared namespaces
283
+ (`xsd_schema.namespaces.values()`) are present in the XML.
284
+ 3. If neither check passes, the schema is considered not to
285
+ match.
286
+
287
+ Args:
288
+
289
+ - xsd_schema (XMLSchema):
290
+ The compiled XSD schema object to test against.
291
+
292
+ - xml_namespaces (set[str]):
293
+ A set of namespace URIs declared in the XML document.
294
+
295
+ Returns:
296
+
297
+ - bool:
298
+ `True` if the schema matches the XML document's namespaces;
299
+ `False` otherwise.
300
+
301
+ Raises:
302
+
303
+ - Exception:
304
+ Propagates any unexpected errors encountered during namespace
305
+ access or comparison.
306
+
307
+ Notes:
308
+
309
+ - This function does not validate the XML against the schema — it only
310
+ performs a namespace-level compatibility check.
311
+ - Used internally to assist with schema selection during multi-schema
312
+ validation.
313
+ """
314
+ try:
315
+ # Primary check: if target namespace is explicitly present in XML.
316
+ if xsd_schema.target_namespace is not None:
317
+ if xsd_schema.target_namespace in xml_namespaces:
318
+ return True
319
+ # Check if any other defined namespaces match.
320
+ if any(
321
+ ns in xml_namespaces for ns in xsd_schema.namespaces.values()
322
+ ):
323
+ return True
324
+ # No matching namespace found.
325
+ return False
326
+ except Exception: # pylint: disable=W0706:try-except-raise
327
+ raise
328
+
329
+ @staticmethod
330
+ def sanity_check_files( # pylint: disable=R0914:too-many-locals
331
+ file_paths: List[Path],
332
+ base_url: Optional[str] = None,
333
+ error_facets: Optional[List[str]] = None,
334
+ parse_files: Optional[bool] = False
335
+ ) -> ValidatorResult:
336
+ """
337
+ Performs sanity checks on XML or XSD files and returns a
338
+ ValidatorResult instance.
339
+
340
+ This method checks each file for basic validity, including:
341
+
342
+ - Existence
343
+ - Non-empty content
344
+ - Correct file extension (".xml" or ".xsd")
345
+ - Optional well-formedness and XSD parsing (`parse_files=True`)
346
+
347
+ If any file fails a check, its error details are collected and
348
+ returned as part of a `ValidatorResult` object. Otherwise, the
349
+ validation is marked as successful.
350
+
351
+ Args:
352
+
353
+ - file_paths (List[Path]):
354
+ A list of file paths (XML or XSD) to validate.
355
+
356
+ - base_url (Optional[str]):
357
+ An optional base URL to resolve includes or imports during
358
+ parsing (used when `parse_files=True`).
359
+
360
+ - error_facets (Optional[List[str]]):
361
+ A list of exception attributes to extract and include in the
362
+ error details (e.g., "msg", "position").
363
+
364
+ - parse_files (bool, optional):
365
+ If True, performs well-formedness checks and XSD schema
366
+ validation. Defaults to False.
367
+
368
+ Returns:
369
+
370
+ - ValidatorResult:
371
+ An object with `success: bool` and `error: List[Dict[str, Any]]`.
372
+
373
+ Notes:
374
+
375
+ - This method catches `OSError`, `etree.XMLSyntaxError`,
376
+ `etree.ParseError`, and related schema exceptions.
377
+ - If no errors are found, `ValidatorResult.success` is True and
378
+ `error` is None.
379
+ - Used during initial file intake to catch structural issues before
380
+ full validation begins.
381
+ """
382
+ # Explicitly type errors and default_facets for clarity.
383
+ errors: List[ dict[str, Optional[str]] ] = []
384
+ default_facets: dict[ type, list[str] ] = {
385
+ OSError: ["strerror"],
386
+ etree.ParseError: ["msg", "position"],
387
+ etree.XMLSchemaParseError: ["msg", "position"],
388
+ etree.XMLSyntaxError: ["msg", "position"]
389
+ }
390
+
391
+ # Helper function to process errors into a data structure.
392
+ def append_error(
393
+ file_path: Path,
394
+ reason: str,
395
+ additional_details: Optional[dict] = None
396
+ ) -> None:
397
+ """
398
+ Helper function to append an error dictionary to the errors
399
+ list.
400
+ """
401
+ # Always add the file path and the general reason.
402
+ error: dict[ str, Optional[str] ] = {
403
+ "file": str(file_path),
404
+ "reason": reason
405
+ }
406
+ # Optionally, add more error details (if provided).
407
+ if additional_details:
408
+ error.update(additional_details)
409
+ # Add the error to the container list (of errors).
410
+ errors.append(error)
411
+
412
+ for file_path in file_paths:
413
+ # Establish the file type/extension for the current file.
414
+ file_type = file_path.suffix.lower()
415
+ # General validations.
416
+ if file_type not in {".xml", ".xsd"}:
417
+ # Incorrect file exstension.
418
+ append_error(
419
+ file_path,
420
+ f"Unsupported file type: {file_type}.",
421
+ {"Error type": "ValueError"}
422
+ )
423
+ continue
424
+ if not file_path.exists():
425
+ # File does not exist.
426
+ append_error(
427
+ file_path,
428
+ f"The {file_type.removeprefix('.')} file does not exist.",
429
+ {"Error type": "OSError"}
430
+ )
431
+ continue
432
+ if file_path.stat().st_size == 0:
433
+ # File is empty.
434
+ append_error(
435
+ file_path,
436
+ "File is empty.",
437
+ {"Error type": "ValueError"}
438
+ )
439
+ continue
440
+ # XML/XSD specific validations.
441
+ try:
442
+ # Read the file.
443
+ with file_path.open("rb") as file:
444
+ # Validate well-formedness (xml or xsd).
445
+ if parse_files:
446
+ tree = etree.parse(
447
+ file,
448
+ parser=etree.XMLParser(),
449
+ base_url=base_url # type:ignore
450
+ )
451
+ # Validate the XSD schema as such.
452
+ if file_type == '.xsd':
453
+ _ = etree.XMLSchema(tree)
454
+ # Handle validation failures.
455
+ except (
456
+ OSError,
457
+ etree.ParseError,
458
+ etree.XMLSchemaParseError,
459
+ etree.XMLSyntaxError
460
+ ) as e:
461
+ # Determine error facets based on error type or provided facets.
462
+ facets_to_include = error_facets or default_facets.get(
463
+ type(e), []
464
+ )
465
+ # Initialize the error details dict.
466
+ error_details: dict[str, Optional[str]] = {}
467
+ # Add aspects/details of the caught error.
468
+ for facet in facets_to_include:
469
+ if hasattr(e, facet):
470
+ value = getattr(e, facet, None)
471
+ # Handle tuple values like (line, column).
472
+ if isinstance(value, tuple):
473
+ value = f"Line {value[0]}, Column {value[1]}."
474
+ error_details[facet] = value
475
+ logger.warn(
476
+ f"Facet '{facet}' is not an attribute of error type'{type(e).__name__}'."
477
+ )
478
+ # Add the error type to the error details
479
+ error_details['Error type'] = type(e).__name__
480
+ # Append the error to the return list.
481
+ append_error(file_path, "File parsing failed.", error_details)
482
+ # Determine success based on whether there are errors.
483
+ success = len(errors) == 0
484
+ return ValidatorResult(success=success, error=errors)