Skip to content

Commit 0e08ff1

Browse files
authored
Python testing mypy conformance (project-chip#38165)
* mypy type stubs for apps and tasks * fix mypy warnings in taglist_and_topology_test.py * fix mypy warnings in pics.py * gh action yml * gh workflow update * run mypy from python env * debug * fix filename * resolve mypy warnings in taglist file * ignore mypy warning * linter fixes * lint fix 2 * resolve review comments
1 parent 326cabf commit 0e08ff1

File tree

9 files changed

+198
-28
lines changed

9 files changed

+198
-28
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Copyright (c) 2025 Project CHIP Authors
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 implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
name: Mypy Type Validation
16+
17+
on:
18+
push:
19+
branches: [ master ]
20+
paths:
21+
- 'src/python_testing/matter_testing_infrastructure/**/*.py'
22+
pull_request:
23+
paths:
24+
- 'src/python_testing/matter_testing_infrastructure/**/*.py'
25+
26+
jobs:
27+
mypy-check:
28+
runs-on: ubuntu-latest
29+
if: github.actor != 'restyled-io[bot]'
30+
31+
container:
32+
image: ghcr.io/project-chip/chip-build:119
33+
options: --privileged --sysctl "net.ipv6.conf.all.disable_ipv6=0 net.ipv4.conf.all.forwarding=1 net.ipv6.conf.all.forwarding=1"
34+
35+
steps:
36+
- name: Checkout
37+
uses: actions/checkout@v4
38+
39+
- name: Checkout submodules & Bootstrap
40+
uses: ./.github/actions/checkout-submodules-and-bootstrap
41+
with:
42+
platform: linux
43+
44+
- name: Build Python environment
45+
run: |
46+
scripts/run_in_build_env.sh './scripts/build_python.sh --install_virtual_env out/venv'
47+
48+
- name: Run mypy validation
49+
run: |
50+
# List the directory to debug the file structure
51+
ls -la src/python_testing/matter_testing_infrastructure/chip/testing/
52+
53+
# TODO: Expand this list to include more files once they are mypy-compatible
54+
# Eventually we should just check all files in the chip/testing directory
55+
56+
./scripts/run_in_python_env.sh out/venv "mypy --config-file=src/python_testing/matter_testing_infrastructure/mypy.ini \
57+
src/python_testing/matter_testing_infrastructure/chip/testing/apps.py \
58+
src/python_testing/matter_testing_infrastructure/chip/testing/tasks.py \
59+
src/python_testing/matter_testing_infrastructure/chip/testing/taglist_and_topology_test.py \
60+
src/python_testing/matter_testing_infrastructure/chip/testing/pics.py"
61+
62+
# Print a reminder about expanding coverage
63+
echo "⚠️ NOTE: Currently only checking a subset of files. Remember to expand coverage!"

src/python_testing/matter_testing_infrastructure/chip/testing/pics.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,26 @@ def parse_pics(lines: typing.List[str]) -> dict[str, bool]:
6464

6565

6666
def parse_pics_xml(contents: str) -> dict[str, bool]:
67-
pics = {}
67+
pics: dict[str, bool] = {}
6868
mytree = ET.fromstring(contents)
6969
for pi in mytree.iter('picsItem'):
70-
name = pi.find('itemNumber').text
71-
support = pi.find('support').text
70+
name_elem = pi.find('itemNumber')
71+
support_elem = pi.find('support')
72+
73+
# Raise an error if either element is None
74+
if name_elem is None:
75+
raise ValueError(f"PICS XML item missing 'itemNumber' element: {ET.tostring(pi, encoding='unicode')}")
76+
if support_elem is None:
77+
raise ValueError(f"PICS XML item missing 'support' element: {ET.tostring(pi, encoding='unicode')}")
78+
79+
# Raise an error if either text is None
80+
name = name_elem.text
81+
support = support_elem.text
82+
if name is None:
83+
raise ValueError(f"PICS XML item 'itemNumber' element missing text: {ET.tostring(pi, encoding='unicode')}")
84+
if support is None:
85+
raise ValueError(f"PICS XML item 'support' element missing text: {ET.tostring(pi, encoding='unicode')}")
86+
7287
pics[name] = int(json.loads(support.lower())) == 1
7388
return pics
7489

src/python_testing/matter_testing_infrastructure/chip/testing/taglist_and_topology_test.py

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#
2-
# Copyright (c) 2023 Project CHIP Authors
2+
# Copyright (c) 2025 Project CHIP Authors
33
# All rights reserved.
44
#
55
# Licensed under the Apache License, Version 2.0 (the "License");
@@ -18,7 +18,7 @@
1818
import functools
1919
from collections import defaultdict
2020
from dataclasses import dataclass, field
21-
from typing import Any
21+
from typing import Any, Dict, List, Set, Tuple
2222

2323
import chip.clusters as Clusters
2424
from chip.clusters.Types import Nullable
@@ -29,11 +29,11 @@ class TagProblem:
2929
root: int
3030
missing_attribute: bool
3131
missing_feature: bool
32-
duplicates: set[int]
33-
same_tag: set[int] = field(default_factory=set)
32+
duplicates: Set[int]
33+
same_tag: Set[int] = field(default_factory=set)
3434

3535

36-
def separate_endpoint_types(endpoint_dict: dict[int, Any]) -> tuple[list[int], list[int]]:
36+
def separate_endpoint_types(endpoint_dict: Dict[int, Any]) -> Tuple[List[int], List[int]]:
3737
"""Returns a tuple containing the list of flat endpoints and a list of tree endpoints"""
3838
flat = []
3939
tree = []
@@ -52,13 +52,13 @@ def separate_endpoint_types(endpoint_dict: dict[int, Any]) -> tuple[list[int], l
5252
return (flat, tree)
5353

5454

55-
def get_all_children(endpoint_id, endpoint_dict: dict[int, Any]) -> set[int]:
55+
def get_all_children(endpoint_id: int, endpoint_dict: Dict[int, Any]) -> Set[int]:
5656
"""Returns all the children (include subchildren) of the given endpoint
5757
This assumes we've already checked that there are no cycles, so we can do the dumb things and just trace the tree
5858
"""
59-
children = set()
59+
children: Set[int] = set()
6060

61-
def add_children(endpoint_id, children):
61+
def add_children(endpoint_id: int, children: Set[int]) -> None:
6262
immediate_children = endpoint_dict[endpoint_id][Clusters.Descriptor][Clusters.Descriptor.Attributes.PartsList]
6363
if not immediate_children:
6464
return
@@ -70,15 +70,16 @@ def add_children(endpoint_id, children):
7070
return children
7171

7272

73-
def find_tree_roots(tree_endpoints: list[int], endpoint_dict: dict[int, Any]) -> set[int]:
73+
def find_tree_roots(tree_endpoints: List[int], endpoint_dict: Dict[int, Any]) -> Set[int]:
7474
"""Returns a set of all the endpoints in tree_endpoints that are roots for a tree (not include singletons)"""
7575
tree_roots = set()
7676

77-
def find_tree_root(current_id):
77+
def find_tree_root(current_id: int) -> int:
7878
for endpoint_id, endpoint in endpoint_dict.items():
7979
if endpoint_id not in tree_endpoints:
8080
continue
81-
if current_id in endpoint[Clusters.Descriptor][Clusters.Descriptor.Attributes.PartsList]:
81+
parts_list = endpoint[Clusters.Descriptor][Clusters.Descriptor.Attributes.PartsList]
82+
if current_id in parts_list:
8283
# this is not the root, move up
8384
return find_tree_root(endpoint_id)
8485
return current_id
@@ -90,9 +91,9 @@ def find_tree_root(current_id):
9091
return tree_roots
9192

9293

93-
def parts_list_cycles(tree_endpoints: list[int], endpoint_dict: dict[int, Any]) -> list[int]:
94+
def parts_list_cycles(tree_endpoints: List[int], endpoint_dict: Dict[int, Any]) -> List[int]:
9495
"""Returns a list of all the endpoints in the tree_endpoints list that contain cycles"""
95-
def parts_list_cycle_detect(visited: set, current_id: int) -> bool:
96+
def parts_list_cycle_detect(visited: Set[int], current_id: int) -> bool:
9697
if current_id in visited:
9798
return True
9899
visited.add(current_id)
@@ -105,28 +106,28 @@ def parts_list_cycle_detect(visited: set, current_id: int) -> bool:
105106
cycles = []
106107
# This is quick enough that we can do all the endpoints without searching for the roots
107108
for endpoint_id in tree_endpoints:
108-
visited = set()
109+
visited: Set[int] = set()
109110
if parts_list_cycle_detect(visited, endpoint_id):
110111
cycles.append(endpoint_id)
111112
return cycles
112113

113114

114-
def create_device_type_lists(roots: list[int], endpoint_dict: dict[int, Any]) -> dict[int, dict[int, set[int]]]:
115+
def create_device_type_lists(roots: List[int], endpoint_dict: Dict[int, Any]) -> Dict[int, Dict[int, Set[int]]]:
115116
"""Returns a list of endpoints per device type for each root in the list"""
116-
device_types = {}
117+
device_types: Dict[int, Dict[int, Set[int]]] = {}
117118
for root in roots:
118-
tree_device_types = defaultdict(set)
119+
tree_device_types: Dict[int, Set[int]] = defaultdict(set)
119120
eps = get_all_children(root, endpoint_dict)
120121
eps.add(root)
121122
for ep in eps:
122123
for d in endpoint_dict[ep][Clusters.Descriptor][Clusters.Descriptor.Attributes.DeviceTypeList]:
123124
tree_device_types[d.deviceType].add(ep)
124-
device_types[root] = tree_device_types
125+
device_types[root] = dict(tree_device_types) # Convert defaultdict to dict before storing
125126

126127
return device_types
127128

128129

129-
def get_direct_children_of_root(endpoint_dict: dict[int, Any]) -> set[int]:
130+
def get_direct_children_of_root(endpoint_dict: Dict[int, Any]) -> Set[int]:
130131
root_children = set(endpoint_dict[0][Clusters.Descriptor][Clusters.Descriptor.Attributes.PartsList])
131132
direct_children = root_children
132133
for ep in root_children:
@@ -135,7 +136,7 @@ def get_direct_children_of_root(endpoint_dict: dict[int, Any]) -> set[int]:
135136
return direct_children
136137

137138

138-
def create_device_type_list_for_root(direct_children, endpoint_dict: dict[int, Any]) -> dict[int, set[int]]:
139+
def create_device_type_list_for_root(direct_children: Set[int], endpoint_dict: Dict[int, Any]) -> Dict[int, Set[int]]:
139140
device_types = defaultdict(set)
140141
for ep in direct_children:
141142
for d in endpoint_dict[ep][Clusters.Descriptor][Clusters.Descriptor.Attributes.DeviceTypeList]:
@@ -147,19 +148,23 @@ def cmp_tag_list(a: Clusters.Descriptor.Structs.SemanticTagStruct, b: Clusters.D
147148
if type(a.mfgCode) != type(b.mfgCode):
148149
return -1 if type(a.mfgCode) is Nullable else 1
149150
if a.mfgCode != b.mfgCode:
150-
return -1 if a.mfgCode < b.mfgCode else 1
151+
# Adding type ignore for the comparison between potentially incompatible types
152+
result = -1 if a.mfgCode < b.mfgCode else 1 # type: ignore
153+
return result
151154
if a.namespaceID != b.namespaceID:
152155
return -1 if a.namespaceID < b.namespaceID else 1
153156
if a.tag != b.tag:
154157
return -1 if a.tag < b.tag else 1
155158
if type(a.label) != type(b.label):
156159
return -1 if type(a.label) is Nullable or a.label is None else 1
157160
if a.label != b.label:
158-
return -1 if a.label < b.label else 1
161+
# Adding type ignore for the comparison between potentially incompatible types
162+
result = -1 if a.label < b.label else 1 # type: ignore
163+
return result
159164
return 0
160165

161166

162-
def find_tag_list_problems(roots: list[int], device_types: dict[int, dict[int, set[int]]], endpoint_dict: dict[int, Any]) -> dict[int, TagProblem]:
167+
def find_tag_list_problems(roots: List[int], device_types: Dict[int, Dict[int, Set[int]]], endpoint_dict: Dict[int, Any]) -> Dict[int, TagProblem]:
163168
"""Checks for non-spec compliant tag lists"""
164169
tag_problems = {}
165170
for root in roots:
@@ -196,7 +201,7 @@ def find_tag_list_problems(roots: list[int], device_types: dict[int, dict[int, s
196201
return tag_problems
197202

198203

199-
def flat_list_ok(flat_endpoint_id_to_check: int, endpoints_dict: dict[int, Any]) -> bool:
204+
def flat_list_ok(flat_endpoint_id_to_check: int, endpoints_dict: Dict[int, Any]) -> bool:
200205
'''Checks if the (flat) PartsList on the supplied endpoint contains all the sub-children of its parts.'''
201206
sub_children = set()
202207
for child in endpoints_dict[flat_endpoint_id_to_check][Clusters.Descriptor][Clusters.Descriptor.Attributes.PartsList]:

src/python_testing/matter_testing_infrastructure/chip/testing/tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ def terminate(self):
154154
self.p.terminate()
155155
self.join()
156156

157-
def wait(self, timeout: Optional[float] = None) -> int:
157+
def wait(self, timeout: Optional[float] = None) -> Optional[int]:
158158
"""Wait for the subprocess to finish."""
159159
self.join(timeout)
160160
return self.returncode
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# This file is a stub for the chip.testing package
2+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from typing import Any
2+
3+
from chip.testing.tasks import Subprocess
4+
5+
class AppServerSubprocess(Subprocess):
6+
PREFIX: bytes
7+
8+
def __init__(self, app: str, storage_dir: str, discriminator: int,
9+
passcode: int, port: int = ...) -> None: ...
10+
11+
def __del__(self) -> None: ...
12+
13+
kvs_fd: int
14+
15+
16+
class IcdAppServerSubprocess(AppServerSubprocess):
17+
paused: bool
18+
19+
def __init__(self, *args: Any, **kwargs: Any) -> None: ...
20+
21+
def pause(self, check_state: bool = ...) -> None: ...
22+
23+
def resume(self, check_state: bool = ...) -> None: ...
24+
25+
def terminate(self) -> None: ...
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import typing
2+
3+
def attribute_pics_str(pics_base: str, id: int) -> str: ...
4+
def accepted_cmd_pics_str(pics_base: str, id: int) -> str: ...
5+
def generated_cmd_pics_str(pics_base: str, id: int) -> str: ...
6+
def feature_pics_str(pics_base: str, bit: int) -> str: ...
7+
def server_pics_str(pics_base: str) -> str: ...
8+
def client_pics_str(pics_base: str) -> str: ...
9+
def parse_pics(lines: typing.List[str]) -> dict[str, bool]: ...
10+
def parse_pics_xml(contents: str) -> dict[str, bool]: ...
11+
def read_pics_from_file(path: str) -> dict[str, bool]: ...
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import threading
2+
from typing import Any, BinaryIO, Callable, Optional, Pattern, Union
3+
4+
def forward_f(f_in: BinaryIO, f_out: BinaryIO,
5+
cb: Optional[Callable[[bytes, bool], bytes]] = ...,
6+
is_stderr: bool = ...) -> None: ...
7+
8+
9+
class Subprocess(threading.Thread):
10+
program: str
11+
args: tuple[str, ...]
12+
output_cb: Optional[Callable[[bytes, bool], bytes]]
13+
f_stdout: BinaryIO
14+
f_stderr: BinaryIO
15+
output_match: Optional[Pattern[bytes]]
16+
returncode: Optional[int]
17+
p: Any
18+
event: threading.Event
19+
event_started: threading.Event
20+
expected_output: Optional[Union[str, Pattern[bytes]]]
21+
22+
def __init__(self, program: str, *args: str,
23+
output_cb: Optional[Callable[[bytes, bool], bytes]] = ...,
24+
f_stdout: BinaryIO = ...,
25+
f_stderr: BinaryIO = ...) -> None: ...
26+
27+
def _set_output_match(self, pattern: Union[str, Pattern[bytes]]) -> None: ...
28+
29+
def _check_output(self, line: bytes, is_stderr: bool) -> bytes: ...
30+
31+
def run(self) -> None: ...
32+
33+
def start(self,
34+
expected_output: Optional[Union[str, Pattern[bytes]]] = ...,
35+
timeout: Optional[float] = ...) -> None: ...
36+
37+
def send(self, message: str, end: str = ...,
38+
expected_output: Optional[Union[str, Pattern[bytes]]] = ...,
39+
timeout: Optional[float] = ...) -> None: ...
40+
41+
def terminate(self) -> None: ...
42+
43+
def wait(self, timeout: Optional[float] = ...) -> Optional[int]: ...
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[mypy]
2+
mypy_path = chip/typings
3+
namespace_packages = True
4+
warn_unused_configs = True
5+
ignore_missing_imports = True
6+
explicit_package_bases = True

0 commit comments

Comments
 (0)