diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..86d43db --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,83 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Lint with ruff + run: ruff check . + + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests + run: pytest -v + + version-check: + name: Version sync + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Check version consistency + run: | + PYPROJECT_VERSION=$(python -c " + import re + with open('pyproject.toml') as f: + match = re.search(r'^version\s*=\s*\"(.+?)\"', f.read(), re.MULTILINE) + print(match.group(1)) + ") + INIT_VERSION=$(python -c " + import re + with open('plexus/__init__.py') as f: + match = re.search(r'^__version__\s*=\s*\"(.+?)\"', f.read(), re.MULTILINE) + print(match.group(1)) + ") + echo "pyproject.toml version: $PYPROJECT_VERSION" + echo "__init__.py version: $INIT_VERSION" + if [ "$PYPROJECT_VERSION" != "$INIT_VERSION" ]; then + echo "::error::Version mismatch! pyproject.toml=$PYPROJECT_VERSION, __init__.py=$INIT_VERSION" + exit 1 + fi + echo "Versions match: $PYPROJECT_VERSION" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8972f79..948dde5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,17 +3,52 @@ name: Publish to PyPI on: release: types: [published] - workflow_dispatch: - inputs: - version: - description: 'Version to publish (leave empty for current)' - required: false jobs: + validate: + name: Validate release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Check tag matches code version + run: | + TAG="${GITHUB_REF#refs/tags/v}" + CODE_VERSION=$(python -c " + import re + with open('pyproject.toml') as f: + match = re.search(r'^version\s*=\s*\"(.+?)\"', f.read(), re.MULTILINE) + print(match.group(1)) + ") + echo "Git tag version: $TAG" + echo "Code version: $CODE_VERSION" + if [ "$TAG" != "$CODE_VERSION" ]; then + echo "::error::Tag v$TAG does not match code version $CODE_VERSION" + exit 1 + fi + + - name: Lint + run: ruff check . + + - name: Run tests + run: pytest -v + publish: + name: Publish to PyPI + needs: validate runs-on: ubuntu-latest permissions: - id-token: write # Required for trusted publishing + id-token: write steps: - uses: actions/checkout@v4 @@ -21,7 +56,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' - name: Install build tools run: | @@ -36,4 +71,3 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - # Uses trusted publishing - configure at pypi.org/manage/project/plexus-agent/settings/publishing/ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 571153a..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Test - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" - - - name: Lint with ruff - run: ruff check . - - - name: Run tests - run: pytest -v diff --git a/plexus/__init__.py b/plexus/__init__.py index 3975fbf..f9b3d3b 100644 --- a/plexus/__init__.py +++ b/plexus/__init__.py @@ -28,5 +28,5 @@ from plexus.config import load_config, save_config from plexus.typed_commands import param, CommandRegistry -__version__ = "0.5.3" +__version__ = "0.5.4" __all__ = ["Plexus", "param", "CommandRegistry", "load_config", "save_config"] diff --git a/plexus/adapters/mavlink_detect.py b/plexus/adapters/mavlink_detect.py index 1035596..1599edc 100644 --- a/plexus/adapters/mavlink_detect.py +++ b/plexus/adapters/mavlink_detect.py @@ -17,7 +17,7 @@ import os import socket from dataclasses import dataclass -from typing import List, Optional +from typing import List logger = logging.getLogger(__name__) diff --git a/plexus/cli.py b/plexus/cli.py index e8925c4..5d5fbbc 100644 --- a/plexus/cli.py +++ b/plexus/cli.py @@ -18,8 +18,6 @@ import click -logger = logging.getLogger(__name__) - from plexus import __version__ from plexus.client import Plexus, AuthenticationError, PlexusError from plexus.config import ( @@ -31,6 +29,8 @@ get_config_path, ) +logger = logging.getLogger(__name__) + # ───────────────────────────────────────────────────────────────────────────── # Console Styling @@ -294,7 +294,7 @@ def start(key: Optional[str], name: Optional[str], bus: int): try: sensor_hub, sensors = detect_sensors(bus) except PermissionError: - i2c_error = f"I2C permission denied (run: sudo usermod -aG i2c $USER)" + i2c_error = "I2C permission denied (run: sudo usermod -aG i2c $USER)" except ImportError: from plexus.deps import prompt_install if prompt_install("smbus2", extra="sensors"): @@ -443,7 +443,7 @@ def add(capabilities: tuple): plexus add can # Add CAN bus support plexus add sensors camera # Add multiple """ - from plexus.deps import is_available, DEPENDENCY_MAP + from plexus.deps import is_available import subprocess if not capabilities: @@ -593,7 +593,7 @@ def run(name: Optional[str], no_sensors: bool, no_cameras: bool, bus: int, senso try: sensor_hub, sensors = detect_sensors(bus) except PermissionError: - i2c_error = f"permission denied (run: sudo usermod -aG i2c $USER)" + i2c_error = "permission denied (run: sudo usermod -aG i2c $USER)" except ImportError: from plexus.deps import prompt_install if prompt_install("smbus2", extra="sensors"): @@ -602,7 +602,7 @@ def run(name: Optional[str], no_sensors: bool, no_cameras: bool, bus: int, senso except Exception as e: i2c_error = str(e) else: - i2c_error = f"smbus2 not installed (run: pip install plexus-agent[sensors])" + i2c_error = "smbus2 not installed (run: pip install plexus-agent[sensors])" except Exception as e: i2c_error = str(e) @@ -1587,7 +1587,7 @@ def _warn(msg: str): _pass(f"I2C bus {bus_num}: accessible") else: _fail(f"I2C bus {bus_num}: permission denied") - dim(f" Fix: sudo usermod -aG i2c $USER") + dim(" Fix: sudo usermod -aG i2c $USER") # Serial ports import glob @@ -1597,7 +1597,7 @@ def _warn(msg: str): _pass(f"{port}: accessible") else: _fail(f"{port}: permission denied") - dim(f" Fix: sudo usermod -aG dialout $USER") + dim(" Fix: sudo usermod -aG dialout $USER") # Camera video_devs = glob.glob("/dev/video*") @@ -1606,7 +1606,7 @@ def _warn(msg: str): _pass(f"{dev}: accessible") else: _fail(f"{dev}: permission denied") - dim(f" Fix: sudo usermod -aG video $USER") + dim(" Fix: sudo usermod -aG video $USER") if not any(os.path.exists(p) for p in ["/dev/i2c-0", "/dev/i2c-1"]) and not serial_ports and not video_devs: dim(" No hardware devices detected on this system") @@ -1636,7 +1636,6 @@ def _warn(msg: str): # ── Summary ────────────────────────────────────────────────────────── - total = checks_passed + checks_failed + checks_warned if checks_failed == 0: click.secho( f" {Style.CHECK} All {checks_passed} checks passed", diff --git a/plexus/client.py b/plexus/client.py index ada2873..9e2b72a 100644 --- a/plexus/client.py +++ b/plexus/client.py @@ -34,7 +34,6 @@ """ import logging -import threading import time from contextlib import contextmanager from typing import Any, Dict, List, Optional, Tuple, Union @@ -107,7 +106,7 @@ def __init__( self.source_id = source_id or get_source_id() self.timeout = timeout self.retry_config = retry_config or RetryConfig() - self.max_buffer_size = max_buffer_size + self._max_buffer_size = max_buffer_size self._session_id: Optional[str] = None self._session: Optional[requests.Session] = None @@ -123,6 +122,15 @@ def __init__( else: self._buffer: BufferBackend = MemoryBuffer(max_size=max_buffer_size) + @property + def max_buffer_size(self): + return self._max_buffer_size + + @max_buffer_size.setter + def max_buffer_size(self, value): + self._max_buffer_size = value + self._buffer._max_size = value + def _get_session(self) -> requests.Session: """Get or create a requests session for connection pooling.""" if self._session is None: diff --git a/plexus/detect.py b/plexus/detect.py index 00e0819..cefc860 100644 --- a/plexus/detect.py +++ b/plexus/detect.py @@ -13,7 +13,6 @@ import shutil import socket import subprocess -import sys from dataclasses import dataclass, field, asdict from typing import List, Optional, Tuple, Dict, Any, TYPE_CHECKING diff --git a/plexus/sensors/gps.py b/plexus/sensors/gps.py index dc22a9f..5885dab 100644 --- a/plexus/sensors/gps.py +++ b/plexus/sensors/gps.py @@ -22,7 +22,6 @@ print(f"{reading.metric}: {reading.value}") """ -import time import logging from typing import List, Optional from .base import BaseSensor, SensorReading diff --git a/plexus/sensors/magnetometer.py b/plexus/sensors/magnetometer.py index 43b2c1d..d963d9b 100644 --- a/plexus/sensors/magnetometer.py +++ b/plexus/sensors/magnetometer.py @@ -102,9 +102,12 @@ def read(self) -> List[SensorReading]: z = (data[5] << 8) | data[4] # Convert to signed - if x > 32767: x -= 65536 - if y > 32767: y -= 65536 - if z > 32767: z -= 65536 + if x > 32767: + x -= 65536 + if y > 32767: + y -= 65536 + if z > 32767: + z -= 65536 # At 8 Gauss range: 3000 LSB/Gauss, 1 Gauss = 100 µT # So LSB = 100/3000 µT ≈ 0.0333 µT @@ -205,9 +208,12 @@ def read(self) -> List[SensorReading]: y = (data[4] << 8) | data[5] # Convert to signed - if x > 32767: x -= 65536 - if y > 32767: y -= 65536 - if z > 32767: z -= 65536 + if x > 32767: + x -= 65536 + if y > 32767: + y -= 65536 + if z > 32767: + z -= 65536 # At 1.3 Gauss gain: 1090 LSB/Gauss, 1 Gauss = 100 µT scale = 100.0 / 1090.0 diff --git a/plexus/sensors/sht3x.py b/plexus/sensors/sht3x.py index 6501213..5747e1e 100644 --- a/plexus/sensors/sht3x.py +++ b/plexus/sensors/sht3x.py @@ -74,7 +74,7 @@ def __init__( def setup(self) -> None: try: - from smbus2 import SMBus, i2c_msg + from smbus2 import SMBus except ImportError: raise ImportError( "smbus2 is required for SHT3x. Install with: pip install smbus2" diff --git a/plexus/tui.py b/plexus/tui.py index a269b73..0cc90de 100644 --- a/plexus/tui.py +++ b/plexus/tui.py @@ -12,7 +12,6 @@ import time import threading -from collections import defaultdict from dataclasses import dataclass, field from typing import Dict, List, Optional, Callable @@ -22,9 +21,6 @@ from rich.console import Console from rich.live import Live from rich.table import Table - from rich.panel import Panel - from rich.layout import Layout - from rich.text import Text _rich_available = True except ImportError: pass diff --git a/pyproject.toml b/pyproject.toml index 8a67ace..7532f91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "plexus-agent" -version = "0.5.3" +version = "0.5.4" description = "Send sensor data to Plexus in one line of code" readme = "README.md" license = "Apache-2.0" diff --git a/tests/test_buffer.py b/tests/test_buffer.py index 90fe47c..17dcdf0 100644 --- a/tests/test_buffer.py +++ b/tests/test_buffer.py @@ -3,7 +3,6 @@ import os import tempfile -import pytest from plexus.buffer import MemoryBuffer, SqliteBuffer diff --git a/tests/test_config.py b/tests/test_config.py index cd2156e..6f1a4c8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,11 +2,8 @@ import os import stat -import tempfile -from pathlib import Path from unittest.mock import patch -import pytest from plexus.config import save_config, load_config diff --git a/tests/test_connector.py b/tests/test_connector.py index 7728de2..c98930f 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -4,9 +4,9 @@ websockets = pytest.importorskip("websockets") -from unittest.mock import patch +from unittest.mock import patch # noqa: E402 -from plexus.connector import PlexusConnector +from plexus.connector import PlexusConnector # noqa: E402 class TestConnectorReconnect: diff --git a/tests/test_gps.py b/tests/test_gps.py index edaf83d..6c0b95f 100644 --- a/tests/test_gps.py +++ b/tests/test_gps.py @@ -1,6 +1,5 @@ """Tests for GPS NMEA parsing hardening.""" -import pytest from plexus.sensors.gps import ( _nmea_checksum, diff --git a/tests/test_mavlink_adapter.py b/tests/test_mavlink_adapter.py index ab696ac..9b86e5b 100644 --- a/tests/test_mavlink_adapter.py +++ b/tests/test_mavlink_adapter.py @@ -6,7 +6,7 @@ import math import pytest -from unittest.mock import Mock, patch, MagicMock, PropertyMock +from unittest.mock import patch, MagicMock import time diff --git a/tests/test_retry.py b/tests/test_retry.py index b36424c..2881bb7 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -1,6 +1,5 @@ """Tests for retry and buffering functionality.""" -import time from unittest.mock import MagicMock, patch import pytest diff --git a/tests/test_sensor_hub.py b/tests/test_sensor_hub.py index a25305a..53e9a19 100644 --- a/tests/test_sensor_hub.py +++ b/tests/test_sensor_hub.py @@ -1,9 +1,8 @@ """Tests for SensorHub concurrent reads, timeouts, and failure handling.""" import time -from typing import Dict, List, Optional +from typing import List -import pytest from plexus.sensors.base import BaseSensor, SensorHub, SensorReading