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

scripts: add script to compare message sets #51

Merged
merged 1 commit into from
Aug 8, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ if (!px4_ros2::messageCompatibilityCheck(node, {{"fmu/in/vehicle_rates_setpoint"
}
```

To manually verify that two local versions of PX4 and px4_msgs have matching message sets, you can use the following script:

```sh
./scripts/check-message-compatibility.py -v path/to/px4_msgs/ path/to/PX4-Autopilot/
```

## Examples
There are code examples under [examples/cpp/modes](examples/cpp/modes).

Expand Down
174 changes: 174 additions & 0 deletions scripts/check-message-compatibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
""" Check message compatibility between two repositories containing a msg/ directory of .msg message definitions """

import os
import sys
import difflib
import re
import argparse

from typing import Optional

TOPIC_LIST_FILE = 'px4_ros2_cpp/include/px4_ros2/components/message_compatibility_check.hpp'
MESSAGES_DEFINE = 'ALL_PX4_ROS2_MESSAGES'


def message_fields_str_for_message_hash(topic_type: str, msgs_dir: str) -> str:
"""
Reads the .msg file corresponding to the given topic type, extracts field definitions,
and recursively processes nested types to generate a string representation of all fields.
"""
filename = f"{msgs_dir}/msg/{topic_type}.msg"
try:
with open(filename, 'r') as file:
text = file.read()
except IOError:
print(f"Failed to open {filename}")
return ""

fields_str = ""

# Regular expression to match field types from .msg definitions
msg_field_type_regex = re.compile(
r"(?:^|\n)\s*([a-zA-Z0-9_/]+)(\[[^\]]*\])?\s+(\w+)[ \t]*(=)?"
)

# Set of basic types
basic_types = {
"bool", "byte", "char", "float32", "float64",
"int8", "uint8", "int16", "uint16", "int32",
"uint32", "int64", "uint64", "string", "wstring"
}

# Iterate over all matches in the text
for match in msg_field_type_regex.finditer(text):
type_, array, field_name, constant = match.groups()

if constant == "=":
continue

fields_str += f"{type_}{array} {field_name}\n"

if type_ not in basic_types:
if '/' not in type_:
# Recursive call to handle nested types
fields_str += message_fields_str_for_message_hash(type_, msgs_dir)
else:
raise ValueError(f"Field {filename} contains namespace {type_}")

return fields_str


def hash32_fnv1a_const(s: str) -> int:
"""Computes the 32-bit FNV-1a hash of a given string"""
kVal32Const = 0x811c9dc5
kPrime32Const = 0x1000193
hash_value = kVal32Const
for c in s:
hash_value ^= ord(c)
hash_value *= kPrime32Const
hash_value &= 0xFFFFFFFF
return hash_value


def message_hash(topic_type: str, msgs_dir: str) -> int:
"""Generate a hash from a message definition file"""
message_fields_str = message_fields_str_for_message_hash(topic_type, msgs_dir)
return hash32_fnv1a_const(message_fields_str)


def snake_to_pascal(name: str) -> str:
"""Convert snake_case to PascalCase"""
return f'{name.replace("_", " ").title().replace(" ", "")}'


def extract_message_type_from_file(filename: str, extract_start_after: Optional[str] = None,
extract_end_before: Optional[str] = None) -> list[str]:
"""Extract message type names from a given file"""
with open(filename) as file:
if extract_start_after is not None:
for line in file:
if re.search(extract_start_after, line):
break

message_types = set()
for line in file:
m = re.search(r'"fmu/(in|out)/([^"]+)"(?:, "([^"]+)")?', line)
if m:
if m.group(3):
# Use the second element directly if available
message_types.add(m.group(3))
else:
# Convert to PascalCase if no second element is present
message_types.add(snake_to_pascal(m.group(2)))

if extract_end_before is not None and re.search(extract_end_before, line):
break

return list(message_types)


def compare_files(file1: str, file2: str):
"""Compare two files and print their differences. """
with open(file1, 'r') as f1, open(file2, 'r') as f2:
diff = list(difflib.unified_diff(f1.readlines(), f2.readlines(), fromfile=file1, tofile=file2))
if diff:
print(f"Mismatch found between {file1} and {file2}:")
print(''.join(diff), end='\n\n')
return False
return True


def main(repo1: str, repo2: str, verbose: bool = False):
if not os.path.isdir(repo1) or not os.path.isdir(repo2):
print("Both arguments must be directories.")
sys.exit(1)

# Retrieve list of message types to check
messages_types = sorted(extract_message_type_from_file(
os.path.join(os.path.dirname(__file__), '..', TOPIC_LIST_FILE),
MESSAGES_DEFINE,
r'^\s*$')
)

if verbose:
print("Checking the following message files:", end='\n\n')
for msg_type in messages_types:
print(f" - {msg_type}.msg")
print()

# Find mismatches
incompatible_types = []
for msg_type in messages_types:
if message_hash(msg_type, repo1) != message_hash(msg_type, repo2):
incompatible_types.append(msg_type)

# Print result
if not incompatible_types:
print("OK! Messages are compatible.")
sys.exit(0)
else:
if verbose:
for msg_type in incompatible_types:
file1 = os.path.join(repo1, 'msg', f'{msg_type}.msg')
GuillaumeLaine marked this conversation as resolved.
Show resolved Hide resolved
file2 = os.path.join(repo2, 'msg', f'{msg_type}.msg')
compare_files(file1, file2)
print("Note: The printed diff includes all content differences. "
"The computed check is less sensitive to formatting and comments.", end='\n\n')
print("FAILED! Some files differ:")
for msg_type in incompatible_types:
print(f" - {msg_type}.msg")
sys.exit(1)


if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Check message compatibility between two repositories \
using the set of checked messages ALL_PX4_ROS2_MESSAGES.")
parser.add_argument('repo1', help="path to the first repo containing a msg/ directory \
(e.g /path/to/px4_msgs/)")
parser.add_argument('repo2', help="path to the second repo containing a msg/ directory \
(e.g /path/to/PX4-Autopilot/)")
parser.add_argument('-v', '--verbose', dest='verbose', action='store_true', help='verbose output')
args = parser.parse_args()

main(args.repo1, args.repo2, args.verbose)
6 changes: 4 additions & 2 deletions scripts/check-used-topics.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import os
import re

from typing import Optional

ignored_topics = ['message_format_request', 'message_format_response']

configs = [
Expand All @@ -17,8 +19,8 @@
project_root_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..')


def extract_topics_from_file(filename: str, extract_start_after: str = None,
extract_end_before: str = None) -> list[str]:
def extract_topics_from_file(filename: str, extract_start_after: Optional[str] = None,
extract_end_before: Optional[str] = None) -> list[str]:
with open(filename) as file:
if extract_start_after is not None:
for line in file:
Expand Down
Loading