Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Creating Namespace parser and unit test for Cecille's PR #37527 #4

Open
wants to merge 7 commits into
base: update_dms_and_add_namespace_docs
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@ jobs:
scripts/run_in_python_env.sh out/venv 'python3 src/python_testing/TestIdChecks.py'
scripts/run_in_python_env.sh out/venv 'python3 src/python_testing/TestMatterTestingSupport.py'
scripts/run_in_python_env.sh out/venv 'python3 src/python_testing/TestSpecParsingDeviceType.py'
scripts/run_in_python_env.sh out/venv 'python3 src/python_testing/TestSpecParsingNamespace.py'
scripts/run_in_python_env.sh out/venv 'python3 src/python_testing/TestSpecParsingSelection.py'
scripts/run_in_python_env.sh out/venv 'python3 src/python_testing/TestSpecParsingSupport.py'

Expand Down
290 changes: 290 additions & 0 deletions src/python_testing/TestSpecParsingNamespace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
#
# Copyright (c) 2025 Project CHIP Authors
# All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

import xml.etree.ElementTree as ElementTree
from jinja2 import Template
import os

from chip.testing.matter_testing import (MatterBaseTest, default_matter_test_main,
ProblemNotice, ProblemSeverity, NamespacePathLocation)
from chip.testing.spec_parsing import (XmlNamespace, parse_namespace,
build_xml_namespaces)
from mobly import asserts

class TestSpecParsingNamespace(MatterBaseTest):
def setup_class(self):
# Get the data model paths
self.dm_1_3 = os.path.join(os.path.dirname(__file__), "..", "..", "data_model", "1.3")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to do this just using the PrebuiltDatamodelDirectory enum - added a comment below on how to do that in the parser.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ok, thank you.
That will be much cleaner, I had not realized that was available.

self.dm_1_4 = os.path.join(os.path.dirname(__file__), "..", "..", "data_model", "1.4")
self.dm_1_4_1 = os.path.join(os.path.dirname(__file__), "..", "..", "data_model", "1.4.1")
self.dm_master = os.path.join(os.path.dirname(__file__), "..", "..", "data_model", "master")

# Test data setup
self.namespace_id = 0x0001
self.namespace_name = "Test Namespace"
self.tags = {
0x0000: "Tag1",
0x0001: "Tag2",
0x0002: "Tag3"
}

# Template for generating test XML
self.template = Template("""<?xml version="1.0"?>
<namespace xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="types types.xsd namespace namespace.xsd"
id="{{ namespace_id }}"
name="{{ namespace_name }}">
<tags>
{% for id, name in tags.items() %}
<tag id="{{ "0x%04X" % id }}" name="{{ name }}"/>
{% endfor %}
</tags>
</namespace>""")

def test_namespace_parsing(self):
"""Test basic namespace parsing with valid data"""
xml = self.template.render(
namespace_id=f"0x{self.namespace_id:04X}",
namespace_name=self.namespace_name,
tags=self.tags
)
et = ElementTree.fromstring(xml)
namespace, problems = parse_namespace(et)

asserts.assert_equal(len(problems), 0, "Unexpected problems parsing namespace")
asserts.assert_equal(namespace.id, self.namespace_id, "Incorrect namespace ID")
asserts.assert_equal(namespace.name, self.namespace_name, "Incorrect namespace name")
asserts.assert_equal(len(namespace.tags), len(self.tags), "Incorrect number of tags")

for tag_id, tag_name in self.tags.items():
asserts.assert_true(tag_id in namespace.tags, f"Tag ID 0x{tag_id:04X} not found")
asserts.assert_equal(namespace.tags[tag_id].name, tag_name, f"Incorrect name for tag 0x{tag_id:04X}")

def test_bad_namespace_id(self):
"""Test parsing with invalid namespace ID"""
xml = self.template.render(
namespace_id="",
namespace_name=self.namespace_name,
tags=self.tags
)
et = ElementTree.fromstring(xml)
namespace, problems = parse_namespace(et)
asserts.assert_equal(len(problems), 1, "Namespace with blank ID did not generate a problem notice")

def test_missing_namespace_name(self):
"""Test parsing with missing namespace name"""
xml = self.template.render(
namespace_id=f"0x{self.namespace_id:04X}",
namespace_name="",
tags=self.tags
)
et = ElementTree.fromstring(xml)
namespace, problems = parse_namespace(et)
asserts.assert_equal(len(problems), 1, "Namespace with no name did not generate a problem notice")

