Autogenerated type safety parameterisation of all methods

This commit is contained in:
Ian Renton
2026-09-20 20:02:19 +01:00
parent 6037e742cc
commit 324dd1414b
132 changed files with 1228 additions and 706 deletions
+4 -2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import logging
import re
@@ -12,7 +14,7 @@ from data.activity_ref import ActivityRef
logger = logging.getLogger(__name__)
def get_activity_ref_info(activity_name, ref_id):
def get_activity_ref_info(activity_name: str, ref_id: str) -> ActivityRef | None:
"""Look up details of an activity reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in an
activity name and a reference ID (both strings) and returns an ActivityRef object populated with as much data
as we can find. This makes use of activity ref data in the data store, live lookups from the web, or just
@@ -139,7 +141,7 @@ def get_activity_ref_info(activity_name, ref_id):
return activity_ref
def populate_missing_activity_ref_info(activity_ref):
def populate_missing_activity_ref_info(activity_ref: ActivityRef) -> ActivityRef:
"""Look up details of an activity reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in an
activity_ref object which must at minimum have a "sig" and an "id". The rest of the object will be populated
and returned. Any data currently in the object will be kept, only missing data in the object will be populated
+8 -4
View File
@@ -1,7 +1,11 @@
from __future__ import annotations
from core.enums import ActivityName
from data.activities import ACTIVITIES
from data.activity import Activity
def get_activity_by_name(name):
def get_activity_by_name(name: str) -> Activity | None:
"""Utility function to resolve an arbitrary, case-insensitive activity name string (e.g. from a spot comment, a
provider, or an API request) to the matching known Activity. Returns None if no match is found."""
@@ -13,7 +17,7 @@ def get_activity_by_name(name):
return None
def get_ref_regex_for_activity(activity):
def get_ref_regex_for_activity(activity: str) -> str | None:
"""Utility function to get the regex string for an activity reference for a named activity. If no match is
found, None will be returned."""
@@ -21,14 +25,14 @@ def get_ref_regex_for_activity(activity):
return found.ref_regex if found else None
def get_icon_for_activity(activity):
def get_icon_for_activity(activity: str) -> str | None:
"""Utility function to get the icon for a named activity. If no match is found, None will be returned."""
found = get_activity_by_name(activity)
return found.icon if found else None
def get_activity_name_from_comment_name(activity):
def get_activity_name_from_comment_name(activity: str) -> ActivityName | None:
"""Utility function to get the name of an activity from its "comment name". Generally these will be the same
but there are some cases (e.g. is "TOTA" Towers, Tiles or Toilets?) where we need to transform one to the
other."""
+4 -1
View File
@@ -1,10 +1,13 @@
from __future__ import annotations
import re
from core.data_providers import DATA_PROVIDERS
from data.callsign import Callsign
from data.lookup_credentials import LookupCredentials
def get_call_info(callsign, lookup_credentials):
def get_call_info(callsign: str, lookup_credentials: LookupCredentials | None) -> Callsign:
"""Utility method to get the best set of data for a callsign as we can, using all enabled providers.
lookup_credentials is an optional object that carries the user's QRZ.com/HamQTH credentials, if they provided them,
to enable lookup using those providers."""
+10 -8
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import logging
from datetime import datetime
from threading import Event, Thread
@@ -12,25 +14,25 @@ logger = logging.getLogger(__name__)
class CleanupTimer:
"""Provides a timed cleanup of the spot list."""
def __init__(self):
def __init__(self) -> None:
"""Constructor"""
self._cleanup_interval = None
self._cleanup_interval: float | None = None
self.last_cleanup_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Starting"
self._thread = None
self._thread: Thread | None = None
self._stop_event = Event()
def setup(self, cleanup_interval):
def setup(self, cleanup_interval: float) -> None:
self._cleanup_interval = cleanup_interval
def start(self):
def start(self) -> None:
"""Start the cleanup timer"""
self._thread = Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self):
def stop(self) -> None:
"""Stop any threads and prepare for application shutdown"""
self._stop_event.set()
@@ -39,11 +41,11 @@ class CleanupTimer:
if self._thread.is_alive():
logger.warning("Cleanup worker thread did not exit on time and will be killed.")
def _run(self):
def _run(self) -> None:
while not self._stop_event.wait(timeout=self._cleanup_interval):
self._cleanup()
def _cleanup(self):
def _cleanup(self) -> None:
"""Perform cleanup and reschedule next timer"""
try:
+21 -18
View File
@@ -1,7 +1,10 @@
from __future__ import annotations
import importlib
import logging
import os
import sys
from typing import Any
import yaml
@@ -16,25 +19,25 @@ if not os.path.isfile("config.yml"):
# Load config
with open("config.yml") as f:
config = yaml.safe_load(f)
config: dict[str, Any] = yaml.safe_load(f)
logger.info("Loaded config.")
BASE_URL = config.get("base_url", "http://localhost:8080")
MAX_SPOT_AGE = config.get("max_spot_age_sec", 3600)
MAX_ALERT_AGE = config.get("max_alert_age_sec", 604800)
SERVER_OWNER_CALLSIGN = config.get("server_owner_callsign", "N0CALL")
WEB_SERVER_PORT = config.get("web_server_port", 8080)
TELNET_SERVER_ENABLED = config.get("telnet_server_enabled", False)
TELNET_SERVER_ADDRESS = config.get("telnet_server_address", "localhost")
TELNET_SERVER_PORT = config.get("telnet_server_port", 7373)
ALLOW_SPOTTING = config.get("allow_spotting", True)
ALLOW_UPSTREAM_SPOTTING = config.get("allow_upstream_spotting", True)
WEB_UI_OPTIONS = config.get("web_ui_options", {})
API_ONLY_MODE = config.get("api_only_mode", False)
RECAPTCHA_SECRET_KEY = config.get("recaptcha_secret_key", "")
RECAPTCHA_SITE_KEY = config.get("recaptcha_site_key", "")
LOG_LEVEL = config.get("log_level", "INFO")
LOG_WEB_REQUESTS = config.get("log_web_requests", False)
BASE_URL: str = config.get("base_url", "http://localhost:8080")
MAX_SPOT_AGE: int = config.get("max_spot_age_sec", 3600)
MAX_ALERT_AGE: int = config.get("max_alert_age_sec", 604800)
SERVER_OWNER_CALLSIGN: str = config.get("server_owner_callsign", "N0CALL")
WEB_SERVER_PORT: int = config.get("web_server_port", 8080)
TELNET_SERVER_ENABLED: bool = config.get("telnet_server_enabled", False)
TELNET_SERVER_ADDRESS: str = config.get("telnet_server_address", "localhost")
TELNET_SERVER_PORT: int = config.get("telnet_server_port", 7373)
ALLOW_SPOTTING: bool = config.get("allow_spotting", True)
ALLOW_UPSTREAM_SPOTTING: bool = config.get("allow_upstream_spotting", True)
WEB_UI_OPTIONS: dict[str, Any] = config.get("web_ui_options", {})
API_ONLY_MODE: bool = config.get("api_only_mode", False)
RECAPTCHA_SECRET_KEY: str = config.get("recaptcha_secret_key", "")
RECAPTCHA_SITE_KEY: str = config.get("recaptcha_site_key", "")
LOG_LEVEL: str = config.get("log_level", "INFO")
LOG_WEB_REQUESTS: bool = config.get("log_web_requests", False)
WEB_UI_OPTIONS["qrz_enabled"] = any(p["class"] == "QRZ" and p["enabled"] for p in config["callsign_data_providers"])
WEB_UI_OPTIONS["hamqth_enabled"] = any(
@@ -44,7 +47,7 @@ WEB_UI_OPTIONS["recaptcha_site_key"] = RECAPTCHA_SITE_KEY
WEB_UI_OPTIONS["allow_upstream_spotting"] = ALLOW_SPOTTING and ALLOW_UPSTREAM_SPOTTING
def create_provider_from_config(package, config_providers_entry):
def create_provider_from_config(package: str, config_providers_entry: dict[str, Any]) -> Any:
"""Utility method to get a provider based on the class specified in its config entry. You must also provide the
package to look for it in, as there are several types of provider. e.g. package "providers.spot", where the config
entry is for a POTA spot provider."""
+8 -6
View File
@@ -1,15 +1,17 @@
from __future__ import annotations
from core.config import SERVER_OWNER_CALLSIGN
from data.band import Band
# General software
SOFTWARE_VERSION = "2.2-pre"
SOFTWARE_VERSION: str = "2.2-pre"
# HTTP headers used for spot providers that use HTTP
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
HAMQTH_PRG = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}".replace(" ", "_")
HTTP_HEADERS: dict[str, str] = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
HAMQTH_PRG: str = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}".replace(" ", "_")
# Band definitions
BANDS = [
BANDS: list[Band] = [
Band(name="2200m", start_freq=135700, end_freq=137800),
Band(name="600m", start_freq=472000, end_freq=479000),
Band(name="160m", start_freq=1800000, end_freq=2000000, is_ham_hf=True),
@@ -37,11 +39,11 @@ BANDS = [
Band(name="47GHz", start_freq=47000000000, end_freq=47200000000),
Band(name="76GHz", start_freq=75500000000, end_freq=81500000000),
]
UNKNOWN_BAND = Band(name="Unknown", start_freq=0, end_freq=0)
UNKNOWN_BAND: Band = Band(name="Unknown", start_freq=0, end_freq=0)
# Propagation modes used in VHF/UHF DX cluster comments, e.g. "JN61ES<ES>JM56XT". I don't think there's an official list
# of these anywhere, but here are some I've seen or seen reference to
PROPAGATION_MODES = {
PROPAGATION_MODES: dict[str, str] = {
"F2": "F2 layer ionospheric",
"ES": "Sporadic-E",
"TR": "Tropospheric ducting",
+28 -13
View File
@@ -1,8 +1,16 @@
from __future__ import annotations
import logging
import threading
import time
from core.config import config, create_provider_from_config
from providers.activityrefdata.activity_ref_data_provider import ActivityRefDataProvider
from providers.alert.alert_provider import AlertProvider
from providers.callsigndata.callsign_data_provider import CallsignDataProvider
from providers.solarconditions.solar_conditions_provider import SolarConditionsProvider
from providers.spot.spot_provider import SpotProvider
from providers.staticdata.static_data_provider import StaticDataProvider
logger = logging.getLogger(__name__)
@@ -10,16 +18,16 @@ logger = logging.getLogger(__name__)
class DataProviders:
"""Global object for storing data providers."""
def __init__(self):
self.spot_providers = []
self.alert_providers = []
self.solar_condition_providers = []
self.static_data_providers = []
self.sig_ref_data_providers = []
self.callsign_data_providers = []
self._startup_timers = []
def __init__(self) -> None:
self.spot_providers: list[SpotProvider] = []
self.alert_providers: list[AlertProvider] = []
self.solar_condition_providers: list[SolarConditionsProvider] = []
self.static_data_providers: list[StaticDataProvider] = []
self.sig_ref_data_providers: list[ActivityRefDataProvider] = []
self.callsign_data_providers: list[CallsignDataProvider] = []
self._startup_timers: list[threading.Timer] = []
def setup(self):
def setup(self) -> None:
for entry in config["spot_providers"]:
self.spot_providers.append(create_provider_from_config("providers.spot", entry))
for entry in config["alert_providers"]:
@@ -34,7 +42,12 @@ class DataProviders:
self.callsign_data_providers.append(create_provider_from_config("providers.callsigndata", entry))
@staticmethod
def start_providers(providers, provider_type):
def start_providers(
providers: list[
SpotProvider | AlertProvider | SolarConditionsProvider | StaticDataProvider | ActivityRefDataProvider | CallsignDataProvider
],
provider_type: str,
) -> None:
"""Helper method to activate enabled providers in the list."""
logger.info(f"Starting {provider_type} providers...")
@@ -42,7 +55,7 @@ class DataProviders:
if p.enabled:
p.start()
def start(self):
def start(self) -> None:
# Start data providers before spot/alert providers so the lookup data is there already for incoming spots.
# Each category is fired off after a small delay to give the rest of Spothole chance to start up.
self._startup_timers = [
@@ -60,7 +73,7 @@ class DataProviders:
t.daemon = True
t.start()
def stop(self):
def stop(self) -> None:
# Cancel any startup timers that haven't fired yet
for t in self._startup_timers:
t.cancel()
@@ -81,7 +94,9 @@ class DataProviders:
if not all_providers:
return
def stop_provider(p):
def stop_provider(
p: SpotProvider | AlertProvider | SolarConditionsProvider | StaticDataProvider | ActivityRefDataProvider | CallsignDataProvider,
) -> None:
try:
p.stop()
except Exception:
+27 -18
View File
@@ -1,14 +1,23 @@
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import TYPE_CHECKING, Any
import diskcache
import geopandas
from core.config import MAX_ALERT_AGE, MAX_SPOT_AGE
from core.live_data_cache import LiveDataCache
from core.single_object_data_cache import SingleObjectDataCache
from data.solar_conditions import SolarConditions
if TYPE_CHECKING:
# Deferred to avoid a circular import: data.alert and data.spot both import core.data_store at module level.
from data.alert import Alert
from data.spot import Spot
logger = logging.getLogger(__name__)
CACHE_DIR = "./cache/"
@@ -18,31 +27,31 @@ class DataStore:
"""Data caching/storage object. Handles storage of spots, alerts, solar conditions, activity reference data, and
callsign lookup data using different caching strategies for each."""
def __init__(self):
def __init__(self) -> None:
# Constants
self._MAX_SPOT_COUNT = 100000
self._MAX_ALERT_COUNT = 100000
self._SPOT_ALERT_SNAPSHOT_INTERVAL_SEC = 300
self.CALLSIGN_DATA_TTL_SEC = 30 * 24 * 60 * 60
# Caches
self.alerts = None
self.spots = None
self.callsign_data_countryfiles = None
self.callsign_data_clublogxml = None
self.callsign_data_clublogapi = None
self.callsign_data_qrz = None
self.callsign_data_hamqth = None
self.dxcc_data = None
self.dxcc_lookup_by_call_regex = []
self.activity_refs = None
self.status = None
self.solar_conditions = None
self.alerts: LiveDataCache[Alert] | None = None
self.spots: LiveDataCache[Spot] | None = None
self.callsign_data_countryfiles: diskcache.Cache | None = None
self.callsign_data_clublogxml: diskcache.Cache | None = None
self.callsign_data_clublogapi: diskcache.Cache | None = None
self.callsign_data_qrz: diskcache.Cache | None = None
self.callsign_data_hamqth: diskcache.Cache | None = None
self.dxcc_data: diskcache.Cache | None = None
self.dxcc_lookup_by_call_regex: list[tuple[re.Pattern[str], Any]] = []
self.activity_refs: diskcache.Cache | None = None
self.status: SingleObjectDataCache[dict[str, Any]] | None = None
self.solar_conditions: SingleObjectDataCache[SolarConditions] | None = None
# ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be
# caches, they can just be straight objects
self.cq_zone_data = None
self.itu_zone_data = None
self.cq_zone_data: geopandas.GeoDataFrame | None = None
self.itu_zone_data: geopandas.GeoDataFrame | None = None
def setup(self):
def setup(self) -> None:
Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
# For solar data and status data, we use a wrapper around disk cache where each cache contains only a single
@@ -100,7 +109,7 @@ class DataStore:
)
logger.info(f"Loaded {len(self.alerts.keys())} alerts from a previous run.")
def regenerate_call_regex_to_dxcc_entity_map(self):
def regenerate_call_regex_to_dxcc_entity_map(self) -> None:
"""DXCC entity data from K0SWE includes a regex which we can use to match a callsign, and determine which DXCC
entity it belongs to. But getting every DXCC entity data object out of DiskCache, iterating, compiling its regex
and testing the callsign every time is expensive. So instead we build a separate in-memory lookup of compiled
@@ -110,7 +119,7 @@ class DataStore:
for entry in [DATA_STORE.dxcc_data[key] for key in DATA_STORE.dxcc_data]:
self.dxcc_lookup_by_call_regex.append((re.compile(entry["prefixRegex"]), entry["entityCode"]))
def close(self):
def close(self) -> None:
self.spots.close()
self.alerts.close()
self.solar_conditions.close()
+5 -3
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from enum import Enum
@@ -44,7 +46,7 @@ class Mode(str, Enum):
return not (self.is_cw or self.is_phone)
@staticmethod
def from_name(name):
def from_name(name: str) -> Mode | None:
"""Convert a string to an enum mode using the alias table."""
if not name:
@@ -140,7 +142,7 @@ class ActivityName(str, Enum):
PGA = "PGA"
TOILETS = "Toilets"
def __str__(self):
def __str__(self) -> str:
return str(self.value)
@@ -179,7 +181,7 @@ class ActivityType(str, Enum):
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots
# that match a key in this table will be converted to the corresponding value, so only the modes above will actually be
# present in the spots.
MODE_ALIASES = {
MODE_ALIASES: dict[str, str] = {
"USB": "SSB",
"LSB": "SSB",
"DIGITALVOICE": "DV",
+14 -10
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import logging
import re
from math import floor, isnan
@@ -14,7 +16,7 @@ TRANSFORMER_IRISH_GRID_TO_WGS84 = Transformer.from_crs("EPSG:29903", "EPSG:4326"
TRANSFORMER_CI_UTM_GRID_TO_WGS84 = Transformer.from_crs("+proj=utm +zone=30 +ellps=WGS84", "EPSG:4326")
def lat_lon_to_cq_zone(lat, lon):
def lat_lon_to_cq_zone(lat: float, lon: float) -> int | None:
"""Finds out which CQ zone a lat/lon point is in."""
if DATA_STORE.cq_zone_data is not None:
@@ -36,7 +38,7 @@ def lat_lon_to_cq_zone(lat, lon):
return None
def lat_lon_to_itu_zone(lat, lon):
def lat_lon_to_itu_zone(lat: float, lon: float) -> int | None:
"""Finds out which ITU zone a lat/lon point is in."""
if DATA_STORE.itu_zone_data is not None:
@@ -58,7 +60,7 @@ def lat_lon_to_itu_zone(lat, lon):
return None
def lat_lon_for_grid_centre(grid):
def lat_lon_for_grid_centre(grid: str) -> list[float] | None:
"""Convert a Maidenhead grid reference of arbitrary precision to the lat/long of the centre point of the square.
Returns None if the grid format is invalid."""
@@ -69,7 +71,7 @@ def lat_lon_for_grid_centre(grid):
return None
def lat_lon_for_grid_sw_corner(grid):
def lat_lon_for_grid_sw_corner(grid: str) -> list[float] | None:
"""Convert a Maidenhead grid reference of arbitrary precision to the lat/long of the southwest corner of the square.
Returns None if the grid format is invalid."""
@@ -80,7 +82,7 @@ def lat_lon_for_grid_sw_corner(grid):
return None
def lat_lon_for_grid_ne_corner(grid):
def lat_lon_for_grid_ne_corner(grid: str) -> list[float] | None:
"""Convert a Maidenhead grid reference of arbitrary precision to the lat/long of the northeast corner of the square.
Returns None if the grid format is invalid."""
@@ -91,7 +93,9 @@ def lat_lon_for_grid_ne_corner(grid):
return None
def lat_lon_for_grid_sw_corner_plus_size(grid):
def lat_lon_for_grid_sw_corner_plus_size(
grid: str,
) -> tuple[float, float, float, float] | tuple[None, None, None, None]:
"""Convert a Maidenhead grid reference of arbitrary precision to lat/long, including in the result the size of the
lowest grid square. This is a utility method used by the main methods that return the centre, southwest, and
northeast coordinates of a grid square.
@@ -162,7 +166,7 @@ def lat_lon_for_grid_sw_corner_plus_size(grid):
return lat, lon, lat_cell_size, lon_cell_size
def wab_wai_square_to_lat_lon(ref):
def wab_wai_square_to_lat_lon(ref: str) -> tuple[float, float] | None:
"""Convert a Worked All Britain or Worked All Ireland reference to a lat/lon point."""
# First check we have a valid grid square, and based on what it looks like, use either the Ordnance Survey, Irish,
@@ -178,7 +182,7 @@ def wab_wai_square_to_lat_lon(ref):
return None
def os_grid_square_to_lat_lon(ref):
def os_grid_square_to_lat_lon(ref: str) -> tuple[float, float]:
"""Get a lat/lon point for the centre of an Ordnance Survey grid square"""
# Convert the letters into multipliers for the 500km squares and 100km squares
@@ -209,7 +213,7 @@ def os_grid_square_to_lat_lon(ref):
return lat, lon
def irish_grid_square_to_lat_lon(ref):
def irish_grid_square_to_lat_lon(ref: str) -> tuple[float, float]:
"""Get a lat/lon point for the centre of an Irish Grid square."""
# Convert the letters into multipliers for the 100km squares
@@ -237,7 +241,7 @@ def irish_grid_square_to_lat_lon(ref):
return lat, lon
def utm_grid_square_to_lat_lon(ref):
def utm_grid_square_to_lat_lon(ref: str) -> tuple[float, float]:
"""Get a lat/lon point for the centre of a UTM grid square (supports only squares WA & WV for the Channel Islands, nothing else implemented)"""
# Take the numeric parts of the grid square and multiply by 10000 to get metres from the corner of the letter-based grid square
+24 -18
View File
@@ -1,33 +1,39 @@
from __future__ import annotations
import logging
import threading
import time
from collections.abc import Callable
from typing import Generic, TypeVar
import diskcache
from cachetools import TTLCache
logger = logging.getLogger(__name__)
VT = TypeVar("VT")
class LiveDataCache:
class LiveDataCache(Generic[VT]):
"""Cache for spots and alerts. Uses the fast in-memory TTLCache for normal data I/O, including the TTL to enforce
maximum lifetime, and adds a separate diskcache to which we can save and load the TTLCache to provide persistence.
Also adds thread safety so spots and alerts can come from any thread, and a listener mechanism so the web server
can get a callback when new spots/alerts are added, and send them to any SSE clients."""
def __init__(self, maxsize, ttl, snapshot_dir, snapshot_interval_sec):
self._cache = TTLCache(maxsize=maxsize, ttl=ttl)
def __init__(self, maxsize: int, ttl: int, snapshot_dir: str, snapshot_interval_sec: int) -> None:
self._cache: TTLCache = TTLCache(maxsize=maxsize, ttl=ttl)
self._lock = threading.Lock()
self._ttl = ttl
self._listeners = []
self._listeners: list[Callable[[VT], None]] = []
self._listeners_lock = threading.Lock()
self._snapshot_dir = snapshot_dir
self._disk_cache = diskcache.Cache(str(snapshot_dir))
self._stop_event = threading.Event()
self._snapshot_thread = None
self._snapshot_thread: threading.Thread | None = None
self._load_snapshot()
self._start_periodic_snapshot(snapshot_interval_sec)
def set(self, key, value):
def set(self, key: str, value: VT) -> None:
with self._lock:
self._cache[key] = value
@@ -40,34 +46,34 @@ class LiveDataCache:
except Exception:
logger.exception(f"Listener raised an exception for key {key}")
def get(self, key, default=None):
def get(self, key: str, default: VT | None = None) -> VT | None:
with self._lock:
return self._cache.get(key, default)
def delete(self, key):
def delete(self, key: str) -> None:
with self._lock:
self._cache.pop(key, None)
def keys(self):
def keys(self) -> list[str]:
with self._lock:
return list(self._cache.keys())
def values(self):
def values(self) -> list[VT]:
with self._lock:
return list(self._cache.values())
def add_listener(self, callback):
def add_listener(self, callback: Callable[[VT], None]) -> None:
"""Register callback(value) which will be called whenever a new spot/alert item is added via set(). Used by the
web server (via SSEBroadcaster) to send SSE clients an update on every new spot."""
with self._listeners_lock:
self._listeners.append(callback)
def remove_listener(self, callback):
def remove_listener(self, callback: Callable[[VT], None]) -> None:
with self._listeners_lock:
self._listeners.remove(callback)
def save_snapshot(self):
def save_snapshot(self) -> None:
with self._lock:
# Store the time with the data so we can avoid loading anything nxt time that's older than TTL
data = [(k, v, time.time()) for k, v in self._cache.items()]
@@ -76,9 +82,9 @@ class LiveDataCache:
except Exception:
logger.exception(f"Failed to write snapshot to {self._snapshot_dir}")
def _load_snapshot(self):
def _load_snapshot(self) -> None:
try:
data = self._disk_cache.get("snapshot")
data: list[tuple[str, VT, float]] | None = self._disk_cache.get("snapshot")
except Exception: # noqa: BLE001 (If any exceptions we treat the data as junk and start from scratch, it's probably due to a version upgrade having incompatible data structures, fine to not log the exception in this case)
logger.warning(f"Failed to load snapshot from {self._snapshot_dir}, clearing it.")
self._disk_cache.clear()
@@ -94,8 +100,8 @@ class LiveDataCache:
self._cache[key] = value
logger.info(f"Loaded snapshot from {self._snapshot_dir}")
def _start_periodic_snapshot(self, interval):
def loop():
def _start_periodic_snapshot(self, interval: int) -> None:
def loop() -> None:
while not self._stop_event.wait(timeout=interval):
self.save_snapshot()
@@ -104,7 +110,7 @@ class LiveDataCache:
)
self._snapshot_thread.start()
def close(self):
def close(self) -> None:
self._stop_event.set()
if self._snapshot_thread:
self._snapshot_thread.join(timeout=15)
+3 -1
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from prometheus_client import (
CollectorRegistry,
Counter,
@@ -25,7 +27,7 @@ memory_use_gauge = Gauge(
)
def get_metrics():
def get_metrics() -> bytes:
"""Get a Prometheus metrics response for the web server"""
return generate_latest(registry)
+11 -6
View File
@@ -1,18 +1,23 @@
from __future__ import annotations
import logging
import threading
from typing import Generic, TypeVar
import diskcache
logger = logging.getLogger(__name__)
T = TypeVar("T")
class SingleObjectDataCache:
class SingleObjectDataCache(Generic[T]):
"""Cache for status and solar conditions. This uses DiskCache, but unlike the standard DiskCache users like
activity ref and callsign lookup handlers, status and solar conditions are persisted as a single object. If we
just load the object from DiskCache and modify it, DiskCache doesn't know that it's been updated and needs
re-caching, so we provide a store() method that any functions updating the object can call afterwards."""
def __init__(self, cache_dir, object_if_empty):
def __init__(self, cache_dir: str, object_if_empty: T) -> None:
"""Initialize a SingleObjectDataCache. Provide the directory to load the cache from and save it to. If the cache
is empty, the provided object_if_empty parameter will be used to initialise it."""
@@ -22,24 +27,24 @@ class SingleObjectDataCache:
if "object" not in self._cache:
self._cache.add("object", object_if_empty)
try:
self._obj = self._cache.get("object")
self._obj: T = self._cache.get("object")
except Exception: # noqa: BLE001 (If any exceptions we treat the data as junk and start from scratch, it's probably due to a version upgrade having incompatible data structures, fine to not log the exception in this case)
logger.warning(f"Failed to load cache from {cache_dir}, clearing it.")
self._cache.clear()
self._cache.add("object", object_if_empty)
self._obj = object_if_empty
def get(self):
def get(self) -> T:
"""Get the data object. This can then be manipulated as necessary across multiple threads. Any function
modifying the object must remember to call store() afterwards."""
return self._obj
def store(self):
def store(self) -> None:
"""Store the updated object in the cache. Any function modifying the object must remember to call this
afterwards."""
with self._lock:
self._cache.set("object", self._obj)
def close(self):
def close(self) -> None:
self.store()
self._cache.close()
+8 -6
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import logging
import os
from datetime import datetime
@@ -21,11 +23,11 @@ logger = logging.getLogger(__name__)
class StatusReporter:
"""Provides a timed update of the application's status data."""
def __init__(self, run_interval):
def __init__(self, run_interval: float) -> None:
"""Constructor"""
self._run_interval = run_interval
self._thread = None
self._thread: Thread | None = None
self._stop_event = Event()
self._startup_time = datetime.now(pytz.UTC)
@@ -33,13 +35,13 @@ class StatusReporter:
DATA_STORE.status.get()["server_owner_callsign"] = SERVER_OWNER_CALLSIGN
DATA_STORE.status.store()
def start(self):
def start(self) -> None:
"""Start the reporter thread"""
self._thread = Thread(target=self._run, name="StatusReporter", daemon=True)
self._thread.start()
def stop(self):
def stop(self) -> None:
"""Stop any threads and prepare for application shutdown"""
self._stop_event.set()
@@ -48,7 +50,7 @@ class StatusReporter:
if self._thread.is_alive():
logger.warning("Status reporter worker thread did not exit on time and will be killed.")
def _run(self):
def _run(self) -> None:
"""Thread entry point: report immediately on startup, then on each interval until stopped"""
while True:
@@ -56,7 +58,7 @@ class StatusReporter:
if self._stop_event.wait(timeout=self._run_interval):
break
def _report(self):
def _report(self) -> None:
"""Write status information"""
DATA_STORE.status.get()["uptime"] = (datetime.now(pytz.UTC) - self._startup_time).total_seconds()
+6 -2
View File
@@ -1,6 +1,10 @@
from __future__ import annotations
import threading
from datetime import timedelta
from typing import Any
from requests import Response
from requests_cache import CachedSession
from core.data_store import CACHE_DIR
@@ -14,7 +18,7 @@ class URLDataCache(CachedSession):
used across multiple threads, though note that URL lookups will block each other this way, so it is still better to
create one of these objects per thread if possible."""
def __init__(self, name):
def __init__(self, name: str) -> None:
super().__init__(
f"{CACHE_DIR}urls/{name}",
expire_after=timedelta(days=1),
@@ -22,6 +26,6 @@ class URLDataCache(CachedSession):
)
self._lock = threading.Lock()
def get(self, *args, **kwargs):
def get(self, *args: Any, **kwargs: Any) -> Response:
with self._lock:
return super().get(*args, **kwargs)
+10 -5
View File
@@ -1,19 +1,24 @@
from __future__ import annotations
import logging
import re
from typing import Any
import simplejson
from pyhamtools import Callinfo
from pyhamtools.frequency import freq_to_band
from pyhamtools.locator import latlong_to_locator
from core.constants import BANDS, UNKNOWN_BAND
from core.data_store import DATA_STORE
from core.enums import MODE_ALIASES, Continent, Mode, ModeType
from data.band import Band
from data.callsign import Callsign, LocationSourceForCallsign
logger = logging.getLogger(__name__)
def safe_json_dumps(obj):
def safe_json_dumps(obj: Any) -> str:
"""Safe version of json.dumps that also converts objects to dicts so they can be output, and ignores NaN floats
which are invalid in JSON."""
@@ -59,7 +64,7 @@ def infer_mode_type_from_mode(mode: str) -> ModeType | None:
return None
def infer_band_from_freq(freq):
def infer_band_from_freq(freq: float) -> Band:
"""Infer a band from a frequency in Hz"""
for b in BANDS:
@@ -68,7 +73,7 @@ def infer_band_from_freq(freq):
return UNKNOWN_BAND
def infer_mode_from_frequency(freq):
def infer_mode_from_frequency(freq: float) -> Mode | None:
"""Infer a mode from the frequency (in Hz) according to the band plan. Just a guess really."""
try:
@@ -113,14 +118,14 @@ def infer_mode_from_frequency(freq):
return None
def get_flag_for_dxcc(dxcc):
def get_flag_for_dxcc(dxcc: int) -> str | None:
"""Get an emoji flag for a given DXCC entity ID"""
dxcc_data = DATA_STORE.dxcc_data.get(dxcc, None)
return dxcc_data["flag"] if dxcc_data else None
def get_callsign_object_from_pyhamtools_callinfo(callsign, callinfo):
def get_callsign_object_from_pyhamtools_callinfo(callsign: str, callinfo: Callinfo) -> Callsign:
"""Utility function to take the data provided by a PyHamTools CallInfo object and populate our own Callsign data
object from it"""