From 7db6535a2614674a27fe5bb2adfc7bb1b2f8e52e Mon Sep 17 00:00:00 2001 From: Zindello Date: Wed, 27 May 2026 09:58:10 +1000 Subject: [PATCH 1/5] fix: Python 3.10 compat for datetime.UTC in api_endpoints datetime.UTC was added in Python 3.11. Fall back to timezone.utc on older interpreters, matching the existing pattern in mqtt_handler.py. Co-Authored-By: Zindello --- repeater/web/api_endpoints.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 2dfd067..20d2ca0 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -2,7 +2,13 @@ import logging import os import time -from datetime import UTC, datetime +from datetime import datetime + +try: + from datetime import UTC +except ImportError: + from datetime import timezone + UTC = timezone.utc from pathlib import Path from typing import Callable, Optional From 9fe0142fa5da3cb33f13eb7ed41430064cd021cd Mon Sep 17 00:00:00 2001 From: Zindello Date: Wed, 27 May 2026 11:03:15 +1000 Subject: [PATCH 2/5] fix: replace datetime.UTC with timezone.utc for Python 3.10 compat datetime.UTC was introduced in Python 3.11. timezone.utc is available since Python 3.2 and is functionally identical. Co-Authored-By: Zindello --- repeater/web/api_endpoints.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 20d2ca0..56cf8c2 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -2,13 +2,7 @@ import logging import os import time -from datetime import datetime - -try: - from datetime import UTC -except ImportError: - from datetime import timezone - UTC = timezone.utc +from datetime import datetime, timezone from pathlib import Path from typing import Callable, Optional @@ -5256,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, From a1c66100f51b446fa1cfcd4f5e90550532f06b0b Mon Sep 17 00:00:00 2001 From: Zindello Date: Wed, 27 May 2026 11:57:45 +1000 Subject: [PATCH 3/5] fix: remove datetime.UTC from mqtt_handler and add 3.10 compat test Replace the try/except UTC shim in mqtt_handler.py with timezone.utc directly, consistent with the api_endpoints.py fix. Add a static AST scan that fails if datetime.UTC is reintroduced anywhere in the codebase, guarding against future regressions on Python 3.10 (LuckFox Pico Ultra). Co-Authored-By: Zindello --- repeater/data_acquisition/mqtt_handler.py | 19 ++++------ tests/test_python310_compat.py | 43 +++++++++++++++++++++++ 2 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 tests/test_python310_compat.py 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/tests/test_python310_compat.py b/tests/test_python310_compat.py new file mode 100644 index 0000000..6d90e17 --- /dev/null +++ b/tests/test_python310_compat.py @@ -0,0 +1,43 @@ +"""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): + 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}") + + assert not violations, ( + "datetime.UTC (Python 3.11+) found in the following files — " + "use timezone.utc instead:\n" + "\n".join(violations) + ) From 317019bf02421997dd97701f8da0e37b6f04a893 Mon Sep 17 00:00:00 2001 From: Zindello Date: Wed, 27 May 2026 12:09:51 +1000 Subject: [PATCH 4/5] =?UTF-8?q?ci:=20add=20Python=20compatibility=20matrix?= =?UTF-8?q?=20(3.10=E2=80=933.13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs the test suite across all supported Python versions on every push and pull request. fail-fast disabled so all versions are reported. Co-Authored-By: Zindello --- .github/workflows/python-compat.yml | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/python-compat.yml 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 From 493f5d1c3e1dc43209f3394c00cf66a249ff2e9a Mon Sep 17 00:00:00 2001 From: Zindello Date: Wed, 27 May 2026 12:12:56 +1000 Subject: [PATCH 5/5] fix: replace datetime.UTC attribute access in repeater_cli Also extend the compat scanner to catch datetime.UTC used as an attribute (datetime.datetime.now(datetime.UTC)) in addition to direct imports, so this form cannot be reintroduced undetected. Co-Authored-By: Zindello --- repeater/handler_helpers/repeater_cli.py | 2 +- tests/test_python310_compat.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) 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/tests/test_python310_compat.py b/tests/test_python310_compat.py index 6d90e17..5edc44f 100644 --- a/tests/test_python310_compat.py +++ b/tests/test_python310_compat.py @@ -32,10 +32,20 @@ def test_no_datetime_utc(): 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 — "