def test_no_tags(self):
"""Test parsing with no tags"""
xml = self.template.render(
namespace_id=f"0x{self.namespace_id:04X}",
namespace_name=self.namespace_name,
tags={}
)
et = ElementTree.fromstring(xml)
namespace, problems = parse_namespace(et)
asserts.assert_equal(len(problems), 0, "Unexpected problems parsing empty namespace")
asserts.assert_equal(len(namespace.tags), 0, "Empty namespace should have no tags")

def test_spec_files(self):
"""Test parsing actual spec files from different versions"""
one_three, _ = build_xml_namespaces(self.dm_1_3)
one_four, one_four_problems = build_xml_namespaces(self.dm_1_4)
one_four_one, one_four_one_problems = build_xml_namespaces(self.dm_1_4_1)
tot, tot_problems = build_xml_namespaces(self.dm_master)

asserts.assert_equal(len(one_four_problems), 0, "Problems found when parsing 1.4 spec")
asserts.assert_equal(len(one_four_one_problems), 0, "Problems found when parsing 1.4.1 spec")

# Check version relationships
asserts.assert_greater(len(set(tot.keys()) - set(one_three.keys())),
0, "Master dir does not contain any namespaces not in 1.3")
asserts.assert_greater(len(set(tot.keys()) - set(one_four.keys())),
0, "Master dir does not contain any namespaces not in 1.4")
asserts.assert_greater(len(set(one_four.keys()) - set(one_three.keys())),
0, "1.4 dir does not contain any namespaces not in 1.3")

# Check version consistency
asserts.assert_equal(set(one_four.keys()) - set(one_four_one.keys()),
set(), "There are some 1.4 namespaces that are unexpectedly not included in the 1.4.1 files")
asserts.assert_equal(set(one_four.keys()) - set(tot.keys()),
set(), "There are some 1.4 namespaces that are unexpectedly not included in the TOT files")

def validate_namespace_xml(self, xml_file: str) -> list[ProblemNotice]:
# Validating XML namespace files
problems = []
try:
tree = ElementTree.parse(xml_file)
root = tree.getroot()

# Check for namespace ID and validate format
namespace_id = root.get('id')
if not namespace_id:
problems.append(ProblemNotice(
test_name="Validate Namespace XML",
location=NamespacePathLocation(),
severity=ProblemSeverity.WARNING,
problem=f"Missing namespace ID in {xml_file}"
))
else:
# Validate 16-bit hex format (0xNNNN)
try:
# Remove '0x' prefix if present and try to parse
id_value = int(namespace_id.replace('0x', ''), 16)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you use int( .... , 0) here, that's probably fine - I don't know that these necessarily need to be hex.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, I have updated the code here to reflect the above change mentioned.

if id_value < 0 or id_value > 0xFFFF:
problems.append(ProblemNotice(
test_name="Validate Namespace XML",
location=NamespacePathLocation(),
severity=ProblemSeverity.WARNING,
problem=f"Namespace ID {namespace_id} is not a valid 16-bit value in {xml_file}"
))

# Check format is exactly 0xNNNN where N is a hex digit
if not namespace_id.lower().startswith('0x') or len(namespace_id) != 6:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also fine - I don't think these need to be that strict.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ok, I left this section as is for now but can swap it over if needed. Was trying to be as thorough as possible, but I might have gone overboard in that regards a little bit here it appears lol.

problems.append(ProblemNotice(
test_name="Validate Namespace XML",
location=NamespacePathLocation(),
severity=ProblemSeverity.WARNING,
problem=f"Namespace ID {namespace_id} does not follow required format '0xNNNN' in {xml_file}"
))
except ValueError:
problems.append(ProblemNotice(
test_name="Validate Namespace XML",
location=NamespacePathLocation(),
severity=ProblemSeverity.WARNING,
problem=f"Invalid hex format for namespace ID {namespace_id} in {xml_file}"
))

# Check for namespace name
namespace_name = root.get('name', '').strip()
if not namespace_name:
problems.append(ProblemNotice(
test_name="Validate Namespace XML",
location=NamespacePathLocation(),
severity=ProblemSeverity.WARNING,
problem=f"Missing or empty namespace name in {xml_file}"
))

