1
0
mirror of https://github.com/craigerl/aprsd.git synced 2026-08-19 10:13:44 -04:00

Compare commits

..

27 Commits

Author SHA1 Message Date
hemna 3a47571b60 Merge pull request #224 from craigerl/dependabot/uv/uv-0.11.6
Bump uv from 0.9.26 to 0.11.6
2026-04-29 09:16:53 -04:00
dependabot[bot] 4ecdec0aa4 Bump uv from 0.9.26 to 0.11.6
Bumps [uv](https://github.com/astral-sh/uv) from 0.9.26 to 0.11.6.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.9.26...0.11.6)

---
updated-dependencies:
- dependency-name: uv
  dependency-version: 0.11.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-10 19:49:05 +00:00
hemna 490ff41cdc feat: Add configurable stats_store_interval for stats file saves
Add stats_store_interval config option to control how frequently
the statsstore.json file is written to disk. Default remains 10
seconds for backward compatibility.

This allows reducing disk I/O in production deployments and
can help avoid potential file corruption issues when external
processes read the stats file.
2026-03-27 17:37:16 -04:00
hemna 27413ab8cf Merge pull request #223 from craigerl/feature/configurable-stale-timeout
feat: Add configurable stale_timeout for APRS-IS connections
2026-03-27 10:27:09 -04:00
hemna 314f4da180 fix: Restore max_delta after custom stale_timeout test
The singleton's max_delta was being modified by test_init_custom_stale_timeout
and not restored, causing test_is_stale_connection_false to fail because
it expected 2 minutes but got 60 seconds.
2026-03-27 10:18:07 -04:00
hemna f6eb383caf fix: Update test to work with singleton pattern
The APRSISDriver uses @singleton decorator which transforms the class
into a function. The test was incorrectly trying to use __new__ which
doesn't work with decorated singletons. Instead, re-initialize the
existing instance after changing the config.
2026-03-27 10:13:55 -04:00
hemna 930339d4cf feat: Add configurable stale_timeout for APRS-IS connections
Add a new 'stale_timeout' configuration option to the aprs_network config
group that allows users to customize how long to wait before considering
an APRS-IS connection stale.

Problem:
The stale connection threshold was hardcoded to 2 minutes. In environments
with frequent network hiccups or when using certain APRS-IS servers that
may drop connections silently, 2 minutes can be too long to wait before
reconnecting, resulting in significant data loss.

Solution:
- Add 'stale_timeout' option to aprsd/conf/client.py with default of 120s
- Update APRSISDriver.__init__ to use the config value
- Maintain backward compatibility by defaulting to 120s if not configured
- Update tests to handle the new configuration option

Usage:
  [aprs_network]
  stale_timeout = 60  # Reconnect after 60 seconds without data

The default remains 120 seconds (2 minutes) for backward compatibility.
2026-03-27 10:05:44 -04:00
hemna a2e07a2279 Merge pull request #221 from craigerl/fix-urllib3-security
security: bump urllib3 from 2.6.2 to 2.6.3
2026-03-24 13:49:17 -04:00
hemna 3a12ccb842 security: bump urllib3 from 2.6.2 to 2.6.3
Fixes CVE-2026-21441 (8.9 High severity) - decompression-bomb safeguards
of the streaming API were bypassed when HTTP redirects were followed.

Closes #210
2026-03-24 13:43:44 -04:00
github-actions[bot] 5463b8d5b5 chore: update AUTHORS [skip ci] 2026-03-24 17:32:35 +00:00
hemna f2526efe1d Merge pull request #219 from craigerl/feature-daemon-threads-event-refactor
Refactor threads to use daemon threads and Event-based timing
2026-03-24 13:32:26 -04:00
hemna 96b017d59e chore: update uv.lock for uv 0.11.0 compatibility 2026-03-24 13:26:33 -04:00
hemna 8d8648e9dd style(threads): add return type to loop() and use modern type hints
- Add -> bool return type annotation to abstract loop() method
- Replace 'from typing import List' with built-in list[] (Python 3.9+)
2026-03-24 13:22:37 -04:00
hemna bf258e4bcf chore(tests): fix unused variable linter warning in test_stats.py 2026-03-24 13:22:37 -04:00
hemna 4ab59c6cf3 refactor(main): update signal handler for Event-based thread shutdown
- Replace time.sleep(1.5) with thread_list.join_non_daemon(timeout=5.0)
- Remove unused import time since time.sleep is no longer used
- Remove outdated commented-out code
- Improve log message (removed '10 seconds' reference)
2026-03-24 13:22:37 -04:00
hemna 505c0fa8a8 refactor(threads): migrate APRSRegistryThread to Event-based timing
- Set self.period=CONF.aprs_registry.frequency_seconds in __init__
- Remove counter-based conditional (loop every N seconds pattern)
- Replace time.sleep(1) with self.wait()
- Remove _loop_cnt tracking (use inherited loop_count from base)
- Remove unused time import
2026-03-24 13:22:37 -04:00
hemna 85ebf8a274 refactor(threads): migrate TX threads to Event-based timing
- PacketSendSchedulerThread: Add daemon=False, replace time.sleep with self.wait
- AckSendSchedulerThread: Add daemon=False, replace time.sleep with self.wait
- SendPacketThread: Replace time.sleep with self.wait, remove manual loop_count
- SendAckThread: Replace time.sleep with self.wait, remove manual loop_count
- BeaconSendThread: Set self.period=CONF.beacon_interval, remove counter-based
  conditional, replace time.sleep with self.wait, remove _loop_cnt tracking
- Update tests to use new Event-based API
2026-03-24 13:22:37 -04:00
hemna bc9ce61e59 refactor(threads): migrate RX threads to Event-based timing
- APRSDRXThread: Replace time.sleep with self.wait for interruptible waits
- APRSDRXThread.stop(): Use _shutdown_event.set() instead of thread_stop
- APRSDRXThread: Error recovery waits check for shutdown signal
- APRSDFilterThread: Use queue timeout with self.period for interruptible wait
- Remove unused time import
- Update tests to use new Event-based API
2026-03-24 13:22:37 -04:00
hemna 343ec3e81c refactor(threads): migrate stats threads to Event-based timing 2026-03-24 13:22:37 -04:00
hemna 44b8bc572d refactor(threads): migrate KeepAliveThread to Event-based timing 2026-03-24 13:22:36 -04:00
hemna 43ba69e352 feat(threads): add join_non_daemon() to APRSDThreadList
Allows graceful shutdown by waiting for non-daemon threads to complete
while allowing daemon threads to be terminated immediately.
2026-03-24 13:22:36 -04:00
hemna b7a37322e1 refactor(threads): add daemon, period, Event-based shutdown to APRSDThread
- Add daemon=True class attribute (subclasses override to False)
- Add period=1 class attribute for wait interval
- Replace thread_stop boolean with _shutdown_event (threading.Event)
- Add wait() method for interruptible sleeps
- Update tests for new Event-based API

BREAKING: thread_stop boolean replaced with _shutdown_event.
Code checking thread.thread_stop directly must use thread._shutdown_event.is_set()
2026-03-24 13:22:36 -04:00
hemna bc9b15d47a Add implementation plan for daemon threads and Event-based timing refactor 2026-03-24 13:22:36 -04:00
hemna d8747317df Add design spec for daemon threads and Event-based timing refactor 2026-03-24 13:22:36 -04:00
github-actions[bot] 4cc90a53ed chore: update AUTHORS [skip ci] 2026-03-24 17:12:56 +00:00
dependabot[bot] 425ad469b0 Bump marshmallow from 3.26.1 to 3.26.2 (#207)
Bumps [marshmallow](https://github.com/marshmallow-code/marshmallow) from 3.26.1 to 3.26.2.
- [Changelog](https://github.com/marshmallow-code/marshmallow/blob/3.26.2/CHANGELOG.rst)
- [Commits](https://github.com/marshmallow-code/marshmallow/compare/3.26.1...3.26.2)

---
updated-dependencies:
- dependency-name: marshmallow
  dependency-version: 3.26.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 13:12:45 -04:00
hemna bbc2ccd302 Merge pull request #214 from craigerl/fix/cli-command-issues
Fix CLI command inconsistencies
2026-02-28 09:33:31 -05:00
19 changed files with 2199 additions and 409 deletions
+6 -2
View File
@@ -32,8 +32,12 @@ class APRSISDriver:
connected = False
def __init__(self):
max_timeout = {'hours': 0.0, 'minutes': 2, 'seconds': 0}
self.max_delta = datetime.timedelta(**max_timeout)
# Use configurable stale_timeout, defaulting to 120 seconds if not set
try:
stale_timeout = CONF.aprs_network.stale_timeout
except (cfg.NoSuchOptError, cfg.NoSuchGroupError):
stale_timeout = 120 # Default to 2 minutes for backward compatibility
self.max_delta = datetime.timedelta(seconds=stale_timeout)
self.login_status = {
'success': False,
'message': None,
+8
View File
@@ -47,6 +47,14 @@ aprs_opts = [
default=14580,
help='APRS-IS port',
),
cfg.IntOpt(
'stale_timeout',
default=120,
help='Seconds without receiving data before connection is considered stale. '
'When a connection goes stale, it will be automatically reconnected. '
'Lower values detect dead connections faster but may cause unnecessary '
'reconnects during brief network hiccups. Default is 120 seconds (2 minutes).',
),
]
kiss_serial_opts = [
+6
View File
@@ -133,6 +133,12 @@ aprsd_opts = [
help='Enable the Callsign seen list tracking feature. This allows aprsd to keep track of '
'callsigns that have been seen and when they were last seen.',
),
cfg.IntOpt(
'stats_store_interval',
default=10,
help='Interval in seconds between stats file saves to disk. '
'Lower values provide more frequent updates but increase disk I/O.',
),
cfg.BoolOpt(
'enable_packet_logging',
default=True,
+6 -6
View File
@@ -24,7 +24,6 @@ import datetime
import importlib.metadata as imp
import logging
import sys
import time
from importlib.metadata import version as metadata_version
import click
@@ -76,14 +75,17 @@ def main():
def signal_handler(sig, frame):
click.echo('signal_handler: called')
collector.Collector().stop_all()
threads.APRSDThreadList().stop_all()
thread_list = threads.APRSDThreadList()
thread_list.stop_all()
if 'subprocess' not in str(frame):
LOG.info(
'Ctrl+C, Sending all threads exit! Can take up to 10 seconds {}'.format(
'Ctrl+C, Sending all threads exit! {}'.format(
datetime.datetime.now(),
),
)
time.sleep(1.5)
# Wait for non-daemon threads to finish gracefully
thread_list.join_non_daemon(timeout=5.0)
try:
packets.PacketTrack().save()
packets.WatchList().save()
@@ -93,8 +95,6 @@ def signal_handler(sig, frame):
except Exception as e:
LOG.error(f'Failed to save data: {e}')
sys.exit(0)
# signal.signal(signal.SIGTERM, sys.exit(0))
# sys.exit(0)
@cli.command()
+40 -12
View File
@@ -2,8 +2,6 @@ import abc
import datetime
import logging
import threading
import time
from typing import List
import wrapt
@@ -13,21 +11,26 @@ LOG = logging.getLogger('APRSD')
class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
"""Base class for all threads in APRSD."""
# Class attributes - subclasses override as needed
daemon = True # Most threads are daemon threads
period = 1 # Default wait period in seconds
loop_count = 1
_pause = False
thread_stop = False
def __init__(self, name):
super().__init__(name=name)
self.thread_stop = False
# Set daemon from class attribute
self.daemon = self.__class__.daemon
# Set period from class attribute (can be overridden in __init__)
self.period = self.__class__.period
self._shutdown_event = threading.Event()
self.loop_count = 0
APRSDThreadList().add(self)
self._last_loop = datetime.datetime.now()
def _should_quit(self):
"""see if we have a quit message from the global queue."""
if self.thread_stop:
return True
return False
"""Check if thread should exit."""
return self._shutdown_event.is_set()
def pause(self):
"""Logically pause the processing of the main loop."""
@@ -40,11 +43,24 @@ class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
self._pause = False
def stop(self):
"""Signal thread to stop. Returns immediately."""
LOG.debug(f"Stopping thread '{self.name}'")
self.thread_stop = True
self._shutdown_event.set()
def wait(self, timeout: float | None = None) -> bool:
"""Wait for shutdown signal or timeout.
Args:
timeout: Seconds to wait. Defaults to self.period.
Returns:
True if shutdown was signaled, False if timeout expired.
"""
wait_time = timeout if timeout is not None else self.period
return self._shutdown_event.wait(timeout=wait_time)
@abc.abstractmethod
def loop(self):
def loop(self) -> bool:
pass
def _cleanup(self):
@@ -64,7 +80,7 @@ class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
LOG.debug('Starting')
while not self._should_quit():
if self._pause:
time.sleep(1)
self.wait(timeout=1)
else:
self.loop_count += 1
can_loop = self.loop()
@@ -81,7 +97,7 @@ class APRSDThreadList:
_instance = None
threads_list: List[APRSDThread] = []
threads_list: list[APRSDThread] = []
lock = threading.Lock()
def __new__(cls, *args, **kwargs):
@@ -167,3 +183,15 @@ class APRSDThreadList:
@wrapt.synchronized(lock)
def __len__(self):
return len(self.threads_list)
@wrapt.synchronized(lock)
def join_non_daemon(self, timeout: float = 5.0):
"""Wait for non-daemon threads to complete gracefully.
Args:
timeout: Maximum seconds to wait per thread.
"""
for th in self.threads_list:
if not th.daemon and th.is_alive():
LOG.info(f'Waiting for non-daemon thread {th.name} to finish')
th.join(timeout=timeout)
+69 -70
View File
@@ -1,6 +1,5 @@
import datetime
import logging
import time
import tracemalloc
from loguru import logger
@@ -20,6 +19,7 @@ LOGU = logger
class KeepAliveThread(APRSDThread):
cntr = 0
checker_time = datetime.datetime.now()
period = 60
def __init__(self):
tracemalloc.start()
@@ -28,81 +28,80 @@ class KeepAliveThread(APRSDThread):
self.max_delta = datetime.timedelta(**max_timeout)
def loop(self):
if self.loop_count % 60 == 0:
stats_json = collector.Collector().collect()
pl = packets.PacketList()
thread_list = APRSDThreadList()
now = datetime.datetime.now()
stats_json = collector.Collector().collect()
pl = packets.PacketList()
thread_list = APRSDThreadList()
now = datetime.datetime.now()
if (
'APRSClientStats' in stats_json
and stats_json['APRSClientStats'].get('transport') == 'aprsis'
):
if stats_json['APRSClientStats'].get('server_keepalive'):
last_msg_time = utils.strfdelta(
now - stats_json['APRSClientStats']['server_keepalive']
)
else:
last_msg_time = 'N/A'
if (
'APRSClientStats' in stats_json
and stats_json['APRSClientStats'].get('transport') == 'aprsis'
):
if stats_json['APRSClientStats'].get('server_keepalive'):
last_msg_time = utils.strfdelta(
now - stats_json['APRSClientStats']['server_keepalive']
)
else:
last_msg_time = 'N/A'
else:
last_msg_time = 'N/A'
tracked_packets = stats_json['PacketTrack']['total_tracked']
tx_msg = 0
rx_msg = 0
if 'PacketList' in stats_json:
msg_packets = stats_json['PacketList'].get('MessagePacket')
if msg_packets:
tx_msg = msg_packets.get('tx', 0)
rx_msg = msg_packets.get('rx', 0)
tracked_packets = stats_json['PacketTrack']['total_tracked']
tx_msg = 0
rx_msg = 0
if 'PacketList' in stats_json:
msg_packets = stats_json['PacketList'].get('MessagePacket')
if msg_packets:
tx_msg = msg_packets.get('tx', 0)
rx_msg = msg_packets.get('rx', 0)
keepalive = (
'{} - Uptime {} RX:{} TX:{} Tracker:{} Msgs TX:{} RX:{} '
'Last:{} - RAM Current:{} Peak:{} Threads:{} LoggingQueue:{}'
).format(
stats_json['APRSDStats']['callsign'],
stats_json['APRSDStats']['uptime'],
pl.total_rx(),
pl.total_tx(),
tracked_packets,
tx_msg,
rx_msg,
last_msg_time,
stats_json['APRSDStats']['memory_current_str'],
stats_json['APRSDStats']['memory_peak_str'],
len(thread_list),
aprsd_log.logging_queue.qsize(),
)
LOG.info(keepalive)
if 'APRSDThreadList' in stats_json:
thread_list = stats_json['APRSDThreadList']
for thread_name in thread_list:
thread = thread_list[thread_name]
alive = thread['alive']
age = thread['age']
key = thread['name']
if not alive:
LOG.error(f'Thread {thread}')
keepalive = (
'{} - Uptime {} RX:{} TX:{} Tracker:{} Msgs TX:{} RX:{} '
'Last:{} - RAM Current:{} Peak:{} Threads:{} LoggingQueue:{}'
).format(
stats_json['APRSDStats']['callsign'],
stats_json['APRSDStats']['uptime'],
pl.total_rx(),
pl.total_tx(),
tracked_packets,
tx_msg,
rx_msg,
last_msg_time,
stats_json['APRSDStats']['memory_current_str'],
stats_json['APRSDStats']['memory_peak_str'],
len(thread_list),
aprsd_log.logging_queue.qsize(),
)
LOG.info(keepalive)
if 'APRSDThreadList' in stats_json:
thread_list = stats_json['APRSDThreadList']
for thread_name in thread_list:
thread = thread_list[thread_name]
alive = thread['alive']
age = thread['age']
key = thread['name']
if not alive:
LOG.error(f'Thread {thread}')
thread_hex = f'fg {utils.hex_from_name(key)}'
t_name = f'<{thread_hex}>{key:<15}</{thread_hex}>'
thread_msg = f'{t_name} Alive? {str(alive): <5} {str(age): <20}'
LOGU.opt(colors=True).info(thread_msg)
# LOG.info(f"{key: <15} Alive? {str(alive): <5} {str(age): <20}")
thread_hex = f'fg {utils.hex_from_name(key)}'
t_name = f'<{thread_hex}>{key:<15}</{thread_hex}>'
thread_msg = f'{t_name} Alive? {str(alive): <5} {str(age): <20}'
LOGU.opt(colors=True).info(thread_msg)
# LOG.info(f"{key: <15} Alive? {str(alive): <5} {str(age): <20}")
# Go through the registered keepalive collectors
# and check them as well as call log.
collect = keepalive_collector.KeepAliveCollector()
collect.check()
collect.log()
# Go through the registered keepalive collectors
# and check them as well as call log.
collect = keepalive_collector.KeepAliveCollector()
collect.check()
collect.log()
# Check version every day
delta = now - self.checker_time
if delta > datetime.timedelta(hours=24):
self.checker_time = now
level, msg = utils._check_version()
if level:
LOG.warning(msg)
self.cntr += 1
time.sleep(1)
# Check version every day
delta = now - self.checker_time
if delta > datetime.timedelta(hours=24):
self.checker_time = now
level, msg = utils._check_version()
if level:
LOG.warning(msg)
self.cntr += 1
self.wait()
return True
+17 -23
View File
@@ -1,5 +1,4 @@
import logging
import time
import requests
from oslo_config import cfg
@@ -14,11 +13,9 @@ LOG = logging.getLogger('APRSD')
class APRSRegistryThread(aprsd_threads.APRSDThread):
"""This sends service information to the configured APRS Registry."""
_loop_cnt: int = 1
def __init__(self):
super().__init__('APRSRegistryThread')
self._loop_cnt = 1
self.period = CONF.aprs_registry.frequency_seconds
if not CONF.aprs_registry.enabled:
LOG.error(
'APRS Registry is not enabled. ',
@@ -34,24 +31,21 @@ class APRSRegistryThread(aprsd_threads.APRSDThread):
)
def loop(self):
# Only call the registry every N seconds
if self._loop_cnt % CONF.aprs_registry.frequency_seconds == 0:
info = {
'callsign': CONF.callsign,
'owner_callsign': CONF.owner_callsign,
'description': CONF.aprs_registry.description,
'service_website': CONF.aprs_registry.service_website,
'software': f'APRSD version {aprsd.__version__} '
'https://github.com/craigerl/aprsd',
}
try:
requests.post(
f'{CONF.aprs_registry.registry_url}',
json=info,
)
except Exception as e:
LOG.error(f'Failed to send registry info: {e}')
info = {
'callsign': CONF.callsign,
'owner_callsign': CONF.owner_callsign,
'description': CONF.aprs_registry.description,
'service_website': CONF.aprs_registry.service_website,
'software': f'APRSD version {aprsd.__version__} '
'https://github.com/craigerl/aprsd',
}
try:
requests.post(
f'{CONF.aprs_registry.registry_url}',
json=info,
)
except Exception as e:
LOG.error(f'Failed to send registry info: {e}')
time.sleep(1)
self._loop_cnt += 1
self.wait()
return True
+8 -7
View File
@@ -1,7 +1,6 @@
import abc
import logging
import queue
import time
import aprslib
from oslo_config import cfg
@@ -43,19 +42,19 @@ class APRSDRXThread(APRSDThread):
self.packet_queue = packet_queue
def stop(self):
self.thread_stop = True
self._shutdown_event.set()
if self._client:
self._client.close()
def loop(self):
if not self._client:
self._client = APRSDClient()
time.sleep(1)
self.wait(timeout=1)
return True
if not self._client.is_alive:
self._client = APRSDClient()
time.sleep(1)
self.wait(timeout=1)
return True
# setup the consumer of messages and block until a messages
@@ -82,12 +81,14 @@ class APRSDRXThread(APRSDThread):
# This will cause a reconnect, next time client.get_client()
# is called
self._client.reset()
time.sleep(5)
if self.wait(timeout=5):
return False
except Exception as ex:
LOG.exception(ex)
LOG.error('Resetting connection and trying again.')
self._client.reset()
time.sleep(5)
if self.wait(timeout=5):
return False
return True
def process_packet(self, *args, **kwargs):
@@ -153,7 +154,7 @@ class APRSDFilterThread(APRSDThread):
def loop(self):
try:
pkt = self.packet_queue.get(timeout=1)
pkt = self.packet_queue.get(timeout=self.period)
self.packet_count += 1
# We use the client here, because the specific
# driver may need to decode the packet differently.
+117 -120
View File
@@ -31,20 +31,20 @@ class StatsStore(objectstore.ObjectStoreMixin):
class APRSDStatsStoreThread(APRSDThread):
"""Save APRSD Stats to disk periodically."""
# how often in seconds to write the file
save_interval = 10
daemon = False
def __init__(self):
super().__init__('StatsStore')
# Use config value for period, default to 10 seconds
self.period = CONF.stats_store_interval
def loop(self):
if self.loop_count % self.save_interval == 0:
stats = collector.Collector().collect()
ss = StatsStore()
ss.add(stats)
ss.save()
stats = collector.Collector().collect()
ss = StatsStore()
ss.add(stats)
ss.save()
time.sleep(1)
self.wait()
return True
@@ -64,143 +64,140 @@ class APRSDPushStatsThread(APRSDThread):
self.send_packetlist = send_packetlist
def loop(self):
if self.loop_count % self.period == 0:
stats_json = collector.Collector().collect(serializable=True)
url = f'{self.push_url}/stats'
headers = {'Content-Type': 'application/json'}
# Remove the PacketList section to reduce payload size
if not self.send_packetlist:
if 'PacketList' in stats_json:
del stats_json['PacketList']['packets']
stats_json = collector.Collector().collect(serializable=True)
url = f'{self.push_url}/stats'
headers = {'Content-Type': 'application/json'}
# Remove the PacketList section to reduce payload size
if not self.send_packetlist:
if 'PacketList' in stats_json:
del stats_json['PacketList']['packets']
now = datetime.datetime.now()
time_format = '%m-%d-%Y %H:%M:%S'
stats = {
'time': now.strftime(time_format),
'stats': stats_json,
}
now = datetime.datetime.now()
time_format = '%m-%d-%Y %H:%M:%S'
stats = {
'time': now.strftime(time_format),
'stats': stats_json,
}
try:
response = requests.post(url, json=stats, headers=headers, timeout=5)
response.raise_for_status()
try:
response = requests.post(url, json=stats, headers=headers, timeout=5)
response.raise_for_status()
if response.status_code == 200:
LOGU.info(f'Successfully pushed stats to {self.push_url}')
else:
LOGU.warning(
f'Failed to push stats to {self.push_url}: HTTP {response.status_code}'
)
if response.status_code == 200:
LOGU.info(f'Successfully pushed stats to {self.push_url}')
else:
LOGU.warning(
f'Failed to push stats to {self.push_url}: HTTP {response.status_code}'
)
except requests.exceptions.RequestException as e:
LOGU.error(f'Error pushing stats to {self.push_url}: {e}')
except Exception as e:
LOGU.error(f'Unexpected error in stats push: {e}')
except requests.exceptions.RequestException as e:
LOGU.error(f'Error pushing stats to {self.push_url}: {e}')
except Exception as e:
LOGU.error(f'Unexpected error in stats push: {e}')
time.sleep(1)
self.wait()
return True
class StatsLogThread(APRSDThread):
"""Log the stats from the PacketList."""
period = 10
def __init__(self):
super().__init__('PacketStatsLog')
self._last_total_rx = 0
self.period = 10
self.start_time = time.time()
def loop(self):
if self.loop_count % self.period == 0:
# log the stats every 10 seconds
stats_json = collector.Collector().collect(serializable=True)
stats = stats_json['PacketList']
total_rx = stats['rx']
rx_delta = total_rx - self._last_total_rx
rate = rx_delta / self.period
# log the stats every 10 seconds
stats_json = collector.Collector().collect(serializable=True)
stats = stats_json['PacketList']
total_rx = stats['rx']
rx_delta = total_rx - self._last_total_rx
rate = rx_delta / self.period
# Get unique callsigns count from SeenList stats
seen_list_instance = seen_list.SeenList()
# stats() returns data while holding lock internally, so copy it immediately
seen_list_stats = seen_list_instance.stats()
seen_list_instance.save()
# Copy the stats to avoid holding references to locked data
seen_list_stats = seen_list_stats.copy()
unique_callsigns_count = len(seen_list_stats)
# Get unique callsigns count from SeenList stats
seen_list_instance = seen_list.SeenList()
# stats() returns data while holding lock internally, so copy it immediately
seen_list_stats = seen_list_instance.stats()
seen_list_instance.save()
# Copy the stats to avoid holding references to locked data
seen_list_stats = seen_list_stats.copy()
unique_callsigns_count = len(seen_list_stats)
# Calculate uptime
elapsed = time.time() - self.start_time
elapsed_minutes = elapsed / 60
elapsed_hours = elapsed / 3600
# Calculate uptime
elapsed = time.time() - self.start_time
elapsed_minutes = elapsed / 60
elapsed_hours = elapsed / 3600
# Log summary stats
LOGU.opt(colors=True).info(
f'<green>RX Rate: {rate:.2f} pps</green> '
f'<yellow>Total RX: {total_rx}</yellow> '
f'<red>RX Last {self.period} secs: {rx_delta}</red> '
# Log summary stats
LOGU.opt(colors=True).info(
f'<green>RX Rate: {rate:.2f} pps</green> '
f'<yellow>Total RX: {total_rx}</yellow> '
f'<red>RX Last {self.period} secs: {rx_delta}</red> '
)
LOGU.opt(colors=True).info(
f'<cyan>Uptime: {elapsed:.0f}s ({elapsed_minutes:.1f}m / {elapsed_hours:.2f}h)</cyan> '
f'<magenta>Unique Callsigns: {unique_callsigns_count}</magenta>',
)
self._last_total_rx = total_rx
# Log individual type stats, sorted by RX count (descending)
sorted_types = sorted(
stats['types'].items(), key=lambda x: x[1]['rx'], reverse=True
)
for k, v in sorted_types:
# Calculate percentage of this packet type compared to total RX
percentage = (v['rx'] / total_rx * 100) if total_rx > 0 else 0.0
# Format values first, then apply colors
packet_type_str = f'{k:<15}'
rx_count_str = f'{v["rx"]:6d}'
tx_count_str = f'{v["tx"]:6d}'
percentage_str = f'{percentage:5.1f}%'
# Use different colors for RX count based on threshold (matching mqtt_injest.py)
rx_color_tag = (
'green' if v['rx'] > 100 else 'yellow' if v['rx'] > 10 else 'red'
)
LOGU.opt(colors=True).info(
f'<cyan>Uptime: {elapsed:.0f}s ({elapsed_minutes:.1f}m / {elapsed_hours:.2f}h)</cyan> '
f'<magenta>Unique Callsigns: {unique_callsigns_count}</magenta>',
f' <cyan>{packet_type_str}</cyan>: '
f'<{rx_color_tag}>RX: {rx_count_str}</{rx_color_tag}> '
f'<red>TX: {tx_count_str}</red> '
f'<magenta>({percentage_str})</magenta>',
)
self._last_total_rx = total_rx
# Log individual type stats, sorted by RX count (descending)
sorted_types = sorted(
stats['types'].items(), key=lambda x: x[1]['rx'], reverse=True
)
for k, v in sorted_types:
# Calculate percentage of this packet type compared to total RX
percentage = (v['rx'] / total_rx * 100) if total_rx > 0 else 0.0
# Format values first, then apply colors
packet_type_str = f'{k:<15}'
rx_count_str = f'{v["rx"]:6d}'
tx_count_str = f'{v["tx"]:6d}'
percentage_str = f'{percentage:5.1f}%'
# Use different colors for RX count based on threshold (matching mqtt_injest.py)
rx_color_tag = (
'green' if v['rx'] > 100 else 'yellow' if v['rx'] > 10 else 'red'
)
# Extract callsign counts from seen_list stats
callsign_counts = {}
for callsign, data in seen_list_stats.items():
if isinstance(data, dict) and 'count' in data:
callsign_counts[callsign] = data['count']
# Sort callsigns by packet count (descending) and get top 10
sorted_callsigns = sorted(
callsign_counts.items(), key=lambda x: x[1], reverse=True
)[:10]
# Log top 10 callsigns
if sorted_callsigns:
LOGU.opt(colors=True).info('<cyan>Top 10 Callsigns by Packet Count:</cyan>')
total_ranks = len(sorted_callsigns)
for rank, (callsign, count) in enumerate(sorted_callsigns, 1):
# Calculate percentage of this callsign compared to total RX
percentage = (count / total_rx * 100) if total_rx > 0 else 0.0
# Use different colors based on rank: most packets (rank 1) = red,
# least packets (last rank) = green, middle = yellow
if rank == 1:
count_color_tag = 'red'
elif rank == total_ranks:
count_color_tag = 'green'
else:
count_color_tag = 'yellow'
LOGU.opt(colors=True).info(
f' <cyan>{packet_type_str}</cyan>: '
f'<{rx_color_tag}>RX: {rx_count_str}</{rx_color_tag}> '
f'<red>TX: {tx_count_str}</red> '
f'<magenta>({percentage_str})</magenta>',
f' <cyan>{rank:2d}.</cyan> '
f'<white>{callsign:<12}</white>: '
f'<{count_color_tag}>{count:6d} packets</{count_color_tag}> '
f'<magenta>({percentage:5.1f}%)</magenta>',
)
# Extract callsign counts from seen_list stats
callsign_counts = {}
for callsign, data in seen_list_stats.items():
if isinstance(data, dict) and 'count' in data:
callsign_counts[callsign] = data['count']
# Sort callsigns by packet count (descending) and get top 10
sorted_callsigns = sorted(
callsign_counts.items(), key=lambda x: x[1], reverse=True
)[:10]
# Log top 10 callsigns
if sorted_callsigns:
LOGU.opt(colors=True).info(
'<cyan>Top 10 Callsigns by Packet Count:</cyan>'
)
total_ranks = len(sorted_callsigns)
for rank, (callsign, count) in enumerate(sorted_callsigns, 1):
# Calculate percentage of this callsign compared to total RX
percentage = (count / total_rx * 100) if total_rx > 0 else 0.0
# Use different colors based on rank: most packets (rank 1) = red,
# least packets (last rank) = green, middle = yellow
if rank == 1:
count_color_tag = 'red'
elif rank == total_ranks:
count_color_tag = 'green'
else:
count_color_tag = 'yellow'
LOGU.opt(colors=True).info(
f' <cyan>{rank:2d}.</cyan> '
f'<white>{callsign:<12}</white>: '
f'<{count_color_tag}>{count:6d} packets</{count_color_tag}> '
f'<magenta>({percentage:5.1f}%)</magenta>',
)
time.sleep(1)
self.wait()
return True
+27 -32
View File
@@ -241,6 +241,8 @@ class PacketSendSchedulerThread(aprsd_threads.APRSDThread):
separate thread for each packet.
"""
daemon = False # Non-daemon for graceful packet handling
def __init__(self, max_workers=5):
super().__init__('PacketSendSchedulerThread')
self.executor = ThreadPoolExecutor(
@@ -272,7 +274,7 @@ class PacketSendSchedulerThread(aprsd_threads.APRSDThread):
# The worker will check timing and send if needed
self.executor.submit(_send_packet_worker, msg_no)
time.sleep(1) # Check every second
self.wait() # Check every period (default 1 second)
return True
def _cleanup(self):
@@ -289,6 +291,8 @@ class AckSendSchedulerThread(aprsd_threads.APRSDThread):
separate thread for each ack.
"""
daemon = False # Non-daemon for graceful ACK handling
def __init__(self, max_workers=3):
super().__init__('AckSendSchedulerThread')
self.executor = ThreadPoolExecutor(
@@ -320,7 +324,7 @@ class AckSendSchedulerThread(aprsd_threads.APRSDThread):
# Submit send task to threadpool
self.executor.submit(_send_ack_worker, msg_no, self.max_retries)
time.sleep(1) # Check every second
self.wait() # Check every period (default 1 second)
return True
def _cleanup(self):
@@ -330,8 +334,6 @@ class AckSendSchedulerThread(aprsd_threads.APRSDThread):
class SendPacketThread(aprsd_threads.APRSDThread):
loop_count: int = 1
def __init__(self, packet):
self.packet = packet
super().__init__(f'TX-{packet.to_call}-{self.packet.msgNo}')
@@ -401,14 +403,12 @@ class SendPacketThread(aprsd_threads.APRSDThread):
if sent:
packet.send_count += 1
time.sleep(1)
self.wait()
# Make sure we get called again.
self.loop_count += 1
return True
class SendAckThread(aprsd_threads.APRSDThread):
loop_count: int = 1
max_retries = 3
def __init__(self, packet):
@@ -462,8 +462,7 @@ class SendAckThread(aprsd_threads.APRSDThread):
self.packet.last_send_time = int(round(time.time()))
time.sleep(1)
self.loop_count += 1
self.wait()
return True
@@ -473,11 +472,9 @@ class BeaconSendThread(aprsd_threads.APRSDThread):
Settings are in the [DEFAULT] section of the config file.
"""
_loop_cnt: int = 1
def __init__(self):
super().__init__('BeaconSendThread')
self._loop_cnt = 1
self.period = CONF.beacon_interval
# Make sure Latitude and Longitude are set.
if not CONF.latitude or not CONF.longitude:
LOG.error(
@@ -491,25 +488,23 @@ class BeaconSendThread(aprsd_threads.APRSDThread):
)
def loop(self):
# Only dump out the stats every N seconds
if self._loop_cnt % CONF.beacon_interval == 0:
pkt = core.BeaconPacket(
from_call=CONF.callsign,
to_call='APRS',
latitude=float(CONF.latitude),
longitude=float(CONF.longitude),
comment='APRSD GPS Beacon',
symbol=CONF.beacon_symbol,
)
try:
# Only send it once
pkt.retry_count = 1
send(pkt, direct=True)
except Exception as e:
LOG.error(f'Failed to send beacon: {e}')
APRSDClient().reset()
time.sleep(5)
pkt = core.BeaconPacket(
from_call=CONF.callsign,
to_call='APRS',
latitude=float(CONF.latitude),
longitude=float(CONF.longitude),
comment='APRSD GPS Beacon',
symbol=CONF.beacon_symbol,
)
try:
# Only send it once
pkt.retry_count = 1
send(pkt, direct=True)
except Exception as e:
LOG.error(f'Failed to send beacon: {e}')
APRSDClient().reset()
if self.wait(timeout=5):
return False
self._loop_cnt += 1
time.sleep(1)
self.wait()
return True
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,259 @@
# Daemon Threads and Event-Based Timing Refactor
**Date:** 2026-03-24
**Status:** Draft
**Scope:** All APRSD thread classes
## Overview
Refactor all APRSD thread classes to use daemon threads and replace counter-based sleep patterns with `threading.Event()` for interruptible waits and cleaner periodic timing.
## Goals
1. **Faster shutdown** — threads respond to shutdown signals immediately instead of waiting for `time.sleep()` to complete
2. **Cleaner periodic timing** — replace counter-based timing (`loop_count % 60`) with explicit period-based waits
3. **Proper daemon semantics** — most threads become daemon threads; critical I/O threads remain non-daemon for graceful shutdown
## Current State
- **14 thread classes** extend `APRSDThread` base class
- All use `time.sleep(1)` with counter-based conditionals for periodic work
- Shutdown via boolean `thread_stop` flag, polled in while loop
- No threads set as daemon — all block program exit
- No `threading.Event()` usage anywhere
### Current Problems
1. Shutdown delay: up to 1-5 seconds waiting for sleep to finish
2. Counter math is fragile and obscures intent
3. Non-daemon threads prevent clean program exit
## Design
### Base Class Changes
File: `aprsd/threads/aprsd.py`
```python
class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
# Class attributes (subclasses override as needed)
daemon = True # Most threads are daemon
period = 1 # Default wait period in seconds
def __init__(self, name):
super().__init__(name=name)
self.daemon = self.__class__.daemon
self._shutdown_event = threading.Event()
self.loop_count = 0 # Retained for debugging
self._last_loop = datetime.datetime.now()
APRSDThreadList().add(self)
def _should_quit(self) -> bool:
"""Check if thread should exit."""
return self._shutdown_event.is_set()
def stop(self):
"""Signal thread to stop. Returns immediately."""
self._shutdown_event.set()
def wait(self, timeout: float | None = None) -> bool:
"""Wait for shutdown signal or timeout.
Args:
timeout: Seconds to wait. Defaults to self.period.
Returns:
True if shutdown was signaled, False if timeout expired.
"""
wait_time = timeout if timeout is not None else self.period
return self._shutdown_event.wait(timeout=wait_time)
def run(self):
while not self._should_quit():
self.loop_count += 1
self._last_loop = datetime.datetime.now()
if not self.loop():
break
APRSDThreadList().remove(self)
```
### Daemon vs Non-Daemon Threads
**Non-daemon threads (3)** — require graceful shutdown for I/O operations:
| Thread | File | Reason |
|--------|------|--------|
| `PacketSendSchedulerThread` | tx.py | Manages packet send queue |
| `AckSendSchedulerThread` | tx.py | Manages ACK send queue |
| `APRSDStatsStoreThread` | stats.py | Writes stats to disk |
**Daemon threads (12)** — can be terminated immediately:
- `APRSDRXThread`, `APRSDFilterThread`, `APRSDProcessPacketThread`, `APRSDPluginProcessPacketThread`
- `APRSDPushStatsThread`, `StatsLogThread`
- `KeepAliveThread`, `APRSRegistryThread`
- `SendPacketThread`, `SendAckThread`, `BeaconSendThread`
- `APRSDListenProcessThread` (listen command)
### Subclass Migration Pattern
**Before:**
```python
class KeepAliveThread(APRSDThread):
def loop(self):
if self.loop_count % 60 == 0:
self._do_keepalive_work()
time.sleep(1)
return True
```
**After:**
```python
class KeepAliveThread(APRSDThread):
period = 60
def loop(self):
self._do_keepalive_work()
self.wait()
return True
```
### Thread Periods
| Thread | New `period` | Source |
|--------|--------------|--------|
| `KeepAliveThread` | 60 | Fixed |
| `APRSDStatsStoreThread` | 10 | Fixed |
| `APRSDPushStatsThread` | config | `CONF.push_stats.period` |
| `StatsLogThread` | 10 | Fixed |
| `APRSRegistryThread` | config | `CONF.aprs_registry.frequency_seconds` |
| `BeaconSendThread` | config | `CONF.beacon_interval` |
| `APRSDRXThread` | 1 | Default |
| `APRSDFilterThread` | 1 | Default |
| `APRSDProcessPacketThread` | 1 | Default |
| `APRSDPluginProcessPacketThread` | 1 | Default |
| `SendPacketThread` | 1 | Default |
| `SendAckThread` | 1 | Default |
| `PacketSendSchedulerThread` | 1 | Default |
| `AckSendSchedulerThread` | 1 | Default |
| `APRSDListenProcessThread` | 1 | Default |
Config-based periods are set in `__init__` or `setup()`. Note: `setup()` is called by subclasses in their `__init__` before the thread starts; the base class does not call it automatically.
```python
class APRSRegistryThread(APRSDThread):
period = 1 # Default
def setup(self):
self.period = CONF.aprs_registry.frequency_seconds
```
### ThreadList Changes
File: `aprsd/threads/aprsd.py`
```python
class APRSDThreadList:
def stop_all(self):
"""Signal all threads to stop."""
with self.lock:
for th in self.threads_list:
th.stop()
def join_non_daemon(self, timeout: float = 5.0):
"""Wait for non-daemon threads to complete gracefully."""
with self.lock:
for th in self.threads_list:
if not th.daemon and th.is_alive():
th.join(timeout=timeout)
```
### Shutdown Handler Changes
File: `aprsd/main.py`
```python
def signal_handler(sig, frame):
LOG.info("Shutdown signal received")
thread_list = threads.APRSDThreadList()
thread_list.stop_all()
thread_list.join_non_daemon(timeout=5.0)
# Daemon threads killed automatically on exit
```
### Queue-Based Threads
Threads that block on queues use queue timeout as interruptible wait:
```python
class APRSDFilterThread(APRSDThread):
period = 1
def loop(self):
try:
packet = self.queue.get(timeout=self.period)
self._process(packet)
except queue.Empty:
pass # Timeout, loop checks _should_quit
return True
```
### Error Recovery Waits
Threads needing longer waits for error recovery use explicit timeout:
```python
class APRSDRXThread(APRSDThread):
period = 1
def loop(self):
try:
self._process_packets()
except ConnectionError:
LOG.error("Connection lost, retrying in 5s")
if self.wait(timeout=5):
return False # Shutdown signaled
self.wait()
return True
```
## Files to Modify
1. `aprsd/threads/aprsd.py` — Base class and ThreadList
2. `aprsd/threads/rx.py` — RX thread classes (4)
3. `aprsd/threads/tx.py` — TX thread classes (5)
4. `aprsd/threads/stats.py` — Stats thread classes (3)
5. `aprsd/threads/keepalive.py` — KeepAliveThread
6. `aprsd/threads/registry.py` — APRSRegistryThread
7. `aprsd/main.py` — Signal handler
8. `aprsd/cmds/listen.py` — APRSDListenProcessThread
## Testing Strategy
1. **Unit tests for base class:**
- `wait()` returns `True` immediately when event is already set
- `wait(timeout=5)` returns `False` after 5 seconds if event not set
- `stop()` causes `_should_quit()` to return `True`
- `daemon` attribute is set correctly from class attribute
2. **Integration tests:**
- Shutdown completes in <1s for daemon-only scenarios
- Non-daemon threads get up to 5s grace period
- `join_non_daemon()` respects timeout parameter
3. **Manual testing:**
- Send SIGINT during operation, verify clean exit
- Verify no "thread still running" warnings on shutdown
4. **Existing test updates:**
- Update any tests that mock `thread_stop` to mock `_shutdown_event` instead
- Update tests that check `time.sleep` calls to check `wait()` calls
## Backwards Compatibility
- `loop_count` retained for debugging/logging
- `_should_quit()` method signature unchanged
- Default `period=1` matches current 1-second sleep behavior
## Rollout
Single PR with all changes — the refactor is atomic and affects thread behavior globally.
+3 -3
View File
@@ -14,7 +14,7 @@ importlib-metadata==8.7.0 # via ax253, kiss3
kiss3==8.0.0 # via -r requirements.in
loguru==0.7.3 # via -r requirements.in
markdown-it-py==4.0.0 # via rich
marshmallow==3.26.1 # via dataclasses-json
marshmallow==3.26.2 # via dataclasses-json
mdurl==0.1.2 # via markdown-it-py
mypy-extensions==1.1.0 # via typing-inspect
netaddr==1.3.0 # via oslo-config
@@ -32,7 +32,7 @@ requests==2.32.5 # via oslo-config, update-checker, -r requirements.in
rfc3986==2.0.0 # via oslo-config
rich==14.2.0 # via -r requirements.in
rush==2021.4.0 # via -r requirements.in
setuptools==80.9.0 # via pbr
setuptools==82.0.1 # via pbr
stevedore==5.6.0 # via oslo-config
thesmuggler==1.0.1 # via -r requirements.in
timeago==1.0.16 # via -r requirements.in
@@ -40,6 +40,6 @@ typing-extensions==4.15.0 # via typing-inspect
typing-inspect==0.9.0 # via dataclasses-json
tzlocal==5.3.1 # via -r requirements.in
update-checker==0.18.0 # via -r requirements.in
urllib3==2.6.2 # via requests
urllib3==2.6.3 # via requests
wrapt==2.0.1 # via -r requirements.in
zipp==3.23.0 # via importlib-metadata
+22 -1
View File
@@ -24,6 +24,7 @@ class TestAPRSISDriver(unittest.TestCase):
self.mock_conf.aprs_network.password = '12345'
self.mock_conf.aprs_network.host = 'rotate.aprs.net'
self.mock_conf.aprs_network.port = 14580
self.mock_conf.aprs_network.stale_timeout = 120 # Default 2 minutes
# Mock APRS Lib Client
self.aprslib_patcher = mock.patch('aprsd.client.drivers.aprsis.APRSLibClient')
@@ -71,11 +72,31 @@ class TestAPRSISDriver(unittest.TestCase):
def test_init(self):
"""Test initialization sets default values."""
self.assertIsInstance(self.driver.max_delta, datetime.timedelta)
self.assertEqual(self.driver.max_delta, datetime.timedelta(minutes=2))
# Default stale_timeout is 120 seconds (2 minutes)
self.assertEqual(self.driver.max_delta, datetime.timedelta(seconds=120))
self.assertFalse(self.driver.login_status['success'])
self.assertIsNone(self.driver.login_status['message'])
self.assertIsNone(self.driver._client)
def test_init_custom_stale_timeout(self):
"""Test initialization with custom stale_timeout."""
# Save original max_delta
original_max_delta = self.driver.max_delta
try:
# Set a custom stale_timeout
self.mock_conf.aprs_network.stale_timeout = 60 # 1 minute
# Re-initialize the existing driver with new config
# (singleton pattern means we can't create a new instance)
self.driver.__init__()
self.assertEqual(self.driver.max_delta, datetime.timedelta(seconds=60))
finally:
# Restore original max_delta so other tests aren't affected
self.driver.max_delta = original_max_delta
self.mock_conf.aprs_network.stale_timeout = 120
def test_is_enabled_true(self):
"""Test is_enabled returns True when APRS-IS is enabled."""
self.mock_conf.aprs_network.enabled = True
+132 -14
View File
@@ -1,3 +1,4 @@
import datetime
import threading
import time
import unittest
@@ -42,11 +43,9 @@ class TestAPRSDThread(unittest.TestCase):
"""Test thread initialization."""
thread = TestThread('TestThread1')
self.assertEqual(thread.name, 'TestThread1')
self.assertFalse(thread.thread_stop)
self.assertFalse(thread._shutdown_event.is_set())
self.assertFalse(thread._pause)
self.assertEqual(thread.loop_count, 1)
# Should be registered in thread list
self.assertEqual(thread.loop_count, 0) # Was 1, now starts at 0
thread_list = APRSDThreadList()
self.assertIn(thread, thread_list.threads_list)
@@ -54,8 +53,7 @@ class TestAPRSDThread(unittest.TestCase):
"""Test _should_quit() method."""
thread = TestThread('TestThread2')
self.assertFalse(thread._should_quit())
thread.thread_stop = True
thread._shutdown_event.set()
self.assertTrue(thread._should_quit())
def test_pause_unpause(self):
@@ -72,20 +70,93 @@ class TestAPRSDThread(unittest.TestCase):
def test_stop(self):
"""Test stop() method."""
thread = TestThread('TestThread4')
self.assertFalse(thread.thread_stop)
self.assertFalse(thread._shutdown_event.is_set())
thread.stop()
self.assertTrue(thread.thread_stop)
self.assertTrue(thread._shutdown_event.is_set())
def test_loop_age(self):
"""Test loop_age() method."""
import datetime
thread = TestThread('TestThread5')
age = thread.loop_age()
self.assertIsInstance(age, datetime.timedelta)
self.assertGreaterEqual(age.total_seconds(), 0)
def test_daemon_attribute_default(self):
"""Test that daemon attribute defaults to True."""
thread = TestThread('DaemonTest')
self.assertTrue(thread.daemon)
def test_daemon_attribute_override(self):
"""Test that daemon attribute can be overridden via class attribute."""
class NonDaemonThread(APRSDThread):
daemon = False
def loop(self):
return False
thread = NonDaemonThread('NonDaemonTest')
self.assertFalse(thread.daemon)
def test_period_attribute_default(self):
"""Test that period attribute defaults to 1."""
thread = TestThread('PeriodTest')
self.assertEqual(thread.period, 1)
def test_period_attribute_override(self):
"""Test that period attribute can be overridden via class attribute."""
class LongPeriodThread(APRSDThread):
period = 60
def loop(self):
return False
thread = LongPeriodThread('LongPeriodTest')
self.assertEqual(thread.period, 60)
def test_shutdown_event_exists(self):
"""Test that _shutdown_event is created."""
thread = TestThread('EventTest')
self.assertIsInstance(thread._shutdown_event, threading.Event)
self.assertFalse(thread._shutdown_event.is_set())
def test_wait_returns_false_on_timeout(self):
"""Test that wait() returns False when timeout expires."""
thread = TestThread('WaitTimeoutTest')
start = time.time()
result = thread.wait(timeout=0.1)
elapsed = time.time() - start
self.assertFalse(result)
self.assertGreaterEqual(elapsed, 0.1)
def test_wait_returns_true_when_stopped(self):
"""Test that wait() returns True immediately when stop() was called."""
thread = TestThread('WaitStopTest')
thread.stop()
start = time.time()
result = thread.wait(timeout=10)
elapsed = time.time() - start
self.assertTrue(result)
self.assertLess(elapsed, 1)
def test_wait_uses_period_by_default(self):
"""Test that wait() uses self.period when no timeout specified."""
class ShortPeriodThread(APRSDThread):
period = 0.1
def loop(self):
return False
thread = ShortPeriodThread('ShortPeriodTest')
start = time.time()
result = thread.wait()
elapsed = time.time() - start
self.assertFalse(result)
self.assertGreaterEqual(elapsed, 0.1)
self.assertLess(elapsed, 0.5)
def test_str(self):
"""Test __str__() method."""
thread = TestThread('TestThread6')
@@ -253,10 +324,9 @@ class TestAPRSDThreadList(unittest.TestCase):
thread2 = TestThread('TestThread9')
thread_list.add(thread1)
thread_list.add(thread2)
thread_list.stop_all()
self.assertTrue(thread1.thread_stop)
self.assertTrue(thread2.thread_stop)
self.assertTrue(thread1._shutdown_event.is_set())
self.assertTrue(thread2._shutdown_event.is_set())
def test_pause_all(self):
"""Test pause_all() method."""
@@ -334,3 +404,51 @@ class TestAPRSDThreadList(unittest.TestCase):
# Should handle concurrent access without errors
self.assertGreaterEqual(len(thread_list), 0)
def test_join_non_daemon(self):
"""Test join_non_daemon() waits for non-daemon threads."""
class NonDaemonTestThread(APRSDThread):
daemon = False
def __init__(self, name):
super().__init__(name)
self.finished = False
def loop(self):
time.sleep(0.2)
self.finished = True
return False
thread_list = APRSDThreadList()
thread = NonDaemonTestThread('NonDaemonJoinTest')
thread_list.add(thread)
thread.start()
# Stop triggers the event, thread should finish its loop then exit
thread.stop()
thread_list.join_non_daemon(timeout=5.0)
self.assertTrue(thread.finished or not thread.is_alive())
def test_join_non_daemon_skips_daemon_threads(self):
"""Test join_non_daemon() does not wait for daemon threads."""
thread_list = APRSDThreadList()
# Clear existing threads
thread_list.threads_list = []
# Create a daemon thread that loops forever
thread = TestThread('DaemonSkipTest', should_loop=True)
thread_list.add(thread)
thread.start()
# This should return quickly since it's a daemon thread
start = time.time()
thread_list.join_non_daemon(timeout=0.1)
elapsed = time.time() - start
self.assertLess(elapsed, 0.5) # Should not wait for daemon
# Cleanup
thread.stop()
thread.join(timeout=1)
+18 -17
View File
@@ -15,17 +15,18 @@ class TestAPRSDRXThread(unittest.TestCase):
self.packet_queue = queue.Queue()
self.rx_thread = rx.APRSDRXThread(self.packet_queue)
self.rx_thread.pkt_count = 0 # Reset packet count
# Mock time.sleep to speed up tests
self.sleep_patcher = mock.patch('aprsd.threads.rx.time.sleep')
self.mock_sleep = self.sleep_patcher.start()
# Mock self.wait to speed up tests
self.wait_patcher = mock.patch.object(
self.rx_thread, 'wait', return_value=False
)
self.mock_wait = self.wait_patcher.start()
def tearDown(self):
"""Clean up after tests."""
self.wait_patcher.stop()
self.rx_thread.stop()
if self.rx_thread.is_alive():
self.rx_thread.join(timeout=1)
# Stop the sleep patcher
self.sleep_patcher.stop()
def test_init(self):
"""Test initialization."""
@@ -39,13 +40,13 @@ class TestAPRSDRXThread(unittest.TestCase):
self.rx_thread._client = mock.MagicMock()
self.rx_thread.stop()
self.assertTrue(self.rx_thread.thread_stop)
self.assertTrue(self.rx_thread._shutdown_event.is_set())
self.rx_thread._client.close.assert_called()
def test_stop_no_client(self):
"""Test stop() when client is None."""
self.rx_thread.stop()
self.assertTrue(self.rx_thread.thread_stop)
self.assertTrue(self.rx_thread._shutdown_event.is_set())
def test_loop_no_client(self):
"""Test loop() when client is None."""
@@ -237,18 +238,18 @@ class TestAPRSDFilterThread(unittest.TestCase):
"""Process packet - required by base class."""
pass
# Mock APRSDClient to avoid config requirements
self.client_patcher = mock.patch('aprsd.threads.rx.APRSDClient')
self.mock_client = self.client_patcher.start()
self.filter_thread = TestFilterThread('TestFilterThread', self.packet_queue)
# Mock time.sleep to speed up tests
self.sleep_patcher = mock.patch('aprsd.threads.rx.time.sleep')
self.mock_sleep = self.sleep_patcher.start()
def tearDown(self):
"""Clean up after tests."""
self.client_patcher.stop()
self.filter_thread.stop()
if self.filter_thread.is_alive():
self.filter_thread.join(timeout=1)
# Stop the sleep patcher
self.sleep_patcher.stop()
def test_init(self):
"""Test initialization."""
@@ -330,18 +331,18 @@ class TestAPRSDProcessPacketThread(unittest.TestCase):
def process_our_message_packet(self, packet):
pass
# Mock APRSDClient to avoid config requirements
self.client_patcher = mock.patch('aprsd.threads.rx.APRSDClient')
self.mock_client = self.client_patcher.start()
self.process_thread = ConcreteProcessThread(self.packet_queue)
# Mock time.sleep to speed up tests
self.sleep_patcher = mock.patch('aprsd.threads.rx.time.sleep')
self.mock_sleep = self.sleep_patcher.start()
def tearDown(self):
"""Clean up after tests."""
self.client_patcher.stop()
self.process_thread.stop()
if self.process_thread.is_alive():
self.process_thread.join(timeout=1)
# Stop the sleep patcher
self.sleep_patcher.stop()
def test_init(self):
"""Test initialization."""
+46 -40
View File
@@ -72,28 +72,36 @@ class TestAPRSDStatsStoreThread(unittest.TestCase):
def test_init(self):
"""Test APRSDStatsStoreThread initialization."""
thread = APRSDStatsStoreThread()
self.assertEqual(thread.name, 'StatsStore')
self.assertEqual(thread.save_interval, 10)
self.assertTrue(hasattr(thread, 'loop_count'))
with mock.patch('aprsd.threads.stats.CONF') as mock_conf:
mock_conf.stats_store_interval = 10
thread = APRSDStatsStoreThread()
self.assertEqual(thread.name, 'StatsStore')
self.assertEqual(thread.period, 10)
self.assertFalse(thread.daemon)
self.assertTrue(hasattr(thread, 'loop_count'))
def test_init_with_custom_interval(self):
"""Test APRSDStatsStoreThread uses stats_store_interval from config."""
with mock.patch('aprsd.threads.stats.CONF') as mock_conf:
mock_conf.stats_store_interval = 60
thread = APRSDStatsStoreThread()
self.assertEqual(thread.period, 60)
def test_loop_with_save(self):
"""Test loop method when save interval is reached."""
"""Test loop method saves stats every call."""
thread = APRSDStatsStoreThread()
# Mock the collector and save methods
with (
mock.patch('aprsd.stats.collector.Collector') as mock_collector_class,
mock.patch('aprsd.utils.objectstore.ObjectStoreMixin.save') as mock_save,
mock.patch.object(thread, 'wait'),
):
# Setup mock collector to return some stats
mock_collector_instance = mock.Mock()
mock_collector_instance.collect.return_value = {'test': 'data'}
mock_collector_class.return_value = mock_collector_instance
# Set loop_count to match save interval
thread.loop_count = 10
# Call loop
result = thread.loop()
@@ -104,45 +112,43 @@ class TestAPRSDStatsStoreThread(unittest.TestCase):
mock_collector_instance.collect.assert_called_once()
mock_save.assert_called_once()
def test_loop_without_save(self):
"""Test loop method when save interval is not reached."""
def test_loop_calls_wait(self):
"""Test loop method calls wait() at the end."""
thread = APRSDStatsStoreThread()
# Mock the collector and save methods
with (
mock.patch('aprsd.stats.collector.Collector') as mock_collector_class,
mock.patch('aprsd.utils.objectstore.ObjectStoreMixin.save') as mock_save,
mock.patch('aprsd.utils.objectstore.ObjectStoreMixin.save'),
mock.patch.object(thread, 'wait') as mock_wait,
):
# Setup mock collector to return some stats
mock_collector_instance = mock.Mock()
mock_collector_instance.collect.return_value = {'test': 'data'}
mock_collector_class.return_value = mock_collector_instance
# Set loop_count to not match save interval
thread.loop_count = 1
# Call loop
result = thread.loop()
# Should return True (continue looping)
self.assertTrue(result)
# Should not have called save
mock_save.assert_not_called()
# Should have called wait
mock_wait.assert_called_once()
def test_loop_with_exception(self):
"""Test loop method when an exception occurs."""
thread = APRSDStatsStoreThread()
# Mock the collector to raise an exception
with mock.patch('aprsd.stats.collector.Collector') as mock_collector_class:
with (
mock.patch('aprsd.stats.collector.Collector') as mock_collector_class,
mock.patch.object(thread, 'wait'),
):
mock_collector_instance = mock.Mock()
mock_collector_instance.collect.side_effect = RuntimeError('Test exception')
mock_collector_class.return_value = mock_collector_instance
# Set loop_count to match save interval
thread.loop_count = 10
# Should raise the exception
with self.assertRaises(RuntimeError):
thread.loop()
@@ -177,24 +183,31 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
self.assertEqual(thread.period, 15)
self.assertFalse(thread.send_packetlist)
def test_loop_skips_push_when_period_not_reached(self):
"""Test loop does not POST when loop_count not divisible by period."""
def test_loop_pushes_stats_every_call(self):
"""Test loop POSTs stats on every call (timing controlled by wait)."""
thread = APRSDPushStatsThread(
push_url='https://example.com',
frequency_seconds=10,
)
thread.loop_count = 3 # 3 % 10 != 0
with (
mock.patch('aprsd.threads.stats.collector.Collector') as mock_collector,
mock.patch('aprsd.threads.stats.requests.post') as mock_post,
mock.patch('aprsd.threads.stats.time.sleep'),
mock.patch.object(thread, 'wait'),
mock.patch('aprsd.threads.stats.datetime') as mock_dt,
):
mock_collector.return_value.collect.return_value = {}
mock_dt.datetime.now.return_value.strftime.return_value = (
'01-01-2025 12:00:00'
)
mock_post.return_value.status_code = 200
mock_post.return_value.raise_for_status = mock.Mock()
result = thread.loop()
self.assertTrue(result)
mock_collector.return_value.collect.assert_not_called()
mock_post.assert_not_called()
mock_collector.return_value.collect.assert_called_once_with(serializable=True)
mock_post.assert_called_once()
def test_loop_pushes_stats_and_removes_packetlist_by_default(self):
"""Test loop collects stats, POSTs to url/stats, and strips PacketList.packets."""
@@ -203,7 +216,6 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
frequency_seconds=10,
send_packetlist=False,
)
thread.loop_count = 10
collected = {
'PacketList': {'packets': [1, 2, 3], 'rx': 5, 'tx': 1},
@@ -215,7 +227,7 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
'aprsd.threads.stats.collector.Collector'
) as mock_collector_class,
mock.patch('aprsd.threads.stats.requests.post') as mock_post,
mock.patch('aprsd.threads.stats.time.sleep'),
mock.patch.object(thread, 'wait'),
mock.patch('aprsd.threads.stats.datetime') as mock_dt,
):
mock_collector_class.return_value.collect.return_value = collected
@@ -247,7 +259,6 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
frequency_seconds=10,
send_packetlist=True,
)
thread.loop_count = 10
collected = {'PacketList': {'packets': [1, 2, 3], 'rx': 5}}
@@ -256,7 +267,7 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
'aprsd.threads.stats.collector.Collector'
) as mock_collector_class,
mock.patch('aprsd.threads.stats.requests.post') as mock_post,
mock.patch('aprsd.threads.stats.time.sleep'),
mock.patch.object(thread, 'wait'),
mock.patch('aprsd.threads.stats.datetime') as mock_dt,
):
mock_collector_class.return_value.collect.return_value = collected
@@ -276,14 +287,13 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
push_url='https://example.com',
frequency_seconds=10,
)
thread.loop_count = 10
with (
mock.patch(
'aprsd.threads.stats.collector.Collector'
) as mock_collector_class,
mock.patch('aprsd.threads.stats.requests.post') as mock_post,
mock.patch('aprsd.threads.stats.time.sleep'),
mock.patch.object(thread, 'wait'),
mock.patch('aprsd.threads.stats.datetime') as mock_dt,
mock.patch('aprsd.threads.stats.LOGU') as mock_logu,
):
@@ -306,14 +316,13 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
push_url='https://example.com',
frequency_seconds=10,
)
thread.loop_count = 10
with (
mock.patch(
'aprsd.threads.stats.collector.Collector'
) as mock_collector_class,
mock.patch('aprsd.threads.stats.requests.post') as mock_post,
mock.patch('aprsd.threads.stats.time.sleep'),
mock.patch.object(thread, 'wait'),
mock.patch('aprsd.threads.stats.datetime') as mock_dt,
mock.patch('aprsd.threads.stats.LOGU') as mock_logu,
):
@@ -337,14 +346,13 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
push_url='https://example.com',
frequency_seconds=10,
)
thread.loop_count = 10
with (
mock.patch(
'aprsd.threads.stats.collector.Collector'
) as mock_collector_class,
mock.patch('aprsd.threads.stats.requests.post') as mock_post,
mock.patch('aprsd.threads.stats.time.sleep'),
mock.patch.object(thread, 'wait'),
mock.patch('aprsd.threads.stats.datetime') as mock_dt,
mock.patch('aprsd.threads.stats.LOGU') as mock_logu,
):
@@ -368,14 +376,13 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
push_url='https://example.com',
frequency_seconds=10,
)
thread.loop_count = 10
with (
mock.patch(
'aprsd.threads.stats.collector.Collector'
) as mock_collector_class,
mock.patch('aprsd.threads.stats.requests.post') as mock_post,
mock.patch('aprsd.threads.stats.time.sleep'),
mock.patch.object(thread, 'wait'),
mock.patch('aprsd.threads.stats.datetime') as mock_dt,
mock.patch('aprsd.threads.stats.LOGU') as mock_logu,
):
@@ -398,7 +405,6 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
frequency_seconds=10,
send_packetlist=False,
)
thread.loop_count = 10
collected = {'Only': 'data', 'No': 'PacketList'}
@@ -407,7 +413,7 @@ class TestAPRSDPushStatsThread(unittest.TestCase):
'aprsd.threads.stats.collector.Collector'
) as mock_collector_class,
mock.patch('aprsd.threads.stats.requests.post') as mock_post,
mock.patch('aprsd.threads.stats.time.sleep'),
mock.patch.object(thread, 'wait'),
mock.patch('aprsd.threads.stats.datetime') as mock_dt,
):
mock_collector_class.return_value.collect.return_value = collected
+28 -30
View File
@@ -596,9 +596,13 @@ class TestSendPacketThread(unittest.TestCase):
tracker.PacketTrack._instance = None
self.packet = fake.fake_packet(msg_number='123')
self.thread = tx.SendPacketThread(self.packet)
# Mock wait to speed up tests
self.wait_patcher = mock.patch.object(self.thread, 'wait', return_value=False)
self.mock_wait = self.wait_patcher.start()
def tearDown(self):
"""Clean up after tests."""
self.wait_patcher.stop()
self.thread.stop()
if self.thread.is_alive():
self.thread.join(timeout=1)
@@ -608,7 +612,8 @@ class TestSendPacketThread(unittest.TestCase):
"""Test initialization."""
self.assertEqual(self.thread.packet, self.packet)
self.assertIn('TX-', self.thread.name)
self.assertEqual(self.thread.loop_count, 1)
# loop_count starts at 0 from base class, incremented in run()
self.assertEqual(self.thread.loop_count, 0)
@mock.patch('aprsd.threads.tx.tracker.PacketTrack')
def test_loop_packet_acked(self, mock_tracker_class):
@@ -761,7 +766,8 @@ class TestBeaconSendThread(unittest.TestCase):
"""Test initialization."""
thread = tx.BeaconSendThread()
self.assertEqual(thread.name, 'BeaconSendThread')
self.assertEqual(thread._loop_cnt, 1)
self.assertEqual(thread.period, 10) # Uses CONF.beacon_interval
thread.stop()
def test_init_no_coordinates(self):
"""Test initialization without coordinates."""
@@ -772,39 +778,27 @@ class TestBeaconSendThread(unittest.TestCase):
CONF.longitude = None
thread = tx.BeaconSendThread()
self.assertTrue(thread.thread_stop)
self.assertTrue(thread._shutdown_event.is_set())
thread.stop()
@mock.patch('aprsd.threads.tx.send')
def test_loop_send_beacon(self, mock_send):
"""Test loop() sends beacon at interval."""
"""Test loop() sends beacon."""
from oslo_config import cfg
CONF = cfg.CONF
CONF.beacon_interval = 1
CONF.latitude = 40.7128
CONF.longitude = -74.0060
thread = tx.BeaconSendThread()
thread._loop_cnt = 1
# Mock wait to return False (no shutdown)
with mock.patch.object(thread, 'wait', return_value=False):
result = thread.loop()
result = thread.loop()
self.assertTrue(result)
mock_send.assert_called()
@mock.patch('aprsd.threads.tx.send')
def test_loop_not_time(self, mock_send):
"""Test loop() doesn't send before interval."""
from oslo_config import cfg
CONF = cfg.CONF
CONF.beacon_interval = 10
thread = tx.BeaconSendThread()
thread._loop_cnt = 5
result = thread.loop()
self.assertTrue(result)
mock_send.assert_not_called()
self.assertTrue(result)
mock_send.assert_called()
thread.stop()
@mock.patch('aprsd.threads.tx.send')
@mock.patch('aprsd.threads.tx.APRSDClient')
@@ -814,13 +808,17 @@ class TestBeaconSendThread(unittest.TestCase):
CONF = cfg.CONF
CONF.beacon_interval = 1
CONF.latitude = 40.7128
CONF.longitude = -74.0060
thread = tx.BeaconSendThread()
thread._loop_cnt = 1
mock_send.side_effect = Exception('Send error')
with mock.patch('aprsd.threads.tx.LOG') as mock_log:
result = thread.loop()
self.assertTrue(result)
mock_log.error.assert_called()
mock_client_class.return_value.reset.assert_called()
# Mock wait to return False (no shutdown signaled during error wait)
with mock.patch.object(thread, 'wait', return_value=False):
result = thread.loop()
self.assertTrue(result)
mock_log.error.assert_called()
mock_client_class.return_value.reset.assert_called()
thread.stop()
Generated
+32 -32
View File
@@ -97,7 +97,7 @@ requires-dist = [
{ name = "kiss3", specifier = "==8.0.0" },
{ name = "loguru", specifier = "==0.7.3" },
{ name = "markdown-it-py", specifier = "==4.0.0" },
{ name = "marshmallow", specifier = "==3.26.1" },
{ name = "marshmallow", specifier = "==3.26.2" },
{ name = "mdurl", specifier = "==0.1.2" },
{ name = "mypy", marker = "extra == 'dev'" },
{ name = "mypy", marker = "extra == 'type'" },
@@ -126,7 +126,7 @@ requires-dist = [
{ name = "rich", specifier = "==14.2.0" },
{ name = "ruff", marker = "extra == 'dev'" },
{ name = "rush", specifier = "==2021.4.0" },
{ name = "setuptools", specifier = "==80.9.0" },
{ name = "setuptools", specifier = "==82.0.1" },
{ name = "stevedore", specifier = "==5.6.0" },
{ name = "thesmuggler", specifier = "==1.0.1" },
{ name = "timeago", specifier = "==1.0.16" },
@@ -142,7 +142,7 @@ requires-dist = [
{ name = "typing-inspect", specifier = "==0.9.0" },
{ name = "tzlocal", specifier = "==5.3.1" },
{ name = "update-checker", specifier = "==0.18.0" },
{ name = "urllib3", specifier = "==2.6.2" },
{ name = "urllib3", specifier = "==2.6.3" },
{ name = "wheel", marker = "extra == 'dev'" },
{ name = "wrapt", specifier = "==2.0.1" },
{ name = "zipp", specifier = "==3.23.0" },
@@ -671,14 +671,14 @@ wheels = [
[[package]]
name = "marshmallow"
version = "3.26.1"
version = "3.26.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ab/5e/5e53d26b42ab75491cda89b871dab9e97c840bf12c63ec58a1919710cd06/marshmallow-3.26.1.tar.gz", hash = "sha256:e6d8affb6cb61d39d26402096dc0aee12d5a26d490a121f118d2e81dc0719dc6", size = 221825, upload-time = "2025-02-03T15:32:25.093Z" }
sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/75/51952c7b2d3873b44a0028b1bd26a25078c18f92f256608e8d1dc61b39fd/marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", size = 50878, upload-time = "2025-02-03T15:32:22.295Z" },
{ url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" },
]
[[package]]
@@ -1110,11 +1110,11 @@ wheels = [
[[package]]
name = "setuptools"
version = "80.9.0"
version = "82.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
]
[[package]]
@@ -1316,37 +1316,37 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.6.2"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" },
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "uv"
version = "0.9.26"
version = "0.11.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/6a/ef4ea19097ecdfd7df6e608f93874536af045c68fd70aa628c667815c458/uv-0.9.26.tar.gz", hash = "sha256:8b7017a01cc48847a7ae26733383a2456dd060fc50d21d58de5ee14f6b6984d7", size = 3790483, upload-time = "2026-01-15T20:51:33.582Z" }
sdist = { url = "https://files.pythonhosted.org/packages/dd/f3/8aceeab67ea69805293ab290e7ca8cc1b61a064d28b8a35c76d8eba063dd/uv-0.11.6.tar.gz", hash = "sha256:e3b21b7e80024c95ff339fcd147ac6fc3dd98d3613c9d45d3a1f4fd1057f127b", size = 4073298, upload-time = "2026-04-09T12:09:01.738Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/e1/5c0b17833d5e3b51a897957348ff8d937a3cdfc5eea5c4a7075d8d7b9870/uv-0.9.26-py3-none-linux_armv6l.whl", hash = "sha256:7dba609e32b7bd13ef81788d580970c6ff3a8874d942755b442cffa8f25dba57", size = 22638031, upload-time = "2026-01-15T20:51:44.187Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8b/68ac5825a615a8697e324f52ac0b92feb47a0ec36a63759c5f2931f0c3a0/uv-0.9.26-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b815e3b26eeed00e00f831343daba7a9d99c1506883c189453bb4d215f54faac", size = 21507805, upload-time = "2026-01-15T20:50:42.574Z" },
{ url = "https://files.pythonhosted.org/packages/0d/a2/664a338aefe009f6e38e47455ee2f64a21da7ad431dbcaf8b45d8b1a2b7a/uv-0.9.26-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1b012e6c4dfe767f818cbb6f47d02c207c9b0c82fee69a5de6d26ffb26a3ef3c", size = 20249791, upload-time = "2026-01-15T20:50:49.835Z" },
{ url = "https://files.pythonhosted.org/packages/ba/3d/b8186a7dec1346ca4630c674b760517d28bffa813a01965f4b57596bacf3/uv-0.9.26-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:ea296b700d7c4c27acdfd23ffaef2b0ecdd0aa1b58d942c62ee87df3b30f06ac", size = 22039108, upload-time = "2026-01-15T20:51:00.675Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a9/687fd587e7a3c2c826afe72214fb24b7f07b0d8b0b0300e6a53b554180ea/uv-0.9.26-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:1ba860d2988efc27e9c19f8537a2f9fa499a8b7ebe4afbe2d3d323d72f9aee61", size = 22174763, upload-time = "2026-01-15T20:50:46.471Z" },
{ url = "https://files.pythonhosted.org/packages/38/69/7fa03ee7d59e562fca1426436f15a8c107447d41b34e0899e25ee69abfad/uv-0.9.26-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8610bdfc282a681a0a40b90495a478599aa3484c12503ef79ef42cd271fd80fe", size = 22189861, upload-time = "2026-01-15T20:51:15.618Z" },
{ url = "https://files.pythonhosted.org/packages/10/2d/4be446a2ec09f3c428632b00a138750af47c76b0b9f987e9a5b52fef0405/uv-0.9.26-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4bf700bd071bd595084b9ee0a8d77c6a0a10ca3773d3771346a2599f306bd9c", size = 23005589, upload-time = "2026-01-15T20:50:57.185Z" },
{ url = "https://files.pythonhosted.org/packages/c3/16/860990b812136695a63a8da9fb5f819c3cf18ea37dcf5852e0e1b795ca0d/uv-0.9.26-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:89a7beea1c692f76a6f8da13beff3cbb43f7123609e48e03517cc0db5c5de87c", size = 24713505, upload-time = "2026-01-15T20:51:04.366Z" },
{ url = "https://files.pythonhosted.org/packages/01/43/5d7f360d551e62d8f8bf6624b8fca9895cea49ebe5fce8891232d7ed2321/uv-0.9.26-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:182f5c086c7d03ad447e522b70fa29a0302a70bcfefad4b8cd08496828a0e179", size = 24342500, upload-time = "2026-01-15T20:51:47.863Z" },
{ url = "https://files.pythonhosted.org/packages/9b/9c/2bae010a189e7d8e5dc555edcfd053b11ce96fad2301b919ba0d9dd23659/uv-0.9.26-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d8c62a501f13425b4b0ce1dd4c6b82f3ce5a5179e2549c55f4bb27cc0eb8ef8", size = 23222578, upload-time = "2026-01-15T20:51:36.85Z" },
{ url = "https://files.pythonhosted.org/packages/38/16/a07593a040fe6403c36f3b0a99b309f295cbfe19a1074dbadb671d5d4ef7/uv-0.9.26-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7e89798bd3df7dcc4b2b4ac4e2fc11d6b3ff4fe7d764aa3012d664c635e2922", size = 23250201, upload-time = "2026-01-15T20:51:19.117Z" },
{ url = "https://files.pythonhosted.org/packages/23/a0/45893e15ad3ab842db27c1eb3b8605b9b4023baa5d414e67cfa559a0bff0/uv-0.9.26-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:60a66f1783ec4efc87b7e1f9bd66e8fd2de3e3b30d122b31cb1487f63a3ea8b7", size = 22229160, upload-time = "2026-01-15T20:51:22.931Z" },
{ url = "https://files.pythonhosted.org/packages/fc/c0/20a597a5c253702a223b5e745cf8c16cd5dd053080f896bb10717b3bedec/uv-0.9.26-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:63c6a1f1187facba1fb45a2fa45396980631a3427ac11b0e3d9aa3ebcf2c73cf", size = 23090730, upload-time = "2026-01-15T20:51:26.611Z" },
{ url = "https://files.pythonhosted.org/packages/40/c9/744537867d9ab593fea108638b57cca1165a0889cfd989981c942b6de9a5/uv-0.9.26-py3-none-musllinux_1_1_i686.whl", hash = "sha256:c6d8650fbc980ccb348b168266143a9bd4deebc86437537caaf8ff2a39b6ea50", size = 22436632, upload-time = "2026-01-15T20:51:12.045Z" },
{ url = "https://files.pythonhosted.org/packages/6b/e2/be683e30262f2cf02dcb41b6c32910a6939517d50ec45f502614d239feb7/uv-0.9.26-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:25278f9298aa4dade38241a93d036739b0c87278dcfad1ec1f57e803536bfc49", size = 23480064, upload-time = "2026-01-15T20:50:53.333Z" },
{ url = "https://files.pythonhosted.org/packages/50/3e/4a7e6bc5db2beac9c4966f212805f1903d37d233f2e160737f0b24780ada/uv-0.9.26-py3-none-win32.whl", hash = "sha256:10d075e0193e3a0e6c54f830731c4cb965d6f4e11956e84a7bed7ed61d42aa27", size = 21000052, upload-time = "2026-01-15T20:51:40.753Z" },
{ url = "https://files.pythonhosted.org/packages/07/5d/eb80c6eff2a9f7d5cf35ec84fda323b74aa0054145db28baf72d35a7a301/uv-0.9.26-py3-none-win_amd64.whl", hash = "sha256:0315fc321f5644b12118f9928086513363ed9b29d74d99f1539fda1b6b5478ab", size = 23684930, upload-time = "2026-01-15T20:51:08.448Z" },
{ url = "https://files.pythonhosted.org/packages/ed/9d/3b2631931649b1783f5024796ca8ad2b42a01a829b9ce1202d973cc7bce5/uv-0.9.26-py3-none-win_arm64.whl", hash = "sha256:344ff38749b6cd7b7dfdfb382536f168cafe917ae3a5aa78b7a63746ba2a905b", size = 22158123, upload-time = "2026-01-15T20:51:30.939Z" },
{ url = "https://files.pythonhosted.org/packages/1f/fe/4b61a3d5ad9d02e8a4405026ccd43593d7044598e0fa47d892d4dafe44c9/uv-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:ada04dcf89ddea5b69d27ac9cdc5ef575a82f90a209a1392e930de504b2321d6", size = 23780079, upload-time = "2026-04-09T12:08:56.609Z" },
{ url = "https://files.pythonhosted.org/packages/52/db/d27519a9e1a5ffee9d71af1a811ad0e19ce7ab9ae815453bef39dd479389/uv-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5be013888420f96879c6e0d3081e7bcf51b539b034a01777041934457dfbedf3", size = 23214721, upload-time = "2026-04-09T12:09:32.228Z" },
{ url = "https://files.pythonhosted.org/packages/a6/8f/4399fa8b882bd7e0efffc829f73ab24d117d490a93e6bc7104a50282b854/uv-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ffa5dc1cbb52bdce3b8447e83d1601a57ad4da6b523d77d4b47366db8b1ceb18", size = 21750109, upload-time = "2026-04-09T12:09:24.357Z" },
{ url = "https://files.pythonhosted.org/packages/32/07/5a12944c31c3dda253632da7a363edddb869ed47839d4d92a2dc5f546c93/uv-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bfb107b4dade1d2c9e572992b06992d51dd5f2136eb8ceee9e62dd124289e825", size = 23551146, upload-time = "2026-04-09T12:09:10.439Z" },
{ url = "https://files.pythonhosted.org/packages/79/5b/2ec8b0af80acd1016ed596baf205ddc77b19ece288473b01926c4a9cf6db/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:9e2fe7ce12161d8016b7deb1eaad7905a76ff7afec13383333ca75e0c4b5425d", size = 23331192, upload-time = "2026-04-09T12:09:34.792Z" },
{ url = "https://files.pythonhosted.org/packages/62/7d/eea35935f2112b21c296a3e42645f3e4b1aa8bcd34dcf13345fbd55134b7/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ed9c6f70c25e8dfeedddf4eddaf14d353f5e6b0eb43da9a14d3a1033d51d915", size = 23337686, upload-time = "2026-04-09T12:09:18.522Z" },
{ url = "https://files.pythonhosted.org/packages/21/47/2584f5ab618f6ebe9bdefb2f765f2ca8540e9d739667606a916b35449eec/uv-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68a013e609cebf82077cbeeb0809ed5e205257814273bfd31e02fc0353bbfc2", size = 25008139, upload-time = "2026-04-09T12:09:03.983Z" },
{ url = "https://files.pythonhosted.org/packages/95/81/497ae5c1d36355b56b97dc59f550c7e89d0291c163a3f203c6f341dff195/uv-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93f736dddca03dae732c6fdea177328d3bc4bf137c75248f3d433c57416a4311", size = 25712458, upload-time = "2026-04-09T12:09:07.598Z" },
{ url = "https://files.pythonhosted.org/packages/3c/1c/74083238e4fab2672b63575b9008f1ea418b02a714bcfcf017f4f6a309b6/uv-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e96a66abe53fced0e3389008b8d2eff8278cfa8bb545d75631ae8ceb9c929aba", size = 24915507, upload-time = "2026-04-09T12:08:50.892Z" },
{ url = "https://files.pythonhosted.org/packages/5a/ee/e14fe10ba455a823ed18233f12de6699a601890905420b5c504abf115116/uv-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b096311b2743b228df911a19532b3f18fa420bf9530547aecd6a8e04bbfaccd", size = 24971011, upload-time = "2026-04-09T12:08:54.016Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/7b9c83eaadf98e343317ff6384a7227a4855afd02cdaf9696bcc71ee6155/uv-0.11.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:904d537b4a6e798015b4a64ff5622023bd4601b43b6cd1e5f423d63471f5e948", size = 23640234, upload-time = "2026-04-09T12:09:15.735Z" },
{ url = "https://files.pythonhosted.org/packages/d6/51/75ccdd23e76ff1703b70eb82881cd5b4d2a954c9679f8ef7e0136ef2cfab/uv-0.11.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4ed8150c26b5e319381d75ae2ce6aba1e9c65888f4850f4e3b3fa839953c90a5", size = 24452664, upload-time = "2026-04-09T12:09:26.875Z" },
{ url = "https://files.pythonhosted.org/packages/4d/86/ace80fe47d8d48b5e3b5aee0b6eb1a49deaacc2313782870250b3faa36f5/uv-0.11.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1c9218c8d4ac35ca6e617fb0951cc0ab2d907c91a6aea2617de0a5494cf162c0", size = 24494599, upload-time = "2026-04-09T12:09:37.368Z" },
{ url = "https://files.pythonhosted.org/packages/05/2d/4b642669b56648194f026de79bc992cbfc3ac2318b0a8d435f3c284934e8/uv-0.11.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9e211c83cc890c569b86a4183fcf5f8b6f0c7adc33a839b699a98d30f1310d3a", size = 24159150, upload-time = "2026-04-09T12:09:13.17Z" },
{ url = "https://files.pythonhosted.org/packages/ae/24/7eecd76fe983a74fed1fc700a14882e70c4e857f1d562a9f2303d4286c12/uv-0.11.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d2a1d2089afdf117ad19a4c1dd36b8189c00ae1ad4135d3bfbfced82342595cf", size = 25164324, upload-time = "2026-04-09T12:08:59.56Z" },
{ url = "https://files.pythonhosted.org/packages/27/e0/bbd4ba7c2e5067bbba617d87d306ec146889edaeeaa2081d3e122178ca08/uv-0.11.6-py3-none-win32.whl", hash = "sha256:6e8344f38fa29f85dcfd3e62dc35a700d2448f8e90381077ef393438dcd5012e", size = 22865693, upload-time = "2026-04-09T12:09:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/a5/33/1983ce113c538a856f2d620d16e39691962ecceef091a84086c5785e32e5/uv-0.11.6-py3-none-win_amd64.whl", hash = "sha256:a28bea69c1186303d1200f155c7a28c449f8a4431e458fcf89360cc7ef546e40", size = 25371258, upload-time = "2026-04-09T12:09:40.52Z" },
{ url = "https://files.pythonhosted.org/packages/35/01/be0873f44b9c9bc250fcbf263367fcfc1f59feab996355bcb6b52fff080d/uv-0.11.6-py3-none-win_arm64.whl", hash = "sha256:a78f6d64b9950e24061bc7ec7f15ff8089ad7f5a976e7b65fcadce58fe02f613", size = 23869585, upload-time = "2026-04-09T12:09:29.425Z" },
]
[[package]]