Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/python-compat.yml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 6 additions & 13 deletions repeater/data_acquisition/mqtt_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"}

Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion repeater/handler_helpers/repeater_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions repeater/web/api_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
53 changes: 53 additions & 0 deletions tests/test_python310_compat.py
Original file line number Diff line number Diff line change
@@ -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)
)
Loading