1
0
mirror of https://github.com/craigerl/aprsd.git synced 2026-08-16 16:43:52 -04:00

Compare commits

...

39 Commits

Author SHA1 Message Date
hemna 698d218572 Fix JSON serialization of UnknownPacket in stats
The SimpleJSONEncoder didn't handle dataclasses like UnknownPacket,
causing a TypeError when saving stats to disk. Added support for
dataclasses using dataclasses.asdict().
2026-02-27 23:40:14 -05:00
hemna fcfb349d29 Fix CLI command inconsistencies
- Refactor duplicate plugin discovery code into aprsd/utils/package.py
- Fix inconsistent --profile option in listen.py (now uses common_options)
- Add common_options decorator to completion command for consistency
- Improve healthcheck error message for missing APRSClientStats
- Consolidate signal handler in listen.py to use shared one from main.py
2026-02-27 23:35:38 -05:00
hemna 7172d6352f address failure for healthcheck 2026-02-19 13:33:55 -05:00
hemna bc8a24bea4 Fix master-build.yml: remove zero-width spaces from GitHub Actions template syntax 2026-02-19 08:33:18 -05:00
hemna ac35c44bc2 another attempt at master build for github 2026-02-18 21:58:54 -05:00
hemna 8483d93965 try and fix master build 2026-02-18 14:30:59 -05:00
hemna 0248f40604 fixed some type declaration inconsistencies. 2026-02-18 14:20:52 -05:00
hemna 6ea9889369 make consumer call signature consistent. 2026-02-18 14:11:25 -05:00
hemna 2b7e42802b update the keepalive for kiss 2026-02-18 14:00:52 -05:00
hemna c99a9c919d fixed inconsistent driver send() declaration.
The KISS drivers aren't adhering to the protocol for
defining the send() method.  both now specify that they
return a bool.
2026-02-18 13:49:40 -05:00
hemna 202c689658 Replace insecure pickle serialization with JSON
SECURITY FIX: Replace pickle.load() with json.load() to eliminate
remote code execution vulnerability from malicious pickle files.

Changes:
- Update ObjectStoreMixin to use JSON instead of pickle
- Add PacketJSONDecoder to reconstruct Packet objects from JSON
- Change file extension from .p to .json
- Add warning when old pickle files detected
- Add OrderedDict restoration for PacketList
- Update all tests to work with JSON format

Users with existing pickle files must run:
  aprsd dev migrate-pickle

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-17 16:07:55 -05:00
hemna 0701db2629 docs on listen 2026-02-10 19:09:07 -05:00
hemna c5ca4f11af Added new APRSDPushStatsThread
This allows an aprsd server instance to push it's to a remote
location.
2026-02-10 18:49:23 -05:00
hemna 008fe3c83e Fixed an issue with dev command 2026-02-07 17:17:15 -05:00
hemna 3128f24ef7 reverse the threaded plugin processing.
Considering how little processing each plugin has, it's
a bit overkill for now to have a threaded processing of plugins.

Also had issues where the help plugin was responding when it shouldn't.
2026-02-06 17:31:05 -05:00
hemna 6968f16cec update multiarch build on tag 2026-02-05 17:23:35 -05:00
hemna 03da33c905 change python tests 2026-02-05 17:17:14 -05:00
hemna 8b500ac5f1 added ruff to tox 2026-02-05 17:11:40 -05:00
hemna c62d0545c6 linting fix for test_tx 2026-02-05 17:04:57 -05:00
hemna 6f9e6b2993 fix issue with getting the right plugin version 2026-01-26 16:57:24 -05:00
hemna 7f7d03ea69 Added export-config
This allows aprsd to export the config options for aprsd
and/or the installed plugins.
2026-01-26 16:12:25 -05:00
hemna 0b01881f46 Added new export-plugins command
This allows the user to run and see the details about all of the
installed plugins in a json format.
2026-01-26 15:52:16 -05:00
github-actions[bot] 2f698ed95e chore: update AUTHORS [skip ci] 2026-01-25 18:05:24 +00:00
hemna 2180a52a9f added some mocks to speed up tests 2026-01-25 13:04:47 -05:00
hemna 95bd43a0b1 update the pyproject to point to documentation 2026-01-24 22:12:59 -05:00
hemna b4763f969c upgrade setuptools 2026-01-23 17:57:17 -05:00
hemna 3bcd03a514 Fix client / driver inconsistencies from protocol
The client registry defined a protocol that all drivers had
to implement.  This patch ensures that all methods are
consistent in the protocol definition.
2026-01-23 09:18:35 -05:00
hemna ee61bf5fd5 Updates for plugins to make them more consistent.
Got rid of some comment blocks and inconsistent logging messsages
2026-01-22 23:35:09 -05:00
hemna 7151cb5d07 Updated docs. 2026-01-21 20:27:38 -05:00
hemna 730f6585af updated docs. Added PLUGINS.md 2026-01-21 20:20:57 -05:00
hemna f9cdb45ea0 fix pep8 failure 2026-01-21 17:53:39 -05:00
hemna 72371cd4ed Account for required option
This patch accounts for a required config option in the
process_standard_options_no_config decorator
2026-01-21 16:37:01 -05:00
hemna cefe3e30e7 Removed AVWX plugin.
The AVWX Based weather plugin requires a subscription to the AVWX
API, or to self host a running AVWX API.  So this plugin has been
moved to it's own external plugin here:
aprsd-avwx-plugin:

https://github.com/hemna/aprsd-avwx-plugin
2026-01-21 16:27:14 -05:00
hemna d783a01400 moved optional deps into pyproject.toml 2026-01-21 15:54:01 -05:00
github-actions[bot] f7e4c47715 chore: update AUTHORS [skip ci] 2026-01-21 20:46:07 +00:00
hemna 24bc86424e Added --output-json for aprsd sample-config
This adds the ability to output the sample config
as json for non-human processing.
2026-01-21 15:45:48 -05:00
hemna e3fda752f6 Remove openweathermap
OpenWeatherMap based plugins have been moved to the external
aprsd-openweathermap-plugins here:

https://github.com/hemna/aprsd-openweathermap-plugin

