Skip to content

Commit d50a2f0

Browse files
committed
Implement XTCE validation
- Ruff formatted and ruff lint fixes - Remove namespace on test_xtce_no_namespace.xml and make namespace handling on definitions.py clearer - Fix XML structure for XTCE test files - Fix CTIM XTCE for validity - Update changelog - Tighten up warnings for no namespace for XtcePacketDefinition - Add tests for XSD schema validity - Add tests for different inputs to validation function - Add test for different inputs to definition from_xtce - Add structural validation and tests - Add validate_xtce to top level module - Add simple CLI to validation tool
1 parent b42e7ae commit d50a2f0

19 files changed

Lines changed: 1250 additions & 178 deletions

docs/source/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Release notes for the `space_packet_parser` library
1414
- *BREAKING*: Removed mid-level abstraction methods `packet_generator()` and `ccsds_packet_generator()`
1515
from `XtcePacketDefinition`. Use low-level `parse_bytes()` with bytes generators directly, or high-level
1616
`space_packet_parser.xarr.create_dataset()` for xarray integration.
17+
- Add validation support for XTCE documents.
1718
- Add support for creating a packet definition from Python objects and serializing it as XML.
1819
- BUGFIX: Fix kbps calculation in packet generator for showing progress.
1920
- Add support for string and float encoded enumerated lookup parameters.