# Check tags structure
tags_elem = root.find('tags')
if tags_elem is not None:
for tag in tags_elem.findall('tag'):
# Check tag ID and validate format
tag_id = tag.get('id')
if not tag_id:
problems.append(ProblemNotice(
test_name="Validate Namespace XML",
location=NamespacePathLocation(),
severity=ProblemSeverity.WARNING,
problem=f"Missing tag ID in {xml_file}"
))
else:
# Validate 16-bit hex format for tags (0xNNNN)
try:
# Remove '0x' prefix if present and try to parse
id_value = int(tag_id.replace('0x', ''), 16)
if id_value < 0 or id_value > 0xFFFF:
problems.append(ProblemNotice(
test_name="Validate Namespace XML",
location=NamespacePathLocation(),
severity=ProblemSeverity.WARNING,
problem=f"Tag ID {tag_id} is not a valid 16-bit value in {xml_file}"
))
# Check format is exactly 0xNNNN where N is hex digit
if not tag_id.lower().startswith('0x') or len(tag_id) != 6:
problems.append(ProblemNotice(
test_name="Validate Namespace XML",
location=NamespacePathLocation(),
severity=ProblemSeverity.WARNING,
problem=f"Tag ID {tag_id} does not follow required format '0xNNNN' in {xml_file}"
))
except ValueError:
problems.append(ProblemNotice(
test_name="Validate Namespace XML",
location=NamespacePathLocation(),
severity=ProblemSeverity.WARNING,
problem=f"Invalid hex format for tag ID {tag_id} in {xml_file}"
))

# Check tag name
tag_name = tag.get('name', '').strip()
if not tag_name:
problems.append(ProblemNotice(
test_name="Validate Namespace XML",
location=NamespacePathLocation(),
severity=ProblemSeverity.WARNING,
problem=f"Missing or empty tag name in {xml_file}"
))

except Exception as e:
problems.append(ProblemNotice(
test_name="Validate Namespace XML",
location=NamespacePathLocation(),
severity=ProblemSeverity.WARNING,
problem=f"Failed to parse {xml_file}: {str(e)}"
))

return problems

def test_all_namespace_files(self):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice.

Copy link
Author

@j-ororke j-ororke Feb 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!
I tried to test all scenarios that I could think of with the namespace files.
It also helped a lot that I had the TestSpecParsingDeviceTypes.py code to reflect on.

"""Test all namespace XML files in the 1.4 and 1.4.1 data model directories"""
data_model_versions = {
"1.4": self.dm_1_4,
"1.4.1": self.dm_1_4_1,
}

for version, dm_path in data_model_versions.items():
namespace_path = os.path.join(dm_path, "namespaces")
if not os.path.exists(namespace_path):
self.print_step("Issue encountered", f"\nSkipping {version} - namespace directory not found")
continue

for filename in os.listdir(namespace_path):
if not filename.endswith('.xml'):
continue

filepath = os.path.join(namespace_path, filename)
problems = self.validate_namespace_xml(filepath)

if problems:
for problem in problems:
self.print_step("problem", problem)

# Run the same validation we did for generated XML
tree = ElementTree.parse(filepath)
namespace, parse_problems = parse_namespace(tree.getroot())

# Verify namespace has required attributes
asserts.assert_true(hasattr(namespace, 'id'), f"Namespace in {filename} missing ID")
asserts.assert_true(hasattr(namespace, 'name'), f"Namespace in {filename} missing name")
asserts.assert_true(hasattr(namespace, 'tags'), f"Namespace in {filename} missing tags dictionary")

# Verify each tag has required attributes
for tag_id, tag in namespace.tags.items():
asserts.assert_true(hasattr(tag, 'id'), f"Tag in {filename} missing ID")
asserts.assert_true(hasattr(tag, 'name'), f"Tag in {filename} missing name")

if __name__ == "__main__":
default_matter_test_main()
Original file line number Diff line number Diff line change
Expand Up @@ -896,13 +896,27 @@ def __str__(self):
msg += f'\n ClusterID: {self.cluster_id}'
return msg

@dataclass
class NamespacePathLocation:
"""Location in a namespace definition"""
def __init__(self, namespace_id: Optional[int] = None, tag_id: Optional[int] = None):
self.namespace_id = namespace_id
self.tag_id = tag_id

def __str__(self) -> str:
result = "Namespace"
if self.namespace_id is not None:
result += f" 0x{self.namespace_id:04X}"
if self.tag_id is not None:
result += f" Tag 0x{self.tag_id:04X}"
return result

class UnknownProblemLocation:
def __str__(self):
return '\n Unknown Locations - see message for more details'


ProblemLocation = typing.Union[ClusterPathLocation, DeviceTypePathLocation, UnknownProblemLocation]
ProblemLocation = typing.Union[ClusterPathLocation, DeviceTypePathLocation,
UnknownProblemLocation, NamespacePathLocation]

# ProblemSeverity is not using StrEnum, but rather Enum, since StrEnum only
# appeared in 3.11. To make it JSON serializable easily, multiple inheritance
Expand Down
Loading