OWM plugins have been removed from aprsd proper because
they require a paid subscription now.  bye.
2026-01-21 15:27:05 -05:00
hemna da3ef77ea3 upgrade to python 3.11 2026-01-20 17:31:14 -05:00
hemna d2cb208be8 Added new owner_callsign
the owner_callsign is a new [DEFAULT]
option that must be set.  It's the legal FCC callsign
associated with the license that owns this instances of APRSD.
that might be different than callsign, because the name of the
aprsd might be a bot for the APRS network that's useful,
such as 'REPEAT'
2026-01-20 17:27:47 -05:00
59 changed files with 2895 additions and 1022 deletions
+2 -1
View File
@@ -49,7 +49,8 @@ jobs:
file: ./docker/Dockerfile
platforms: linux/amd64,linux/arm64
build-args: |
INSTALL_TYPE=pypi
INSTALL_TYPE=github
BRANCH=${{ steps.version.outputs.tag }}
VERSION=${{ steps.version.outputs.version }}
BUILDX_QEMU_ENV=true
push: true
+24 -2
View File
@@ -35,9 +35,31 @@ jobs:
needs: tox
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Get Branch Name
id: branch-name
uses: tj-actions/branch-names@v8
- name: Resolve Docker Tag
id: docker-tag
env:
BRANCH_NAME: ${{ steps.branch-name.outputs.current_branch }}
run: |
#!/bin/bash
branch="${BRANCH_NAME}"
# If branch is empty, use 'master' as fallback
if [ -z "$branch" ]; then
echo "Branch is empty, using 'master'"
tag="master"
else
# Sanitize branch name for Docker tag (replace / with -)
tag="${branch//\//-}"
echo "Using sanitized branch: $tag"
fi
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
echo "Docker tag will be: ${tag}"
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker Buildx
@@ -55,8 +77,8 @@ jobs:
file: ./Dockerfile
build-args: |
INSTALL_TYPE=github
BRANCH=${{ steps.branch-name.outputs.current_branch }}
BRANCH=${{ steps.docker-tag.outputs.tag }}
BUILDX_QEMU_ENV=true
push: true
tags: |
hemna6969/aprsd:${{ steps.branch-name.outputs.current_branch }}
hemna6969/aprsd:${{ steps.docker-tag.outputs.tag }}
+1 -1
View File
@@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11"]
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
+430
View File
@@ -0,0 +1,430 @@
# APRSD Built-in Plugins
APRSD comes with several built-in plugins that provide various functionality out of the box. These plugins are automatically available when you install APRSD and can be enabled or disabled through the configuration file.
## Message Command Plugins
These plugins respond to APRS messages sent to your APRSD callsign.
### PingPlugin
**Command:** `ping`, `p`, or `p` followed by a space
**Description:** Responds with "Pong!" and the current time in HH:MM:SS format.
**Usage:** Send a message containing "ping" to your APRSD callsign.
**Example:**
```
You: ping
APRSD: Pong! 14:30:05
```
**Configuration:** No configuration required.
**Plugin Path:** `aprsd.plugins.ping.PingPlugin`
---
### FortunePlugin
**Command:** `fortune`, `f`, or `f` followed by a space
**Description:** Returns a random fortune cookie message using the system's `fortune` command.
**Usage:** Send a message containing "fortune" to your APRSD callsign.
**Requirements:** Requires the `fortune` command to be installed on the system. The plugin will automatically search common installation paths (`/usr/games/fortune`, `/usr/local/bin/fortune`, `/usr/bin/fortune`) and disable itself if not found.
**Example:**
```
You: fortune
APRSD: A journey of a thousand miles begins with a single step.
```
**Configuration:** No configuration required.
**Plugin Path:** `aprsd.plugins.fortune.FortunePlugin`
---
### TimePlugin
**Command:** `time`, `t`, or `t` followed by a space
**Description:** Returns the current local time of the APRSD server in a human-readable format (fuzzy time) with timezone information.
**Usage:** Send a message containing "time" to your APRSD callsign.
**Example:**
```
You: time
APRSD: half past two (14:30 PDT)
```
**Configuration:** No configuration required. Uses the system's local timezone.
**Plugin Path:** `aprsd.plugins.time.TimePlugin`
---
### VersionPlugin
**Command:** `version`, `v`, or `v` followed by a space
**Description:** Returns the APRSD version number, server uptime, and owner callsign.
**Usage:** Send a message containing "version" to your APRSD callsign.
**Example:**
```
You: version
APRSD: APRSD ver:4.2.4 uptime:2 days, 5:30:15 owner:WB4BOR
```
**Configuration:** No configuration required.
**Plugin Path:** `aprsd.plugins.version.VersionPlugin`
---
### USWeatherPlugin
**Command:** `weather`, `w`, or `W` (w or W at start of message)
**Description:** Provides weather information for locations within the United States only. Uses the forecast.weather.gov API to fetch weather data based on the GPS beacon location of the calling callsign (or optionally a specified callsign).
**Usage:**
```
You: weather
APRSD: 72F(68F/75F) Partly cloudy. Tonight, Clear.
You: weather WB4BOR
APRSD: 65F(60F/70F) Sunny. Tonight, Partly cloudy.
```
**Requirements:** Requires an `aprs_fi.apiKey` configuration option.
**Configuration:**
- `aprs_fi.apiKey` - API key from aprs.fi account
**Note:** This plugin does not require an API key for the weather service itself, only for aprs.fi to get the GPS location.
**Plugin Path:** `aprsd.plugins.weather.USWeatherPlugin`
---
### USMetarPlugin
**Command:** `metar`, `m`, `M`, or `m` followed by a space (m or M at start of message)
**Description:** Provides METAR (Meteorological Aerodrome Report) weather reports for stations within the United States only. Uses the forecast.weather.gov API.
**Usage:**
```
You: metar
APRSD: KORD 101451Z 28010KT 10SM FEW250 22/12 A3001
You: metar KORD
APRSD: KORD 101451Z 28010KT 10SM FEW250 22/12 A3001
```
**Requirements:** Requires an `aprs_fi.apiKey` configuration option (when querying by callsign location).
**Configuration:**
- `aprs_fi.apiKey` - API key from aprs.fi account
**Note:** When specifying a station identifier directly (e.g., "metar KORD"), the aprs.fi API key is not required.
**Plugin Path:** `aprsd.plugins.weather.USMetarPlugin`
---
## WatchList Plugins
These plugins monitor APRS traffic and can send notifications based on watch list criteria.
### NotifySeenPlugin
**Type:** WatchList Plugin
**Description:** Monitors callsigns in the watch list and sends a notification message when a callsign that hasn't been seen recently (based on the configured age limit) appears on the APRS network.
**How it works:**
- Tracks callsigns configured in the watch list
- Monitors all incoming APRS packets
- When a callsign in the watch list is seen and hasn't been seen recently (exceeds the age limit), sends a notification message to the configured alert callsign
**Configuration:**
- `watch_list.enabled` - Must be set to `true`
- `watch_list.callsigns` - List of callsigns to watch for (supports wildcards like `KM6LYW*`)
- `watch_list.alert_callsign` - Callsign to send notifications to
- `watch_list.alert_time_seconds` - Time threshold in seconds (default: 3600)
**Example Notification:**
```
APRSD -> WB4BOR: KM6LYW was just seen by type:'BeaconPacket'
```
**Plugin Path:** `aprsd.plugins.notify.NotifySeenPlugin`
---
## HelpPlugin
**Command:** `help`, `h`, or `H` (h or H at start of message)
**Description:** Provides help information about available plugins. Can list all available plugins or provide specific help for a named plugin.
**Usage:**
```
You: help
APRSD: Send APRS MSG of 'help' or 'help <plugin>'
plugins: fortune ping time version weather
You: help weather
APRSD: openweathermap: Send ^[wW] to get weather from your location
openweathermap: Send ^[wW] <callsign> to get weather from <callsign>
```
**Configuration:** Can be disabled by setting `load_help_plugin = false` in the configuration. The HelpPlugin is enabled by default and does not need to be listed in `enabled_plugins`.
**Plugin Path:** `aprsd.plugin.HelpPlugin`
---
## Enabling Built-in Plugins
Built-in plugins are enabled through the `enabled_plugins` configuration option in your APRSD configuration file. List the full Python path to each plugin class you want to enable, separated by commas.
**Example Configuration:**
```ini
[DEFAULT]
enabled_plugins = aprsd.plugins.fortune.FortunePlugin,aprsd.plugins.ping.PingPlugin,aprsd.plugins.time.TimePlugin,aprsd.plugins.weather.USWeatherPlugin,aprsd.plugins.version.VersionPlugin,aprsd.plugins.notify.NotifySeenPlugin
```
**Important Notes:**
- The HelpPlugin is enabled by default and does not need to be listed in `enabled_plugins`. It can be disabled by setting `load_help_plugin = false`.
- Some plugins may require additional configuration (API keys, etc.) and will automatically disable themselves if required configuration is missing.
- Weather plugins may use the same command patterns. Only one weather plugin should be enabled at a time to avoid conflicts.
- Similarly, only one METAR plugin should be enabled at a time.
---
## Listing Available Plugins
You can see all available built-in plugins, along with their descriptions and command patterns, by running:
```bash
aprsd list-plugins
```
This command will show:
- Built-in plugins included with APRSD
- Available plugins on PyPI that can be installed
- Currently installed third-party plugins
---
## Plugin Types
APRSD plugins come in different types:
### RegexCommand Plugins
These plugins respond to text commands in APRS messages. They use regular expressions to match command patterns and respond with text messages.
### WatchList Plugins
These plugins monitor APRS traffic and can send notifications based on watch list criteria. They don't respond to direct commands but instead react to packets from callsigns in the watch list.
---
## Getting API Keys
Some plugins require API keys:
### aprs.fi API Key
Required for plugins that need to look up GPS locations of callsigns (like USWeatherPlugin and USMetarPlugin):
- Get your API key at: https://aprs.fi/api/info
**Note:** External weather plugins may require additional API keys. Check the documentation for the specific plugin you're using.
---
## Finding External Plugins and Extensions
APRSD supports external plugins and extensions that extend the functionality beyond the built-in plugins. These are distributed as separate Python packages that follow a specific naming convention.
### Naming Convention
All external APRSD plugins and extensions follow a consistent naming scheme:
- **Plugins:** `aprsd-<name>-plugin`
- **Extensions:** `aprsd-<name>-extension`
For example:
- `aprsd-email-plugin` - A plugin for email functionality
- `aprsd-admin-extension` - An extension for web administration
### Finding Plugins and Extensions
#### PyPI (Python Package Index)
You can find all available APRSD plugins and extensions on PyPI:
- **Search for plugins:** https://pypi.org/search/?q=aprsd+-plugin
- **Search for extensions:** https://pypi.org/search/?q=aprsd+-extension
- **General APRSD search:** https://pypi.org/search/?q=aprsd
The `aprsd list-plugins` command also shows available plugins and extensions from PyPI along with installation status.
#### GitHub
Many APRSD plugins and extensions are hosted on GitHub under the [hemna organization](https://github.com/hemna):
- **Organization:** https://github.com/hemna/
- **Search for plugins:** https://github.com/orgs/hemna/repositories?q=aprsd-plugin
- **Search for extensions:** https://github.com/orgs/hemna/repositories?q=aprsd-extension
### Installing External Plugins and Extensions
To install an external plugin or extension, use pip:
```bash
pip install aprsd-<name>-plugin
# or
pip install aprsd-<name>-extension
```
After installation, the plugin or extension will be automatically discovered by APRSD. You may need to add it to your `enabled_plugins` configuration or configure it according to its documentation.
### Available External Plugins
The following external plugins are available:
#### Email Plugin
- **PyPI:** https://pypi.org/project/aprsd-email-plugin/
- **GitHub:** https://github.com/hemna/aprsd-email-plugin
- **Description:** Send and receive email via APRS messages.
#### Location Plugin
- **PyPI:** https://pypi.org/project/aprsd-location-plugin/
- **GitHub:** https://github.com/hemna/aprsd-location-plugin
- **Description:** Get the latest GPS location of a callsign.
#### Location Data Plugin
- **PyPI:** https://pypi.org/project/aprsd-locationdata-plugin/
- **GitHub:** https://github.com/hemna/aprsd-locationdata-plugin
- **Description:** Get detailed GPS location data for a callsign.
#### DigiPi Plugin
- **PyPI:** https://pypi.org/project/aprsd-digipi-plugin/
- **GitHub:** https://github.com/hemna/aprsd-digipi-plugin
- **Description:** Look for DigiPi beacon packets and provide DigiPi-specific functionality.
#### W3W Plugin
- **PyPI:** https://pypi.org/project/aprsd-w3w-plugin/
- **GitHub:** https://github.com/hemna/aprsd-w3w-plugin
- **Description:** Get What3Words (w3w) coordinates for a location.
#### MQTT Plugin
- **PyPI:** https://pypi.org/project/aprsd-mqtt-plugin/
- **GitHub:** https://github.com/hemna/aprsd-mqtt-plugin
- **Description:** Send APRS packets to an MQTT topic for integration with IoT systems.
#### Telegram Plugin
- **PyPI:** https://pypi.org/project/aprsd-telegram-plugin/
- **GitHub:** https://github.com/hemna/aprsd-telegram-plugin
- **Description:** Send and receive messages via Telegram.
#### Borat Plugin
- **PyPI:** https://pypi.org/project/aprsd-borat-plugin/
- **GitHub:** https://github.com/hemna/aprsd-borat-plugin
- **Description:** Get random Borat quotes via APRS messages.
#### WXNow Plugin
- **PyPI:** https://pypi.org/project/aprsd-wxnow-plugin/
- **GitHub:** https://github.com/hemna/aprsd-wxnow-plugin
- **Description:** Get weather reports from the closest N weather stations.
#### WeeWX Plugin
- **PyPI:** https://pypi.org/project/aprsd-weewx-plugin/
- **GitHub:** https://github.com/hemna/aprsd-weewx-plugin
- **Description:** Get weather data from your WeeWX weather station.
#### Slack Plugin
- **PyPI:** https://pypi.org/project/aprsd-slack-plugin/
- **GitHub:** https://github.com/hemna/aprsd-slack-plugin
- **Description:** Send and receive messages to/from a Slack channel.
#### Sentry Plugin
- **PyPI:** https://pypi.org/project/aprsd-sentry-plugin/
- **GitHub:** https://github.com/hemna/aprsd-sentry-plugin
- **Description:** Integration with Sentry for error tracking and monitoring.
#### Repeat Plugins
- **PyPI:** https://pypi.org/project/aprsd-repeat-plugins/
- **GitHub:** https://github.com/hemna/aprsd-repeat-plugins
- **Description:** Plugins for the REPEAT service - get nearest Ham radio repeaters.
#### Twitter Plugin
- **PyPI:** https://pypi.org/project/aprsd-twitter-plugin/
- **GitHub:** https://github.com/hemna/aprsd-twitter-plugin
- **Description:** Make tweets from your Ham Radio via APRS messages.
#### Time OpenCage Plugin
- **PyPI:** https://pypi.org/project/aprsd-timeopencage-plugin/
- **GitHub:** https://github.com/hemna/aprsd-timeopencage-plugin
- **Description:** Get local time for a callsign using OpenCage geocoding.
#### Stock Plugin
- **PyPI:** https://pypi.org/project/aprsd-stock-plugin/
- **GitHub:** https://github.com/hemna/aprsd-stock-plugin
- **Description:** Get stock quotes from your Ham radio via APRS messages.
### Available External Extensions
The following external extensions are available:
#### Admin Extension
- **PyPI:** https://pypi.org/project/aprsd-admin-extension/
- **GitHub:** https://github.com/hemna/aprsd-admin-extension
- **Description:** Web-based administration interface for APRSD with real-time status, configuration management, and monitoring capabilities.
#### WebChat Extension
- **PyPI:** https://pypi.org/project/aprsd-webchat-extension/
- **GitHub:** https://github.com/hemna/aprsd-webchat-extension
- **Description:** Web-based APRS messaging interface that allows you to send and receive APRS messages through a browser.
#### Rich CLI Extension
- **PyPI:** https://pypi.org/project/aprsd-rich-cli-extension/
- **GitHub:** https://github.com/hemna/aprsd-rich-cli-extension
- **Description:** Enhanced Textual-based rich CLI versions of APRSD commands with improved user interface and interactivity.
#### IRC Extension
- **PyPI:** https://pypi.org/project/aprsd-irc-extension/
- **GitHub:** https://github.com/hemna/aprsd-irc-extension
- **Description:** IRC-like server command for APRS, providing an IRC-style interface to the APRS network.
## Troubleshooting
### Plugin Not Responding
1. Check that the plugin is listed in `enabled_plugins` in your configuration file
2. Verify the plugin path is correct
3. Check the logs for any error messages
4. Ensure any required API keys are configured
### Fortune Plugin Not Working
- Ensure the `fortune` command is installed on your system
- Check that the fortune binary is in one of the standard paths
- The plugin will automatically disable itself if fortune is not found
### Weather Plugin Not Working
- Verify your `aprs_fi.apiKey` is configured correctly
- Check that the callsign has a recent GPS beacon on aprs.fi
- Ensure the weather service API is accessible from your server
### WatchList Plugin Not Sending Notifications
- Verify `watch_list.enabled` is set to `true`
- Check that `watch_list.callsigns` contains the callsigns you want to monitor
- Ensure `watch_list.alert_callsign` is set to your callsign
- Verify the callsigns in the watch list are actually being seen on APRS-IS
+18
View File
@@ -194,6 +194,20 @@ def process_standard_options(f: F) -> F:
)
except cfg.ConfigFilesNotFoundError:
config_file_found = False
except cfg.RequiredOptError as roe:
import sys
LOG = logging.getLogger('APRSD') # noqa: N806
LOG.error(f'A Required option is missing in the config file : {roe}')
# If the missing option is callsign or owner_callsign, give specific message
if 'owner_callsign' in str(roe):
LOG.error(
'The "owner_callsign" option is required. '
'It is used to identify the licensed ham radio operator '
'responsible for this APRSD instance, which may be different than '
'the "callsign" used by APRSD for messaging.',
)
sys.exit(-1)
ctx.obj['loglevel'] = kwargs['loglevel']
# ctx.obj["config_file"] = kwargs["config_file"]
@@ -268,6 +282,10 @@ def process_standard_options_no_config(f: F) -> F:
except cfg.ConfigFilesNotFoundError:
# Config file not needed for this function, so ignore error
pass
except cfg.RequiredOptError:
# They are missing a required option from the config,
# but we don't care, because they aren't loading a config
pass
ctx.obj['loglevel'] = kwargs['loglevel']
ctx.obj['config_file'] = kwargs['config_file']
+2 -4
View File
@@ -73,17 +73,15 @@ class APRSDClient(metaclass=trace.TraceWrapperMetaclass):
return True
return False
@property
def login_success(self):
if not self.driver:
return False
return self.driver.login_success
return self.driver.login_success()
@property
def login_failure(self):
if not self.driver:
return None
return self.driver.login_failure
return self.driver.login_failure()
def set_filter(self, filter):
self.filter = filter
+23 -7
View File
@@ -1,7 +1,7 @@
import datetime
import logging
import time
from typing import Callable
from typing import Any, Callable
import aprslib
from aprslib.exceptions import LoginError
@@ -78,10 +78,15 @@ class APRSISDriver:
return False
return self._client.is_alive() and not self._is_stale_connection()
def close(self):
def close(self) -> None:
"""Close the APRS-IS connection."""
if self._client:
self._client.stop()
self._client.close()
try:
self._client.stop()
self._client.close()
LOG.info('Closing APRSISDriver')
except Exception as e:
LOG.error(f'Error closing APRS-IS connection: {e}')
self.connected = False
def send(self, packet: core.Packet) -> bool:
@@ -134,7 +139,7 @@ class APRSISDriver:
backoff += 1
continue
def set_filter(self, filter):
def set_filter(self, filter: str) -> None:
LOG.info(f'Setting filter to {filter}')
self._client.set_filter(filter)
@@ -146,14 +151,25 @@ class APRSISDriver:
@property
def filter(self):
if not self._client:
return ''
return self._client.filter
@property
def server_string(self):
if not self._client:
return None
return self._client.server_string
@property
def keepalive(self):
def keepalive(self) -> datetime.datetime:
"""Get the keepalive timestamp.
Returns:
datetime.datetime: Last keepalive timestamp
"""
if not self._client:
return datetime.datetime.now()
return self._client.aprsd_keepalive
def _is_stale_connection(self):
@@ -193,7 +209,7 @@ class APRSISDriver:
else:
self.connected = False
def stats(self, serializable: bool = False) -> dict:
def stats(self, serializable: bool = False) -> dict[str, Any]:
stats = {}
if self.is_configured():
if self._client:
+20 -9
View File
@@ -2,7 +2,7 @@ import datetime
import logging
import threading
import time
from typing import Callable
from typing import Any, Callable
import aprslib
import wrapt
@@ -34,21 +34,32 @@ class APRSDFakeDriver(metaclass=trace.TraceWrapperMetaclass):
@staticmethod
def is_enabled():
if CONF.fake_client.enabled:
return True
return False
return CONF.fake_client.enabled
@staticmethod
def is_configured():
return APRSDFakeDriver.is_enabled
return CONF.fake_client.enabled
@property
def is_alive(self):
"""If the connection is alive or not."""
return not self.thread_stop
def close(self):
@property
def filter(self) -> str:
return 'FAKE'
@staticmethod
def transport() -> str:
return 'FAKE'
@property
def keepalive(self) -> datetime.datetime:
return datetime.datetime.now()
def close(self) -> None:
self.thread_stop = True
LOG.info('Shutdown APRSDFakeDriver driver.')
LOG.info('Closing APRSDFakeDriver')
def setup_connection(self):
# It's fake....
@@ -118,9 +129,9 @@ class APRSDFakeDriver(metaclass=trace.TraceWrapperMetaclass):
return core.factory(args[0])
return core.factory(aprslib.parse(args[0]))
def stats(self, serializable: bool = False) -> dict:
def stats(self, serializable: bool = False) -> dict[str, Any]:
return {
'driver': self.__class__.__name__,
'is_alive': self.is_alive(),
'is_alive': self.is_alive,
'transport': 'fake',
}
+36 -11
View File
@@ -7,7 +7,7 @@ non-asyncio KISSInterface implementation.
import datetime
import logging
from typing import Any, Callable, Dict
from typing import Any, Callable
import aprslib
from kiss import util as kissutil
@@ -25,7 +25,7 @@ class KISSDriver(metaclass=trace.TraceWrapperMetaclass):
packets_sent = 0
last_packet_sent = None
last_packet_received = None
keepalive = None
_keepalive = None
# timeout in seconds
select_timeout = 1
@@ -38,7 +38,16 @@ class KISSDriver(metaclass=trace.TraceWrapperMetaclass):
"""
super().__init__()
self._connected = False
self.keepalive = datetime.datetime.now()
self._keepalive = datetime.datetime.now()
@property
def keepalive(self) -> datetime.datetime:
"""Get the keepalive timestamp.
Returns:
datetime.datetime: Last keepalive timestamp
"""
return self._keepalive
def login_success(self) -> bool:
"""There is no login for KISS."""
@@ -50,11 +59,11 @@ class KISSDriver(metaclass=trace.TraceWrapperMetaclass):
"""There is no login for KISS."""
return 'Login successful'
def set_filter(self, filter_text: str):
def set_filter(self, filter: str) -> None:
"""Set packet filter (not implemented for KISS).
Args:
filter_text: Filter specification (ignored for KISS)
filter: Filter specification (ignored for KISS)
"""
# KISS doesn't support filtering at the TNC level
pass
@@ -122,10 +131,19 @@ class KISSDriver(metaclass=trace.TraceWrapperMetaclass):
"""Start consuming frames with the given callback.
Args:
callback: Function to call with received packets
callback: Function to call with received packets.
Called with frame=<frame> keyword argument to match
the signature used by other drivers (packet=<packet>).
raw: If True, callback receives raw frame data.
If False, callback receives decoded packet.
Raises:
Exception: If not connected to KISS TNC
Note:
The callback signature should accept keyword arguments:
- For raw frames: callback(frame=frame_obj)
- For decoded packets: callback(packet=packet_obj)
"""
# Ensure connection
if not self._connected:
@@ -135,7 +153,14 @@ class KISSDriver(metaclass=trace.TraceWrapperMetaclass):
frame = self.read_frame()
if frame:
LOG.info(f'GOT FRAME: {frame} calling {callback}')
callback(frame)
if raw:
# Pass raw frame with keyword argument for consistency
callback(frame=frame)
else:
# Decode frame to packet and pass with keyword argument
packet = self.decode_packet(frame)
if packet:
callback(packet=packet)
def read_frame(self):
"""Read a frame from the KISS interface.
@@ -146,14 +171,14 @@ class KISSDriver(metaclass=trace.TraceWrapperMetaclass):
raise NotImplementedError('read_frame is not implemented for KISS')
@trace.no_trace
def stats(self, serializable: bool = False) -> Dict[str, Any]:
def stats(self, serializable: bool = False) -> dict[str, Any]:
"""Get client statistics.
Returns:
Dict containing client statistics
"""
if serializable:
keepalive = self.keepalive.isoformat()
keepalive = self._keepalive.isoformat() if self._keepalive else 'None'
if self.last_packet_sent:
last_packet_sent = self.last_packet_sent.isoformat()
else:
@@ -163,13 +188,13 @@ class KISSDriver(metaclass=trace.TraceWrapperMetaclass):
else:
last_packet_received = 'None'
else:
keepalive = self.keepalive
keepalive = self._keepalive
last_packet_sent = self.last_packet_sent
last_packet_received = self.last_packet_received
stats = {
'client': self.__class__.__name__,
'transport': self.transport,
'transport': self.transport(),
'connected': self._connected,
'path': self.path,
'packets_sent': self.packets_sent,
+17 -4
View File
@@ -1,4 +1,5 @@
from typing import Callable, Protocol, runtime_checkable
import datetime
from typing import Any, Callable, Protocol, runtime_checkable
from aprsd.packets import core
from aprsd.utils import singleton, trace
@@ -13,17 +14,29 @@ class ClientDriver(Protocol):
"""
@staticmethod
def is_enabled(self) -> bool:
def is_enabled() -> bool:
pass
@staticmethod
def is_configured(self) -> bool:
def is_configured() -> bool:
pass
@property
def is_alive(self) -> bool:
pass
@property
def filter(self) -> str:
pass
@staticmethod
def transport() -> str:
pass
@property
def keepalive(self) -> datetime.datetime:
pass
def close(self) -> None:
pass
@@ -48,7 +61,7 @@ class ClientDriver(Protocol):
def decode_packet(self, *args, **kwargs) -> core.Packet:
pass
def stats(self, serializable: bool = False) -> dict:
def stats(self, serializable: bool = False) -> dict[str, Any]:
pass
+36 -13
View File
@@ -9,7 +9,7 @@ import datetime
import logging
# import select
from typing import Any, Dict
from typing import Any
import serial
from ax253 import frame as ax25frame
@@ -50,12 +50,12 @@ class SerialKISSDriver(KISSDriver):
"""
super().__init__()
self._connected = False
self.keepalive = datetime.datetime.now()
# keepalive is set in parent KISSDriver.__init__()
# This is initialized in setup_connection()
self.socket = None
@property
def transport(self) -> str:
@staticmethod
def transport() -> str:
return client.TRANSPORT_SERIALKISS
@staticmethod
@@ -79,17 +79,27 @@ class SerialKISSDriver(KISSDriver):
return True
return False
def close(self):
"""Close the connection."""
def close(self) -> None:
"""Close the serial KISS connection."""
self._connected = False
if self.socket and self.socket.is_open:
try:
self.socket.close()
except Exception:
pass
LOG.info('Closing SerialKISSDriver')
except Exception as e:
LOG.error(f'Error closing serial KISS port: {e}')
def setup_connection(self):
"""Set up the KISS interface."""
"""Set up the KISS interface.
This is the Protocol-defined method that initializes the connection.
It internally calls connect() to establish the actual serial connection.
Note:
This method follows the ClientDriver Protocol. Use this method
for standard connection setup. The connect() method is an internal
KISS-specific helper for establishing the serial port connection.
"""
if not self.is_enabled():
LOG.error('KISS is not enabled in configuration')
return
@@ -99,7 +109,7 @@ class SerialKISSDriver(KISSDriver):
return
try:
# Configure for TCP KISS
# Configure for Serial KISS
if self.is_enabled():
LOG.info(
f'Serial KISS Connection to {CONF.kiss_serial.device}:{CONF.kiss_serial.baudrate}'
@@ -116,7 +126,16 @@ class SerialKISSDriver(KISSDriver):
self._connected = False
def connect(self):
"""Connect to the KISS interface."""
"""Establish serial connection to the KISS device.
This is a KISS-specific internal method that handles the low-level
serial port connection. It is called by setup_connection().
Note:
This method is NOT part of the ClientDriver Protocol. It is specific
to KISS drivers and handles serial port establishment and configuration.
External code should use setup_connection() instead.
"""
if not self.is_enabled():
LOG.error('KISS is not enabled in configuration')
return
@@ -192,12 +211,15 @@ class SerialKISSDriver(KISSDriver):
self._connected = False
break
def send(self, packet: core.Packet):
def send(self, packet: core.Packet) -> bool:
"""Send an APRS packet.
Args:
packet: APRS packet to send (Packet or Message object)
Returns:
bool: True if packet was sent successfully
Raises:
Exception: If not connected or send fails
"""
@@ -235,9 +257,10 @@ class SerialKISSDriver(KISSDriver):
self.last_packet_sent = datetime.datetime.now()
# Increment packets sent counter
self.packets_sent += 1
return True
@trace.no_trace
def stats(self, serializable: bool = False) -> Dict[str, Any]:
def stats(self, serializable: bool = False) -> dict[str, Any]:
"""Get client statistics.
Returns:
+32 -13
View File
@@ -9,7 +9,7 @@ import datetime
import logging
import select
import socket
from typing import Any, Dict
from typing import Any
import aprslib
from ax253 import frame as ax25frame
@@ -55,12 +55,12 @@ class TCPKISSDriver(KISSDriver):
"""
super().__init__()
self._connected = False
self.keepalive = datetime.datetime.now()
# keepalive is set in parent KISSDriver.__init__()
# This is initialized in setup_connection()
self.socket = None
@property
def transport(self) -> str:
@staticmethod
def transport() -> str:
return client.TRANSPORT_TCPKISS
@staticmethod
@@ -84,24 +84,25 @@ class TCPKISSDriver(KISSDriver):
return True
return False
def close(self):
"""Close the connection."""
def close(self) -> None:
"""Close the TCP KISS connection."""
self._connected = False
if self.socket:
try:
self.socket.close()
LOG.info('Closing TCPKISSDriver')
except Exception as e:
LOG.error(f'close: error closing socket: {e}')
pass
else:
LOG.warning('close: socket not initialized. no reason to close.')
LOG.error(f'Error closing TCP KISS socket: {e}')
def send(self, packet: core.Packet):
def send(self, packet: core.Packet) -> bool:
"""Send an APRS packet.
Args:
packet: APRS packet to send (Packet or Message object)
Returns:
bool: True if packet was sent successfully
Raises:
Exception: If not connected or send fails
"""
@@ -139,9 +140,19 @@ class TCPKISSDriver(KISSDriver):
self.last_packet_sent = datetime.datetime.now()
# Increment packets sent counter
self.packets_sent += 1
return True
def setup_connection(self):
"""Set up the KISS interface."""
"""Set up the KISS interface.
This is the Protocol-defined method that initializes the connection.
It internally calls connect() to establish the actual TCP connection.
Note:
This method follows the ClientDriver Protocol. Use this method
for standard connection setup. The connect() method is an internal
KISS-specific helper for establishing the TCP socket connection.
"""
if not self.is_enabled():
LOG.error('KISS is not enabled in configuration')
return
@@ -168,7 +179,7 @@ class TCPKISSDriver(KISSDriver):
LOG.exception(ex)
self._connected = False
def stats(self, serializable: bool = False) -> Dict[str, Any]:
def stats(self, serializable: bool = False) -> dict[str, Any]:
"""Get client statistics.
Returns:
@@ -182,6 +193,14 @@ class TCPKISSDriver(KISSDriver):
def connect(self) -> bool:
"""Establish TCP connection to the KISS host.
This is a KISS-specific internal method that handles the low-level
socket connection. It is called by setup_connection().
Note:
This method is NOT part of the ClientDriver Protocol. It is specific
to KISS drivers and handles TCP socket establishment and configuration.
External code should use setup_connection() instead.
Returns:
bool: True if connection successful, False otherwise
"""
+4 -1
View File
@@ -1,16 +1,19 @@
import click
import click.shell_completion
from aprsd import cli_helper
from aprsd.main import cli
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
@cli.command()
@cli_helper.add_options(cli_helper.common_options)
@click.argument(
'shell', type=click.Choice(list(click.shell_completion._available_shells))
)
def completion(shell):
@cli_helper.process_standard_options_no_config
def completion(ctx, shell):
"""Show the shell completion code"""
from click.utils import _detect_program_name
+6 -2
View File
@@ -10,6 +10,7 @@ import click
from oslo_config import cfg
import aprsd
import aprsd.packets.log as packet_log
from aprsd import cli_helper, packets, plugin, utils
# local imports here
@@ -128,15 +129,17 @@ def test_plugin(
message_text=message,
)
LOG.info(f"P'{plugin_path}' F'{fromcall}' C'{message}'")
packet_log.log(packet)
for _ in range(number):
# PluginManager.run() executes all plugins in parallel
# Results may be in a different order than plugin registration
# NULL_MESSAGE results are already filtered out
replies = pm.run(packet)
results, handled = pm.run(packet)
LOG.debug(f'Replies: {results}')
# Plugin might have threads, so lets stop them so we can exit.
# obj.stop_threads()
for reply in replies:
for reply in results:
if isinstance(reply, list):
# one of the plugins wants to send multiple messages
for subreply in reply:
@@ -157,6 +160,7 @@ def test_plugin(
# Note: NULL_MESSAGE results are already filtered out
# in PluginManager.run(), but keeping this check for safety
if reply is not packets.NULL_MESSAGE:
LOG.debug(f'Reply: {reply}')
LOG.info(
packets.MessagePacket(
from_call=CONF.callsign,
+166
View File
@@ -0,0 +1,166 @@
import importlib.metadata as imp
import json
import logging
import sys
import click
from aprsd import cli_helper
from aprsd.main import cli
LOG = logging.getLogger('APRSD')
def _get_entry_points():
"""Get all oslo.config.opts entry points."""
try:
if sys.version_info < (3, 10):
all_eps = imp.entry_points()
selected = []
if 'oslo.config.opts' in all_eps:
for ep in all_eps['oslo.config.opts']:
if ep.group == 'oslo.config.opts':
selected.append(ep)
return selected
else:
return imp.entry_points(group='oslo.config.opts')
except Exception as e:
LOG.warning(f'Failed to get entry points: {e}')
return []
def _extract_package_name(entry_point_name):
"""Extract package name from entry point name.
Examples:
- 'aprsd.conf' -> 'aprsd'
- 'aprsd_plugin_name.conf' -> 'aprsd_plugin_name'
"""
if '.' in entry_point_name:
return entry_point_name.rsplit('.', 1)[0]
return entry_point_name
def _serialize_config_option(opt):
"""Convert an oslo.config option to a serializable dict."""
opt_dict = {
'name': opt.name,
'type': type(opt).__name__,
'help': getattr(opt, 'help', '') or '',
}
# Get default value if available
if hasattr(opt, 'default'):
default = opt.default
# Handle callable defaults
if callable(default):
try:
default = default()
except Exception:
default = None
opt_dict['default'] = default
else:
opt_dict['default'] = None
# Check if required (no default or default is None)
opt_dict['required'] = not hasattr(opt, 'default') or opt_dict['default'] is None
# Add additional attributes if available
if hasattr(opt, 'choices') and opt.choices:
opt_dict['choices'] = list(opt.choices)
if hasattr(opt, 'secret') and opt.secret:
opt_dict['secret'] = True
if hasattr(opt, 'min') and opt.min is not None:
opt_dict['min'] = opt.min
if hasattr(opt, 'max') and opt.max is not None:
opt_dict['max'] = opt.max
return opt_dict
def get_plugin_config_options(plugins_only=False):
"""Discover all config options from installed plugin packages.
Args:
plugins_only: If True, exclude the built-in 'aprsd' package config.
"""
entry_points = _get_entry_points()
packages = {}
for ep in entry_points:
# Only process entry points that contain 'aprsd' in the name
if 'aprsd' not in ep.name:
continue
package_name = _extract_package_name(ep.name)
# Skip built-in aprsd config if plugins_only is True
if plugins_only and package_name == 'aprsd':
continue
try:
# Load the entry point and call list_opts()
list_opts_func = ep.load()
config_opts = list_opts_func()
# config_opts can be a dict or a list of tuples
if isinstance(config_opts, dict):
# Convert dict to list of tuples for consistent processing
config_opts = list(config_opts.items())
elif not isinstance(config_opts, (list, tuple)):
LOG.warning(
f'Entry point {ep.name} returned unexpected type: '
f'{type(config_opts)}',
)
continue
# Process each config group
package_config = {}
for group_name, opt_list in config_opts:
if not opt_list:
continue
group_options = []
for opt in opt_list:
opt_dict = _serialize_config_option(opt)
group_options.append(opt_dict)
if group_options:
package_config[group_name] = group_options
if package_config:
packages[package_name] = package_config
except Exception as e:
LOG.warning(
f'Failed to load config options from {ep.name}: {e}',
)
continue
return packages
@cli.command()
@cli_helper.add_options(cli_helper.common_options)
@click.option(
'--plugins-only',
is_flag=True,
default=False,
help='Only export config options from installed plugins, excluding built-in aprsd config.',
)
@click.pass_context
@cli_helper.process_standard_options_no_config
def export_config(ctx, plugins_only):
"""Export all config options from installed APRSD plugins as JSON.
This command discovers all installed APRSD plugin packages that have
registered configuration options via oslo.config.opts entry points and
builds a JSON output of all configuration options grouped by plugin package.
Use --plugins-only to exclude the built-in aprsd configuration options
and only show config from installed 3rd party plugins.
"""
output = get_plugin_config_options(plugins_only=plugins_only)
# Output as JSON
click.echo(json.dumps(output, indent=2))
+86
View File
@@ -0,0 +1,86 @@
import inspect
import json
import logging
import click
from aprsd import cli_helper
from aprsd import plugin as aprsd_plugin
from aprsd.main import cli
from aprsd.utils import package as aprsd_package
LOG = logging.getLogger('APRSD')
def get_installed_plugin_classes():
"""Discover all installed 3rd party plugin classes, grouped by package."""
installed_plugins = aprsd_package.get_installed_plugins()
packages = {}
for package_name, plugin_list in installed_plugins.items():
if not plugin_list:
continue
package_plugins = []
for plugin_info in plugin_list:
plugin_class = plugin_info['obj']
plugin_data = {
'class_name': plugin_info['name'],
'path': plugin_info['path'],
'version': plugin_info['version'],
'base_class_type': aprsd_package.plugin_type(plugin_class),
}
# If it's a regex command plugin, include the command_regex
if issubclass(plugin_class, aprsd_plugin.APRSDRegexCommandPluginBase):
# Try to get command_regex from the class
# It's typically defined as a class attribute in plugin implementations
try:
# Check the MRO to find where command_regex is actually defined
cmd_regex = None
for base_cls in inspect.getmro(plugin_class):
if 'command_regex' in base_cls.__dict__:
attr = base_cls.__dict__['command_regex']
# If it's not a property descriptor, use it
if not isinstance(attr, property):
cmd_regex = attr
break
plugin_data['command_regex'] = cmd_regex
except Exception:
plugin_data['command_regex'] = None
package_plugins.append(plugin_data)
if package_plugins:
packages[package_name] = package_plugins
return packages
@cli.command()
@cli_helper.add_options(cli_helper.common_options)
@click.pass_context
@cli_helper.process_standard_options_no_config
def export_plugins(ctx):
"""Export all installed APRSD plugins as JSON.
This command discovers all installed APRSD plugin packages and builds
a JSON output of each plugin class associated with the plugin package.
For each plugin class it includes the base class type, and if it's an
APRSDRegexCommandPluginBase class, it includes the command_regex.
"""
output = {
'built_in_plugins': [],
'installed_plugins': {},
}
# Get built-in plugins
built_in = aprsd_package.get_built_in_plugins()
output['built_in_plugins'] = built_in
# Get installed 3rd party plugins (grouped by package)
installed = get_installed_plugin_classes()
output['installed_plugins'] = installed
# Output as JSON
click.echo(json.dumps(output, indent=2))
+29 -1
View File
@@ -64,6 +64,18 @@ def healthcheck(ctx, timeout):
email_thread_last_update = email_stats['last_check_time']
if email_thread_last_update != 'never':
# Parse ISO format string back to datetime if needed
if isinstance(email_thread_last_update, str):
try:
email_thread_last_update = datetime.datetime.fromisoformat(
email_thread_last_update
)
except (ValueError, TypeError):
console.log(
f'Invalid email thread last update time: '
f'{email_thread_last_update}'
)
sys.exit(-1)
d = now - email_thread_last_update
max_timeout = {'hours': 0.0, 'minutes': 5, 'seconds': 30}
max_delta = datetime.timedelta(**max_timeout)
@@ -73,10 +85,26 @@ def healthcheck(ctx, timeout):
client_stats = stats.get('APRSClientStats')
if not client_stats:
console.log('No APRSClientStats')
console.log('No APRSClientStats - Is the aprsd server running?')
sys.exit(-1)
else:
aprsis_last_update = client_stats['connection_keepalive']
# Handle None or 'None' string values
if aprsis_last_update in (None, 'None'):
console.log('APRS-IS connection keepalive is None')
sys.exit(-1)
# Parse ISO format string back to datetime if needed
if isinstance(aprsis_last_update, str):
try:
aprsis_last_update = datetime.datetime.fromisoformat(
aprsis_last_update
)
except (ValueError, TypeError):
console.log(
f'Invalid APRS-IS connection keepalive time: '
f'{aprsis_last_update}'
)
sys.exit(-1)
d = now - aprsis_last_update
max_timeout = {'hours': 0.0, 'minutes': 5, 'seconds': 0}
max_delta = datetime.timedelta(**max_timeout)
+16 -23
View File
@@ -1,4 +1,3 @@
import inspect
import logging
import click
@@ -7,39 +6,33 @@ from rich.table import Table
from rich.text import Text
from aprsd import cli_helper
from aprsd import plugin as aprsd_plugin
from aprsd.main import cli
from aprsd.plugins import fortune, notify, ping, time, version, weather
from aprsd.utils import package as aprsd_package
LOG = logging.getLogger('APRSD')
def show_built_in_plugins(console):
modules = [fortune, notify, ping, time, version, weather]
built_in = aprsd_package.get_built_in_plugins()
plugins = []
for module in modules:
entries = inspect.getmembers(module, inspect.isclass)
for entry in entries:
cls = entry[1]
if issubclass(cls, aprsd_plugin.APRSDPluginBase):
info = {
'name': cls.__qualname__,
'path': f'{cls.__module__}.{cls.__qualname__}',
'version': cls.version,
'docstring': cls.__doc__,
'short_desc': cls.short_description,
}
for plugin in built_in:
info = {
'name': plugin['class_name'],
'path': plugin['path'],
'version': plugin['version'],
'short_desc': '',
}
if issubclass(cls, aprsd_plugin.APRSDRegexCommandPluginBase):
info['command_regex'] = cls.command_regex
info['type'] = 'RegexCommand'
if plugin.get('command_regex'):
info['command_regex'] = plugin['command_regex']
info['type'] = 'RegexCommand'
elif plugin['base_class_type'] == 'WatchList':
info['type'] = 'WatchList'
else:
info['type'] = plugin['base_class_type']
if issubclass(cls, aprsd_plugin.APRSDWatchListPluginBase):
info['type'] = 'WatchList'
plugins.append(info)
plugins.append(info)
plugins = sorted(plugins, key=lambda i: i['name'])
+7 -105
View File
@@ -1,18 +1,8 @@
#
# License GPLv2
#
# python included libs
import cProfile
import datetime
import logging
import pstats
import signal
import sys
import time
import click
import requests
from loguru import logger
from oslo_config import cfg
from rich.console import Console
@@ -29,8 +19,7 @@ from aprsd.packets.filters import dupe_filter, packet_type
from aprsd.stats import collector
from aprsd.threads import keepalive, rx
from aprsd.threads import stats as stats_thread
from aprsd.threads.aprsd import APRSDThread
from aprsd.threads.stats import StatsLogThread
from aprsd.threads.stats import APRSDPushStatsThread, StatsLogThread
# setup the global logger
# log.basicConfig(level=log.DEBUG) # level=10
@@ -41,16 +30,9 @@ console = Console()
def signal_handler(sig, frame):
threads.APRSDThreadList().stop_all()
if 'subprocess' not in str(frame):
LOG.info(
'Ctrl+C, Sending all threads exit! Can take up to 10 seconds {}'.format(
datetime.datetime.now(),
),
)
time.sleep(5)
# Last save to disk
collector.Collector().collect()
from aprsd import main as aprsd_main
aprsd_main.signal_handler(sig, frame)
class APRSDListenProcessThread(rx.APRSDFilterThread):
@@ -86,51 +68,6 @@ class APRSDListenProcessThread(rx.APRSDFilterThread):
self.plugin_manager.run(packet)
class StatsExportThread(APRSDThread):
"""Export stats to remote aprsd-exporter API."""
def __init__(self, exporter_url):
super().__init__('StatsExport')
self.exporter_url = exporter_url
self.period = 10 # Export stats every 60 seconds
def loop(self):
if self.loop_count % self.period == 0:
try:
# Collect all stats
stats_json = collector.Collector().collect(serializable=True)
# Remove the PacketList section to reduce payload size
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,
}
# Send stats to exporter API
url = f'{self.exporter_url}/stats'
headers = {'Content-Type': 'application/json'}
response = requests.post(url, json=stats, headers=headers, timeout=10)
if response.status_code == 200:
LOGU.info(f'Successfully exported stats to {self.exporter_url}')
else:
LOGU.warning(
f'Failed to export stats to {self.exporter_url}: HTTP {response.status_code}'
)
except requests.exceptions.RequestException as e:
LOGU.error(f'Error exporting stats to {self.exporter_url}: {e}')
except Exception as e:
LOGU.error(f'Unexpected error in stats export: {e}')
time.sleep(1)
return True
@cli.command()
@cli_helper.add_options(cli_helper.common_options)
@click.option(
@@ -206,12 +143,6 @@ class StatsExportThread(APRSDThread):
default='http://localhost:8081',
help='URL of the aprsd-exporter API to send stats to.',
)
@click.option(
'--profile',
default=False,
is_flag=True,
help='Enable Python cProfile profiling to identify performance bottlenecks.',
)
@click.pass_context
@cli_helper.process_standard_options
def listen(
@@ -226,7 +157,6 @@ def listen(
enable_packet_stats,
export_stats,
exporter_url,
profile,
):
"""Listen to packets on the APRS-IS Network based on FILTER.
@@ -238,12 +168,6 @@ def listen(
o/obj1/obj2... - Object Filter Pass all objects with the exact name of obj1, obj2, ... (* wild card allowed)\n
"""
# Initialize profiler if enabled
profiler = None
if profile:
LOG.info('Starting Python cProfile profiling')
profiler = cProfile.Profile()
profiler.enable()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
@@ -281,9 +205,9 @@ def listen(
LOG.info('Creating client connection')
aprs_client = APRSDClient()
LOG.info(aprs_client)
if not aprs_client.login_success:
if not aprs_client.login_success():
# We failed to login, will just quit!
msg = f'Login Failure: {aprs_client.login_failure}'
msg = f'Login Failure: {aprs_client.login_failure()}'
LOG.error(msg)
print(msg)
sys.exit(-1)
@@ -356,7 +280,7 @@ def listen(
stats_export = None
if export_stats:
LOG.debug('Start StatsExportThread')
stats_export = StatsExportThread(exporter_url)
stats_export = APRSDPushStatsThread(push_url=exporter_url)
stats_export.start()
keepalive_thread.start()
@@ -367,25 +291,3 @@ def listen(
stats.join()
if stats_export:
stats_export.join()
# Save profiling results if enabled
if profiler:
profiler.disable()
profile_file = 'aprsd_listen_profile.prof'
profiler.dump_stats(profile_file)
LOG.info(f'Profile saved to {profile_file}')
# Print profiling summary
LOG.info('Profile Summary (top 50 functions by cumulative time):')
stats = pstats.Stats(profiler)
stats.sort_stats('cumulative')
# Log the top functions
LOG.info('-' * 80)
for item in stats.get_stats().items()[:50]:
func_info, stats_tuple = item
cumulative = stats_tuple[3]
total_calls = stats_tuple[0]
LOG.info(
f'{func_info} - Calls: {total_calls}, Cumulative: {cumulative:.4f}s'
)
+6 -2
View File
@@ -80,9 +80,9 @@ def server(ctx, flush, enable_packet_stats):
LOG.info('Creating client connection')
aprs_client = APRSDClient()
LOG.info(aprs_client)
if not aprs_client.login_success:
if not aprs_client.login_success():
# We failed to login, will just quit!
msg = f'Login Failure: {aprs_client.login_failure}'
msg = f'Login Failure: {aprs_client.login_failure()}'
LOG.error(msg)
print(msg)
sys.exit(-1)
@@ -168,6 +168,10 @@ def server(ctx, flush, enable_packet_stats):
LOG.info('Beacon Enabled. Starting static Beacon thread.')
service_threads.register(tx.BeaconSendThread())
if CONF.push_stats.enabled:
LOG.info('Push Stats Enabled. Starting Push Stats thread.')
service_threads.register(stats_thread.APRSDPushStatsThread())
if CONF.aprs_registry.enabled:
LOG.info('Registry Enabled. Starting Registry thread.')
service_threads.register(registry.APRSRegistryThread())
+28
View File
@@ -16,15 +16,22 @@ registry_group = cfg.OptGroup(
title='APRS Registry settings',
)
push_stats_group = cfg.OptGroup(
name='push_stats',
title='Push local stats to a remote API',
)
aprsd_opts = [
cfg.StrOpt(
'callsign',
default='NOCALL',
required=True,
help='Callsign to use for messages sent by APRSD',
),
cfg.StrOpt(
'owner_callsign',
default=None,
required=True,
help='The ham radio license callsign that owns this APRSD instance.',
),
cfg.BoolOpt(
@@ -178,6 +185,24 @@ watch_list_opts = [
),
]
push_stats_opts = [
cfg.BoolOpt(
'enabled',
default=False,
help='Enable pushing local stats to a remote API.',
),
cfg.StrOpt(
'push_url',
default=None,
help='The URL of the remote API to push the stats to. This should be the base URL of the API.'
'APRSD Will make a POST request to this url endpoint.',
),
cfg.IntOpt(
'frequency_seconds',
default=15,
help='The frequency in seconds to push the stats to the remote API.',
),
]
enabled_plugins_opts = [
cfg.ListOpt(
@@ -238,6 +263,8 @@ def register_opts(config):
config.register_opts(watch_list_opts, group=watch_list_group)
config.register_group(registry_group)
config.register_opts(registry_opts, group=registry_group)
config.register_group(push_stats_group)
config.register_opts(push_stats_opts, group=push_stats_group)
def list_opts():
@@ -245,4 +272,5 @@ def list_opts():
'DEFAULT': (aprsd_opts + enabled_plugins_opts),
watch_list_group.name: watch_list_opts,
registry_group.name: registry_opts,
push_stats_group.name: push_stats_opts,
}
-48
View File
@@ -4,18 +4,6 @@ aprsfi_group = cfg.OptGroup(
name='aprs_fi',
title='APRS.FI website settings',
)
query_group = cfg.OptGroup(
name='query_plugin',
title='Options for the Query Plugin',
)
avwx_group = cfg.OptGroup(
name='avwx_plugin',
title='Options for the AVWXWeatherPlugin',
)
owm_wx_group = cfg.OptGroup(
name='owm_weather_plugin',
title='Options for the OWMWeatherPlugin',
)
aprsfi_opts = [
cfg.StrOpt(
@@ -24,49 +12,13 @@ aprsfi_opts = [
),
]
owm_wx_opts = [
cfg.StrOpt(
'apiKey',
help="OWMWeatherPlugin api key to OpenWeatherMap's API."
'This plugin uses the openweathermap API to fetch'
'location and weather information.'
'To use this plugin you need to get an openweathermap'
'account and apikey.'
'https://home.openweathermap.org/api_keys',
),
]
avwx_opts = [
cfg.StrOpt(
'apiKey',
help='avwx-api is an opensource project that has'
'a hosted service here: https://avwx.rest/'
'You can launch your own avwx-api in a container'
'by cloning the githug repo here:'
'https://github.com/avwx-rest/AVWX-API',
),
cfg.StrOpt(
'base_url',
default='https://avwx.rest',
help='The base url for the avwx API. If you are hosting your own'
'Here is where you change the url to point to yours.',
),
]
def register_opts(config):
config.register_group(aprsfi_group)
config.register_opts(aprsfi_opts, group=aprsfi_group)
config.register_group(query_group)
config.register_group(owm_wx_group)
config.register_opts(owm_wx_opts, group=owm_wx_group)
config.register_group(avwx_group)
config.register_opts(avwx_opts, group=avwx_group)
def list_opts():
return {
aprsfi_group.name: aprsfi_opts,
owm_wx_group.name: owm_wx_opts,
avwx_group.name: avwx_opts,
}
+38 -3
View File
@@ -34,6 +34,7 @@ from oslo_config import cfg, generator
import aprsd
from aprsd import cli_helper, packets, threads, utils
from aprsd.stats import collector
from aprsd.utils import config_converter
# setup the global logger
# log.basicConfig(level=log.DEBUG) # level=10
@@ -53,6 +54,8 @@ def load_commands():
from .cmds import ( # noqa
completion,
dev,
export_config,
export_plugins,
fetch_stats,
healthcheck,
list_plugins,
@@ -108,8 +111,14 @@ def check_version(ctx):
@cli.command()
@click.option(
'--output-json',
default=False,
is_flag=True,
help='Output the sample config in JSON format instead of INI.',
)
@click.pass_context
def sample_config(ctx):
def sample_config(ctx, output_json):
"""Generate a sample Config file from aprsd and all installed plugins."""
def _get_selected_entry_points():
@@ -151,8 +160,34 @@ def sample_config(ctx):
if not sys.argv[1:]:
raise SystemExit from ex
raise
generator.generate(conf)
return
if not output_json:
generator.generate(conf)
return
import io
import json
from contextlib import redirect_stdout
from rich.console import Console
f = io.StringIO()
with redirect_stdout(f):
conf.format_ = 'json'
generator.generate(conf)
s = f.getvalue()
c = Console()
c.print_json(data=json.loads(s))
@cli.command()
@cli_helper.add_options(cli_helper.common_options)
@click.pass_context
@cli_helper.process_standard_options
def json_config(ctx):
"""Output the current loaded configuration in JSON format."""
click.echo(config_converter.conf_to_json(CONF))
@cli.command()
+9
View File
@@ -34,6 +34,15 @@ class PacketList(objectstore.ObjectStoreMixin):
'packets': OrderedDict(),
}
def _restore_ordereddict(self):
"""Restore OrderedDict for packets after loading from JSON.
JSON doesn't preserve OrderedDict type, but Python 3.7+ dicts
maintain insertion order, so we can safely convert back.
"""
if 'packets' in self.data and not isinstance(self.data['packets'], OrderedDict):
self.data['packets'] = OrderedDict(self.data['packets'])
def rx(self, packet: type[core.Packet]):
"""Add a packet that was received."""
with self.lock:
+35 -60
View File
@@ -7,7 +7,6 @@ import logging
import re
import textwrap
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
import pluggy
from oslo_config import cfg
@@ -22,9 +21,7 @@ CONF = cfg.CONF
LOG = logging.getLogger('APRSD')
CORE_MESSAGE_PLUGINS = [
'aprsd.plugins.email.EmailPlugin',
'aprsd.plugins.fortune.FortunePlugin',
'aprsd.plugins.location.LocationPlugin',
'aprsd.plugins.ping.PingPlugin',
'aprsd.plugins.time.TimePlugin',
'aprsd.plugins.weather.USWeatherPlugin',
@@ -517,12 +514,10 @@ class PluginManager:
LOG.info('Completed Plugin Loading.')
def run(self, packet: packets.MessagePacket):
"""Execute all plugins in parallel.
"""Execute all plugins sequentially.
Plugins are executed concurrently using ThreadPoolExecutor to improve
performance, especially when plugins perform I/O operations (API calls,
subprocess calls, etc.). Each plugin's filter() method is called in
parallel, and results are collected as they complete.
Each plugin's filter() method is called in order. Results are
collected and returned.
Returns:
tuple: (results, handled) where:
@@ -537,68 +532,48 @@ class PluginManager:
results = []
handled = False
# Execute all plugins in parallel
with ThreadPoolExecutor(max_workers=len(plugins)) as executor:
future_to_plugin = {
executor.submit(plugin.filter, packet=packet): plugin
for plugin in plugins
}
for future in as_completed(future_to_plugin):
plugin = future_to_plugin[future]
try:
result = future.result()
# Track if any plugin processed the message (even if NULL_MESSAGE)
if result is not None:
handled = True
# Only include non-NULL results
if result and result is not packets.NULL_MESSAGE:
results.append(result)
except Exception as ex:
LOG.error(
'Plugin {} failed to process packet: {}'.format(
plugin.__class__.__name__,
ex,
),
)
LOG.exception(ex)
for plugin in plugins:
try:
result = plugin.filter(packet=packet)
# Track if any plugin processed the message (even if NULL_MESSAGE)
if result is not None:
handled = True
# Only include non-NULL results
if result and result is not packets.NULL_MESSAGE:
results.append(result)
except Exception as ex:
LOG.error(
'Plugin {} failed to process packet: {}'.format(
plugin.__class__.__name__,
ex,
),
)
LOG.exception(ex)
return (results, handled)
def run_watchlist(self, packet: packets.Packet):
"""Execute all watchlist plugins in parallel.
Watchlist plugins are executed concurrently using ThreadPoolExecutor
to improve performance when multiple watchlist plugins are registered.
"""
"""Execute all watchlist plugins sequentially."""
plugins = list(self._watchlist_pm.get_plugins())
if not plugins:
return []
results = []
# Execute all plugins in parallel
with ThreadPoolExecutor(max_workers=len(plugins)) as executor:
future_to_plugin = {
executor.submit(plugin.filter, packet=packet): plugin
for plugin in plugins
}
for future in as_completed(future_to_plugin):
plugin = future_to_plugin[future]
try:
result = future.result()
# Only include non-NULL results
if result and result is not packets.NULL_MESSAGE:
results.append(result)
except Exception as ex:
LOG.error(
'Watchlist plugin {} failed to process packet: {}'.format(
plugin.__class__.__name__,
ex,
),
)
LOG.exception(ex)
for plugin in plugins:
try:
result = plugin.filter(packet=packet)
# Only include non-NULL results
if result and result is not packets.NULL_MESSAGE:
results.append(result)
except Exception as ex:
LOG.error(
'Watchlist plugin {} failed to process packet: {}'.format(
plugin.__class__.__name__,
ex,
),
)
LOG.exception(ex)
return results
+2 -10
View File
@@ -3,7 +3,6 @@ import shutil
import subprocess
from aprsd import packets, plugin
from aprsd.utils import trace
LOG = logging.getLogger('APRSD')
@@ -34,16 +33,9 @@ class FortunePlugin(plugin.APRSDRegexCommandPluginBase):
else:
self.enabled = True
@trace.trace
def process(self, packet: packets.MessagePacket):
def process(self, packet: packets.MessagePacket) -> str:
LOG.info('FortunePlugin')
# fromcall = packet.get("from")
# message = packet.get("message_text", None)
# ack = packet.get("msgNo", "0")
reply = None
reply = packets.NULL_MESSAGE
try:
cmnd = [self.fortune_path, '-s', '-n 60']
command = ' '.join(cmnd)
+1 -1
View File
@@ -19,7 +19,7 @@ class NotifySeenPlugin(plugin.APRSDWatchListPluginBase):
short_description = 'Notify me when a CALLSIGN is recently seen on APRS-IS'
def process(self, packet: packets.MessagePacket):
def process(self, packet: packets.MessagePacket) -> packets.MessagePacket:
LOG.info('NotifySeenPlugin')
notify_callsign = CONF.watch_list.alert_callsign
+2 -7
View File
@@ -1,8 +1,7 @@
import logging
import time
from aprsd import plugin
from aprsd.utils import trace
from aprsd import packets, plugin
LOG = logging.getLogger('APRSD')
@@ -14,12 +13,8 @@ class PingPlugin(plugin.APRSDRegexCommandPluginBase):
command_name = 'ping'
short_description = 'reply with a Pong!'
@trace.trace
def process(self, packet):
def process(self, packet: packets.MessagePacket) -> str:
LOG.info('PingPlugin')
# fromcall = packet.get("from")
# message = packet.get("message_text", None)
# ack = packet.get("msgNo", "0")
stm = time.localtime()
h = stm.tm_hour
m = stm.tm_min
+4 -68
View File
@@ -1,12 +1,11 @@
import logging
import re
import pytz
from oslo_config import cfg
from tzlocal import get_localzone
from aprsd import packets, plugin, plugin_utils
from aprsd.utils import fuzzy, trace
from aprsd import packets, plugin
from aprsd.utils import fuzzy
CONF = cfg.CONF
LOG = logging.getLogger('APRSD')
@@ -44,71 +43,8 @@ class TimePlugin(plugin.APRSDRegexCommandPluginBase):
return reply
@trace.trace
def process(self, packet: packets.Packet):
LOG.info('TIME COMMAND')
def process(self, packet: packets.MessagePacket) -> str:
LOG.info('TimePlugin')
# So we can mock this in unit tests
localzone = self._get_local_tz()
return self.build_date_str(localzone)
class TimeOWMPlugin(TimePlugin, plugin.APRSFIKEYMixin):
"""OpenWeatherMap based timezone fetching."""
command_regex = r'^([t]|[t]\s|time)'
command_name = 'time'
short_description = "Current time of GPS beacon's timezone. Uses OpenWeatherMap"
def setup(self):
self.ensure_aprs_fi_key()
@trace.trace
def process(self, packet: packets.MessagePacket):
fromcall = packet.from_call
message = packet.message_text
# ack = packet.get("msgNo", "0")
# optional second argument is a callsign to search
a = re.search(r'^.*\s+(.*)', message)
if a is not None:
searchcall = a.group(1)
searchcall = searchcall.upper()
else:
# if no second argument, search for calling station
searchcall = fromcall
api_key = CONF.aprs_fi.apiKey
try:
aprs_data = plugin_utils.get_aprs_fi(api_key, searchcall)
except Exception as ex:
LOG.error(f'Failed to fetch aprs.fi data {ex}')
return 'Failed to fetch location'
LOG.debug(f'LocationPlugin: aprs_data = {aprs_data}')
if not len(aprs_data['entries']):
LOG.error("Didn't get any entries from aprs.fi")
return 'Failed to fetch aprs.fi location'
lat = aprs_data['entries'][0]['lat']
lon = aprs_data['entries'][0]['lng']
try:
self.config.exists(
['services', 'openweathermap', 'apiKey'],
)
except Exception as ex:
LOG.error(f'Failed to find config openweathermap:apiKey {ex}')
return 'No openweathermap apiKey found'
api_key = self.config['services']['openweathermap']['apiKey']
try:
results = plugin_utils.fetch_openweathermap(api_key, lat, lon)
except Exception as ex:
LOG.error(f"Couldn't fetch openweathermap api '{ex}'")
# default to UTC
localzone = pytz.timezone('UTC')
else:
tzone = results['timezone']
localzone = pytz.timezone(tzone)
return self.build_date_str(localzone)
+3 -6
View File
@@ -1,7 +1,7 @@
import logging
import aprsd
from aprsd import conf, plugin
from aprsd import conf, packets, plugin
from aprsd.stats import collector
LOG = logging.getLogger('APRSD')
@@ -18,11 +18,8 @@ class VersionPlugin(plugin.APRSDRegexCommandPluginBase):
# five mins {int:int}
email_sent_dict = {}
def process(self, packet):
LOG.info('Version COMMAND')
# fromcall = packet.get("from")
# message = packet.get("message_text", None)
# ack = packet.get("msgNo", "0")
def process(self, packet: packets.MessagePacket) -> str:
LOG.info('VersionPlugin')
s = collector.Collector().collect()
owner = conf.CONF.owner_callsign or '-'
return 'APRSD ver:{} uptime:{} owner:{}'.format(
+9 -255
View File
@@ -2,11 +2,9 @@ import json
import logging
import re
import requests
from oslo_config import cfg
from aprsd import plugin, plugin_utils
from aprsd.utils import trace
from aprsd import packets, plugin, plugin_utils
CONF = cfg.CONF
LOG = logging.getLogger('APRSD')
@@ -25,7 +23,6 @@ class USWeatherPlugin(plugin.APRSDRegexCommandPluginBase, plugin.APRSFIKEYMixin)
"weather" - returns weather near the calling callsign
"""
# command_regex = r"^([w][x]|[w][x]\s|weather)"
command_regex = r'^[wW]'
command_name = 'USWeather'
@@ -34,13 +31,10 @@ class USWeatherPlugin(plugin.APRSDRegexCommandPluginBase, plugin.APRSFIKEYMixin)
def setup(self):
self.ensure_aprs_fi_key()
@trace.trace
def process(self, packet):
LOG.info('Weather Plugin')
def process(self, packet: packets.MessagePacket) -> str:
LOG.info('USWeatherPlugin')
fromcall = packet.from_call
message = packet.get('message_text', None)
# message = packet.get("message_text", None)
# ack = packet.get("msgNo", "0")
message = packet.message_text
a = re.search(r'^.*\s+(.*)', message)
if a is not None:
searchcall = a.group(1)
@@ -68,7 +62,7 @@ class USWeatherPlugin(plugin.APRSDRegexCommandPluginBase, plugin.APRSFIKEYMixin)
LOG.error(f"Couldn't fetch forecast.weather.gov '{ex}'")
return 'Unable to get weather'
LOG.info(f'WX data {wx_data}')
LOG.debug(f'WX data {wx_data}')
reply = (
'%sF(%sF/%sF) %s. %s, %s.'
@@ -107,12 +101,10 @@ class USMetarPlugin(plugin.APRSDRegexCommandPluginBase, plugin.APRSFIKEYMixin):
def setup(self):
self.ensure_aprs_fi_key()
@trace.trace
def process(self, packet):
fromcall = packet.get('from')
message = packet.get('message_text', None)
# ack = packet.get("msgNo", "0")
LOG.info(f"WX Plugin '{message}'")
def process(self, packet: packets.MessagePacket) -> str:
LOG.info('USMetarPlugin')
fromcall = packet.from_call
message = packet.message_text
a = re.search(r'^.*\s+(.*)', message)
if a is not None:
searchcall = a.group(1)
@@ -168,241 +160,3 @@ class USMetarPlugin(plugin.APRSDRegexCommandPluginBase, plugin.APRSFIKEYMixin):
reply = 'No Metar station found'
return reply
class OWMWeatherPlugin(plugin.APRSDRegexCommandPluginBase):
"""OpenWeatherMap Weather Command
This provides weather near the caller or callsign.
How to Call: Send a message to aprsd
"weather" - returns the weather near the calling callsign
"weather CALLSIGN" - returns the weather near CALLSIGN
This plugin uses the openweathermap API to fetch
location and weather information.
To use this plugin you need to get an openweathermap
account and apikey.
https://home.openweathermap.org/api_keys
"""
# command_regex = r"^([w][x]|[w][x]\s|weather)"
command_regex = r'^[wW]'
command_name = 'OpenWeatherMap'
short_description = 'OpenWeatherMap weather of GPS Beacon location'
def setup(self):
if not CONF.owm_weather_plugin.apiKey:
LOG.error('Config.owm_weather_plugin.apiKey is not set. Disabling')
self.enabled = False
else:
self.enabled = True
def help(self):
_help = [
'openweathermap: Send {} to get weather from your location'.format(
self.command_regex
),
'openweathermap: Send {} <callsign> to get weather from <callsign>'.format(
self.command_regex
),
]
return _help
@trace.trace
def process(self, packet):
fromcall = packet.get('from_call')
message = packet.get('message_text', None)
# ack = packet.get("msgNo", "0")
LOG.info(f"OWMWeather Plugin '{message}'")
a = re.search(r'^.*\s+(.*)', message)
if a is not None:
searchcall = a.group(1)
searchcall = searchcall.upper()
else:
searchcall = fromcall
api_key = CONF.aprs_fi.apiKey
try:
aprs_data = plugin_utils.get_aprs_fi(api_key, searchcall)
except Exception as ex:
LOG.error(f'Failed to fetch aprs.fi data {ex}')
return 'Failed to fetch location'
# LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
if not len(aprs_data['entries']):
LOG.error('Found no entries from aprs.fi!')
return 'Failed to fetch location'
lat = aprs_data['entries'][0]['lat']
lon = aprs_data['entries'][0]['lng']
units = CONF.units
api_key = CONF.owm_weather_plugin.apiKey
try:
wx_data = plugin_utils.fetch_openweathermap(
api_key,
lat,
lon,
units=units,
exclude='minutely,hourly',
)
except Exception as ex:
LOG.error(f"Couldn't fetch openweathermap api '{ex}'")
# default to UTC
return 'Unable to get weather'
if units == 'metric':
degree = 'C'
else:
degree = 'F'
if 'wind_gust' in wx_data['current']:
wind = '{:.0f}@{}G{:.0f}'.format(
wx_data['current']['wind_speed'],
wx_data['current']['wind_deg'],
wx_data['current']['wind_gust'],
)
else:
wind = '{:.0f}@{}'.format(
wx_data['current']['wind_speed'],
wx_data['current']['wind_deg'],
)
# LOG.debug(wx_data["current"])
# LOG.debug(wx_data["daily"])
reply = '{} {:.1f}{}/{:.1f}{} Wind {} {}%'.format(
wx_data['current']['weather'][0]['description'],
wx_data['current']['temp'],
degree,
wx_data['current']['dew_point'],
degree,
wind,
wx_data['current']['humidity'],
)
return reply
class AVWXWeatherPlugin(plugin.APRSDRegexCommandPluginBase):
"""AVWXWeatherMap Weather Command
Fetches a METAR weather report for the nearest
weather station from the callsign
Can be called with:
metar - fetches metar for caller
metar <CALLSIGN> - fetches metar for <CALLSIGN>
This plugin requires the avwx-api service
to provide the metar for a station near
the callsign.
avwx-api is an opensource project that has
a hosted service here: https://avwx.rest/
You can launch your own avwx-api in a container
by cloning the githug repo here: https://github.com/avwx-rest/AVWX-API
Then build the docker container with:
docker build -f Dockerfile -t avwx-api:master .
"""
command_regex = r'^([m]|[m]|[m]\s|metar)'
command_name = 'AVWXWeather'
short_description = 'AVWX weather of GPS Beacon location'
def setup(self):
if not CONF.avwx_plugin.base_url:
LOG.error('Config avwx_plugin.base_url not specified. Disabling')
return False
elif not CONF.avwx_plugin.apiKey:
LOG.error('Config avwx_plugin.apiKey not specified. Disabling')
return False
self.enabled = True
def help(self):
_help = [
'avwxweather: Send {} to get weather from your location'.format(
self.command_regex
),
'avwxweather: Send {} <callsign> to get weather from <callsign>'.format(
self.command_regex
),
]
return _help
@trace.trace
def process(self, packet):
fromcall = packet.get('from')
message = packet.get('message_text', None)
# ack = packet.get("msgNo", "0")
LOG.info(f"AVWXWeather Plugin '{message}'")
a = re.search(r'^.*\s+(.*)', message)
if a is not None:
searchcall = a.group(1)
searchcall = searchcall.upper()
else:
searchcall = fromcall
api_key = CONF.aprs_fi.apiKey
try:
aprs_data = plugin_utils.get_aprs_fi(api_key, searchcall)
except Exception as ex:
LOG.error(f'Failed to fetch aprs.fi data {ex}')
return 'Failed to fetch location'
# LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
if not len(aprs_data['entries']):
LOG.error('Found no entries from aprs.fi!')
return 'Failed to fetch location'
lat = aprs_data['entries'][0]['lat']
lon = aprs_data['entries'][0]['lng']
api_key = CONF.avwx_plugin.apiKey
base_url = CONF.avwx_plugin.base_url
token = f'TOKEN {api_key}'
headers = {'Authorization': token}
try:
coord = f'{lat},{lon}'
url = (
'{}/api/station/near/{}?'
'n=1&airport=false&reporting=true&format=json'.format(base_url, coord)
)
LOG.debug(f"Get stations near me '{url}'")
response = requests.get(url, headers=headers)
except Exception as ex:
LOG.error(ex)
raise Exception(f"Failed to get the weather '{ex}'") from ex
else:
wx_data = json.loads(response.text)
# LOG.debug(wx_data)
station = wx_data[0]['station']['icao']
try:
url = (
'{}/api/metar/{}?options=info,translate,summary'
'&airport=true&reporting=true&format=json&onfail=cache'.format(
base_url,
station,
)
)
LOG.debug(f"Get METAR '{url}'")
response = requests.get(url, headers=headers)
except Exception as ex:
LOG.error(ex)
raise Exception(f'Failed to get metar {ex}') from ex
else:
metar_data = json.loads(response.text)
# LOG.debug(metar_data)
return metar_data['raw']
+1
View File
@@ -38,6 +38,7 @@ class APRSRegistryThread(aprsd_threads.APRSDThread):
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__} '
+16 -2
View File
@@ -94,12 +94,26 @@ class APRSDRXThread(APRSDThread):
"""Put the raw packet on the queue.
The processing of the packet will happen in a separate thread.
Accepts packets/frames as either:
- Positional arg: process_packet(frame)
- Keyword args: process_packet(raw=frame), process_packet(frame=frame), or process_packet(packet=pkt)
"""
if not args:
# Extract the packet/frame from either args or kwargs
if args:
data = args[0]
elif 'raw' in kwargs:
data = kwargs['raw']
elif 'frame' in kwargs:
data = kwargs['frame']
elif 'packet' in kwargs:
data = kwargs['packet']
else:
LOG.warning('No frame received to process?!?!')
return
self.pkt_count += 1
self.packet_queue.put(args[0])
self.packet_queue.put(data)
class APRSDFilterThread(APRSDThread):
+54
View File
@@ -1,7 +1,9 @@
import datetime
import logging
import threading
import time
import requests
from loguru import logger
from oslo_config import cfg
@@ -46,6 +48,58 @@ class APRSDStatsStoreThread(APRSDThread):
return True
class APRSDPushStatsThread(APRSDThread):
"""Push the local stats to a remote API."""
def __init__(
self, push_url=None, frequency_seconds=None, send_packetlist: bool = False
):
super().__init__('PushStats')
self.push_url = push_url if push_url else CONF.push_stats.push_url
self.period = (
frequency_seconds
if frequency_seconds
else CONF.push_stats.frequency_seconds
)
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']
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()
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}')
time.sleep(1)
return True
class StatsLogThread(APRSDThread):
"""Log the stats from the PacketList."""
+277
View File
@@ -0,0 +1,277 @@
"""Utilities for converting oslo_cfg CONF objects to/from JSON."""
import json
from typing import Any, Dict
from oslo_config import cfg
def conf_to_dict(conf: cfg.CONF) -> Dict[str, Any]:
"""Convert an oslo_cfg CONF object to a flat dictionary.
Converts a CONF object with hierarchical groups into a flat dictionary
where group options are prefixed with 'group_name.option_name'.
Args:
conf: The oslo_cfg CONF object to convert
Returns:
A dictionary with configuration values, where secret options are masked
Example:
>>> from oslo_config import cfg
>>> CONF = cfg.CONF
>>> d = conf_to_dict(CONF)
>>> print(d.get('aprsd.callsign'))
'W5XYZ'
"""
entries = {}
def _sanitize(opt, value):
"""Obfuscate values of options declared secret."""
if opt.secret:
return '*' * 4
return value
# Process top-level options
for opt_name in sorted(conf._opts):
opt = conf._get_opt_info(opt_name)['opt']
value = getattr(conf, opt_name)
sanitized = _sanitize(opt, value)
entries[opt_name] = sanitized
# Process group options
for group_name in sorted(conf._groups):
group_obj = conf._get_group(group_name)
group_attr = conf.GroupAttr(conf, group_obj)
for opt_name in sorted(conf._groups[group_name]._opts):
opt = conf._get_opt_info(opt_name, group_name)['opt']
value = getattr(group_attr, opt_name)
sanitized = _sanitize(opt, value)
gname_opt_name = f'{group_name}.{opt_name}'
entries[gname_opt_name] = sanitized
return entries
def conf_to_json(conf: cfg.CONF, indent: int = 2) -> str:
"""Convert an oslo_cfg CONF object to a JSON string.
Args:
conf: The oslo_cfg CONF object to convert
indent: Number of spaces for indentation (None for compact output)
Returns:
A JSON string representation of the configuration
Example:
>>> from oslo_config import cfg
>>> CONF = cfg.CONF
>>> json_str = conf_to_json(CONF)
>>> print(json_str)
"""
config_dict = conf_to_dict(conf)
return json.dumps(config_dict, indent=indent, default=_json_serializer)
def dict_to_conf(
config_dict: Dict[str, Any],
conf: cfg.CONF = None,
mask_secrets: bool = True,
) -> cfg.CONF:
"""Convert a flat dictionary back to an oslo_cfg CONF object.
Takes a flat dictionary (with keys like ``group_name.option_name`` for grouped
options) and applies those values to a CONF object. Only updates options that
exist in the CONF object.
Args:
config_dict: The configuration dictionary to convert
conf: The oslo_cfg CONF object to update (uses cfg.CONF if None)
mask_secrets: If True, skips options with masked values (four asterisks)
Returns:
The updated CONF object
Example:
>>> from oslo_config import cfg
>>> config_dict = {'aprsd.callsign': 'W5XYZ', 'log_level': 'DEBUG'}
>>> CONF = dict_to_conf(config_dict)
>>> print(CONF.aprsd.callsign)
'W5XYZ'
Note:
- Options with secret masks (four asterisks) are skipped to avoid overwriting
with placeholder values
- Only recognized options in the CONF schema are updated
- Invalid group/option names are silently skipped
"""
if conf is None:
conf = cfg.CONF
for key, value in config_dict.items():
# Skip masked secret values
if mask_secrets and isinstance(value, str) and value == '*' * 4:
continue
if '.' in key:
# Handle grouped options
group_name, opt_name = key.split('.', 1)
try:
# Check if group exists
if group_name in conf:
group = getattr(conf, group_name)
# Check if option exists in group
if hasattr(group, opt_name):
_set_conf_value(conf, group_name, opt_name, value)
except (KeyError, AttributeError):
# Skip unrecognized groups
continue
else:
# Handle top-level options
try:
if hasattr(conf, key):
_set_conf_value(conf, None, key, value)
except (KeyError, AttributeError):
# Skip unrecognized options
continue
return conf
def json_to_conf(
json_str: str,
conf: cfg.CONF = None,
mask_secrets: bool = True,
) -> cfg.CONF:
"""Convert a JSON string back to an oslo_cfg CONF object.
Args:
json_str: The JSON string to parse
conf: The oslo_cfg CONF object to update (uses cfg.CONF if None)
mask_secrets: If True, skips options with masked values (four asterisks)
Returns:
The updated CONF object
Raises:
json.JSONDecodeError: If the JSON string is invalid
Example:
>>> json_str = '{"aprsd.callsign": "W5XYZ", "log_level": "DEBUG"}'
>>> CONF = json_to_conf(json_str)
"""
config_dict = json.loads(json_str)
return dict_to_conf(config_dict, conf, mask_secrets)
def _set_conf_value(
conf: cfg.CONF,
group_name: str,
opt_name: str,
value: Any,
) -> None:
"""Set a configuration value in CONF object with proper type conversion.
Args:
conf: The CONF object
group_name: The group name (None for top-level options)
opt_name: The option name
value: The value to set
Raises:
KeyError: If the option is not found
"""
# Get the option metadata
if group_name:
opt_info = conf._get_opt_info(opt_name, group_name)
else:
opt_info = conf._get_opt_info(opt_name)
opt = opt_info['opt']
# Convert value to appropriate type
converted_value = _convert_value(opt, value)
# Set the value
if group_name:
# For grouped options, we need to set via the group
group = getattr(conf, group_name)
setattr(group, opt_name, converted_value)
else:
# For top-level options
setattr(conf, opt_name, converted_value)
def _convert_value(opt, value: Any) -> Any:
"""Convert a value to the appropriate type for an option.
Handles conversion for oslo_config option types:
- StrOpt: keeps as string
- IntOpt: converts to int
- FloatOpt: converts to float
- BoolOpt: converts to bool
- ListOpt: ensures it's a list
- DictOpt: ensures it's a dict
Args:
opt: The oslo_config option object
value: The value to convert
Returns:
The converted value
"""
if value is None:
return None
# Handle string representations
if isinstance(value, str):
if isinstance(opt, cfg.IntOpt):
return int(value)
elif isinstance(opt, cfg.FloatOpt):
return float(value)
elif isinstance(opt, cfg.BoolOpt):
return value.lower() in ('true', '1', 'yes', 'on')
elif isinstance(opt, (cfg.ListOpt, cfg.MultiOpt)):
# If it's a string representation of a list, parse it
if value.startswith('[') and value.endswith(']'):
return json.loads(value)
return [value] if value else []
elif isinstance(opt, cfg.DictOpt):
# If it's a string representation of a dict, parse it
if value.startswith('{') and value.endswith('}'):
return json.loads(value)
return {}
elif isinstance(value, bool) and not isinstance(opt, cfg.BoolOpt):
# If we got a bool but it's not a BoolOpt, convert to appropriate type
if isinstance(opt, cfg.StrOpt):
return str(value)
elif isinstance(opt, cfg.IntOpt):
return int(value)
elif isinstance(value, (list, tuple)):
if isinstance(opt, (cfg.ListOpt, cfg.MultiOpt)):
return list(value)
elif isinstance(opt, cfg.StrOpt):
# Convert list to comma-separated string
return ','.join(str(v) for v in value)
elif isinstance(value, dict):
if isinstance(opt, cfg.DictOpt):
return value
elif isinstance(opt, cfg.StrOpt):
return json.dumps(value)
return value
def _json_serializer(obj: Any) -> Any:
"""Custom JSON serializer for oslo_config types.
Args:
obj: The object to serialize
Returns:
A JSON-serializable representation of the object
"""
if hasattr(obj, '__iter__') and not isinstance(obj, (str, bytes)):
return list(obj)
return str(obj)
+49
View File
@@ -2,6 +2,7 @@ import datetime
import decimal
import json
import sys
from dataclasses import asdict, is_dataclass
from aprsd.packets import core
@@ -63,6 +64,8 @@ class SimpleJSONEncoder(json.JSONEncoder):
return str(obj)
elif isinstance(obj, core.Packet):
return obj.to_dict()
elif is_dataclass(obj):
return asdict(obj)
else:
return super().default(obj)
@@ -83,3 +86,49 @@ class EnhancedJSONDecoder(json.JSONDecoder):
o = getattr(o, e)
args, kwargs = d.get('args', ()), d.get('kwargs', {})
return o(*args, **kwargs)
class PacketJSONDecoder(json.JSONDecoder):
"""Custom JSON decoder for reconstructing Packet objects from dicts.
This decoder is used by ObjectStoreMixin to reconstruct Packet objects
when loading from JSON files. It handles:
- Packet objects and their subclasses (AckPacket, MessagePacket, etc.)
- Datetime objects stored as ISO format strings
"""
def __init__(self, *args, **kwargs):
super().__init__(
*args,
object_hook=self.object_hook,
**kwargs,
)
def object_hook(self, obj):
"""Reconstruct objects from their dict representation."""
if not isinstance(obj, dict):
return obj
# Check if this looks like a Packet object
# Packets have _type, from_call, and to_call fields
if '_type' in obj and 'from_call' in obj and 'to_call' in obj:
try:
# Use the factory function to reconstruct the correct packet type
return core.factory(obj)
except Exception:
# If reconstruction fails, return as dict
# This prevents data loss if packet format changes
return obj
# Handle datetime strings (ISO format)
# Check for common datetime field names
for key in ['last', 'timestamp', 'last_send_time']:
if key in obj and isinstance(obj[key], str):
try:
# Try to parse as datetime
obj[key] = datetime.datetime.fromisoformat(obj[key])
except (ValueError, TypeError, AttributeError):
# Not a datetime, leave as string
pass
return obj
+39 -13
View File
@@ -1,10 +1,12 @@
import json
import logging
import os
import pathlib
import pickle
from oslo_config import cfg
from aprsd.utils.json import PacketJSONDecoder, SimpleJSONEncoder
CONF = cfg.CONF
LOG = logging.getLogger('APRSD')
@@ -61,13 +63,21 @@ class ObjectStoreMixin:
def _save_filename(self):
save_location = CONF.save_location
return '{}/{}.json'.format(
save_location,
self.__class__.__name__.lower(),
)
def _old_save_filename(self):
"""Return the old pickle filename for migration detection."""
save_location = CONF.save_location
return '{}/{}.p'.format(
save_location,
self.__class__.__name__.lower(),
)
def save(self):
"""Save any queued to disk?"""
"""Save any queued to disk as JSON."""
if not CONF.enable_save:
return
self._init_store()
@@ -79,8 +89,8 @@ class ObjectStoreMixin:
f'{save_filename}',
)
with self.lock:
with open(save_filename, 'wb+') as fp:
pickle.dump(self.data, fp)
with open(save_filename, 'w') as fp:
json.dump(self.data, fp, cls=SimpleJSONEncoder, indent=2)
else:
LOG.debug(
"{} Nothing to save, flushing old save file '{}'".format(
@@ -91,33 +101,49 @@ class ObjectStoreMixin:
self.flush()
def load(self):
"""Load data from JSON file."""
if not CONF.enable_save:
return
json_file = self._save_filename()
pickle_file = self._old_save_filename()
with self.lock:
if os.path.exists(self._save_filename()):
# Check if old pickle file exists but JSON doesn't
if not os.path.exists(json_file) and os.path.exists(pickle_file):
LOG.warning(
f'{self.__class__.__name__}::Found old pickle file {pickle_file}. '
f'Please run "aprsd dev migrate-pickle" to convert to JSON format. '
f'Skipping load to avoid security risk.'
)
return
if os.path.exists(json_file):
try:
with open(self._save_filename(), 'rb') as fp:
raw = pickle.load(fp)
with open(json_file, 'r') as fp:
raw = json.load(fp, cls=PacketJSONDecoder)
if raw:
self.data = raw
# Special handling for OrderedDict in PacketList
if hasattr(self, '_restore_ordereddict'):
self._restore_ordereddict()
LOG.debug(
f'{self.__class__.__name__}::Loaded {len(self)} entries from disk.',
)
else:
LOG.debug(f'{self.__class__.__name__}::No data to load.')
except (pickle.UnpicklingError, Exception) as ex:
LOG.error(f'Failed to UnPickle {self._save_filename()}')
LOG.error(ex)
except (json.JSONDecodeError, Exception) as ex:
LOG.error(f'Failed to load JSON from {json_file}')
LOG.exception(ex)
self.data = {}
else:
LOG.debug(f'{self.__class__.__name__}::No save file found.')
def flush(self):
"""Nuke the old pickle file that stored the old results from last aprsd run."""
"""Remove the JSON save file and clear data."""
if not CONF.enable_save:
return
with self.lock:
if os.path.exists(self._save_filename()):
pathlib.Path(self._save_filename()).unlink()
with self.lock:
self.data = {}
self.data = {}
+101 -2
View File
@@ -11,6 +11,14 @@ import requests
from thesmuggler import smuggle
from aprsd import plugin as aprsd_plugin
from aprsd.plugins import fortune, notify, ping, time, version, weather
# Handle importlib.metadata compatibility
try:
import importlib_metadata
except ImportError:
# For python 3.10 and later
import importlib.metadata as importlib_metadata
LOG = logging.getLogger()
@@ -48,6 +56,34 @@ def walk_package(package):
)
def _get_package_version(package_name):
"""Get the version of an installed package.
Tries multiple methods to get the package version:
1. importlib.metadata.version() - from package metadata
2. Module __version__ attribute - from the package module
3. Returns None if not found
"""
# Try to get version from package metadata
try:
return importlib_metadata.version(package_name)
except importlib_metadata.PackageNotFoundError:
pass
except Exception:
# Handle other potential errors (e.g., AttributeError on older Python)
pass
# Try to get version from the module's __version__ attribute
try:
module = importlib.import_module(package_name)
if hasattr(module, '__version__'):
return module.__version__
except (ImportError, AttributeError):
pass
return None
def get_module_info(package_name, module_name, module_path):
if not os.path.exists(module_path):
return None
@@ -57,6 +93,9 @@ def get_module_info(package_name, module_name, module_path):
obj_list = []
# Get the package version once for all plugins in this package
package_version = _get_package_version(package_name)
for path, _subdirs, files in os.walk(dir_path):
for name in files:
if fnmatch.fnmatch(name, pattern):
@@ -68,13 +107,37 @@ def get_module_info(package_name, module_name, module_path):
module = smuggle(f'{path}/{name}')
for mem_name, obj in inspect.getmembers(module):
if inspect.isclass(obj) and is_plugin(obj):
# Use the actual module path from the class object
# This ensures we get the correct path even when using smuggle
obj_module = getattr(obj, '__module__', None)
# If __module__ is not set or looks incorrect (contains path separators),
# try to construct it from the module_name parameter
if (
not obj_module
or '/' in obj_module
or '\\' in obj_module
):
# Fallback: use module_name from the walk
class_path = f'{module_name}.{obj.__qualname__}'
else:
# Use the actual module path from the class
class_path = f'{obj_module}.{obj.__qualname__}'
# Use package version if available, otherwise fall back to class version
version = (
package_version
if package_version
else getattr(obj, 'version', None)
)
obj_list.append(
{
'package': package_name,
'name': mem_name,
'obj': obj,
'version': obj.version,
'path': f'{".".join([module_name, obj.__name__])}',
'version': version,
'path': class_path,
},
)
except (ImportError, SyntaxError, AttributeError) as e:
@@ -183,3 +246,39 @@ def log_installed_extensions_and_plugins():
for plugin in plugins:
LOG.info(f'Plugin: {plugin} version: {plugins[plugin][0]["version"]}')
def get_built_in_plugins():
"""Discover all built-in APRSD plugins."""
modules = [fortune, notify, ping, time, version, weather]
plugins = []
for module in modules:
entries = inspect.getmembers(module, inspect.isclass)
for entry in entries:
cls = entry[1]
if issubclass(cls, aprsd_plugin.APRSDPluginBase):
plugin_info = {
'package': 'aprsd',
'class_name': cls.__qualname__,
'path': f'{cls.__module__}.{cls.__qualname__}',
'version': cls.version,
'base_class_type': plugin_type(cls),
}
if issubclass(cls, aprsd_plugin.APRSDRegexCommandPluginBase):
try:
cmd_regex = None
for base_cls in inspect.getmro(cls):
if 'command_regex' in base_cls.__dict__:
attr = base_cls.__dict__['command_regex']
if not isinstance(attr, property):
cmd_regex = attr
break
plugin_info['command_regex'] = cmd_regex
except Exception:
plugin_info['command_regex'] = None
plugins.append(plugin_info)
return plugins
+8
View File
@@ -4,6 +4,14 @@ aprsd.utils package
Submodules
----------
aprsd.utils.config\_converter module
------------------------------------
.. automodule:: aprsd.utils.config_converter
:members:
:show-inheritance:
:undoc-members:
aprsd.utils.counter module
--------------------------
+220 -100
View File
@@ -59,7 +59,7 @@ TimePlugin
**Command:** ``time``, ``t``, or ``t`` followed by a space
**Description:** Returns the current local time of the APRSD server in a human-readable format
with timezone information.
(fuzzy time) with timezone information.
**Usage:** Send a message containing "time" to your APRSD callsign.
@@ -74,35 +74,6 @@ with timezone information.
**Plugin Path:** ``aprsd.plugins.time.TimePlugin``
TimeOWMPlugin
~~~~~~~~~~~~~
**Command:** ``time``, ``t``, or ``t`` followed by a space
**Description:** Returns the current time based on the GPS beacon location of the calling
callsign (or optionally a specified callsign). Uses OpenWeatherMap API to determine the
timezone for the location.
**Usage:**
::
You: time
APRSD: quarter to three (14:45 EST)
You: time WB4BOR
APRSD: half past two (14:30 PDT)
**Requirements:**
- Requires an ``aprs_fi.apiKey`` configuration option
- Requires an ``owm_weather_plugin.apiKey`` configuration option
**Configuration:**
- ``aprs_fi.apiKey`` - API key from aprs.fi account
- ``owm_weather_plugin.apiKey`` - OpenWeatherMap API key
**Plugin Path:** ``aprsd.plugins.time.TimeOWMPlugin``
VersionPlugin
~~~~~~~~~~~~~
@@ -181,71 +152,6 @@ aprs.fi API key is not required.
**Plugin Path:** ``aprsd.plugins.weather.USMetarPlugin``
OWMWeatherPlugin
~~~~~~~~~~~~~~~~
**Command:** ``weather``, ``w``, or ``W`` (w or W at start of message)
**Description:** Provides weather information using the OpenWeatherMap API. Works worldwide
and provides current weather conditions including temperature, dew point, wind speed and
direction, and humidity.
**Usage:**
::
You: weather
APRSD: clear sky 72.1F/65.2F Wind 5@270 45%
You: weather WB4BOR
APRSD: partly cloudy 68.5F/62.1F Wind 8@180G12 52%
**Requirements:**
- Requires an ``aprs_fi.apiKey`` configuration option
- Requires an ``owm_weather_plugin.apiKey`` configuration option
**Configuration:**
- ``aprs_fi.apiKey`` - API key from aprs.fi account
- ``owm_weather_plugin.apiKey`` - OpenWeatherMap API key (get one at https://home.openweathermap.org/api_keys)
- ``units`` - Set to "imperial" or "metric" (default: "imperial")
**Plugin Path:** ``aprsd.plugins.weather.OWMWeatherPlugin``
AVWXWeatherPlugin
~~~~~~~~~~~~~~~~~
**Command:** ``metar``, ``m``, or ``m`` followed by a space (m at start of message)
**Description:** Provides METAR weather reports using the AVWX API service. Fetches METAR
data from the nearest weather station to the GPS beacon location of the calling callsign
(or optionally a specified callsign).
**Usage:**
::
You: metar
APRSD: KORD 101451Z 28010KT 10SM FEW250 22/12 A3001 RMK AO2 SLP168 T02220122
You: metar WB4BOR
APRSD: KSFO 101500Z 25015KT 10SM FEW030 18/14 A2998 RMK AO2
**Requirements:**
- Requires an ``aprs_fi.apiKey`` configuration option
- Requires an ``avwx_plugin.apiKey`` configuration option
- Requires an ``avwx_plugin.base_url`` configuration option
**Configuration:**
- ``aprs_fi.apiKey`` - API key from aprs.fi account
- ``avwx_plugin.apiKey`` - API key for AVWX service
- ``avwx_plugin.base_url`` - Base URL for AVWX API (default: https://avwx.rest)
**Note:** AVWX is an open-source project. You can use the hosted service at https://avwx.rest/
or host your own instance. See the plugin code comments for instructions on running your
own AVWX API server.
**Plugin Path:** ``aprsd.plugins.weather.AVWXWeatherPlugin``
HelpPlugin
~~~~~~~~~~
@@ -314,7 +220,7 @@ APRSD configuration file. List the full Python path to each plugin class you wan
::
[DEFAULT]
enabled_plugins = aprsd.plugins.fortune.FortunePlugin,aprsd.plugins.ping.PingPlugin,aprsd.plugins.time.TimePlugin,aprsd.plugins.weather.OWMWeatherPlugin,aprsd.plugins.version.VersionPlugin,aprsd.plugins.notify.NotifySeenPlugin
enabled_plugins = aprsd.plugins.fortune.FortunePlugin,aprsd.plugins.ping.PingPlugin,aprsd.plugins.time.TimePlugin,aprsd.plugins.weather.USWeatherPlugin,aprsd.plugins.version.VersionPlugin,aprsd.plugins.notify.NotifySeenPlugin
**Note:** The HelpPlugin is enabled by default and does not need to be listed in
``enabled_plugins``. It can be disabled by setting ``load_help_plugin = false``.
@@ -322,10 +228,9 @@ APRSD configuration file. List the full Python path to each plugin class you wan
**Note:** Some plugins may require additional configuration (API keys, etc.) and will
automatically disable themselves if required configuration is missing.
**Note:** Weather plugins (USWeatherPlugin, OWMWeatherPlugin, AVWXWeatherPlugin) all use
the same command pattern (``w`` or ``W`` at the start). Only one should be enabled at a time
to avoid conflicts. Similarly, METAR plugins (USMetarPlugin, AVWXWeatherPlugin) use the
same pattern (``m`` or ``M`` at the start).
**Note:** Weather plugins may use the same command patterns. Only one weather plugin should
be enabled at a time to avoid conflicts. Similarly, only one METAR plugin should be enabled
at a time.
Listing Available Plugins
@@ -343,4 +248,219 @@ This command will show:
- Available plugins on PyPI that can be installed
- Currently installed third-party plugins
Finding External Plugins and Extensions
=========================================
APRSD supports external plugins and extensions that extend the functionality beyond the
built-in plugins. These are distributed as separate Python packages that follow a specific
naming convention.
Naming Convention
-----------------
All external APRSD plugins and extensions follow a consistent naming scheme:
* **Plugins:** ``aprsd-<name>-plugin``
* **Extensions:** ``aprsd-<name>-extension``
For example:
* ``aprsd-email-plugin`` - A plugin for email functionality
* ``aprsd-admin-extension`` - An extension for web administration
Finding Plugins and Extensions
-------------------------------
PyPI (Python Package Index)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can find all available APRSD plugins and extensions on PyPI:
* **Search for plugins:** https://pypi.org/search/?q=aprsd+-plugin
* **Search for extensions:** https://pypi.org/search/?q=aprsd+-extension
* **General APRSD search:** https://pypi.org/search/?q=aprsd
The ``aprsd list-plugins`` command also shows available plugins and extensions from PyPI
along with installation status.
GitHub
~~~~~~
Many APRSD plugins and extensions are hosted on GitHub under the `hemna organization`_:
* **Organization:** https://github.com/hemna/
* **Search for plugins:** https://github.com/orgs/hemna/repositories?q=aprsd-plugin
* **Search for extensions:** https://github.com/orgs/hemna/repositories?q=aprsd-extension
Installing External Plugins and Extensions
-------------------------------------------
To install an external plugin or extension, use pip:
.. code-block:: shell
pip install aprsd-<name>-plugin
# or
pip install aprsd-<name>-extension
After installation, the plugin or extension will be automatically discovered by APRSD.
You may need to add it to your ``enabled_plugins`` configuration or configure it according
to its documentation.
Available External Plugins
---------------------------
The following external plugins are available:
Email Plugin
~~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-email-plugin/
* **GitHub:** https://github.com/hemna/aprsd-email-plugin
* **Description:** Send and receive email via APRS messages.
Location Plugin
~~~~~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-location-plugin/
* **GitHub:** https://github.com/hemna/aprsd-location-plugin
* **Description:** Get the latest GPS location of a callsign.
Location Data Plugin
~~~~~~~~~~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-locationdata-plugin/
* **GitHub:** https://github.com/hemna/aprsd-locationdata-plugin
* **Description:** Get detailed GPS location data for a callsign.
DigiPi Plugin
~~~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-digipi-plugin/
* **GitHub:** https://github.com/hemna/aprsd-digipi-plugin
* **Description:** Look for DigiPi beacon packets and provide DigiPi-specific functionality.
W3W Plugin
~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-w3w-plugin/
* **GitHub:** https://github.com/hemna/aprsd-w3w-plugin
* **Description:** Get What3Words (w3w) coordinates for a location.
MQTT Plugin
~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-mqtt-plugin/
* **GitHub:** https://github.com/hemna/aprsd-mqtt-plugin
* **Description:** Send APRS packets to an MQTT topic for integration with IoT systems.
Telegram Plugin
~~~~~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-telegram-plugin/
* **GitHub:** https://github.com/hemna/aprsd-telegram-plugin
* **Description:** Send and receive messages via Telegram.
Borat Plugin
~~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-borat-plugin/
* **GitHub:** https://github.com/hemna/aprsd-borat-plugin
* **Description:** Get random Borat quotes via APRS messages.
WXNow Plugin
~~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-wxnow-plugin/
* **GitHub:** https://github.com/hemna/aprsd-wxnow-plugin
* **Description:** Get weather reports from the closest N weather stations.
WeeWX Plugin
~~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-weewx-plugin/
* **GitHub:** https://github.com/hemna/aprsd-weewx-plugin
* **Description:** Get weather data from your WeeWX weather station.
Slack Plugin
~~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-slack-plugin/
* **GitHub:** https://github.com/hemna/aprsd-slack-plugin
* **Description:** Send and receive messages to/from a Slack channel.
Sentry Plugin
~~~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-sentry-plugin/
* **GitHub:** https://github.com/hemna/aprsd-sentry-plugin
* **Description:** Integration with Sentry for error tracking and monitoring.
Repeat Plugins
~~~~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-repeat-plugins/
* **GitHub:** https://github.com/hemna/aprsd-repeat-plugins
* **Description:** Plugins for the REPEAT service - get nearest Ham radio repeaters.
Twitter Plugin
~~~~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-twitter-plugin/
* **GitHub:** https://github.com/hemna/aprsd-twitter-plugin
* **Description:** Make tweets from your Ham Radio via APRS messages.
Time OpenCage Plugin
~~~~~~~~~~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-timeopencage-plugin/
* **GitHub:** https://github.com/hemna/aprsd-timeopencage-plugin
* **Description:** Get local time for a callsign using OpenCage geocoding.
Stock Plugin
~~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-stock-plugin/
* **GitHub:** https://github.com/hemna/aprsd-stock-plugin
* **Description:** Get stock quotes from your Ham radio via APRS messages.
Available External Extensions
-----------------------------
The following external extensions are available:
Admin Extension
~~~~~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-admin-extension/
* **GitHub:** https://github.com/hemna/aprsd-admin-extension
* **Description:** Web-based administration interface for APRSD with real-time status,
configuration management, and monitoring capabilities.
WebChat Extension
~~~~~~~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-webchat-extension/
* **GitHub:** https://github.com/hemna/aprsd-webchat-extension
* **Description:** Web-based APRS messaging interface that allows you to send and receive
APRS messages through a browser.
Rich CLI Extension
~~~~~~~~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-rich-cli-extension/
* **GitHub:** https://github.com/hemna/aprsd-rich-cli-extension
* **Description:** Enhanced Textual-based rich CLI versions of APRSD commands with improved
user interface and interactivity.
IRC Extension
~~~~~~~~~~~~~
* **PyPI:** https://pypi.org/project/aprsd-irc-extension/
* **GitHub:** https://github.com/hemna/aprsd-irc-extension
* **Description:** IRC-like server command for APRS, providing an IRC-style interface to
the APRS network.
.. _hemna organization: https://github.com/hemna
.. include:: links.rst
+7 -1
View File
@@ -133,7 +133,7 @@ Sample config file
# Comma separated list of enabled plugins for APRSD.To enable
# installed external plugins add them here.The full python path to the
# class name must be used (list value)
#enabled_plugins = aprsd.plugins.fortune.FortunePlugin,aprsd.plugins.location.LocationPlugin,aprsd.plugins.ping.PingPlugin,aprsd.plugins.time.TimePlugin,aprsd.plugins.weather.OWMWeatherPlugin,aprsd.plugins.version.VersionPlugin,aprsd.plugins.notify.NotifySeenPlugin
#enabled_plugins = aprsd.plugins.fortune.FortunePlugin,aprsd.plugins.ping.PingPlugin,aprsd.plugins.time.TimePlugin,aprsd.plugins.weather.USWeatherPlugin,aprsd.plugins.version.VersionPlugin,aprsd.plugins.notify.NotifySeenPlugin
[aprs_fi]
@@ -208,6 +208,9 @@ Sample config file
#
# From aprsd.conf
#
# Note: AVWXWeatherPlugin is an external plugin. Install it with:
# pip install aprsd-avwx-weather-plugin
# See the builtin_plugins documentation for information about external plugins.
# avwx-api is an opensource project that hasa hosted service here:
# https://avwx.rest/You can launch your own avwx-api in a containerby
@@ -303,6 +306,9 @@ Sample config file
#
# From aprsd.conf
#
# Note: OWMWeatherPlugin is an external plugin. Install it with:
# pip install aprsd-owm-weather-plugin
# See the builtin_plugins documentation for information about external plugins.
# OWMWeatherPlugin api key to OpenWeatherMap's API.This plugin uses
# the openweathermap API to fetchlocation and weather information.To
+3 -3
View File
@@ -74,7 +74,7 @@ on creating your own plugins.
2025-12-10 14:30:05.256 | MainThread | INFO | <aprsd.client.client.APRSDClient object at 0x1096ac460> | aprsd.cmds.server:server:64
2025-12-10 14:30:05.256 | MainThread | INFO | Loading Plugin Manager and registering plugins | aprsd.cmds.server:server:78
2025-12-10 14:30:05.257 | MainThread | INFO | Loading APRSD Plugins | aprsd.plugin:setup_plugins:493
2025-12-10 14:30:05.257 | MainThread | INFO | Registering Regex plugin 'aprsd.plugins.weather.AVWXWeatherPlugin'(4.2.5.dev8+g9c0695794) -- ^([m]|[m]|[m]\s|metar) | aprsd.plugin:_load_plugin:452
2025-12-10 14:30:05.257 | MainThread | INFO | Registering Regex plugin 'aprsd.plugins.weather.USWeatherPlugin'(4.2.5.dev8+g9c0695794) -- ^[wW] | aprsd.plugin:_load_plugin:452
2025-12-10 14:30:05.257 | MainThread | INFO | Completed Plugin Loading. | aprsd.plugin:setup_plugins:513
2025-12-10 14:30:05.257 | MainThread | DEBUG | ******************************************************************************** | oslo_config.cfg:log_opt_values:2804
2025-12-10 14:30:05.257 | MainThread | DEBUG | Configuration options gathered from: | oslo_config.cfg:log_opt_values:2805
@@ -95,7 +95,7 @@ on creating your own plugins.
2025-12-10 14:30:05.258 | MainThread | DEBUG | enable_save = True | oslo_config.cfg:log_opt_values:2817
2025-12-10 14:30:05.258 | MainThread | DEBUG | enable_seen_list = True | oslo_config.cfg:log_opt_values:2817
2025-12-10 14:30:05.258 | MainThread | DEBUG | enable_sending_ack_packets = True | oslo_config.cfg:log_opt_values:2817
2025-12-10 14:30:05.258 | MainThread | DEBUG | enabled_plugins = ['aprsd.plugins.weather.AVWXWeatherPlugin'] | oslo_config.cfg:log_opt_values:2817
2025-12-10 14:30:05.258 | MainThread | DEBUG | enabled_plugins = ['aprsd.plugins.weather.USWeatherPlugin'] | oslo_config.cfg:log_opt_values:2817
2025-12-10 14:30:05.258 | MainThread | DEBUG | is_digipi = False | oslo_config.cfg:log_opt_values:2817
2025-12-10 14:30:05.258 | MainThread | DEBUG | latitude = 37.3443862 | oslo_config.cfg:log_opt_values:2817
2025-12-10 14:30:05.258 | MainThread | DEBUG | load_help_plugin = True | oslo_config.cfg:log_opt_values:2817
@@ -143,7 +143,7 @@ on creating your own plugins.
2025-12-10 14:30:05.260 | MainThread | DEBUG | avwx_plugin.base_url = https://avwx.rest | oslo_config.cfg:log_opt_values:2824
2025-12-10 14:30:05.260 | MainThread | DEBUG | ******************************************************************************** | oslo_config.cfg:log_opt_values:2828
2025-12-10 14:30:05.260 | MainThread | INFO | Message Plugins enabled and running: | aprsd.cmds.server:server:86
2025-12-10 14:30:05.260 | MainThread | INFO | <aprsd.plugins.weather.AVWXWeatherPlugin object at 0x109a74c40> | aprsd.cmds.server:server:88
2025-12-10 14:30:05.260 | MainThread | INFO | <aprsd.plugins.weather.USWeatherPlugin object at 0x109a74c40> | aprsd.cmds.server:server:88
2025-12-10 14:30:05.260 | MainThread | INFO | <aprsd.plugin.HelpPlugin object at 0x109a74ac0> | aprsd.cmds.server:server:88
2025-12-10 14:30:05.260 | MainThread | INFO | Watchlist Plugins enabled and running: | aprsd.cmds.server:server:89
2025-12-10 14:30:05.260 | MainThread | DEBUG | Loading saved packet tracking data. | aprsd.cmds.server:server:103
+34 -11
View File
@@ -18,9 +18,9 @@ description = "APRSd is a APRS-IS server that can be used to connect to APRS-IS
# 'Programming Language' classifiers in this file, 'pip install' will check this
# and refuse to install the project if the version does not match. See
# https://packaging.python.org/guides/distributing-packages-using-setuptools/#python-requires
requires-python = ">=3.10"
requires-python = ">=3.11"
dynamic = ["version", "dependencies", "optional-dependencies"]
dynamic = ["version", "dependencies"]
# This is an optional longer description of your project that represents
# the body of text which users will see when they visit PyPI.
@@ -43,8 +43,8 @@ license = {file = "LICENSE"}
# authored the project, and a valid email address corresponding to the name
# listed.
authors = [
{name = "Craig Lamparter", email = "craig@craiger.org"},
{name = "Walter A. Boring IV", email = "waboring@hemna.com"},
{name = "Craig Lamparter", email = "craig@craiger.org"},
{name = "Emre Saglam", email = "emresaglam@gmail.com"},
{name = "Jason Martin", email= "jhmartin@toger.us"},
{name = "John", email="johng42@users.noreply.github.com"},
@@ -57,8 +57,8 @@ authors = [
# maintains the project, and a valid email address corresponding to the name
# listed.
maintainers = [
{name = "Craig Lamparter", email = "craig@craiger.org"},
{name = "Walter A. Boring IV", email = "waboring@hemna.com"},
{name = "Craig Lamparter", email = "craig@craiger.org"},
]
# This field adds keywords for your project which will appear on the
@@ -91,7 +91,6 @@ classifiers = [
"Topic :: Internet",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
@@ -104,9 +103,6 @@ classifiers = [
# https://packaging.python.org/discussions/install-requires-vs-requirements/
[tool.setuptools.dynamic]
dependencies = {file = ["./requirements.txt"]}
optional-dependencies.dev = {file = ["./requirements-dev.txt"]}
optional-dependencies.tests = {file = ["./requirements-tests.txt"]}
optional-dependencies.type = {file = ["./requirements-type.txt"]}
# List additional groups of dependencies here (e.g. development
# dependencies). Users will be able to install these using the "extras"
@@ -117,7 +113,34 @@ optional-dependencies.type = {file = ["./requirements-type.txt"]}
# Optional dependencies the project provides. These are commonly
# referred to as "extras". For a more extensive definition see:
# https://packaging.python.org/en/latest/specifications/dependency-specifiers/#extras
# [project.optional-dependencies]
[project.optional-dependencies]
dev = [
"build",
"pip",
"pip-tools",
"pre-commit",
"pre-commit-uv>=4.1.1",
"tox",
"tox-uv",
"wheel",
"pytest",
"pytest-cov",
"ruff",
"mypy",
"types-pytz",
"types-requests",
"types-tzlocal",
]
tests = [
"pytest",
"pytest-cov",
]
type = [
"mypy",
"types-pytz",
"types-requests",
"types-tzlocal",
]
# List URLs that are relevant to your project
#
@@ -131,7 +154,7 @@ optional-dependencies.type = {file = ["./requirements-type.txt"]}
# what's used to render the link text on PyPI.
[project.urls]
"Homepage" = "https://github.com/craigerl/aprsd"
"Documentation" = "https://aprsd.readthedocs.io/en/latest/"
"Documentation" = "https://aprsd.readthedocs.io"
"Bug Reports" = "https://github.com/craigerl/aprsd/issues"
"Source" = "https://github.com/craigerl/aprsd"
@@ -156,7 +179,7 @@ packages = ["aprsd"]
[build-system]
requires = [
"setuptools>=69.5.0",
"setuptools>=80.10.0",
"setuptools_scm>=0",
"wheel",
]
-26
View File
@@ -1,26 +0,0 @@
build
pip
pip-tools
pre-commit
pre-commit-uv>=4.1.1
tox
tox-uv
wheel
# Testing
pytest
pytest-cov
# Linting and formatting
ruff
# Type checking
mypy
types-pytz
types-requests
types-tzlocal
# Twine is used for uploading packages to pypi
# but it induces an install of cryptography
# This is sucky for rpi systems.
# twine
-45
View File
@@ -1,45 +0,0 @@
# This file was autogenerated by uv via the following command:
# uv pip compile --resolver backtracking --annotation-style=line requirements-dev.in -o requirements-dev.txt
build==1.3.0 # via pip-tools, -r requirements-dev.in
cachetools==6.2.4 # via tox
cfgv==3.5.0 # via pre-commit
chardet==5.2.0 # via tox
click==8.3.1 # via pip-tools
colorama==0.4.6 # via tox
coverage==7.13.1 # via pytest-cov
distlib==0.4.0 # via virtualenv
exceptiongroup==1.3.1 # via pytest
filelock==3.20.0 # via tox, virtualenv
identify==2.6.15 # via pre-commit
iniconfig==2.3.0 # via pytest
librt==0.7.8 # via mypy
mypy==1.19.1 # via -r requirements-dev.in
mypy-extensions==1.1.0 # via mypy
nodeenv==1.9.1 # via pre-commit
packaging==25.0 # via build, pyproject-api, pytest, tox, tox-uv
pathspec==1.0.3 # via mypy
pip==25.3 # via pip-tools, -r requirements-dev.in
pip-tools==7.5.2 # via -r requirements-dev.in
platformdirs==4.5.1 # via tox, virtualenv
pluggy==1.6.0 # via pytest, pytest-cov, tox
pre-commit==4.5.0 # via pre-commit-uv, -r requirements-dev.in
pre-commit-uv==4.2.0 # via -r requirements-dev.in
pygments==2.19.2 # via pytest
pyproject-api==1.10.0 # via tox
pyproject-hooks==1.2.0 # via build, pip-tools
pytest==9.0.2 # via pytest-cov, -r requirements-dev.in
pytest-cov==7.0.0 # via -r requirements-dev.in
pyyaml==6.0.3 # via pre-commit
ruff==0.14.13 # via -r requirements-dev.in
setuptools==80.9.0 # via pip-tools
tomli==2.4.0 # via build, coverage, mypy, pip-tools, pyproject-api, pytest, tox, tox-uv
tox==4.32.0 # via tox-uv, -r requirements-dev.in
tox-uv==1.29.0 # via -r requirements-dev.in
types-pytz==2025.2.0.20251108 # via types-tzlocal, -r requirements-dev.in
types-requests==2.32.4.20260107 # via -r requirements-dev.in
types-tzlocal==5.1.0.1 # via -r requirements-dev.in
typing-extensions==4.15.0 # via exceptiongroup, mypy, tox, virtualenv
urllib3==2.6.2 # via types-requests
uv==0.9.26 # via pre-commit-uv, tox-uv
virtualenv==20.35.4 # via pre-commit, tox
wheel==0.45.1 # via pip-tools, -r requirements-dev.in
+1 -1
View File
@@ -1,5 +1,5 @@
#aprslib>=0.7.0
git+https://github.com/hemna/aprs-python.git@telemetry
aprslib @ git+https://github.com/hemna/aprs-python.git@telemetry
click
dataclasses-json
haversine
+2 -2
View File
@@ -40,10 +40,10 @@ class TestAPRSDFakeDriver(unittest.TestCase):
def test_is_alive(self):
"""Test is_alive returns True when thread_stop is False."""
self.driver.thread_stop = False
self.assertTrue(self.driver.is_alive())
self.assertTrue(self.driver.is_alive)
self.driver.thread_stop = True
self.assertFalse(self.driver.is_alive())
self.assertFalse(self.driver.is_alive)
def test_close(self):
"""Test close sets thread_stop to True."""
+12 -4
View File
@@ -11,9 +11,13 @@ class ConcreteKISSDriver(KISSDriver):
def __init__(self):
super().__init__()
self.transport = 'test'
self.path = '/dev/test'
@staticmethod
def transport() -> str:
"""Return transport type."""
return 'test'
def read_frame(self):
"""Implementation of abstract method."""
return None
@@ -171,11 +175,15 @@ class TestKISSDriver(unittest.TestCase):
self.driver._connected = True
callback = mock.MagicMock()
mock_frame = b'test_frame'
mock_packet = mock.MagicMock()
with mock.patch.object(self.driver, 'read_frame', return_value=mock_frame):
with mock.patch('aprsd.client.drivers.kiss_common.LOG'):
self.driver.consumer(callback)
callback.assert_called()
with mock.patch.object(
self.driver, 'decode_packet', return_value=mock_packet
):
with mock.patch('aprsd.client.drivers.kiss_common.LOG'):
self.driver.consumer(callback)
callback.assert_called_once_with(packet=mock_packet)
def test_read_frame_not_implemented(self):
"""Test read_frame() raises NotImplementedError."""
+10 -6
View File
@@ -77,8 +77,8 @@ class TestTCPKISSDriver(unittest.TestCase):
self.assertFalse(self.driver._running)
def test_transport_property(self):
"""Test transport property returns correct value."""
self.assertEqual(self.driver.transport, 'tcpkiss')
"""Test transport method returns correct value."""
self.assertEqual(self.driver.transport(), 'tcpkiss')
def test_is_enabled_true(self):
"""Test is_enabled returns True when KISS TCP is enabled."""
@@ -270,7 +270,7 @@ class TestTCPKISSDriver(unittest.TestCase):
def test_stats_serializable(self):
"""Test stats with serializable=True converts datetime to ISO format."""
self.driver.keepalive = datetime.datetime.now()
self.driver._keepalive = datetime.datetime.now()
stats = self.driver.stats(serializable=True)
@@ -373,6 +373,7 @@ class TestTCPKISSDriver(unittest.TestCase):
"""Test consumer processes frames and calls callback."""
mock_callback = mock.MagicMock()
mock_frame = mock.MagicMock()
mock_packet = mock.MagicMock()
# Configure driver for test
self.driver._connected = True
@@ -386,10 +387,13 @@ class TestTCPKISSDriver(unittest.TestCase):
with mock.patch.object(
self.driver, 'read_frame', side_effect=side_effect
) as mock_read_frame:
self.driver.consumer(mock_callback)
with mock.patch.object(
self.driver, 'decode_packet', return_value=mock_packet
):
self.driver.consumer(mock_callback)
mock_read_frame.assert_called_once()
mock_callback.assert_called_once_with(mock_frame)
mock_read_frame.assert_called_once()
mock_callback.assert_called_once_with(packet=mock_packet)
@mock.patch('aprsd.client.drivers.tcpkiss.LOG')
def test_read_frame_success(self, mock_log):
+9 -9
View File
@@ -149,31 +149,31 @@ class TestAPRSDClient(unittest.TestCase):
self.registry_patcher.start()
def test_login_success_property(self):
"""Test login_success property."""
"""Test login_success method."""
client = APRSDClient(auto_connect=False)
self.mock_driver.login_status['success'] = True
self.assertTrue(client.login_success)
self.assertTrue(client.login_success())
self.mock_driver.login_status['success'] = False
self.assertFalse(client.login_success)
self.assertFalse(client.login_success())
def test_login_success_no_driver(self):
"""Test login_success property when driver is None."""
"""Test login_success method when driver is None."""
client = APRSDClient(auto_connect=False)
client.driver = None
self.assertFalse(client.login_success)
self.assertFalse(client.login_success())
def test_login_failure_property(self):
"""Test login_failure property."""
"""Test login_failure method."""
client = APRSDClient(auto_connect=False)
self.mock_driver.login_status['message'] = 'Test failure'
self.assertEqual(client.login_failure, 'Test failure')
self.assertEqual(client.login_failure(), 'Test failure')
def test_login_failure_no_driver(self):
"""Test login_failure property when driver is None."""
"""Test login_failure method when driver is None."""
client = APRSDClient(auto_connect=False)
client.driver = None
self.assertIsNone(client.login_failure)
self.assertIsNone(client.login_failure())
def test_set_filter(self):
"""Test set_filter() method."""
+144
View File
@@ -0,0 +1,144 @@
import sys
import unittest
from unittest import mock
from click.testing import CliRunner
from aprsd.main import cli
class TestSampleConfigCommand(unittest.TestCase):
"""Unit tests for the sample_config command."""
def _create_mock_entry_point(self, name):
"""Create a mock entry point object."""
mock_entry = mock.Mock()
mock_entry.name = name
mock_entry.group = 'oslo.config.opts'
return mock_entry
@mock.patch('aprsd.main.generator.generate')
@mock.patch('aprsd.main.imp.entry_points')
@mock.patch('aprsd.main.metadata_version')
def test_sample_config_default_ini_output(
self, mock_version, mock_entry_points, mock_generate
):
"""Test sample_config command outputs INI format by default."""
mock_version.return_value = '1.0.0'
# Mock entry_points to return at least one aprsd entry point
# so that get_namespaces() returns a non-empty list
if sys.version_info >= (3, 10):
mock_entry_points.return_value = [
self._create_mock_entry_point('aprsd.conf')
]
else:
# For Python < 3.10, entry_points() returns a dict-like object
mock_entry = self._create_mock_entry_point('aprsd.conf')
mock_dict = {'oslo.config.opts': [mock_entry]}
mock_entry_points.return_value = mock_dict
runner = CliRunner()
result = runner.invoke(
cli,
['sample-config'],
catch_exceptions=False,
)
assert result.exit_code == 0
# Verify generator.generate was called
mock_generate.assert_called_once()
# The conf object passed should not have format_ set to 'json'
call_args = mock_generate.call_args
conf_obj = call_args[0][0]
# When output_json is False, format_ should not be set to 'json'
assert not hasattr(conf_obj, 'format_') or conf_obj.format_ != 'json'
@mock.patch('rich.console.Console')
@mock.patch('aprsd.main.generator.generate')
@mock.patch('aprsd.main.imp.entry_points')
@mock.patch('aprsd.main.metadata_version')
def test_sample_config_json_output(
self, mock_version, mock_entry_points, mock_generate, mock_console
):
"""Test sample_config command with --output-json flag outputs JSON format."""
mock_version.return_value = '1.0.0'
# Mock entry_points to return at least one aprsd entry point
if sys.version_info >= (3, 10):
mock_entry_points.return_value = [
self._create_mock_entry_point('aprsd.conf')
]
else:
# For Python < 3.10, entry_points() returns a dict-like object
mock_entry = self._create_mock_entry_point('aprsd.conf')
mock_dict = {'oslo.config.opts': [mock_entry]}
mock_entry_points.return_value = mock_dict
# Mock generator.generate to write JSON to stdout
# This simulates what oslo.config generator does when format_='json'
def generate_side_effect(conf):
import sys
json_output = '{"test": "config", "version": "1.0"}'
sys.stdout.write(json_output)
mock_generate.side_effect = generate_side_effect
# Mock the Console
mock_console_instance = mock.Mock()
mock_console.return_value = mock_console_instance
runner = CliRunner()
result = runner.invoke(
cli,
['sample-config', '--output-json'],
catch_exceptions=False,
)
assert result.exit_code == 0
# Verify generator.generate was called
mock_generate.assert_called_once()
# Verify Console was instantiated
mock_console.assert_called_once()
# Verify print_json was called with parsed JSON
mock_console_instance.print_json.assert_called_once()
call_args = mock_console_instance.print_json.call_args
# The data argument should be a dict (parsed JSON)
assert isinstance(call_args[1]['data'], dict)
assert call_args[1]['data'] == {'test': 'config', 'version': '1.0'}
# Verify that conf.format_ was set to 'json' before generate was called
generate_call_conf = mock_generate.call_args[0][0]
assert generate_call_conf.format_ == 'json'
@mock.patch('aprsd.main.generator.generate')
@mock.patch('aprsd.main.imp.entry_points')
@mock.patch('aprsd.main.metadata_version')
def test_sample_config_without_flag(
self, mock_version, mock_entry_points, mock_generate
):
"""Test sample_config command without --output-json flag (explicit default)."""
mock_version.return_value = '1.0.0'
# Mock entry_points to return at least one aprsd entry point
if sys.version_info >= (3, 10):
mock_entry_points.return_value = [
self._create_mock_entry_point('aprsd.conf')
]
else:
# For Python < 3.10, entry_points() returns a dict-like object
mock_entry = self._create_mock_entry_point('aprsd.conf')
mock_dict = {'oslo.config.opts': [mock_entry]}
mock_entry_points.return_value = mock_dict
runner = CliRunner()
result = runner.invoke(
cli,
['sample-config'],
catch_exceptions=False,
)
assert result.exit_code == 0
# Verify generator.generate was called
mock_generate.assert_called_once()
# Verify format_ was not set to 'json'
call_args = mock_generate.call_args
conf_obj = call_args[0][0]
assert not hasattr(conf_obj, 'format_') or conf_obj.format_ != 'json'
+2 -4
View File
@@ -61,14 +61,12 @@ class MockClientDriver:
stats['path'] = self.path
return stats
@property
def login_success(self):
"""Property to get login success status."""
"""Method to get login success status."""
return self.login_status['success']
@property
def login_failure(self):
"""Property to get login failure message."""
"""Method to get login failure message."""
return self.login_status['message']
def _decode_packet(self, *args, **kwargs):
+38 -2
View File
@@ -1,5 +1,6 @@
import os
import unittest
from unittest import mock
from aprsd import plugin
from aprsd.utils import package
@@ -74,8 +75,43 @@ class TestPackage(unittest.TestCase):
self.assertIsNotNone(extensions)
def test_get_pypi_packages(self):
packages = package.get_pypi_packages()
self.assertIsNotNone(packages)
# Mock PyPI API responses
mock_simple_response = mock.MagicMock()
mock_simple_response.json.return_value = {
'projects': [
{'name': 'aprsd-plugin-test'},
{'name': 'aprsd-extension-test'},
{'name': 'other-package'},
]
}
# Create mock responses for each package
def create_package_response(pkg_name):
mock_response = mock.MagicMock()
mock_response.json.return_value = {
'info': {
'name': pkg_name,
'version': '1.0.0',
'summary': f'Test {pkg_name}',
'package_url': f'https://pypi.org/project/{pkg_name}/',
},
'releases': {'1.0.0': [{'upload_time': '2024-01-01T00:00:00'}]},
}
return mock_response
with mock.patch('aprsd.utils.package.requests.get') as mock_get:
# First call returns simple response, subsequent calls return package info
mock_get.side_effect = [
mock_simple_response,
create_package_response('aprsd-plugin-test'),
create_package_response('aprsd-extension-test'),
]
packages = package.get_pypi_packages()
self.assertIsNotNone(packages)
# Verify requests.get was called (at least once for simple API)
self.assertTrue(mock_get.called)
# Should have called for simple API + 2 packages
self.assertGreaterEqual(mock_get.call_count, 1)
def test_log_installed_extensions_and_plugins(self):
package.log_installed_extensions_and_plugins()
+15
View File
@@ -15,12 +15,17 @@ 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()
def tearDown(self):
"""Clean up after tests."""
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."""
@@ -233,12 +238,17 @@ class TestAPRSDFilterThread(unittest.TestCase):
pass
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.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."""
@@ -321,12 +331,17 @@ class TestAPRSDProcessPacketThread(unittest.TestCase):
pass
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.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."""
+278 -1
View File
@@ -1,8 +1,14 @@
import unittest
from unittest import mock
import requests
from aprsd.stats import collector
from aprsd.threads.stats import APRSDStatsStoreThread, StatsStore
from aprsd.threads.stats import (
APRSDPushStatsThread,
APRSDStatsStoreThread,
StatsStore,
)
class TestStatsStore(unittest.TestCase):
@@ -145,5 +151,276 @@ class TestAPRSDStatsStoreThread(unittest.TestCase):
# since the increment happens in the parent run() method, not in loop()
class TestAPRSDPushStatsThread(unittest.TestCase):
"""Unit tests for the APRSDPushStatsThread class."""
def test_init_with_explicit_args(self):
"""Test initialization with explicit push_url, frequency, and send_packetlist."""
thread = APRSDPushStatsThread(
push_url='https://example.com/api',
frequency_seconds=30,
send_packetlist=True,
)
self.assertEqual(thread.name, 'PushStats')
self.assertEqual(thread.push_url, 'https://example.com/api')
self.assertEqual(thread.period, 30)
self.assertTrue(thread.send_packetlist)
self.assertTrue(hasattr(thread, 'loop_count'))
def test_init_uses_conf_when_args_not_passed(self):
"""Test initialization uses CONF.push_stats when args omitted."""
with mock.patch('aprsd.threads.stats.CONF') as mock_conf:
mock_conf.push_stats.push_url = 'https://conf.example.com'
mock_conf.push_stats.frequency_seconds = 15
thread = APRSDPushStatsThread()
self.assertEqual(thread.push_url, 'https://conf.example.com')
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."""
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'),
):
result = thread.loop()
self.assertTrue(result)
mock_collector.return_value.collect.assert_not_called()
mock_post.assert_not_called()
def test_loop_pushes_stats_and_removes_packetlist_by_default(self):
"""Test loop collects stats, POSTs to url/stats, and strips PacketList.packets."""
thread = APRSDPushStatsThread(
push_url='https://example.com',
frequency_seconds=10,
send_packetlist=False,
)
thread.loop_count = 10
collected = {
'PacketList': {'packets': [1, 2, 3], 'rx': 5, 'tx': 1},
'Other': 'data',
}
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('aprsd.threads.stats.datetime') as mock_dt,
):
mock_collector_class.return_value.collect.return_value = collected
mock_dt.datetime.now.return_value.strftime.return_value = (
'01-01-2025 12:00:00'
)
result = thread.loop()
self.assertTrue(result)
mock_collector_class.return_value.collect.assert_called_once_with(
serializable=True
)
mock_post.assert_called_once()
call_args = mock_post.call_args
self.assertEqual(call_args[0][0], 'https://example.com/stats')
self.assertEqual(call_args[1]['headers'], {'Content-Type': 'application/json'})
self.assertEqual(call_args[1]['timeout'], 5)
body = call_args[1]['json']
self.assertEqual(body['time'], '01-01-2025 12:00:00')
self.assertNotIn('packets', body['stats']['PacketList'])
self.assertEqual(body['stats']['PacketList']['rx'], 5)
self.assertEqual(body['stats']['Other'], 'data')
def test_loop_pushes_stats_with_packetlist_when_send_packetlist_true(self):
"""Test loop includes PacketList.packets when send_packetlist is True."""
thread = APRSDPushStatsThread(
push_url='https://example.com',
frequency_seconds=10,
send_packetlist=True,
)
thread.loop_count = 10
collected = {'PacketList': {'packets': [1, 2, 3], 'rx': 5}}
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('aprsd.threads.stats.datetime') as mock_dt,
):
mock_collector_class.return_value.collect.return_value = collected
mock_dt.datetime.now.return_value.strftime.return_value = (
'01-01-2025 12:00:00'
)
result = thread.loop()
self.assertTrue(result)
body = mock_post.call_args[1]['json']
self.assertEqual(body['stats']['PacketList']['packets'], [1, 2, 3])
def test_loop_on_http_200_logs_success(self):
"""Test loop logs info on successful 200 response."""
thread = APRSDPushStatsThread(
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('aprsd.threads.stats.datetime') as mock_dt,
mock.patch('aprsd.threads.stats.LOGU') as mock_logu,
):
mock_collector_class.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_logu.info.assert_called()
self.assertIn('Successfully pushed stats', mock_logu.info.call_args[0][0])
def test_loop_on_non_200_logs_warning(self):
"""Test loop logs warning when response is not 200."""
thread = APRSDPushStatsThread(
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('aprsd.threads.stats.datetime') as mock_dt,
mock.patch('aprsd.threads.stats.LOGU') as mock_logu,
):
mock_collector_class.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 = 500
mock_post.return_value.raise_for_status = mock.Mock()
result = thread.loop()
self.assertTrue(result)
mock_logu.warning.assert_called_once()
self.assertIn('Failed to push stats', mock_logu.warning.call_args[0][0])
self.assertIn('500', mock_logu.warning.call_args[0][0])
def test_loop_on_request_exception_logs_error_and_continues(self):
"""Test loop logs error on requests.RequestException and returns True."""
thread = APRSDPushStatsThread(
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('aprsd.threads.stats.datetime') as mock_dt,
mock.patch('aprsd.threads.stats.LOGU') as mock_logu,
):
mock_collector_class.return_value.collect.return_value = {}
mock_dt.datetime.now.return_value.strftime.return_value = (
'01-01-2025 12:00:00'
)
mock_post.side_effect = requests.exceptions.ConnectionError(
'Connection refused'
)
result = thread.loop()
self.assertTrue(result)
mock_logu.error.assert_called_once()
self.assertIn('Error pushing stats', mock_logu.error.call_args[0][0])
def test_loop_on_other_exception_logs_error_and_continues(self):
"""Test loop logs error on unexpected exception and returns True."""
thread = APRSDPushStatsThread(
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('aprsd.threads.stats.datetime') as mock_dt,
mock.patch('aprsd.threads.stats.LOGU') as mock_logu,
):
mock_collector_class.return_value.collect.return_value = {}
mock_dt.datetime.now.return_value.strftime.return_value = (
'01-01-2025 12:00:00'
)
mock_post.side_effect = ValueError('unexpected')
result = thread.loop()
self.assertTrue(result)
mock_logu.error.assert_called_once()
self.assertIn('Unexpected error in stats push', mock_logu.error.call_args[0][0])
def test_loop_no_packetlist_key_in_stats(self):
"""Test loop does not fail when stats have no PacketList key."""
thread = APRSDPushStatsThread(
push_url='https://example.com',
frequency_seconds=10,
send_packetlist=False,
)
thread.loop_count = 10
collected = {'Only': 'data', 'No': 'PacketList'}
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('aprsd.threads.stats.datetime') as mock_dt,
):
mock_collector_class.return_value.collect.return_value = collected
mock_dt.datetime.now.return_value.strftime.return_value = (
'01-01-2025 12:00:00'
)
result = thread.loop()
self.assertTrue(result)
body = mock_post.call_args[1]['json']
self.assertEqual(body['stats'], collected)
if __name__ == '__main__':
unittest.main()
+2 -2
View File
@@ -531,8 +531,8 @@ class TestAckSendSchedulerThread(unittest.TestCase):
ack_packet2 = fake.fake_ack_packet()
ack_packet2.send_count = 0
mock_tracker.keys.return_value = ['123', '456']
mock_tracker.get.side_effect = (
lambda x: ack_packet1 if x == '123' else ack_packet2
mock_tracker.get.side_effect = lambda x: (
ack_packet1 if x == '123' else ack_packet2
)
mock_tracker_class.return_value = mock_tracker
+67 -8
View File
@@ -1,5 +1,6 @@
import datetime
import json
import os
import pickle
import shutil
import tempfile
import threading
@@ -8,6 +9,7 @@ from unittest import mock
from oslo_config import cfg
from aprsd.packets import core
from aprsd.utils import objectstore
CONF = cfg.CONF
@@ -94,7 +96,7 @@ class TestObjectStoreMixin(unittest.TestCase):
filename = obj._save_filename()
self.assertIn('testobjectstore', filename.lower())
self.assertTrue(filename.endswith('.p'))
self.assertTrue(filename.endswith('.json'))
def test_save(self):
"""Test save() method."""
@@ -107,9 +109,9 @@ class TestObjectStoreMixin(unittest.TestCase):
filename = obj._save_filename()
self.assertTrue(os.path.exists(filename))
# Verify data was saved
with open(filename, 'rb') as fp:
loaded_data = pickle.load(fp)
# Verify data was saved as JSON
with open(filename, 'r') as fp:
loaded_data = json.load(fp)
self.assertEqual(loaded_data, obj.data)
def test_save_empty(self):
@@ -154,13 +156,13 @@ class TestObjectStoreMixin(unittest.TestCase):
mock_log.debug.assert_called()
def test_load_corrupted_file(self):
"""Test load() with corrupted pickle file."""
"""Test load() with corrupted JSON file."""
obj = TestObjectStore()
filename = obj._save_filename()
# Create corrupted file
with open(filename, 'wb') as fp:
fp.write(b'corrupted data')
with open(filename, 'w') as fp:
fp.write('{corrupted json data')
with mock.patch('aprsd.utils.objectstore.LOG') as mock_log:
obj.load()
@@ -246,3 +248,60 @@ class TestObjectStoreMixin(unittest.TestCase):
self.assertEqual(len(errors), 0)
# All operations should complete
self.assertGreater(len(obj.data), 0)
def test_save_load_with_datetime(self):
"""Test save/load with datetime objects."""
obj = TestObjectStore()
test_time = datetime.datetime.now()
obj.data['timestamp'] = test_time
obj.data['key1'] = 'value1'
obj.save()
obj2 = TestObjectStore()
obj2.load()
self.assertEqual(obj2.data['key1'], 'value1')
# Datetime should be preserved (may lose microseconds precision)
loaded_time = obj2.data['timestamp']
self.assertIsInstance(loaded_time, (datetime.datetime, str))
def test_save_load_with_packet(self):
"""Test save/load with Packet objects."""
obj = TestObjectStore()
packet = core.MessagePacket(
from_call='N0CALL',
to_call='TEST',
message_text='Test message',
)
obj.data['packet1'] = packet
obj.data['key1'] = 'value1'
obj.save()
obj2 = TestObjectStore()
obj2.load()
self.assertEqual(obj2.data['key1'], 'value1')
# Packet should be reconstructed
loaded_packet = obj2.data['packet1']
self.assertIsInstance(loaded_packet, core.Packet)
self.assertEqual(loaded_packet.from_call, 'N0CALL')
self.assertEqual(loaded_packet.to_call, 'TEST')
def test_old_pickle_file_warning(self):
"""Test warning when old pickle file exists."""
obj = TestObjectStore()
pickle_filename = obj._old_save_filename()
# Create a fake pickle file
with open(pickle_filename, 'wb') as fp:
fp.write(b'fake pickle data')
with mock.patch('aprsd.utils.objectstore.LOG') as mock_log:
obj.load()
# Should log warning about pickle file
mock_log.warning.assert_called()
call_args = str(mock_log.warning.call_args)
self.assertIn('pickle', call_args.lower())
self.assertIn('migrate', call_args.lower())
+16 -14
View File
@@ -3,7 +3,7 @@ minversion = 4.30.0
skipdist = True
skip_missing_interpreters = true
envlist = lint,py{311,312,313,314}
requires = tox-uv
requires = tox-uv, ruff
# Activate isolated build environment. tox will use a virtual environment
# to build a source distribution from the source tree. For build tools and
@@ -11,15 +11,14 @@ requires = tox-uv
isolated_build = true
[testenv]
description = Run unit-testing
runner = uv-venv-lock-runner
description = Run unit-testing with pytest
setenv =
_PYTEST_SETUP_SKIP_APRSD_DEP=1
PYTHONDONTWRITEBYTECODE=1
PYTHONUNBUFFERED=1
package = editable
deps =
pytest
pytest-cov
extras =
tests
commands =
pytest -v --cov-report term-missing --cov=aprsd tests {posargs}
coverage: coverage report -m
@@ -42,9 +41,9 @@ commands =
sphinx-build -M html source build
[testenv:lint]
skip_install = true
deps =
ruff
runner = uv-venv-lock-runner
description = Run ruff linter and formatter checks
allowlist_externals = ruff
commands =
ruff check aprsd tests {posargs}
ruff format --check aprsd tests
@@ -62,14 +61,15 @@ passenv = FAST8_NUM_COMMITS
# This section is not needed if not using GitHub Actions for CI.
[gh-actions]
python =
3.10: py39, lint, type-check, docs
3.11: py311, lint, type-check, docs
3.12: py312, lint, type-check, docs
3.13: py313, lint, type-check, docs
3.14: py314, lint, type-check, docs
[testenv:fmt]
# This will reformat your code using ruff
skip_install = true
deps =
ruff
runner = uv-venv-lock-runner
description = Auto-fix code formatting and linting errors using ruff
allowlist_externals = ruff
commands =
ruff format aprsd tests
ruff check --fix aprsd tests
@@ -88,6 +88,7 @@ commands =
skip_install = true
basepython = python3
deps = pre-commit
allowlist_externals = pre-commit
commands = pre-commit run --all-files --show-diff-on-failure
[testenv:fix]
@@ -95,6 +96,7 @@ description = run code formatter and linter (auto-fix)
skip_install = true
deps =
pre-commit-uv>=4.1.1
allowlist_externals = pre-commit
commands =
pre-commit run --all-files --show-diff-on-failure
Generated
+398 -109
View File
@@ -1,6 +1,6 @@
version = 1
revision = 3
requires-python = ">=3.10"
requires-python = ">=3.11"
[[package]]
name = "aprsd"
@@ -54,52 +54,44 @@ dependencies = [
[package.optional-dependencies]
dev = [
{ name = "build" },
{ name = "cachetools" },
{ name = "cfgv" },
{ name = "chardet" },
{ name = "click" },
{ name = "colorama" },
{ name = "distlib" },
{ name = "filelock" },
{ name = "identify" },
{ name = "nodeenv" },
{ name = "packaging" },
{ name = "mypy" },
{ name = "pip" },
{ name = "pip-tools" },
{ name = "platformdirs" },
{ name = "pluggy" },
{ name = "pre-commit" },
{ name = "pyproject-api" },
{ name = "pyproject-hooks" },
{ name = "pyyaml" },
{ name = "setuptools" },
{ name = "tomli" },
{ name = "pre-commit-uv" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
{ name = "tox" },
{ name = "typing-extensions" },
{ name = "virtualenv" },
{ name = "tox-uv" },
{ name = "types-pytz" },
{ name = "types-requests" },
{ name = "types-tzlocal" },
{ name = "wheel" },
]
tests = [
{ name = "pytest" },
{ name = "pytest-cov" },
]
type = [
{ name = "mypy" },
{ name = "types-pytz" },
{ name = "types-requests" },
{ name = "types-tzlocal" },
]
[package.metadata]
requires-dist = [
{ name = "aprslib", specifier = "==0.7.2" },
{ name = "aprslib", git = "https://github.com/hemna/aprs-python.git?rev=09cd7a2829a2e9d28ee1566881c843cc4769e590" },
{ name = "attrs", specifier = "==25.4.0" },
{ name = "ax253", specifier = "==0.1.5.post1" },
{ name = "bitarray", specifier = "==3.8.0" },
{ name = "build", marker = "extra == 'dev'", specifier = "==1.3.0" },
{ name = "cachetools", marker = "extra == 'dev'", specifier = "==6.2.4" },
{ name = "build", marker = "extra == 'dev'" },
{ name = "certifi", specifier = "==2025.11.12" },
{ name = "cfgv", marker = "extra == 'dev'", specifier = "==3.5.0" },
{ name = "chardet", marker = "extra == 'dev'", specifier = "==5.2.0" },
{ name = "charset-normalizer", specifier = "==3.4.4" },
{ name = "click", specifier = "==8.3.1" },
{ name = "click", marker = "extra == 'dev'", specifier = "==8.3.1" },
{ name = "colorama", marker = "extra == 'dev'", specifier = "==0.4.6" },
{ name = "dataclasses-json", specifier = "==0.6.7" },
{ name = "distlib", marker = "extra == 'dev'", specifier = "==0.4.0" },
{ name = "filelock", marker = "extra == 'dev'", specifier = "==3.20.0" },
{ name = "haversine", specifier = "==2.9.0" },
{ name = "identify", marker = "extra == 'dev'", specifier = "==2.6.15" },
{ name = "idna", specifier = "==3.11" },
{ name = "importlib-metadata", specifier = "==8.7.0" },
{ name = "kiss3", specifier = "==8.0.0" },
@@ -107,60 +99,60 @@ requires-dist = [
{ name = "markdown-it-py", specifier = "==4.0.0" },
{ name = "marshmallow", specifier = "==3.26.1" },
{ name = "mdurl", specifier = "==0.1.2" },
{ name = "mypy", marker = "extra == 'dev'" },
{ name = "mypy", marker = "extra == 'type'" },
{ name = "mypy-extensions", specifier = "==1.1.0" },
{ name = "netaddr", specifier = "==1.3.0" },
{ name = "nodeenv", marker = "extra == 'dev'", specifier = "==1.9.1" },
{ name = "oslo-config", specifier = "==10.1.0" },
{ name = "oslo-i18n", specifier = "==6.7.1" },
{ name = "packaging", specifier = "==25.0" },
{ name = "packaging", marker = "extra == 'dev'", specifier = "==25.0" },
{ name = "pbr", specifier = "==7.0.3" },
{ name = "pip", marker = "extra == 'dev'", specifier = "==25.3" },
{ name = "pip-tools", marker = "extra == 'dev'", specifier = "==7.5.2" },
{ name = "platformdirs", marker = "extra == 'dev'", specifier = "==4.5.1" },
{ name = "pip", marker = "extra == 'dev'" },
{ name = "pip-tools", marker = "extra == 'dev'" },
{ name = "pluggy", specifier = "==1.6.0" },
{ name = "pluggy", marker = "extra == 'dev'", specifier = "==1.6.0" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.5.0" },
{ name = "pre-commit", marker = "extra == 'dev'" },
{ name = "pre-commit-uv", marker = "extra == 'dev'", specifier = ">=4.1.1" },
{ name = "pygments", specifier = "==2.19.2" },
{ name = "pyproject-api", marker = "extra == 'dev'", specifier = "==1.10.0" },
{ name = "pyproject-hooks", marker = "extra == 'dev'", specifier = "==1.2.0" },
{ name = "pyserial", specifier = "==3.5" },
{ name = "pyserial-asyncio", specifier = "==0.6" },
{ name = "pytest", marker = "extra == 'dev'" },
{ name = "pytest", marker = "extra == 'tests'" },
{ name = "pytest-cov", marker = "extra == 'dev'" },
{ name = "pytest-cov", marker = "extra == 'tests'" },
{ name = "pytz", specifier = "==2025.2" },
{ name = "pyyaml", specifier = "==6.0.3" },
{ name = "pyyaml", marker = "extra == 'dev'", specifier = "==6.0.3" },
{ name = "requests", specifier = "==2.32.5" },
{ name = "rfc3986", specifier = "==2.0.0" },
{ 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", marker = "extra == 'dev'", specifier = "==80.9.0" },
{ name = "stevedore", specifier = "==5.6.0" },
{ name = "thesmuggler", specifier = "==1.0.1" },
{ name = "timeago", specifier = "==1.0.16" },
{ name = "tomli", marker = "extra == 'dev'", specifier = "==2.3.0" },
{ name = "tox", marker = "extra == 'dev'", specifier = "==4.32.0" },
{ name = "tox", marker = "extra == 'dev'" },
{ name = "tox-uv", marker = "extra == 'dev'" },
{ name = "types-pytz", marker = "extra == 'dev'" },
{ name = "types-pytz", marker = "extra == 'type'" },
{ name = "types-requests", marker = "extra == 'dev'" },
{ name = "types-requests", marker = "extra == 'type'" },
{ name = "types-tzlocal", marker = "extra == 'dev'" },
{ name = "types-tzlocal", marker = "extra == 'type'" },
{ name = "typing-extensions", specifier = "==4.15.0" },
{ name = "typing-extensions", marker = "extra == 'dev'", specifier = "==4.15.0" },
{ 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 = "virtualenv", marker = "extra == 'dev'", specifier = "==20.35.4" },
{ name = "wheel", marker = "extra == 'dev'", specifier = "==0.45.1" },
{ name = "wheel", marker = "extra == 'dev'" },
{ name = "wrapt", specifier = "==2.0.1" },
{ name = "zipp", specifier = "==3.23.0" },
]
provides-extras = ["dev"]
provides-extras = ["dev", "tests", "type"]
[[package]]
name = "aprslib"
version = "0.7.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/2b/3c051f7bf65cb684040dfc13713d8836b72615f3b811107736aee59cab0d/aprslib-0.7.2.tar.gz", hash = "sha256:c20d2568ab8728a0526b4c95952c6022433d462674abdfb0fd86b2e50ed3c097", size = 25826, upload-time = "2022-07-10T12:45:13.214Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/b4/4b82e41dc007cfa68ee076ef905b823f841e4ce868fe7ee8b22cfe26796a/aprslib-0.7.2-py2.py3-none-any.whl", hash = "sha256:db09eac4c5f44b172e15399350147370fdafdd8f6befd69b697fbced8c83e7c6", size = 44746, upload-time = "2022-07-10T12:45:11.607Z" },
]
source = { git = "https://github.com/hemna/aprs-python.git?rev=09cd7a2829a2e9d28ee1566881c843cc4769e590#09cd7a2829a2e9d28ee1566881c843cc4769e590" }
[[package]]
name = "attrs"
@@ -191,19 +183,6 @@ version = "3.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/06/92fdc84448d324ab8434b78e65caf4fb4c6c90b4f8ad9bdd4c8021bfaf1e/bitarray-3.8.0.tar.gz", hash = "sha256:3eae38daffd77c9621ae80c16932eea3fb3a4af141fb7cc724d4ad93eff9210d", size = 151991, upload-time = "2025-11-02T21:41:15.117Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/b9/8a645fd36fc4c01ee223f97eccd4699c2f2e91681ccb33c0e963881c8e58/bitarray-3.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f08342dc8d19214faa7ef99574dea6c37a2790d6d04a9793ef8fa76c188dc08d", size = 148504, upload-time = "2025-11-02T21:38:54.596Z" },
{ url = "https://files.pythonhosted.org/packages/c0/f4/11b562e13ff732bd0674376f367f0a272034ebc28b8efbafbeb924552d21/bitarray-3.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:792462abfeeca6cc8c6c1e6d27e14319682f0182f6b0ba37befe911af794db70", size = 145481, upload-time = "2025-11-02T21:38:56.253Z" },
{ url = "https://files.pythonhosted.org/packages/d3/7c/5a2487da579491b38abab3b437e01d3b05be6e16e69cc5eb304040dcebd5/bitarray-3.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0df69d26f21a9d2f1b20266f6737fa43f08aa5015c99900fb69f255fbe4dabb4", size = 322760, upload-time = "2025-11-02T21:38:57.189Z" },
{ url = "https://files.pythonhosted.org/packages/8d/59/f0ef82d6a878d4af1b4961d208a716317929aa172fc0dfa5f4115319a873/bitarray-3.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b4f10d3f304be7183fac79bf2cd997f82e16aa9a9f37343d76c026c6e435a8a8", size = 350332, upload-time = "2025-11-02T21:38:58.238Z" },
{ url = "https://files.pythonhosted.org/packages/e5/ec/d444b22fce853327d4a8adec1de9987e11b28fcc2d7204dcbc544e196ed9/bitarray-3.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fc98ff43abad61f00515ad9a06213b7716699146e46eabd256cdfe7cb522bd97", size = 360787, upload-time = "2025-11-02T21:38:59.239Z" },
{ url = "https://files.pythonhosted.org/packages/9f/9e/60b205f52ea9ff155e9f12249090475159c909039daa29e47cd95e115dd5/bitarray-3.8.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81c6b4a6c1af800d52a6fa32389ef8f4281583f4f99dc1a40f2bb47667281541", size = 329050, upload-time = "2025-11-02T21:39:00.455Z" },
{ url = "https://files.pythonhosted.org/packages/e3/da/2ce373b423bc85a0eb93ee1cba3977971259a92a116932632f417b1b04d2/bitarray-3.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f3fd8df63c41ff6a676d031956aebf68ebbc687b47c507da25501eb22eec341f", size = 320507, upload-time = "2025-11-02T21:39:01.714Z" },
{ url = "https://files.pythonhosted.org/packages/2a/88/437408a2674b8bdb02063dd1535969b9c73cb8fdd197485de431e506c50e/bitarray-3.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0ce9d9e07c75da8027c62b4c9f45771d1d8aae7dc9ad7fb606c6a5aedbe9741", size = 348449, upload-time = "2025-11-02T21:39:03.124Z" },
{ url = "https://files.pythonhosted.org/packages/97/46/d799e7e731c778b6dcb4627bafd395102065e5ab15a4a31f4222a3e20706/bitarray-3.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8a9c962c64a4c08def58b9799333e33af94ec53038cf151d36edacdb41f81646", size = 344776, upload-time = "2025-11-02T21:39:04.147Z" },
{ url = "https://files.pythonhosted.org/packages/b3/9a/129fff56d22d316b1c848c6e13e64191485756b5cd6ceb08e640edb80020/bitarray-3.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1a54d7e7999735faacdcbe8128e30207abc2caf9f9fd7102d180b32f1b78bfce", size = 325899, upload-time = "2025-11-02T21:39:05.118Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/4b01e99452ecc39f4abccf9bf83fe0f01c390e9794dad2d04b2c8b893c5f/bitarray-3.8.0-cp310-cp310-win32.whl", hash = "sha256:3ea52df96566457735314794422274bd1962066bfb609e7eea9113d70cf04ffe", size = 142756, upload-time = "2025-11-02T21:39:06.402Z" },
{ url = "https://files.pythonhosted.org/packages/18/3f/c83635a67d90f45f88012468566c233eed1e9e9a9184fa882ba4039fadb3/bitarray-3.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:82a07de83dce09b4fa1bccbdc8bde8f188b131666af0dc9048ba0a0e448d8a3b", size = 149527, upload-time = "2025-11-02T21:39:07.377Z" },
{ url = "https://files.pythonhosted.org/packages/33/46/391b3902a523d4555313640746460b19d317c6233d9379e150af97fa1554/bitarray-3.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:c5ba07e58fd98c9782201e79eb8dd4225733d212a5a3700f9a84d329bd0463a6", size = 146453, upload-time = "2025-11-02T21:39:08.624Z" },
{ url = "https://files.pythonhosted.org/packages/bc/7d/63558f1d0eb09217a3d30c1c847890879973e224a728fcff9391fab999b8/bitarray-3.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:25b9cff6c9856bc396232e2f609ea0c5ec1a8a24c500cee4cca96ba8a3cd50b6", size = 148502, upload-time = "2025-11-02T21:39:09.993Z" },
{ url = "https://files.pythonhosted.org/packages/5e/7b/f957ad211cb0172965b5f0881b67b99e2b6d41512af0a1001f44a44ddf4a/bitarray-3.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d9984017314da772f5f7460add7a0301a4ffc06c72c2998bb16c300a6253607", size = 145484, upload-time = "2025-11-02T21:39:10.904Z" },
{ url = "https://files.pythonhosted.org/packages/9f/dc/897973734f14f91467a3a795a4624752238053ecffaec7c8bbda1e363fda/bitarray-3.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbbbfbb7d039b20d289ce56b1beb46138d65769d04af50c199c6ac4cb6054d52", size = 330909, upload-time = "2025-11-02T21:39:12.276Z" },
@@ -277,10 +256,8 @@ version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "os_name == 'nt'" },
{ name = "importlib-metadata", marker = "python_full_version < '3.10.2'" },
{ name = "packaging" },
{ name = "pyproject-hooks" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/1c/23e33405a7c9eac261dff640926b8b5adaed6a6eb3e1767d441ed611d0c0/build-1.3.0.tar.gz", hash = "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397", size = 48544, upload-time = "2025-08-01T21:27:09.268Z" }
wheels = [
@@ -329,22 +306,6 @@ version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" },
{ url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" },
{ url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" },
{ url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" },
{ url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" },
{ url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" },
{ url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" },
{ url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" },
{ url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" },
{ url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" },
{ url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" },
{ url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" },
{ url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" },
{ url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
{ url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
{ url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
@@ -433,6 +394,98 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.13.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" },
{ url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" },
{ url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" },
{ url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" },
{ url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" },
{ url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" },
{ url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" },
{ url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" },
{ url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" },
{ url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" },
{ url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" },
{ url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" },
{ url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" },
{ url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" },
{ url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" },
{ url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" },
{ url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" },
{ url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" },
{ url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" },
{ url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" },
{ url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" },
{ url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" },
{ url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" },
{ url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" },
{ url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" },
{ url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" },
{ url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" },
{ url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" },
{ url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" },
{ url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" },
{ url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" },
{ url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" },
{ url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" },
{ url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" },
{ url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" },
{ url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" },
{ url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" },
{ url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" },
{ url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" },
{ url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" },
{ url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" },
{ url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" },
{ url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" },
{ url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" },
{ url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" },
{ url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" },
{ url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" },
{ url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" },
{ url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" },
{ url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" },
{ url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" },
{ url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" },
{ url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" },
{ url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" },
{ url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" },
{ url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" },
{ url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" },
{ url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" },
{ url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" },
{ url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" },
{ url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" },
{ url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" },
{ url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" },
{ url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" },
{ url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" },
{ url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" },
{ url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" },
{ url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" },
]
[package.optional-dependencies]
toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[package]]
name = "dataclasses-json"
version = "0.6.7"
@@ -503,6 +556,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "kiss3"
version = "8.0.0"
@@ -519,6 +581,69 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/8f/8aa885553dc126a5fa5277cf2879c5bff85cf3cb200ba2f9a6b26b53d37e/kiss3-8.0.0-py3-none-any.whl", hash = "sha256:374cbd86ac817c811dbddab12053e13563f34bcfa43eca7aed49a94d91083bba", size = 11261, upload-time = "2022-06-13T02:57:39.481Z" },
]
[[package]]
name = "librt"
version = "0.7.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" },
{ url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" },
{ url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" },
{ url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" },
{ url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" },
{ url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" },
{ url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" },
{ url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803, upload-time = "2026-01-14T12:55:04.996Z" },
{ url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080, upload-time = "2026-01-14T12:55:06.489Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383, upload-time = "2026-01-14T12:55:07.49Z" },
{ url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" },
{ url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" },
{ url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" },
{ url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" },
{ url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" },
{ url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" },
{ url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" },
{ url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" },
{ url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" },
{ url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" },
{ url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" },
{ url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" },
{ url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" },
{ url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" },
{ url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" },
{ url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" },
{ url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" },
{ url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" },
{ url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" },
{ url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" },
{ url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" },
{ url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" },
{ url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" },
{ url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" },
{ url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" },
{ url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" },
{ url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" },
{ url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" },
{ url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" },
{ url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" },
{ url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" },
{ url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" },
{ url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" },
{ url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" },
{ url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" },
{ url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" },
{ url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" },
{ url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" },
{ url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" },
{ url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" },
]
[[package]]
name = "loguru"
version = "0.7.3"
@@ -565,6 +690,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mypy"
version = "1.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" },
{ url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" },
{ url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" },
{ url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" },
{ url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" },
{ url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" },
{ url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" },
{ url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" },
{ url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" },
{ url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" },
{ url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" },
{ url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" },
{ url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" },
{ url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" },
{ url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" },
{ url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" },
{ url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" },
{ url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" },
{ url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" },
{ url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" },
{ url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" },
{ url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
@@ -630,6 +794,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pathspec"
version = "1.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" },
]
[[package]]
name = "pbr"
version = "7.0.3"
@@ -661,7 +834,6 @@ dependencies = [
{ name = "pip" },
{ name = "pyproject-hooks" },
{ name = "setuptools" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "wheel" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/79/d149fb40bc425ad9defcb8ff73c65088bbc36a84b1825e035397d1c40624/pip_tools-7.5.2.tar.gz", hash = "sha256:2d64d72da6a044da1110257d333960563d7a4743637e8617dd2610ae7b82d60f", size = 164815, upload-time = "2025-11-12T22:46:12.627Z" }
@@ -703,6 +875,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/c4/b2d28e9d2edf4f1713eb3c29307f1a63f3d67cf09bdda29715a36a68921a/pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1", size = 226429, upload-time = "2025-11-22T21:02:40.836Z" },
]
[[package]]
name = "pre-commit-uv"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pre-commit" },
{ name = "uv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/42/84372bc99a841bfdd8b182a50186471a7f5e873d8e8bcec0d0cb6dabcbb0/pre_commit_uv-4.2.0.tar.gz", hash = "sha256:c32bb1d90235507726eee2aeef2be5fdab431a6f1906e3f1addb0a4e99b369d1", size = 6912, upload-time = "2025-10-09T19:30:48.354Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/9f/ec8491f6b3022489a4d36ce372214c10a34f90b425aa61ff2e0a8dc5b9d5/pre_commit_uv-4.2.0-py3-none-any.whl", hash = "sha256:cc1b56641e6c62d90a4d8b4f0af6f2610f1c397ce81af024e768c0f33715cb81", size = 5650, upload-time = "2025-10-09T19:30:47.257Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
@@ -718,7 +903,6 @@ version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/45/7b/c0e1333b61d41c69e59e5366e727b18c4992688caf0de1be10b3e5265f6b/pyproject_api-1.10.0.tar.gz", hash = "sha256:40c6f2d82eebdc4afee61c773ed208c04c19db4c4a60d97f8d7be3ebc0bbb330", size = 22785, upload-time = "2025-10-09T19:12:27.21Z" }
wheels = [
@@ -755,6 +939,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/27/24/c820cf15f87f7b164e83710c1852d4f900d9793961579e5ef64189bc0c10/pyserial_asyncio-0.6-py3-none-any.whl", hash = "sha256:de9337922619421b62b9b1a84048634b3ac520e1d690a674ed246a2af7ce1fc5", size = 7594, upload-time = "2021-09-30T22:29:00.12Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage", extra = ["toml"] },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]]
name = "pytz"
version = "2025.2"
@@ -770,15 +984,6 @@ version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
@@ -865,6 +1070,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
]
[[package]]
name = "ruff"
version = "0.14.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" },
{ url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" },
{ url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" },
{ url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" },
{ url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" },
{ url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" },
{ url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" },
{ url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" },
{ url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" },
{ url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" },
{ url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" },
{ url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" },
{ url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" },
{ url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" },
{ url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" },
{ url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" },
{ url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" },
]
[[package]]
name = "rush"
version = "2021.4.0"
@@ -974,8 +1205,6 @@ dependencies = [
{ name = "platformdirs" },
{ name = "pluggy" },
{ name = "pyproject-api" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/bf/0e4dbd42724cbae25959f0e34c95d0c730df03ab03f54d52accd9abfc614/tox-4.32.0.tar.gz", hash = "sha256:1ad476b5f4d3679455b89a992849ffc3367560bbc7e9495ee8a3963542e7c8ff", size = 203330, upload-time = "2025-10-24T18:03:38.132Z" }
@@ -983,6 +1212,53 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/cc/e09c0d663a004945f82beecd4f147053567910479314e8d01ba71e5d5dea/tox-4.32.0-py3-none-any.whl", hash = "sha256:451e81dc02ba8d1ed20efd52ee409641ae4b5d5830e008af10fe8823ef1bd551", size = 175905, upload-time = "2025-10-24T18:03:36.337Z" },
]
[[package]]
name = "tox-uv"
version = "1.29.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
{ name = "tox" },
{ name = "uv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4f/90/06752775b8cfadba8856190f5beae9f552547e0f287e0246677972107375/tox_uv-1.29.0.tar.gz", hash = "sha256:30fa9e6ad507df49d3c6a2f88894256bcf90f18e240a00764da6ecab1db24895", size = 23427, upload-time = "2025-10-09T20:40:27.384Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/17/221d62937c4130b044bb437caac4181e7e13d5536bbede65264db1f0ac9f/tox_uv-1.29.0-py3-none-any.whl", hash = "sha256:b1d251286edeeb4bc4af1e24c8acfdd9404700143c2199ccdbb4ea195f7de6cc", size = 17254, upload-time = "2025-10-09T20:40:25.885Z" },
]
[[package]]
name = "types-pytz"
version = "2025.2.0.20251108"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/40/ff/c047ddc68c803b46470a357454ef76f4acd8c1088f5cc4891cdd909bfcf6/types_pytz-2025.2.0.20251108.tar.gz", hash = "sha256:fca87917836ae843f07129567b74c1929f1870610681b4c92cb86a3df5817bdb", size = 10961, upload-time = "2025-11-08T02:55:57.001Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/c1/56ef16bf5dcd255155cc736d276efa6ae0a5c26fd685e28f0412a4013c01/types_pytz-2025.2.0.20251108-py3-none-any.whl", hash = "sha256:0f1c9792cab4eb0e46c52f8845c8f77cf1e313cb3d68bf826aa867fe4717d91c", size = 10116, upload-time = "2025-11-08T02:55:56.194Z" },
]
[[package]]
name = "types-requests"
version = "2.32.4.20260107"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" },
]
[[package]]
name = "types-tzlocal"
version = "5.1.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-pytz" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/cf/e4d446e57c0b14ed1da4de180d2a4cac773b667f183e83bdad76ea6e2238/types-tzlocal-5.1.0.1.tar.gz", hash = "sha256:b84a115c0c68f0d0fa9af1c57f0645eeef0e539147806faf1f95ac3ac01ce47b", size = 3549, upload-time = "2023-10-24T02:15:07.127Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/13/caeb438290df069ddda6f055d0eb14337ada293c7d43ab89419ba4b1a778/types_tzlocal-5.1.0.1-py3-none-any.whl", hash = "sha256:0302e8067c86936de8f7e0aaedc2cfbf240080802c603df0f80312fbd4efb926", size = 3005, upload-time = "2023-10-24T02:15:05.815Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
@@ -1047,6 +1323,32 @@ 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" },
]
[[package]]
name = "uv"
version = "0.9.26"
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" }
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" },
]
[[package]]
name = "virtualenv"
version = "20.35.4"
@@ -1055,7 +1357,6 @@ dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" }
wheels = [
@@ -1086,18 +1387,6 @@ version = "2.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/0d/12d8c803ed2ce4e5e7d5b9f5f602721f9dfef82c95959f3ce97fa584bb5c/wrapt-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:64b103acdaa53b7caf409e8d45d39a8442fe6dcfec6ba3f3d141e0cc2b5b4dbd", size = 77481, upload-time = "2025-11-07T00:43:11.103Z" },
{ url = "https://files.pythonhosted.org/packages/05/3e/4364ebe221ebf2a44d9fc8695a19324692f7dd2795e64bd59090856ebf12/wrapt-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:91bcc576260a274b169c3098e9a3519fb01f2989f6d3d386ef9cbf8653de1374", size = 60692, upload-time = "2025-11-07T00:43:13.697Z" },
{ url = "https://files.pythonhosted.org/packages/1f/ff/ae2a210022b521f86a8ddcdd6058d137c051003812b0388a5e9a03d3fe10/wrapt-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ab594f346517010050126fcd822697b25a7031d815bb4fbc238ccbe568216489", size = 61574, upload-time = "2025-11-07T00:43:14.967Z" },
{ url = "https://files.pythonhosted.org/packages/c6/93/5cf92edd99617095592af919cb81d4bff61c5dbbb70d3c92099425a8ec34/wrapt-2.0.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:36982b26f190f4d737f04a492a68accbfc6fa042c3f42326fdfbb6c5b7a20a31", size = 113688, upload-time = "2025-11-07T00:43:18.275Z" },
{ url = "https://files.pythonhosted.org/packages/a0/0a/e38fc0cee1f146c9fb266d8ef96ca39fb14a9eef165383004019aa53f88a/wrapt-2.0.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23097ed8bc4c93b7bf36fa2113c6c733c976316ce0ee2c816f64ca06102034ef", size = 115698, upload-time = "2025-11-07T00:43:19.407Z" },
{ url = "https://files.pythonhosted.org/packages/b0/85/bef44ea018b3925fb0bcbe9112715f665e4d5309bd945191da814c314fd1/wrapt-2.0.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bacfe6e001749a3b64db47bcf0341da757c95959f592823a93931a422395013", size = 112096, upload-time = "2025-11-07T00:43:16.5Z" },
{ url = "https://files.pythonhosted.org/packages/7c/0b/733a2376e413117e497aa1a5b1b78e8f3a28c0e9537d26569f67d724c7c5/wrapt-2.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8ec3303e8a81932171f455f792f8df500fc1a09f20069e5c16bd7049ab4e8e38", size = 114878, upload-time = "2025-11-07T00:43:20.81Z" },
{ url = "https://files.pythonhosted.org/packages/da/03/d81dcb21bbf678fcda656495792b059f9d56677d119ca022169a12542bd0/wrapt-2.0.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3f373a4ab5dbc528a94334f9fe444395b23c2f5332adab9ff4ea82f5a9e33bc1", size = 111298, upload-time = "2025-11-07T00:43:22.229Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d5/5e623040e8056e1108b787020d56b9be93dbbf083bf2324d42cde80f3a19/wrapt-2.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f49027b0b9503bf6c8cdc297ca55006b80c2f5dd36cecc72c6835ab6e10e8a25", size = 113361, upload-time = "2025-11-07T00:43:24.301Z" },
{ url = "https://files.pythonhosted.org/packages/a1/f3/de535ccecede6960e28c7b722e5744846258111d6c9f071aa7578ea37ad3/wrapt-2.0.1-cp310-cp310-win32.whl", hash = "sha256:8330b42d769965e96e01fa14034b28a2a7600fbf7e8f0cc90ebb36d492c993e4", size = 58035, upload-time = "2025-11-07T00:43:28.96Z" },
{ url = "https://files.pythonhosted.org/packages/21/15/39d3ca5428a70032c2ec8b1f1c9d24c32e497e7ed81aed887a4998905fcc/wrapt-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:1218573502a8235bb8a7ecaed12736213b22dcde9feab115fa2989d42b5ded45", size = 60383, upload-time = "2025-11-07T00:43:25.804Z" },
{ url = "https://files.pythonhosted.org/packages/43/c2/dfd23754b7f7a4dce07e08f4309c4e10a40046a83e9ae1800f2e6b18d7c1/wrapt-2.0.1-cp310-cp310-win_arm64.whl", hash = "sha256:eda8e4ecd662d48c28bb86be9e837c13e45c58b8300e43ba3c9b4fa9900302f7", size = 58894, upload-time = "2025-11-07T00:43:27.074Z" },
{ url = "https://files.pythonhosted.org/packages/98/60/553997acf3939079dab022e37b67b1904b5b0cc235503226898ba573b10c/wrapt-2.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e17283f533a0d24d6e5429a7d11f250a58d28b4ae5186f8f47853e3e70d2590", size = 77480, upload-time = "2025-11-07T00:43:30.573Z" },
{ url = "https://files.pythonhosted.org/packages/2d/50/e5b3d30895d77c52105c6d5cbf94d5b38e2a3dd4a53d22d246670da98f7c/wrapt-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85df8d92158cb8f3965aecc27cf821461bb5f40b450b03facc5d9f0d4d6ddec6", size = 60690, upload-time = "2025-11-07T00:43:31.594Z" },
{ url = "https://files.pythonhosted.org/packages/f0/40/660b2898703e5cbbb43db10cdefcc294274458c3ca4c68637c2b99371507/wrapt-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1be685ac7700c966b8610ccc63c3187a72e33cab53526a27b2a285a662cd4f7", size = 61578, upload-time = "2025-11-07T00:43:32.918Z" },