diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 4ab33c74..c48d92cb 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -1,6 +1,6 @@ # GitHub Actions Workflows -This directory contains GitHub Actions workflows for automated testing and code quality checks. +This directory contains GitHub Actions workflows for automated testing, code quality checks, and releases. ## Workflows @@ -10,8 +10,8 @@ This directory contains GitHub Actions workflows for automated testing and code - **Matrix**: Python 3.10, 3.12 - **Purpose**: Run comprehensive test suite with coverage reporting - **Features**: - - Automatic git submodule initialization for `bluetooth_sig` dependency - - Test execution with pytest and coverage reporting (76% coverage) + - Automatic git submodule initialisation for `bluetooth_sig` dependency + - Test execution with pytest and coverage reporting (85% threshold) - Coverage upload to Codecov for Python 3.12 runs - Uses project configuration from `pyproject.toml` @@ -23,9 +23,18 @@ This directory contains GitHub Actions workflows for automated testing and code - **Tools**: - **ruff**: Formatting and linting - **mypy**: strict for production code, lenient for tests/examples - - **Environment Setup**: All tools run via `python -m` to ensure proper configuration loading +### Release (`release.yml`) + +- **Trigger**: Push of a `v*.*.*` tag (e.g. `v0.1.0`) +- **Purpose**: Build, publish to PyPI, and create a GitHub Release +- **Jobs**: + 1. **build** — builds sdist + wheel using `hatchling`, verifies with `twine check` + 2. **publish-pypi** — publishes to PyPI via trusted publisher (OIDC). Requires the `pypi` GitHub environment. + 3. **github-release** — generates release notes with `git-cliff` and creates a GitHub Release with the distribution artefacts. +- **Prerequisites**: The repository must have a `pypi` environment configured with PyPI trusted publisher credentials. + ## Local Development To run the same checks locally: @@ -34,17 +43,18 @@ To run the same checks locally: # Install development dependencies pip install -e ".[dev]" -# Initialize git submodules (required for UUID registry) +# Initialise git submodules (required for UUID registry) git submodule update --init --recursive # Run tests with coverage -python -m pytest tests/ --cov=src/ble_gatt_device --cov-report=term-missing +python -m pytest tests/ --cov=src/bluetooth_sig --cov-report=term-missing + +# Run linting +python -m ruff check src/ tests/ +python -m ruff format --check src/ tests/ -# Run linting tools (use python -m for proper configuration loading) -python -m flake8 src/ tests/ --count --statistics -python -m black --check --diff src/ tests/ -python -m isort --check-only --diff src/ tests/ -python -m pylint src/ble_gatt_device/ --exit-zero --score y +# Run type checking +python -m mypy src/bluetooth_sig/ ``` ## Environment Setup Requirements @@ -54,21 +64,18 @@ python -m pylint src/ble_gatt_device/ --exit-zero --score y When testing locally or in agent environments, ensure: 1. **Python 3.11+** is available -1. **Git submodules** are initialized: `git submodule update --init --recursive` +1. **Git submodules** are initialised: `git submodule update --init --recursive` 1. **Package installation** in development mode: `pip install -e ".[dev]"` 1. **Tool execution** via Python modules: Use `python -m tool_name` instead of direct commands -1. **Configuration loading**: flake8-pyproject allows flake8 to read from `pyproject.toml` ### Key Environment Dependencies - Git submodule `bluetooth_sig` must be present for UUID registry functionality -- All linting tools should be run via `python -m` to ensure proper configuration loading -- Black handles most formatting that would trigger flake8 style errors +- All linting/formatting is handled by `ruff` — configured in `pyproject.toml` ## Notes -- All tool configurations are defined in `pyproject.toml` (no separate `.flake8` file) -- Workflows use `--exit-zero` for pylint to prevent CI failures on minor issues -- Coverage reporting is optional and won't fail the build -- Git submodules are automatically initialized for the Bluetooth SIG UUID registry dependency +- All tool configurations are defined in `pyproject.toml` +- Coverage threshold is 85% (`--cov-fail-under=85`) +- Git submodules are automatically initialised for the Bluetooth SIG UUID registry dependency - Use `python -m` prefix for all tools to ensure proper package and configuration loading diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 3141a97b..4f3b8d42 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -40,7 +40,7 @@ jobs: cache-dependency-path: 'pyproject.toml' - name: Cache system dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: /var/cache/apt key: ${{ runner.os }}-apt-copilot-${{ hashFiles('scripts/install-deps.sh') }} @@ -58,6 +58,8 @@ jobs: python -m pip install --upgrade pip # Install dev, test and examples extras so local setup has BLE example libraries pip install -e .[dev,test,examples] + env: + SETUPTOOLS_SCM_PRETEND_VERSION: "0.0.0.dev0" - name: Verify environment run: | @@ -66,5 +68,4 @@ jobs: - name: Read the instructions run: | cat .github/copilot-instructions.md - cat .github/copilot-code-review.md diff --git a/.github/workflows/lint-check.yml b/.github/workflows/lint-check.yml index 2793ec44..6a696911 100644 --- a/.github/workflows/lint-check.yml +++ b/.github/workflows/lint-check.yml @@ -32,7 +32,7 @@ jobs: cache-dependency-path: 'pyproject.toml' - name: 'Cache system dependencies' - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: /var/cache/apt key: ${{ runner.os }}-apt-lint-${{ hashFiles('scripts/install-deps.sh') }} @@ -73,7 +73,7 @@ jobs: cache-dependency-path: 'pyproject.toml' - name: 'Cache system dependencies' - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: /var/cache/apt key: ${{ runner.os }}-apt-lint-${{ hashFiles('scripts/install-deps.sh') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 129eb9e1..ecebe4ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,9 +39,61 @@ jobs: name: dist path: dist/ + validate: + name: Validate packages + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: true + matrix: + artifact: [wheel, sdist] + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: | + tests/ + pyproject.toml + .gitignore + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - uses: actions/download-artifact@v7 + with: + name: dist + path: dist/ + + - name: Install package (${{ matrix.artifact }}) with test extras + run: | + if [ "${{ matrix.artifact }}" = "wheel" ]; then + pip install "$(ls dist/*.whl)[test-core]" + else + pip install "$(ls dist/*.tar.gz)[test-core]" + fi + + - name: Verify package metadata + run: | + python -c " + import bluetooth_sig + v = getattr(bluetooth_sig, '__version__', None) + print(f'Version: {v}') + assert v, 'Missing __version__' + " + + - name: Run packaging smoke tests against installed ${{ matrix.artifact }} + run: | + python -m pytest tests/ \ + -m packaging \ + --ignore=tests/docs \ + --override-ini="pythonpath=" \ + -v + publish-pypi: name: Publish to PyPI - needs: publish-testpypi + needs: validate runs-on: ubuntu-latest timeout-minutes: 10 environment: pypi @@ -63,16 +115,6 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Generate release notes - run: | - pip install "git-cliff~=2.7" - git-cliff --latest --strip header --output RELEASE_NOTES.md - cat RELEASE_NOTES.md - - uses: actions/download-artifact@v7 with: name: dist @@ -83,6 +125,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release create "${{ github.ref_name }}" \ + --repo "${{ github.repository }}" \ --title "${{ github.ref_name }}" \ - --notes-file RELEASE_NOTES.md \ + --generate-notes \ dist/* diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml index 4f9d1866..50d0b831 100644 --- a/.github/workflows/test-coverage.yml +++ b/.github/workflows/test-coverage.yml @@ -145,14 +145,14 @@ jobs: # don't require a token when not pushing pages - name: Upload benchmark baseline cache - uses: actions/cache@v4 + uses: actions/cache@v5 if: github.event_name == 'push' && github.ref == 'refs/heads/main' with: path: ./cache key: ${{ runner.os }}-benchmark - name: Download previous benchmark data - uses: actions/cache@v4 + uses: actions/cache@v5 if: github.event_name == 'pull_request' with: path: ./cache @@ -221,7 +221,7 @@ jobs: summary-always: true - name: Download previous benchmark history - uses: dawidd6/action-download-artifact@v12 + uses: dawidd6/action-download-artifact@v16 if: github.event_name == 'push' && github.ref == 'refs/heads/main' continue-on-error: true with: @@ -302,7 +302,7 @@ jobs: continue-on-error: true - name: Download benchmark history - uses: dawidd6/action-download-artifact@v12 + uses: dawidd6/action-download-artifact@v16 continue-on-error: true with: name: benchmark-history @@ -383,7 +383,7 @@ jobs: pip install -e ".[test]" - name: Cache Playwright browsers - uses: actions/cache@v4 + uses: actions/cache@v5 id: playwright-cache with: path: ~/.cache/ms-playwright diff --git a/.vscode/settings.json b/.vscode/settings.json index 6580bcb2..29dfb599 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,8 @@ "chat.tools.terminal.autoApprove": { "bluetoothctl": true, "hciconfig": true - } + }, + "python.defaultInterpreterPath": "${workspaceFolder}/../../../.venv/bin/python", + "python.venvPath": "${workspaceFolder}/../../../", + "python.analysis.extraPaths": ["${workspaceFolder}/src"] } \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index e1a1949d..000d4047 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -487,20 +487,35 @@ def run_pre_build_scripts(app: Sphinx, config: object) -> None: # ========================================================================= changelog_output = repo_root / "docs" / "source" / "community" / "changelog.md" try: - print("Generating changelog from git history via git-cliff...") - subprocess.run( - ["git-cliff", "--output", str(changelog_output)], + print("Generating changelog from git history via git-changelog...") + result = subprocess.run( + [ + "git-changelog", + "--output", + str(changelog_output), + "--convention", + "conventional", + "--provider", + "github", + "--sections", + ":all:", + "--include-all", + ], cwd=repo_root, check=True, + capture_output=True, + text=True, ) print(f"✓ Changelog generated at {changelog_output}") - except (subprocess.CalledProcessError, FileNotFoundError) as e: - print(f"Warning: git-cliff changelog generation failed: {e}") - # Write a minimal placeholder so docs build doesn't break - changelog_output.write_text( - "# Changelog\n\nChangelog generation requires git-cliff. " - "Install with `pip install git-cliff`.\n" - ) + except subprocess.CalledProcessError as e: + print(f"Error: git-changelog failed: {e}") + print(f"stdout: {e.stdout}") + print(f"stderr: {e.stderr}") + raise + except FileNotFoundError as e: + print(f"Error: git-changelog not found: {e}") + print("Install with: pip install git-changelog") + raise # ========================================================================= # Step 3: Generate architecture diagrams diff --git a/pyproject.toml b/pyproject.toml index 90de20c4..2f5e7ec5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,8 @@ bugs = "https://github.com/RonanB96/bluetooth-sig-python/issues" changelog = "https://ronanb96.github.io/bluetooth-sig-python/community/changelog.html" [project.optional-dependencies] -dev = [ +# test-core: pytest framework (shared by dev, test, and release validation) +test-core = [ "pytest~=8.4", # pytest-asyncio 0.24+ has compatibility issues with pytest-markdown-docs # Keep <0.24 constraint until pytest-markdown-docs is updated @@ -49,39 +50,36 @@ dev = [ "pytest-cov>=6.2,<8", "nest-asyncio>=1.5.0", "pytest-benchmark~=5.1", + "pytest-xdist~=3.0", +] +# ble: BLE client libraries (shared by test and examples) +ble = [ + "bleak>=0.21.0", + "bleak-retry-connector>=2.13.1,<3", + "simplepyble>=0.10.3", + "bluepy>=1.3.0", +] +dev = [ + "bluetooth-sig[test-core]", "ruff~=0.15", "pydocstyle~=6.3", "types-PyYAML~=6.0", "types-requests~=2.32", "mypy>=1.19.0", "ipdb~=0.13", - "coverage~=7.0"] + "coverage~=7.0", +] test = [ - "pytest~=8.4", - # pytest-asyncio 0.24+ has compatibility issues with pytest-markdown-docs - # Keep <0.24 constraint until pytest-markdown-docs is updated - "pytest-asyncio>=0.23.0,<0.24", - "pytest-cov>=6.2,<8", - "nest-asyncio>=1.5.0", - "pytest-benchmark~=5.1", - "pytest-xdist~=3.0", + "bluetooth-sig[test-core,ble]", "pytest-markdown-docs~=0.9", "playwright~=1.56", "pytest-playwright~=0.7", "beautifulsoup4>=4.12.0", "lxml>=4.9.0", - "bleak>=0.21.0", - "bleak-retry-connector>=2.13.1,<3", - "simplepyble>=0.10.3", - "bluepy>=1.3.0", - "requests>=2.32.0" + "requests>=2.32.0", ] examples = [ - # BLE client libraries for example integrations - "bleak>=0.21.0", - "bleak-retry-connector>=2.13.1,<3", - "simplepyble>=0.10.3", - "bluepy>=1.3.0", + "bluetooth-sig[ble]", # BLE server/peripheral library for encoding examples "bless>=0.3.0", ] @@ -92,7 +90,7 @@ docs = [ "myst-parser>=2.0.0", "sphinx-design>=0.5.0", "furo>=2024.1.29", - "git-cliff~=2.7", + "git-changelog~=2.7", "sphinxcontrib-mermaid>=0.9.0", "sphinx-copybutton>=0.5.0", "linkify-it-py>=2.0.0", @@ -108,7 +106,7 @@ docs = [ [tool.pytest.ini_options] asyncio_mode = "strict" testpaths = ["tests"] -pythonpath = ["src"] +pythonpath = ["src", "."] addopts = "-m 'not benchmark and not built_docs and not playwright'" norecursedirs = ["tests/benchmarks"] markers = [ @@ -118,6 +116,7 @@ markers = [ "code_blocks: Tests that execute code blocks extracted from markdown documentation", "playwright: Tests using Playwright for browser automation", "accessibility: Tests for documentation accessibility compliance", + "packaging: Smoke tests that validate the installed package (data files, imports, core lookups)", ] [tool.hatch.build] @@ -132,7 +131,7 @@ path = "hatch_build.py" source = "vcs" [tool.hatch.version.raw-options] -version_scheme = "post-release" +version_scheme = "no-guess-dev" local_scheme = "node-and-date" [tool.hatch.build.hooks.vcs] @@ -339,51 +338,14 @@ inherit = false # formatter when there are nested functions (Black requires blank line) add-ignore = ["D202"] -[tool.git-cliff.changelog] +[tool.git-changelog] # Auto-generated changelog from conventional commits -# https://git-cliff.org/docs/configuration -header = "# Changelog\n\nAll notable changes to this project are documented here.\nGenerated automatically from commit history.\n" -body = """ -{%- macro remote_url() -%} - https://github.com/RonanB96/bluetooth-sig-python -{%- endmacro -%} - -{% if version %}\ -## [{{ version | trim_start_matches(pat="v") }}]({{ remote_url() }}/releases/tag/{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }} -{% else %}\ -## Unreleased -{% endif %}\ -{% for group, commits in commits | group_by(attribute="group") %} -### {{ group | striptags | trim | upper_first }} -{% for commit in commits %} -- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | upper_first }}\ -{% if commit.breaking %} (**BREAKING**){% endif %}\ -{% if commit.body %}\ -\n {{ commit.body | indent(prefix=" ") }}\ -{% endif %} -{%- endfor %} -{% endfor %} -""" -trim = true - -[tool.git-cliff.git] -conventional_commits = true -filter_unconventional = false -commit_parsers = [ - { message = "^feat", group = "Features" }, - { message = "^fix", group = "Bug Fixes" }, - { message = "^doc|^docs", group = "Documentation" }, - { message = "^perf", group = "Performance" }, - { message = "^refactor", group = "Refactoring" }, - { message = "^test", group = "Testing" }, - { message = "^ci", group = "CI/CD" }, - { message = "^build", group = "Build" }, - { message = "^chore", group = "Miscellaneous" }, - { body = ".*security", group = "Security" }, -] -filter_commits = false -tag_pattern = "v[0-9].*" -sort_commits = "newest" -# D202: No blank lines allowed after function docstring - conflicts with Black -# formatter when there are nested functions (Black requires blank line) -add-ignore = ["D202"] +# https://pawamoy.github.io/git-changelog/usage/ +convention = "conventional" +output = "docs/source/community/changelog.md" +provider = "github" +template = "keepachangelog" +sections = "feat,fix,doc,docs,perf,refactor,test,ci,build,chore,revert,deps,style" +include-all = true +versioning = "semver" +zerover = true diff --git a/src/bluetooth_sig/gatt/uuid_registry.py b/src/bluetooth_sig/gatt/uuid_registry.py index f53ec709..60816718 100644 --- a/src/bluetooth_sig/gatt/uuid_registry.py +++ b/src/bluetooth_sig/gatt/uuid_registry.py @@ -383,7 +383,7 @@ def get_service_info(self, key: str | BluetoothUUID) -> ServiceInfo | None: if canonical_key in self._services: return self._services[canonical_key] except ValueError: - logger.warning("UUID normalization failed for service lookup: %s", search_key) + pass # UUID normalization failed, continue to alias lookup # Check alias index (normalized to lowercase) alias_key = self._service_aliases.get(search_key.lower()) @@ -411,7 +411,7 @@ def get_characteristic_info(self, identifier: str | BluetoothUUID) -> Characteri if canonical_key in self._characteristics: return self._characteristics[canonical_key] except ValueError: - logger.warning("UUID normalization failed for characteristic lookup: %s", search_key) + pass # UUID normalization failed, continue to alias lookup # Check alias index (normalized to lowercase) alias_key = self._characteristic_aliases.get(search_key.lower()) @@ -439,7 +439,7 @@ def get_descriptor_info(self, identifier: str | BluetoothUUID) -> DescriptorInfo if canonical_key in self._descriptors: return self._descriptors[canonical_key] except ValueError: - logger.warning("UUID normalization failed for descriptor lookup: %s", search_key) + pass # UUID normalization failed, continue to alias lookup # Check alias index (normalized to lowercase) alias_key = self._descriptor_aliases.get(search_key.lower()) diff --git a/src/bluetooth_sig/registry/base.py b/src/bluetooth_sig/registry/base.py index 907833fe..5d2a05fd 100644 --- a/src/bluetooth_sig/registry/base.py +++ b/src/bluetooth_sig/registry/base.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging import threading from abc import ABC, abstractmethod from collections.abc import Callable @@ -18,8 +17,6 @@ from bluetooth_sig.types.registry import BaseUuidInfo, generate_basic_aliases from bluetooth_sig.types.uuid import BluetoothUUID -logger = logging.getLogger(__name__) - T = TypeVar("T") E = TypeVar("E", bound=Enum) # For enum-keyed registries C = TypeVar("C") # For class types @@ -213,8 +210,7 @@ def get_info(self, identifier: str | BluetoothUUID) -> U | None: if canonical_key in self._canonical_store: return self._canonical_store[canonical_key] except ValueError: - logger.warning("UUID normalization failed for registry lookup: %s", search_key) - + pass # UUID normalization failed, continue to alias lookup # Check alias index (normalized to lowercase) alias_key = self._alias_index.get(search_key.lower()) if alias_key and alias_key in self._canonical_store: diff --git a/tests/conftest.py b/tests/conftest.py index cb8ee29f..7d05ef87 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,4 @@ -"""Pytest configuration helpers. - -Ensure repository root and `src/` are on `sys.path` so tests can import -local packages without per-test sys.path hacks. -""" +"""Pytest configuration helpers.""" from __future__ import annotations @@ -28,12 +24,6 @@ ROOT = Path(__file__).resolve().parent.parent # Export ROOT_DIR for tests that need to construct paths relative to project root ROOT_DIR = ROOT -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -SRC = ROOT / "src" -if SRC.exists() and str(SRC) not in sys.path: - sys.path.insert(0, str(SRC)) def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: diff --git a/tests/docs/conftest.py b/tests/docs/conftest.py index a2dbb6f3..c9bca0a9 100644 --- a/tests/docs/conftest.py +++ b/tests/docs/conftest.py @@ -28,8 +28,6 @@ from pathlib import Path from typing import Any -from tests.conftest import ROOT_DIR - # Skip playwright_tests folder during collection if playwright is not installed # This prevents collection errors from breaking non-playwright tests # Must use collect_ignore (not collect_ignore_glob) to ignore directories entirely @@ -44,6 +42,11 @@ import pytest +# Compute ROOT_DIR relative to this file (tests/docs/conftest.py → project root) +# Avoids importing from the `tests` package, which is not on sys.path in all +# environments (e.g. CI runners where only `pythonpath = ["src"]` is set). +ROOT_DIR = Path(__file__).resolve().parent.parent.parent + # ============================================================================ # Shared Test Constants # ============================================================================ diff --git a/tests/docs/html/test_accessibility_static.py b/tests/docs/html/test_accessibility_static.py index 020c5532..303adf7a 100644 --- a/tests/docs/html/test_accessibility_static.py +++ b/tests/docs/html/test_accessibility_static.py @@ -12,7 +12,12 @@ from pathlib import Path import pytest -from bs4 import BeautifulSoup, Tag + +# Skip this entire module if beautifulsoup4 is not installed +try: + from bs4 import BeautifulSoup, Tag +except ModuleNotFoundError: + pytest.skip("beautifulsoup4 not installed", allow_module_level=True) @pytest.mark.built_docs diff --git a/tests/docs/html/test_content_quality_static.py b/tests/docs/html/test_content_quality_static.py index c9e56b3e..27f877c1 100644 --- a/tests/docs/html/test_content_quality_static.py +++ b/tests/docs/html/test_content_quality_static.py @@ -9,7 +9,12 @@ from pathlib import Path import pytest -from bs4 import BeautifulSoup + +# Skip this entire module if beautifulsoup4 is not installed +try: + from bs4 import BeautifulSoup +except ModuleNotFoundError: + pytest.skip("beautifulsoup4 not installed", allow_module_level=True) @pytest.mark.built_docs diff --git a/tests/docs/html/test_structure_static.py b/tests/docs/html/test_structure_static.py index 15a5377f..2d6c770a 100644 --- a/tests/docs/html/test_structure_static.py +++ b/tests/docs/html/test_structure_static.py @@ -10,7 +10,12 @@ from pathlib import Path import pytest -from bs4 import BeautifulSoup + +# Skip this entire module if beautifulsoup4 is not installed +try: + from bs4 import BeautifulSoup +except ModuleNotFoundError: + pytest.skip("beautifulsoup4 not installed", allow_module_level=True) @pytest.mark.built_docs diff --git a/tests/docs/playwright_tests/test_accessibility.py b/tests/docs/playwright_tests/test_accessibility.py index c1c7bc1c..45989b16 100644 --- a/tests/docs/playwright_tests/test_accessibility.py +++ b/tests/docs/playwright_tests/test_accessibility.py @@ -36,7 +36,7 @@ def test_page_has_proper_title(page: Page, html_file: str) -> None: def test_heading_hierarchy(page: Page, html_file: str) -> None: """Test proper heading hierarchy.""" page.goto(html_file) - page.wait_for_load_state("networkidle") + page.wait_for_load_state("domcontentloaded") h1_count = page.locator("h1").count() assert 1 <= h1_count <= 2, f"Wrong h1 count on {html_file}: {h1_count}" @@ -203,7 +203,7 @@ def test_page_load_performance(page: Page, html_file: str) -> None: f"{html_file}: DOM content loaded too slow: {metrics['domContentLoaded']:.0f}ms" ) assert metrics["loadComplete"] < 2000, f"{html_file}: Page load too slow: {metrics['loadComplete']:.0f}ms" - assert metrics["domInteractive"] < 1500, f"{html_file}: DOM interactive too slow: {metrics['domInteractive']:.0f}ms" + assert metrics["domInteractive"] < 3000, f"{html_file}: DOM interactive too slow: {metrics['domInteractive']:.0f}ms" @pytest.mark.built_docs diff --git a/tests/docs/test_readme_badges.py b/tests/docs/test_readme_badges.py index 9bf290db..f214e8aa 100644 --- a/tests/docs/test_readme_badges.py +++ b/tests/docs/test_readme_badges.py @@ -7,7 +7,12 @@ from urllib.parse import urlparse import pytest -import requests + +# Skip this entire module if requests is not installed +try: + import requests +except ModuleNotFoundError: + pytest.skip("requests not installed", allow_module_level=True) def _is_trusted_domain(url: str, trusted_domains: list[str]) -> bool: diff --git a/tests/gatt/test_uuid_registry.py b/tests/gatt/test_uuid_registry.py index 2cbfea18..2ccbc8a4 100644 --- a/tests/gatt/test_uuid_registry.py +++ b/tests/gatt/test_uuid_registry.py @@ -15,9 +15,9 @@ from bluetooth_sig.gatt.services.battery_service import BatteryService from bluetooth_sig.gatt.services.environmental_sensing import EnvironmentalSensingService from bluetooth_sig.gatt.uuid_registry import UuidRegistry +from bluetooth_sig.registry.utils import find_bluetooth_sig_path from bluetooth_sig.types.gatt_services import ServiceDiscoveryData from bluetooth_sig.types.uuid import BluetoothUUID -from tests.conftest import ROOT_DIR @pytest.fixture(scope="session") @@ -123,6 +123,7 @@ def test_characteristic_uuid_lookup_parametrized( assert info.id == char_id +@pytest.mark.packaging def test_service_class_name_resolution() -> None: """Test that service classes correctly resolve their UUIDs from names.""" battery = BatteryService() @@ -135,6 +136,7 @@ def test_service_class_name_resolution() -> None: assert env.name == "Environmental Sensing", "Wrong Environmental Service name" +@pytest.mark.packaging def test_characteristic_discovery() -> None: """Test discovery and creation of characteristics from device data.""" # Use characteristic classes to get proper SIG UUIDs @@ -186,9 +188,11 @@ def test_invalid_uuid_lookup(mock_uuid_registry: UuidRegistry) -> None: assert mock_uuid_registry.get_characteristic_info("0000") is None, "Should return None for invalid characteristic" +@pytest.mark.packaging def test_yaml_file_presence() -> None: """Test that required YAML files exist.""" - base_path = ROOT_DIR / "bluetooth_sig" / "assigned_numbers" / "uuids" + base_path = find_bluetooth_sig_path() + assert base_path is not None, "Cannot locate bluetooth_sig data path (submodule or installed package)" assert (base_path / "service_uuids.yaml").exists(), "Service UUIDs YAML file missing" assert (base_path / "characteristic_uuids.yaml").exists(), "Characteristic UUIDs YAML file missing" @@ -197,7 +201,8 @@ def test_yaml_file_presence() -> None: @pytest.fixture(scope="session") def yaml_data() -> dict[str, Any]: """Load YAML data once per session for performance.""" - base_path = ROOT_DIR / "bluetooth_sig" / "assigned_numbers" / "uuids" + base_path = find_bluetooth_sig_path() + assert base_path is not None, "Cannot locate bluetooth_sig data path" # Load service data service_file = base_path / "service_uuids.yaml" @@ -212,6 +217,7 @@ def yaml_data() -> dict[str, Any]: return {"services": service_data, "characteristics": char_data} +@pytest.mark.packaging def test_direct_yaml_loading(yaml_data: dict[str, Any]) -> None: """Test direct loading and parsing of YAML files. @@ -332,6 +338,7 @@ def test_to_bytes_little_endian(self) -> None: # Test default is little-endian (BLE default) assert uuid_short.to_bytes() == uuid_short.to_bytes("little") + @pytest.mark.packaging def test_bytes_roundtrip(self) -> None: """Test that UUID can be reconstructed from bytes.""" original = BluetoothUUID("2A37") # Heart Rate Measurement diff --git a/tests/integration/test_connection_managers.py b/tests/integration/test_connection_managers.py index 52e84bf2..b4d2f568 100644 --- a/tests/integration/test_connection_managers.py +++ b/tests/integration/test_connection_managers.py @@ -2,7 +2,7 @@ """Tests for connection manager implementations. These tests verify actual behaviour of connection managers. -No skips allowed - if imports fail, the test fails. +Requires bleak, bluepy and simplepyble to be installed. """ from __future__ import annotations @@ -12,6 +12,8 @@ import pytest +pytest.importorskip("bleak", reason="bleak required for connection manager tests") + from examples.connection_managers.bleak_retry import BleakRetryClientManager from examples.connection_managers.bleak_utils import bleak_services_to_batch from examples.connection_managers.bluepy import BluePyClientManager diff --git a/tests/integration/test_examples.py b/tests/integration/test_examples.py index 2df41244..5a36beb8 100644 --- a/tests/integration/test_examples.py +++ b/tests/integration/test_examples.py @@ -11,10 +11,13 @@ import pytest -from examples.utils.data_parsing import parse_and_display_results -from examples.utils.library_detection import AVAILABLE_LIBRARIES, show_library_availability -from examples.utils.mock_data import mock_ble_data -from examples.utils.models import ReadResult +try: + from examples.utils.data_parsing import parse_and_display_results + from examples.utils.library_detection import AVAILABLE_LIBRARIES, show_library_availability + from examples.utils.mock_data import mock_ble_data + from examples.utils.models import ReadResult +except ModuleNotFoundError: + pytest.skip("examples module not available", allow_module_level=True) class TestUtilityFunctions: