The class-level annotation 'data: list = []' creates a single list shared
among all instances. While __init__ already rebinds self.data = [], the
class-level default is confusing and fragile.
Remove the class-level default; move the annotation to __init__.
Closes#250
The guard:
if sys.version_info.major == 3 and sys.version_info.minor >= 3:
from collections.abc import MutableMapping
else:
from collections.abc import MutableMapping
has identical branches — both import from collections.abc (the correct
Python 3.3+ location). Remove the guard; keep the bare import.
Closes#251
The 'I' (isort) ruleset was commented out. The codebase now has zero
isort violations so the ruleset can be enabled to enforce import order
going forward.
Closes#252
In the else-branch of USMetarPlugin.process(), 'fromcall = fromcall'
is a self-assignment that does nothing. It was likely a leftover from
an edit that intended to reassign fromcall but forgot to write the RHS.
Remove the dead line. The variable is still used correctly on the next
line (get_aprs_fi call).
Closes#253
The @singleton decorator stored its instance in wrapper_singleton.instance
inside a closure. Tests could reset __new__-based singletons via
ClassName._instance = None, but @singleton classes had no equivalent reset
mechanism, causing state to leak between tests.
Add wrapper_singleton.reset() to every @singleton-decorated class. The
method clears wrapper_singleton.instance so the next call creates a fresh
instance, matching the __new__ singleton pattern.
Update all tests to call ClassName.reset() instead of manually setting
ClassName.instance = None (tests/client/test_client.py x5,
tests/client/test_registry.py x1).
Tests added (tests/utils/test_utils.py):
- test_singleton_has_reset: asserts reset is callable on the wrapper
- test_singleton_reset_clears_instance: verifies a new instance is created
after reset(), not the cached one
- test_singleton_instance_is_none_before_first_call: verifies the instance
lifecycle — None → populated → None after reset
Closes#240
ObjectStoreMixin.save() calls len(self) -> len(self.data) immediately.
StatsStore never set self.data in __init__, so any call to save() before
add() raised AttributeError: 'StatsStore' object has no attribute 'data',
crashing APRSDStatsStoreThread on the first tick if the path where
enable_save is True is hit.
Tests updated/added (tests/threads/test_stats.py):
- test_init: now asserts data exists and equals {} (was asserting absence)
- test_save_before_add_does_not_raise: regression guard — save() must not
raise AttributeError when called before add()
Closes#241
keys(), items(), values() each acquired self.lock then returned a live
dict view (dict_keys, dict_items, dict_values). The lock was released
on return, leaving callers with an unsynchronised view that races with
concurrent tx()/rx()/remove() calls on other threads.
Wrap each return in list() so a snapshot is taken while the lock is held.
Tests added (tests/packets/test_tracker.py):
- test_keys_returns_snapshot_not_view: asserts isinstance list and that
a subsequent mutation does not appear in the returned value
- test_items_returns_snapshot_not_view: same for items()
- test_values_returns_snapshot_not_view: same for values()
Closes#242
factory() was calling globals()[raw['_type']] on a value read from a
persisted JSON file on disk without validation, allowing an attacker who
can write to ~/.config/aprsd/ to reference arbitrary names in the module
global namespace.
Add an allowlist (_known_packet_type_names) derived lazily from
TYPE_LOOKUP. Any _type value not in the set raises ValueError before
globals() is ever called.
Tests added (tests/packets/test_packet.py):
- test_factory_known_type_roundtrip: valid known _type still deserialises
- test_factory_unknown_type_raises: module-global name ('os') is rejected
- test_factory_arbitrary_string_raises: arbitrary strings are rejected
- test_factory_empty_type_raises: empty string is rejected
- test_factory_allowlist_covers_all_type_lookup_classes: allowlist stays
in sync with TYPE_LOOKUP automatically
Closes#239
stats() had a bare 'return self.data' on the first line that:
- returned the raw internal dict without holding self.lock (race condition)
- made the locked loop below unreachable dead code
- returned the wrong shape (callers expect age/old/packet/last keys, not
the raw was_old_before_update internal key)
Remove the early return so the existing with self.lock: loop executes.
Tests added (tests/packets/test_watch_list.py):
- test_stats_empty: empty watch list returns {}
- test_stats_returns_enriched_shape: verifies the four expected keys are
present and the raw-internal 'was_old_before_update' key is absent
- test_stats_not_raw_internal_dict: returned dict must not be wl.data itself
- setUp/tearDown: reset class-level data and initialized to prevent leakage
Closes#237
- test_checks_initialised_to_false: asserts _checks is False right after
__init__, catching any regression that removes the initialisation
- test_keepalive_check_first_call_no_reset: verifies no AttributeError and
no spurious reset on the very first keepalive_check() call even when the
driver is already dead
- test_keepalive_check: remove the manual _checks = False setup that masked
the original bug; rely on the __init__ value instead
keepalive_check() reads self._checks on its first call but the
attribute was never set in __init__, causing AttributeError which
kills the KeepAliveThread on first invocation.
Closes#238
The final `return True` in is_configured() should be `return False`.
When APRS-IS is not enabled, the method always returned True, so the
startup guard in server.py never fired for misconfigured instances.
Closes#2
The aprsd/~/ directory (unexpanded tilde) contained a stale pickle
save file at aprsd/~/.config/aprsd/listen/statsstore.p. Runtime data
should never be committed. Added explicit .gitignore entry to prevent
accidental future commits.
Closes#17
* fix: rename 'list' variable in HelpPlugin to avoid shadowing Python builtin
Closes#15
aprsd/plugin.py HelpPlugin.process() used 'list' as a local variable name,
shadowing the Python built-in list type. Renamed to 'plugin_names'.
* ci: fix CI workflow for Forgejo self-hosted runners
- Switch runs-on from ubuntu-latest to docker (node:20-bookworm)
ubuntu-latest runner has no Node.js so actions/checkout@v4 fails
- Replace actions/setup-python@v5 (broken on self-hosted) with uv
- Use tox-uv instead of pip-installed tox for faster installs
* ci: use catthehacker/ubuntu:act-latest container (matches haminfo working CI)
* ci: fix master-build.yml for Forgejo self-hosted runners
Use catthehacker/ubuntu:act-latest container + uv for tox,
matching the working pattern from haminfo. Removes broken
actions/setup-python@v2 + ubuntu-latest bare runner combo.
* ci: revert workflow changes - Forgejo-specific, not for GitHub Actions
* ci: restrict master-build to master branch and tags only
The Docker build job clones from GitHub by branch name, which fails
for feature branches (sanitized slash → name mismatch). This workflow
should only run on master pushes and version tags, not on PRs.
* docs: add unreleased changelog entries for PR #233
* docs: update changelog for 5.0.1 release