From 9425069a5f42618bcdef2ac026a11f7475b42180 Mon Sep 17 00:00:00 2001 From: Nick Brook Date: Tue, 18 Mar 2025 12:50:59 +0000 Subject: [PATCH 1/9] Switch to Ruff Signed-off-by: Nick Brook --- .pre-commit-config.yaml | 20 +-- Makefile | 9 +- docs/source/conf.py | 3 +- examples/__init__.py | 3 +- examples/nordic_blinky/__init__.py | 3 +- examples/nordic_blinky/config.py | 3 +- examples/nordic_blinky/tests/test_blinky.py | 18 +- examples/nordic_blinky/verify_fixes.py | 12 +- examples/run_nordic_example.py | 10 +- pyproject.toml | 90 +++++----- scripts/release.py | 26 ++- test_a_ble/__init__.py | 8 +- test_a_ble/ble_manager.py | 94 +++++----- test_a_ble/cli.py | 121 ++++++------- test_a_ble/test_context.py | 181 +++++++++---------- test_a_ble/test_runner.py | 105 +++++------ tests/test_ble_manager.py | 8 +- tests/test_cli.py | 16 +- tests/test_test_context.py | 8 +- tests/test_test_runner.py | 14 +- uv.lock | 182 ++++---------------- 21 files changed, 383 insertions(+), 551 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4dc8ff8..5880393 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,19 +7,9 @@ repos: - id: check-yaml - id: check-added-large-files -- repo: https://github.com/pycqa/isort - rev: 6.0.1 +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.0 hooks: - - id: isort - -- repo: https://github.com/psf/black - rev: 25.1.0 - hooks: - - id: black - language_version: python3 - -- repo: https://github.com/pycqa/flake8 - rev: 7.1.2 - hooks: - - id: flake8 - additional_dependencies: [flake8-docstrings, flake8-pyproject] + - id: ruff + args: [--fix] + - id: ruff-format diff --git a/Makefile b/Makefile index 76052ee..ff9675e 100644 --- a/Makefile +++ b/Makefile @@ -59,13 +59,12 @@ clean-test: ## remove test and coverage artifacts rm -fr .pytest_cache/ lint: ## check style - $(PY_CMD_PREFIX) black --check . - $(PY_CMD_PREFIX) isort --check . - $(PY_CMD_PREFIX) flake8 . + $(PY_CMD_PREFIX) ruff check . + $(PY_CMD_PREFIX) ruff format --check . format: ## format code - $(PY_CMD_PREFIX) black . - $(PY_CMD_PREFIX) isort . + $(PY_CMD_PREFIX) ruff format . + $(PY_CMD_PREFIX) ruff check --fix . typecheck: ## type check code $(PY_CMD_PREFIX) mypy diff --git a/docs/source/conf.py b/docs/source/conf.py index e6b7beb..9eddcca 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,5 +1,4 @@ -""" -Configuration file for the Sphinx documentation builder. +"""Configuration file for the Sphinx documentation builder. This file contains the configuration settings for generating the project's documentation. """ diff --git a/examples/__init__.py b/examples/__init__.py index 6f25a14..235f975 100644 --- a/examples/__init__.py +++ b/examples/__init__.py @@ -1,5 +1,4 @@ -""" -BLE Test Framework Examples. +"""BLE Test Framework Examples. Examples demonstrating usage of the BLE IoT device testing framework. """ diff --git a/examples/nordic_blinky/__init__.py b/examples/nordic_blinky/__init__.py index a55b326..c166540 100644 --- a/examples/nordic_blinky/__init__.py +++ b/examples/nordic_blinky/__init__.py @@ -1,5 +1,4 @@ -""" -Nordic Blinky Example. +"""Nordic Blinky Example. Example tests for the Nordic Semiconductor BLE Blinky sample application. https://developer.nordicsemi.com/nRF_Connect_SDK/doc/latest/nrf/samples/bluetooth/peripheral_lbs/README.html diff --git a/examples/nordic_blinky/config.py b/examples/nordic_blinky/config.py index 0aa51a4..b51a78a 100644 --- a/examples/nordic_blinky/config.py +++ b/examples/nordic_blinky/config.py @@ -1,5 +1,4 @@ -""" -Nordic Blinky Example Configuration. +"""Nordic Blinky Example Configuration. Configuration for the Nordic Semiconductor BLE Blinky sample application. """ diff --git a/examples/nordic_blinky/tests/test_blinky.py b/examples/nordic_blinky/tests/test_blinky.py index ac11f4c..a1836a1 100644 --- a/examples/nordic_blinky/tests/test_blinky.py +++ b/examples/nordic_blinky/tests/test_blinky.py @@ -1,5 +1,4 @@ -""" -Nordic Blinky Tests. +"""Nordic Blinky Tests. Tests for the Nordic Semiconductor BLE Blinky sample application. These tests demonstrate controlling the LED and receiving button state notifications. @@ -22,8 +21,7 @@ @ble_test_class("Blinky Tests") class BlinkyTests: - """ - Tests for the Nordic Semiconductor BLE Blinky sample application. + """Tests for the Nordic Semiconductor BLE Blinky sample application. These tests demonstrate controlling the LED and receiving button state notifications. """ @@ -117,7 +115,9 @@ async def test_button_press(self, ble_manager: BLEManager, test_context: TestCon ["Please PRESS the button on the device to demonstrate BLE notifications."], ) press_result = await test_context.wait_for_notification_interactive( - characteristic_uuid=CHAR_BUTTON, expected_value=BUTTON_PRESSED, timeout=15.0 + characteristic_uuid=CHAR_BUTTON, + expected_value=BUTTON_PRESSED, + timeout=15.0, ) # Button press detected @@ -163,10 +163,12 @@ async def test_led_button_interaction(self, ble_manager: BLEManager, test_contex test_context.debug("Waiting for button press to turn on LED") test_context.print_formatted_box( "WAITING FOR NOTIFICATION", - ["Press and HOLD the button on the device.\n" "The LED should remain OFF until you press the button."], + ["Press and HOLD the button on the device.\nThe LED should remain OFF until you press the button."], ) press_result = await test_context.wait_for_notification_interactive( - characteristic_uuid=CHAR_BUTTON, expected_value=BUTTON_PRESSED, timeout=15.0 + characteristic_uuid=CHAR_BUTTON, + expected_value=BUTTON_PRESSED, + timeout=15.0, ) # If we get here, button press was successful @@ -196,7 +198,7 @@ async def test_led_button_interaction(self, ble_manager: BLEManager, test_contex test_context.debug("Waiting for button release to turn off LED") test_context.print_formatted_box( "WAITING FOR NOTIFICATION", - ["RELEASE the button now.\n" "When you release the button, the LED should turn OFF."], + ["RELEASE the button now.\nWhen you release the button, the LED should turn OFF."], ) await test_context.wait_for_notification_interactive( characteristic_uuid=CHAR_BUTTON, diff --git a/examples/nordic_blinky/verify_fixes.py b/examples/nordic_blinky/verify_fixes.py index 0dea016..be94c08 100644 --- a/examples/nordic_blinky/verify_fixes.py +++ b/examples/nordic_blinky/verify_fixes.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Verification script for BLE test framework fixes. +"""Verification script for BLE test framework fixes. This script validates the fixes made to the notification handling, LED control, and user prompts in the BLE test framework. @@ -8,6 +7,7 @@ Usage: python verify_fixes.py [--address=] """ + import argparse import asyncio import logging @@ -45,7 +45,7 @@ async def verify_led_control(ble_manager, test_context): else: test_context.log(f"LED state mismatch - expected {LED_ON.hex()}, got {led_state.hex()}") except Exception as e: - logger.info(f"Could not read LED state: {str(e)}") + logger.info(f"Could not read LED state: {e!s}") # Ask user to verify response = test_context.prompt_user("Is the LED ON? (y/n)") @@ -67,7 +67,7 @@ async def verify_led_control(ble_manager, test_context): else: test_context.log(f"LED state mismatch - expected {LED_OFF.hex()}, got {led_state.hex()}") except Exception as e: - logger.info(f"Could not read LED state: {str(e)}") + logger.info(f"Could not read LED state: {e!s}") # Ask user to verify response = test_context.prompt_user("Is the LED OFF? (y/n)") @@ -102,7 +102,7 @@ async def verify_notification_handling(ble_manager, test_context): except Exception as e: # Only catch to ensure we end the test properly - test_context.log(f"Notification handling: {str(e)}") + test_context.log(f"Notification handling: {e!s}") return test_context.end_test("pass", "Notification handling verification complete") @@ -134,7 +134,7 @@ async def main(): logger.info(f"Found {len(devices)} devices:") for i, device in enumerate(devices): - logger.info(f"{i+1}: {device.name or 'Unknown'} ({device.address})") + logger.info(f"{i + 1}: {device.name or 'Unknown'} ({device.address})") device_idx = int(input("Enter device number to connect to: ")) - 1 if 0 <= device_idx < len(devices): diff --git a/examples/run_nordic_example.py b/examples/run_nordic_example.py index fcdf07f..907b7dc 100644 --- a/examples/run_nordic_example.py +++ b/examples/run_nordic_example.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -""" -Run Nordic Blinky Example. +"""Run Nordic Blinky Example. This script demonstrates how to run the Nordic Blinky example tests programmatically. """ + import asyncio import logging import os @@ -44,7 +44,7 @@ async def run_blinky_tests(device_name: str = None, device_address: str = None): # Display found devices console.print(f"[bold green]Found {len(devices)} devices:[/bold green]") for i, device in enumerate(devices): - console.print(f"{i+1}. {device.name or 'Unknown'} ({device.address})") + console.print(f"{i + 1}. {device.name or 'Unknown'} ({device.address})") # Connect to first matching device target_device = devices[0] @@ -92,7 +92,7 @@ async def run_blinky_tests(device_name: str = None, device_address: str = None): console.print( f"[bold]Total:[/bold] {total} | [bold green]Passed:[/bold green] {passed} | [bold red]Failed:[/bold red]" - f" {failed}" + f" {failed}", ) finally: @@ -121,7 +121,7 @@ def main(): except KeyboardInterrupt: console.print("\n[bold yellow]Test execution interrupted![/bold yellow]") except Exception as e: - console.print(f"\n[bold red]Error: {str(e)}[/bold red]") + console.print(f"\n[bold red]Error: {e!s}[/bold red]") import traceback traceback.print_exc() diff --git a/pyproject.toml b/pyproject.toml index aaea937..bd14054 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,11 +41,7 @@ test = [ "tox-uv>=1.25.0", ] lint = [ - "black>=25.1.0", - "isort>=6.0.1", - "flake8>=7.1.2", - "flake8-docstrings>=1.7.0", - "flake8-pyproject>=1.2.3", + "ruff>=0.3.0", ] type = [ "mypy>=1.15.0", @@ -73,37 +69,54 @@ dev = [ # Formatting and linting -[tool.black] +[tool.ruff] line-length = 120 - -[tool.isort] -profile = "black" -line_length = 120 - -[tool.flake8] -# Check that this is aligned with your other tools like Black -max-line-length = 120 -exclude = [ - # No need to traverse our git directory - ".git", - # There's no value in checking cache directories - "__pycache__", - "*.pyc", - ".venv", - ".tox", -] -# Use extend-ignore to add to already ignored checks which are anti-patterns like W503. -extend-ignore = [ - # PEP 8 recommends to treat : in slices as a binary operator with the lowest priority, and to leave an equal - # amount of space on either side, except if a parameter is omitted (e.g. ham[1 + 1 :]). - # This behaviour may raise E203 whitespace before ':' warnings in style guide enforcement tools like Flake8. - # Since E203 is not PEP 8 compliant, we tell Flake8 to ignore this warning. - # https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html#slices - "E203", - # Black adds newlines after docstrings if the next line is a function def, and then D202 causes an error - "D202" +target-version = "py312" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "D", # pydocstyle + "UP", # pyupgrade + "N", # pep8-naming + "S", # bandit + "C", # flake8-comprehensions + "B", # flake8-bugbear + "A", # flake8-builtins + "COM", # flake8-commas + "T20", # flake8-print + "PT", # flake8-pytest-style + "RET", # flake8-return + "SIM", # flake8-simplify + "ARG", # flake8-unused-arguments + "PTH", # flake8-use-pathlib + "ERA", # eradicate + "PL", # pylint + "TRY", # tryceratops + "RUF", # ruff-specific rules ] +# Ignore assert usage in test files +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101", "ARG001"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.isort] +force-single-line = false +known-first-party = ["test_a_ble"] +combine-as-imports = true + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + [tool.mypy] files = ["test_a_ble", "docs", "tests"] @@ -136,12 +149,11 @@ commands = [["pytest"]] [tool.tox.env.lint] runner = "uv-venv-lock-runner" -description = "format code" +description = "format and lint code" dependency_groups = ["lint"] commands = [ - ["black", "--check", "."], - ["isort", "--check", "."], - ["flake8", "."], + ["ruff", "check", "."], + ["ruff", "format", "."], ] [tool.tox.env.format] @@ -149,8 +161,8 @@ runner = "uv-venv-lock-runner" description = "format code" dependency_groups = ["lint"] commands = [ - ["black", "."], - ["isort", "."], + ["ruff", "format", "."], + ["ruff", "check", "--select", "I", "--fix", "."], ] [tool.tox.env.type] diff --git a/scripts/release.py b/scripts/release.py index 8996d89..bf88eb6 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Script to bump the version of the package. +"""Script to bump the version of the package. Usage: python scripts/bump_version.py [major|minor|patch] """ @@ -16,7 +15,7 @@ def update_setup_py(new_version): """Update the version in setup.py.""" setup_py_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "setup.py") - with open(setup_py_path, "r") as f: + with open(setup_py_path) as f: content = f.read() # Replace the version @@ -31,7 +30,7 @@ def update_setup_py(new_version): def update_init_py(new_version): """Update the version in __init__.py.""" init_py_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "test-a-ble", "__init__.py") - with open(init_py_path, "r") as f: + with open(init_py_path) as f: content = f.read() # Replace the version @@ -50,7 +49,7 @@ def update_init_py(new_version): def update_docs_conf_py(new_version): """Update the version in docs/source/conf.py.""" conf_py_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "docs", "source", "conf.py") - with open(conf_py_path, "r") as f: + with open(conf_py_path) as f: content = f.read() # Replace the version @@ -65,7 +64,7 @@ def update_docs_conf_py(new_version): def update_changelog(new_version): """Update the changelog with a new version section.""" changelog_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "CHANGELOG.md") - with open(changelog_path, "r") as f: + with open(changelog_path) as f: content = f.read() # Check if the new version already exists in the changelog @@ -107,16 +106,15 @@ def update_changelog(new_version): def get_current_version(): """Get the current version from setup.py.""" setup_py_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "setup.py") - with open(setup_py_path, "r") as f: + with open(setup_py_path) as f: content = f.read() # Extract the version match = re.search(r'version="([0-9]+\.[0-9]+\.[0-9]+)"', content) if match: return match.group(1) - else: - print("Could not find version in setup.py") - sys.exit(1) + print("Could not find version in setup.py") + sys.exit(1) def bump_version(current_version, part): @@ -175,12 +173,8 @@ def main(): if response == "y" or response == "yes": print("Running git commands...") if args.part: - subprocess.run( - ["git", "commit", "-am", f"Bump version to {new_version}"], check=True, shell=False - ) # nosec B603 - subprocess.run( - ["git", "tag", "-a", f"v{new_version}", "-m", f"Version {new_version}"], check=True, shell=False - ) # nosec B603 + subprocess.run(["git", "commit", "-am", f"Bump version to {new_version}"], check=True, shell=False) # nosec B603 + subprocess.run(["git", "tag", "-a", f"v{new_version}", "-m", f"Version {new_version}"], check=True, shell=False) # nosec B603 print("Do you want to push the changes? (y/n)") push_response = input().strip().lower() diff --git a/test_a_ble/__init__.py b/test_a_ble/__init__.py index 518feb6..24d8e3e 100644 --- a/test_a_ble/__init__.py +++ b/test_a_ble/__init__.py @@ -1,5 +1,4 @@ -""" -BLE IoT Device Testing Framework. +"""BLE IoT Device Testing Framework. A modular, extensible framework for testing Bluetooth Low Energy IoT devices. """ @@ -11,9 +10,8 @@ __version__ = "0.1.0" -def setup_logging(verbose: bool = False, log_file: Optional[str] = None): - """ - Configure logging for the BLE test framework. +def setup_logging(verbose: bool = False, log_file: str | None = None): + """Configure logging for the BLE test framework. Args: verbose: If True, set log level to DEBUG, otherwise WARNING to hide diff --git a/test_a_ble/ble_manager.py b/test_a_ble/ble_manager.py index d1a7e3d..0b531d1 100644 --- a/test_a_ble/ble_manager.py +++ b/test_a_ble/ble_manager.py @@ -1,5 +1,4 @@ -""" -BLE Manager. +"""BLE Manager. Manages BLE device discovery, connection, and communication. """ @@ -8,7 +7,8 @@ import logging import sys import uuid -from typing import Any, Callable, Dict, List, Optional, Set, Union +from collections.abc import Callable +from typing import Any from bleak import BleakClient, BleakScanner from bleak.backends.device import BLEDevice @@ -18,7 +18,8 @@ def retrieveConnectedPeripheralsWithServices( - scanner: BleakScanner, services: Union[List[str], List[uuid.UUID]] + scanner: BleakScanner, + services: list[str] | list[uuid.UUID], ) -> list[BLEDevice]: """Retrieve connected peripherals with specified services.""" devices: list[BLEDevice] = [] @@ -27,7 +28,7 @@ def retrieveConnectedPeripheralsWithServices( from Foundation import NSArray # type: ignore for p in scanner._backend._manager.central_manager.retrieveConnectedPeripheralsWithServices_( # type: ignore - NSArray.alloc().initWithArray_(list(map(CBUUID.UUIDWithString_, services))) + NSArray.alloc().initWithArray_(list(map(CBUUID.UUIDWithString_, services))), ): if scanner._backend._use_bdaddr: # type: ignore # HACK: retrieveAddressForPeripheral_ is undocumented but seems to do the @@ -63,12 +64,11 @@ class BLEManager: """Manages BLE device discovery, connection, and communication.""" # Class variable to store services that the framework should look for when finding connected devices - _expected_service_uuids: Set[str] = set() + _expected_service_uuids: set[str] = set() @classmethod def register_expected_services(cls, service_uuids): - """ - Register service UUIDs that should be used when looking for connected devices. + """Register service UUIDs that should be used when looking for connected devices. Args: service_uuids: List or set of service UUID strings in standard format @@ -87,24 +87,23 @@ def register_expected_services(cls, service_uuids): def __init__(self: "BLEManager"): """Initialize the BLEManager.""" - self.device: Optional[BLEDevice] = None - self.client: Optional[BleakClient] = None - self.discovered_devices: List[BLEDevice] = [] - self.services: Dict[str, Any] = {} - self.characteristics: Dict[str, Any] = {} - self.notification_callbacks: Dict[str, List[Callable]] = {} + self.device: BLEDevice | None = None + self.client: BleakClient | None = None + self.discovered_devices: list[BLEDevice] = [] + self.services: dict[str, Any] = {} + self.characteristics: dict[str, Any] = {} + self.notification_callbacks: dict[str, list[Callable]] = {} self.connected = False - self.advertisement_data_map: Dict[str, AdvertisementData] = {} # Map device addresses to advertisement data + self.advertisement_data_map: dict[str, AdvertisementData] = {} # Map device addresses to advertisement data self.active_subscriptions: list[str] = [] async def discover_devices( self, timeout: float = 5.0, - name_filter: Optional[str] = None, - address_filter: Optional[str] = None, - ) -> List[BLEDevice]: - """ - Scan for BLE devices and return filtered results. + name_filter: str | None = None, + address_filter: str | None = None, + ) -> list[BLEDevice]: + """Scan for BLE devices and return filtered results. Args: timeout: Scan duration in seconds @@ -145,7 +144,7 @@ def _device_found(device: BLEDevice, adv_data: AdvertisementData): if devices: logger.debug( f"Found {len(devices)} connected devices with services " - f"{self._expected_service_uuids}, not scanning for more devices" + f"{self._expected_service_uuids}, not scanning for more devices", ) else: await scanner.start() @@ -165,12 +164,11 @@ def get_rssi(device): async def connect_to_device( self, - device_or_address: Union[BLEDevice, str], + device_or_address: BLEDevice | str, retry_count: int = 3, retry_delay: float = 1.0, ) -> bool: - """ - Connect to a BLE device. + """Connect to a BLE device. Args: device_or_address: BLEDevice or device address to connect to @@ -207,7 +205,7 @@ async def connect_to_device( # For modern Bleak (0.19.0+), create a device with required parameters self.device = BLEDevice(address=device_or_address, name=None, details={}, rssi=0) except Exception as e: - logger.error(f"Failed to create BLEDevice: {str(e)}") + logger.error(f"Failed to create BLEDevice: {e!s}") logger.debug("Attempting to discover the device first...") # Try to discover the device first @@ -241,7 +239,7 @@ async def connect_to_device( return True except Exception as e: - logger.warning(f"Connection attempt {attempt + 1} failed: {str(e)}") + logger.warning(f"Connection attempt {attempt + 1} failed: {e!s}") if attempt < retry_count - 1: await asyncio.sleep(retry_delay) @@ -290,9 +288,8 @@ async def disconnect(self): logger.debug("Disconnect cleanup completed") - async def discover_services(self, cache: bool = True) -> Dict[str, Any]: - """ - Discover services and characteristics of the connected device. + async def discover_services(self, cache: bool = True) -> dict[str, Any]: + """Discover services and characteristics of the connected device. Args: cache: Whether to cache results for future use @@ -342,8 +339,7 @@ async def discover_services(self, cache: bool = True) -> Dict[str, Any]: return services async def read_characteristic(self, characteristic_uuid: str) -> bytearray: - """ - Read value from a characteristic. + """Read value from a characteristic. Args: characteristic_uuid: UUID of the characteristic to read @@ -362,11 +358,10 @@ async def read_characteristic(self, characteristic_uuid: str) -> bytearray: async def write_characteristic( self, characteristic_uuid: str, - data: Union[bytes, bytearray, memoryview], + data: bytes | bytearray | memoryview, response: bool = True, ) -> None: - """ - Write value to a characteristic. + """Write value to a characteristic. Args: characteristic_uuid: UUID of the characteristic to write to @@ -395,7 +390,7 @@ async def write_characteristic( logger.debug(f"Characteristic {characteristic_uuid} is readable: {is_readable}") except Exception as e: - logger.debug(f"Error checking if characteristic is readable: {str(e)}") + logger.debug(f"Error checking if characteristic is readable: {e!s}") is_readable = False try: @@ -405,7 +400,7 @@ async def write_characteristic( current_value = await self.client.read_gatt_char(characteristic_uuid) logger.debug(f"Current value before write: {current_value.hex()}") except Exception as e: - logger.debug(f"Could not read characteristic before write despite being readable: {str(e)}") + logger.debug(f"Could not read characteristic before write despite being readable: {e!s}") else: logger.debug("Skipping pre-write read - characteristic not readable") @@ -428,21 +423,20 @@ async def write_characteristic( else: logger.warning(f"Write verification failed. Expected: {data.hex()}, Got: {new_value.hex()}") except Exception as e: - logger.debug(f"Could not verify write: {str(e)}") + logger.debug(f"Could not verify write: {e!s}") elif not is_readable: logger.debug("Skipping write verification - characteristic not readable") logger.debug("Write operation completed") except Exception as e: - logger.error(f"Error writing to characteristic {characteristic_uuid}: {str(e)}") + logger.error(f"Error writing to characteristic {characteristic_uuid}: {e!s}") raise def _notification_handler(self, characteristic_uuid: str): """Create a notification handler for a specific characteristic.""" def _handle_notification(sender, data: bytearray): - """ - Handle BLE notifications in latest Bleak versions. + """Handle BLE notifications in latest Bleak versions. The sender parameter can be of different types in different Bleak versions. """ @@ -455,7 +449,7 @@ def _handle_notification(sender, data: bytearray): try: callback(data) except Exception as e: - logger.error(f"Error in notification callback: {str(e)}") + logger.error(f"Error in notification callback: {e!s}") # If we get a non-data value (like an error string), log it but don't invoke callbacks elif data is not None: # Log but at debug level to avoid cluttering logs @@ -464,10 +458,11 @@ def _handle_notification(sender, data: bytearray): return _handle_notification async def subscribe_to_characteristic( - self, characteristic_uuid: str, callback: Callable[[bytearray], None] + self, + characteristic_uuid: str, + callback: Callable[[bytearray], None], ) -> None: - """ - Subscribe to notifications from a characteristic. + """Subscribe to notifications from a characteristic. Args: characteristic_uuid: UUID of the characteristic to subscribe to @@ -492,12 +487,11 @@ async def subscribe_to_characteristic( self.notification_callbacks[characteristic_uuid].append(callback) logger.debug( f"Added callback for {characteristic_uuid}, total callbacks: " - f"{len(self.notification_callbacks[characteristic_uuid])}" + f"{len(self.notification_callbacks[characteristic_uuid])}", ) async def unsubscribe_from_characteristic(self, characteristic_uuid: str) -> None: - """ - Unsubscribe from notifications from a characteristic. + """Unsubscribe from notifications from a characteristic. Args: characteristic_uuid: UUID of the characteristic to unsubscribe from @@ -533,7 +527,7 @@ async def unsubscribe_from_characteristic(self, characteristic_uuid: str) -> Non if characteristic_uuid in self.notification_callbacks: logger.debug( f"Clearing {len(self.notification_callbacks[characteristic_uuid])} callbacks for " - f"{characteristic_uuid}" + f"{characteristic_uuid}", ) del self.notification_callbacks[characteristic_uuid] @@ -545,7 +539,7 @@ async def unsubscribe_from_characteristic(self, characteristic_uuid: str) -> Non if characteristic_uuid in self.notification_callbacks: del self.notification_callbacks[characteristic_uuid] - def get_discovered_device_info(self) -> List[Dict[str, Any]]: + def get_discovered_device_info(self) -> list[dict[str, Any]]: """Return information about discovered devices in a structured format.""" result = [] for device in self.discovered_devices: @@ -558,6 +552,6 @@ def get_discovered_device_info(self) -> List[Dict[str, Any]]: "name": device.name or "Unknown", "address": device.address, "rssi": rssi, - } + }, ) return result diff --git a/test_a_ble/cli.py b/test_a_ble/cli.py index e7c24c6..7fdd891 100644 --- a/test_a_ble/cli.py +++ b/test_a_ble/cli.py @@ -6,7 +6,7 @@ import logging import sys import time -from typing import Any, Dict, Optional, Tuple +from typing import Any import bleak from rich import box @@ -29,9 +29,8 @@ def get_console() -> Console: return console -async def dynamic_device_selection(ble_manager: BLEManager, timeout: float = 10.0) -> Tuple[bool, bool]: - """ - Interactive device discovery with real-time updates and concurrent user input. +async def dynamic_device_selection(ble_manager: BLEManager, timeout: float = 10.0) -> tuple[bool, bool]: + """Interactive device discovery with real-time updates and concurrent user input. Args: ble_manager: BLE Manager instance @@ -44,7 +43,7 @@ async def dynamic_device_selection(ble_manager: BLEManager, timeout: float = 10. console.print(f"[dim]Scan will continue for up to {timeout} seconds[/dim]") console.print( "[bold yellow]Enter a device number to select it immediately, press Enter for options, or wait for scan to " - "complete[/bold yellow]" + "complete[/bold yellow]", ) # Keep track of discovered devices in order of discovery @@ -108,7 +107,7 @@ async def update_ui(): await asyncio.wait_for(ui_update_needed.wait(), timeout=0.5) ui_update_needed.clear() force_update = True # Force update when signal is received - except asyncio.TimeoutError: + except TimeoutError: # Force update every 3 seconds regardless of signal if time.time() - last_update_time >= 3.0: force_update = True @@ -149,12 +148,12 @@ async def update_ui(): console.print(table) console.print( "[bold yellow]Enter a device number to select it immediately, press Enter for options, or wait " - "for scan to complete[/bold yellow]" + "for scan to complete[/bold yellow]", ) else: console.print("[dim]No devices found yet...[/dim]") console.print( - "[bold yellow]Press Enter for options or wait for devices to be discovered[/bold yellow]" + "[bold yellow]Press Enter for options or wait for devices to be discovered[/bold yellow]", ) except Exception as e: @@ -190,44 +189,41 @@ async def update_ui(): if 0 <= device_index < len(discovered_devices): device = discovered_devices[device_index] console.print( - f"[bold]Connecting to {device.name or 'Unknown'} ({device.address})...[/bold]" + f"[bold]Connecting to {device.name or 'Unknown'} ({device.address})...[/bold]", ) connected = await ble_manager.connect_to_device(device) if connected: console.print(f"[bold green]Successfully connected to {device.address}![/bold green]") return True, False # Connected, not user quit - else: - console.print(f"[bold red]Failed to connect to {device.address}![/bold red]") - # Return to selection menu rather than quitting - break - else: - console.print(f"[bold red]Invalid device number: {user_input}![/bold red]") - await asyncio.sleep(1) # Brief pause so user can see the error - # Continue scanning - stop_event.clear() - scan_task = asyncio.create_task(scan_for_devices()) - ui_task = asyncio.create_task(update_ui()) - continue - else: - # Empty input (just Enter key) - stop scanning and show menu - stop_event.set() - await asyncio.wait_for( - asyncio.gather(scan_task, ui_task, return_exceptions=True), - timeout=2.0, - ) - break + console.print(f"[bold red]Failed to connect to {device.address}![/bold red]") + # Return to selection menu rather than quitting + break + console.print(f"[bold red]Invalid device number: {user_input}![/bold red]") + await asyncio.sleep(1) # Brief pause so user can see the error + # Continue scanning + stop_event.clear() + scan_task = asyncio.create_task(scan_for_devices()) + ui_task = asyncio.create_task(update_ui()) + continue + # Empty input (just Enter key) - stop scanning and show menu + stop_event.set() + await asyncio.wait_for( + asyncio.gather(scan_task, ui_task, return_exceptions=True), + timeout=2.0, + ) + break except ValueError: # Not a number, treat as Enter key console.print( - f"[bold red]Invalid input: {user_input}. Press Enter or enter a device number.[/bold red]" + f"[bold red]Invalid input: {user_input}. Press Enter or enter a device number.[/bold red]", ) await asyncio.sleep(1) # Brief pause so user can see the error # Continue scanning ui_update_needed.set() # Force UI refresh continue - except asyncio.TimeoutError: + except TimeoutError: # No input received, continue scanning continue @@ -248,14 +244,14 @@ async def update_ui(): scan_task.cancel() try: await asyncio.wait_for(scan_task, timeout=1.0) - except (asyncio.TimeoutError, asyncio.CancelledError): + except (TimeoutError, asyncio.CancelledError): pass if not ui_task.done(): ui_task.cancel() try: await asyncio.wait_for(ui_task, timeout=1.0) - except (asyncio.TimeoutError, asyncio.CancelledError): + except (TimeoutError, asyncio.CancelledError): pass # Show selection menu after scan completes or user presses Enter @@ -279,7 +275,7 @@ async def update_ui(): while True: selection = console.input( - "\n[bold yellow]Enter device number to connect, 'r' to rescan, or 'q' to quit: [/bold yellow]" + "\n[bold yellow]Enter device number to connect, 'r' to rescan, or 'q' to quit: [/bold yellow]", ) if selection.lower() == "q": @@ -303,20 +299,17 @@ async def update_ui(): if connected: console.print(f"[bold green]Successfully connected to {device.address}![/bold green]") return True, False # Connected, not user quit - else: - console.print(f"[bold red]Failed to connect to {device.address}![/bold red]") - # Ask if user wants to try again - retry = console.input("[bold yellow]Try again? (y/n): [/bold yellow]") - if retry.lower() == "y": - # Restart scanning - discovered_devices.clear() - ble_manager.advertisement_data_map.clear() - ble_manager.discovered_devices.clear() - return await dynamic_device_selection(ble_manager, timeout) - else: - return False, True # User quit - else: - console.print("[bold red]Invalid selection![/bold red]") + console.print(f"[bold red]Failed to connect to {device.address}![/bold red]") + # Ask if user wants to try again + retry = console.input("[bold yellow]Try again? (y/n): [/bold yellow]") + if retry.lower() == "y": + # Restart scanning + discovered_devices.clear() + ble_manager.advertisement_data_map.clear() + ble_manager.discovered_devices.clear() + return await dynamic_device_selection(ble_manager, timeout) + return False, True # User quit + console.print("[bold red]Invalid selection![/bold red]") except ValueError: console.print("[bold red]Please enter a number, 'r', or 'q'![/bold red]") else: @@ -333,13 +326,12 @@ async def update_ui(): async def connect_to_device( ble_manager: BLEManager, - address: Optional[str] = None, - name: Optional[str] = None, + address: str | None = None, + name: str | None = None, interactive: bool = False, scan_timeout: float = 10.0, -) -> Tuple[bool, bool]: - """ - Connect to a BLE device by address, name, or interactively. +) -> tuple[bool, bool]: + """Connect to a BLE device by address, name, or interactively. Args: ble_manager: BLE Manager instance @@ -364,9 +356,8 @@ async def connect_to_device( if connected: console.print(f"[bold green]Successfully connected to {address}![/bold green]") return True, False # Connected, not user quit - else: - console.print(f"[bold red]Failed to connect to {address}![/bold red]") - return False, False # Not connected, not user quit + console.print(f"[bold red]Failed to connect to {address}![/bold red]") + return False, False # Not connected, not user quit # Connect by name if name: @@ -385,18 +376,16 @@ async def connect_to_device( if connected: console.print(f"[bold green]Successfully connected to {device.address}![/bold green]") return True, False # Connected, not user quit - else: - console.print(f"[bold red]Failed to connect to {device.address}![/bold red]") - return False, False # Not connected, not user quit + console.print(f"[bold red]Failed to connect to {device.address}![/bold red]") + return False, False # Not connected, not user quit # No connection method specified console.print("[bold red]No device specified for connection![/bold red]") return False, False # Not connected, not user quit -def print_test_results(results: Dict[str, Any], verbose=False): - """ - Print formatted test results. +def print_test_results(results: dict[str, Any], verbose=False): + """Print formatted test results. Args: results: Test results dictionary @@ -546,7 +535,9 @@ async def run_ble_tests(args): # No address or name specified, use interactive device discovery console.print("[bold]No device address or name specified, starting interactive device discovery...[/bold]") connected, user_quit = await connect_to_device( - ble_manager, interactive=True, scan_timeout=args.scan_timeout + ble_manager, + interactive=True, + scan_timeout=args.scan_timeout, ) if not connected: if user_quit: @@ -650,7 +641,7 @@ def main(): """Execute the main function.""" parser = argparse.ArgumentParser( description="BLE IoT Device Testing Tool - Discovers and runs tests for BLE devices. " - "If no device address or name is provided, interactive device discovery will be used." + "If no device address or name is provided, interactive device discovery will be used.", ) # Device selection options @@ -753,7 +744,7 @@ def main(): except Exception as e: logger.error(f"Error during test execution: {e}") - console.print(f"\n[bold red]Error: {str(e)}[/bold red]") + console.print(f"\n[bold red]Error: {e!s}[/bold red]") if args.verbose: console.print_exception() finally: diff --git a/test_a_ble/test_context.py b/test_a_ble/test_context.py index 1ed9e34..4e530c2 100644 --- a/test_a_ble/test_context.py +++ b/test_a_ble/test_context.py @@ -1,5 +1,4 @@ -""" -Test Context for BLE tests. +"""Test Context for BLE tests. Provides environment for test execution. """ @@ -7,8 +6,9 @@ import asyncio import logging import time +from collections.abc import Callable from enum import Enum -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import Any, Optional from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.shortcuts import PromptSession @@ -20,8 +20,7 @@ # Decorator for test functions def ble_test(description=None): - """ - Decorate a BLE test function. + """Decorate a BLE test function. Args: description: Description of the test (optional, will use function name if not provided) @@ -48,8 +47,7 @@ def decorator(func): # Decorator for test classes def ble_test_class(description=None): - """ - Decorate a BLE test class. + """Decorate a BLE test class. Args: description: Description of the test class (optional, will use class name if not provided) @@ -129,10 +127,7 @@ def __str__(self): # (NotificationResult, str) # If the callable returns a NotificationResult of FAIL, the reason should be provided in the str NotificationExpectedValue = Optional[ - Union[ - bytes, - Callable[[bytes], Union[bool, NotificationResult, Tuple[NotificationResult, str]]], - ] + bytes | Callable[[bytes], bool | NotificationResult | tuple[NotificationResult, str]] ] @@ -144,13 +139,12 @@ def __init__(self, characteristic_uuid: str, expected_value: NotificationExpecte self.characteristic_uuid = characteristic_uuid self.expected_value = expected_value self.received_notifications: list[bytes] = [] - self.matching_notification: Optional[bytes] = None - self.failure_reason: Optional[str] = None + self.matching_notification: bytes | None = None + self.failure_reason: str | None = None self.complete_event = asyncio.Event() - def check_notification(self, data: bytes) -> Tuple[bool, Optional[str]]: - """ - Check if a notification matches our criteria. + def check_notification(self, data: bytes) -> tuple[bool, str | None]: + """Check if a notification matches our criteria. Args: data: The notification data to check @@ -175,27 +169,26 @@ def check_notification(self, data: bytes) -> Tuple[bool, Optional[str]]: notification_result, reason = result if notification_result == NotificationResult.MATCH: return True, None - elif notification_result == NotificationResult.FAIL: + if notification_result == NotificationResult.FAIL: return ( False, reason or f"Notification evaluated as failure condition: {data.hex()}", ) - else: # IGNORE - return False, None - elif isinstance(result, NotificationResult): + # IGNORE + return False, None + if isinstance(result, NotificationResult): # Handle direct NotificationResult enum if result == NotificationResult.MATCH: return True, None - elif result == NotificationResult.FAIL: + if result == NotificationResult.FAIL: return ( False, f"Notification evaluated as failure condition: {data.hex()}", ) - else: # IGNORE - return False, None - else: - # True = match, False = ignore (not a failure) - return bool(result), None + # IGNORE + return False, None + # True = match, False = ignore (not a failure) + return bool(result), None except Exception as e: # If the function raises an exception, log it but don't fail logger.error(f"Error in notification evaluation function: {e}") @@ -208,8 +201,7 @@ def check_notification(self, data: bytes) -> Tuple[bool, Optional[str]]: ) def on_notification(self, data) -> bool: - """ - Handle a notification. + """Handle a notification. Args: data: The notification data @@ -231,21 +223,20 @@ def on_notification(self, data) -> bool: self.matching_notification = data self.complete_event.set() return True - elif failure_reason: + if failure_reason: # We have a failure condition from the notification logger.debug(f"Notification indicates failure: {failure_reason}") self.failure_reason = failure_reason self.complete_event.set() return False - else: - logger.debug("Notification in callback didn't match criteria") - return False + logger.debug("Notification in callback didn't match criteria") + return False class NotificationSubscription: """A helper class to manage notification subscriptions and waiters.""" - def __init__(self, characteristic_uuid: str, initial_waiter: Optional[NotificationWaiter] = None): + def __init__(self, characteristic_uuid: str, initial_waiter: NotificationWaiter | None = None): """Initialize the notification subscription.""" self.characteristic_uuid = characteristic_uuid self.waiter = initial_waiter @@ -256,7 +247,7 @@ def on_notification(self, data): self.collected_notifications.append(data) logger.debug( f"Notification callback received: {data.hex() if data else 'None'}, " - f"{len(self.collected_notifications)} notifications collected" + f"{len(self.collected_notifications)} notifications collected", ) if self.waiter is None: return @@ -281,8 +272,7 @@ def clear_waiter(self): class TestContext: - """ - Context for test execution. + """Context for test execution. Provides access to the BLE device and helper methods for test operations. """ @@ -291,13 +281,12 @@ def __init__(self, ble_manager: BLEManager): """Initialize the test context.""" self.ble_manager = ble_manager self.start_time = time.time() - self.test_results: Dict[str, Dict[str, Any]] = {} - self.current_test: Optional[str] = None - self.notification_subscriptions: Dict[str, NotificationSubscription] = {} + self.test_results: dict[str, dict[str, Any]] = {} + self.current_test: str | None = None + self.notification_subscriptions: dict[str, NotificationSubscription] = {} - def print_formatted_box(self, title: str, messages: List[str]) -> None: - """ - Print a formatted box with consistent alignment. + def print_formatted_box(self, title: str, messages: list[str]) -> None: + """Print a formatted box with consistent alignment. Args: title: The title to display at the top of the box @@ -348,8 +337,7 @@ def print_formatted_box(self, title: str, messages: List[str]) -> None: print("╚" + "═" * (box_width - 2) + "╝") def print(self, message: str) -> None: - """ - Print a message directly to the console for user-facing output. + """Print a message directly to the console for user-facing output. Use this for information that should always be visible to the user, regardless of log level settings. @@ -376,12 +364,11 @@ def print(self, message: str) -> None: "timestamp": time.time(), "level": "USER", # Special level to mark user-facing output "message": clean_message, - } + }, ) def prompt_user(self, message: str) -> str: - """ - Display a prompt to the user and wait for input. + """Display a prompt to the user and wait for input. Args: message: The message to display to the user @@ -397,8 +384,7 @@ def prompt_user(self, message: str) -> str: return response def start_test(self, test_name: str) -> None: - """ - Start a new test and record the start time. + """Start a new test and record the start time. Args: test_name: Name of the test being started @@ -413,8 +399,7 @@ def start_test(self, test_name: str) -> None: logger.debug(f"Starting test: {test_name}") async def unsubscribe_all(self) -> None: - """ - Unsubscribe from all active notification subscriptions. + """Unsubscribe from all active notification subscriptions. Call this at the end of a test to clean up resources. """ @@ -432,13 +417,12 @@ async def unsubscribe_all(self) -> None: self.notification_subscriptions.pop(characteristic_uuid, None) logger.debug(f"Successfully unsubscribed from {characteristic_uuid}") except Exception as e: - logger.error(f"Error unsubscribing from {characteristic_uuid}: {str(e)}") + logger.error(f"Error unsubscribing from {characteristic_uuid}: {e!s}") logger.debug(f"Unsubscribed from all {len(characteristics)} active characteristics") async def cleanup_tasks(self): - """ - Clean up any remaining async tasks created during testing. + """Clean up any remaining async tasks created during testing. This should be called before program exit. """ @@ -448,9 +432,8 @@ async def cleanup_tasks(self): # Clear any remaining state logger.debug("Cleanup tasks completed") - def end_test(self, status: Union[TestStatus, str], message: str = "") -> Dict[str, Any]: - """ - End the current test and record results. + def end_test(self, status: TestStatus | str, message: str = "") -> dict[str, Any]: + """End the current test and record results. Args: status: Test status (TestStatus enum or string value) @@ -484,7 +467,7 @@ def end_test(self, status: Union[TestStatus, str], message: str = "") -> Dict[st "duration": end_time - self.test_results[test_name]["start_time"], "status": status_value, "message": message, - } + }, ) # Define color codes for different statuses @@ -513,8 +496,7 @@ def end_test(self, status: Union[TestStatus, str], message: str = "") -> Dict[st return self.test_results[test_name] def log(self, message: str, level: str = "info") -> None: - """ - Log a message within the current test context. + """Log a message within the current test context. Args: message: Message to log @@ -526,7 +508,7 @@ def log(self, message: str, level: str = "info") -> None: # Always store in test results with level information for later retrieval if self.current_test: self.test_results[self.current_test]["logs"].append( - {"timestamp": time.time(), "level": level.upper(), "message": message} + {"timestamp": time.time(), "level": level.upper(), "message": message}, ) # Only display INFO and DEBUG logs in the console if the test fails @@ -562,11 +544,10 @@ def critical(self, message: str) -> None: async def subscribe_to_characteristic( self, characteristic_uuid: str, - waiter: Optional[NotificationWaiter] = None, + waiter: NotificationWaiter | None = None, process_collected_notifications: bool = True, ): - """ - Subscribe to a characteristic and create a waiter if provided. + """Subscribe to a characteristic and create a waiter if provided. Args: characteristic_uuid: UUID of characteristic to subscribe to @@ -589,11 +570,11 @@ async def subscribe_to_characteristic( await asyncio.sleep(0.5) except Exception as e: - logger.error(f"Error subscribing to characteristic: {str(e)}") + logger.error(f"Error subscribing to characteristic: {e!s}") # Remove the waiter if we failed to subscribe if characteristic_uuid in self.notification_subscriptions: del self.notification_subscriptions[characteristic_uuid] - raise RuntimeError(f"Failed to subscribe: {str(e)}") + raise RuntimeError(f"Failed to subscribe: {e!s}") else: # Already subscribed - reuse the existing subscription logger.debug(f"Using existing subscription to {characteristic_uuid}") @@ -612,8 +593,7 @@ async def create_notification_waiter( expected_value: NotificationExpectedValue = None, process_collected_notifications: bool = True, ) -> NotificationWaiter: - """ - Create a notification waiter for a characteristic. + """Create a notification waiter for a characteristic. Args: characteristic_uuid: UUID of characteristic to wait for notification @@ -630,9 +610,8 @@ async def create_notification_waiter( return waiter - def handle_notification_waiter_result(self, waiter: NotificationWaiter, timeout: float) -> Dict[str, Any]: - """ - Handle the result of a notification waiter. + def handle_notification_waiter_result(self, waiter: NotificationWaiter, timeout: float) -> dict[str, Any]: + """Handle the result of a notification waiter. Args: waiter: The notification waiter to check @@ -651,32 +630,31 @@ def handle_notification_waiter_result(self, waiter: NotificationWaiter, timeout: if waiter.matching_notification: logger.debug( "Found matching notification: " - f"{waiter.matching_notification.hex() if waiter.matching_notification else 'None'}" + f"{waiter.matching_notification.hex() if waiter.matching_notification else 'None'}", ) return { "value": waiter.matching_notification, "success": True, "received_notifications": waiter.received_notifications, } - elif waiter.failure_reason: + if waiter.failure_reason: # We got a failure notification logger.info(f"Test failed due to notification: {waiter.failure_reason}") raise TestFailure(waiter.failure_reason) - elif waiter.received_notifications: + if waiter.received_notifications: # We got notifications but none matched our expected value logger.info( - f"Received {len(waiter.received_notifications)} notifications, but none matched the expected value" + f"Received {len(waiter.received_notifications)} notifications, but none matched the expected value", ) for i, notif in enumerate(waiter.received_notifications): - logger.debug(f"Notification {i+1}: {notif.hex() if notif else 'None'}") + logger.debug(f"Notification {i + 1}: {notif.hex() if notif else 'None'}") # Raise exception for non-matching notifications raise TestFailure( - f"No matching notification received. Got: {', '.join(n.hex() for n in waiter.received_notifications)}" + f"No matching notification received. Got: {', '.join(n.hex() for n in waiter.received_notifications)}", ) - else: - # Raise timeout error with user-friendly message - raise TimeoutError(f"No notification received within {timeout} seconds") + # Raise timeout error with user-friendly message + raise TimeoutError(f"No notification received within {timeout} seconds") async def wait_for_notification( self, @@ -684,9 +662,8 @@ async def wait_for_notification( timeout: float = 10.0, expected_value: NotificationExpectedValue = None, process_collected_notifications: bool = True, - ) -> Dict[str, Any]: - """ - Wait for a notification from a characteristic without user interaction. + ) -> dict[str, Any]: + """Wait for a notification from a characteristic without user interaction. Args: characteristic_uuid: UUID of characteristic to wait for notification @@ -706,7 +683,9 @@ async def wait_for_notification( TestFailure: If a notification is received but doesn't match expected criteria """ waiter = await self.create_notification_waiter( - characteristic_uuid, expected_value, process_collected_notifications + characteristic_uuid, + expected_value, + process_collected_notifications, ) try: @@ -717,7 +696,7 @@ async def wait_for_notification( try: await asyncio.wait_for(notification_future, timeout) logger.debug("Notification received before timeout") - except asyncio.TimeoutError: + except TimeoutError: logger.info(f"Timed out waiting for notification after {timeout} seconds") if notification_future.cancel(): logger.debug("Successfully cancelled notification future") @@ -731,9 +710,8 @@ async def wait_for_notification_interactive( characteristic_uuid: str, timeout: float = 10.0, expected_value: NotificationExpectedValue = None, - ) -> Dict[str, Any]: - """ - Wait for a notification from a characteristic with user interaction support. + ) -> dict[str, Any]: + """Wait for a notification from a characteristic with user interaction support. This method will display a prompt to the user and wait for a notification. The user can type 's' or 'skip' to skip the test, or 'f' or 'fail' to fail it. @@ -759,16 +737,15 @@ async def wait_for_notification_interactive( TimeoutError: If no notification is received within the timeout """ - async def user_input_handler() -> Optional[Tuple[str, str]]: - """ - Handle user input during the waiting period. + async def user_input_handler() -> tuple[str, str] | None: + """Handle user input during the waiting period. Returns: Tuple of user response and message, or None if user input is cancelled """ print("\nThe test will continue automatically when event is detected.") print( - "If nothing happens, type 's' or 'skip' to skip, 'f' or 'fail' to fail the test, or 'd' for debug info." + "If nothing happens, type 's' or 'skip' to skip, 'f' or 'fail' to fail the test, or 'd' for debug info.", ) session = PromptSession() # type: ignore @@ -798,9 +775,9 @@ async def user_input_handler() -> Optional[Tuple[str, str]]: # Process based on user input if user_input in ["s", "skip"]: return ("skip", "User chose to skip the test") - elif user_input in ["f", "fail"]: + if user_input in ["f", "fail"]: return ("fail", "User reported test failure") - elif user_input == "d": + if user_input == "d": # Debug - show received notifications if characteristic_uuid not in self.notification_subscriptions: print(f"No subscription to {characteristic_uuid}") @@ -817,7 +794,7 @@ async def user_input_handler() -> Optional[Tuple[str, str]]: print(f"Received {len(sub.waiter.received_notifications)} notifications so far:") for i, n in enumerate(sub.waiter.received_notifications): - print(f" Notification {i+1}: {n.hex() if n else 'None'}") + print(f" Notification {i + 1}: {n.hex() if n else 'None'}") is_match, _ = sub.waiter.check_notification(n) if is_match: print(" --> This notification MATCHES the expected criteria") @@ -850,7 +827,7 @@ async def user_input_handler() -> Optional[Tuple[str, str]]: if notification_task in done: logger.info("Notification task completed first") return self.handle_notification_waiter_result(waiter, timeout) - elif user_input_task in done: + if user_input_task in done: # User input finished first if result := user_input_task.result(): user_response, message = result @@ -859,10 +836,9 @@ async def user_input_handler() -> Optional[Tuple[str, str]]: # Raise appropriate exception based on user input if user_response == "skip": raise TestSkip("User chose to skip test") - elif user_response == "fail": + if user_response == "fail": raise TestFailure("User reported test failure") - else: - raise ValueError(f"Invalid user response: {user_response}") + raise ValueError(f"Invalid user response: {user_response}") raise TimeoutError("Timeout occurred while waiting for notification or user input") finally: @@ -872,12 +848,11 @@ async def user_input_handler() -> Optional[Tuple[str, str]]: task.cancel() try: await asyncio.wait_for(task, timeout=0.1) - except (asyncio.TimeoutError, asyncio.CancelledError): + except (TimeoutError, asyncio.CancelledError): pass - def get_test_summary(self) -> Dict[str, Any]: - """ - Generate a summary of all test results. + def get_test_summary(self) -> dict[str, Any]: + """Generate a summary of all test results. Returns: Dictionary with test summary statistics diff --git a/test_a_ble/test_runner.py b/test_a_ble/test_runner.py index 490269c..087b246 100644 --- a/test_a_ble/test_runner.py +++ b/test_a_ble/test_runner.py @@ -1,5 +1,4 @@ -""" -Test Runner. +"""Test Runner. Discovers and executes BLE tests """ @@ -14,7 +13,8 @@ import re import sys import traceback -from typing import Any, Callable, Coroutine, Dict, List, Optional, Tuple, Union +from collections.abc import Callable, Coroutine +from typing import Any, Union from .ble_manager import BLEManager from .test_context import TestContext, TestException, TestFailure, TestSkip, TestStatus @@ -25,9 +25,9 @@ TestFunction = Callable[[BLEManager, TestContext], Coroutine[Any, Any, None]] # Type for test item: a test function or (class_name, class_obj, method) tuple -TestItem = Union[Callable, Tuple[str, Any, Callable]] +TestItem = Union[Callable, tuple[str, Any, Callable]] # Type for test: (test_name, test_item) -TestNameItem = Tuple[str, TestItem] +TestNameItem = tuple[str, TestItem] class TestRunner: @@ -39,8 +39,7 @@ def __init__(self, ble_manager: BLEManager): self.test_context = TestContext(ble_manager) def _is_package(self, path: str) -> bool: - """ - Check if a directory is a Python package (has __init__.py file). + """Check if a directory is a Python package (has __init__.py file). Args: path: Path to check @@ -51,8 +50,7 @@ def _is_package(self, path: str) -> bool: return os.path.isdir(path) and os.path.exists(os.path.join(path, "__init__.py")) def _import_package(self, package_path: str, base_package: str = "") -> str: - """ - Import a Python package and all its parent packages. + """Import a Python package and all its parent packages. Args: package_path: Absolute path to the package @@ -98,11 +96,10 @@ def _import_package(self, package_path: str, base_package: str = "") -> str: return full_package_name except Exception as e: - raise ImportError(f"Error importing package {full_package_name}: {str(e)}") from e + raise ImportError(f"Error importing package {full_package_name}: {e!s}") from e - def _find_and_import_nearest_package(self, path: str) -> Optional[Tuple[str, str]]: - """ - Find the nearest package in the given path and import it. + def _find_and_import_nearest_package(self, path: str) -> tuple[str, str] | None: + """Find the nearest package in the given path and import it. Args: path: Path to search for a package @@ -125,7 +122,7 @@ def _find_and_import_nearest_package(self, path: str) -> Optional[Tuple[str, str self._import_package(current_dir) return package_name, package_dir except ImportError as e: - logger.error(f"Error importing package {current_dir}: {str(e)}") + logger.error(f"Error importing package {current_dir}: {e!s}") raise # Move up to the parent directory @@ -138,9 +135,8 @@ def _find_and_import_nearest_package(self, path: str) -> Optional[Tuple[str, str return None - def _discover_tests_from_specifier(self, test_specifier: str) -> List[Tuple[str, List[TestNameItem]]]: - """ - Parse a test specifier. + def _discover_tests_from_specifier(self, test_specifier: str) -> list[tuple[str, list[TestNameItem]]]: + """Parse a test specifier. Args: test_specifier: Test specifier @@ -150,9 +146,8 @@ def _discover_tests_from_specifier(self, test_specifier: str) -> List[Tuple[str, test_item is a test function or (class, method) tuple """ - def check_if_file_exists(test_dir: str, test_file: str) -> Optional[Tuple[str, str]]: - """ - Check if a file exists in the given directory. + def check_if_file_exists(test_dir: str, test_file: str) -> tuple[str, str] | None: + """Check if a file exists in the given directory. Returns: Tuple of (test_dir, test_file) if the file exists, None otherwise @@ -169,9 +164,8 @@ def check_if_file_exists(test_dir: str, test_file: str) -> Optional[Tuple[str, s return (os.path.join(test_dir, "tests"), test_file) return None - def check_wildcard_match(test_wildcard: Optional[str], test_string: str) -> bool: - """ - Check if the test string matches the test wildcard. + def check_wildcard_match(test_wildcard: str | None, test_string: str) -> bool: + """Check if the test string matches the test wildcard. Args: test_wildcard: Wildcard to match against @@ -182,9 +176,8 @@ def check_wildcard_match(test_wildcard: Optional[str], test_string: str) -> bool """ return test_wildcard is None or fnmatch.fnmatch(test_string, test_wildcard) - def find_files_matching_wildcard(test_dir: str, test_file_wildcard: Optional[str] = None) -> List[str]: - """ - Find files matching the wildcard (or any file if test_file_wildcard is None) in the given directory. + def find_files_matching_wildcard(test_dir: str, test_file_wildcard: str | None = None) -> list[str]: + """Find files matching the wildcard (or any file if test_file_wildcard is None) in the given directory. Args: test_dir: Directory to search in @@ -203,14 +196,13 @@ def find_files_matching_wildcard(test_dir: str, test_file_wildcard: Optional[str return files def find_tests_in_module( - package_dir: Optional[str], + package_dir: str | None, import_name: str, test_dir: str, test_file: str, - method_or_wildcard: Optional[str] = None, - ) -> List[TestNameItem]: - """ - Find tests in the given module. + method_or_wildcard: str | None = None, + ) -> list[TestNameItem]: + """Find tests in the given module. Args: package_dir: Directory of the package @@ -291,13 +283,13 @@ def find_tests_in_module( class_obj, method_obj, line_number, - ) + ), ) logger.debug(f"Discovered class test method: {test_name} at line {line_number}") else: logger.warning( f"Method {method_name} in class {class_full_name} is not a coroutine function, " - "skipping" + "skipping", ) # Sort class methods by line number to preserve definition order @@ -336,7 +328,7 @@ def find_tests_in_module( # Sort standalone functions by line number function_tests.sort(key=lambda x: x[2]) - tests: list[Tuple[str, TestItem]] = [] + tests: list[tuple[str, TestItem]] = [] # Add class tests to the order list first for test_name, class_name, class_obj, method_obj, _ in class_tests: tests.append((test_name, (class_name, class_obj, method_obj))) @@ -348,24 +340,23 @@ def find_tests_in_module( return tests except ImportError as e: - logger.error(f"Import error loading module {import_name}: {str(e)}") + logger.error(f"Import error loading module {import_name}: {e!s}") logger.info(f"File path: {file_path}") logger.info(f"Current sys.path: {sys.path}") raise except Exception as e: - logger.error(f"Error loading module {import_name}: {str(e)}") + logger.error(f"Error loading module {import_name}: {e!s}") logger.debug(f"Exception details: {traceback.format_exc()}") raise def find_tests_in_file( - package_dir: Optional[str], + package_dir: str | None, test_dir: str, test_file: str, - method_or_wildcard: Optional[str] = None, - ) -> List[TestNameItem]: - """ - Find tests in the given file. + method_or_wildcard: str | None = None, + ) -> list[TestNameItem]: + """Find tests in the given file. Args: package_dir: Directory of the package @@ -408,7 +399,7 @@ def find_tests_in_file( method_or_wildcard, ) - tests: List[Tuple[str, List[TestNameItem]]] = [] + tests: list[tuple[str, list[TestNameItem]]] = [] # Split the specifier by both '.' and '/' or '\' to handle different path formats path_parts = re.split(r"[./\\]", test_specifier) starts_with_slash = ( @@ -510,9 +501,8 @@ def find_tests_in_file( return tests - def discover_tests(self, test_specifiers: List[str]) -> List[Tuple[str, List[TestNameItem]]]: - """ - Discover test modules with the given specifiers. + def discover_tests(self, test_specifiers: list[str]) -> list[tuple[str, list[TestNameItem]]]: + """Discover test modules with the given specifiers. Args: test_specifiers: List of test specifiers @@ -525,9 +515,8 @@ def discover_tests(self, test_specifiers: List[str]) -> List[Tuple[str, List[Tes tests.extend(self._discover_tests_from_specifier(test_specifier)) return tests - async def run_test(self, test_name: str, test_item: TestItem) -> Dict[str, Any]: - """ - Run a single test by name. + async def run_test(self, test_name: str, test_item: TestItem) -> dict[str, Any]: + """Run a single test by name. Args: test_name: Name of the test to run @@ -584,7 +573,6 @@ async def run_test(self, test_name: str, test_item: TestItem) -> Dict[str, Any]: try: # If this is a class method test, call setUp if it exists if test_class_instance: - # Call setUp if it exists if hasattr(test_class_instance, "setUp") and callable(test_class_instance.setUp): if asyncio.iscoroutinefunction(test_class_instance.setUp): @@ -606,28 +594,28 @@ async def run_test(self, test_name: str, test_item: TestItem) -> Dict[str, Any]: result = self.test_context.end_test(TestStatus.PASS) except TestFailure as e: - logger.error(f"Test {test_name} failed: {str(e)}") + logger.error(f"Test {test_name} failed: {e!s}") result = self.test_context.end_test(TestStatus.FAIL, str(e)) except TestSkip as e: - logger.info(f"Test {test_name} skipped: {str(e)}") + logger.info(f"Test {test_name} skipped: {e!s}") result = self.test_context.end_test(TestStatus.SKIP, str(e)) except TestException as e: - logger.error(f"Test {test_name} error: {str(e)}") + logger.error(f"Test {test_name} error: {e!s}") result = self.test_context.end_test(e.status, str(e)) except AssertionError as e: - logger.error(f"Test {test_name} failed: {str(e)}") + logger.error(f"Test {test_name} failed: {e!s}") result = self.test_context.end_test(TestStatus.FAIL, str(e)) except TimeoutError as e: # Handle timeout errors gracefully without showing traceback - logger.error(f"Test {test_name} error: {str(e)}") + logger.error(f"Test {test_name} error: {e!s}") result = self.test_context.end_test(TestStatus.ERROR, str(e)) except Exception as e: - logger.error(f"Error running test {test_name}: {str(e)}") + logger.error(f"Error running test {test_name}: {e!s}") traceback.print_exc() result = self.test_context.end_test(TestStatus.ERROR, str(e)) @@ -643,7 +631,7 @@ async def run_test(self, test_name: str, test_item: TestItem) -> Dict[str, Any]: logger.debug(f"Calling sync tearDown for {class_name}") test_class_instance.tearDown(self.ble_manager, self.test_context) except Exception as e: - logger.error(f"Error in tearDown for {test_name}: {str(e)}") + logger.error(f"Error in tearDown for {test_name}: {e!s}") # Don't override test result if tearDown fails # Clean up subscriptions after test is complete @@ -651,9 +639,8 @@ async def run_test(self, test_name: str, test_item: TestItem) -> Dict[str, Any]: return result - async def run_tests(self, tests: List[TestNameItem]) -> Dict[str, Any]: - """ - Run multiple tests in the order they were defined in the source code. + async def run_tests(self, tests: list[TestNameItem]) -> dict[str, Any]: + """Run multiple tests in the order they were defined in the source code. Args: tests: List of tests to run diff --git a/tests/test_ble_manager.py b/tests/test_ble_manager.py index 4170fc9..5e4bfb0 100644 --- a/tests/test_ble_manager.py +++ b/tests/test_ble_manager.py @@ -235,10 +235,10 @@ async def test_write_characteristic(): "properties": ["write"], "description": "Heart Rate Control Point", "handle": 43, - } + }, }, - } - } + }, + }, } mock_discover.return_value = mock_services manager.services = mock_services @@ -399,7 +399,7 @@ async def test_register_expected_services(): # Register multiple services BLEManager.register_expected_services( - ["0000180a-0000-1000-8000-00805f9b34fb", "00001810-0000-1000-8000-00805f9b34fb"] + ["0000180a-0000-1000-8000-00805f9b34fb", "00001810-0000-1000-8000-00805f9b34fb"], ) assert "0000180a-0000-1000-8000-00805f9b34fb" in BLEManager._expected_service_uuids assert "00001810-0000-1000-8000-00805f9b34fb" in BLEManager._expected_service_uuids diff --git a/tests/test_cli.py b/tests/test_cli.py index 36eba64..b1cd260 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -69,7 +69,10 @@ async def test_print_test_results(mock_console): @patch("test_a_ble.cli.print_test_results") @patch("test_a_ble.cli.console") async def test_run_ble_tests_with_address( - mock_console, mock_print_results, mock_ble_manager_class, mock_test_runner_class + mock_console, + mock_print_results, + mock_ble_manager_class, + mock_test_runner_class, ): """Test running BLE tests with a specific device address.""" # Setup mocks @@ -98,7 +101,7 @@ async def test_run_ble_tests_with_address( "status": TestStatus.PASS, "message": "Test passed", "duration": 0.1, - } + }, ], } mock_test_runner_class.return_value = mock_test_runner @@ -131,7 +134,10 @@ async def test_run_ble_tests_with_address( @patch("test_a_ble.cli.print_test_results") @patch("test_a_ble.cli.console") async def test_run_ble_tests_with_name( - mock_console, mock_print_results, mock_ble_manager_class, mock_test_runner_class + mock_console, + mock_print_results, + mock_ble_manager_class, + mock_test_runner_class, ): """Test running BLE tests with a device name filter.""" # Setup mocks @@ -168,7 +174,7 @@ async def test_run_ble_tests_with_name( "status": TestStatus.PASS, "message": "Test passed", "duration": 0.1, - } + }, ], } mock_test_runner_class.return_value = mock_test_runner @@ -238,7 +244,7 @@ async def test_run_ble_tests_interactive( "status": TestStatus.PASS, "message": "Test passed", "duration": 0.1, - } + }, ], } mock_test_runner_class.return_value = mock_test_runner diff --git a/tests/test_test_context.py b/tests/test_test_context.py index bc8bed4..93660be 100644 --- a/tests/test_test_context.py +++ b/tests/test_test_context.py @@ -156,7 +156,8 @@ async def test_subscribe_to_characteristic(test_context, mock_ble_manager): # Check that the BLE manager was called mock_ble_manager.subscribe_to_characteristic.assert_called_once_with( - char_uuid, test_context.notification_subscriptions[char_uuid].on_notification + char_uuid, + test_context.notification_subscriptions[char_uuid].on_notification, ) @@ -302,10 +303,9 @@ def check_notification(data): def check_notification_enum(data): if data[0] == 0x01: return NotificationResult.MATCH - elif data[0] == 0x02: + if data[0] == 0x02: return NotificationResult.FAIL - else: - return NotificationResult.IGNORE + return NotificationResult.IGNORE waiter = NotificationWaiter(char_uuid, check_notification_enum) diff --git a/tests/test_test_runner.py b/tests/test_test_runner.py index 92c6066..220f6b9 100644 --- a/tests/test_test_runner.py +++ b/tests/test_test_runner.py @@ -54,7 +54,11 @@ def test_is_package(test_runner, tmp_path): @patch("importlib.util.module_from_spec") @patch("os.path.exists") def test_import_package_with_base_package( - mock_exists, mock_module_from_spec, mock_spec_from_file, test_runner, tmp_path + mock_exists, + mock_module_from_spec, + mock_spec_from_file, + test_runner, + tmp_path, ): """Test the _import_package method with a base package.""" # Setup @@ -89,7 +93,11 @@ def test_import_package_with_base_package( @patch("importlib.util.module_from_spec") @patch("os.path.exists") def test_import_package_without_base_package( - mock_exists, mock_module_from_spec, mock_spec_from_file, test_runner, tmp_path + mock_exists, + mock_module_from_spec, + mock_spec_from_file, + test_runner, + tmp_path, ): """Test the _import_package method without a base package.""" # Setup @@ -152,7 +160,7 @@ def test_find_and_import_nearest_package_when_no_package_found(mock_exists, mock mock_isdir.return_value = True # Configure mock to indicate no package is found - mock_exists.side_effect = lambda path: False + mock_exists.side_effect = lambda _path: False # Test when no package is found result = test_runner._find_and_import_nearest_package(str(tmp_path / "path" / "to" / "nowhere")) diff --git a/uv.lock b/uv.lock index 10930ab..5338470 100644 --- a/uv.lock +++ b/uv.lock @@ -60,30 +60,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/85/db74b9233e0aa27ec96891045c5e920a64dd5cbccd50f8e64e9460f48d35/bandit-1.8.3-py3-none-any.whl", hash = "sha256:28f04dc0d258e1dd0f99dee8eefa13d1cb5e3fde1a5ab0c523971f97b289bcd8", size = 129078 }, ] -[[package]] -name = "black" -version = "25.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988 }, - { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985 }, - { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816 }, - { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860 }, - { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673 }, - { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190 }, - { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926 }, - { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613 }, - { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646 }, -] - [[package]] name = "bleak" version = "0.22.3" @@ -366,44 +342,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 }, ] -[[package]] -name = "flake8" -version = "7.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mccabe" }, - { name = "pycodestyle" }, - { name = "pyflakes" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/16/3f2a0bb700ad65ac9663262905a025917c020a3f92f014d2ba8964b4602c/flake8-7.1.2.tar.gz", hash = "sha256:c586ffd0b41540951ae41af572e6790dbd49fc12b3aa2541685d253d9bd504bd", size = 48119 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/f8/08d37b2cd89da306e3520bd27f8a85692122b42b56c0c2c3784ff09c022f/flake8-7.1.2-py2.py3-none-any.whl", hash = "sha256:1cbc62e65536f65e6d754dfe6f1bada7f5cf392d6f5db3c2b85892466c3e7c1a", size = 57745 }, -] - -[[package]] -name = "flake8-docstrings" -version = "1.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "flake8" }, - { name = "pydocstyle" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/93/24/f839e3a06e18f4643ccb81370909a497297909f15106e6af2fecdef46894/flake8_docstrings-1.7.0.tar.gz", hash = "sha256:4c8cc748dc16e6869728699e5d0d685da9a10b0ea718e090b1ba088e67a941af", size = 5995 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/7d/76a278fa43250441ed9300c344f889c7fb1817080c8fb8996b840bf421c2/flake8_docstrings-1.7.0-py2.py3-none-any.whl", hash = "sha256:51f2344026da083fc084166a9353f5082b01f72901df422f74b4d953ae88ac75", size = 4994 }, -] - -[[package]] -name = "flake8-pyproject" -version = "1.2.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "flake8" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/1d/635e86f9f3a96b7ea9e9f19b5efe17a987e765c39ca496e4a893bb999112/flake8_pyproject-1.2.3-py3-none-any.whl", hash = "sha256:6249fe53545205af5e76837644dc80b4c10037e73a0e5db87ff562d75fb5bd4a", size = 4756 }, -] - [[package]] name = "identify" version = "2.6.9" @@ -440,15 +378,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, ] -[[package]] -name = "isort" -version = "6.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/21/1e2a441f74a653a144224d7d21afe8f4169e6c7c20bb13aec3a2dc3815e0/isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", size = 821955 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186 }, -] - [[package]] name = "jinja2" version = "3.1.6" @@ -532,15 +461,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/75/51952c7b2d3873b44a0028b1bd26a25078c18f92f256608e8d1dc61b39fd/marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", size = 50878 }, ] -[[package]] -name = "mccabe" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350 }, -] - [[package]] name = "mdit-py-plugins" version = "0.4.2" @@ -646,15 +566,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, ] -[[package]] -name = "pathspec" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, -] - [[package]] name = "pbr" version = "6.1.1" @@ -728,15 +639,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/d7/7831438e6c3ebbfa6e01a927127a6cb42ad3ab844247f3c5b96bea25d73d/psutil-6.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649", size = 254444 }, ] -[[package]] -name = "pycodestyle" -version = "2.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/aa/210b2c9aedd8c1cbeea31a50e42050ad56187754b34eb214c46709445801/pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521", size = 39232 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/d8/a211b3f85e99a0daa2ddec96c949cac6824bd305b040571b82a03dd62636/pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", size = 31284 }, -] - [[package]] name = "pycparser" version = "2.22" @@ -795,27 +697,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/ae/e14b0ff8b3f48e02394d8acd911376b7b66e164535687ef7dc24ea03072f/pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5", size = 1919411 }, ] -[[package]] -name = "pydocstyle" -version = "6.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "snowballstemmer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/5c/d5385ca59fd065e3c6a5fe19f9bc9d5ea7f2509fa8c9c22fb6b2031dd953/pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1", size = 36796 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/ea/99ddefac41971acad68f14114f38261c1f27dac0b3ec529824ebc739bdaa/pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019", size = 38038 }, -] - -[[package]] -name = "pyflakes" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/f9/669d8c9c86613c9d568757c7f5824bd3197d7b1c6c27553bc5618a27cce2/pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", size = 63788 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/d7/f1b7db88d8e4417c5d47adad627a93547f44bdc9028372dbd2313f34a855/pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a", size = 62725 }, -] - [[package]] name = "pygments" version = "2.19.1" @@ -1073,6 +954,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/85/e8e751d8791564dd333d5d9a4eab0a7a115f7e349595417fd50ecae3395c/ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3", size = 115190 }, ] +[[package]] +name = "ruff" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/2b/7ca27e854d92df5e681e6527dc0f9254c9dc06c8408317893cf96c851cdd/ruff-0.11.0.tar.gz", hash = "sha256:e55c620690a4a7ee6f1cccb256ec2157dc597d109400ae75bbf944fc9d6462e2", size = 3799407 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/40/3d0340a9e5edc77d37852c0cd98c5985a5a8081fc3befaeb2ae90aaafd2b/ruff-0.11.0-py3-none-linux_armv6l.whl", hash = "sha256:dc67e32bc3b29557513eb7eeabb23efdb25753684b913bebb8a0c62495095acb", size = 10098158 }, + { url = "https://files.pythonhosted.org/packages/ec/a9/d8f5abb3b87b973b007649ac7bf63665a05b2ae2b2af39217b09f52abbbf/ruff-0.11.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:38c23fd9bdec4eb437b4c1e3595905a0a8edfccd63a790f818b28c78fe345639", size = 10879071 }, + { url = "https://files.pythonhosted.org/packages/ab/62/aaa198614c6211677913ec480415c5e6509586d7b796356cec73a2f8a3e6/ruff-0.11.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7c8661b0be91a38bd56db593e9331beaf9064a79028adee2d5f392674bbc5e88", size = 10247944 }, + { url = "https://files.pythonhosted.org/packages/9f/52/59e0a9f2cf1ce5e6cbe336b6dd0144725c8ea3b97cac60688f4e7880bf13/ruff-0.11.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6c0e8d3d2db7e9f6efd884f44b8dc542d5b6b590fc4bb334fdbc624d93a29a2", size = 10421725 }, + { url = "https://files.pythonhosted.org/packages/a6/c3/dcd71acc6dff72ce66d13f4be5bca1dbed4db678dff2f0f6f307b04e5c02/ruff-0.11.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c3156d3f4b42e57247275a0a7e15a851c165a4fc89c5e8fa30ea6da4f7407b8", size = 9954435 }, + { url = "https://files.pythonhosted.org/packages/a6/9a/342d336c7c52dbd136dee97d4c7797e66c3f92df804f8f3b30da59b92e9c/ruff-0.11.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:490b1e147c1260545f6d041c4092483e3f6d8eba81dc2875eaebcf9140b53905", size = 11492664 }, + { url = "https://files.pythonhosted.org/packages/84/35/6e7defd2d7ca95cc385ac1bd9f7f2e4a61b9cc35d60a263aebc8e590c462/ruff-0.11.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1bc09a7419e09662983b1312f6fa5dab829d6ab5d11f18c3760be7ca521c9329", size = 12207856 }, + { url = "https://files.pythonhosted.org/packages/22/78/da669c8731bacf40001c880ada6d31bcfb81f89cc996230c3b80d319993e/ruff-0.11.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcfa478daf61ac8002214eb2ca5f3e9365048506a9d52b11bea3ecea822bb844", size = 11645156 }, + { url = "https://files.pythonhosted.org/packages/ee/47/e27d17d83530a208f4a9ab2e94f758574a04c51e492aa58f91a3ed7cbbcb/ruff-0.11.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fbb2aed66fe742a6a3a0075ed467a459b7cedc5ae01008340075909d819df1e", size = 13884167 }, + { url = "https://files.pythonhosted.org/packages/9f/5e/42ffbb0a5d4b07bbc642b7d58357b4e19a0f4774275ca6ca7d1f7b5452cd/ruff-0.11.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92c0c1ff014351c0b0cdfdb1e35fa83b780f1e065667167bb9502d47ca41e6db", size = 11348311 }, + { url = "https://files.pythonhosted.org/packages/c8/51/dc3ce0c5ce1a586727a3444a32f98b83ba99599bb1ebca29d9302886e87f/ruff-0.11.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e4fd5ff5de5f83e0458a138e8a869c7c5e907541aec32b707f57cf9a5e124445", size = 10305039 }, + { url = "https://files.pythonhosted.org/packages/60/e0/475f0c2f26280f46f2d6d1df1ba96b3399e0234cf368cc4c88e6ad10dcd9/ruff-0.11.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:96bc89a5c5fd21a04939773f9e0e276308be0935de06845110f43fd5c2e4ead7", size = 9937939 }, + { url = "https://files.pythonhosted.org/packages/e2/d3/3e61b7fd3e9cdd1e5b8c7ac188bec12975c824e51c5cd3d64caf81b0331e/ruff-0.11.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a9352b9d767889ec5df1483f94870564e8102d4d7e99da52ebf564b882cdc2c7", size = 10923259 }, + { url = "https://files.pythonhosted.org/packages/30/32/cd74149ebb40b62ddd14bd2d1842149aeb7f74191fb0f49bd45c76909ff2/ruff-0.11.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:049a191969a10897fe052ef9cc7491b3ef6de79acd7790af7d7897b7a9bfbcb6", size = 11406212 }, + { url = "https://files.pythonhosted.org/packages/00/ef/033022a6b104be32e899b00de704d7c6d1723a54d4c9e09d147368f14b62/ruff-0.11.0-py3-none-win32.whl", hash = "sha256:3191e9116b6b5bbe187447656f0c8526f0d36b6fd89ad78ccaad6bdc2fad7df2", size = 10310905 }, + { url = "https://files.pythonhosted.org/packages/ed/8a/163f2e78c37757d035bd56cd60c8d96312904ca4a6deeab8442d7b3cbf89/ruff-0.11.0-py3-none-win_amd64.whl", hash = "sha256:c58bfa00e740ca0a6c43d41fb004cd22d165302f360aaa56f7126d544db31a21", size = 11411730 }, + { url = "https://files.pythonhosted.org/packages/4e/f7/096f6efabe69b49d7ca61052fc70289c05d8d35735c137ef5ba5ef423662/ruff-0.11.0-py3-none-win_arm64.whl", hash = "sha256:868364fc23f5aa122b00c6f794211e85f7e78f5dffdf7c590ab90b8c4e69b657", size = 10538956 }, +] + [[package]] name = "safety" version = "3.3.1" @@ -1276,31 +1182,23 @@ dependencies = [ [package.dev-dependencies] check = [ - { name = "black" }, - { name = "flake8" }, - { name = "flake8-docstrings" }, - { name = "flake8-pyproject" }, - { name = "isort" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "ruff" }, { name = "tox" }, { name = "tox-uv" }, ] dev = [ { name = "bandit" }, - { name = "black" }, - { name = "flake8" }, - { name = "flake8-docstrings" }, - { name = "flake8-pyproject" }, - { name = "isort" }, { name = "mypy" }, { name = "myst-parser" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "ruff" }, { name = "safety" }, { name = "sphinx" }, { name = "sphinx-rtd-theme" }, @@ -1313,11 +1211,7 @@ docs = [ { name = "sphinx-rtd-theme" }, ] lint = [ - { name = "black" }, - { name = "flake8" }, - { name = "flake8-docstrings" }, - { name = "flake8-pyproject" }, - { name = "isort" }, + { name = "ruff" }, ] security = [ { name = "bandit" }, @@ -1344,31 +1238,23 @@ requires-dist = [ [package.metadata.requires-dev] check = [ - { name = "black", specifier = ">=25.1.0" }, - { name = "flake8", specifier = ">=7.1.2" }, - { name = "flake8-docstrings", specifier = ">=1.7.0" }, - { name = "flake8-pyproject", specifier = ">=1.2.3" }, - { name = "isort", specifier = ">=6.0.1" }, { name = "mypy", specifier = ">=1.15.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-asyncio", specifier = ">=0.22.0" }, { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "ruff", specifier = ">=0.3.0" }, { name = "tox", specifier = ">=4.24.2" }, { name = "tox-uv", specifier = ">=1.25.0" }, ] dev = [ { name = "bandit", specifier = ">=1.8.3" }, - { name = "black", specifier = ">=25.1.0" }, - { name = "flake8", specifier = ">=7.1.2" }, - { name = "flake8-docstrings", specifier = ">=1.7.0" }, - { name = "flake8-pyproject", specifier = ">=1.2.3" }, - { name = "isort", specifier = ">=6.0.1" }, { name = "mypy", specifier = ">=1.15.0" }, { name = "myst-parser", specifier = ">=4.0.1" }, { name = "pre-commit", specifier = ">=4.1.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-asyncio", specifier = ">=0.22.0" }, { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "ruff", specifier = ">=0.3.0" }, { name = "safety", specifier = ">=3.3.1" }, { name = "sphinx", specifier = ">=8.2.3" }, { name = "sphinx-rtd-theme", specifier = ">=3.0.2" }, @@ -1380,13 +1266,7 @@ docs = [ { name = "sphinx", specifier = ">=8.2.3" }, { name = "sphinx-rtd-theme", specifier = ">=3.0.2" }, ] -lint = [ - { name = "black", specifier = ">=25.1.0" }, - { name = "flake8", specifier = ">=7.1.2" }, - { name = "flake8-docstrings", specifier = ">=1.7.0" }, - { name = "flake8-pyproject", specifier = ">=1.2.3" }, - { name = "isort", specifier = ">=6.0.1" }, -] +lint = [{ name = "ruff", specifier = ">=0.3.0" }] security = [ { name = "bandit", specifier = ">=1.8.3" }, { name = "safety", specifier = ">=3.3.1" }, From eb4506940c3ee3a352cdb8d67f855033b0573ee1 Mon Sep 17 00:00:00 2001 From: Nick Brook Date: Wed, 19 Mar 2025 10:04:50 +0000 Subject: [PATCH 2/9] Tests passing Signed-off-by: Nick Brook --- .vscode/settings.json | 3 +- docs/source/conf.py | 6 +- examples/nordic_blinky/tests/test_blinky.py | 4 +- examples/nordic_blinky/verify_fixes.py | 14 +- examples/run_nordic_example.py | 8 +- pyproject.toml | 18 +- scripts/release.py | 192 ----- test_a_ble/__init__.py | 1 - test_a_ble/ble_manager.py | 69 +- test_a_ble/cli.py | 42 +- test_a_ble/test_context.py | 42 +- test_a_ble/test_discovery.py | 500 +++++++++++++ test_a_ble/test_runner.py | 673 +++--------------- tests/test_discovery_test_package/__init__.py | 6 +- tests/test_test_discovery.py | 386 +++++++++- tests/test_test_runner.py | 138 +--- 16 files changed, 1082 insertions(+), 1020 deletions(-) delete mode 100755 scripts/release.py create mode 100644 test_a_ble/test_discovery.py diff --git a/.vscode/settings.json b/.vscode/settings.json index c594fa6..cb46919 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,6 @@ "tests" ], "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true + "python.testing.pytestEnabled": true, + "flake8.enabled": false } diff --git a/docs/source/conf.py b/docs/source/conf.py index 9eddcca..4fc2dc7 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -3,16 +3,16 @@ This file contains the configuration settings for generating the project's documentation. """ -import os import sys +from pathlib import Path -sys.path.insert(0, os.path.abspath("../..")) +sys.path.insert(0, str(Path(__file__).parent.parent.parent.resolve())) # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = "Test-a-BLE" -copyright = "2025, NRB Tech Ltd" +copyright = "2025, NRB Tech Ltd" # noqa: A001 author = "NRB Tech Ltd" release = "0.1.0" diff --git a/examples/nordic_blinky/tests/test_blinky.py b/examples/nordic_blinky/tests/test_blinky.py index a1836a1..c0c3d5f 100644 --- a/examples/nordic_blinky/tests/test_blinky.py +++ b/examples/nordic_blinky/tests/test_blinky.py @@ -26,11 +26,11 @@ class BlinkyTests: These tests demonstrate controlling the LED and receiving button state notifications. """ - async def setUp(self, ble_manager: BLEManager, test_context: TestContext): + async def setUp(self, _ble_manager: BLEManager, test_context: TestContext): """Set up the test environment.""" test_context.debug("Setting up the test environment") - async def tearDown(self, ble_manager: BLEManager, test_context: TestContext): + async def tearDown(self, _ble_manager: BLEManager, test_context: TestContext): """Tear down the test environment.""" test_context.debug("Tearing down the test environment") diff --git a/examples/nordic_blinky/verify_fixes.py b/examples/nordic_blinky/verify_fixes.py index be94c08..187f4be 100644 --- a/examples/nordic_blinky/verify_fixes.py +++ b/examples/nordic_blinky/verify_fixes.py @@ -44,8 +44,8 @@ async def verify_led_control(ble_manager, test_context): test_context.log("LED ON verified by reading characteristic") else: test_context.log(f"LED state mismatch - expected {LED_ON.hex()}, got {led_state.hex()}") - except Exception as e: - logger.info(f"Could not read LED state: {e!s}") + except Exception: + logger.exception("Could not read LED state") # Ask user to verify response = test_context.prompt_user("Is the LED ON? (y/n)") @@ -66,8 +66,8 @@ async def verify_led_control(ble_manager, test_context): test_context.log("LED OFF verified by reading characteristic") else: test_context.log(f"LED state mismatch - expected {LED_OFF.hex()}, got {led_state.hex()}") - except Exception as e: - logger.info(f"Could not read LED state: {e!s}") + except Exception: + logger.exception("Could not read LED state") # Ask user to verify response = test_context.prompt_user("Is the LED OFF? (y/n)") @@ -77,7 +77,7 @@ async def verify_led_control(ble_manager, test_context): return test_context.end_test("pass", "LED control verification complete") -async def verify_notification_handling(ble_manager, test_context): +async def verify_notification_handling(_ble_manager: BLEManager, test_context: TestContext): """Test improved notification handling with user input option.""" test_context.start_test("Notification Handling Verification") @@ -100,9 +100,9 @@ async def verify_notification_handling(ble_manager, test_context): elif result["value"] == BUTTON_RELEASED: test_context.log("Detected button release event") - except Exception as e: + except Exception: # Only catch to ensure we end the test properly - test_context.log(f"Notification handling: {e!s}") + logger.exception("Notification handling") return test_context.end_test("pass", "Notification handling verification complete") diff --git a/examples/run_nordic_example.py b/examples/run_nordic_example.py index 907b7dc..c834fba 100644 --- a/examples/run_nordic_example.py +++ b/examples/run_nordic_example.py @@ -6,8 +6,8 @@ import asyncio import logging -import os import sys +from pathlib import Path from rich.console import Console from rich.logging import RichHandler @@ -26,7 +26,7 @@ console = Console() -async def run_blinky_tests(device_name: str = None, device_address: str = None): +async def run_blinky_tests(device_name: str | None = None, device_address: str | None = None): """Run the Nordic Blinky example tests.""" console.print("[bold]Nordic Blinky Example Test Runner[/bold]\n") @@ -66,8 +66,8 @@ async def run_blinky_tests(device_name: str = None, device_address: str = None): # Get the path to the nordic blinky tests directory # This ensures it works both when installed and when run from source - current_dir = os.path.dirname(os.path.abspath(__file__)) - test_dir = os.path.join(current_dir, "nordic_blinky", "tests") + current_dir = Path(__file__).parent + test_dir = current_dir / "nordic_blinky" / "tests" console.print(f"[bold]Discovering tests in {test_dir}...[/bold]") diff --git a/pyproject.toml b/pyproject.toml index bd14054..052bcee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,10 +98,26 @@ select = [ "TRY", # tryceratops "RUF", # ruff-specific rules ] +ignore = [ + "T201", # ignore print usage + "TRY003", # ignore long messages when raising exceptions + + # for now, ignore complexity warnings + "C901", + "PLR0911", + "PLR0912", + "PLR0915", + +] # Ignore assert usage in test files [tool.ruff.lint.per-file-ignores] -"tests/*" = ["S101", "ARG001"] +"tests/*" = [ + "S101", # ignore assert usage + "ARG001", # ignore unused arguments + "ARG002", # ignore unused arguments + "PLR2004", # ignore numerical comparisons +] [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/scripts/release.py b/scripts/release.py deleted file mode 100755 index bf88eb6..0000000 --- a/scripts/release.py +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env python3 -"""Script to bump the version of the package. - -Usage: python scripts/bump_version.py [major|minor|patch] -""" - -import argparse -import os -import re -import subprocess -import sys -from datetime import datetime - - -def update_setup_py(new_version): - """Update the version in setup.py.""" - setup_py_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "setup.py") - with open(setup_py_path) as f: - content = f.read() - - # Replace the version - content = re.sub(r'version="[0-9]+\.[0-9]+\.[0-9]+"', f'version="{new_version}"', content) - - with open(setup_py_path, "w") as f: - f.write(content) - - print(f"Updated version in setup.py to {new_version}") - - -def update_init_py(new_version): - """Update the version in __init__.py.""" - init_py_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "test-a-ble", "__init__.py") - with open(init_py_path) as f: - content = f.read() - - # Replace the version - content = re.sub( - r'__version__ = "[0-9]+\.[0-9]+\.[0-9]+"', - f'__version__ = "{new_version}"', - content, - ) - - with open(init_py_path, "w") as f: - f.write(content) - - print(f"Updated version in __init__.py to {new_version}") - - -def update_docs_conf_py(new_version): - """Update the version in docs/source/conf.py.""" - conf_py_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "docs", "source", "conf.py") - with open(conf_py_path) as f: - content = f.read() - - # Replace the version - content = re.sub(r"release = '[0-9]+\.[0-9]+\.[0-9]+'", f"release = '{new_version}'", content) - - with open(conf_py_path, "w") as f: - f.write(content) - - print(f"Updated version in docs/source/conf.py to {new_version}") - - -def update_changelog(new_version): - """Update the changelog with a new version section.""" - changelog_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "CHANGELOG.md") - with open(changelog_path) as f: - content = f.read() - - # Check if the new version already exists in the changelog - if f"## [{new_version}]" in content: - print(f"Version {new_version} already exists in CHANGELOG.md") - return - - # Get the current date - today = datetime.now().strftime("%Y-%m-%d") - - # Create a new version section - new_section = f"""## [{new_version}] - {today} - -### Added -- - -### Changed -- - -### Fixed -- - -""" - - # Insert the new section after the header - content = re.sub( - r"(## \[)", - f"{new_section}\n\n\1", - content, - flags=re.DOTALL, - ) - - with open(changelog_path, "w") as f: - f.write(content) - - print(f"Updated CHANGELOG.md with new version {new_version}") - - -def get_current_version(): - """Get the current version from setup.py.""" - setup_py_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "setup.py") - with open(setup_py_path) as f: - content = f.read() - - # Extract the version - match = re.search(r'version="([0-9]+\.[0-9]+\.[0-9]+)"', content) - if match: - return match.group(1) - print("Could not find version in setup.py") - sys.exit(1) - - -def bump_version(current_version, part): - """Bump the version according to the specified part.""" - major, minor, patch = map(int, current_version.split(".")) - - if part == "major": - major += 1 - minor = 0 - patch = 0 - elif part == "minor": - minor += 1 - patch = 0 - elif part == "patch": - patch += 1 - else: - print(f"Invalid part: {part}") - sys.exit(1) - - return f"{major}.{minor}.{patch}" - - -def main(): - """Execute the main function.""" - parser = argparse.ArgumentParser(description="Bump the version of the package.") - parser.add_argument( - "part", - nargs="?", - choices=["major", "minor", "patch"], - help="The part of the version to bump (optional)", - ) - args = parser.parse_args() - - current_version = get_current_version() - if args.part: - new_version = bump_version(current_version, args.part) - print(f"Bumping version from {current_version} to {new_version}") - - update_setup_py(new_version) - update_init_py(new_version) - update_docs_conf_py(new_version) - update_changelog(new_version) - - print(f"Version bumped to {new_version}") - print("Don't forget to commit the changes and create a new tag:") - print(f"git commit -am 'Bump version to {new_version}'") - else: - new_version = current_version - print("Commands to run to create a new tag:") - - print(f"git tag -a v{new_version} -m 'Version {new_version}'") - print("git push && git push --tags") - print("\nDo you want to run the git commands now? (y/n)") - response = input().strip().lower() - - if response == "y" or response == "yes": - print("Running git commands...") - if args.part: - subprocess.run(["git", "commit", "-am", f"Bump version to {new_version}"], check=True, shell=False) # nosec B603 - subprocess.run(["git", "tag", "-a", f"v{new_version}", "-m", f"Version {new_version}"], check=True, shell=False) # nosec B603 - - print("Do you want to push the changes? (y/n)") - push_response = input().strip().lower() - if push_response == "y" or push_response == "yes": - subprocess.run(["git", "push"], check=True, shell=False) # nosec B603 - subprocess.run(["git", "push", "--tags"], check=True, shell=False) # nosec B603 - print("Changes pushed successfully.") - else: - print("Changes committed and tagged locally. Run 'git push && git push --tags' when ready.") - else: - print("Commands not executed. Run them manually when ready.") - - -if __name__ == "__main__": - main() diff --git a/test_a_ble/__init__.py b/test_a_ble/__init__.py index 24d8e3e..7ee81ae 100644 --- a/test_a_ble/__init__.py +++ b/test_a_ble/__init__.py @@ -5,7 +5,6 @@ import logging import sys -from typing import Optional __version__ = "0.1.0" diff --git a/test_a_ble/ble_manager.py b/test_a_ble/ble_manager.py index 0b531d1..b341db7 100644 --- a/test_a_ble/ble_manager.py +++ b/test_a_ble/ble_manager.py @@ -8,7 +8,7 @@ import sys import uuid from collections.abc import Callable -from typing import Any +from typing import Any, ClassVar from bleak import BleakClient, BleakScanner from bleak.backends.device import BLEDevice @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) -def retrieveConnectedPeripheralsWithServices( +def retrieve_connected_peripherals_with_services( scanner: BleakScanner, services: list[str] | list[uuid.UUID], ) -> list[BLEDevice]: @@ -64,7 +64,7 @@ class BLEManager: """Manages BLE device discovery, connection, and communication.""" # Class variable to store services that the framework should look for when finding connected devices - _expected_service_uuids: set[str] = set() + _expected_service_uuids: ClassVar[set[str]] = set() @classmethod def register_expected_services(cls, service_uuids): @@ -77,7 +77,7 @@ def register_expected_services(cls, service_uuids): return # Convert to set for deduplication - if isinstance(service_uuids, (list, tuple, set)): + if isinstance(service_uuids, list | tuple | set): cls._expected_service_uuids.update(service_uuids) else: # If a single UUID is provided @@ -138,7 +138,7 @@ def _device_found(device: BLEDevice, adv_data: AdvertisementData): # Perform scan scanner = BleakScanner(detection_callback=_device_found) - devices = retrieveConnectedPeripheralsWithServices(scanner, list(self._expected_service_uuids)) + devices = retrieve_connected_peripherals_with_services(scanner, list(self._expected_service_uuids)) self.discovered_devices.extend(devices) if devices: @@ -204,8 +204,8 @@ async def connect_to_device( try: # For modern Bleak (0.19.0+), create a device with required parameters self.device = BLEDevice(address=device_or_address, name=None, details={}, rssi=0) - except Exception as e: - logger.error(f"Failed to create BLEDevice: {e!s}") + except Exception: + logger.exception("Failed to create BLEDevice") logger.debug("Attempting to discover the device first...") # Try to discover the device first @@ -215,7 +215,7 @@ async def connect_to_device( self.device = devices[0] logger.debug(f"Found device: {self.device.name or 'Unknown'} ({self.device.address})") else: - logger.error(f"Could not find device with address {device_or_address}") + logger.exception(f"Could not find device with address {device_or_address}") self.device = None return False else: @@ -225,24 +225,23 @@ async def connect_to_device( # Attempt connection with retries for attempt in range(retry_count): + logger.debug(f"Connection attempt {attempt + 1}/{retry_count}") + # Create client with the device identifier + self.client = BleakClient(self.device) try: - logger.debug(f"Connection attempt {attempt + 1}/{retry_count}") - - # Create client with the device identifier - self.client = BleakClient(self.device) - # Connect to the device await self.client.connect() + except Exception: + logger.exception(f"Connection attempt {attempt + 1} failed") + if attempt < retry_count - 1: + await asyncio.sleep(retry_delay) + + else: self.connected = True logger.info(f"Connected to {self.device.name or 'Unknown'} ({self.device.address})") return True - except Exception as e: - logger.warning(f"Connection attempt {attempt + 1} failed: {e!s}") - if attempt < retry_count - 1: - await asyncio.sleep(retry_delay) - logger.error(f"Failed to connect to device after {retry_count} attempts") self.device = None return False @@ -262,8 +261,8 @@ async def disconnect(self): try: logger.debug(f"Unsubscribing from {sub_uuid}") await self.unsubscribe_from_characteristic(sub_uuid) - except Exception as e: - logger.debug(f"Error cleaning up subscription to {uuid}: {e}") + except Exception: + logger.debug(f"Error cleaning up subscription to {sub_uuid}") # Now attempt to disconnect from the device try: @@ -273,8 +272,8 @@ async def disconnect(self): logger.debug("Disconnected successfully") else: logger.debug("Client already disconnected") - except Exception as e: - logger.error(f"Error during disconnect: {e}") + except Exception: + logger.exception("Error during disconnect") finally: # Ensure these are cleaned up regardless of disconnect success self.connected = False @@ -381,7 +380,7 @@ async def write_characteristic( await self.discover_services() # Look for the characteristic in all services - for service_uuid, service_info in self.services.get(self.device.address, {}).items(): + for _service_uuid, service_info in self.services.get(self.device.address, {}).items(): characteristics = service_info.get("characteristics", {}) if characteristic_uuid in characteristics: properties = characteristics[characteristic_uuid].get("properties", []) @@ -428,28 +427,28 @@ async def write_characteristic( logger.debug("Skipping write verification - characteristic not readable") logger.debug("Write operation completed") - except Exception as e: - logger.error(f"Error writing to characteristic {characteristic_uuid}: {e!s}") + except Exception: + logger.exception(f"Error writing to characteristic {characteristic_uuid}") raise def _notification_handler(self, characteristic_uuid: str): """Create a notification handler for a specific characteristic.""" - def _handle_notification(sender, data: bytearray): + def _handle_notification(_sender, data: bytearray): """Handle BLE notifications in latest Bleak versions. The sender parameter can be of different types in different Bleak versions. """ # Check if we received actual data - sometimes error strings may be passed - if isinstance(data, bytearray) or isinstance(data, bytes): + if isinstance(data, bytearray | bytes): logger.debug(f"Notification from {characteristic_uuid}: {data.hex()}") # Call all registered callbacks for this characteristic if characteristic_uuid in self.notification_callbacks: for callback in self.notification_callbacks[characteristic_uuid]: try: callback(data) - except Exception as e: - logger.error(f"Error in notification callback: {e!s}") + except Exception: + logger.exception("Error in notification callback") # If we get a non-data value (like an error string), log it but don't invoke callbacks elif data is not None: # Log but at debug level to avoid cluttering logs @@ -480,8 +479,8 @@ async def subscribe_to_characteristic( # Track the active subscription self.active_subscriptions.append(characteristic_uuid) logger.debug(f"Subscribed to notifications from {characteristic_uuid}") - except Exception as e: - logger.error(f"Failed to subscribe to {characteristic_uuid}: {e}") + except Exception: + logger.exception(f"Failed to subscribe to {characteristic_uuid}") raise self.notification_callbacks[characteristic_uuid].append(callback) @@ -515,8 +514,8 @@ async def unsubscribe_from_characteristic(self, characteristic_uuid: str) -> Non try: await self.client.stop_notify(characteristic_uuid) logger.info(f"Unsubscribed from notifications from {characteristic_uuid}") - except Exception as e: - logger.error(f"Error stopping notifications for {characteristic_uuid}: {e}") + except Exception: + logger.exception(f"Error stopping notifications for {characteristic_uuid}") finally: # Remove from active subscriptions even if there was an error self.active_subscriptions.remove(characteristic_uuid) @@ -531,8 +530,8 @@ async def unsubscribe_from_characteristic(self, characteristic_uuid: str) -> Non ) del self.notification_callbacks[characteristic_uuid] - except Exception as e: - logger.error(f"Error during unsubscribe from {characteristic_uuid}: {e}") + except Exception: + logger.exception(f"Error during unsubscribe from {characteristic_uuid}") # Still clean up local state even if there was an error if characteristic_uuid in self.active_subscriptions: self.active_subscriptions.remove(characteristic_uuid) diff --git a/test_a_ble/cli.py b/test_a_ble/cli.py index 7fdd891..2200085 100644 --- a/test_a_ble/cli.py +++ b/test_a_ble/cli.py @@ -3,6 +3,7 @@ import argparse import asyncio import concurrent.futures +import contextlib import logging import sys import time @@ -22,10 +23,11 @@ console = Console() logger = logging.getLogger("ble_tester") +TIME_BETWEEN_UPDATES = 3.0 + def get_console() -> Console: """Return the global console object for rich output.""" - global console return console @@ -109,7 +111,7 @@ async def update_ui(): force_update = True # Force update when signal is received except TimeoutError: # Force update every 3 seconds regardless of signal - if time.time() - last_update_time >= 3.0: + if time.time() - last_update_time >= TIME_BETWEEN_UPDATES: force_update = True else: continue # No update needed @@ -156,8 +158,8 @@ async def update_ui(): "[bold yellow]Press Enter for options or wait for devices to be discovered[/bold yellow]", ) - except Exception as e: - logger.error(f"Error updating UI: {e}") + except Exception: + logger.exception("Error updating UI") await asyncio.sleep(0.5) # Avoid tight loop on error # Create the tasks @@ -242,17 +244,13 @@ async def update_ui(): # Cancel any running tasks if not scan_task.done(): scan_task.cancel() - try: + with contextlib.suppress(TimeoutError, asyncio.CancelledError): await asyncio.wait_for(scan_task, timeout=1.0) - except (TimeoutError, asyncio.CancelledError): - pass if not ui_task.done(): ui_task.cancel() - try: + with contextlib.suppress(TimeoutError, asyncio.CancelledError): await asyncio.wait_for(ui_task, timeout=1.0) - except (TimeoutError, asyncio.CancelledError): - pass # Show selection menu after scan completes or user presses Enter if discovered_devices: @@ -577,15 +575,15 @@ async def run_ble_tests(args): if "test_runner" in locals(): try: await test_runner.test_context.cleanup_tasks() - except Exception as e: - logger.error(f"Error cleaning up test context: {e}") + except Exception: + logger.exception("Error cleaning up test context") # Disconnect from device console.print("[bold]Disconnecting from device...[/bold]") try: await ble_manager.disconnect() - except Exception as e: - logger.error(f"Error during disconnect: {e}") + except Exception: + logger.exception("Error during disconnect") # More aggressive task cancellation to ensure clean exit # Get all tasks except the current one @@ -729,21 +727,17 @@ def main(): task.cancel() # Short wait for cancellation - try: + with contextlib.suppress(Exception): loop.run_until_complete(asyncio.wait(remaining, timeout=1.0, loop=loop)) - except Exception: - pass # nosec B110 # Close the loop - try: + with contextlib.suppress(Exception): loop.close() - except Exception: - pass # nosec B110 - except Exception as e: - logger.debug(f"Error during keyboard interrupt cleanup: {e}") + except Exception: + logger.exception("Error during keyboard interrupt cleanup") except Exception as e: - logger.error(f"Error during test execution: {e}") + logger.exception("Error during test execution") console.print(f"\n[bold red]Error: {e!s}[/bold red]") if args.verbose: console.print_exception() @@ -755,7 +749,7 @@ def main(): # Log clean exit logger.debug("Exiting program") - return 0 + return 0 if __name__ == "__main__": diff --git a/test_a_ble/test_context.py b/test_a_ble/test_context.py index 4e530c2..bd1a72b 100644 --- a/test_a_ble/test_context.py +++ b/test_a_ble/test_context.py @@ -4,11 +4,12 @@ """ import asyncio +import contextlib import logging import time from collections.abc import Callable from enum import Enum -from typing import Any, Optional +from typing import Any from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.shortcuts import PromptSession @@ -75,7 +76,7 @@ def decorator(cls): class TestStatus(Enum): """Enum for test execution status.""" - PASS = "pass" # nosec B105 + PASS = "pass" # nosec B105 # noqa: S105 FAIL = "fail" SKIP = "skip" ERROR = "error" @@ -86,7 +87,7 @@ def __str__(self): return self.value -class TestException(Exception): +class TestException(Exception): # noqa: N818 """Base class for test exceptions.""" status = TestStatus.ERROR @@ -123,12 +124,10 @@ def __str__(self): # Type alias for notification expected value # Can be bytes for exact matching, a callable for custom evaluation, or None to match any notification -# The callable should return a boolean (pass or fail), a notification result enum, or a tuple of -# (NotificationResult, str) +# The callable should return a boolean (pass or fail), a notification result enum, +# or a tuple of (NotificationResult, str) # If the callable returns a NotificationResult of FAIL, the reason should be provided in the str -NotificationExpectedValue = Optional[ - bytes | Callable[[bytes], bool | NotificationResult | tuple[NotificationResult, str]] -] +NotificationExpectedValue = bytes | Callable[[bytes], bool | NotificationResult | tuple[NotificationResult, str]] | None class NotificationWaiter: @@ -164,7 +163,7 @@ def check_notification(self, data: bytes) -> tuple[bool, str | None]: # User provided a lambda/function to evaluate the notification try: result = current_expected(data) - if isinstance(result, tuple) and len(result) == 2 and isinstance(result[0], NotificationResult): + if isinstance(result, tuple) and len(result) == 2 and isinstance(result[0], NotificationResult): # noqa: PLR2004 # Handle tuple return format: (NotificationResult, Optional[str]) notification_result, reason = result if notification_result == NotificationResult.MATCH: @@ -189,9 +188,9 @@ def check_notification(self, data: bytes) -> tuple[bool, str | None]: return False, None # True = match, False = ignore (not a failure) return bool(result), None - except Exception as e: + except Exception: # If the function raises an exception, log it but don't fail - logger.error(f"Error in notification evaluation function: {e}") + logger.exception("Error in notification evaluation function") return False, None else: # Direct comparison with expected bytes @@ -416,8 +415,8 @@ async def unsubscribe_all(self) -> None: # Remove from subscriptions self.notification_subscriptions.pop(characteristic_uuid, None) logger.debug(f"Successfully unsubscribed from {characteristic_uuid}") - except Exception as e: - logger.error(f"Error unsubscribing from {characteristic_uuid}: {e!s}") + except Exception: + logger.exception(f"Error unsubscribing from {characteristic_uuid}") logger.debug(f"Unsubscribed from all {len(characteristics)} active characteristics") @@ -570,11 +569,11 @@ async def subscribe_to_characteristic( await asyncio.sleep(0.5) except Exception as e: - logger.error(f"Error subscribing to characteristic: {e!s}") + logger.exception("Error subscribing to characteristic") # Remove the waiter if we failed to subscribe if characteristic_uuid in self.notification_subscriptions: del self.notification_subscriptions[characteristic_uuid] - raise RuntimeError(f"Failed to subscribe: {e!s}") + raise RuntimeError(f"Failed to subscribe: {e!s}") from e else: # Already subscribed - reuse the existing subscription logger.debug(f"Using existing subscription to {characteristic_uuid}") @@ -600,6 +599,7 @@ async def create_notification_waiter( expected_value: If provided, validates the notification value. Can be: - bytes: exact value to match - callable: function that takes the notification data and returns a NotificationResult + process_collected_notifications: If True, process collected notifications Returns: NotificationWaiter instance @@ -671,6 +671,7 @@ async def wait_for_notification( expected_value: If provided, validates the notification value. Can be: - bytes: exact value to match - callable: function that takes the notification data and returns a NotificationResult + process_collected_notifications: If True, process collected notifications Returns: Dictionary with notification details: @@ -745,7 +746,8 @@ async def user_input_handler() -> tuple[str, str] | None: """ print("\nThe test will continue automatically when event is detected.") print( - "If nothing happens, type 's' or 'skip' to skip, 'f' or 'fail' to fail the test, or 'd' for debug info.", + "If nothing happens, type 's' or 'skip' to skip, " + "'f' or 'fail' to fail the test, or 'd' for debug info.", ) session = PromptSession() # type: ignore @@ -761,8 +763,8 @@ async def user_input_handler() -> tuple[str, str] | None: # Task cancelled - exit cleanly logger.debug("User input task cancelled") break - except Exception as e: - logger.error(f"Error in user input handler: {e}") + except Exception: + logger.exception("Error in user input handler") break # Handle EOF or errors @@ -846,10 +848,8 @@ async def user_input_handler() -> tuple[str, str] | None: for task in [notification_task, user_input_task]: if not task.done(): task.cancel() - try: + with contextlib.suppress(TimeoutError, asyncio.CancelledError): await asyncio.wait_for(task, timeout=0.1) - except (TimeoutError, asyncio.CancelledError): - pass def get_test_summary(self) -> dict[str, Any]: """Generate a summary of all test results. diff --git a/test_a_ble/test_discovery.py b/test_a_ble/test_discovery.py new file mode 100644 index 0000000..135f3a7 --- /dev/null +++ b/test_a_ble/test_discovery.py @@ -0,0 +1,500 @@ +"""Test Discovery. + +Functions for discovering and importing test files. +""" + +import asyncio +import fnmatch +import importlib +import importlib.util +import inspect +import logging +import os +import re +import sys +import traceback +from collections.abc import Callable, Coroutine +from pathlib import Path +from typing import Any + +from .ble_manager import BLEManager +from .test_context import TestContext + +logger = logging.getLogger(__name__) + +# Type for test function +TestFunction = Callable[[BLEManager, TestContext], Coroutine[Any, Any, None]] + +# Type for test item: a test function or (class_name, class_obj, method) tuple +TestItem = Callable | tuple[str, Any, Callable] +# Type for test: (test_name, test_item) +TestNameItem = tuple[str, TestItem] + +MAX_IMPORT_PARENT_DIRECTORIES = 2 + + +class NoTestFilesFoundError(ValueError): + """Exception raised when no test files are found in a directory.""" + + pass + + +def _is_package(path: Path) -> bool: + """Check if a directory is a Python package (has __init__.py file). + + Args: + path: Path to check + + Returns: + True if the path is a Python package, False otherwise + """ + return path.is_dir() and (path / "__init__.py").exists() + + +def _import_package(package_path: Path, base_package: str = "") -> str: + """Import a Python package and all its parent packages. + + Args: + package_path: Path to the package + base_package: Base package name + + Returns: + The imported package name + """ + logger.debug(f"Importing package: {package_path}") + + # Get the package name from the path + package_name = package_path.name + + # Construct the full package name + full_package_name = f"{base_package}.{package_name}" if base_package else package_name + + # Check if package is already imported + if full_package_name in sys.modules: + logger.debug(f"Package {full_package_name} already imported") + return full_package_name + + # Find the __init__.py file + init_path = Path(package_path) / "__init__.py" + + if not init_path.exists(): + raise ImportError(f"No __init__.py found in {package_path}") + + try: + # Import the package + spec = importlib.util.spec_from_file_location(full_package_name, init_path) + if not spec or not spec.loader: + raise ImportError(f"Failed to load module spec for {init_path}") + + module = importlib.util.module_from_spec(spec) + sys.modules[full_package_name] = module + + # Execute the module + spec.loader.exec_module(module) + logger.debug(f"Successfully imported package: {full_package_name}") + except Exception as e: + raise ImportError(f"Error importing package {full_package_name}") from e + else: + return full_package_name + + +def find_and_import_nearest_package(path: Path) -> tuple[str, Path] | None: + """Find the nearest package in the given path and import it. + + Args: + path: Path to search for a package + + Returns: + Tuple of (package_name, package_dir) if a package is found, None otherwise + """ + current_dir = path + parent_count = 0 + + # Check up to 2 parent directories for __init__.py + while parent_count < MAX_IMPORT_PARENT_DIRECTORIES: + if _is_package(current_dir): + # Found a module - use this as our base + package_dir = current_dir + package_name = current_dir.name + logger.debug(f"Found package: {package_name} at {package_dir}") + + try: + _import_package(current_dir) + except ImportError: + logger.exception(f"Error importing package {current_dir}") + raise + else: + return package_name, package_dir + + # Move up to the parent directory + parent_dir = current_dir.parent + if parent_dir == current_dir: # We've reached the root + return None + + current_dir = parent_dir + parent_count += 1 + + return None + + +def _check_if_file_exists(test_dir: Path, test_file: str) -> tuple[Path, str] | None: + """Check if a file exists in the given directory. + + Returns: + Tuple of (test_dir, test_file) if the file exists, None otherwise + """ + print(f"Test dir: {test_dir}, test file: {test_file}") + if test_file is None: + return None + if not test_dir.is_dir(): + return None + if not test_file.endswith(".py"): + test_file = test_file + ".py" + if (test_dir / test_file).exists(): + return (test_dir, test_file) + if (test_dir / "tests" / test_file).exists(): + return (test_dir / "tests", test_file) + return None + + +def _check_wildcard_match(test_wildcard: str | None, test_string: str) -> bool: + """Check if the test string matches the test wildcard. + + Args: + test_wildcard: Wildcard to match against + test_string: String to match + + Returns: + True if the test string matches the test wildcard, False otherwise + """ + return test_wildcard is None or fnmatch.fnmatch(test_string, test_wildcard) + + +def _find_files_matching_wildcard(test_dir: Path, test_file_wildcard: str | None = None) -> list[str]: + """Find files matching the wildcard (or any file if test_file_wildcard is None) in the given directory. + + Args: + test_dir: Directory to search in + test_file_wildcard: Wildcard to match against, or None to match any file + + Returns: + List of files matching the wildcard + """ + if not test_dir.is_dir(): + return [] + # list files in test_dir that match the wildcard + files = [] + for file in os.listdir(test_dir): + if file.endswith(".py") and _check_wildcard_match(test_file_wildcard, file): + files.append(file) + return files + + +def _find_tests_in_module( + package_dir: Path | None, + import_name: str, + test_dir: Path, + test_file: str, + method_or_wildcard: str | None = None, +) -> list[TestNameItem]: + """Find tests in the given module. + + Args: + package_dir: Directory of the package + import_name: Import name of the module + test_dir: Directory of the test + test_file: File to find tests in + method_or_wildcard: Method name or wildcard of the tests to find + + Returns: + List of tuples (test_name, test_item) where test_item is a test function or (class, method) tuple + """ + file_path = test_dir / test_file + try: + # Try to import the module using importlib.import_module first + try: + if package_dir is not None: + # If we have a module, try to use standard import + module = importlib.import_module(import_name) + logger.debug(f"Imported {import_name} using import_module") + else: + # No module structure, use direct file import + raise ImportError("Not in a package, using spec_from_file_location") + + except ImportError: + # Fallback to the file-based import method + spec = importlib.util.spec_from_file_location(import_name, file_path) + + if not spec or not spec.loader: + raise ImportError(f"Failed to load module spec for {file_path}") + + module = importlib.util.module_from_spec(spec) + # Add the module to sys.modules to allow relative imports + sys.modules[import_name] = module + + # Execute the module + spec.loader.exec_module(module) + logger.debug(f"Imported {import_name} using spec_from_file_location") + + except ImportError: + logger.exception(f"Import error loading module {import_name}") + logger.info(f"File path: {file_path}") + logger.info(f"Current sys.path: {sys.path}") + raise + except Exception: + logger.exception(f"Error loading module {import_name}") + logger.debug(f"Exception details: {traceback.format_exc()}") + raise + + # Use the relative path from test_dir as the module prefix for test names + rel_path = file_path.relative_to(test_dir) + rel_module = rel_path.with_suffix("").as_posix().replace(os.path.sep, ".") + + # First, discover test classes + class_tests = [] + for class_name, class_obj in module.__dict__.items(): + # Check if it's a class and follows naming convention + if inspect.isclass(class_obj) and ( + class_name.startswith("Test") or (hasattr(class_obj, "_is_test_class") and class_obj._is_test_class) + ): + # Store class for later use + class_full_name = f"{rel_module}.{class_name}" + logger.debug(f"Discovered test class: {class_full_name}") + + # Discover test methods in the class and collect with source line numbers + class_method_tests = [] + for method_name, method_obj in inspect.getmembers(class_obj, predicate=inspect.isfunction): + if not _check_wildcard_match(method_or_wildcard, method_name): + continue + + # Check if the method is a test method + is_test = (hasattr(method_obj, "_is_ble_test") and method_obj._is_ble_test) or method_name.startswith( + "test_", + ) + + if is_test: + # Check if the method is a coroutine function + if asyncio.iscoroutinefunction(method_obj) or inspect.iscoroutinefunction(method_obj): + test_name = f"{class_full_name}.{method_name}" + + # Get line number for sorting + line_number = inspect.getsourcelines(method_obj)[1] + + # Store tuple of (test_name, class_name, class_obj, method, line_number) + class_method_tests.append( + ( + test_name, + class_full_name, + class_obj, + method_obj, + line_number, + ), + ) + logger.debug(f"Discovered class test method: {test_name} at line {line_number}") + else: + logger.warning( + f"Method {method_name} in class {class_full_name} is not a coroutine function, skipping", + ) + + # Sort class methods by line number to preserve definition order + class_method_tests.sort(key=lambda x: x[4]) + + # Add sorted methods to class_tests + class_tests.extend(class_method_tests) + + # Then, discover standalone test functions + function_tests = [] + for name, obj in module.__dict__.items(): + if not _check_wildcard_match(method_or_wildcard, name): + continue + + # Check if the function is decorated with @ble_test or starts with test_ + is_test = (hasattr(obj, "_is_ble_test") and obj._is_ble_test) or name.startswith("test_") + + if is_test and callable(obj) and not inspect.isclass(obj): + # Don't process methods that belong to test classes (already handled) + if any(t[2] == obj for t in class_tests): + continue + + # Check if the function is a coroutine function + if asyncio.iscoroutinefunction(obj) or inspect.iscoroutinefunction(obj): + test_name = f"{rel_module}.{name}" + + # Get line number for sorting + line_number = inspect.getsourcelines(obj)[1] + + # Store tuple of (test_name, function, line_number) + function_tests.append((test_name, obj, line_number)) + logger.debug(f"Discovered standalone test: {test_name} at line {line_number}") + else: + logger.warning(f"Function {name} in {file_path} is not a coroutine function, skipping") + + # Sort standalone functions by line number + function_tests.sort(key=lambda x: x[2]) + + tests: list[tuple[str, TestItem]] = [] + # Add class tests to the order list first + for test_name, class_name, class_obj, method_obj, _ in class_tests: + tests.append((test_name, (class_name, class_obj, method_obj))) + + # Then add standalone function tests to maintain file definition order + for test_name, obj, _ in function_tests: + tests.append((test_name, obj)) + + return tests + + +def _find_tests_in_file( + package_dir: Path | None, + test_dir: Path, + test_file: str, + method_or_wildcard: str | None = None, +) -> list[TestNameItem]: + """Find tests in the given file. + + Args: + package_dir: Directory of the package + test_dir: Directory of the test + test_file: File to find tests in + method_or_wildcard: Method name or wildcard of the tests to find + + Returns: + List of tuples (test_name, test_item) where test_item is a test function or (class, method) tuple + """ + # first we need to import the test file. If we are in a package, we need to import the file from the + # package, otherwise we need to import the file from the test directory + if package_dir is not None: + # find additional path beyond package_dir to the file + rel_path = test_dir.relative_to(package_dir) + package_name = package_dir.name + if rel_path == ".": + # File is directly in the module directory + package_path = None + import_name = f"{package_name}.{test_file}" + else: + # File is in a subdirectory + package_path = rel_path.as_posix().replace(os.path.sep, ".") + import_name = f"{package_name}.{package_path}.{test_file}" + + else: + # No module structure, just import the file directly + package_path = None + import_name = Path(test_file).stem + # Add the test directory to sys.path to allow importing modules from it + if test_dir not in sys.path: + sys.path.insert(0, str(test_dir)) + logger.debug(f"Added {test_dir} to sys.path") + + return _find_tests_in_module( + package_dir, + import_name, + test_dir, + test_file, + method_or_wildcard, + ) + + +def discover_tests_from_specifier(test_specifier: str) -> list[tuple[str, list[TestNameItem]]]: + """Parse a test specifier. + + Args: + test_specifier: Test specifier + + Returns: + List of tuples (module_name, test_items) where test_items is a list of tuples (test_name, test_item) where + test_item is a test function or (class, method) tuple + """ + tests: list[tuple[str, list[TestNameItem]]] = [] + # Split the specifier by both '.' and '/' or '\' to handle different path formats + path_parts = re.split(r"[./\\]", test_specifier) + starts_with_slash = test_specifier[0] if test_specifier.startswith("/") or test_specifier.startswith("\\") else "" + + # If the specifier is empty after splitting, skip it + if not path_parts or all(not part for part in path_parts): + logger.warning(f"Warning: Empty specifier after splitting: '{test_specifier}'") + return tests + + # Check if the last path part contains a wildcard + wildcard = None + if path_parts and "*" in path_parts[-1]: + wildcard = path_parts[-1] + path_parts = path_parts[:-1] + logger.debug(f"Extracted wildcard '{wildcard}' from path parts") + + test_dir = None + test_file = None + test_method = None + + for i in range(min(3, len(path_parts))): + # create a possible path from the path_parts + possible_path = Path(*path_parts[:-i]) if i > 0 else Path(*path_parts) + logger.debug(f"possible_path {i}: {possible_path}") + if starts_with_slash: + possible_path = Path(starts_with_slash + str(possible_path)) + if possible_path.is_dir(): + test_dir = possible_path + if i > 1: + test_file = path_parts[-i] + test_method = path_parts[-i + 1] + elif i > 0: + test_file = path_parts[-i] + test_method = None + else: + test_file = None + test_method = None + break + tmp_dir = possible_path.parent + tmp_file = possible_path.name + if result := _check_if_file_exists(tmp_dir, tmp_file): + test_dir, test_file = result + logger.debug(f"Found test_dir: {test_dir}, test_file: {test_file}") + test_method = path_parts[-i] if i > 0 else None + break + if test_dir is None: + # Not found a dir yet, so specifier is not dir or file in current directory + test_dir = Path.cwd() + test_file = None + if test_specifier == "all": + logger.debug(f"Finding all tests in {test_dir}") + elif len(path_parts) > 0 and (result := _check_if_file_exists(test_dir, path_parts[-1])): + test_dir, test_file = result + logger.debug(f"Found test_dir: {test_dir}, test_file: {test_file}") + + logger.debug(f"test_dir: {test_dir}, test_file: {test_file}, test_method: {test_method}") + + if test_file is None: # find all files in test_dir + test_file_wildcard = wildcard if test_method is None else None + test_files = _find_files_matching_wildcard(test_dir, test_file_wildcard or "test_*") + if not test_files: + if (test_dir / "tests").is_dir(): + test_dir = test_dir / "tests" + test_files = _find_files_matching_wildcard(test_dir, test_file_wildcard or "test_*") + if not test_files: + test_files = _find_files_matching_wildcard(test_dir, "test_*") + if not test_files: + raise NoTestFilesFoundError() + test_method = wildcard + test_file_wildcard = None + if test_file_wildcard is not None: + # do not reuse wildcard for method search + wildcard = None + else: + test_files = [test_file] + + if test_method is None and wildcard is not None: + test_method = wildcard + + logger.debug(f"Discovering tests in test_dir: {test_dir}, test_file: {test_file}, test_method: {test_method}") + if pkg_result := find_and_import_nearest_package(test_dir): + package_name, package_dir = pkg_result + else: + package_dir = None + + for test_file in test_files: + module_name = Path(test_file).stem + module_tests = _find_tests_in_file(package_dir, test_dir, test_file, test_method) + tests.append((module_name, module_tests)) + + tests.sort(key=lambda x: x[0]) + + return tests diff --git a/test_a_ble/test_runner.py b/test_a_ble/test_runner.py index 087b246..dfe6d14 100644 --- a/test_a_ble/test_runner.py +++ b/test_a_ble/test_runner.py @@ -4,502 +4,29 @@ """ import asyncio -import fnmatch -import importlib -import importlib.util -import inspect import logging -import os -import re -import sys import traceback -from collections.abc import Callable, Coroutine -from typing import Any, Union +from collections.abc import Callable +from typing import Any from .ble_manager import BLEManager from .test_context import TestContext, TestException, TestFailure, TestSkip, TestStatus +from .test_discovery import TestFunction, TestItem, TestNameItem, discover_tests_from_specifier logger = logging.getLogger(__name__) -# Type for test function -TestFunction = Callable[[BLEManager, TestContext], Coroutine[Any, Any, None]] - -# Type for test item: a test function or (class_name, class_obj, method) tuple -TestItem = Union[Callable, tuple[str, Any, Callable]] -# Type for test: (test_name, test_item) -TestNameItem = tuple[str, TestItem] - class TestRunner: """Discovers and runs tests against BLE devices.""" def __init__(self, ble_manager: BLEManager): - """Initialize the test runner.""" - self.ble_manager = ble_manager - self.test_context = TestContext(ble_manager) - - def _is_package(self, path: str) -> bool: - """Check if a directory is a Python package (has __init__.py file). - - Args: - path: Path to check - - Returns: - True if the path is a Python package, False otherwise - """ - return os.path.isdir(path) and os.path.exists(os.path.join(path, "__init__.py")) - - def _import_package(self, package_path: str, base_package: str = "") -> str: - """Import a Python package and all its parent packages. + """Initialize the test runner. Args: - package_path: Absolute path to the package - base_package: Base package name - - Returns: - The imported package name + ble_manager: BLE manager instance to use for tests """ - logger.debug(f"Importing package: {package_path}") - - # Get the package name from the path - package_name = os.path.basename(package_path) - - # Construct the full package name - if base_package: - full_package_name = f"{base_package}.{package_name}" - else: - full_package_name = package_name - - # Check if package is already imported - if full_package_name in sys.modules: - logger.debug(f"Package {full_package_name} already imported") - return full_package_name - - # Find the __init__.py file - init_path = os.path.join(package_path, "__init__.py") - - if not os.path.exists(init_path): - raise ImportError(f"No __init__.py found in {package_path}") - - try: - # Import the package - spec = importlib.util.spec_from_file_location(full_package_name, init_path) - if not spec or not spec.loader: - raise ImportError(f"Failed to load module spec for {init_path}") - - module = importlib.util.module_from_spec(spec) - sys.modules[full_package_name] = module - - # Execute the module - spec.loader.exec_module(module) - logger.debug(f"Successfully imported package: {full_package_name}") - - return full_package_name - except Exception as e: - raise ImportError(f"Error importing package {full_package_name}: {e!s}") from e - - def _find_and_import_nearest_package(self, path: str) -> tuple[str, str] | None: - """Find the nearest package in the given path and import it. - - Args: - path: Path to search for a package - - Returns: - Tuple of (package_name, package_dir) if a package is found, None otherwise - """ - current_dir = path - parent_count = 0 - - # Check up to 2 parent directories for __init__.py - while parent_count < 2: - if self._is_package(current_dir): - # Found a module - use this as our base - package_dir = current_dir - package_name = os.path.basename(current_dir) - logger.debug(f"Found package: {package_name} at {package_dir}") - - try: - self._import_package(current_dir) - return package_name, package_dir - except ImportError as e: - logger.error(f"Error importing package {current_dir}: {e!s}") - raise - - # Move up to the parent directory - parent_dir = os.path.dirname(current_dir) - if parent_dir == current_dir: # We've reached the root - return None - - current_dir = parent_dir - parent_count += 1 - - return None - - def _discover_tests_from_specifier(self, test_specifier: str) -> list[tuple[str, list[TestNameItem]]]: - """Parse a test specifier. - - Args: - test_specifier: Test specifier - - Returns: - List of tuples (module_name, test_items) where test_items is a list of tuples (test_name, test_item) where - test_item is a test function or (class, method) tuple - """ - - def check_if_file_exists(test_dir: str, test_file: str) -> tuple[str, str] | None: - """Check if a file exists in the given directory. - - Returns: - Tuple of (test_dir, test_file) if the file exists, None otherwise - """ - if test_file is None: - return None - if not os.path.isdir(test_dir): - return None - if not test_file.endswith(".py"): - test_file = test_file + ".py" - if os.path.isfile(os.path.join(test_dir, test_file)): - return (test_dir, test_file) - if os.path.isfile(os.path.join(test_dir, "tests", test_file)): - return (os.path.join(test_dir, "tests"), test_file) - return None - - def check_wildcard_match(test_wildcard: str | None, test_string: str) -> bool: - """Check if the test string matches the test wildcard. - - Args: - test_wildcard: Wildcard to match against - test_string: String to match - - Returns: - True if the test string matches the test wildcard, False otherwise - """ - return test_wildcard is None or fnmatch.fnmatch(test_string, test_wildcard) - - def find_files_matching_wildcard(test_dir: str, test_file_wildcard: str | None = None) -> list[str]: - """Find files matching the wildcard (or any file if test_file_wildcard is None) in the given directory. - - Args: - test_dir: Directory to search in - test_file_wildcard: Wildcard to match against, or None to match any file - - Returns: - List of files matching the wildcard - """ - if not os.path.isdir(test_dir): - return [] - # list files in test_dir that match the wildcard - files = [] - for file in os.listdir(test_dir): - if file.endswith(".py") and check_wildcard_match(test_file_wildcard, file): - files.append(file) - return files - - def find_tests_in_module( - package_dir: str | None, - import_name: str, - test_dir: str, - test_file: str, - method_or_wildcard: str | None = None, - ) -> list[TestNameItem]: - """Find tests in the given module. - - Args: - package_dir: Directory of the package - import_name: Import name of the module - method_or_wildcard: Method name or wildcard of the tests to - find, or None to find all tests in the module - - Returns: - List of tuples (test_name, test_item) where test_item is a test function or (class, method) tuple - """ - file_path = os.path.join(test_dir, test_file) - try: - # Try to import the module using importlib.import_module first - try: - if package_dir is not None: - # If we have a module, try to use standard import - module = importlib.import_module(import_name) - logger.debug(f"Imported {import_name} using import_module") - else: - # No module structure, use direct file import - raise ImportError("Not in a package, using spec_from_file_location") - - except ImportError: - # Fallback to the file-based import method - spec = importlib.util.spec_from_file_location(import_name, file_path) - - if not spec or not spec.loader: - raise ImportError(f"Failed to load module spec for {file_path}") - - module = importlib.util.module_from_spec(spec) - # Add the module to sys.modules to allow relative imports - sys.modules[import_name] = module - - # Execute the module - spec.loader.exec_module(module) - logger.debug(f"Imported {import_name} using spec_from_file_location") - - # Use the relative path from test_dir as the module prefix for test names - rel_path = os.path.relpath(file_path, test_dir) - rel_module = os.path.splitext(rel_path)[0].replace(os.path.sep, ".") - - # First, discover test classes - class_tests = [] - for class_name, class_obj in module.__dict__.items(): - # Check if it's a class and follows naming convention - if inspect.isclass(class_obj) and ( - class_name.startswith("Test") - or (hasattr(class_obj, "_is_test_class") and class_obj._is_test_class) - ): - # Store class for later use - class_full_name = f"{rel_module}.{class_name}" - logger.debug(f"Discovered test class: {class_full_name}") - - # Discover test methods in the class and collect with source line numbers - class_method_tests = [] - for method_name, method_obj in inspect.getmembers(class_obj, predicate=inspect.isfunction): - if not check_wildcard_match(method_or_wildcard, method_name): - continue - - # Check if the method is a test method - is_test = ( - hasattr(method_obj, "_is_ble_test") and method_obj._is_ble_test - ) or method_name.startswith("test_") - - if is_test: - # Check if the method is a coroutine function - if asyncio.iscoroutinefunction(method_obj) or inspect.iscoroutinefunction(method_obj): - test_name = f"{class_full_name}.{method_name}" - - # Get line number for sorting - line_number = inspect.getsourcelines(method_obj)[1] - - # Store tuple of (test_name, class_name, class_obj, method, line_number) - class_method_tests.append( - ( - test_name, - class_full_name, - class_obj, - method_obj, - line_number, - ), - ) - logger.debug(f"Discovered class test method: {test_name} at line {line_number}") - else: - logger.warning( - f"Method {method_name} in class {class_full_name} is not a coroutine function, " - "skipping", - ) - - # Sort class methods by line number to preserve definition order - class_method_tests.sort(key=lambda x: x[4]) - - # Add sorted methods to class_tests - class_tests.extend(class_method_tests) - - # Then, discover standalone test functions - function_tests = [] - for name, obj in module.__dict__.items(): - if not check_wildcard_match(method_or_wildcard, name): - continue - - # Check if the function is decorated with @ble_test or starts with test_ - is_test = (hasattr(obj, "_is_ble_test") and obj._is_ble_test) or name.startswith("test_") - - if is_test and callable(obj) and not inspect.isclass(obj): - # Don't process methods that belong to test classes (already handled) - if any(t[2] == obj for t in class_tests): - continue - - # Check if the function is a coroutine function - if asyncio.iscoroutinefunction(obj) or inspect.iscoroutinefunction(obj): - test_name = f"{rel_module}.{name}" - - # Get line number for sorting - line_number = inspect.getsourcelines(obj)[1] - - # Store tuple of (test_name, function, line_number) - function_tests.append((test_name, obj, line_number)) - logger.debug(f"Discovered standalone test: {test_name} at line {line_number}") - else: - logger.warning(f"Function {name} in {file_path} is not a coroutine function, skipping") - - # Sort standalone functions by line number - function_tests.sort(key=lambda x: x[2]) - - tests: list[tuple[str, TestItem]] = [] - # Add class tests to the order list first - for test_name, class_name, class_obj, method_obj, _ in class_tests: - tests.append((test_name, (class_name, class_obj, method_obj))) - - # Then add standalone function tests to maintain file definition order - for test_name, obj, _ in function_tests: - tests.append((test_name, obj)) - - return tests - - except ImportError as e: - logger.error(f"Import error loading module {import_name}: {e!s}") - logger.info(f"File path: {file_path}") - logger.info(f"Current sys.path: {sys.path}") - - raise - except Exception as e: - logger.error(f"Error loading module {import_name}: {e!s}") - logger.debug(f"Exception details: {traceback.format_exc()}") - raise - - def find_tests_in_file( - package_dir: str | None, - test_dir: str, - test_file: str, - method_or_wildcard: str | None = None, - ) -> list[TestNameItem]: - """Find tests in the given file. - - Args: - package_dir: Directory of the package - test_dir: Directory of the test - test_file: File to find tests in - method_or_wildcard: Method name or wildcard of the tests to find, or None to find all tests in the file - - Returns: - List of tuples (test_name, test_item) where test_item is a test function or (class, method) tuple - """ - # first we need to import the test file. If we are in a package, we need to import the file from the - # package, otherwise we need to import the file from the test directory - if package_dir is not None: - # find additional path beyond package_dir to the file - rel_path = os.path.relpath(test_dir, package_dir) - package_name = os.path.basename(package_dir) - if rel_path == ".": - # File is directly in the module directory - package_path = None - import_name = f"{package_name}.{test_file}" - else: - # File is in a subdirectory - package_path = rel_path.replace(os.path.sep, ".") - import_name = f"{package_name}.{package_path}.{test_file}" - - else: - # No module structure, just import the file directly - package_path = None - import_name = os.path.basename(test_file) - # Add the test directory to sys.path to allow importing modules from it - if test_dir not in sys.path: - sys.path.insert(0, test_dir) - logger.debug(f"Added {test_dir} to sys.path") - - return find_tests_in_module( - package_dir, - import_name, - test_dir, - test_file, - method_or_wildcard, - ) - - tests: list[tuple[str, list[TestNameItem]]] = [] - # Split the specifier by both '.' and '/' or '\' to handle different path formats - path_parts = re.split(r"[./\\]", test_specifier) - starts_with_slash = ( - test_specifier[0] if test_specifier.startswith("/") or test_specifier.startswith("\\") else "" - ) - - # If the specifier is empty after splitting, skip it - if not path_parts or all(not part for part in path_parts): - logger.warning(f"Warning: Empty specifier after splitting: '{test_specifier}'") - return tests - - # Check if the last path part contains a wildcard - wildcard = None - if path_parts and "*" in path_parts[-1]: - wildcard = path_parts[-1] - path_parts = path_parts[:-1] - logger.debug(f"Extracted wildcard '{wildcard}' from path parts") - - test_dir = None - test_file = None - test_method = None - - for i in range(min(3, len(path_parts))): - # create a possible path from the path_parts - possible_path = os.path.join(*path_parts[:-i]) if i > 0 else os.path.join(*path_parts) - logger.debug(f"possible_path {i}: {possible_path}") - if starts_with_slash: - possible_path = starts_with_slash + possible_path - if os.path.isdir(possible_path): - test_dir = possible_path - if i > 1: - test_file = path_parts[-i] - test_method = path_parts[-i + 1] - elif i > 0: - test_file = path_parts[-i] - test_method = None - else: - test_file = None - test_method = None - break - tmp_dir = os.path.dirname(possible_path) - tmp_file = os.path.basename(possible_path) - if result := check_if_file_exists(tmp_dir, tmp_file): - test_dir, test_file = result - logger.debug(f"Found test_dir: {test_dir}, test_file: {test_file}") - if i > 0: - test_method = path_parts[-i] - else: - test_method = None - break - if test_dir is None: - # Not found a dir yet, so specifier is not dir or file in current directory - test_dir = os.getcwd() - test_file = None - if test_specifier == "all": - logger.debug(f"Finding all tests in {test_dir}") - elif len(path_parts) > 0 and (result := check_if_file_exists(test_dir, path_parts[-1])): - test_dir, test_file = result - logger.debug(f"Found test_dir: {test_dir}, test_file: {test_file}") - - test_dir = os.path.abspath(test_dir) - - logger.debug(f"test_dir: {test_dir}, test_file: {test_file}, test_method: {test_method}") - - if test_file is None: # find all files in test_dir - test_file_wildcard = wildcard if test_method is None else None - test_files = find_files_matching_wildcard(test_dir, test_file_wildcard or "test_*") - if not test_files: - if os.path.isdir(os.path.join(test_dir, "tests")): - test_dir = os.path.join(test_dir, "tests") - test_files = find_files_matching_wildcard(test_dir, test_file_wildcard or "test_*") - if not test_files: - test_files = find_files_matching_wildcard(test_dir, "test_*") - if not test_files: - raise ValueError(f"No test files found in {test_dir}") - test_method = wildcard - test_file_wildcard = None - if test_file_wildcard is not None: - # do not reuse wildcard for method search - wildcard = None - else: - test_files = [test_file] - - if test_method is None and wildcard is not None: - test_method = wildcard - - logger.debug(f"Discovering tests in test_dir: {test_dir}, test_file: {test_file}, test_method: {test_method}") - if result := self._find_and_import_nearest_package(test_dir): - package_name, package_dir = result - else: - package_dir = None - - for test_file in test_files: - module_name = os.path.splitext(os.path.basename(test_file))[0] - module_tests = find_tests_in_file(package_dir, test_dir, test_file, test_method) - tests.append((module_name, module_tests)) - - tests.sort(key=lambda x: x[0]) - - return tests + self.ble_manager = ble_manager + self.test_context = TestContext(ble_manager) def discover_tests(self, test_specifiers: list[str]) -> list[tuple[str, list[TestNameItem]]]: """Discover test modules with the given specifiers. @@ -508,11 +35,11 @@ def discover_tests(self, test_specifiers: list[str]) -> list[tuple[str, list[Tes test_specifiers: List of test specifiers Returns: - Dictionary mapping test names to test functions or (class, method) tuples + List of tuples containing module names and their test items """ tests = [] for test_specifier in test_specifiers: - tests.extend(self._discover_tests_from_specifier(test_specifier)) + tests.extend(discover_tests_from_specifier(test_specifier)) return tests async def run_test(self, test_name: str, test_item: TestItem) -> dict[str, Any]: @@ -520,11 +47,12 @@ async def run_test(self, test_name: str, test_item: TestItem) -> dict[str, Any]: Args: test_name: Name of the test to run + test_item: Test item to run (function or class method tuple) Returns: Test result dictionary """ - # Check if test is already in results (might have been directly started by another test) + # Check if test is already in results if ( test_name in self.test_context.test_results and self.test_context.test_results[test_name]["status"] != TestStatus.RUNNING.value @@ -532,109 +60,48 @@ async def run_test(self, test_name: str, test_item: TestItem) -> dict[str, Any]: logger.debug(f"Test {test_name} already has results, skipping") return self.test_context.test_results[test_name] - # Get the test description - test_description = None - - # Handle class method tests + test_description = self._get_test_description(test_name, test_item) test_class_instance = None - if isinstance(test_item, tuple): - class_name, class_obj, method = test_item - - # Check if method has description - if hasattr(method, "_test_description") and method._test_description: - test_description = method._test_description - else: - # Use the method name - test_description = method.__name__ - - # Create an instance of the test class - test_class_instance = class_obj() - - # Handle standalone test functions - else: - test_func = test_item - # Get the test description - either from the decorated function or use the function name - if hasattr(test_func, "_test_description") and test_func._test_description: - test_description = test_func._test_description - else: - # Use the base name without the module part - test_description = test_name.split(".")[-1] - - # Display a clear message showing which test is running with visual enhancements - print("\n") # Add space before test for separation + # Display test header + print("\n") self.test_context.print(f"\033[1m\033[4mRunning test: {test_description}\033[0m") - print("") # Add space after header + print("") - # Automatically start the test - don't rely on test function to do this self.test_context.start_test(test_description) - result = None try: - # If this is a class method test, call setUp if it exists - if test_class_instance: - # Call setUp if it exists - if hasattr(test_class_instance, "setUp") and callable(test_class_instance.setUp): - if asyncio.iscoroutinefunction(test_class_instance.setUp): - logger.debug(f"Calling async setUp for {class_name}") - await test_class_instance.setUp(self.ble_manager, self.test_context) - else: - logger.debug(f"Calling sync setUp for {class_name}") - test_class_instance.setUp(self.ble_manager, self.test_context) - - # Run the test method - logger.debug(f"Executing class test method: {test_name}") - await method(test_class_instance, self.ble_manager, self.test_context) + if isinstance(test_item, tuple): + test_class_instance = await self._run_class_test(test_item) else: - # Run standalone test function - logger.debug(f"Executing standalone test: {test_name}") - await test_func(self.ble_manager, self.test_context) + await self._run_standalone_test(test_item) - # Test completed without exceptions - mark as pass result = self.test_context.end_test(TestStatus.PASS) - except TestFailure as e: - logger.error(f"Test {test_name} failed: {e!s}") + except (TestFailure, AssertionError) as e: + logger.exception(f"Test {test_name} failed") result = self.test_context.end_test(TestStatus.FAIL, str(e)) except TestSkip as e: - logger.info(f"Test {test_name} skipped: {e!s}") + logger.info(f"Test {test_name} skipped") result = self.test_context.end_test(TestStatus.SKIP, str(e)) except TestException as e: - logger.error(f"Test {test_name} error: {e!s}") + logger.exception(f"Test {test_name} error") result = self.test_context.end_test(e.status, str(e)) - except AssertionError as e: - logger.error(f"Test {test_name} failed: {e!s}") - result = self.test_context.end_test(TestStatus.FAIL, str(e)) - except TimeoutError as e: - # Handle timeout errors gracefully without showing traceback - logger.error(f"Test {test_name} error: {e!s}") + logger.exception(f"Test {test_name} error") result = self.test_context.end_test(TestStatus.ERROR, str(e)) except Exception as e: - logger.error(f"Error running test {test_name}: {e!s}") + logger.exception(f"Error running test {test_name}") traceback.print_exc() result = self.test_context.end_test(TestStatus.ERROR, str(e)) finally: - # If this is a class method test, call tearDown if it exists if test_class_instance: - try: - if hasattr(test_class_instance, "tearDown") and callable(test_class_instance.tearDown): - if asyncio.iscoroutinefunction(test_class_instance.tearDown): - logger.debug(f"Calling async tearDown for {class_name}") - await test_class_instance.tearDown(self.ble_manager, self.test_context) - else: - logger.debug(f"Calling sync tearDown for {class_name}") - test_class_instance.tearDown(self.ble_manager, self.test_context) - except Exception as e: - logger.error(f"Error in tearDown for {test_name}: {e!s}") - # Don't override test result if tearDown fails - - # Clean up subscriptions after test is complete + await self._run_teardown(test_class_instance, test_name) await self.test_context.unsubscribe_all() return result @@ -649,12 +116,94 @@ async def run_tests(self, tests: list[TestNameItem]) -> dict[str, Any]: Summary of test results """ try: - # Run each test in the order they were defined for test_name, test_item in tests: await self.run_test(test_name, test_item) - - # Return summary return self.test_context.get_test_summary() finally: - # Ensure all tasks are cleaned up await self.test_context.cleanup_tasks() + + def _get_test_description(self, test_name: str, test_item: TestItem) -> str: + """Get the description for a test. + + Args: + test_name: Name of the test + test_item: Test item (function or class method tuple) + + Returns: + Test description string + """ + if isinstance(test_item, tuple): + _, _, method = test_item + if hasattr(method, "_test_description") and method._test_description: + return method._test_description + return method.__name__ + + test_func = test_item + if hasattr(test_func, "_test_description") and test_func._test_description: + return test_func._test_description + return test_name.split(".")[-1] + + async def _run_class_test(self, test_item: tuple[str, Any, Callable]) -> Any: + """Run a class-based test. + + Args: + test_item: Tuple of (class_name, class_obj, method) + + Returns: + Test class instance + """ + class_name, class_obj, method = test_item + test_class_instance = class_obj() + + # Run setUp if it exists + if hasattr(test_class_instance, "setUp"): + await self._run_setup(test_class_instance, class_name) + + # Run the test method + logger.debug(f"Executing class test method: {class_name}.{method.__name__}") + await method(test_class_instance, self.ble_manager, self.test_context) + + return test_class_instance + + async def _run_standalone_test(self, test_func: TestFunction) -> None: + """Run a standalone test function. + + Args: + test_func: Test function to run + """ + logger.debug(f"Executing standalone test: {test_func.__name__}") + await test_func(self.ble_manager, self.test_context) + + async def _run_setup(self, test_class_instance: Any, class_name: str) -> None: + """Run the setUp method of a test class. + + Args: + test_class_instance: Test class instance + class_name: Name of the test class + """ + setup = test_class_instance.setUp + if asyncio.iscoroutinefunction(setup): + logger.debug(f"Calling async setUp for {class_name}") + await setup(self.ble_manager, self.test_context) + else: + logger.debug(f"Calling sync setUp for {class_name}") + setup(self.ble_manager, self.test_context) + + async def _run_teardown(self, test_class_instance: Any, test_name: str) -> None: + """Run the tearDown method of a test class. + + Args: + test_class_instance: Test class instance + test_name: Name of the test + """ + try: + if hasattr(test_class_instance, "tearDown"): + teardown = test_class_instance.tearDown + if asyncio.iscoroutinefunction(teardown): + logger.debug(f"Calling async tearDown for {test_name}") + await teardown(self.ble_manager, self.test_context) + else: + logger.debug(f"Calling sync tearDown for {test_name}") + teardown(self.ble_manager, self.test_context) + except Exception: + logger.exception(f"Error in tearDown for {test_name}") diff --git a/tests/test_discovery_test_package/__init__.py b/tests/test_discovery_test_package/__init__.py index 819b227..cc20f9f 100644 --- a/tests/test_discovery_test_package/__init__.py +++ b/tests/test_discovery_test_package/__init__.py @@ -1,11 +1,11 @@ """Test discovery test package.""" -import os import time +from pathlib import Path # Create a timestamp file to track when the package was imported -TIMESTAMP_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "import_timestamp.txt") +TIMESTAMP_FILE = Path(__file__).parent / "import_timestamp.txt" # Write the current timestamp to the file -with open(TIMESTAMP_FILE, "w") as f: +with TIMESTAMP_FILE.open("w") as f: f.write(str(time.time())) diff --git a/tests/test_test_discovery.py b/tests/test_test_discovery.py index 9ea7580..824101e 100644 --- a/tests/test_test_discovery.py +++ b/tests/test_test_discovery.py @@ -1,17 +1,24 @@ """Test test discovery.""" -import os import sys +from pathlib import Path from unittest.mock import MagicMock, patch import pytest # type: ignore from test_a_ble.ble_manager import BLEManager +from test_a_ble.test_discovery import ( + NoTestFilesFoundError, + _import_package, + _is_package, + discover_tests_from_specifier, + find_and_import_nearest_package, +) from test_a_ble.test_runner import TestRunner # Get the absolute path to the test_discovery_test_package -TEST_PACKAGE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "test_discovery_test_package") -TIMESTAMP_FILE = os.path.join(TEST_PACKAGE_DIR, "import_timestamp.txt") +TEST_PACKAGE_DIR = Path(__file__).parent / "test_discovery_test_package" +TIMESTAMP_FILE = TEST_PACKAGE_DIR / "import_timestamp.txt" @pytest.fixture @@ -28,8 +35,8 @@ def test_runner(mock_ble_manager): def reset_now(): """Reset the timestamp file and package import.""" - if os.path.exists(TIMESTAMP_FILE): - os.remove(TIMESTAMP_FILE) + if TIMESTAMP_FILE.exists(): + TIMESTAMP_FILE.unlink() if "test_discovery_test_package" in sys.modules: del sys.modules["test_discovery_test_package"] @@ -48,15 +55,15 @@ def reset(): def was_package_imported(): """Check if the package was imported by looking for the timestamp file.""" - return os.path.exists(TIMESTAMP_FILE) + return TIMESTAMP_FILE.exists() -def test_discover_specific_function(test_runner): +def test_discover_specific_function(test_runner: TestRunner): """Test discovering a specific function.""" # Change to the test package directory - with patch("os.getcwd", return_value=TEST_PACKAGE_DIR): + with patch("pathlib.Path.cwd", return_value=TEST_PACKAGE_DIR): # Discover tests with the specifier "test_function" - tests = test_runner.discover_tests(["test_function"]) + tests = discover_tests_from_specifier("test_function") # Verify the discovered tests assert len(tests) == 1 # One module @@ -73,12 +80,12 @@ def test_discover_specific_function(test_runner): assert was_package_imported(), "Package was not imported during test discovery" -def test_discover_specific_class(test_runner): +def test_discover_specific_class(test_runner: TestRunner): """Test discovering a specific class.""" # Change to the test package directory - with patch("os.getcwd", return_value=TEST_PACKAGE_DIR): + with patch("pathlib.Path.cwd", return_value=TEST_PACKAGE_DIR): # Discover tests with the specifier "test_class" - tests = test_runner.discover_tests(["test_class"]) + tests = discover_tests_from_specifier("test_class") # Verify the discovered tests assert len(tests) == 1 # One module @@ -95,12 +102,12 @@ def test_discover_specific_class(test_runner): assert was_package_imported(), "Package was not imported during test discovery" -def test_discover_all_tests_from_cwd(test_runner): +def test_discover_all_tests_from_cwd(test_runner: TestRunner): """Test discovering all tests from the current working directory.""" # Change to the test package directory - with patch("os.getcwd", return_value=TEST_PACKAGE_DIR): + with patch("pathlib.Path.cwd", return_value=TEST_PACKAGE_DIR): # Discover tests with no specifier - tests = test_runner.discover_tests(["all"]) + tests = discover_tests_from_specifier("all") # Verify the discovered tests assert len(tests) == 2 # Two modules @@ -132,14 +139,14 @@ def test_discover_all_tests_from_cwd(test_runner): assert was_package_imported(), "Package was not imported during test discovery" -def test_discover_with_relative_path(test_runner): +def test_discover_with_relative_path(test_runner: TestRunner): """Test discovering tests with a relative path.""" # Get the relative path from the current directory to the test package - current_dir = os.getcwd() - relative_path = os.path.relpath(TEST_PACKAGE_DIR, current_dir) + current_dir = Path.cwd() + relative_path = TEST_PACKAGE_DIR.relative_to(current_dir) # Discover tests with the relative path - tests = test_runner.discover_tests([relative_path]) + tests = discover_tests_from_specifier(str(relative_path)) # Verify the discovered tests assert len(tests) == 2 # Two modules @@ -161,10 +168,10 @@ def test_discover_with_relative_path(test_runner): assert was_package_imported(), "Package was not imported during test discovery" -def test_discover_with_absolute_path(test_runner): +def test_discover_with_absolute_path(test_runner: TestRunner): """Test discovering tests with an absolute path.""" # Discover tests with the absolute path - tests = test_runner.discover_tests([TEST_PACKAGE_DIR]) + tests = discover_tests_from_specifier(str(TEST_PACKAGE_DIR)) # Verify the discovered tests assert len(tests) == 2 # Two modules @@ -186,12 +193,12 @@ def test_discover_with_absolute_path(test_runner): assert was_package_imported(), "Package was not imported during test discovery" -def test_discover_with_file_wildcard(test_runner): +def test_discover_with_file_wildcard(test_runner: TestRunner): """Test discovering tests with a wildcard for test files.""" # Change to the test package directory - with patch("os.getcwd", return_value=TEST_PACKAGE_DIR): + with patch("pathlib.Path.cwd", return_value=TEST_PACKAGE_DIR): # Discover tests with the wildcard specifier "test_c*" - tests = test_runner.discover_tests(["test_c*"]) + tests = discover_tests_from_specifier("test_c*") # Verify the discovered tests assert len(tests) == 1 # One module @@ -208,12 +215,12 @@ def test_discover_with_file_wildcard(test_runner): assert was_package_imported(), "Package was not imported during test discovery" -def test_discover_with_function_wildcard(test_runner): +def test_discover_with_function_wildcard(test_runner: TestRunner): """Test discovering tests with a wildcard for test functions.""" # Change to the test package directory - with patch("os.getcwd", return_value=TEST_PACKAGE_DIR): + with patch("pathlib.Path.cwd", return_value=TEST_PACKAGE_DIR): # Discover tests with the wildcard specifier "*_1" - tests = test_runner.discover_tests(["*_1"]) + tests = discover_tests_from_specifier("*_1") # Verify the discovered tests assert len(tests) == 2 # Two modules @@ -223,7 +230,7 @@ def test_discover_with_function_wildcard(test_runner): # Check the test items - TestNameItem is a tuple of (name, test_item) all_test_names = [] - for module_name, test_items in tests: + for _module_name, test_items in tests: all_test_names.extend([item[0] for item in test_items]) # We should have exactly 2 tests with names ending in _1 @@ -233,3 +240,326 @@ def test_discover_with_function_wildcard(test_runner): # Check that the package was imported assert was_package_imported(), "Package was not imported during test discovery" + + +def test_is_package(test_runner, tmp_path): + """Test the _is_package method.""" + # Create a directory that is a package + package_dir = tmp_path / "package" + package_dir.mkdir() + init_file = package_dir / "__init__.py" + init_file.write_text("") + + # Create a directory that is not a package + not_package_dir = tmp_path / "not_package" + not_package_dir.mkdir() + + # Test + assert _is_package(package_dir) is True + assert _is_package(not_package_dir) is False + + +@patch("importlib.util.spec_from_file_location") +@patch("importlib.util.module_from_spec") +def test_import_package_with_base_package( + mock_module_from_spec, + mock_spec_from_file, + test_runner, + tmp_path, +): + """Test the _import_package method with a base package.""" + # Setup + + # Create a mock spec and module + mock_spec = MagicMock() + mock_spec.loader = MagicMock() + mock_spec_from_file.return_value = mock_spec + + mock_module = MagicMock() + mock_module_from_spec.return_value = mock_module + + # Create a package directory structure + package_dir = tmp_path / "base_package" / "my_test_package" + package_dir.mkdir(parents=True) + init_file = package_dir / "__init__.py" + init_file.write_text("") + + # Pre-construct the expected path string + expected_init_path = package_dir / "__init__.py" + + # Test with base package + with patch.dict("sys.modules", {}, clear=True): + result = _import_package(package_dir, "base.package") + assert result == "base.package.my_test_package" + mock_spec_from_file.assert_called_with("base.package.my_test_package", expected_init_path) + + +@patch("importlib.util.spec_from_file_location") +@patch("importlib.util.module_from_spec") +def test_import_package_without_base_package( + mock_module_from_spec, + mock_spec_from_file, + test_runner, + tmp_path, +): + """Test the _import_package method without a base package.""" + # Setup + + # Create a mock spec and module + mock_spec = MagicMock() + mock_spec.loader = MagicMock() + mock_spec_from_file.return_value = mock_spec + + mock_module = MagicMock() + mock_module_from_spec.return_value = mock_module + + # Create a package directory structure + package_dir = tmp_path / "base_package" / "my_test_package" + package_dir.mkdir(parents=True) + init_file = package_dir / "__init__.py" + init_file.write_text("") + + # Pre-construct the expected path string + expected_init_path = package_dir / "__init__.py" + + # Test without base package + with patch.dict("sys.modules", {}, clear=True): + result = _import_package(package_dir) + assert result == "my_test_package" + mock_spec_from_file.assert_called_with("my_test_package", expected_init_path) + + +@patch("pathlib.Path") +def test_find_and_import_nearest_package_when_package_found(mock_path, test_runner, tmp_path): + """Test the _find_and_import_nearest_package method when a package is found.""" + # Setup + mock_path_instance = MagicMock() + mock_path.return_value = mock_path_instance + mock_path_instance.is_dir.return_value = True + mock_path_instance.name = "package" # Set the name property + mock_path_instance.parent = mock_path_instance # Set parent to self to prevent infinite loop + + # Configure mock to indicate a package is found + def exists_side_effect(path): + return "__init__.py" in str(path) + + mock_path_instance.exists.side_effect = exists_side_effect + + # Test when a package is found + with patch("test_a_ble.test_discovery._import_package") as mock_import: + mock_import.return_value = "package" # Just the package name, not the full import path + result = find_and_import_nearest_package(mock_path_instance) + assert result == ("package", mock_path_instance) + + +@patch("test_a_ble.test_discovery._is_package") +def test_find_and_import_nearest_package_when_no_package_found(mock_is_package, test_runner, tmp_path): + """Test the _find_and_import_nearest_package method when no package is found.""" + # Configure mock to indicate no package is found + mock_is_package.return_value = False + + # Create a path to test + path = tmp_path / "path" / "to" / "nowhere" + + # Test when no package is found + result = find_and_import_nearest_package(path) + assert result is None + + +def test_import_package_no_init_file(test_runner, tmp_path): + """Test that _import_package raises an ImportError when no __init__.py file exists.""" + # Create a directory without an __init__.py file + package_dir = tmp_path / "fake_package" + package_dir.mkdir() + + # Test that it raises an ImportError + with pytest.raises(ImportError, match="No __init__.py found"): + _import_package(package_dir) + + +def test_find_and_import_nearest_package_with_import_error(test_runner, tmp_path): + """Test that find_and_import_nearest_package raises when _import_package fails.""" + # Create a package directory structure + package_dir = tmp_path / "error_package" + package_dir.mkdir() + + # Create an __init__.py file that will exist but will fail during import + init_file = package_dir / "__init__.py" + init_file.write_text("raise ImportError('Testing import error')") + + # Test that it raises the ImportError + with pytest.raises(ImportError): + find_and_import_nearest_package(package_dir) + + +def test_import_already_imported_package(test_runner, tmp_path): + """Test that _import_package returns the package name if it's already imported.""" + # Create a package directory + package_dir = tmp_path / "already_imported" + package_dir.mkdir() + init_file = package_dir / "__init__.py" + init_file.write_text("") + + package_name = "already_imported" + + # Mock sys.modules to simulate the package being already imported + with patch.dict(sys.modules, {package_name: MagicMock()}): + result = _import_package(package_dir) + assert result == package_name + + +def test_check_if_file_exists(): + """Test _check_if_file_exists function.""" + # Import the function since it's not exported + from test_a_ble.test_discovery import _check_if_file_exists + + # Create a simple test with mock Path objects + test_dir = MagicMock() + test_dir.is_dir.return_value = True + + # For the first test, configure Path.__truediv__ to return a path that exists + path_mock = MagicMock() + path_mock.exists.return_value = True + test_dir.__truediv__.return_value = path_mock + + # Test when file exists + result = _check_if_file_exists(test_dir, "test_file.py") + assert result == (test_dir, "test_file.py") + + # Create a new mock for the second test with a different configuration + test_dir2 = MagicMock() + test_dir2.is_dir.return_value = True + + path_mock2 = MagicMock() + path_mock2.exists.return_value = False + test_dir2.__truediv__.return_value = path_mock2 + + # Mock for the "tests" subdirectory check that also doesn't exist + tests_dir_mock = MagicMock() + tests_dir_mock.exists.return_value = False + test_dir2.__truediv__.return_value.__truediv__.return_value = tests_dir_mock + + # Test when file doesn't exist + result = _check_if_file_exists(test_dir2, "nonexistent_file.py") + assert result is None + + +def test_find_tests_with_max_parent_directories(test_runner, tmp_path): + """Test that find_and_import_nearest_package stops after MAX_IMPORT_PARENT_DIRECTORIES.""" + # Create a deep directory structure without any packages + deep_dir = tmp_path + for i in range(5): # More than MAX_IMPORT_PARENT_DIRECTORIES + deep_dir = deep_dir / f"level_{i}" + deep_dir.mkdir() + + # Test that it returns None after checking the max number of parent directories + result = find_and_import_nearest_package(deep_dir) + assert result is None + + +@patch("pathlib.Path.is_dir") +@patch("test_a_ble.test_discovery._find_files_matching_wildcard") +def test_discover_tests_from_specifier_with_nonexistent_file(mock_find_files, mock_is_dir): + """Test discover_tests_from_specifier with a file that doesn't exist.""" + # Setup mocks + mock_is_dir.return_value = False + # Configure _find_files_matching_wildcard to return empty list (no files found) + mock_find_files.return_value = [] + + # Test with a specific file pattern that is unlikely to exist + # It should raise a specialized NoTestFilesFoundError + with pytest.raises(NoTestFilesFoundError): + discover_tests_from_specifier("nonexistent_file_xyz123.py") + + +@patch("pathlib.Path.is_dir") +@patch("test_a_ble.test_discovery._find_files_matching_wildcard") +def test_discover_tests_from_specifier_with_nonexistent_directory(mock_find_files, mock_is_dir): + """Test discover_tests_from_specifier with a directory that doesn't exist.""" + # Configure mocks + mock_is_dir.return_value = False + # Configure _find_files_matching_wildcard to return empty list (no files found) + mock_find_files.return_value = [] + + # Test with a specific directory pattern + # It should raise a specialized NoTestFilesFoundError + with pytest.raises(NoTestFilesFoundError): + discover_tests_from_specifier("nonexistent_dir_xyz123/") + + +def test_discover_tests_with_empty_directory(tmp_path): + """Test discover_tests_from_specifier with an empty directory.""" + # Create an empty directory + empty_dir = tmp_path / "empty_dir" + empty_dir.mkdir() + + # Test that it raises a specialized NoTestFilesFoundError + with pytest.raises(NoTestFilesFoundError): + discover_tests_from_specifier(str(empty_dir)) + + +@patch("importlib.util.spec_from_file_location") +def test_import_package_spec_failure(mock_spec_from_file, test_runner, tmp_path): + """Test that _import_package raises an ImportError when spec_from_file_location returns None.""" + # Setup + mock_spec_from_file.return_value = None + + # Create a package directory structure + package_dir = tmp_path / "spec_fail_package" + package_dir.mkdir() + init_file = package_dir / "__init__.py" + init_file.write_text("") + + # Test that it raises an ImportError with any message + with pytest.raises(ImportError): + _import_package(package_dir) + + +@patch("test_a_ble.test_discovery._find_tests_in_file") +def test_find_tests_in_file_with_wildcard(mock_find_tests_in_file): + """Test _find_tests_in_file with a wildcard.""" + # Import the function since it's not exported + from test_a_ble.test_discovery import _find_tests_in_file + + # Setup mock to return test data + mock_find_tests_in_file.return_value = [("test1", lambda: None), ("test2", lambda: None)] + + # Test with wildcard + result = _find_tests_in_file(None, Path("/test/dir"), "test_file.py", "*test*") + + # Should filter results based on wildcard + assert result == mock_find_tests_in_file.return_value + + # Check function was called with correct arguments + mock_find_tests_in_file.assert_called_once() + + +@patch("test_a_ble.test_discovery._find_files_matching_wildcard") +def test_discover_tests_from_specifier_with_wildcard(mock_find_files): + """Test discover_tests_from_specifier with a wildcard.""" + # Setup mock to return a list of files + mock_find_files.return_value = ["test_file1.py", "test_file2.py"] + + # Test with wildcard + with patch("test_a_ble.test_discovery._find_tests_in_file") as mock_find_tests: + # Configure the inner mock to return some test data + mock_find_tests.return_value = [("test1", lambda: None)] + + # Call function with wildcard + result = discover_tests_from_specifier("test_*") + + # Should return results from all matched files + assert len(result) == 2 # Two module entries from the two files + assert result[0][0] == "test_file1" # First module name + assert result[1][0] == "test_file2" # Second module name + + +@patch("test_a_ble.test_discovery._find_files_matching_wildcard") +def test_discover_tests_from_specifier_handles_error_paths(mock_find_files): + """Test error handling in discover_tests_from_specifier function.""" + # Mock to return no test files found - should cause NoTestFilesFoundError + mock_find_files.return_value = [] + + # Should raise a specialized NoTestFilesFoundError + with pytest.raises(NoTestFilesFoundError): + discover_tests_from_specifier("nonexistent_pattern") diff --git a/tests/test_test_runner.py b/tests/test_test_runner.py index 220f6b9..c83cb3d 100644 --- a/tests/test_test_runner.py +++ b/tests/test_test_runner.py @@ -1,6 +1,6 @@ """Tests for the TestRunner class.""" -import os +from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest # type: ignore @@ -9,7 +9,7 @@ from test_a_ble.test_context import TestContext, TestStatus from test_a_ble.test_runner import TestRunner -TEST_PACKAGE_DIR = os.path.join(os.path.dirname(__file__), "test_discovery_test_package") +TEST_PACKAGE_DIR = Path(__file__).parent / "test_discovery_test_package" @pytest.fixture @@ -33,140 +33,6 @@ def test_init(test_runner, mock_ble_manager): assert isinstance(test_runner.test_context, TestContext) -def test_is_package(test_runner, tmp_path): - """Test the _is_package method.""" - # Create a directory that is a package - package_dir = tmp_path / "package" - package_dir.mkdir() - init_file = package_dir / "__init__.py" - init_file.write_text("") - - # Create a directory that is not a package - not_package_dir = tmp_path / "not_package" - not_package_dir.mkdir() - - # Test - assert test_runner._is_package(str(package_dir)) is True - assert test_runner._is_package(str(not_package_dir)) is False - - -@patch("importlib.util.spec_from_file_location") -@patch("importlib.util.module_from_spec") -@patch("os.path.exists") -def test_import_package_with_base_package( - mock_exists, - mock_module_from_spec, - mock_spec_from_file, - test_runner, - tmp_path, -): - """Test the _import_package method with a base package.""" - # Setup - mock_exists.return_value = True - - # Create a mock spec and module - mock_spec = MagicMock() - mock_spec.loader = MagicMock() - mock_spec_from_file.return_value = mock_spec - - mock_module = MagicMock() - mock_module_from_spec.return_value = mock_module - - # Create a package directory structure - package_dir = tmp_path / "base_package" / "my_test_package" - package_dir.mkdir(parents=True) - init_file = package_dir / "__init__.py" - init_file.write_text("") - - # Pre-construct the expected path string - expected_init_path = str(package_dir / "__init__.py") - - # Test with base package - with patch.dict("sys.modules", {}, clear=True): - result = test_runner._import_package(str(package_dir), "base.package") - assert result == "base.package.my_test_package" - mock_exists.assert_called_with(expected_init_path) - mock_spec_from_file.assert_called_with("base.package.my_test_package", expected_init_path) - - -@patch("importlib.util.spec_from_file_location") -@patch("importlib.util.module_from_spec") -@patch("os.path.exists") -def test_import_package_without_base_package( - mock_exists, - mock_module_from_spec, - mock_spec_from_file, - test_runner, - tmp_path, -): - """Test the _import_package method without a base package.""" - # Setup - mock_exists.return_value = True - - # Create a mock spec and module - mock_spec = MagicMock() - mock_spec.loader = MagicMock() - mock_spec_from_file.return_value = mock_spec - - mock_module = MagicMock() - mock_module_from_spec.return_value = mock_module - - # Create a package directory structure - package_dir = tmp_path / "base_package" / "my_test_package" - package_dir.mkdir(parents=True) - init_file = package_dir / "__init__.py" - init_file.write_text("") - - # Pre-construct the expected path string - expected_init_path = str(package_dir / "__init__.py") - - # Test without base package - with patch.dict("sys.modules", {}, clear=True): - result = test_runner._import_package(str(package_dir)) - assert result == "my_test_package" - mock_exists.assert_called_with(expected_init_path) - mock_spec_from_file.assert_called_with("my_test_package", expected_init_path) - - -@patch("os.path.isdir") -@patch("os.path.exists") -def test_find_and_import_nearest_package_when_package_found(mock_exists, mock_isdir, test_runner, tmp_path): - """Test the _find_and_import_nearest_package method when a package is found.""" - # Setup - mock_isdir.return_value = True - - # Create a package directory structure - package_dir = tmp_path / "path" / "to" / "test" / "package" - package_dir.mkdir(parents=True) - - # Configure mock to indicate a package is found - def exists_side_effect(path): - return "__init__.py" in path - - mock_exists.side_effect = exists_side_effect - - # Test when a package is found - with patch.object(test_runner, "_import_package") as mock_import: - mock_import.return_value = "package" # Just the package name, not the full import path - result = test_runner._find_and_import_nearest_package(str(package_dir)) - assert result == ("package", str(package_dir)) - - -@patch("os.path.isdir") -@patch("os.path.exists") -def test_find_and_import_nearest_package_when_no_package_found(mock_exists, mock_isdir, test_runner, tmp_path): - """Test the _find_and_import_nearest_package method when no package is found.""" - # Setup - mock_isdir.return_value = True - - # Configure mock to indicate no package is found - mock_exists.side_effect = lambda _path: False - - # Test when no package is found - result = test_runner._find_and_import_nearest_package(str(tmp_path / "path" / "to" / "nowhere")) - assert result is None - - @pytest.mark.asyncio @patch("inspect.getmembers") async def test_run_test_function(mock_getmembers, test_runner): From ae17e3aea73b10d0a2909cad58295f2fd963f385 Mon Sep 17 00:00:00 2001 From: Nick Brook Date: Wed, 19 Mar 2025 10:31:18 +0000 Subject: [PATCH 3/9] Formatting fixes Signed-off-by: Nick Brook --- pyproject.toml | 1 + test_a_ble/test_discovery.py | 89 +++++++++++++++++++++++++----------- 2 files changed, 63 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 052bcee..cfb2286 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,7 @@ select = [ ignore = [ "T201", # ignore print usage "TRY003", # ignore long messages when raising exceptions + "COM812", # ignore trailing commas as it conflicts with the ruff formatter # for now, ignore complexity warnings "C901", diff --git a/test_a_ble/test_discovery.py b/test_a_ble/test_discovery.py index 135f3a7..390481e 100644 --- a/test_a_ble/test_discovery.py +++ b/test_a_ble/test_discovery.py @@ -84,7 +84,8 @@ def _import_package(package_path: Path, base_package: str = "") -> str: # Import the package spec = importlib.util.spec_from_file_location(full_package_name, init_path) if not spec or not spec.loader: - raise ImportError(f"Failed to load module spec for {init_path}") + # Use a function to abstract the raise + _raise_import_error(f"Failed to load module spec for {init_path}") module = importlib.util.module_from_spec(spec) sys.modules[full_package_name] = module @@ -98,6 +99,18 @@ def _import_package(package_path: Path, base_package: str = "") -> str: return full_package_name +def _raise_import_error(message: str) -> None: + """Raise an ImportError with the given message. + + Args: + message: Error message + + Raises: + ImportError: Always raised with the provided message + """ + raise ImportError(message) + + def find_and_import_nearest_package(path: Path) -> tuple[str, Path] | None: """Find the nearest package in the given path and import it. @@ -182,14 +195,48 @@ def _find_files_matching_wildcard(test_dir: Path, test_file_wildcard: str | None """ if not test_dir.is_dir(): return [] + # list files in test_dir that match the wildcard files = [] - for file in os.listdir(test_dir): - if file.endswith(".py") and _check_wildcard_match(test_file_wildcard, file): - files.append(file) + for file_path in test_dir.iterdir(): + if ( + file_path.is_file() + and file_path.name.endswith(".py") + and _check_wildcard_match(test_file_wildcard, file_path.name) + ): + files.append(file_path.name) return files +def _import_module_from_file(import_name: str, file_path: Path) -> Any: + """Import a module from a file path. + + Args: + import_name: Name to use for the imported module + file_path: Path to the file to import + + Returns: + The imported module + + Raises: + ImportError: If the module cannot be imported + """ + spec = importlib.util.spec_from_file_location(import_name, file_path) + + if not spec or not spec.loader: + raise ImportError(f"Failed to load module spec for {file_path}") + + module = importlib.util.module_from_spec(spec) + # Add the module to sys.modules to allow relative imports + sys.modules[import_name] = module + + # Execute the module + spec.loader.exec_module(module) + logger.debug(f"Imported {import_name} using spec_from_file_location") + + return module + + def _find_tests_in_module( package_dir: Path | None, import_name: str, @@ -210,32 +257,20 @@ def _find_tests_in_module( List of tuples (test_name, test_item) where test_item is a test function or (class, method) tuple """ file_path = test_dir / test_file + + # Import the module try: - # Try to import the module using importlib.import_module first - try: - if package_dir is not None: - # If we have a module, try to use standard import + if package_dir is not None: + # Standard package import + try: module = importlib.import_module(import_name) logger.debug(f"Imported {import_name} using import_module") - else: - # No module structure, use direct file import - raise ImportError("Not in a package, using spec_from_file_location") - - except ImportError: - # Fallback to the file-based import method - spec = importlib.util.spec_from_file_location(import_name, file_path) - - if not spec or not spec.loader: - raise ImportError(f"Failed to load module spec for {file_path}") - - module = importlib.util.module_from_spec(spec) - # Add the module to sys.modules to allow relative imports - sys.modules[import_name] = module - - # Execute the module - spec.loader.exec_module(module) - logger.debug(f"Imported {import_name} using spec_from_file_location") - + except ImportError: + # Fall back to file-based import + module = _import_module_from_file(import_name, file_path) + else: + # Direct file import (no package) + module = _import_module_from_file(import_name, file_path) except ImportError: logger.exception(f"Import error loading module {import_name}") logger.info(f"File path: {file_path}") From 1deecebe1238d2b700bcfaeecc801c06d0e13fa1 Mon Sep 17 00:00:00 2001 From: Nick Brook Date: Wed, 19 Mar 2025 13:55:28 +0000 Subject: [PATCH 4/9] Fix example Signed-off-by: Nick Brook --- examples/nordic_blinky/tests/test_blinky.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/nordic_blinky/tests/test_blinky.py b/examples/nordic_blinky/tests/test_blinky.py index c0c3d5f..8efb269 100644 --- a/examples/nordic_blinky/tests/test_blinky.py +++ b/examples/nordic_blinky/tests/test_blinky.py @@ -6,7 +6,7 @@ import asyncio -from config import ( +from nordic_blinky.config import ( BUTTON_PRESSED, BUTTON_RELEASED, CHAR_BUTTON, From 82960d81fc46d2951843eff1731f388cbae8ec5d Mon Sep 17 00:00:00 2001 From: Nick Brook Date: Wed, 19 Mar 2025 14:30:56 +0000 Subject: [PATCH 5/9] Update ruff pre commit plugin Signed-off-by: Nick Brook --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5880393..c49582d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ repos: - id: check-added-large-files - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.0 + rev: v0.11.0 hooks: - id: ruff args: [--fix] From 44aa72137fc7a05d519c2835aa313a84a326619d Mon Sep 17 00:00:00 2001 From: Nick Brook Date: Wed, 19 Mar 2025 15:05:04 +0000 Subject: [PATCH 6/9] Mypy fixes Signed-off-by: Nick Brook --- .pre-commit-config.yaml | 16 ++++++++++++++++ test_a_ble/test_discovery.py | 18 +++--------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c49582d..7067cb3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,3 +13,19 @@ repos: - id: ruff args: [--fix] - id: ruff-format + +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.15.0 + hooks: + - id: mypy + exclude: ^(examples) + args: [ + --config-file, pyproject.toml, + + ] + additional_dependencies: [ + bleak==0.22.3, + rich==13.9.4, + packaging, + prompt_toolkit>=3.0.0, + ] diff --git a/test_a_ble/test_discovery.py b/test_a_ble/test_discovery.py index 390481e..3cac3f8 100644 --- a/test_a_ble/test_discovery.py +++ b/test_a_ble/test_discovery.py @@ -83,9 +83,9 @@ def _import_package(package_path: Path, base_package: str = "") -> str: try: # Import the package spec = importlib.util.spec_from_file_location(full_package_name, init_path) - if not spec or not spec.loader: + if spec is None or spec.loader is None: # Use a function to abstract the raise - _raise_import_error(f"Failed to load module spec for {init_path}") + raise ImportError(f"Failed to load module spec for {init_path}") # noqa: TRY301 module = importlib.util.module_from_spec(spec) sys.modules[full_package_name] = module @@ -99,18 +99,6 @@ def _import_package(package_path: Path, base_package: str = "") -> str: return full_package_name -def _raise_import_error(message: str) -> None: - """Raise an ImportError with the given message. - - Args: - message: Error message - - Raises: - ImportError: Always raised with the provided message - """ - raise ImportError(message) - - def find_and_import_nearest_package(path: Path) -> tuple[str, Path] | None: """Find the nearest package in the given path and import it. @@ -223,7 +211,7 @@ def _import_module_from_file(import_name: str, file_path: Path) -> Any: """ spec = importlib.util.spec_from_file_location(import_name, file_path) - if not spec or not spec.loader: + if spec is None or spec.loader is None: raise ImportError(f"Failed to load module spec for {file_path}") module = importlib.util.module_from_spec(spec) From 8422f1f417abaf6c4b171a8d114262abe5eb5031 Mon Sep 17 00:00:00 2001 From: Nick Brook Date: Wed, 19 Mar 2025 15:05:15 +0000 Subject: [PATCH 7/9] Added test coverage target Signed-off-by: Nick Brook --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index ff9675e..a3b0a6d 100644 --- a/Makefile +++ b/Makefile @@ -83,6 +83,9 @@ pre-commit: ## run pre-commit checks test: ## run tests quickly with the default Python $(PY_CMD_PREFIX) pytest +test-coverage: ## run tests with coverage + $(PY_CMD_PREFIX) pytest --cov=test_a_ble + check: ## run all checks $(PY_CMD_PREFIX) tox From a0eab83f3cb30e2658226e3576d80d2259e100cf Mon Sep 17 00:00:00 2001 From: Nick Brook Date: Wed, 19 Mar 2025 15:05:36 +0000 Subject: [PATCH 8/9] Removed redundant file Signed-off-by: Nick Brook --- examples/nordic_blinky/verify_fixes.py | 169 ------------------------- 1 file changed, 169 deletions(-) delete mode 100644 examples/nordic_blinky/verify_fixes.py diff --git a/examples/nordic_blinky/verify_fixes.py b/examples/nordic_blinky/verify_fixes.py deleted file mode 100644 index 187f4be..0000000 --- a/examples/nordic_blinky/verify_fixes.py +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env python3 -"""Verification script for BLE test framework fixes. - -This script validates the fixes made to the notification handling, -LED control, and user prompts in the BLE test framework. - -Usage: - python verify_fixes.py [--address=] -""" - -import argparse -import asyncio -import logging - -from config import ( - BUTTON_PRESSED, - BUTTON_RELEASED, - CHAR_BUTTON, - CHAR_LED, - LED_OFF, - LED_ON, -) - -from test_a_ble import BLEManager, TestContext, setup_logging - -logger = logging.getLogger(__name__) - - -async def verify_led_control(ble_manager, test_context): - """Test LED control with improved error handling and verification.""" - test_context.start_test("LED Control Verification") - - # Test LED ON - logger.info("Testing LED ON state") - test_context.log("Setting LED to ON state") - await ble_manager.write_characteristic(CHAR_LED, LED_ON) - await asyncio.sleep(0.5) - - # Verify LED state by reading it back - try: - led_state = await ble_manager.read_characteristic(CHAR_LED) - logger.info(f"Read LED state after setting ON: {led_state.hex()}") - if led_state == LED_ON: - test_context.log("LED ON verified by reading characteristic") - else: - test_context.log(f"LED state mismatch - expected {LED_ON.hex()}, got {led_state.hex()}") - except Exception: - logger.exception("Could not read LED state") - - # Ask user to verify - response = test_context.prompt_user("Is the LED ON? (y/n)") - if response.lower() not in ["y", "yes"]: - logger.warning("User reported LED did not turn on") - - # Test LED OFF - logger.info("Testing LED OFF state") - test_context.log("Setting LED to OFF state") - await ble_manager.write_characteristic(CHAR_LED, LED_OFF) - await asyncio.sleep(0.5) - - # Verify LED state by reading it back - try: - led_state = await ble_manager.read_characteristic(CHAR_LED) - logger.info(f"Read LED state after setting OFF: {led_state.hex()}") - if led_state == LED_OFF: - test_context.log("LED OFF verified by reading characteristic") - else: - test_context.log(f"LED state mismatch - expected {LED_OFF.hex()}, got {led_state.hex()}") - except Exception: - logger.exception("Could not read LED state") - - # Ask user to verify - response = test_context.prompt_user("Is the LED OFF? (y/n)") - if response.lower() not in ["y", "yes"]: - logger.warning("User reported LED did not turn off") - - return test_context.end_test("pass", "LED control verification complete") - - -async def verify_notification_handling(_ble_manager: BLEManager, test_context: TestContext): - """Test improved notification handling with user input option.""" - test_context.start_test("Notification Handling Verification") - - # Test the new helper method - logger.info("Testing new notification handling") - try: - # Use the new method name - result = await test_context.wait_for_notification_interactive( - characteristic_uuid=CHAR_BUTTON, - prompt_message="Press the button on the device to test notification handling.", - timeout=15.0, - log_level="INFO", - ) - - logger.info(f"Notification result: {result}") - - test_context.log(f"Successfully received notification: {result['value'].hex()}") - if result["value"] == BUTTON_PRESSED: - test_context.log("Detected button press event") - elif result["value"] == BUTTON_RELEASED: - test_context.log("Detected button release event") - - except Exception: - # Only catch to ensure we end the test properly - logger.exception("Notification handling") - - return test_context.end_test("pass", "Notification handling verification complete") - - -async def main(): - """Execute the main function.""" - parser = argparse.ArgumentParser(description="BLE test framework verification script") - parser.add_argument("--address", "-a", help="BLE device address") - parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging") - args = parser.parse_args() - - # Setup logging - setup_logging(verbose=args.verbose) - - # Create BLE manager and test context - ble_manager = BLEManager() - test_context = TestContext(ble_manager) - - # Connect to device - if args.address: - logger.info(f"Connecting to device at {args.address}") - connected = await ble_manager.connect_to_device(args.address) - else: - logger.info("Discovering devices...") - devices = await ble_manager.discover_devices(timeout=5.0) - if not devices: - logger.error("No devices found") - return - - logger.info(f"Found {len(devices)} devices:") - for i, device in enumerate(devices): - logger.info(f"{i + 1}: {device.name or 'Unknown'} ({device.address})") - - device_idx = int(input("Enter device number to connect to: ")) - 1 - if 0 <= device_idx < len(devices): - connected = await ble_manager.connect_to_device(devices[device_idx]) - else: - logger.error("Invalid device selection") - return - - if not connected: - logger.error("Failed to connect to device") - return - - try: - # Run verification tests - logger.info("Starting verification tests") - - # Verify LED control - await verify_led_control(ble_manager, test_context) - - # Verify notification handling - await verify_notification_handling(ble_manager, test_context) - - logger.info("Verification tests completed successfully") - - finally: - # Disconnect from device - logger.info("Disconnecting from device") - await ble_manager.disconnect() - - -if __name__ == "__main__": - asyncio.run(main()) From 0ccea87d8a5977c59a50ae8c0d965bafe5cd80b5 Mon Sep 17 00:00:00 2001 From: Nick Brook Date: Wed, 19 Mar 2025 15:21:34 +0000 Subject: [PATCH 9/9] Fixes for testing on linux Signed-off-by: Nick Brook --- tests/test_ble_manager.py | 32 +++++++++++++++++++++++++------- tests/test_test_discovery.py | 24 ++++++++++++++++-------- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/tests/test_ble_manager.py b/tests/test_ble_manager.py index 5e4bfb0..6e02988 100644 --- a/tests/test_ble_manager.py +++ b/tests/test_ble_manager.py @@ -76,6 +76,10 @@ async def test_connect_to_device(): @pytest.mark.asyncio async def test_connect_to_device_not_found(): """Test connecting to a device that's not in discovered devices.""" + import sys + + from bleak.backends.device import BLEDevice + from test_a_ble.ble_manager import BLEManager # Create manager instance @@ -86,13 +90,27 @@ async def test_connect_to_device_not_found(): with patch.object(manager, "discover_devices", new_callable=AsyncMock) as mock_discover: mock_discover.return_value = [] - # Call connect_to_device with a non-existent address - result = await manager.connect_to_device("00:11:22:33:44:55") - - # Assert - assert result is False - assert manager.device is None - assert manager.connected is False + # Create platform-specific device details + if sys.platform == "linux": + details = {"path": "/org/bluez/hci0/dev_00_11_22_33_44_55"} + else: + details = {} + + # Mock BLEDevice creation + with patch("test_a_ble.ble_manager.BLEDevice") as mock_ble_device: + mock_device = MagicMock(spec=BLEDevice) + mock_device.address = "00:11:22:33:44:55" + mock_device.name = None + mock_device.details = details + mock_ble_device.return_value = mock_device + + # Call connect_to_device with a non-existent address + result = await manager.connect_to_device("00:11:22:33:44:55") + + # Assert + assert result is False + assert manager.device is None + assert manager.connected is False @pytest.mark.asyncio diff --git a/tests/test_test_discovery.py b/tests/test_test_discovery.py index 824101e..55c7fd0 100644 --- a/tests/test_test_discovery.py +++ b/tests/test_test_discovery.py @@ -288,10 +288,14 @@ def test_import_package_with_base_package( expected_init_path = package_dir / "__init__.py" # Test with base package - with patch.dict("sys.modules", {}, clear=True): - result = _import_package(package_dir, "base.package") - assert result == "base.package.my_test_package" - mock_spec_from_file.assert_called_with("base.package.my_test_package", expected_init_path) + package_name = "base.package.my_test_package" + if package_name in sys.modules: + del sys.modules[package_name] + + result = _import_package(package_dir, "base.package") + assert result == package_name + assert package_name in sys.modules + mock_spec_from_file.assert_called_with(package_name, expected_init_path) @patch("importlib.util.spec_from_file_location") @@ -323,10 +327,14 @@ def test_import_package_without_base_package( expected_init_path = package_dir / "__init__.py" # Test without base package - with patch.dict("sys.modules", {}, clear=True): - result = _import_package(package_dir) - assert result == "my_test_package" - mock_spec_from_file.assert_called_with("my_test_package", expected_init_path) + package_name = "my_test_package" + if package_name in sys.modules: + del sys.modules[package_name] + + result = _import_package(package_dir) + assert result == package_name + assert package_name in sys.modules + mock_spec_from_file.assert_called_with(package_name, expected_init_path) @patch("pathlib.Path")