docs/source/users.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,56 @@ This equation can be implemented in XTCE by referencing the packet length field
376376
</xtce:BinaryParameterType>
377377
```
378378

379+
## XTCE Document Validation
380+
381+
Space Packet Parser provides comprehensive validation capabilities for XTCE documents to help ensure they are correct and will work properly for parsing packets. The validation system operates in two modes:
382+
383+
- **Schema Validation**: Validates the XML document against the in-document referenced XTCE XSD schema
384+
- **Structural Validation**: Validates XTCE-specific structure and reference integrity
385+
386+
Schema validation requires correct namespacing declarations at the top of your XTCE document.
387+
e.g.
388+
389+
```xml
390+
<xtce:SpaceSystem name="SpacePacketParser"
391+
xmlns:xtce="http://www.omg.org/spec/XTCE/20180204"
392+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
393+
xsi:schemaLocation="http://www.omg.org/spec/XTCE/20180204
394+
https://www.omg.org/spec/XTCE/20180204/SpaceSystem.xsd">
395+
```
396+
397+
### Basic Validation Usage
398+
399+
```python
400+
from space_packet_parser import validate_xtce
401+
402+
# Validate an XTCE file against the referenced schema
403+
result = validate_xtce("my_xtce.xml", level="schema")
404+
if result.errors:
405+
for error in result.errors:
406+
print(f"Error: {error}")
407+
else:
408+
print("Document is valid")
409+
410+
# Validate an XTCE document structure to check for
411+
# unused Parameters ParameterTypes and nonexistent references
412+
result = validate_xtce("my_xtce.xml", level="structure")
413+
if result.errors:
414+
for error in result.errors:
415+
print(f"Error: {error}")
416+
else:
417+
print("Document is valid")
418+
419+
# Comprehensive validation (both schema and structure)
420+
result = validate_xtce("my_xtce.xml", level="all")
421+
print(f"Validation completed in {result.validation_time_ms:.1f}ms")
422+
if result.errors:
423+
for error in result.errors:
424+
print(f"Error: {error}")
425+
else:
426+
print("Document is valid")
427+
```
428+
379429
## Troubleshooting Packet Parsing
380430
Parsing binary packets is error-prone and getting the XTCE definition correct can be a challenge at first.
381431
Most flight software teams can export XTCE from their command and telemetry database but these exports usually require

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,4 +150,7 @@ select = [
150150
"UP" # pyupgrade syntax upgrader
151151
]
152152
per-file-ignores = { "tests/*" = ["S"] }
153-
ignore = ["E501"] # Ignore line length errors, as we trust ruff format to handle this.
153+
ignore = [
154+
"E501", # Ignore line length errors, as we trust ruff format to handle this.
155+
"F541" # Allow f strings as docstrings
156+
]

space_packet_parser/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
from space_packet_parser.ccsds import ccsds_generator
77
from space_packet_parser.common import SpacePacket
88
from space_packet_parser.xtce.definitions import XtcePacketDefinition
9+
from space_packet_parser.xtce.validation import validate_xtce
910

1011
__all__ = [
1112
"ccsds_generator",
1213
"SpacePacket",
1314
"XtcePacketDefinition",
1415
"load_xtce",
16+
"validate_xtce",
1517
]
1618

1719

space_packet_parser/cli.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from space_packet_parser import ccsds
2727
from space_packet_parser.ccsds import ccsds_generator
2828
from space_packet_parser.xtce.definitions import DEFAULT_ROOT_CONTAINER, XtcePacketDefinition
29+
from space_packet_parser.xtce.validation import validate_xtce
2930

3031
# Initialize a console instance for rich output
3132
console = Console()
@@ -208,3 +209,42 @@ def parse(
208209
# Limit the number of packets and variables printed
209210
# also limit the length of strings (binary data can be long)
210211
pretty.pprint(packets, indent_guides=False, max_length=max_items, max_string=max_string)
212+
213+
214+
@spp.command()
215+
@click.argument("file_path", type=click.Path(exists=True, path_type=Path))
216+
@click.option(
217+
"--level",
218+
type=click.Choice(["schema", "structure", "all"], case_sensitive=False),
219+
default="all",
220+
help="Validation level to perform",
221+
)
222+
@click.option("--timeout", type=int, default=30, help="Timeout in seconds for schema downloads")
223+
def validate(file_path: Path, level: str, timeout: int) -> None:
224+
"""Validate an XTCE document."""
225+
logging.debug(f"Validating XTCE file: {file_path}")
226+
logging.debug(f"Validation level: {level}")
227+
228+
result = validate_xtce(file_path, level=level.lower(), timeout=timeout)
229+
230+
if result.valid:
231+
console.print(f"[bold green]✓ VALID[/bold green] ({result.validation_level.value} level)")
232+
else:
233+
console.print(f"[bold red]✗ INVALID[/bold red] ({result.validation_level.value} level)")
234+
235+
if result.schema_location:
236+
console.print(f"Schema: {result.schema_location}")
237+
if result.schema_version:
238+
console.print(f"Version: {result.schema_version}")
239+
240+
if result.validation_time_ms:
241+
console.print(f"Validation time: {result.validation_time_ms:.1f}ms")
242+
243+
if result.errors:
244+
console.print(f"\n[bold red]Errors ({len(result.errors)}):[/bold red]")
245+
for error in result.errors:
246+
console.print(f" {error}")
247+
248+
# Exit with error code if validation failed
249+
if not result.valid:
250+
raise click.ClickException("Validation failed")

space_packet_parser/xtce/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
This module contains Python object representations of XTCE UML/XML models
44
"""
55

6-
DEFAULT_XTCE_NS_PREFIX = "xtce" # Standard XTCE prefix using in xmlns:prefix="url" attribute
6+
STANDARD_XTCE_NS_PREFIX = "xtce" # Standard XTCE prefix using in xmlns:prefix="url" attribute
77

88
XTCE_1_2_XSD_URL = "https://www.omg.org/spec/XTCE/20180204/SpaceSystem.xsd"
99
XTCE_1_2_XMLNS = "https://www.omg.org/spec/XTCE/20180204"
@@ -14,4 +14,4 @@
1414
# Note: There is no XSD available from omg.org for XTCE 1.0
1515

1616
XTCE_URI = XTCE_1_2_XMLNS
17-
DEFAULT_XTCE_NSMAP = {DEFAULT_XTCE_NS_PREFIX: XTCE_1_2_XMLNS, "xsi": "http://www.w3.org/2001/XMLSchema-instance"}
17+
STANDARD_XTCE_NSMAP = {STANDARD_XTCE_NS_PREFIX: XTCE_1_2_XMLNS, "xsi": "http://www.w3.org/2001/XMLSchema-instance"}

space_packet_parser/xtce/definitions.py

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
from space_packet_parser import ccsds, common
1515
from space_packet_parser.exceptions import InvalidParameterTypeError, UnrecognizedPacketTypeError
1616
from space_packet_parser.xtce import (
17-
DEFAULT_XTCE_NS_PREFIX,
18-
DEFAULT_XTCE_NSMAP,
17+
STANDARD_XTCE_NS_PREFIX,
18+
STANDARD_XTCE_NSMAP,
1919
containers,
2020
parameter_types,
2121
parameters,
@@ -49,8 +49,8 @@ def __init__(
4949
self,
5050
container_set: Optional[Iterable[containers.SequenceContainer]] = None,
5151
*,
52-
ns: dict = DEFAULT_XTCE_NSMAP,
53-
xtce_ns_prefix: Optional[str] = DEFAULT_XTCE_NS_PREFIX,
52+
ns: dict = STANDARD_XTCE_NSMAP,
53+
xtce_ns_prefix: Optional[str] = STANDARD_XTCE_NS_PREFIX,
5454
root_container_name: Optional[str] = DEFAULT_ROOT_CONTAINER,
5555
space_system_name: Optional[str] = None,
5656
validation_status: str = "Unknown",
@@ -69,10 +69,10 @@ def __init__(
6969
e.g. every Parameter object named `MY_PARAM` must be the same class instance.
7070
ns : dict
7171
XML namespace mapping, expected as a dictionary with the keys being namespace labels and
72-
values being namespace URIs. Default {DEFAULT_XTCE_NSMAP}. An empty dictionary indicates no namespace
72+
values being namespace URIs. Default {STANDARD_XTCE_NSMAP}. An empty dictionary indicates no namespace
7373
awareness, in which case `xtce_ns_prefix` must be None.
7474
xtce_ns_prefix : str
75-
XTCE namespace prefix. Default {DEFAULT_XTCE_NS_PREFIX}. This is the key for the XTCE namespace in the
75+
XTCE namespace prefix. Default {STANDARD_XTCE_NS_PREFIX}. This is the key for the XTCE namespace in the
7676
namespace mapping dictionary, `ns` and is used to write XML output when necessary.
7777
root_container_name : Optional[str]
7878
Name of root sequence container (where to start parsing)
@@ -129,8 +129,13 @@ def _update_caches(sc: containers.SequenceContainer) -> None:
129129
_update_caches(sequence_container)
130130

131131
self.ns = ns # Default ns dict used when creating XML elements
132-
self.xtce_schema_uri = ns[xtce_ns_prefix] if ns else None # XTCE schema URI
133-
self.xtce_ns_prefix = xtce_ns_prefix
132+
# If the ns dict exists but xtce_ns_prefix is not in it
133+
# (including the None key representing a default namespace),
134+
# we assume the document is using no namespace awareness.
135+
self.xtce_ns_uri = ns[xtce_ns_prefix] if ns and xtce_ns_prefix in ns else None # XTCE namespace URI
136+
self.xtce_ns_prefix = (
137+
xtce_ns_prefix # This is basically an alias to the ns URI (not to be confused with the XSD schema URL)
138+
)
134139
self.root_container_name = root_container_name
135140
self.space_system_name = space_system_name
136141
self.validation_status = validation_status
@@ -154,11 +159,17 @@ def to_xml_tree(self) -> ElementTree.ElementTree:
154159
-------
155160
: ElementTree.ElementTree
156161
"""
162+
if self.xtce_ns_uri not in self.ns.values():
163+
warnings.warn(
164+
"No XTCE namespace defined. This is invalid per XSD, but will be serialized. "
165+
"Ensure mydef.xtce_ns_prefix is a key in mydef.ns for valid XTCE output.",
166+
UserWarning,
167+
)
157168
# ElementMaker element factory with predefined namespace and namespace mapping
158169
# The XTCE namespace actually defines the XTCE elements
159170
# The ns mapping just affects the serialization of XTCE elements
160171
# Both can be None, resulting in no namespace awareness
161-
elmaker = ElementMaker(namespace=self.xtce_schema_uri, nsmap=self.ns)
172+
elmaker = ElementMaker(namespace=self.xtce_ns_uri, nsmap=self.ns)
162173

163174
space_system_attrib = {}
164175
if self.space_system_name:
@@ -196,7 +207,6 @@ def from_xtce(
196207
cls,
197208
xtce_document: Union[str, Path, TextIO],
198209
*,
199-
xtce_ns_prefix: Optional[str] = DEFAULT_XTCE_NS_PREFIX,
200210
root_container_name: Optional[str] = DEFAULT_ROOT_CONTAINER,
201211
) -> "XtcePacketDefinition":
202212
f"""Instantiate an object representation of a CCSDS packet definition,
@@ -217,27 +227,45 @@ def from_xtce(
217227
----------
218228
xtce_document : TextIO
219229
Path to XTCE XML document containing packet definition.
220-
xtce_ns_prefix : Optional[str]
221-
The namespace prefix associated with the XTCE xmlns attribute. Default is {DEFAULT_XTCE_NS_PREFIX}.
222-
None means XTCE is the default namespace for elements with no prefix. The namespace mapping itself is
223-
parsed out of the XML automatically.
224230
root_container_name : Optional[str]
225231
Optional override to the root container name. Default is {DEFAULT_ROOT_CONTAINER}.
226232
"""
227233
# Define a namespace and prefix aware Element subclass so that we don't have to pass the namespace
228234
# into every from_xml method
229235
xtce_element_class = common.NamespaceAwareElement
230-
xtce_element_lookup = ElementTree.ElementDefaultClassLookup(element=common.NamespaceAwareElement)
236+
xtce_element_lookup = ElementTree.ElementDefaultClassLookup(element=xtce_element_class)
231237
xtce_parser = ElementTree.XMLParser()
232238
xtce_parser.set_element_class_lookup(xtce_element_lookup)
233239

234240
tree = ElementTree.parse(xtce_document, parser=xtce_parser) # noqa: S320
235241

236-
xtce_element_class.set_ns_prefix(xtce_ns_prefix)
237-
xtce_element_class.set_nsmap(tree.getroot().nsmap)
238-
239242
space_system = tree.getroot()
240-
ns = tree.getroot().nsmap
243+
ns = space_system.nsmap
244+
245+
# Search nsmap dict for the XTCE namespace prefix, if present (may be absent)
246+
possible_prefixes = [pre for pre, uri in ns.items() if "xtce" in uri.lower()]
247+
248+
if len(possible_prefixes) == 1:
249+
# Exactly one namespace (possibly prefixed) that looks like XTCE
250+
xtce_ns_prefix = possible_prefixes[0]
251+
elif len(possible_prefixes) == 0:
252+
# This indicates no namespace is present (no xmlns attribute for XTCE)
253+
# Some XML documents do not use namespaces at all, which is invalid per the XTCE XSD and will fail XSD validation
254+
# We make an effort to parse these documents anyway, but warn the user
255+
xtce_ns_prefix = None
256+
warnings.warn(
257+
"No XTCE namespace found in the document. This is invalid per XSD, but will be parsed. "
258+
"Add an `xmlns` attribute to the root XML element to enable namespace awareness.",
259+
UserWarning,
260+
)
261+
else:
262+
# If there are multiple namespaces that look like XTCE, we cannot determine which one to use
263+
raise ValueError(f"Multiple XTCE namespace prefixes found in the document: {possible_prefixes}. ")
264+
265+
# These change class attributes on the NamespaceAwareElement class,
266+
# which allow the XTCE parser to correctly handle namespaces when parsing (and serializing) elements later on
267+
xtce_element_class.set_ns_prefix(xtce_ns_prefix)
268+
xtce_element_class.set_nsmap(ns)
241269

242270
header = space_system.find("Header")
243271

0 commit comments

Comments
 (0)