chore: remove legacy setup.cfg

All build metadata is already fully covered in pyproject.toml.
setup.cfg was a leftover from the pbr era with duplicate entries
for package name, author, classifiers, entry points, and long
description — all of which are present in pyproject.toml.

Closes #1
This commit is contained in:
2026-08-28 18:30:57 -04:00
parent d9b8fbf811
commit 3835b4e9d1
24 changed files with 4191 additions and 47 deletions
+88
View File
@@ -0,0 +1,88 @@
# APRSD Cursor Rules
This directory contains Cursor AI rules that enforce consistent coding patterns across all APRSD plugins and extensions.
## Available Rules
| Rule File | Description | Scope |
|-----------|-------------|-------|
| `aprsd-project-structure.mdc` | Standard project structure and organization | Always applies |
| `aprsd-plugin-patterns.mdc` | Plugin development patterns and best practices | Applies to `*_plugin.py` files |
| `aprsd-extension-patterns.mdc` | Extension development patterns | Applies to `*extension.py` files |
| `aprsd-config-patterns.mdc` | Oslo.config configuration patterns | Applies to `conf/*.py` files |
| `aprsd-pyproject-standards.mdc` | pyproject.toml standards and conventions | Applies to `pyproject.toml` files |
| `aprsd-cli-patterns.mdc` | CLI command patterns | Applies to `cli.py` files |
| `aprsd-testing-standards.mdc` | Testing standards and best practices | Applies to `test_*.py` files |
## How These Rules Work
- **Always Apply Rules**: `aprsd-project-structure.mdc` applies to every Cursor session in this project
- **File-Specific Rules**: Other rules activate when you're working with matching files (e.g., plugin rules when editing a `*_plugin.py` file)
## Deploying to Plugin/Extension Projects
To apply these rules across all your APRSD plugin and extension projects, copy this entire `.cursor/rules/` directory to each project:
```bash
# From the aprsd directory
for dir in ../aprsd-plugins/*/; do
mkdir -p "$dir/.cursor"
cp -r .cursor/rules "$dir/.cursor/"
done
```
Or copy manually to specific projects:
```bash
cp -r /path/to/aprsd/.cursor/rules /path/to/aprsd-joke-plugin/.cursor/
cp -r /path/to/aprsd/.cursor/rules /path/to/aprsd-admin-extension/.cursor/
# etc.
```
## Customizing Rules
If a specific plugin/extension needs custom rules:
1. Copy these base rules to the project
2. Add project-specific rules as additional `.mdc` files
3. Modify existing rules if needed (but try to keep consistency)
## Rule Syntax
Each rule file uses this format:
```markdown
---
description: Brief description of the rule
globs: **/*.py # File pattern (optional)
alwaysApply: false # Set to true for universal rules
---
# Rule Title
Rule content in markdown...
```
## Benefits
These rules help:
- Maintain consistent code patterns across all APRSD projects
- Provide context-aware guidance when developing plugins/extensions
- Enforce best practices automatically
- Reduce code review feedback on structural issues
- Onboard new contributors faster
## Updating Rules
When updating these rules:
1. Update in the main APRSD repository first
2. Test the changes
3. Deploy to plugin/extension projects
4. Communicate changes to the team
## See Also
- [Cursor Rules Documentation](https://docs.cursor.com/context/rules-for-ai)
- Main APRSD workspace rules in `/Users/I530566/devel/mine/hamradio/aprsd/.cursor/rules/`
+112
View File
@@ -0,0 +1,112 @@
---
description: CLI patterns for APRSD plugins and extensions
globs: **/cli.py
alwaysApply: false
---
# APRSD CLI Patterns
## Config Export Command
All plugins/extensions must provide a config export CLI command in `cli.py`:
```python
#!/usr/bin/env python3
"""
CLI tool for module-name configuration export.
"""
import json
import sys
def export_config_cmd(format="json"):
"""Export plugin configuration options."""
try:
from module_name.conf.opts import export_config
result = export_config(format=format)
if format == "json":
print(result)
else:
print(json.dumps(result, indent=2))
return 0
except ImportError as e:
print(f"Error: {e}", file=sys.stderr)
print("\nTo export config, install oslo.config:", file=sys.stderr)
print(" pip install oslo.config", file=sys.stderr)
return 1
except Exception as e:
print(f"Error exporting config: {e}", file=sys.stderr)
return 1
def main():
"""Main entry point for CLI."""
import argparse
parser = argparse.ArgumentParser(
description="Export module-name configuration options"
)
parser.add_argument(
"--format",
choices=["dict", "json"],
default="json",
help="Output format (default: json)",
)
args = parser.parse_args()
sys.exit(export_config_cmd(format=args.format))
if __name__ == "__main__":
main()
```
## CLI Best Practices
- Use argparse for command-line parsing
- Provide clear help messages
- Return appropriate exit codes (0 for success, non-zero for errors)
- Write errors to stderr using `file=sys.stderr`
- Handle ImportError gracefully with helpful messages
- Include shebang `#!/usr/bin/env python3` at the top
## Additional CLI Commands
For extensions with additional commands, organize them in `cmds/` directory:
```
module_name/
├── cli.py (config export)
└── cmds/
├── __init__.py
└── mycommand.py
```
Each command should:
- Have a clear entry point function
- Accept parsed arguments
- Return proper exit codes
- Log to appropriate streams
- Handle errors gracefully
## Entry Point Registration
Register CLI commands in pyproject.toml:
```toml
[project.scripts]
"module-name-export-config" = "module_name.cli:main"
"module-name-mycommand" = "module_name.cmds.mycommand:main"
```
## Command Naming Convention
- Use hyphenated names for commands (e.g., `aprsd-joke-plugin-export-config`)
- Start with the module name
- End with the action (e.g., `export-config`)
- Keep names descriptive but concise
+196
View File
@@ -0,0 +1,196 @@
---
description: Oslo config patterns for APRSD plugins and extensions
globs: **/conf/*.py
alwaysApply: false
---
# APRSD Configuration Patterns
## conf/opts.py Structure
Must include Apache license header and follow this standard pattern:
```python
# Copyright 2015 OpenStack Foundation
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import collections
import importlib
import importlib.util
import os
import pkgutil
LIST_OPTS_FUNC_NAME = "list_opts"
def _tupleize(dct):
"""Take the dict of options and convert to the 2-tuple format."""
return [(key, val) for key, val in dct.items()]
def list_opts():
opts = collections.defaultdict(list)
module_names = _list_module_names()
imported_modules = _import_modules(module_names)
_append_config_options(imported_modules, opts)
return _tupleize(opts)
def _list_module_names():
module_names = []
package_path = os.path.dirname(os.path.abspath(__file__))
for _, modname, ispkg in pkgutil.iter_modules(path=[package_path]):
if modname == "opts" or ispkg:
continue
else:
module_names.append(modname)
return module_names
def _import_modules(module_names):
imported_modules = []
for modname in module_names:
mod = importlib.import_module("module_name.conf." + modname)
if not hasattr(mod, LIST_OPTS_FUNC_NAME):
msg = (
"The module 'module_name.conf.%s' should have a '%s' "
"function which returns the config options."
% (modname, LIST_OPTS_FUNC_NAME)
)
raise Exception(msg)
else:
imported_modules.append(mod)
return imported_modules
def _append_config_options(imported_modules, config_options):
for mod in imported_modules:
configs = mod.list_opts()
for key, val in configs.items():
config_options[key].extend(val)
def export_config(format="dict"):
"""Export configuration options as a simple data structure.
Returns dict or JSON string containing all configuration options.
"""
if importlib.util.find_spec("oslo_config") is None:
raise ImportError(
"oslo_config is required to export configuration. "
"Install it with: pip install oslo.config"
)
opts = list_opts()
result = {}
for group_name, opt_list in opts:
result[group_name] = []
for opt in opt_list:
opt_dict = {
"name": opt.name,
"type": type(opt).__name__,
"default": getattr(opt, "default", None),
"help": getattr(opt, "help", ""),
"required": not hasattr(opt, "default")
or getattr(opt, "default", None) 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
result[group_name].append(opt_dict)
if format == "json":
import json
return json.dumps(result, indent=2)
return result
```
## conf/main.py Pattern
Define plugin/extension-specific config options using oslo.config:
```python
from oslo_config import cfg
# Define configuration group
plugin_group = cfg.OptGroup(
name="my_plugin",
title="My Plugin Settings",
)
# Define configuration options
plugin_opts = [
cfg.BoolOpt(
"enabled",
default=False,
help="Enable the plugin",
),
cfg.StrOpt(
"api_key",
default=None,
help="API key for external service",
secret=True,
),
cfg.IntOpt(
"timeout",
default=30,
help="Timeout in seconds for API calls",
min=1,
max=300,
),
]
def register_opts(config):
"""Register configuration options."""
config.register_group(plugin_group)
config.register_opts(plugin_opts, group=plugin_group)
def list_opts():
"""Return configuration options."""
return {plugin_group.name: plugin_opts}
```
## Configuration Best Practices
- Always include an `enabled` boolean option
- Mark sensitive options with `secret=True`
- Provide reasonable defaults
- Include helpful documentation in help strings
- Use appropriate option types (StrOpt, BoolOpt, IntOpt, ListOpt, etc.)
- Set min/max bounds for numeric values
- Use choices for enum-like options
## Accessing Configuration
In plugin/extension code:
```python
from oslo_config import cfg
CONF = cfg.CONF
# Access config values
if CONF.my_plugin.enabled:
api_key = CONF.my_plugin.api_key
```
@@ -0,0 +1,87 @@
---
description: APRSD extension development patterns
globs: **/*extension.py
alwaysApply: false
---
# APRSD Extension Patterns
## Extension Base Class
Extensions provide additional functionality to APRSD beyond message processing plugins.
```python
from aprsd import plugin
class MyExtension(plugin.APRSDExtensionBase):
"""Extension description.
Extensions can add web interfaces, commands, background services, etc.
"""
version = module_name.__version__
```
## Common Extension Types
### Web Extensions
Extensions that add web interfaces (like aprsd-admin-extension, aprsd-webchat-extension):
```python
def setup(self):
"""Setup the extension."""
self.enabled = CONF.my_extension.enabled
def create_threads(self):
"""Create web server or other background threads."""
if self.enabled:
return [MyWebServerThread()]
return []
```
### CLI Extensions
Extensions that add CLI commands (like aprsd-trip-extension):
- Add commands in `cmds/` directory
- Register entry points in pyproject.toml
- Follow Click or argparse patterns consistently
### Background Service Extensions
Extensions that run background services:
- All threads must inherit from `APRSDThread` base class
- Implement proper thread lifecycle methods
- Handle graceful shutdown
## Configuration Pattern
Extensions should use oslo.config for configuration:
```python
from oslo_config import cfg
extension_group = cfg.OptGroup(
name="my_extension",
title="My Extension Settings",
)
extension_opts = [
cfg.BoolOpt(
"enabled",
default=False,
help="Enable the extension",
),
]
```
## Entry Points
Extensions must define entry points in pyproject.toml:
```toml
[project.entry-points."oslo.config.opts"]
"module_name.conf" = "module_name.conf.opts:list_opts"
```
+129
View File
@@ -0,0 +1,129 @@
---
description: APRSD plugin development patterns and best practices
globs: **/*_plugin.py
alwaysApply: false
---
# APRSD Plugin Patterns
## Plugin Base Class
All plugins must inherit from `plugin.APRSDRegexCommandPluginBase`:
```python
from aprsd import plugin, packets
from aprsd.utils import trace
import logging
LOG = logging.getLogger("APRSD")
class MyPlugin(plugin.APRSDRegexCommandPluginBase):
"""Plugin description goes here.
Explain what the plugin does, what commands it responds to,
and any special configuration needed.
"""
version = module_name.__version__
command_regex = "^[cC]" # Regex to match command
command_name = "mycommand" # One-word command name
enabled = False
```
## Required Methods
### setup()
Initialize plugin and set `self.enabled` flag:
```python
def setup(self):
"""Allows the plugin to do some 'setup' type checks.
If the setup checks fail, set self.enabled = False. This
will prevent the plugin from being called when packets are received.
"""
self.enabled = CONF.my_plugin.enabled
# Additional setup checks here
```
### process()
Must use `@trace.trace` decorator and accept `Packet`:
```python
@trace.trace
def process(self, packet: packets.core.Packet):
"""Process matching packet.
This is called when a received packet matches self.command_regex.
Only called when self.enabled = True.
"""
if not self.enabled:
LOG.info("Plugin is not enabled")
return
message = packet.message_text
# Your logic here
return "response message"
```
### help()
Return list of help strings:
```python
def help(self):
"""Return help message for the plugin."""
return [
f"{self.command_name}: Description of what command does",
"Usage: command [options]",
"Additional help lines if needed"
]
```
### create_threads()
Return list of APRSDThread objects (or empty list):
```python
def create_threads(self):
"""Create and return custom APRSDThread objects.
Create a child of aprsd.threads.APRSDThread object and return it.
It will automatically get started.
"""
if self.enabled:
# return [MyAPRSDThread()]
return []
```
## Message Formatting
- Wrap long messages using `textwrap.wrap(text, 67, break_long_words=False)`
- APRS messages have a 67 character limit per line
- Return strings or lists of strings from `process()`
- Use `textwrap` module for proper text wrapping
## Error Handling
- Always wrap external API calls in try/except blocks
- Log errors with `LOG.error()`
- Return user-friendly error messages
- Don't let exceptions bubble up from `process()`
```python
try:
result = external_api_call()
except Exception as e:
LOG.error(f"Error calling API: {e}")
return "Error: Unable to fetch data"
```
## Logging
- Use `LOG = logging.getLogger("APRSD")` at module level
- Use appropriate log levels: DEBUG, INFO, WARNING, ERROR
- Include context in log messages
+50
View File
@@ -0,0 +1,50 @@
---
description: Standard APRSD plugin/extension project structure
alwaysApply: true
---
# APRSD Project Structure Standards
All APRSD plugins and extensions must follow this structure:
```
project-name/
├── module_name/
│ ├── __init__.py
│ ├── conf/
│ │ ├── __init__.py
│ │ ├── opts.py (standard oslo.config pattern)
│ │ └── main.py
│ ├── cli.py (for config export command)
│ └── [plugin/extension files]
├── tests/
│ ├── __init__.py
│ └── test_*.py
├── docs/
├── pyproject.toml
├── setup.py
├── tox.ini
├── .pre-commit-config.yaml
├── Makefile
└── README.md or README.rst
```
## Key Rules
- Always use underscores in module names (e.g., `aprsd_joke_plugin`, not `aprsd-joke-plugin`)
- Place all configuration in `conf/` subdirectory within the module
- Keep tests in top-level `tests/` folder, not nested in module
- Use `.venv` for virtual environment
- All plugins/extensions should have a `cli.py` for config export
- Include both `pyproject.toml` and `setup.py` for maximum compatibility
## Virtual Environment
- Always use the virtual environment provided in the project called `.venv`
- Never commit `.venv` to git
## Documentation
- Maintain docs in `docs/` directory
- Use Sphinx for documentation generation
- Keep README files up to date with plugin functionality
+209
View File
@@ -0,0 +1,209 @@
---
description: pyproject.toml standards for APRSD plugins and extensions
globs: **/pyproject.toml
alwaysApply: false
---
# APRSD pyproject.toml Standards
## Required Entry Points
All plugins/extensions must define these entry points:
```toml
[project.entry-points."oslo.config.opts"]
"module_name.conf" = "module_name.conf.opts:list_opts"
[project.scripts]
"module-name-export-config" = "module_name.cli:main"
```
For plugins with README support:
```toml
[project.entry-points."aprsd.plugin.readme"]
"module_name" = "module_name:get_readme"
```
## Standard Dependencies
All APRSD plugins/extensions should include:
```toml
dependencies = [
"aprsd>=4.2.0", # Pin minimum APRSD version
"oslo-config", # or "oslo_config" - for configuration management
]
```
## Project Metadata
### Author Information
Use consistent author format:
```toml
authors = [
{name = "Walter A. Boring IV", email = "waboring@hemna.com"},
]
maintainers = [
{name = "Walter A. Boring IV", email = "waboring@hemna.com"},
]
```
### Keywords
Include standard APRSD keywords plus project-specific ones:
```toml
keywords = [
"aprs",
"aprs-is",
"aprsd",
"aprsd-server",
"aprsd-client",
"ham-radio",
# Add project-specific keywords
]
```
### Classifiers
Include appropriate classifiers:
```toml
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: End Users/Desktop",
"Topic :: Communications :: Ham Radio",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
]
```
### Python Version
Set appropriate Python version requirements:
```toml
requires-python = ">=3.10"
```
### License
```toml
license = {file = "LICENSE"}
```
### README
```toml
readme = {file = "README.md", content-type = "text/markdown"}
# or
readme = {file = "README.rst", content-type = "text/x-rst"}
```
## Tool Configuration
### setuptools_scm
For version management:
```toml
[tool.setuptools_scm]
[project]
dynamic = ["version"]
```
### isort
For import sorting:
```toml
[tool.isort]
force_sort_within_sections = true
line_length = 88
skip_gitignore = true
```
### coverage
For test coverage:
```toml
[tool.coverage.run]
branch = true
```
### pytest
For testing configuration:
```toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
norecursedirs = [
".tox",
".git",
"build",
"dist",
"*.egg-info",
".venv",
]
```
### ruff (if using)
For linting and formatting:
```toml
[tool.ruff]
line-length = 99
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"]
ignore = ["E203"]
[tool.ruff.lint.isort]
known-first-party = ["module_name"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
```
## Build System
Use setuptools with setuptools_scm:
```toml
[build-system]
requires = [
"setuptools>=69.5.0",
"setuptools_scm>=0",
"wheel",
]
build-backend = "setuptools.build_meta"
```
## Package Configuration
```toml
[tool.setuptools]
packages = ["module_name"]
# or for auto-discovery:
packages = {find = {}}
[tool.setuptools.package-data]
"*" = ["LICENSE"]
"module_name" = ["README.md"]
```
+175
View File
@@ -0,0 +1,175 @@
---
description: Testing standards for APRSD plugins and extensions
globs: **/test_*.py
alwaysApply: false
---
# APRSD Testing Standards
## Test Organization
- Place all tests in top-level `tests/` folder, not nested in module
- Name test files `test_*.py`
- Use pytest as the test framework
- Create `tests/__init__.py` to make tests a package
## Test Structure
```python
import pytest
from aprsd import packets
from unittest import mock
from module_name import MyPlugin
class TestMyPlugin:
"""Test cases for MyPlugin."""
def setup_method(self):
"""Setup test fixtures."""
self.plugin = MyPlugin()
def test_plugin_setup(self):
"""Test plugin setup."""
self.plugin.setup()
# Assertions here
def test_plugin_process_success(self):
"""Test successful message processing."""
# Create test packet
packet = packets.core.Packet(
from_call="TEST",
to_call="DEST",
message_text="test command",
)
result = self.plugin.process(packet)
assert result is not None
def test_plugin_process_error(self):
"""Test error handling."""
# Test error cases
pass
@mock.patch("module_name.plugin.requests.get")
def test_external_api_call(self, mock_get):
"""Test external API calls are mocked."""
mock_get.return_value.json.return_value = {"data": "test"}
# Test logic here
```
## Testing Best Practices
### Mock External Dependencies
- Always mock external API calls
- Mock network requests using `unittest.mock` or `pytest-mock`
- Don't make real network calls in tests
```python
@mock.patch("requests.get")
def test_api_call(self, mock_get):
mock_get.return_value.json.return_value = {"result": "ok"}
# Test here
```
### Test Both Success and Failure Cases
- Test happy path (successful operations)
- Test error handling (exceptions, invalid input, etc.)
- Test edge cases (empty strings, None values, etc.)
### Use Fixtures
```python
@pytest.fixture
def sample_packet():
"""Create a sample test packet."""
return packets.core.Packet(
from_call="TEST",
to_call="DEST",
message_text="test",
)
def test_with_fixture(sample_packet):
"""Test using fixture."""
result = process(sample_packet)
assert result is not None
```
### Test Configuration
Test that configuration is properly loaded:
```python
def test_config_loading(self):
"""Test configuration options."""
from module_name.conf import main
opts = main.list_opts()
assert "my_plugin" in opts
```
## pytest Configuration
Include in `pyproject.toml`:
```toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
norecursedirs = [
".tox",
".git",
"build",
"dist",
"*.egg-info",
".venv",
"venv",
]
```
## Coverage Configuration
Include in `pyproject.toml`:
```toml
[tool.coverage.run]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise AssertionError",
"raise NotImplementedError",
"if __name__ == .__main__.:",
]
```
## Running Tests
Tests should be runnable with:
```bash
# Run all tests
pytest
# Run with coverage
pytest --cov=module_name
# Run specific test
pytest tests/test_specific.py::TestClass::test_method
```
## What to Test
- Plugin setup and initialization
- Message processing logic
- Configuration loading
- Error handling
- Helper functions
- Thread creation (if applicable)
- CLI commands
+21
View File
@@ -0,0 +1,21 @@
---
description: my rules for aprsd
globs:
alwaysApply: true
---
# Coding pattern preferences
- Always prefer simple solutions
- Avoid duplication of code wherever possible, which means checking for other areas of the codebase that might already have similar code and functionality
- you are careful to only make changes that are requires or you are confident are well understood and related to the change being requested
- when fixing an issue or bug, do not introduce a new patter or technology without first exhausting all options for the exhausting implementation. And if you finally do this, make sure to remove the old implementation afterwards so we don't have duplicate logic
- keep the codebase very clean and organized
- Avoid writing scripts in files if possible, especially if the script is likely only to be run once.
- avoid having files over 200-300 lines of code. Refactor at that point.
- Mocking data is only needed for tests, never mock data for dev or prod
- Never add stubbing or fake data patterns to code that affects the dev or prod environments
- never overwrite my .env file without first asking and confirming.
- All threads should be done with APRSDThread base class
- place all unit tests in the top level aprsd/tests folder
- Always use the virtual environment provided in the project called .venv
+9
View File
@@ -0,0 +1,9 @@
---
description: This is the project's tech stack
globs:
alwaysApply: true
---
# Technical stack
- Python
- Python tests
+12
View File
@@ -0,0 +1,12 @@
---
description:
globs:
alwaysApply: true
---
# Coding workflow preferences
- Focus on areas of code relevant to the task
- Do not touch code unrelated to the task
- write thorough tests for all major functionality
- Avoid making major changes to the patterns and architecture of how a feature works, after it was shown to work well, unless explicitly instructed
- Always think about what other methods and areas of code might be affected by code changes
+684
View File
@@ -0,0 +1,684 @@
# APRSD GitHub Copilot Instructions
This file provides coding standards and patterns for all APRSD plugins, extensions, and the main APRSD project.
## General Coding Principles
- Always prefer simple solutions over complex ones
- Avoid code duplication - check for existing similar functionality before adding new code
- Keep the codebase very clean and organized
- Avoid having files over 200-300 lines of code - refactor at that point
- Only make changes that are required or well understood and related to the change being requested
- When fixing bugs, don't introduce new patterns without exhausting existing implementation options first
- Mocking data is only needed for tests, never mock data for dev or prod environments
- Never add stubbing or fake data patterns to code that affects dev or prod environments
- Never overwrite `.env` files without first asking and confirming
- Always use the virtual environment provided in the project called `.venv`
- Focus on areas of code relevant to the task - don't touch unrelated code
- Write thorough tests for all major functionality
---
## Project Structure Standards
All APRSD plugins and extensions must follow this structure:
```
project-name/
├── module_name/ # Use underscores, not hyphens
│ ├── __init__.py
│ ├── conf/
│ │ ├── __init__.py
│ │ ├── opts.py # Standard oslo.config pattern
│ │ └── main.py
│ ├── cli.py # For config export command
│ └── [plugin/extension files]
├── tests/ # Top-level tests folder
│ ├── __init__.py
│ └── test_*.py
├── docs/
├── pyproject.toml
├── setup.py
├── tox.ini
├── .pre-commit-config.yaml
├── Makefile
└── README.md or README.rst
```
**Key Requirements:**
- Module names use underscores (e.g., `aprsd_joke_plugin`, not `aprsd-joke-plugin`)
- All configuration goes in `conf/` subdirectory within the module
- Tests in top-level `tests/` folder, not nested in module
- Use `.venv` for virtual environment
- All plugins/extensions have a `cli.py` for config export
- Include both `pyproject.toml` and `setup.py` for maximum compatibility
---
## Plugin Development Patterns
### Plugin Base Class
All plugins must inherit from `plugin.APRSDRegexCommandPluginBase`:
```python
from aprsd import plugin, packets
from aprsd.utils import trace
import logging
LOG = logging.getLogger("APRSD")
class MyPlugin(plugin.APRSDRegexCommandPluginBase):
"""Plugin description.
Explain what the plugin does, what commands it responds to,
and any special configuration needed.
"""
version = module_name.__version__
command_regex = "^[cC]" # Regex to match command
command_name = "mycommand" # One-word command name
enabled = False
```
### Required Plugin Methods
#### setup()
Initialize plugin and set `self.enabled` flag:
```python
def setup(self):
"""Allows the plugin to do some 'setup' type checks.
If the setup checks fail, set self.enabled = False. This
will prevent the plugin from being called when packets are received.
"""
self.enabled = CONF.my_plugin.enabled
# Additional setup checks here
```
#### process()
Must use `@trace.trace` decorator and accept `Packet`:
```python
@trace.trace
def process(self, packet: packets.core.Packet):
"""Process matching packet.
This is called when a received packet matches self.command_regex.
Only called when self.enabled = True.
"""
if not self.enabled:
LOG.info("Plugin is not enabled")
return
message = packet.message_text
# Your logic here
return "response message"
```
#### help()
Return list of help strings:
```python
def help(self):
"""Return help message for the plugin."""
return [
f"{self.command_name}: Description of what command does",
"Usage: command [options]",
"Additional help lines if needed"
]
```
#### create_threads()
Return list of APRSDThread objects (or empty list):
```python
def create_threads(self):
"""Create and return custom APRSDThread objects.
Create a child of aprsd.threads.APRSDThread object and return it.
It will automatically get started.
"""
if self.enabled:
# return [MyAPRSDThread()]
return []
```
### Plugin Best Practices
**Message Formatting:**
- Wrap long messages using `textwrap.wrap(text, 67, break_long_words=False)`
- APRS messages have a 67 character limit per line
- Return strings or lists of strings from `process()`
**Error Handling:**
- Always wrap external API calls in try/except blocks
- Log errors with `LOG.error()`
- Return user-friendly error messages
- Don't let exceptions bubble up from `process()`
```python
try:
result = external_api_call()
except Exception as e:
LOG.error(f"Error calling API: {e}")
return "Error: Unable to fetch data"
```
**Logging:**
- Use `LOG = logging.getLogger("APRSD")` at module level
- Use appropriate log levels: DEBUG, INFO, WARNING, ERROR
- Include context in log messages
---
## Extension Development Patterns
### Extension Base Class
Extensions provide additional functionality beyond message processing:
```python
from aprsd import plugin
class MyExtension(plugin.APRSDExtensionBase):
"""Extension description.
Extensions can add web interfaces, commands, background services, etc.
"""
version = module_name.__version__
```
### Extension Types
**Web Extensions** (like aprsd-admin-extension, aprsd-webchat-extension):
```python
def setup(self):
"""Setup the extension."""
self.enabled = CONF.my_extension.enabled
def create_threads(self):
"""Create web server or other background threads."""
if self.enabled:
return [MyWebServerThread()]
return []
```
**CLI Extensions** (like aprsd-trip-extension):
- Add commands in `cmds/` directory
- Register entry points in pyproject.toml
- Follow Click or argparse patterns consistently
**Background Service Extensions:**
- All threads must inherit from `APRSDThread` base class
- Implement proper thread lifecycle methods
- Handle graceful shutdown
---
## Configuration Patterns (Oslo.config)
### conf/opts.py Structure
Must include Apache license header and follow this standard pattern:
```python
# Copyright 2015 OpenStack Foundation
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
import collections
import importlib
import importlib.util
import os
import pkgutil
LIST_OPTS_FUNC_NAME = "list_opts"
def list_opts():
"""Collect all configuration options from conf modules."""
opts = collections.defaultdict(list)
module_names = _list_module_names()
imported_modules = _import_modules(module_names)
_append_config_options(imported_modules, opts)
return _tupleize(opts)
def export_config(format="dict"):
"""Export configuration options as dict or JSON.
Returns configuration metadata including type, default, help text, etc.
"""
# Standard export implementation
# See existing plugins for complete implementation
```
### conf/main.py Pattern
Define plugin/extension-specific config options:
```python
from oslo_config import cfg
# Define configuration group
plugin_group = cfg.OptGroup(
name="my_plugin",
title="My Plugin Settings",
)
# Define configuration options
plugin_opts = [
cfg.BoolOpt(
"enabled",
default=False,
help="Enable the plugin",
),
cfg.StrOpt(
"api_key",
default=None,
help="API key for external service",
secret=True,
),
cfg.IntOpt(
"timeout",
default=30,
help="Timeout in seconds for API calls",
min=1,
max=300,
),
]
def register_opts(config):
"""Register configuration options."""
config.register_group(plugin_group)
config.register_opts(plugin_opts, group=plugin_group)
def list_opts():
"""Return configuration options."""
return {plugin_group.name: plugin_opts}
```
### Configuration Best Practices
- Always include an `enabled` boolean option
- Mark sensitive options with `secret=True`
- Provide reasonable defaults
- Include helpful documentation in help strings
- Use appropriate option types (StrOpt, BoolOpt, IntOpt, ListOpt, etc.)
- Set min/max bounds for numeric values
- Use choices for enum-like options
### Accessing Configuration
```python
from oslo_config import cfg
CONF = cfg.CONF
# Access config values
if CONF.my_plugin.enabled:
api_key = CONF.my_plugin.api_key
```
---
## pyproject.toml Standards
### Required Entry Points
```toml
[project.entry-points."oslo.config.opts"]
"module_name.conf" = "module_name.conf.opts:list_opts"
[project.scripts]
"module-name-export-config" = "module_name.cli:main"
```
For plugins with README support:
```toml
[project.entry-points."aprsd.plugin.readme"]
"module_name" = "module_name:get_readme"
```
### Standard Dependencies
```toml
dependencies = [
"aprsd>=4.2.0", # Pin minimum APRSD version
"oslo-config", # or "oslo_config" - for configuration management
]
```
### Project Metadata
**Author Information:**
```toml
authors = [
{name = "Walter A. Boring IV", email = "waboring@hemna.com"},
]
maintainers = [
{name = "Walter A. Boring IV", email = "waboring@hemna.com"},
]
```
**Keywords:**
```toml
keywords = [
"aprs",
"aprs-is",
"aprsd",
"ham-radio",
# Add project-specific keywords
]
```
**Classifiers:**
```toml
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"Topic :: Communications :: Ham Radio",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
]
```
**Python Version:**
```toml
requires-python = ">=3.10"
```
### Tool Configurations
```toml
[tool.setuptools_scm]
[tool.isort]
force_sort_within_sections = true
line_length = 88
skip_gitignore = true
[tool.coverage.run]
branch = true
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
norecursedirs = [".tox", ".git", "build", "dist", "*.egg-info", ".venv"]
```
---
## CLI Command Patterns
### Config Export Command (cli.py)
All plugins/extensions must provide a config export CLI command:
```python
#!/usr/bin/env python3
"""CLI tool for module-name configuration export."""
import json
import sys
def export_config_cmd(format="json"):
"""Export plugin configuration options."""
try:
from module_name.conf.opts import export_config
result = export_config(format=format)
if format == "json":
print(result)
else:
print(json.dumps(result, indent=2))
return 0
except ImportError as e:
print(f"Error: {e}", file=sys.stderr)
print("\nTo export config, install oslo.config:", file=sys.stderr)
print(" pip install oslo.config", file=sys.stderr)
return 1
except Exception as e:
print(f"Error exporting config: {e}", file=sys.stderr)
return 1
def main():
"""Main entry point for CLI."""
import argparse
parser = argparse.ArgumentParser(
description="Export module-name configuration options"
)
parser.add_argument(
"--format",
choices=["dict", "json"],
default="json",
help="Output format (default: json)",
)
args = parser.parse_args()
sys.exit(export_config_cmd(format=args.format))
if __name__ == "__main__":
main()
```
### CLI Best Practices
- Use argparse for command-line parsing
- Provide clear help messages
- Return appropriate exit codes (0 for success, non-zero for errors)
- Write errors to stderr using `file=sys.stderr`
- Handle ImportError gracefully with helpful messages
- Include shebang `#!/usr/bin/env python3` at the top
### Command Naming Convention
- Use hyphenated names (e.g., `aprsd-joke-plugin-export-config`)
- Start with the module name
- End with the action (e.g., `export-config`)
- Keep names descriptive but concise
---
## Testing Standards
### Test Organization
- Place all tests in top-level `tests/` folder, not nested in module
- Name test files `test_*.py`
- Use pytest as the test framework
- Create `tests/__init__.py` to make tests a package
### Test Structure
```python
import pytest
from aprsd import packets
from unittest import mock
from module_name import MyPlugin
class TestMyPlugin:
"""Test cases for MyPlugin."""
def setup_method(self):
"""Setup test fixtures."""
self.plugin = MyPlugin()
def test_plugin_setup(self):
"""Test plugin setup."""
self.plugin.setup()
# Assertions here
def test_plugin_process_success(self):
"""Test successful message processing."""
packet = packets.core.Packet(
from_call="TEST",
to_call="DEST",
message_text="test command",
)
result = self.plugin.process(packet)
assert result is not None
@mock.patch("module_name.plugin.requests.get")
def test_external_api_call(self, mock_get):
"""Test external API calls are mocked."""
mock_get.return_value.json.return_value = {"data": "test"}
# Test logic here
```
### Testing Best Practices
**Mock External Dependencies:**
- Always mock external API calls
- Mock network requests using `unittest.mock` or `pytest-mock`
- Don't make real network calls in tests
**Test Both Success and Failure Cases:**
- Test happy path (successful operations)
- Test error handling (exceptions, invalid input, etc.)
- Test edge cases (empty strings, None values, etc.)
**Use Fixtures:**
```python
@pytest.fixture
def sample_packet():
"""Create a sample test packet."""
return packets.core.Packet(
from_call="TEST",
to_call="DEST",
message_text="test",
)
def test_with_fixture(sample_packet):
"""Test using fixture."""
result = process(sample_packet)
assert result is not None
```
### What to Test
- Plugin setup and initialization
- Message processing logic
- Configuration loading
- Error handling
- Helper functions
- Thread creation (if applicable)
- CLI commands
---
## Thread Management
### APRSDThread Base Class
All background threads must inherit from `APRSDThread`:
```python
from aprsd.threads import APRSDThread
class MyThread(APRSDThread):
"""Background thread description."""
def __init__(self, config):
super().__init__("MyThread")
self.config = config
def loop(self):
"""Main thread loop logic."""
# Your periodic logic here
return True # Return True to continue, False to stop
```
**Key Points:**
- Place all thread implementations using APRSDThread base class
- Implement the `loop()` method for periodic operations
- Handle graceful shutdown properly
- Use appropriate logging
---
## Thread-Specific Guidelines
When implementing threads:
- All threads should be done with APRSDThread base class
- Implement proper lifecycle methods
- Handle exceptions within the thread
- Log thread startup and shutdown
- Use configuration for thread intervals
---
## Additional Guidelines
### Documentation
- Maintain docs in `docs/` directory
- Use Sphinx for documentation generation
- Keep README files up to date with plugin functionality
- Document configuration options in docstrings
### Pre-commit Hooks
- Use `.pre-commit-config.yaml` for code quality
- Include linting, formatting, and type checking
- Run pre-commit hooks before committing
### Build System
```toml
[build-system]
requires = [
"setuptools>=69.5.0",
"setuptools_scm>=0",
"wheel",
]
build-backend = "setuptools.build_meta"
```
### Package Configuration
```toml
[tool.setuptools]
packages = ["module_name"]
# or for auto-discovery:
packages = {find = {}}
[tool.setuptools.package-data]
"*" = ["LICENSE"]
"module_name" = ["README.md"]
```
---
## Summary
When developing APRSD plugins and extensions:
1. **Follow the standard project structure** with underscored module names
2. **Inherit from the correct base classes** (APRSDRegexCommandPluginBase or APRSDExtensionBase)
3. **Implement required methods** (setup, process, help, create_threads)
4. **Use oslo.config** for all configuration management
5. **Provide CLI commands** for config export
6. **Write comprehensive tests** with proper mocking
7. **Handle errors gracefully** with appropriate logging
8. **Format APRS messages** to 67 character limit
9. **Use APRSDThread** for all background threads
10. **Maintain clean, simple, DRY code**
These standards ensure consistency, maintainability, and quality across all APRSD projects.
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.8 (aprsd-twitter-plugin)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
<component name="TestRunnerService">
<option name="PROJECT_TEST_RUNNER" value="py.test" />
</component>
</module>
Generated
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="accountSettings">
<option name="activeProfile" value="profile:default" />
<option name="activeRegion" value="us-east-1" />
<option name="recentlyUsedProfiles">
<list>
<option value="profile:default" />
</list>
</option>
<option name="recentlyUsedRegions">
<list>
<option value="us-east-1" />
</list>
</option>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.8 (aprsd-twitter-plugin)" project-jdk-type="Python SDK" />
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/aprsd-twitter-plugin.iml" filepath="$PROJECT_DIR$/.idea/aprsd-twitter-plugin.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
View File
+183
View File
@@ -0,0 +1,183 @@
#
# SEAMLESSLY MANAGE PYTHON VIRTUAL ENVIRONMENT WITH A MAKEFILE
#
# https://github.com/sio/Makefile.venv v2020.08.14
#
#
# Insert `include Makefile.venv` at the bottom of your Makefile to enable these
# rules.
#
# When writing your Makefile use '$(VENV)/python' to refer to the Python
# interpreter within virtual environment and '$(VENV)/executablename' for any
# other executable in venv.
#
# This Makefile provides the following targets:
# venv
# Use this as a dependency for any target that requires virtual
# environment to be created and configured
# python, ipython
# Use these to launch interactive Python shell within virtual environment
# shell, bash, zsh
# Launch interactive command line shell. "shell" target launches the
# default shell Makefile executes its rules in (usually /bin/sh).
# "bash" and "zsh" can be used to refer to the specific desired shell.
# show-venv
# Show versions of Python and pip, and the path to the virtual environment
# clean-venv
# Remove virtual environment
# $(VENV)/executable_name
# Install `executable_name` with pip. Only packages with names matching
# the name of the corresponding executable are supported.
# Use this as a lightweight mechanism for development dependencies
# tracking. E.g. for one-off tools that are not required in every
# developer's environment, therefore are not included into
# requirements.txt or setup.py.
# Note:
# Rules using such target or dependency MUST be defined below
# `include` directive to make use of correct $(VENV) value.
# Example:
# codestyle: $(VENV)/pyflakes
# $(VENV)/pyflakes .
# See `ipython` target below for another example.
#
# This Makefile can be configured via following variables:
# PY
# Command name for system Python interpreter. It is used only initialy to
# create the virtual environment
# Default: python3
# REQUIREMENTS_TXT
# Space separated list of paths to requirements.txt files.
# Paths are resolved relative to current working directory.
# Default: requirements.txt
# WORKDIR
# Parent directory for the virtual environment.
# Default: current working directory.
# VENVDIR
# Python virtual environment directory.
# Default: $(WORKDIR)/.venv
#
# This Makefile was written for GNU Make and may not work with other make
# implementations.
#
#
# Copyright (c) 2019-2020 Vitaly Potyarkin
#
# Licensed under the Apache License, Version 2.0
# <http://www.apache.org/licenses/LICENSE-2.0>
#
#
# Configuration variables
#
PY?=python3
WORKDIR?=.
VENVDIR?=$(WORKDIR)/.venv
REQUIREMENTS_TXT?=$(wildcard requirements.txt) # Multiple paths are supported (space separated)
MARKER=.initialized-with-Makefile.venv
#
# Internal variable resolution
#
VENV=$(VENVDIR)/bin
EXE=
# Detect windows
ifeq (win32,$(shell $(PY) -c "import __future__, sys; print(sys.platform)"))
VENV=$(VENVDIR)/Scripts
EXE=.exe
endif
#
# Virtual environment
#
.PHONY: venv
venv: $(VENV)/$(MARKER)
.PHONY: clean-venv
clean-venv:
-$(RM) -r "$(VENVDIR)"
.PHONY: show-venv
show-venv: venv
@$(VENV)/python -c "import sys; print('Python ' + sys.version.replace('\n',''))"
@$(VENV)/pip --version
@echo venv: $(VENVDIR)
.PHONY: debug-venv
debug-venv:
@$(MAKE) --version
$(info PY="$(PY)")
$(info REQUIREMENTS_TXT="$(REQUIREMENTS_TXT)")
$(info VENVDIR="$(VENVDIR)")
$(info VENVDEPENDS="$(VENVDEPENDS)")
$(info WORKDIR="$(WORKDIR)")
#
# Dependencies
#
ifneq ($(strip $(REQUIREMENTS_TXT)),)
VENVDEPENDS+=$(REQUIREMENTS_TXT)
endif
ifneq ($(wildcard setup.py),)
VENVDEPENDS+=setup.py
endif
ifneq ($(wildcard setup.cfg),)
VENVDEPENDS+=setup.cfg
endif
$(VENV):
$(PY) -m venv $(VENVDIR)
$(VENV)/python -m pip install --upgrade pip setuptools wheel
$(VENV)/$(MARKER): $(VENVDEPENDS) | $(VENV)
ifneq ($(strip $(REQUIREMENTS_TXT)),)
$(VENV)/pip install $(foreach path,$(REQUIREMENTS_TXT),-r $(path))
endif
ifneq ($(wildcard setup.py),)
$(VENV)/pip install -e .
endif
touch $(VENV)/$(MARKER)
#
# Interactive shells
#
.PHONY: python
python: venv
exec $(VENV)/python
.PHONY: ipython
ipython: $(VENV)/ipython
exec $(VENV)/ipython
.PHONY: shell
shell: venv
. $(VENV)/activate && exec $(notdir $(SHELL))
.PHONY: bash zsh
bash zsh: venv
. $(VENV)/activate && exec $@
#
# Commandline tools (wildcard rule, executable name must match package name)
#
ifneq ($(EXE),)
$(VENV)/%: $(VENV)/%$(EXE) ;
.PHONY: $(VENV)/%
.PRECIOUS: $(VENV)/%$(EXE)
endif
$(VENV)/%$(EXE): $(VENV)/$(MARKER)
$(VENV)/pip install --upgrade $*
touch $@
+42
View File
@@ -0,0 +1,42 @@
ham:
callsign: WB4BOR
aprs:
login: WB4BOR-13
password: XXXXXXXXXX
host: noam.aprs2.net
port: 14580
logfile: /tmp/aprsd.log
aprsd:
trace: True
watch_list:
enabled: False
alert_time_seconds: 9600
alert_callsign: WB4BOR
packet_keep_count: 25
web:
enabled: True
logging_enabled: False
host: '0.0.0.0'
port: 8013
users:
admin: XXXXXXXXXXX
email:
enabled: False
enabled_plugins:
- aprsd_twitter_plugin.twitter.SendTweetPlugin
units: imperial
services:
aprs.fi:
apiKey: XXXXxxxXXXXXXXXXXxxXXxX
twitter:
apiKey: XXXXXXXXXXXXXXXXXXXXXXXXx
apiKey_secret: XXXXXXXxxxxxxxxXXXXXXxXXxXxXxXxXxXxXxXxXxXxXXxXxXxXx
access_token: XXXXXxxxXXXXxXxXxXXXXxXxXXxXxXXxXxXxxXxXxXxxXxXxXxXx
access_token_secret: XXXxxxXxXXXXxXxXxXXxXxXxxXxXxXXxXxXXxXxXxXXxxxXxxx
-47
View File
@@ -1,47 +0,0 @@
[metadata]
name = aprsd_twitter_plugin
long_description = file: README.rst
long_description_content_type = text/x-rst
author = Walter A. Boring IV
author_email = waboring@hemna.com
license = MIT
license_file = LICENSE
classifiers =
License :: OSI Approved :: MIT License
classifier =
Topic :: Communications :: Ham Radio
Operating System :: POSIX :: Linux
Programming Language :: Python
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
description_file =
README.rst
summary = Python APRSD plugin to send tweets
[options.entry_points]
oslo.config.opts =
aprsd_twitter_plugin.conf = aprsd_twitter_plugin.conf.opts:list_opts
[global]
setup-hooks =
pbr.hooks.setup_hook
[files]
packages =
aprsd_twitter_plugin
[build_sphinx]
source-dir = doc/source
build-dir = doc/build
all_files = 1
[upload_sphinx]
upload-dir = doc/build/html
[mypy]
ignore_missing_imports = True
strict = True
[bdist_wheel]
universal = 1
Generated
+2133
View File
File diff suppressed because it is too large Load Diff