diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2f862ee40..2e0d1466d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,11 +33,23 @@ repos: - id: flake8 name: flake8 additional_dependencies: - - flake8-blind-except - - flake8-class-newline + # The five ament_flake8 declares as exec_depend on Jazzy, so code + # that fails quality.yml also fails here - though only for the + # files a commit touches; quality.yml lints the whole package. + # quotes, builtins and comprehensions were missing, which is how Q, + # A and C4 came to run in CI and nowhere locally. + - flake8-builtins + - flake8-comprehensions - flake8-docstrings - flake8-import-order - # Config read from .flake8 (matches ament_flake8 defaults) + - flake8-quotes + # Local-only. ament_flake8 comments both out of its package.xml, so + # CI does not run them; keeping them here makes this hook a superset + # of CI rather than a match, which is the safe direction. + - flake8-blind-except + - flake8-class-newline + # Config read from .flake8, which is also what ament_flake8 reads (see + # the CONFIG_FILE argument in ros2_medkit_integration_tests). # ── General ──────────────────────────────────────────────────────────── - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/Dockerfile b/Dockerfile index 8a15d05fc..86852f57f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -84,7 +84,7 @@ RUN bash -c "source /opt/ros/${ROS_DISTRO}/setup.bash && \ apt-get update && \ rosdep update && \ rosdep install --from-paths src --ignore-src -r -y \ - --skip-keys='ament_cmake_clang_format ament_cmake_clang_tidy test_msgs sqlite3 libcpp-httplib-dev rosbag2_storage_mcap' && \ + --skip-keys='ament_cmake_clang_format ament_cmake_clang_tidy ament_cmake_flake8 test_msgs sqlite3 libcpp-httplib-dev rosbag2_storage_mcap' && \ rm -rf /var/lib/apt/lists/* && \ colcon build --cmake-args -DBUILD_TESTING=OFF" diff --git a/docs/conf.py b/docs/conf.py index f994904fb..1761a2e1b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,135 +19,135 @@ import matplotlib -matplotlib.use("Agg") +matplotlib.use('Agg') import matplotlib.pyplot as plt # Ensure readable plots -matplotlib.rcParams["figure.facecolor"] = "white" -matplotlib.rcParams["text.color"] = "black" -matplotlib.rcParams["legend.frameon"] = True -matplotlib.rcParams["legend.framealpha"] = 0.8 -matplotlib.rcParams["legend.facecolor"] = "white" -matplotlib.rcParams["legend.edgecolor"] = "gray" -matplotlib.rcParams["figure.autolayout"] = True -matplotlib.rcParams["figure.figsize"] = [10, 6] -matplotlib.rcParams["savefig.bbox"] = "tight" +matplotlib.rcParams['figure.facecolor'] = 'white' +matplotlib.rcParams['text.color'] = 'black' +matplotlib.rcParams['legend.frameon'] = True +matplotlib.rcParams['legend.framealpha'] = 0.8 +matplotlib.rcParams['legend.facecolor'] = 'white' +matplotlib.rcParams['legend.edgecolor'] = 'gray' +matplotlib.rcParams['figure.autolayout'] = True +matplotlib.rcParams['figure.figsize'] = [10, 6] +matplotlib.rcParams['savefig.bbox'] = 'tight' from datetime import datetime # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -project = "ros2_medkit" -project_copyright = f"{datetime.now().year}, selfpatch" -author = "selfpatch Team" +project = 'ros2_medkit' +project_copyright = f'{datetime.now().year}, selfpatch' +author = 'selfpatch Team' -version = "0.6.0" -release = "0.6.0" +version = '0.6.0' +release = '0.6.0' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.viewcode", - "sphinx.ext.intersphinx", - "sphinx_needs", - "sphinxcontrib.plantuml", - "sphinx_design", - "breathe", - "sphinx_copybutton", + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode', + 'sphinx.ext.intersphinx', + 'sphinx_needs', + 'sphinxcontrib.plantuml', + 'sphinx_design', + 'breathe', + 'sphinx_copybutton', ] # -- Options for Breathe (Doxygen integration) ------------------------------- -breathe_projects = {"ros2_medkit": "_build/doxygen/xml"} -breathe_default_project = "ros2_medkit" -breathe_default_members = ("members", "undoc-members") +breathe_projects = {'ros2_medkit': '_build/doxygen/xml'} +breathe_default_project = 'ros2_medkit' +breathe_default_members = ('members', 'undoc-members') -templates_path = ["_templates"] -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", ".venv"] +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', '.venv'] # The suffix(es) of source filenames source_suffix = { - ".rst": "restructuredtext", + '.rst': 'restructuredtext', } # The master toctree document -master_doc = "index" +master_doc = 'index' # The language for content autogenerated by Sphinx -language = "en" +language = 'en' # -- Options for Sphinx-Needs ------------------------------------------------ needs_build_json = True needs_types = [ - dict( - directive="req", - title="Requirement", - prefix="REQ_", - color="#BFD8D2", - style="node", - ), - dict( - directive="spec", - title="Specification", - prefix="SPEC_", - color="#FEDCD2", - style="node", - ), - dict( - directive="impl", - title="Implementation", - prefix="IMPL_", - color="#DF744A", - style="node", - ), - dict( - directive="test", - title="Test Case", - prefix="TEST_", - color="#DCB239", - style="node", - ), + { + 'directive': 'req', + 'title': 'Requirement', + 'prefix': 'REQ_', + 'color': '#BFD8D2', + 'style': 'node', + }, + { + 'directive': 'spec', + 'title': 'Specification', + 'prefix': 'SPEC_', + 'color': '#FEDCD2', + 'style': 'node', + }, + { + 'directive': 'impl', + 'title': 'Implementation', + 'prefix': 'IMPL_', + 'color': '#DF744A', + 'style': 'node', + }, + { + 'directive': 'test', + 'title': 'Test Case', + 'prefix': 'TEST_', + 'color': '#DCB239', + 'style': 'node', + }, ] needs_extra_links = [ { - "option": "verifies", - "incoming": "is verified by", - "outgoing": "verifies", - "copy": False, - "allow_dead_links": False, + 'option': 'verifies', + 'incoming': 'is verified by', + 'outgoing': 'verifies', + 'copy': False, + 'allow_dead_links': False, }, ] # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = "sphinx_rtd_theme" -html_static_path = ["_static"] -html_css_files = ["custom.css"] -html_title = f"{project} Documentation" +html_theme = 'sphinx_rtd_theme' +html_static_path = ['_static'] +html_css_files = ['custom.css'] +html_title = f'{project} Documentation' html_theme_options = { - "prev_next_buttons_location": "bottom", - "style_external_links": False, - "collapse_navigation": False, - "sticky_navigation": True, - "navigation_depth": 4, - "includehidden": True, - "titles_only": False, + 'prev_next_buttons_location': 'bottom', + 'style_external_links': False, + 'collapse_navigation': False, + 'sticky_navigation': True, + 'navigation_depth': 4, + 'includehidden': True, + 'titles_only': False, } # -- Options for intersphinx extension --------------------------------------- intersphinx_mapping = { - "python": ("https://docs.python.org/3", None), - "sphinx": ("https://www.sphinx-doc.org/en/master", None), + 'python': ('https://docs.python.org/3', None), + 'sphinx': ('https://www.sphinx-doc.org/en/master', None), } # -- Options for PlantUML ---------------------------------------------------- -plantuml = "java -Djava.awt.headless=true -jar /usr/share/plantuml/plantuml.jar" -plantuml_output_format = "svg" +plantuml = 'java -Djava.awt.headless=true -jar /usr/share/plantuml/plantuml.jar' +plantuml_output_format = 'svg' # -- Options for linkcheck --------------------------------------------------- # Retry transient external failures (e.g. docs.ros.org read timeouts) instead of @@ -155,16 +155,16 @@ linkcheck_retries = 3 linkcheck_timeout = 30 linkcheck_ignore = [ - r"http://localhost:\d+", # Ignore localhost URLs - r"http://127\.0\.0\.1:\d+", + r'http://localhost:\d+', # Ignore localhost URLs + r'http://127\.0\.0\.1:\d+', # Auto-generated line-number links in verification.rst; skipped to # avoid 395+ GitHub requests (rate-limiting) and anchor mismatches # on branches where files differ from main. - r"https://github\.com/selfpatch/ros2_medkit/blob/.+#L\d+", + r'https://github\.com/selfpatch/ros2_medkit/blob/.+#L\d+', # GitHub issue links may 404 in linkcheck without auth token - r"https://github\.com/selfpatch/ros2_medkit/issues/\d+", + r'https://github\.com/selfpatch/ros2_medkit/issues/\d+', # The rep-0149 page intermittently exceeds the read timeout in CI; the # canonical REP host is slow, not broken. Scope the skip to this exact URL # so other REP links stay checked. - r"https://ros\.org/reps/rep-0149\.html", + r'https://ros\.org/reps/rep-0149\.html', ] diff --git a/scripts/generate_verification.py b/scripts/generate_verification.py index d7030f63a..9526f73bc 100755 --- a/scripts/generate_verification.py +++ b/scripts/generate_verification.py @@ -22,10 +22,10 @@ # Script is in /scripts/generate_verification.py SCRIPT_DIR = Path(__file__).parent.resolve() WORKSPACE_DIR = SCRIPT_DIR.parent -SRC_DIR = WORKSPACE_DIR / "src" -OUTPUT_FILE = WORKSPACE_DIR / "docs/requirements/verification.rst" -REQUIREMENTS_SPECS_DIR = WORKSPACE_DIR / "docs/requirements/specs" -GITHUB_BASE_URL = "https://github.com/selfpatch/ros2_medkit/blob/main" +SRC_DIR = WORKSPACE_DIR / 'src' +OUTPUT_FILE = WORKSPACE_DIR / 'docs/requirements/verification.rst' +REQUIREMENTS_SPECS_DIR = WORKSPACE_DIR / 'docs/requirements/specs' +GITHUB_BASE_URL = 'https://github.com/selfpatch/ros2_medkit/blob/main' def _extract_verifies_before(content, match_start, comment_prefix): @@ -35,7 +35,7 @@ def _extract_verifies_before(content, match_start, comment_prefix): that contain @verifies (or Links to: / Verifies:) tags. """ before_text = content[:match_start] - before_lines = before_text.rstrip().split("\n") + before_lines = before_text.rstrip().split('\n') verifies_reqs = [] for line in reversed(before_lines): @@ -46,11 +46,11 @@ def _extract_verifies_before(content, match_start, comment_prefix): break comment_content = stripped[len(comment_prefix):].strip() tag_match = re.match( - r"(?:@verifies|Links to:|Verifies:)\s*(.*)", comment_content + r'(?:@verifies|Links to:|Verifies:)\s*(.*)', comment_content ) if tag_match: reqs_text = tag_match.group(1) - reqs = re.findall(r"(REQ_\w+)", reqs_text) + reqs = re.findall(r'(REQ_\w+)', reqs_text) verifies_reqs.extend(reqs) else: break @@ -59,12 +59,12 @@ def _extract_verifies_before(content, match_start, comment_prefix): def parse_cpp_file(file_path): """Parse C++ test file for @verifies tags.""" - with open(file_path, "r", encoding="utf-8") as f: + with open(file_path, 'r', encoding='utf-8') as f: content = f.read() # Regex to find TEST or TEST_F blocks # Matches: TEST_F(Suite, Name) { ... } - test_pattern = re.compile(r"TEST(?:_F)?\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)\s*\{") + test_pattern = re.compile(r'TEST(?:_F)?\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)\s*\{') tests = [] @@ -75,10 +75,10 @@ def parse_cpp_file(file_path): # Extract a chunk of text after the match to search for comments search_window = content[start_index:start_index + 2000] - lines = search_window.split("\n") + lines = search_window.split('\n') # Auto-generate ID and Title, including suite to avoid collisions - test_id = f"TEST_{suite_name}_{test_name}" + test_id = f'TEST_{suite_name}_{test_name}' test_title = test_name line_number = content[:match.start()].count('\n') + 1 @@ -87,7 +87,7 @@ def parse_cpp_file(file_path): # Check comments before the TEST macro verifies_reqs.extend( - _extract_verifies_before(content, match.start(), "//") + _extract_verifies_before(content, match.start(), '//') ) for line in lines: @@ -96,9 +96,9 @@ def parse_cpp_file(file_path): continue # Stop if we hit code (not a comment) - if not line.startswith("//"): + if not line.startswith('//'): # If it's an opening brace on a new line, ignore - if line == "{": + if line == '{': continue break @@ -107,13 +107,13 @@ def parse_cpp_file(file_path): # Parse tags # Support '@verifies REQ_...' (list) or 'Links to: REQ_...' tag_match = re.match( - r"(?:@verifies|Links to:|Verifies:)\s*(.*)", comment_content + r'(?:@verifies|Links to:|Verifies:)\s*(.*)', comment_content ) if tag_match: reqs_text = tag_match.group(1) # Extract anything looking like REQ_\w+ - reqs = re.findall(r"(REQ_\w+)", reqs_text) + reqs = re.findall(r'(REQ_\w+)', reqs_text) verifies_reqs.extend(reqs) else: # Treat other comments as description @@ -122,13 +122,13 @@ def parse_cpp_file(file_path): if verifies_reqs: tests.append( { - "id": test_id, - "title": test_title, - "verifies": list(set(verifies_reqs)), - "description": "\n ".join(description), - "file": str(file_path.relative_to(WORKSPACE_DIR)), - "test_func": test_name, - "line": line_number, + 'id': test_id, + 'title': test_title, + 'verifies': list(set(verifies_reqs)), + 'description': '\n '.join(description), + 'file': str(file_path.relative_to(WORKSPACE_DIR)), + 'test_func': test_name, + 'line': line_number, } ) @@ -137,16 +137,16 @@ def parse_cpp_file(file_path): def parse_py_file(file_path): """Parse Python test file for @verifies tags.""" - with open(file_path, "r", encoding="utf-8") as f: + with open(file_path, 'r', encoding='utf-8') as f: content = f.read() # Regex to find python test methods # Matches: def test_something(self): - test_pattern = re.compile(r"def\s+(test_\w+)\s*\(self\):") + test_pattern = re.compile(r'def\s+(test_\w+)\s*\(self\):') # Build a list of (class_name, start_pos) so we can find which class # a test method belongs to. - class_pattern = re.compile(r"^class\s+(\w+)\s*[\(:]", re.MULTILINE) + class_pattern = re.compile(r'^class\s+(\w+)\s*[\(:]', re.MULTILINE) class_ranges = [] for cls_match in class_pattern.finditer(content): class_ranges.append((cls_match.group(1), cls_match.start())) @@ -169,14 +169,14 @@ def _find_class(pos): # Extract a chunk of text after the match to search for docstrings/comments search_window = content[start_index:start_index + 2000] - lines = search_window.split("\n") + lines = search_window.split('\n') # Auto-generate ID and Title, including class name to avoid collisions cls_name = _find_class(match.start()) if cls_name: - test_id = f"TEST_{cls_name}_{test_name}" + test_id = f'TEST_{cls_name}_{test_name}' else: - test_id = "TEST_" + test_name + test_id = 'TEST_' + test_name test_title = test_name line_number = content[:match.start()].count('\n') + 1 @@ -185,7 +185,7 @@ def _find_class(pos): # Check comments before the def line verifies_reqs.extend( - _extract_verifies_before(content, match.start(), "#") + _extract_verifies_before(content, match.start(), '#') ) in_docstring = False @@ -204,12 +204,12 @@ def _find_class(pos): for dline in docstring_lines: # Parse tags in docstring tag_match = re.match( - r"(?:@verifies|Links to:|Verifies:)\s*(.*)", dline + r'(?:@verifies|Links to:|Verifies:)\s*(.*)', dline ) if tag_match: reqs_text = tag_match.group(1) - reqs = re.findall(r"(REQ_\w+)", reqs_text) + reqs = re.findall(r'(REQ_\w+)', reqs_text) verifies_reqs.extend(reqs) else: description.append(dline) @@ -218,18 +218,18 @@ def _find_class(pos): # Handle one-line docstrings if stripped.count('"""') == 2 or stripped.count("'''") == 2: content_line = ( - stripped.replace('"""', "").replace("'''", "").strip() + stripped.replace('"""', '').replace("'''", '').strip() ) docstring_lines.append(content_line) in_docstring = False # Parse immediately dline = content_line tag_match = re.match( - r"(?:@verifies|Links to:|Verifies:)\s*(.*)", dline + r'(?:@verifies|Links to:|Verifies:)\s*(.*)', dline ) if tag_match: reqs_text = tag_match.group(1) - reqs = re.findall(r"(REQ_\w+)", reqs_text) + reqs = re.findall(r'(REQ_\w+)', reqs_text) verifies_reqs.extend(reqs) else: description.append(dline) @@ -240,32 +240,32 @@ def _find_class(pos): continue # Also check for comments # - if stripped.startswith("#"): + if stripped.startswith('#'): comment_content = stripped[1:].strip() tag_match = re.match( - r"(?:@verifies|Links to:|Verifies:)\s*(.*)", comment_content + r'(?:@verifies|Links to:|Verifies:)\s*(.*)', comment_content ) if tag_match: reqs_text = tag_match.group(1) - reqs = re.findall(r"(REQ_\w+)", reqs_text) + reqs = re.findall(r'(REQ_\w+)', reqs_text) verifies_reqs.extend(reqs) continue # If we hit code that is not comment or docstring, stop - if not in_docstring and not stripped.startswith("#"): + if not in_docstring and not stripped.startswith('#'): break if verifies_reqs: tests.append( { - "id": test_id, - "title": test_title, - "verifies": list(set(verifies_reqs)), - "description": "\n ".join(description), - "file": str(file_path.relative_to(WORKSPACE_DIR)), - "test_func": test_name, - "line": line_number, + 'id': test_id, + 'title': test_title, + 'verifies': list(set(verifies_reqs)), + 'description': '\n '.join(description), + 'file': str(file_path.relative_to(WORKSPACE_DIR)), + 'test_func': test_name, + 'line': line_number, } ) @@ -275,61 +275,61 @@ def _find_class(pos): def generate_rst(tests): """Generate RST content from parsed tests.""" lines = [ - "Verification", - "============", - "", - "This section documents the test cases and their traceability to requirements.", - "It is automatically generated from the source code.", - "", + 'Verification', + '============', + '', + 'This section documents the test cases and their traceability to requirements.', + 'It is automatically generated from the source code.', + '', ] for test in tests: - lines.append(".. test:: " + test["title"]) - lines.append(" :id: " + test["id"]) - lines.append(" :status: verified") - if test["verifies"]: - lines.append(" :verifies: " + ", ".join(test["verifies"])) - lines.append("") - if test["description"]: - lines.append(" " + test["description"]) - lines.append("") + lines.append('.. test:: ' + test['title']) + lines.append(' :id: ' + test['id']) + lines.append(' :status: verified') + if test['verifies']: + lines.append(' :verifies: ' + ', '.join(test['verifies'])) + lines.append('') + if test['description']: + lines.append(' ' + test['description']) + lines.append('') github_url = f'{GITHUB_BASE_URL}/{test["file"]}#L{test["line"]}' lines.append( f" **Implementation:** `{test['file']}#L{test['line']}" - f" <{github_url}>`_" + f' <{github_url}>`_' f" (Test: ``{test['test_func']}``)" ) - lines.append("") - lines.append("") + lines.append('') + lines.append('') - lines.append(".. needtable::") + lines.append('.. needtable::') lines.append(" :filter: type == 'test'") - lines.append(" :columns: id, title, status, verifies") - lines.append(" :style: table") - lines.append("") + lines.append(' :columns: id, title, status, verifies') + lines.append(' :style: table') + lines.append('') - return "\n".join(lines) + return '\n'.join(lines) def update_requirement_status(verified_reqs): """Update requirement spec file statuses from open to verified.""" if not REQUIREMENTS_SPECS_DIR.exists(): - print(f"Requirements specs directory {REQUIREMENTS_SPECS_DIR} does not exist.") + print(f'Requirements specs directory {REQUIREMENTS_SPECS_DIR} does not exist.') return updated_files = [] total_updated_reqs = 0 - for spec_file in REQUIREMENTS_SPECS_DIR.glob("*.rst"): - if spec_file.name == "index.rst": + for spec_file in REQUIREMENTS_SPECS_DIR.glob('*.rst'): + if spec_file.name == 'index.rst': continue - with open(spec_file, "r", encoding="utf-8") as f: + with open(spec_file, 'r', encoding='utf-8') as f: content = f.read() modified = False - lines = content.split("\n") + lines = content.split('\n') new_lines = [] i = 0 @@ -378,7 +378,7 @@ def update_requirement_status(verified_reqs): and status_line_idx is not None and current_status == 'open'): indent = re.match(r'(\s*)', req_block_lines[status_line_idx]).group(1) - req_block_lines[status_line_idx] = f"{indent}:status: verified" + req_block_lines[status_line_idx] = f'{indent}:status: verified' modified = True total_updated_reqs += 1 @@ -388,56 +388,56 @@ def update_requirement_status(verified_reqs): i += 1 if modified: - with open(spec_file, "w", encoding="utf-8") as f: - f.write("\n".join(new_lines)) + with open(spec_file, 'w', encoding='utf-8') as f: + f.write('\n'.join(new_lines)) updated_files.append(spec_file.name) if updated_files: print( - f"Updated {total_updated_reqs} requirement(s)" + f'Updated {total_updated_reqs} requirement(s)' f" to 'verified' status in {len(updated_files)} file(s):" ) for filename in updated_files: - print(f" - {filename}") + print(f' - {filename}') else: - print("No requirement status updates needed.") + print('No requirement status updates needed.') def main(): """Scan tests and generate verification.rst.""" all_tests = [] if not SRC_DIR.exists(): - print("Source directory " + str(SRC_DIR) + " does not exist.") + print('Source directory ' + str(SRC_DIR) + ' does not exist.') return for root, dirs, files in os.walk(SRC_DIR): for file in files: file_path = Path(root) / file - if file.endswith(".cpp"): + if file.endswith('.cpp'): all_tests.extend(parse_cpp_file(file_path)) - elif file.endswith(".py"): + elif file.endswith('.py'): all_tests.extend(parse_py_file(file_path)) # Sort by ID for consistency - all_tests.sort(key=lambda x: x["id"]) + all_tests.sort(key=lambda x: x['id']) # Collect all verified requirement IDs verified_reqs = set() for test in all_tests: - verified_reqs.update(test["verifies"]) + verified_reqs.update(test['verifies']) # Generate verification.rst rst_content = generate_rst(all_tests) - with open(OUTPUT_FILE, "w", encoding="utf-8") as f: + with open(OUTPUT_FILE, 'w', encoding='utf-8') as f: f.write(rst_content) - print("Generated " + str(OUTPUT_FILE) + " with " + str(len(all_tests)) + " tests.") - print(f"Found {len(verified_reqs)} verified requirement(s): {sorted(verified_reqs)}") + print('Generated ' + str(OUTPUT_FILE) + ' with ' + str(len(all_tests)) + ' tests.') + print(f'Found {len(verified_reqs)} verified requirement(s): {sorted(verified_reqs)}') # Update requirement statuses update_requirement_status(verified_reqs) -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/src/ros2_medkit_integration_tests/CMakeLists.txt b/src/ros2_medkit_integration_tests/CMakeLists.txt index 56f13fdcd..0611aef96 100644 --- a/src/ros2_medkit_integration_tests/CMakeLists.txt +++ b/src/ros2_medkit_integration_tests/CMakeLists.txt @@ -136,6 +136,35 @@ install(TARGETS topics_test_plugin LIBRARY DESTINATION lib/${PROJECT_NAME}) if(BUILD_TESTING) find_package(launch_testing_ament_cmake REQUIRED) + # Lint the Python that makes up this package. Without this the whole package + # sits outside `colcon test -L linter`, so the documented lint command passes + # while none of these files are checked, and the pre-commit hooks are the + # first thing to see a problem. + # + # Both gates read the same .flake8 (see CONFIG_FILE below) and the hook + # installs the same plugins ament_flake8 depends on, though CI installs + # those from apt while the hook's additional_dependencies are unpinned and + # resolve to whatever is current on PyPI - plugin coverage matches, exact + # plugin versions are not guaranteed to. The hook additionally runs + # flake8-blind-except and flake8-class-newline, which ament_flake8 does not + # depend on - it is a superset, not a match. + # + # Only flake8 for now. ament_pep257 also belongs here - every package in + # the tree runs it except ros2_medkit_graph_watchdog, which excludes + # pep257 - but this package drifted while it was unchecked: 559 + # multi-line docstrings put the summary on the opening line, which the + # convention the rest of the tree follows does not. Adding the checker and + # correcting those docstrings is its own change. + # + # ament_lint_common is deliberately not used either: it would also apply the + # C++ style checks to the demo nodes, which is a separate decision. + find_package(ament_cmake_flake8 REQUIRED) + # Without CONFIG_FILE, ament_flake8 reads the ini bundled inside the + # ament_flake8 package and the repository .flake8 never applies here. The two + # would then be hand-synced copies that drift in silence. Point it at the + # repository file so there is one configuration. + ament_flake8(CONFIG_FILE "${CMAKE_CURRENT_SOURCE_DIR}/../../.flake8") + # Each integration test gets a unique HTTP port via GATEWAY_TEST_PORT env var, # allowing parallel CTest execution without port conflicts. # Stride of 10 per test so multi-gateway tests can use get_test_port(offset). diff --git a/src/ros2_medkit_integration_tests/package.xml b/src/ros2_medkit_integration_tests/package.xml index 7823240cc..ac90a6179 100644 --- a/src/ros2_medkit_integration_tests/package.xml +++ b/src/ros2_medkit_integration_tests/package.xml @@ -31,6 +31,7 @@ ament_index_python python3-requests python3-jsonschema + ament_cmake_flake8 ros2_medkit_gateway ros2_medkit_fault_manager ros2_medkit_linux_introspection diff --git a/src/ros2_medkit_integration_tests/test/docker/introspection/test_container_introspection.py b/src/ros2_medkit_integration_tests/test/docker/introspection/test_container_introspection.py index cb8df882c..0d5ec5cdf 100644 --- a/src/ros2_medkit_integration_tests/test/docker/introspection/test_container_introspection.py +++ b/src/ros2_medkit_integration_tests/test/docker/introspection/test_container_introspection.py @@ -33,40 +33,40 @@ import requests BASE_URL = os.environ.get( - "CONTAINER_TEST_BASE_URL", "http://localhost:9210/api/v1" + 'CONTAINER_TEST_BASE_URL', 'http://localhost:9210/api/v1' ) STARTUP_TIMEOUT = 60 POLL_INTERVAL = 1 POLL_RETRIES = 20 -@pytest.fixture(scope="module", autouse=True) +@pytest.fixture(scope='module', autouse=True) def wait_for_gateway(): """Wait for gateway health and app discovery inside Docker container.""" deadline = time.monotonic() + STARTUP_TIMEOUT while time.monotonic() < deadline: try: - r = requests.get(f"{BASE_URL}/health", timeout=2) + r = requests.get(f'{BASE_URL}/health', timeout=2) if r.status_code == 200: # Also wait for at least 2 apps (temp_sensor + rpm_sensor) - r2 = requests.get(f"{BASE_URL}/apps", timeout=2) + r2 = requests.get(f'{BASE_URL}/apps', timeout=2) if r2.status_code == 200: - apps = r2.json().get("items", []) + apps = r2.json().get('items', []) if len(apps) >= 2: return except requests.RequestException: pass time.sleep(1) - pytest.fail(f"Gateway not ready after {STARTUP_TIMEOUT}s") + pytest.fail(f'Gateway not ready after {STARTUP_TIMEOUT}s') def _get_app_ids(): """Get all discovered app IDs.""" - r = requests.get(f"{BASE_URL}/apps", timeout=5) + r = requests.get(f'{BASE_URL}/apps', timeout=5) r.raise_for_status() - items = r.json().get("items", []) - assert len(items) > 0, "No apps discovered" - return [item["id"] for item in items] + items = r.json().get('items', []) + assert len(items) > 0, 'No apps discovered' + return [item['id'] for item in items] def _get_first_app_id(): @@ -76,11 +76,11 @@ def _get_first_app_id(): def _get_first_component_id(): """Get first discovered component ID.""" - r = requests.get(f"{BASE_URL}/components", timeout=5) + r = requests.get(f'{BASE_URL}/components', timeout=5) r.raise_for_status() - items = r.json().get("items", []) - assert len(items) > 0, "No components discovered" - return items[0]["id"] + items = r.json().get('items', []) + assert len(items) > 0, 'No components discovered' + return items[0]['id'] def _poll_endpoint(url, retries=POLL_RETRIES, interval=POLL_INTERVAL): @@ -103,13 +103,13 @@ def test_returns_container_info(self): """ app_id = _get_first_app_id() status, data = _poll_endpoint( - f"{BASE_URL}/apps/{app_id}/x-medkit-container" + f'{BASE_URL}/apps/{app_id}/x-medkit-container' ) assert status == 200, ( - f"container endpoint not available for {app_id}" + f'container endpoint not available for {app_id}' ) - assert "container_id" in data - assert "runtime" in data + assert 'container_id' in data + assert 'runtime' in data def test_container_id_is_64_char_hex(self): """Container ID should be a full 64-character hex SHA-256 hash. @@ -118,15 +118,15 @@ def test_container_id_is_64_char_hex(self): """ app_id = _get_first_app_id() status, data = _poll_endpoint( - f"{BASE_URL}/apps/{app_id}/x-medkit-container" + f'{BASE_URL}/apps/{app_id}/x-medkit-container' ) assert status == 200 - cid = data["container_id"] + cid = data['container_id'] assert len(cid) == 64, ( - f"Expected 64-char container ID, got {len(cid)}: {cid}" + f'Expected 64-char container ID, got {len(cid)}: {cid}' ) - assert re.match(r"^[0-9a-f]{64}$", cid), ( - f"Container ID is not valid hex: {cid}" + assert re.match(r'^[0-9a-f]{64}$', cid), ( + f'Container ID is not valid hex: {cid}' ) def test_runtime_is_docker(self): @@ -136,10 +136,10 @@ def test_runtime_is_docker(self): """ app_id = _get_first_app_id() status, data = _poll_endpoint( - f"{BASE_URL}/apps/{app_id}/x-medkit-container" + f'{BASE_URL}/apps/{app_id}/x-medkit-container' ) assert status == 200 - assert data["runtime"] == "docker", ( + assert data['runtime'] == 'docker', ( f"Expected runtime 'docker', got '{data['runtime']}'" ) @@ -152,15 +152,15 @@ def test_memory_limit_detected(self): """ app_id = _get_first_app_id() status, data = _poll_endpoint( - f"{BASE_URL}/apps/{app_id}/x-medkit-container" + f'{BASE_URL}/apps/{app_id}/x-medkit-container' ) assert status == 200 - assert "memory_limit_bytes" in data, ( - f"memory_limit_bytes missing from response: {data}" + assert 'memory_limit_bytes' in data, ( + f'memory_limit_bytes missing from response: {data}' ) # 512MB = 536870912 bytes - assert data["memory_limit_bytes"] == 536870912, ( - f"Expected 536870912 bytes (512MB), " + assert data['memory_limit_bytes'] == 536870912, ( + f'Expected 536870912 bytes (512MB), ' f"got {data['memory_limit_bytes']}" ) @@ -175,19 +175,19 @@ def test_cpu_quota_detected(self): """ app_id = _get_first_app_id() status, data = _poll_endpoint( - f"{BASE_URL}/apps/{app_id}/x-medkit-container" + f'{BASE_URL}/apps/{app_id}/x-medkit-container' ) assert status == 200 - assert "cpu_quota_us" in data, ( - f"cpu_quota_us missing from response: {data}" + assert 'cpu_quota_us' in data, ( + f'cpu_quota_us missing from response: {data}' ) - assert "cpu_period_us" in data, ( - f"cpu_period_us missing from response: {data}" + assert 'cpu_period_us' in data, ( + f'cpu_period_us missing from response: {data}' ) # cpus: 1.0 means quota/period = 1.0 - ratio = data["cpu_quota_us"] / data["cpu_period_us"] + ratio = data['cpu_quota_us'] / data['cpu_period_us'] assert abs(ratio - 1.0) < 0.01, ( - f"Expected CPU ratio ~1.0, got {ratio} " + f'Expected CPU ratio ~1.0, got {ratio} ' f"(quota={data['cpu_quota_us']}, " f"period={data['cpu_period_us']})" ) @@ -201,14 +201,14 @@ def test_all_apps_same_container(self): container_ids = set() for app_id in app_ids: status, data = _poll_endpoint( - f"{BASE_URL}/apps/{app_id}/x-medkit-container" + f'{BASE_URL}/apps/{app_id}/x-medkit-container' ) - if status == 200 and "container_id" in data: - container_ids.add(data["container_id"]) + if status == 200 and 'container_id' in data: + container_ids.add(data['container_id']) # All apps run in the same Docker container assert len(container_ids) == 1, ( - f"Expected 1 unique container ID, got {len(container_ids)}: " - f"{container_ids}" + f'Expected 1 unique container ID, got {len(container_ids)}: ' + f'{container_ids}' ) @@ -222,13 +222,13 @@ def test_returns_containers_aggregation(self): """ comp_id = _get_first_component_id() status, data = _poll_endpoint( - f"{BASE_URL}/components/{comp_id}/x-medkit-container" + f'{BASE_URL}/components/{comp_id}/x-medkit-container' ) assert status == 200, ( - f"container component endpoint not available for {comp_id}" + f'container component endpoint not available for {comp_id}' ) - assert "containers" in data - assert isinstance(data["containers"], list) + assert 'containers' in data + assert isinstance(data['containers'], list) def test_containers_include_node_ids(self): """Each container in the aggregation includes node_ids. @@ -237,14 +237,14 @@ def test_containers_include_node_ids(self): """ comp_id = _get_first_component_id() status, data = _poll_endpoint( - f"{BASE_URL}/components/{comp_id}/x-medkit-container" + f'{BASE_URL}/components/{comp_id}/x-medkit-container' ) assert status == 200 - if len(data["containers"]) > 0: - container = data["containers"][0] - assert "node_ids" in container - assert isinstance(container["node_ids"], list) - assert len(container["node_ids"]) > 0 + if len(data['containers']) > 0: + container = data['containers'][0] + assert 'node_ids' in container + assert isinstance(container['node_ids'], list) + assert len(container['node_ids']) > 0 def test_containers_include_runtime(self): """Each container in the aggregation includes runtime info. @@ -253,12 +253,12 @@ def test_containers_include_runtime(self): """ comp_id = _get_first_component_id() status, data = _poll_endpoint( - f"{BASE_URL}/components/{comp_id}/x-medkit-container" + f'{BASE_URL}/components/{comp_id}/x-medkit-container' ) assert status == 200 - for container in data["containers"]: - assert "runtime" in container - assert "container_id" in container + for container in data['containers']: + assert 'runtime' in container + assert 'container_id' in container class TestContainerErrorHandling: @@ -270,7 +270,7 @@ def test_nonexistent_app_returns_404(self): @verifies REQ_INTEROP_003 """ r = requests.get( - f"{BASE_URL}/apps/nonexistent_app_xyz/x-medkit-container", + f'{BASE_URL}/apps/nonexistent_app_xyz/x-medkit-container', timeout=5, ) assert r.status_code == 404 @@ -281,7 +281,7 @@ def test_nonexistent_component_returns_404(self): @verifies REQ_INTEROP_003 """ r = requests.get( - f"{BASE_URL}/components/nonexistent_comp_xyz/x-medkit-container", + f'{BASE_URL}/components/nonexistent_comp_xyz/x-medkit-container', timeout=5, ) assert r.status_code == 404 diff --git a/src/ros2_medkit_integration_tests/test/docker/introspection/test_systemd_introspection.py b/src/ros2_medkit_integration_tests/test/docker/introspection/test_systemd_introspection.py index d984bfc7e..e8f30dc2c 100644 --- a/src/ros2_medkit_integration_tests/test/docker/introspection/test_systemd_introspection.py +++ b/src/ros2_medkit_integration_tests/test/docker/introspection/test_systemd_introspection.py @@ -32,40 +32,40 @@ import requests BASE_URL = os.environ.get( - "SYSTEMD_TEST_BASE_URL", "http://localhost:9200/api/v1" + 'SYSTEMD_TEST_BASE_URL', 'http://localhost:9200/api/v1' ) STARTUP_TIMEOUT = 60 POLL_INTERVAL = 1 POLL_RETRIES = 20 -@pytest.fixture(scope="module", autouse=True) +@pytest.fixture(scope='module', autouse=True) def wait_for_gateway(): """Wait for gateway health and app discovery inside Docker container.""" deadline = time.monotonic() + STARTUP_TIMEOUT while time.monotonic() < deadline: try: - r = requests.get(f"{BASE_URL}/health", timeout=2) + r = requests.get(f'{BASE_URL}/health', timeout=2) if r.status_code == 200: # Also wait for at least 2 apps (temp_sensor + rpm_sensor) - r2 = requests.get(f"{BASE_URL}/apps", timeout=2) + r2 = requests.get(f'{BASE_URL}/apps', timeout=2) if r2.status_code == 200: - apps = r2.json().get("items", []) + apps = r2.json().get('items', []) if len(apps) >= 2: return except requests.RequestException: pass time.sleep(1) - pytest.fail(f"Gateway not ready after {STARTUP_TIMEOUT}s") + pytest.fail(f'Gateway not ready after {STARTUP_TIMEOUT}s') def _get_app_ids(): """Get all discovered app IDs.""" - r = requests.get(f"{BASE_URL}/apps", timeout=5) + r = requests.get(f'{BASE_URL}/apps', timeout=5) r.raise_for_status() - items = r.json().get("items", []) - assert len(items) > 0, "No apps discovered" - return [item["id"] for item in items] + items = r.json().get('items', []) + assert len(items) > 0, 'No apps discovered' + return [item['id'] for item in items] def _get_first_app_id(): @@ -75,11 +75,11 @@ def _get_first_app_id(): def _get_first_component_id(): """Get first discovered component ID.""" - r = requests.get(f"{BASE_URL}/components", timeout=5) + r = requests.get(f'{BASE_URL}/components', timeout=5) r.raise_for_status() - items = r.json().get("items", []) - assert len(items) > 0, "No components discovered" - return items[0]["id"] + items = r.json().get('items', []) + assert len(items) > 0, 'No components discovered' + return items[0]['id'] def _poll_endpoint(url, retries=POLL_RETRIES, interval=POLL_INTERVAL): @@ -102,16 +102,16 @@ def test_returns_unit_info(self): """ app_id = _get_first_app_id() status, data = _poll_endpoint( - f"{BASE_URL}/apps/{app_id}/x-medkit-systemd" + f'{BASE_URL}/apps/{app_id}/x-medkit-systemd' ) assert status == 200, ( - f"systemd endpoint not available for {app_id}" + f'systemd endpoint not available for {app_id}' ) - assert "unit" in data - assert data["unit"].endswith(".service") - assert data["active_state"] == "active" - assert "sub_state" in data - assert data["sub_state"] == "running" + assert 'unit' in data + assert data['unit'].endswith('.service') + assert data['active_state'] == 'active' + assert 'sub_state' in data + assert data['sub_state'] == 'running' def test_returns_unit_type(self): """Systemd endpoint includes unit_type field. @@ -120,10 +120,10 @@ def test_returns_unit_type(self): """ app_id = _get_first_app_id() status, data = _poll_endpoint( - f"{BASE_URL}/apps/{app_id}/x-medkit-systemd" + f'{BASE_URL}/apps/{app_id}/x-medkit-systemd' ) assert status == 200 - assert data["unit_type"] == "service" + assert data['unit_type'] == 'service' def test_returns_restart_count(self): """Systemd endpoint includes restart_count (NRestarts property). @@ -132,12 +132,12 @@ def test_returns_restart_count(self): """ app_id = _get_first_app_id() status, data = _poll_endpoint( - f"{BASE_URL}/apps/{app_id}/x-medkit-systemd" + f'{BASE_URL}/apps/{app_id}/x-medkit-systemd' ) assert status == 200 - assert "restart_count" in data - assert isinstance(data["restart_count"], int) - assert data["restart_count"] >= 0 + assert 'restart_count' in data + assert isinstance(data['restart_count'], int) + assert data['restart_count'] >= 0 def test_returns_watchdog_usec(self): """Systemd endpoint includes watchdog_usec field. @@ -146,11 +146,11 @@ def test_returns_watchdog_usec(self): """ app_id = _get_first_app_id() status, data = _poll_endpoint( - f"{BASE_URL}/apps/{app_id}/x-medkit-systemd" + f'{BASE_URL}/apps/{app_id}/x-medkit-systemd' ) assert status == 200 - assert "watchdog_usec" in data - assert isinstance(data["watchdog_usec"], int) + assert 'watchdog_usec' in data + assert isinstance(data['watchdog_usec'], int) class TestSystemdComponentEndpoint: @@ -163,13 +163,13 @@ def test_returns_units_aggregation(self): """ comp_id = _get_first_component_id() status, data = _poll_endpoint( - f"{BASE_URL}/components/{comp_id}/x-medkit-systemd" + f'{BASE_URL}/components/{comp_id}/x-medkit-systemd' ) assert status == 200, ( - f"systemd component endpoint not available for {comp_id}" + f'systemd component endpoint not available for {comp_id}' ) - assert "units" in data - assert isinstance(data["units"], list) + assert 'units' in data + assert isinstance(data['units'], list) def test_units_include_node_ids(self): """Each unit in the aggregation includes node_ids listing the apps. @@ -178,14 +178,14 @@ def test_units_include_node_ids(self): """ comp_id = _get_first_component_id() status, data = _poll_endpoint( - f"{BASE_URL}/components/{comp_id}/x-medkit-systemd" + f'{BASE_URL}/components/{comp_id}/x-medkit-systemd' ) assert status == 200 - if len(data["units"]) > 0: - unit = data["units"][0] - assert "node_ids" in unit - assert isinstance(unit["node_ids"], list) - assert len(unit["node_ids"]) > 0 + if len(data['units']) > 0: + unit = data['units'][0] + assert 'node_ids' in unit + assert isinstance(unit['node_ids'], list) + assert len(unit['node_ids']) > 0 def test_units_have_active_state(self): """Each unit in the aggregation includes active_state. @@ -194,12 +194,12 @@ def test_units_have_active_state(self): """ comp_id = _get_first_component_id() status, data = _poll_endpoint( - f"{BASE_URL}/components/{comp_id}/x-medkit-systemd" + f'{BASE_URL}/components/{comp_id}/x-medkit-systemd' ) assert status == 200 - for unit in data["units"]: - assert "active_state" in unit - assert "unit" in unit + for unit in data['units']: + assert 'active_state' in unit + assert 'unit' in unit class TestSystemdErrorHandling: @@ -211,7 +211,7 @@ def test_nonexistent_app_returns_404(self): @verifies REQ_INTEROP_003 """ r = requests.get( - f"{BASE_URL}/apps/nonexistent_app_xyz/x-medkit-systemd", + f'{BASE_URL}/apps/nonexistent_app_xyz/x-medkit-systemd', timeout=5, ) assert r.status_code == 404 @@ -222,7 +222,7 @@ def test_nonexistent_component_returns_404(self): @verifies REQ_INTEROP_003 """ r = requests.get( - f"{BASE_URL}/components/nonexistent_comp_xyz/x-medkit-systemd", + f'{BASE_URL}/components/nonexistent_comp_xyz/x-medkit-systemd', timeout=5, ) assert r.status_code == 404 diff --git a/src/ros2_medkit_integration_tests/test/features/test_daisy_chain_aggregation.test.py b/src/ros2_medkit_integration_tests/test/features/test_daisy_chain_aggregation.test.py index 6e55d1289..bd662d589 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_daisy_chain_aggregation.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_daisy_chain_aggregation.test.py @@ -80,7 +80,7 @@ def _manifest(name, ecu_id): only exercise discovery endpoints and request forwarding - not live runtime sampling. """ - return textwrap.dedent(f''' + return textwrap.dedent(f""" manifest_version: "1.0" metadata: name: {name} @@ -106,7 +106,7 @@ def _manifest(name, ecu_id): ros_binding: node_name: {ecu_id}_app namespace: /{ecu_id} - ''').strip() + """).strip() def _write_manifest(name, ecu_id): diff --git a/src/ros2_medkit_integration_tests/test/features/test_discovery_gap_fill.test.py b/src/ros2_medkit_integration_tests/test/features/test_discovery_gap_fill.test.py index bda3db884..f61399889 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_discovery_gap_fill.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_discovery_gap_fill.test.py @@ -94,7 +94,7 @@ def test_only_manifest_areas_present(self): 'powertrain', 'chassis', 'body', 'perception', # HATEOAS edge-case fixture, manifest-only. 'hateoas-edge-area', - ], f"Unexpected area found in top-level listing: {area_id}") + ], f'Unexpected area found in top-level listing: {area_id}') def test_only_manifest_components_present(self): """Components come from manifest only - runtime never creates components.""" @@ -117,7 +117,7 @@ def test_only_manifest_components_present(self): for comp_id in component_ids: self.assertIn( comp_id, manifest_components, - f"Unexpected heuristic component found: {comp_id}", + f'Unexpected heuristic component found: {comp_id}', ) def test_manifest_apps_present_and_linked(self): @@ -138,7 +138,7 @@ def test_manifest_apps_present_and_linked(self): for app_id in expected_linked: self.assertIn( app_id, app_ids, - f"Expected manifest app {app_id} not found in apps list", + f'Expected manifest app {app_id} not found in apps list', ) def test_health_shows_discovery_info(self): diff --git a/src/ros2_medkit_integration_tests/test/features/test_discovery_layer_policies.test.py b/src/ros2_medkit_integration_tests/test/features/test_discovery_layer_policies.test.py index 3dab04f21..ff4f13d28 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_discovery_layer_policies.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_discovery_layer_policies.test.py @@ -114,7 +114,7 @@ def test_manifest_apps_present(self): # Manifest defines engine-temp-sensor, engine-rpm-sensor, etc. self.assertTrue( any('engine' in aid for aid in app_ids), - f"No engine apps found: {app_ids}", + f'No engine apps found: {app_ids}', ) def test_merge_pipeline_has_layers(self): @@ -151,7 +151,7 @@ def test_manifest_authoritative_live_data(self): # be no conflict (authoritative simply wins) self.assertEqual( pipeline.get('conflict_count', -1), 0, - f"Expected no merge conflicts with authoritative policy, got: {pipeline}", + f'Expected no merge conflicts with authoritative policy, got: {pipeline}', ) diff --git a/src/ros2_medkit_integration_tests/test/features/test_discovery_legacy_mode.test.py b/src/ros2_medkit_integration_tests/test/features/test_discovery_legacy_mode.test.py index 430197554..8681827c7 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_discovery_legacy_mode.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_discovery_legacy_mode.test.py @@ -70,7 +70,7 @@ def test_no_components_without_host_provider(self): components = comp_data.get('items', []) self.assertEqual( len(components), 0, - f"Expected no components without HostInfoProvider, " + f'Expected no components without HostInfoProvider, ' f"got: {[c.get('id') for c in components]}", ) @@ -89,7 +89,7 @@ def test_apps_still_discovered(self): for name in expected: self.assertTrue( any(name in aid for aid in app_ids), - f"{name} not found in apps: {app_ids}", + f'{name} not found in apps: {app_ids}', ) diff --git a/src/ros2_medkit_integration_tests/test/features/test_discovery_namespace_filter.test.py b/src/ros2_medkit_integration_tests/test/features/test_discovery_namespace_filter.test.py index 360814901..abd3636bc 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_discovery_namespace_filter.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_discovery_namespace_filter.test.py @@ -148,7 +148,7 @@ def test_blacklist_filters_unmanifested_chassis_node(self): for app_id in app_ids: self.assertNotIn( 'unmanifested_chassis_sensor', app_id, - f"Blacklisted namespace node should not appear: {app_id}", + f'Blacklisted namespace node should not appear: {app_id}', ) def test_health_shows_gap_fill_filtering(self): diff --git a/src/ros2_medkit_integration_tests/test/features/test_logging_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_logging_api.test.py index 25fd764f5..f6e3e8820 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_logging_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_logging_api.test.py @@ -102,7 +102,7 @@ def test_app_log_entry_has_required_fields(self): ts = entry['timestamp'] self.assertTrue( ts.endswith('Z') and 'T' in ts, - f"timestamp should be ISO 8601 with Z suffix, got: {ts}" + f'timestamp should be ISO 8601 with Z suffix, got: {ts}' ) # severity is one of the valid values self.assertIn( @@ -269,7 +269,7 @@ def test_component_get_logs_aggregates_child_apps(self): sources = ext.get('aggregation_sources', []) self.assertTrue( any('temp_sensor' in src for src in sources), - f"Expected aggregation_sources to contain a temp_sensor fqn, got: {sources}", + f'Expected aggregation_sources to contain a temp_sensor fqn, got: {sources}', ) def test_component_get_logs_configuration_returns_200(self): @@ -434,7 +434,7 @@ def test_app_severity_configured_filter_applies_to_get(self): for entry in data.get('items', []): self.assertEqual( entry['severity'], 'fatal', - f'Expected only fatal entries after setting filter, got: {entry["severity"]}' + f"Expected only fatal entries after setting filter, got: {entry['severity']}" ) diff --git a/src/ros2_medkit_integration_tests/test/features/test_triggers_plugin_entity.test.py b/src/ros2_medkit_integration_tests/test/features/test_triggers_plugin_entity.test.py index a45b9f218..cd2256ab6 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_triggers_plugin_entity.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_triggers_plugin_entity.test.py @@ -24,22 +24,20 @@ """ import json +import os import threading import time import unittest +from ament_index_python.packages import get_package_prefix import launch_testing import launch_testing.actions import requests -from ament_index_python.packages import get_package_prefix - from ros2_medkit_test_utils.constants import ALLOWED_EXIT_CODES, API_BASE_PATH from ros2_medkit_test_utils.gateway_test_case import GatewayTestCase from ros2_medkit_test_utils.launch_helpers import create_test_launch -import os - def _get_plugin_path(): pkg_prefix = get_package_prefix('ros2_medkit_integration_tests') diff --git a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/CMakeLists.txt b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/CMakeLists.txt index 0061f08d5..22d10ea87 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/CMakeLists.txt +++ b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/CMakeLists.txt @@ -101,14 +101,14 @@ if(BUILD_TESTING) find_package(ament_lint_auto REQUIRED) # uncrustify/cpplint conflict with project-wide clang-format (120 cols vs 100). - # flake8/pep257 are excluded to match the rest of the workspace: no package here - # lints Python, including ros2_medkit_integration_tests, which holds every other - # launch test. copyright, lint_cmake and xmllint stay on. + # flake8 now runs here too. Only pep257 stays excluded, deferred together + # with the same change in ros2_medkit_integration_tests - see that + # package's CMakeLists.txt for the docstring backlog behind it. copyright, + # lint_cmake and xmllint stay on. list(APPEND AMENT_LINT_AUTO_EXCLUDE ament_cmake_uncrustify ament_cmake_cpplint ament_cmake_clang_format - ament_cmake_flake8 ament_cmake_pep257 ) ament_lint_auto_find_test_dependencies() diff --git a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/test/e2e/test_config_plumbing_e2e.test.py b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/test/e2e/test_config_plumbing_e2e.test.py index 9ee26ead6..bb3cb116b 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/test/e2e/test_config_plumbing_e2e.test.py +++ b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/test/e2e/test_config_plumbing_e2e.test.py @@ -12,8 +12,7 @@ # 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. -"""Config-plumbing e2e: proves NESTED plugin config reaches a live detector -through the REAL gateway. +"""Config-plumbing e2e: proves NESTED plugin config reaches a live detector via the REAL gateway. A C++ unit test cannot prove this - it hand-builds already-nested JSON and calls a detector's configure() directly, bypassing the real config-delivery @@ -64,7 +63,9 @@ import launch_testing sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from harness import ( # noqa: E402 +# I100 as well as E402: `harness` is only importable because of the sys.path line above, so this +# import cannot be moved up to where the alphabetical order would put it. +from harness import ( # noqa: E402, I100 create_watchdog_test_launch, poll_faults, wait_until_watchdog_armed, diff --git a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/test/e2e/test_orphan_e2e.test.py b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/test/e2e/test_orphan_e2e.test.py index 2c638ba02..1a34bd587 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/test/e2e/test_orphan_e2e.test.py +++ b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/test/e2e/test_orphan_e2e.test.py @@ -12,9 +12,10 @@ # 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. -"""Orphan / topic-name-mismatch e2e: proves GRAPH_ORPHAN raises and clears through the -REAL gateway + orphan_detector + fault_manager stack. This is the acceptance gate for -its design issue. +"""Orphan / topic-name-mismatch e2e: proves GRAPH_ORPHAN raises and clears through the REAL stack. + +The real stack is the gateway process, the orphan_detector, and the fault_manager. This is the +acceptance gate for its design issue. Unlike test_orphan_integration.cpp (which drives the detector directly against a fake ReportFault service and a bare rclcpp::Node), this launches the REAL gateway process with @@ -53,7 +54,9 @@ from sensor_msgs.msg import LaserScan sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from harness import create_watchdog_test_launch, poll_cleared, poll_faults # noqa: E402 +# I100 as well as E402: `harness` is only importable because of the sys.path line above, so this +# import cannot be moved up to where the alphabetical order would put it. +from harness import create_watchdog_test_launch, poll_cleared, poll_faults # noqa: E402, I100 from ros2_medkit_test_utils.constants import ALLOWED_EXIT_CODES, get_test_port # noqa: E402 @@ -106,15 +109,18 @@ def generate_test_description(): class TestOrphanE2e(unittest.TestCase): - """GRAPH_ORPHAN raises on a real pub-only/sub-only topic-name near-miss pair, clears - once the typo publisher is destroyed.""" + """GRAPH_ORPHAN raises on a real pub-only/sub-only topic-name near-miss pair. + + Clears once the typo publisher is destroyed. + """ @classmethod def setUpClass(cls): rclpy.init() cls._pub_node = Node('orphan_e2e_typo_pub') cls._pub = cls._pub_node.create_publisher(LaserScan, TYPO_TOPIC, QoSProfile(depth=10)) - cls._ns_pub = cls._pub_node.create_publisher(LaserScan, NS_TYPO_TOPIC, QoSProfile(depth=10)) + cls._ns_pub = cls._pub_node.create_publisher( + LaserScan, NS_TYPO_TOPIC, QoSProfile(depth=10)) cls._sub_node = Node('orphan_e2e_target_sub') cls._sub = cls._sub_node.create_subscription( diff --git a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/test/e2e/test_param_roundtrip_e2e.test.py b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/test/e2e/test_param_roundtrip_e2e.test.py index 26d30171e..c311c848a 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/test/e2e/test_param_roundtrip_e2e.test.py +++ b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/test/e2e/test_param_roundtrip_e2e.test.py @@ -111,7 +111,9 @@ import requests sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from harness import create_watchdog_test_launch # noqa: E402 +# I100 as well as E402: `harness` is only importable because of the sys.path line above, so this +# import cannot be moved up to where the alphabetical order would put it. +from harness import create_watchdog_test_launch # noqa: E402, I100 from ros2_medkit_test_utils.constants import ALLOWED_EXIT_CODES, get_test_port # noqa: E402 diff --git a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/test/e2e/test_qos_e2e.test.py b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/test/e2e/test_qos_e2e.test.py index dae28cc4a..ea3c83de2 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/test/e2e/test_qos_e2e.test.py +++ b/src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/test/e2e/test_qos_e2e.test.py @@ -12,9 +12,10 @@ # 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. -"""QoS-mismatch e2e: proves GRAPH_QOS_MISMATCH raises and clears through the REAL -gateway + qos_mismatch_detector + fault_manager stack. This is the acceptance gate -for its design issue. +"""QoS-mismatch e2e: proves GRAPH_QOS_MISMATCH raises and clears through the REAL stack. + +The real stack is the gateway process, the qos_mismatch_detector, and the fault_manager. This +is the acceptance gate for its design issue. Unlike test_qos_mismatch_integration.cpp (which drives the detector directly against a fake ReportFault service and a bare rclcpp::Node), this launches the REAL gateway @@ -58,7 +59,9 @@ from std_msgs.msg import String sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from harness import ( # noqa: E402 +# I100 as well as E402: `harness` is only importable because of the sys.path line above, so this +# import cannot be moved up to where the alphabetical order would put it. +from harness import ( # noqa: E402, I100 create_watchdog_test_launch, poll_cleared, poll_entity_faults, @@ -109,7 +112,8 @@ def setUpClass(cls): cls._sub_node = Node('qos_e2e_sub') reliable_qos = QoSProfile(depth=10, reliability=ReliabilityPolicy.RELIABLE) - cls._sub = cls._sub_node.create_subscription(String, TOPIC, lambda _msg: None, reliable_qos) + cls._sub = cls._sub_node.create_subscription( + String, TOPIC, lambda _msg: None, reliable_qos) @classmethod def tearDownClass(cls):