mirror of
https://github.com/craigerl/aprsd.git
synced 2026-08-16 16:43:52 -04:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 698d218572 | |||
| fcfb349d29 | |||
| 7172d6352f | |||
| bc8a24bea4 | |||
| ac35c44bc2 | |||
| 8483d93965 | |||
| 0248f40604 | |||
| 6ea9889369 | |||
| 2b7e42802b | |||
| c99a9c919d | |||
| 202c689658 | |||
| 0701db2629 | |||
| c5ca4f11af | |||
| 008fe3c83e | |||
| 3128f24ef7 | |||
| 6968f16cec | |||
| 03da33c905 | |||
| 8b500ac5f1 | |||
| c62d0545c6 | |||
| 6f9e6b2993 | |||
| 7f7d03ea69 | |||
| 0b01881f46 | |||
| 2f698ed95e | |||
| 2180a52a9f | |||
| 95bd43a0b1 | |||
| b4763f969c | |||
| 3bcd03a514 | |||
| ee61bf5fd5 | |||
| 7151cb5d07 | |||
| 730f6585af | |||
| f9cdb45ea0 | |||
| 72371cd4ed | |||
| cefe3e30e7 | |||
| d783a01400 | |||
| f7e4c47715 | |||
| 24bc86424e | |||
| e3fda752f6 | |||
| da3ef77ea3 | |||
| d2cb208be8 |
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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']
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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'
|
||||
@@ -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):
|
||||
|
||||
@@ -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,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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user