diff --git a/.github/workflows/python-compat.yml b/.github/workflows/python-compat.yml new file mode 100644 index 0000000..3fc44db --- /dev/null +++ b/.github/workflows/python-compat.yml @@ -0,0 +1,33 @@ +name: Python Compatibility + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + 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 ".[dev]" + + - name: Run tests + run: python -m pytest tests/ -v diff --git a/repeater/data_acquisition/mqtt_handler.py b/repeater/data_acquisition/mqtt_handler.py index 8164611..0d94484 100644 --- a/repeater/data_acquisition/mqtt_handler.py +++ b/repeater/data_acquisition/mqtt_handler.py @@ -4,19 +4,12 @@ import logging import string import threading -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any, Callable, Dict, List, Optional import paho.mqtt.client as mqtt from nacl.signing import SigningKey -# Try to import datetime.UTC (Python 3.11+) otherwise fallback to timezone.utc -try: - from datetime import UTC -except Exception: - from datetime import timezone - UTC = timezone.utc - from repeater import __version__, config from repeater.presets import get_preset @@ -268,7 +261,7 @@ def __init__( def _generate_jwt(self) -> str: """Generate MeshCore-style Ed25519 JWT token""" - now = datetime.now(UTC) + now = datetime.now(timezone.utc) header = {"alg": "Ed25519", "typ": "JWT"} @@ -448,7 +441,7 @@ def _set_credentials(self): else: logger.info(f"No credentials set for {self.broker['name']} (JWT auth disabled and no username/password provided)") - self._connect_time = datetime.now(UTC) + self._connect_time = datetime.now(timezone.utc) except Exception as e: logger.error(f"Failed to set JWT credentials for {self.broker['name']}: {e}") @@ -550,7 +543,7 @@ def should_reconnect_for_token_expiry(self) -> bool: """Check if connection should be reconnected due to JWT expiry (at 80% of lifetime)""" if not self._connect_time: return False - elapsed = (datetime.now(UTC) - self._connect_time).total_seconds() + elapsed = (datetime.now(timezone.utc) - self._connect_time).total_seconds() expiry_seconds = self.jwt_expiry_minutes * 60 # Stagger refresh by 5% per broker to prevent simultaneous disconnects # Broker 0: 80%, Broker 1: 85%, Broker 2: 90%, etc. @@ -906,7 +899,7 @@ def _status_heartbeat_loop(self): # Packet helpers # ---------------------------------------------------------------- def _process_packet(self, pkt: dict) -> dict: - return {"timestamp": datetime.now(UTC).isoformat(), "origin_id": self.public_key, **pkt} + return {"timestamp": datetime.now(timezone.utc).isoformat(), "origin_id": self.public_key, **pkt} def publish_packet(self, pkt: dict, subtopic="packets", retain=False): return self.publish(subtopic, self._process_packet(pkt), retain) @@ -941,7 +934,7 @@ def publish_status( status = { "status": state, - "timestamp": datetime.now(UTC).isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), "origin": origin or self.node_name, "origin_id": self.public_key, "model": "PyMC-Repeater", diff --git a/repeater/handler_helpers/repeater_cli.py b/repeater/handler_helpers/repeater_cli.py index 278a052..e2f993b 100644 --- a/repeater/handler_helpers/repeater_cli.py +++ b/repeater/handler_helpers/repeater_cli.py @@ -285,7 +285,7 @@ def _cmd_clock(self, command: str) -> str: # Display current time import datetime - dt = datetime.datetime.now(datetime.UTC) + dt = datetime.datetime.now(datetime.timezone.utc) return f"{dt.hour:02d}:{dt.minute:02d} - {dt.day}/{dt.month}/{dt.year} UTC" elif command == "clock sync": # Clock sync happens automatically via sender_timestamp in protocol diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 2dfd067..56cf8c2 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -2,7 +2,7 @@ import logging import os import time -from datetime import UTC, datetime +from datetime import datetime, timezone from pathlib import Path from typing import Callable, Optional @@ -5250,7 +5250,7 @@ def _sanitize(obj): exported = _sanitize(exported) meta = { - "exported_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + "exported_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "version": __version__, "config_path": self._config_path, "includes_secrets": full_backup, diff --git a/tests/test_python310_compat.py b/tests/test_python310_compat.py new file mode 100644 index 0000000..5edc44f --- /dev/null +++ b/tests/test_python310_compat.py @@ -0,0 +1,53 @@ +"""Regression tests guarding against Python 3.10 compatibility breakage. + +pyMC Repeater supports Python 3.10+ (LuckFox Pico Ultra ships with 3.10). +These tests scan the source tree statically so regressions are caught in CI +without needing a 3.10 interpreter in the test environment. +""" + +import ast +import sys +from pathlib import Path + +_REPEATER_ROOT = Path(__file__).parent.parent / "repeater" + + +def _py_files(): + return [p for p in _REPEATER_ROOT.rglob("*.py") if ".pyc" not in str(p)] + + +def test_minimum_python_version(): + """Fail fast if the test environment itself is below the minimum supported version.""" + assert sys.version_info >= (3, 10), ( + f"Python 3.10+ required, running {sys.version_info.major}.{sys.version_info.minor}" + ) + + +def test_no_datetime_utc(): + """`datetime.UTC` was added in 3.11 — `timezone.utc` must be used instead.""" + violations = [] + for path in _py_files(): + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except SyntaxError: + continue + for node in ast.walk(tree): + # catch: from datetime import UTC + if isinstance(node, ast.ImportFrom) and node.module == "datetime": + if any(alias.name == "UTC" for alias in node.names): + rel = path.relative_to(_REPEATER_ROOT.parent) + violations.append(f" {rel}:{node.lineno}") + # catch: datetime.UTC + if ( + isinstance(node, ast.Attribute) + and node.attr == "UTC" + and isinstance(node.value, ast.Name) + and node.value.id == "datetime" + ): + rel = path.relative_to(_REPEATER_ROOT.parent) + violations.append(f" {rel}:{node.lineno}") + + assert not violations, ( + "datetime.UTC (Python 3.11+) found in the following files — " + "use timezone.utc instead:\n" + "\n".join(violations) + )