mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-09-21 06:47:42 +00:00
Autogenerated type safety parameterisation of all methods
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user