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 logging
import re import re
@@ -12,7 +14,7 @@ from data.activity_ref import ActivityRef
logger = logging.getLogger(__name__) 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 """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 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 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 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 """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 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 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.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 """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.""" 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 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 """Utility function to get the regex string for an activity reference for a named activity. If no match is
found, None will be returned.""" found, None will be returned."""
@@ -21,14 +25,14 @@ def get_ref_regex_for_activity(activity):
return found.ref_regex if found else None 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.""" """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) found = get_activity_by_name(activity)
return found.icon if found else None 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 """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 but there are some cases (e.g. is "TOTA" Towers, Tiles or Toilets?) where we need to transform one to the
other.""" other."""
+4 -1
View File
@@ -1,10 +1,13 @@
from __future__ import annotations
import re import re
from core.data_providers import DATA_PROVIDERS from core.data_providers import DATA_PROVIDERS
from data.callsign import Callsign 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. """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, 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.""" to enable lookup using those providers."""
+10 -8
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
from threading import Event, Thread from threading import Event, Thread
@@ -12,25 +14,25 @@ logger = logging.getLogger(__name__)
class CleanupTimer: class CleanupTimer:
"""Provides a timed cleanup of the spot list.""" """Provides a timed cleanup of the spot list."""
def __init__(self): def __init__(self) -> None:
"""Constructor""" """Constructor"""
self._cleanup_interval = None self._cleanup_interval: float | None = None
self.last_cleanup_time = datetime.min.replace(tzinfo=pytz.UTC) self.last_cleanup_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Starting" self.status = "Starting"
self._thread = None self._thread: Thread | None = None
self._stop_event = Event() self._stop_event = Event()
def setup(self, cleanup_interval): def setup(self, cleanup_interval: float) -> None:
self._cleanup_interval = cleanup_interval self._cleanup_interval = cleanup_interval
def start(self): def start(self) -> None:
"""Start the cleanup timer""" """Start the cleanup timer"""
self._thread = Thread(target=self._run, daemon=True) self._thread = Thread(target=self._run, daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self) -> None:
"""Stop any threads and prepare for application shutdown""" """Stop any threads and prepare for application shutdown"""
self._stop_event.set() self._stop_event.set()
@@ -39,11 +41,11 @@ class CleanupTimer:
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning("Cleanup worker thread did not exit on time and will be killed.") 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): while not self._stop_event.wait(timeout=self._cleanup_interval):
self._cleanup() self._cleanup()
def _cleanup(self): def _cleanup(self) -> None:
"""Perform cleanup and reschedule next timer""" """Perform cleanup and reschedule next timer"""
try: try:
+21 -18
View File
@@ -1,7 +1,10 @@
from __future__ import annotations
import importlib import importlib
import logging import logging
import os import os
import sys import sys
from typing import Any
import yaml import yaml
@@ -16,25 +19,25 @@ if not os.path.isfile("config.yml"):
# Load config # Load config
with open("config.yml") as f: with open("config.yml") as f:
config = yaml.safe_load(f) config: dict[str, Any] = yaml.safe_load(f)
logger.info("Loaded config.") logger.info("Loaded config.")
BASE_URL = config.get("base_url", "http://localhost:8080") BASE_URL: str = config.get("base_url", "http://localhost:8080")
MAX_SPOT_AGE = config.get("max_spot_age_sec", 3600) MAX_SPOT_AGE: int = config.get("max_spot_age_sec", 3600)
MAX_ALERT_AGE = config.get("max_alert_age_sec", 604800) MAX_ALERT_AGE: int = config.get("max_alert_age_sec", 604800)
SERVER_OWNER_CALLSIGN = config.get("server_owner_callsign", "N0CALL") SERVER_OWNER_CALLSIGN: str = config.get("server_owner_callsign", "N0CALL")
WEB_SERVER_PORT = config.get("web_server_port", 8080) WEB_SERVER_PORT: int = config.get("web_server_port", 8080)
TELNET_SERVER_ENABLED = config.get("telnet_server_enabled", False) TELNET_SERVER_ENABLED: bool = config.get("telnet_server_enabled", False)
TELNET_SERVER_ADDRESS = config.get("telnet_server_address", "localhost") TELNET_SERVER_ADDRESS: str = config.get("telnet_server_address", "localhost")
TELNET_SERVER_PORT = config.get("telnet_server_port", 7373) TELNET_SERVER_PORT: int = config.get("telnet_server_port", 7373)
ALLOW_SPOTTING = config.get("allow_spotting", True) ALLOW_SPOTTING: bool = config.get("allow_spotting", True)
ALLOW_UPSTREAM_SPOTTING = config.get("allow_upstream_spotting", True) ALLOW_UPSTREAM_SPOTTING: bool = config.get("allow_upstream_spotting", True)
WEB_UI_OPTIONS = config.get("web_ui_options", {}) WEB_UI_OPTIONS: dict[str, Any] = config.get("web_ui_options", {})
API_ONLY_MODE = config.get("api_only_mode", False) API_ONLY_MODE: bool = config.get("api_only_mode", False)
RECAPTCHA_SECRET_KEY = config.get("recaptcha_secret_key", "") RECAPTCHA_SECRET_KEY: str = config.get("recaptcha_secret_key", "")
RECAPTCHA_SITE_KEY = config.get("recaptcha_site_key", "") RECAPTCHA_SITE_KEY: str = config.get("recaptcha_site_key", "")
LOG_LEVEL = config.get("log_level", "INFO") LOG_LEVEL: str = config.get("log_level", "INFO")
LOG_WEB_REQUESTS = config.get("log_web_requests", False) 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["qrz_enabled"] = any(p["class"] == "QRZ" and p["enabled"] for p in config["callsign_data_providers"])
WEB_UI_OPTIONS["hamqth_enabled"] = any( 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 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 """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 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.""" 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 core.config import SERVER_OWNER_CALLSIGN
from data.band import Band from data.band import Band
# General software # General software
SOFTWARE_VERSION = "2.2-pre" SOFTWARE_VERSION: str = "2.2-pre"
# HTTP headers used for spot providers that use HTTP # HTTP headers used for spot providers that use HTTP
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"} HTTP_HEADERS: dict[str, str] = {"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(" ", "_") HAMQTH_PRG: str = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}".replace(" ", "_")
# Band definitions # Band definitions
BANDS = [ BANDS: list[Band] = [
Band(name="2200m", start_freq=135700, end_freq=137800), Band(name="2200m", start_freq=135700, end_freq=137800),
Band(name="600m", start_freq=472000, end_freq=479000), Band(name="600m", start_freq=472000, end_freq=479000),
Band(name="160m", start_freq=1800000, end_freq=2000000, is_ham_hf=True), 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="47GHz", start_freq=47000000000, end_freq=47200000000),
Band(name="76GHz", start_freq=75500000000, end_freq=81500000000), 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 # 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 # 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", "F2": "F2 layer ionospheric",
"ES": "Sporadic-E", "ES": "Sporadic-E",
"TR": "Tropospheric ducting", "TR": "Tropospheric ducting",
+28 -13
View File
@@ -1,8 +1,16 @@
from __future__ import annotations
import logging import logging
import threading import threading
import time import time
from core.config import config, create_provider_from_config 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__) logger = logging.getLogger(__name__)
@@ -10,16 +18,16 @@ logger = logging.getLogger(__name__)
class DataProviders: class DataProviders:
"""Global object for storing data providers.""" """Global object for storing data providers."""
def __init__(self): def __init__(self) -> None:
self.spot_providers = [] self.spot_providers: list[SpotProvider] = []
self.alert_providers = [] self.alert_providers: list[AlertProvider] = []
self.solar_condition_providers = [] self.solar_condition_providers: list[SolarConditionsProvider] = []
self.static_data_providers = [] self.static_data_providers: list[StaticDataProvider] = []
self.sig_ref_data_providers = [] self.sig_ref_data_providers: list[ActivityRefDataProvider] = []
self.callsign_data_providers = [] self.callsign_data_providers: list[CallsignDataProvider] = []
self._startup_timers = [] self._startup_timers: list[threading.Timer] = []
def setup(self): def setup(self) -> None:
for entry in config["spot_providers"]: for entry in config["spot_providers"]:
self.spot_providers.append(create_provider_from_config("providers.spot", entry)) self.spot_providers.append(create_provider_from_config("providers.spot", entry))
for entry in config["alert_providers"]: for entry in config["alert_providers"]:
@@ -34,7 +42,12 @@ class DataProviders:
self.callsign_data_providers.append(create_provider_from_config("providers.callsigndata", entry)) self.callsign_data_providers.append(create_provider_from_config("providers.callsigndata", entry))
@staticmethod @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.""" """Helper method to activate enabled providers in the list."""
logger.info(f"Starting {provider_type} providers...") logger.info(f"Starting {provider_type} providers...")
@@ -42,7 +55,7 @@ class DataProviders:
if p.enabled: if p.enabled:
p.start() 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. # 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. # Each category is fired off after a small delay to give the rest of Spothole chance to start up.
self._startup_timers = [ self._startup_timers = [
@@ -60,7 +73,7 @@ class DataProviders:
t.daemon = True t.daemon = True
t.start() t.start()
def stop(self): def stop(self) -> None:
# Cancel any startup timers that haven't fired yet # Cancel any startup timers that haven't fired yet
for t in self._startup_timers: for t in self._startup_timers:
t.cancel() t.cancel()
@@ -81,7 +94,9 @@ class DataProviders:
if not all_providers: if not all_providers:
return return
def stop_provider(p): def stop_provider(
p: SpotProvider | AlertProvider | SolarConditionsProvider | StaticDataProvider | ActivityRefDataProvider | CallsignDataProvider,
) -> None:
try: try:
p.stop() p.stop()
except Exception: except Exception:
+27 -18
View File
@@ -1,14 +1,23 @@
from __future__ import annotations
import logging import logging
import re import re
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any
import diskcache import diskcache
import geopandas
from core.config import MAX_ALERT_AGE, MAX_SPOT_AGE from core.config import MAX_ALERT_AGE, MAX_SPOT_AGE
from core.live_data_cache import LiveDataCache from core.live_data_cache import LiveDataCache
from core.single_object_data_cache import SingleObjectDataCache from core.single_object_data_cache import SingleObjectDataCache
from data.solar_conditions import SolarConditions 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__) logger = logging.getLogger(__name__)
CACHE_DIR = "./cache/" CACHE_DIR = "./cache/"
@@ -18,31 +27,31 @@ class DataStore:
"""Data caching/storage object. Handles storage of spots, alerts, solar conditions, activity reference data, and """Data caching/storage object. Handles storage of spots, alerts, solar conditions, activity reference data, and
callsign lookup data using different caching strategies for each.""" callsign lookup data using different caching strategies for each."""
def __init__(self): def __init__(self) -> None:
# Constants # Constants
self._MAX_SPOT_COUNT = 100000 self._MAX_SPOT_COUNT = 100000
self._MAX_ALERT_COUNT = 100000 self._MAX_ALERT_COUNT = 100000
self._SPOT_ALERT_SNAPSHOT_INTERVAL_SEC = 300 self._SPOT_ALERT_SNAPSHOT_INTERVAL_SEC = 300
self.CALLSIGN_DATA_TTL_SEC = 30 * 24 * 60 * 60 self.CALLSIGN_DATA_TTL_SEC = 30 * 24 * 60 * 60
# Caches # Caches
self.alerts = None self.alerts: LiveDataCache[Alert] | None = None
self.spots = None self.spots: LiveDataCache[Spot] | None = None
self.callsign_data_countryfiles = None self.callsign_data_countryfiles: diskcache.Cache | None = None
self.callsign_data_clublogxml = None self.callsign_data_clublogxml: diskcache.Cache | None = None
self.callsign_data_clublogapi = None self.callsign_data_clublogapi: diskcache.Cache | None = None
self.callsign_data_qrz = None self.callsign_data_qrz: diskcache.Cache | None = None
self.callsign_data_hamqth = None self.callsign_data_hamqth: diskcache.Cache | None = None
self.dxcc_data = None self.dxcc_data: diskcache.Cache | None = None
self.dxcc_lookup_by_call_regex = [] self.dxcc_lookup_by_call_regex: list[tuple[re.Pattern[str], Any]] = []
self.activity_refs = None self.activity_refs: diskcache.Cache | None = None
self.status = None self.status: SingleObjectDataCache[dict[str, Any]] | None = None
self.solar_conditions = 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 # 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 # caches, they can just be straight objects
self.cq_zone_data = None self.cq_zone_data: geopandas.GeoDataFrame | None = None
self.itu_zone_data = 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) 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 # 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.") 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 """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 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 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]: 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"])) self.dxcc_lookup_by_call_regex.append((re.compile(entry["prefixRegex"]), entry["entityCode"]))
def close(self): def close(self) -> None:
self.spots.close() self.spots.close()
self.alerts.close() self.alerts.close()
self.solar_conditions.close() self.solar_conditions.close()
+5 -3
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from enum import Enum from enum import Enum
@@ -44,7 +46,7 @@ class Mode(str, Enum):
return not (self.is_cw or self.is_phone) return not (self.is_cw or self.is_phone)
@staticmethod @staticmethod
def from_name(name): def from_name(name: str) -> Mode | None:
"""Convert a string to an enum mode using the alias table.""" """Convert a string to an enum mode using the alias table."""
if not name: if not name:
@@ -140,7 +142,7 @@ class ActivityName(str, Enum):
PGA = "PGA" PGA = "PGA"
TOILETS = "Toilets" TOILETS = "Toilets"
def __str__(self): def __str__(self) -> str:
return str(self.value) 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 # 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 # 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. # present in the spots.
MODE_ALIASES = { MODE_ALIASES: dict[str, str] = {
"USB": "SSB", "USB": "SSB",
"LSB": "SSB", "LSB": "SSB",
"DIGITALVOICE": "DV", "DIGITALVOICE": "DV",
+14 -10
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import logging import logging
import re import re
from math import floor, isnan 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") 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.""" """Finds out which CQ zone a lat/lon point is in."""
if DATA_STORE.cq_zone_data is not None: if DATA_STORE.cq_zone_data is not None:
@@ -36,7 +38,7 @@ def lat_lon_to_cq_zone(lat, lon):
return None 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.""" """Finds out which ITU zone a lat/lon point is in."""
if DATA_STORE.itu_zone_data is not None: if DATA_STORE.itu_zone_data is not None:
@@ -58,7 +60,7 @@ def lat_lon_to_itu_zone(lat, lon):
return None 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. """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.""" Returns None if the grid format is invalid."""
@@ -69,7 +71,7 @@ def lat_lon_for_grid_centre(grid):
return None 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. """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.""" Returns None if the grid format is invalid."""
@@ -80,7 +82,7 @@ def lat_lon_for_grid_sw_corner(grid):
return None 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. """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.""" Returns None if the grid format is invalid."""
@@ -91,7 +93,9 @@ def lat_lon_for_grid_ne_corner(grid):
return None 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 """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 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. 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 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.""" """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, # 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 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""" """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 # 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 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.""" """Get a lat/lon point for the centre of an Irish Grid square."""
# Convert the letters into multipliers for the 100km squares # Convert the letters into multipliers for the 100km squares
@@ -237,7 +241,7 @@ def irish_grid_square_to_lat_lon(ref):
return lat, lon 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)""" """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 # 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 logging
import threading import threading
import time import time
from collections.abc import Callable
from typing import Generic, TypeVar
import diskcache import diskcache
from cachetools import TTLCache from cachetools import TTLCache
logger = logging.getLogger(__name__) 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 """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. 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 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.""" 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): def __init__(self, maxsize: int, ttl: int, snapshot_dir: str, snapshot_interval_sec: int) -> None:
self._cache = TTLCache(maxsize=maxsize, ttl=ttl) self._cache: TTLCache = TTLCache(maxsize=maxsize, ttl=ttl)
self._lock = threading.Lock() self._lock = threading.Lock()
self._ttl = ttl self._ttl = ttl
self._listeners = [] self._listeners: list[Callable[[VT], None]] = []
self._listeners_lock = threading.Lock() self._listeners_lock = threading.Lock()
self._snapshot_dir = snapshot_dir self._snapshot_dir = snapshot_dir
self._disk_cache = diskcache.Cache(str(snapshot_dir)) self._disk_cache = diskcache.Cache(str(snapshot_dir))
self._stop_event = threading.Event() self._stop_event = threading.Event()
self._snapshot_thread = None self._snapshot_thread: threading.Thread | None = None
self._load_snapshot() self._load_snapshot()
self._start_periodic_snapshot(snapshot_interval_sec) self._start_periodic_snapshot(snapshot_interval_sec)
def set(self, key, value): def set(self, key: str, value: VT) -> None:
with self._lock: with self._lock:
self._cache[key] = value self._cache[key] = value
@@ -40,34 +46,34 @@ class LiveDataCache:
except Exception: except Exception:
logger.exception(f"Listener raised an exception for key {key}") 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: with self._lock:
return self._cache.get(key, default) return self._cache.get(key, default)
def delete(self, key): def delete(self, key: str) -> None:
with self._lock: with self._lock:
self._cache.pop(key, None) self._cache.pop(key, None)
def keys(self): def keys(self) -> list[str]:
with self._lock: with self._lock:
return list(self._cache.keys()) return list(self._cache.keys())
def values(self): def values(self) -> list[VT]:
with self._lock: with self._lock:
return list(self._cache.values()) 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 """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.""" web server (via SSEBroadcaster) to send SSE clients an update on every new spot."""
with self._listeners_lock: with self._listeners_lock:
self._listeners.append(callback) self._listeners.append(callback)
def remove_listener(self, callback): def remove_listener(self, callback: Callable[[VT], None]) -> None:
with self._listeners_lock: with self._listeners_lock:
self._listeners.remove(callback) self._listeners.remove(callback)
def save_snapshot(self): def save_snapshot(self) -> None:
with self._lock: with self._lock:
# Store the time with the data so we can avoid loading anything nxt time that's older than TTL # 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()] data = [(k, v, time.time()) for k, v in self._cache.items()]
@@ -76,9 +82,9 @@ class LiveDataCache:
except Exception: except Exception:
logger.exception(f"Failed to write snapshot to {self._snapshot_dir}") logger.exception(f"Failed to write snapshot to {self._snapshot_dir}")
def _load_snapshot(self): def _load_snapshot(self) -> None:
try: 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) 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.") logger.warning(f"Failed to load snapshot from {self._snapshot_dir}, clearing it.")
self._disk_cache.clear() self._disk_cache.clear()
@@ -94,8 +100,8 @@ class LiveDataCache:
self._cache[key] = value self._cache[key] = value
logger.info(f"Loaded snapshot from {self._snapshot_dir}") logger.info(f"Loaded snapshot from {self._snapshot_dir}")
def _start_periodic_snapshot(self, interval): def _start_periodic_snapshot(self, interval: int) -> None:
def loop(): def loop() -> None:
while not self._stop_event.wait(timeout=interval): while not self._stop_event.wait(timeout=interval):
self.save_snapshot() self.save_snapshot()
@@ -104,7 +110,7 @@ class LiveDataCache:
) )
self._snapshot_thread.start() self._snapshot_thread.start()
def close(self): def close(self) -> None:
self._stop_event.set() self._stop_event.set()
if self._snapshot_thread: if self._snapshot_thread:
self._snapshot_thread.join(timeout=15) self._snapshot_thread.join(timeout=15)
+3 -1
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from prometheus_client import ( from prometheus_client import (
CollectorRegistry, CollectorRegistry,
Counter, 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""" """Get a Prometheus metrics response for the web server"""
return generate_latest(registry) return generate_latest(registry)
+11 -6
View File
@@ -1,18 +1,23 @@
from __future__ import annotations
import logging import logging
import threading import threading
from typing import Generic, TypeVar
import diskcache import diskcache
logger = logging.getLogger(__name__) 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 """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 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 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.""" 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 """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.""" 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: if "object" not in self._cache:
self._cache.add("object", object_if_empty) self._cache.add("object", object_if_empty)
try: 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) 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.") logger.warning(f"Failed to load cache from {cache_dir}, clearing it.")
self._cache.clear() self._cache.clear()
self._cache.add("object", object_if_empty) self._cache.add("object", object_if_empty)
self._obj = 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 """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.""" modifying the object must remember to call store() afterwards."""
return self._obj 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 """Store the updated object in the cache. Any function modifying the object must remember to call this
afterwards.""" afterwards."""
with self._lock: with self._lock:
self._cache.set("object", self._obj) self._cache.set("object", self._obj)
def close(self): def close(self) -> None:
self.store() self.store()
self._cache.close() self._cache.close()
+8 -6
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import logging import logging
import os import os
from datetime import datetime from datetime import datetime
@@ -21,11 +23,11 @@ logger = logging.getLogger(__name__)
class StatusReporter: class StatusReporter:
"""Provides a timed update of the application's status data.""" """Provides a timed update of the application's status data."""
def __init__(self, run_interval): def __init__(self, run_interval: float) -> None:
"""Constructor""" """Constructor"""
self._run_interval = run_interval self._run_interval = run_interval
self._thread = None self._thread: Thread | None = None
self._stop_event = Event() self._stop_event = Event()
self._startup_time = datetime.now(pytz.UTC) 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.get()["server_owner_callsign"] = SERVER_OWNER_CALLSIGN
DATA_STORE.status.store() DATA_STORE.status.store()
def start(self): def start(self) -> None:
"""Start the reporter thread""" """Start the reporter thread"""
self._thread = Thread(target=self._run, name="StatusReporter", daemon=True) self._thread = Thread(target=self._run, name="StatusReporter", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self) -> None:
"""Stop any threads and prepare for application shutdown""" """Stop any threads and prepare for application shutdown"""
self._stop_event.set() self._stop_event.set()
@@ -48,7 +50,7 @@ class StatusReporter:
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning("Status reporter worker thread did not exit on time and will be killed.") 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""" """Thread entry point: report immediately on startup, then on each interval until stopped"""
while True: while True:
@@ -56,7 +58,7 @@ class StatusReporter:
if self._stop_event.wait(timeout=self._run_interval): if self._stop_event.wait(timeout=self._run_interval):
break break
def _report(self): def _report(self) -> None:
"""Write status information""" """Write status information"""
DATA_STORE.status.get()["uptime"] = (datetime.now(pytz.UTC) - self._startup_time).total_seconds() 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 import threading
from datetime import timedelta from datetime import timedelta
from typing import Any
from requests import Response
from requests_cache import CachedSession from requests_cache import CachedSession
from core.data_store import CACHE_DIR 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 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.""" create one of these objects per thread if possible."""
def __init__(self, name): def __init__(self, name: str) -> None:
super().__init__( super().__init__(
f"{CACHE_DIR}urls/{name}", f"{CACHE_DIR}urls/{name}",
expire_after=timedelta(days=1), expire_after=timedelta(days=1),
@@ -22,6 +26,6 @@ class URLDataCache(CachedSession):
) )
self._lock = threading.Lock() self._lock = threading.Lock()
def get(self, *args, **kwargs): def get(self, *args: Any, **kwargs: Any) -> Response:
with self._lock: with self._lock:
return super().get(*args, **kwargs) return super().get(*args, **kwargs)
+10 -5
View File
@@ -1,19 +1,24 @@
from __future__ import annotations
import logging import logging
import re import re
from typing import Any
import simplejson import simplejson
from pyhamtools import Callinfo
from pyhamtools.frequency import freq_to_band from pyhamtools.frequency import freq_to_band
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.constants import BANDS, UNKNOWN_BAND from core.constants import BANDS, UNKNOWN_BAND
from core.data_store import DATA_STORE from core.data_store import DATA_STORE
from core.enums import MODE_ALIASES, Continent, Mode, ModeType from core.enums import MODE_ALIASES, Continent, Mode, ModeType
from data.band import Band
from data.callsign import Callsign, LocationSourceForCallsign from data.callsign import Callsign, LocationSourceForCallsign
logger = logging.getLogger(__name__) 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 """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.""" which are invalid in JSON."""
@@ -59,7 +64,7 @@ def infer_mode_type_from_mode(mode: str) -> ModeType | None:
return 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""" """Infer a band from a frequency in Hz"""
for b in BANDS: for b in BANDS:
@@ -68,7 +73,7 @@ def infer_band_from_freq(freq):
return UNKNOWN_BAND 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.""" """Infer a mode from the frequency (in Hz) according to the band plan. Just a guess really."""
try: try:
@@ -113,14 +118,14 @@ def infer_mode_from_frequency(freq):
return None 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""" """Get an emoji flag for a given DXCC entity ID"""
dxcc_data = DATA_STORE.dxcc_data.get(dxcc, None) dxcc_data = DATA_STORE.dxcc_data.get(dxcc, None)
return dxcc_data["flag"] if dxcc_data else 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 """Utility function to take the data provided by a PyHamTools CallInfo object and populate our own Callsign data
object from it""" object from it"""
+2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from core.enums import ActivityName, ActivityRefType, ActivityType from core.enums import ActivityName, ActivityRefType, ActivityType
from data.activity import Activity from data.activity import Activity
+2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from core.enums import ActivityName, ActivityRefType, ActivityType from core.enums import ActivityName, ActivityRefType, ActivityType
+2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from core.enums import ActivityRefType from core.enums import ActivityRefType
+10 -6
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import hashlib import hashlib
import json import json
import logging import logging
@@ -11,6 +13,8 @@ from core.activity_utils import get_icon_for_activity
from core.call_lookup_helper import get_call_info from core.call_lookup_helper import get_call_info
from core.enums import Continent from core.enums import Continent
from core.utils import get_flag_for_dxcc from core.utils import get_flag_for_dxcc
from data.activity_ref import ActivityRef
from data.lookup_credentials import LookupCredentials
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -25,9 +29,9 @@ class Alert:
# DX (alerting) operator info # DX (alerting) operator info
# Callsigns of the operators that has been alerted # Callsigns of the operators that has been alerted
dx_calls: list | None = None dx_calls: list[str] | None = None
# Names of the operators that has been alerted # Names of the operators that has been alerted
dx_names: list | None = None dx_names: list[str | None] | None = None
# Country of the DX operator # Country of the DX operator
dx_country: str | None = None dx_country: str | None = None
# Country flag of the DX operator # Country flag of the DX operator
@@ -64,7 +68,7 @@ class Alert:
sig: str | None = None sig: str | None = None
# Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO. Still named # Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO. Still named
# "sig_refs" for API backwards compatibility. # "sig_refs" for API backwards compatibility.
sig_refs: list = field(default_factory=list) sig_refs: list[ActivityRef] = field(default_factory=list)
# Timing info # Timing info
@@ -87,7 +91,7 @@ class Alert:
# Icon to use when displaying this alert in the web UI. Chosen from the Font Awesome set. # Icon to use when displaying this alert in the web UI. Chosen from the Font Awesome set.
icon: str | None = None icon: str | None = None
def infer_missing(self, credentials=None): def infer_missing(self, credentials: LookupCredentials | None = None) -> None:
"""Infer missing parameters where possible""" """Infer missing parameters where possible"""
try: try:
@@ -160,12 +164,12 @@ class Alert:
except Exception: except Exception:
logger.exception("Exception while inferring missing data from spot") logger.exception("Exception while inferring missing data from spot")
def to_json(self): def to_json(self) -> str:
"""JSON serialise""" """JSON serialise"""
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True) return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
def expired(self): def expired(self) -> bool:
"""Decide if this alert has expired (in which case it should not be added to the system in the first place, and not """Decide if this alert has expired (in which case it should not be added to the system in the first place, and not
returned by the web server if later requested, and removed by the cleanup functions). "Expired" is defined as returned by the web server if later requested, and removed by the cleanup functions). "Expired" is defined as
either having an end_time in the past, or if it only has a start_time, then that start time was more than 3 hours either having an end_time in the past, or if it only has a start_time, then that start time was more than 3 hours
+3 -1
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from core.enums import Continent, LocationSourceForCallsign from core.enums import Continent, LocationSourceForCallsign
@@ -40,7 +42,7 @@ class Callsign:
# Location source # Location source
location_source: LocationSourceForCallsign | None = None location_source: LocationSourceForCallsign | None = None
def fully_populated(self): def fully_populated(self) -> bool:
"""Utility method to indicate that the callsign data is fully populated. Multiple providers can return data for """Utility method to indicate that the callsign data is fully populated. Multiple providers can return data for
a callsign, and we try them in sequence until we have all the data we can, in which case there's no point a callsign, and we try them in sequence until we have all the data we can, in which case there's no point
querying any other providers.""" querying any other providers."""
+5 -1
View File
@@ -1,5 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from tornado.httputil import HTTPHeaders
@dataclass @dataclass
class LookupCredentials: class LookupCredentials:
@@ -13,7 +17,7 @@ class LookupCredentials:
hamqth_session_id: str = "" # alternative to username/password hamqth_session_id: str = "" # alternative to username/password
def extract_credentials(headers): def extract_credentials(headers: HTTPHeaders) -> LookupCredentials | None:
"""Build a LookupCredentials from HTTP request headers; returns None if no usable credentials are present.""" """Build a LookupCredentials from HTTP request headers; returns None if no usable credentials are present."""
creds = LookupCredentials( creds = LookupCredentials(
qrz_username=headers.get("X-QRZ-Username", ""), qrz_username=headers.get("X-QRZ-Username", ""),
+16 -11
View File
@@ -1,5 +1,10 @@
from __future__ import annotations
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, TypeVar
T = TypeVar("T")
# Lookup tables for derived text descriptions. # Lookup tables for derived text descriptions.
# Each threshold-based table is a list of (min_value, description) pairs in descending order; # Each threshold-based table is a list of (min_value, description) pairs in descending order;
@@ -71,7 +76,7 @@ ELECTRON_FLUX_DESCRIPTIONS = [
] ]
def _xray_blackout_scale(xray): def _xray_blackout_scale(xray: str | None) -> int:
"""Return the NOAA Radio Blackout scale number (R0-R5) for the given X-ray flux class string """Return the NOAA Radio Blackout scale number (R0-R5) for the given X-ray flux class string
(e.g. "M4.5", "X12").""" (e.g. "M4.5", "X12")."""
@@ -93,7 +98,7 @@ def _xray_blackout_scale(xray):
return 0 return 0
def _lookup_by_threshold(value, table, default=None): def _lookup_by_threshold(value: int | None, table: list[tuple[int, T]], default: T | None = None) -> T | None:
"""Return the description from a threshold table for the given numeric value. """Return the description from a threshold table for the given numeric value.
The table is a list of (min_value, description) pairs in descending order.""" The table is a list of (min_value, description) pairs in descending order."""
@@ -150,20 +155,20 @@ class SolarConditions:
# Geomagnetic background noise level, e.g. "S0", "S1", "S2" # Geomagnetic background noise level, e.g. "S0", "S1", "S2"
geomag_noise: str | None = None geomag_noise: str | None = None
# HF band propagation conditions, keyed by "{band}-{time}" e.g. "80m-40m-day" # HF band propagation conditions, keyed by "{band}-{time}" e.g. "80m-40m-day"
hf_conditions: dict | None = None hf_conditions: dict[str, str] | None = None
# VHF propagation conditions, keyed by condition name # VHF propagation conditions, keyed by condition name
vhf_conditions: dict | None = None vhf_conditions: dict[str, str | None] | None = None
# NOAA Kp index 3-day forecast, keyed by UNIX timestamp of the start of each 3-hour UTC period # NOAA Kp index 3-day forecast, keyed by UNIX timestamp of the start of each 3-hour UTC period
k_index_forecast: dict | None = None k_index_forecast: dict[float, float] | None = None
# NOAA Solar Radiation Storm (S1 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC # NOAA Solar Radiation Storm (S1 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC
solar_storm_forecast: dict | None = None solar_storm_forecast: dict[float, int] | None = None
# NOAA Radio Blackout (R1-R2) probability forecast, keyed by UNIX timestamp of start of day UTC # NOAA Radio Blackout (R1-R2) probability forecast, keyed by UNIX timestamp of start of day UTC
blackout_forecast_r1r2: dict | None = None blackout_forecast_r1r2: dict[float, int] | None = None
# NOAA Radio Blackout (R3 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC # NOAA Radio Blackout (R3 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC
blackout_forecast_r3_or_greater: dict | None = None blackout_forecast_r3_or_greater: dict[float, int] | None = None
# Ionosonde measurements, dict keyed by URSI code, values are dicts with keys: ursi, name, fof2, muf, luf, # Ionosonde measurements, dict keyed by URSI code, values are dicts with keys: ursi, name, fof2, muf, luf,
# band_states. Populated by GIROIonosonde or KC2GProp providers. # band_states. Populated by GIROIonosonde or KC2GProp providers.
ionosonde_data: dict | None = None ionosonde_data: dict[str, Any] | None = None
# Derived values (populated by infer_descriptions()) # Derived values (populated by infer_descriptions())
# HF radio blackout risk description, derived from xray # HF radio blackout risk description, derived from xray
@@ -183,7 +188,7 @@ class SolarConditions:
# Electron flux description, derived from electron_flux # Electron flux description, derived from electron_flux
electron_flux_desc: str | None = None electron_flux_desc: str | None = None
def infer_descriptions(self): def infer_descriptions(self) -> None:
"""Populate derived text description fields from the current numeric/raw field values.""" """Populate derived text description fields from the current numeric/raw field values."""
if self.xray and len(self.xray) > 0: if self.xray and len(self.xray) > 0:
@@ -196,7 +201,7 @@ class SolarConditions:
self.band_conditions_desc = _lookup_by_threshold(self.sfi, BAND_CONDITIONS_DESCRIPTIONS) self.band_conditions_desc = _lookup_by_threshold(self.sfi, BAND_CONDITIONS_DESCRIPTIONS)
self.electron_flux_desc = _lookup_by_threshold(self.electron_flux, ELECTRON_FLUX_DESCRIPTIONS) self.electron_flux_desc = _lookup_by_threshold(self.electron_flux, ELECTRON_FLUX_DESCRIPTIONS)
def to_json(self): def to_json(self) -> str:
"""JSON serialise. Dict key order is insertion order (Python 3.7+ guarantee), so callers receive """JSON serialise. Dict key order is insertion order (Python 3.7+ guarantee), so callers receive
fields in a predictable, logical sequence without relying on sort_keys.""" fields in a predictable, logical sequence without relying on sort_keys."""
+9 -6
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import hashlib import hashlib
import json import json
import logging import logging
@@ -32,6 +34,7 @@ from core.utils import (
) )
from data.activities import ACTIVITIES from data.activities import ACTIVITIES
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
from data.lookup_credentials import LookupCredentials
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -129,7 +132,7 @@ class Spot:
sig: str | None = None sig: str | None = None
# Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO. Still named # Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO. Still named
# "sig_refs" for API backwards compatibility. # "sig_refs" for API backwards compatibility.
sig_refs: list = field(default_factory=list) sig_refs: list[ActivityRef] = field(default_factory=list)
# Timing info # Timing info
@@ -156,7 +159,7 @@ class Spot:
# Icon to use when displaying this spot in the web UI. Chosen from the Font Awesome set. # Icon to use when displaying this spot in the web UI. Chosen from the Font Awesome set.
icon: str | None = None icon: str | None = None
def __post_init__(self): def __post_init__(self) -> None:
"""Normalise fields that don't survive a plain dict to Spot conversion. This is used in the "add spot" API """Normalise fields that don't survive a plain dict to Spot conversion. This is used in the "add spot" API
endpoint where the client is submitting JSON, and we want to recreate a full Spot object, including nested endpoint where the client is submitting JSON, and we want to recreate a full Spot object, including nested
objects such as the sig_refs list..""" objects such as the sig_refs list.."""
@@ -167,7 +170,7 @@ class Spot:
for activity_ref in self.sig_refs for activity_ref in self.sig_refs
] ]
def infer_missing(self, credentials=None): def infer_missing(self, credentials: LookupCredentials | None = None) -> None:
"""Infer missing parameters where possible""" """Infer missing parameters where possible"""
try: try:
@@ -538,12 +541,12 @@ class Spot:
except Exception: except Exception:
logger.exception("Exception while inferring missing data from spot") logger.exception("Exception while inferring missing data from spot")
def to_json(self): def to_json(self) -> str:
"""JSON serialise""" """JSON serialise"""
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True) return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
def _append_activity_ref_if_missing(self, new_activity_ref): def _append_activity_ref_if_missing(self, new_activity_ref: ActivityRef) -> None:
"""Append an activity ref to the list, so long as it's not already there.""" """Append an activity ref to the list, so long as it's not already there."""
new_activity_ref.id = new_activity_ref.id.strip().upper() new_activity_ref.id = new_activity_ref.id.strip().upper()
@@ -555,7 +558,7 @@ class Spot:
return return
self.sig_refs.append(new_activity_ref) self.sig_refs.append(new_activity_ref)
def expired(self): def expired(self) -> bool:
"""Decide if this spot has expired (in which case it should not be added to the system in the first place, and not """Decide if this spot has expired (in which case it should not be added to the system in the first place, and not
returned by the web server if later requested, and removed by the cleanup functions). "Expired" is defined as returned by the web server if later requested, and removed by the cleanup functions). "Expired" is defined as
either having a time further ago than the server's MAX_SPOT_AGE. If it somehow doesn't have a time either, it is either having a time further ago than the server's MAX_SPOT_AGE. If it somehow doesn't have a time either, it is
@@ -1,10 +1,14 @@
from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
from threading import Event from threading import Event
from typing import Any
import pytz import pytz
from core.data_store import DATA_STORE from core.data_store import DATA_STORE
from data.activity_ref import ActivityRef
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -13,29 +17,29 @@ class ActivityRefDataProvider:
"""Generic activity reference data provider class. Subclasses of this query the individual URLs or files for """Generic activity reference data provider class. Subclasses of this query the individual URLs or files for
data.""" data."""
def __init__(self, sig_name, provider_config): def __init__(self, sig_name: str, provider_config: dict[str, Any]) -> None:
"""Constructor. Note the parameter and attribute are still named "sig_name" for consistency with the API's """Constructor. Note the parameter and attribute are still named "sig_name" for consistency with the API's
"sig" field name.""" "sig" field name."""
self.sig_name = sig_name self.sig_name = sig_name
self.enabled = provider_config["enabled"] self.enabled: bool = provider_config["enabled"]
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC) self.last_update_time: datetime = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled" self.status: str = "Not Started" if self.enabled else "Disabled"
self.reference_count = 0 self.reference_count: int = 0
self._stop_event = Event() self._stop_event = Event()
def start(self): def start(self) -> None:
"""Start the provider. This should return immediately after spawning threads to access the remote resources""" """Start the provider. This should return immediately after spawning threads to access the remote resources"""
raise NotImplementedError("Subclasses must implement this method") raise NotImplementedError("Subclasses must implement this method")
def stop(self): def stop(self) -> None:
"""Stop any threads and prepare for application shutdown. Subclasses should implement this method and call """Stop any threads and prepare for application shutdown. Subclasses should implement this method and call
super().""" super()."""
self._stop_event.set() self._stop_event.set()
def _add_data(self, new_data): def _add_data(self, new_data: list[ActivityRef]) -> None:
"""Add all the provided reference data objects to the data store.""" """Add all the provided reference data objects to the data store."""
# with transact() batches all writes together to save making thousands of individual sqlite writes. However, # with transact() batches all writes together to save making thousands of individual sqlite writes. However,
+8 -3
View File
@@ -1,5 +1,10 @@
from __future__ import annotations
import csv import csv
from time import sleep from time import sleep
from typing import Any
import requests
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -15,11 +20,11 @@ class ARLHS(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.ARLHS ACTIVITY = ActivityName.ARLHS
DATA_URL = "https://www.gma.rocks/download/lighthouse.csv" DATA_URL = "https://www.gma.rocks/download/lighthouse.csv"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
if "ARLHS" in row and row["ARLHS"] != "": if "ARLHS" in row and row["ARLHS"] != "":
ref_id = row["ARLHS"] ref_id = row["ARLHS"]
+8 -4
View File
@@ -1,5 +1,9 @@
from time import sleep from __future__ import annotations
from time import sleep
from typing import Any
import requests
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
@@ -14,11 +18,11 @@ class COTA(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.COTA ACTIVITY = ActivityName.COTA
DATA_URL = "https://www.cotagroup.org/cotagroup/map/data/castles-all-7d90ee2a5e1175e5dece1bbf9dc87504.json" DATA_URL = "https://www.cotagroup.org/cotagroup/map/data/castles-all-7d90ee2a5e1175e5dece1bbf9dc87504.json"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
data = http_response.json() data = http_response.json()
if isinstance(data, list): if isinstance(data, list):
for ref in data[2]: for ref in data[2]:
+7 -3
View File
@@ -1,7 +1,11 @@
from __future__ import annotations
import io import io
from time import sleep from time import sleep
from typing import Any
import pandas as pd import pandas as pd
import requests
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -15,11 +19,11 @@ class DCE(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.DCE ACTIVITY = ActivityName.DCE
DATA_URL = "https://www.acracb.org/dce/descargas/General/directorio_referencias_dce.xls" DATA_URL = "https://www.acracb.org/dce/descargas/General/directorio_referencias_dce.xls"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
file_stream = io.BytesIO(http_response.content) file_stream = io.BytesIO(http_response.content)
df = pd.read_excel(file_stream, engine="xlrd", header=None) df = pd.read_excel(file_stream, engine="xlrd", header=None)
+7 -3
View File
@@ -1,7 +1,11 @@
from __future__ import annotations
import io import io
from time import sleep from time import sleep
from typing import Any
import pandas as pd import pandas as pd
import requests
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -15,11 +19,11 @@ class DEFE(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.DEFE ACTIVITY = ActivityName.DEFE
DATA_URL = "https://www.acracb.org/defe/descargas/General/directorio_referencias_defe.xls" DATA_URL = "https://www.acracb.org/defe/descargas/General/directorio_referencias_defe.xls"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
file_stream = io.BytesIO(http_response.content) file_stream = io.BytesIO(http_response.content)
df = pd.read_excel(file_stream, engine="xlrd", header=None) df = pd.read_excel(file_stream, engine="xlrd", header=None)
+6 -3
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
import csv import csv
from time import sleep from time import sleep
from typing import Any
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
@@ -16,11 +19,11 @@ class DME(LocalFileActivityRefDataProvider):
ACTIVITY = ActivityName.DME ACTIVITY = ActivityName.DME
PATH = "datafiles/MUNICIPIOS.csv" PATH = "datafiles/MUNICIPIOS.csv"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.PATH) super().__init__(self.ACTIVITY, provider_config, self.PATH)
def _file_to_data(self, path): def _file_to_data(self, path: str) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
with open(path, encoding="latin-1") as _f: with open(path, encoding="latin-1") as _f:
for row in csv.DictReader(_f, delimiter=";"): for row in csv.DictReader(_f, delimiter=";"):
# Store reference IDs with the "DME-" prefix rather than just the number. This will prevent Spothole # Store reference IDs with the "DME-" prefix rather than just the number. This will prevent Spothole
+8 -3
View File
@@ -1,5 +1,10 @@
from __future__ import annotations
import csv import csv
from time import sleep from time import sleep
from typing import Any
import requests
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -13,11 +18,11 @@ class DMUE(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.DMUE ACTIVITY = ActivityName.DMUE
DATA_URL = "https://dmue.radiogalena.es/nom_dmue.csv" DATA_URL = "https://dmue.radiogalena.es/nom_dmue.csv"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
for row in csv.reader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"): for row in csv.reader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"):
if len(row) > 1 and row[0] and row[1]: if len(row) > 1 and row[0] and row[1]:
+7 -3
View File
@@ -1,7 +1,11 @@
from __future__ import annotations
import io import io
from time import sleep from time import sleep
from typing import Any
import pandas as pd import pandas as pd
import requests
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -15,11 +19,11 @@ class DMVE(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.DMVE ACTIVITY = ActivityName.DMVE
DATA_URL = "https://www.acracb.org/dmve/descargas/General/directorio_referencias_dmve.xls" DATA_URL = "https://www.acracb.org/dmve/descargas/General/directorio_referencias_dmve.xls"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
file_stream = io.BytesIO(http_response.content) file_stream = io.BytesIO(http_response.content)
# Despide the .xls extension this is actually an xlsx file, so we need openpyxl not xlrd # Despide the .xls extension this is actually an xlsx file, so we need openpyxl not xlrd
+8 -3
View File
@@ -1,4 +1,9 @@
from __future__ import annotations
from time import sleep from time import sleep
from typing import Any
import requests
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -12,11 +17,11 @@ class DTMBA(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.DTMBA ACTIVITY = ActivityName.DTMBA
DATA_URL = "https://www.iu1fig.com/share/iz0eik/dtmba/export.php" DATA_URL = "https://www.iu1fig.com/share/iz0eik/dtmba/export.php"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
for row in http_response.content.decode("utf-8-sig").splitlines(): for row in http_response.content.decode("utf-8-sig").splitlines():
split = row.split(";") split = row.split(";")
ref_id = split[0] ref_id = split[0]
+7 -3
View File
@@ -1,7 +1,11 @@
from __future__ import annotations
from io import BytesIO from io import BytesIO
from time import sleep from time import sleep
from typing import Any
import pdfplumber import pdfplumber
import requests
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -15,11 +19,11 @@ class FEA(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.FEA ACTIVITY = ActivityName.FEA
DATA_URL = "http://ea5ol.net/Lista%20Faros.pdf" DATA_URL = "http://ea5ol.net/Lista%20Faros.pdf"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
# Use PDFPlumber to extract the tables in the PDF # Use PDFPlumber to extract the tables in the PDF
with pdfplumber.open(BytesIO(http_response.content)) as pdf: with pdfplumber.open(BytesIO(http_response.content)) as pdf:
@@ -1,12 +1,17 @@
from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
from threading import Thread from threading import Thread
from typing import Any
import pytz import pytz
import requests
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.url_data_cache import URLDataCache from core.url_data_cache import URLDataCache
from data.activity_ref import ActivityRef
from providers.activityrefdata.activity_ref_data_provider import ActivityRefDataProvider from providers.activityrefdata.activity_ref_data_provider import ActivityRefDataProvider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -16,35 +21,35 @@ class FileDownloadActivityRefDataProvider(ActivityRefDataProvider):
"""Generic activity ref data provider class for providers that fetch their data from the web by downloading a """Generic activity ref data provider class for providers that fetch their data from the web by downloading a
file.""" file."""
def __init__(self, sig_name, provider_config, url, poll_interval): def __init__(self, sig_name: str, provider_config: dict[str, Any], url: str, poll_interval: float) -> None:
"""Set up the provider, note poll_interval is in *days*.""" """Set up the provider, note poll_interval is in *days*."""
super().__init__(sig_name, provider_config) super().__init__(sig_name, provider_config)
self._url = url self._url = url
self._poll_interval = poll_interval self._poll_interval = poll_interval
self._thread = None self._thread: Thread | None = None
self._url_data_cache = URLDataCache(f"activity_ref_data_{sig_name}") self._url_data_cache = URLDataCache(f"activity_ref_data_{sig_name}")
def start(self): def start(self) -> None:
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between # Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting. # subsequent polls, so start() returns immediately and the application can continue starting.
logger.info(f"Set up query of {self.sig_name} activity ref data every {self._poll_interval!s} days.") logger.info(f"Set up query of {self.sig_name} activity ref data every {self._poll_interval!s} days.")
self._thread = Thread(target=self._run, name=f"FileDownloadActivityRefDataProvider-{self.sig_name}", daemon=True) self._thread = Thread(target=self._run, name=f"FileDownloadActivityRefDataProvider-{self.sig_name}", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self) -> None:
super().stop() super().stop()
if self._thread: if self._thread:
self._thread.join(timeout=12) self._thread.join(timeout=12)
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning(f"{self.sig_name} activity ref data worker thread did not exit on time and will be killed.") logger.warning(f"{self.sig_name} activity ref data worker thread did not exit on time and will be killed.")
def _run(self): def _run(self) -> None:
while True: while True:
self._poll() self._poll()
if self._stop_event.wait(timeout=self._poll_interval * 60 * 60 * 24): if self._stop_event.wait(timeout=self._poll_interval * 60 * 60 * 24):
break break
def _poll(self): def _poll(self) -> None:
try: try:
# Request data from API. Use the data cache (with a TTL of 1 day) here, not as the main mechanism for # Request data from API. Use the data cache (with a TTL of 1 day) here, not as the main mechanism for
# caching, but just so continual restarts of the software during testing don't hammer the servers. # caching, but just so continual restarts of the software during testing don't hammer the servers.
@@ -76,7 +81,7 @@ class FileDownloadActivityRefDataProvider(ActivityRefDataProvider):
logger.exception(f"Exception in HTTP Activity Ref Data Provider ({self.sig_name})") logger.exception(f"Exception in HTTP Activity Ref Data Provider ({self.sig_name})")
self._stop_event.wait(timeout=1) self._stop_event.wait(timeout=1)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
"""Convert an HTTP response returned by the server into activity ref data. The whole response is provided here """Convert an HTTP response returned by the server into activity ref data. The whole response is provided here
so the subclass implementations can check for HTTP status codes if necessary, and handle the response as so the subclass implementations can check for HTTP status codes if necessary, and handle the response as
JSON, CSV, whatever the remote file actually is.""" JSON, CSV, whatever the remote file actually is."""
+8 -3
View File
@@ -1,5 +1,10 @@
from __future__ import annotations
import csv import csv
from time import sleep from time import sleep
from typing import Any
import requests
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -15,11 +20,11 @@ class GMA(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.GMA ACTIVITY = ActivityName.GMA
DATA_URL = "https://www.gma.rocks/download/summits.csv" DATA_URL = "https://www.gma.rocks/download/summits.csv"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
ref_id = row["Reference"] ref_id = row["Reference"]
new_data.append( new_data.append(
+8 -3
View File
@@ -1,5 +1,10 @@
from __future__ import annotations
import csv import csv
from time import sleep from time import sleep
from typing import Any
import requests
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -15,11 +20,11 @@ class ILLW(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.ILLW ACTIVITY = ActivityName.ILLW
DATA_URL = "https://www.gma.rocks/download/lighthouse.csv" DATA_URL = "https://www.gma.rocks/download/lighthouse.csv"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
if "ILLW" in row and row["ILLW"] != "": if "ILLW" in row and row["ILLW"] != "":
ref_id = row["ILLW"] ref_id = row["ILLW"]
+7 -3
View File
@@ -1,6 +1,10 @@
from __future__ import annotations
import logging import logging
from time import sleep from time import sleep
from typing import Any
import requests
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
@@ -19,11 +23,11 @@ class IOTA(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.IOTA ACTIVITY = ActivityName.IOTA
DATA_URL = "https://www.iota-world.org/islands-on-the-air/downloads/download-file.html?path=groups.json" DATA_URL = "https://www.iota-world.org/islands-on-the-air/downloads/download-file.html?path=groups.json"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
data = http_response.json() data = http_response.json()
if isinstance(data, list): if isinstance(data, list):
for ref in data: for ref in data:
+5 -1
View File
@@ -1,3 +1,7 @@
from __future__ import annotations
from typing import Any
from core.enums import ActivityName from core.enums import ActivityName
from providers.activityrefdata.pnp_kml_activity_ref_data_provider import ( from providers.activityrefdata.pnp_kml_activity_ref_data_provider import (
ParksNPeaksKMLActivityRefDataProvider, ParksNPeaksKMLActivityRefDataProvider,
@@ -11,5 +15,5 @@ class KRMNPA(ParksNPeaksKMLActivityRefDataProvider):
ACTIVITY = ActivityName.KRMNPA ACTIVITY = ActivityName.KRMNPA
DATA_URL = "https://parksnpeaks.org/getPOI.php?poiClass=KRMNPA&poiFormat=4" DATA_URL = "https://parksnpeaks.org/getPOI.php?poiClass=KRMNPA&poiFormat=4"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
+8 -4
View File
@@ -1,5 +1,9 @@
from time import sleep from __future__ import annotations
from time import sleep
from typing import Any
import requests
from pyhamtools.locator import locator_to_latlong from pyhamtools.locator import locator_to_latlong
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
@@ -16,11 +20,11 @@ class LLOTA(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.LLOTA ACTIVITY = ActivityName.LLOTA
DATA_URL = "https://llota.app/api/public/references" DATA_URL = "https://llota.app/api/public/references"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
data = http_response.json() data = http_response.json()
if isinstance(data, list): if isinstance(data, list):
for ref in data: for ref in data:
@@ -1,8 +1,12 @@
from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
from data.activity_ref import ActivityRef
from providers.activityrefdata.activity_ref_data_provider import ActivityRefDataProvider from providers.activityrefdata.activity_ref_data_provider import ActivityRefDataProvider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -11,11 +15,11 @@ logger = logging.getLogger(__name__)
class LocalFileActivityRefDataProvider(ActivityRefDataProvider): class LocalFileActivityRefDataProvider(ActivityRefDataProvider):
"""Generic activity ref data provider class for providers that fetch their data from a local file on startup.""" """Generic activity ref data provider class for providers that fetch their data from a local file on startup."""
def __init__(self, sig_name, provider_config, path): def __init__(self, sig_name: str, provider_config: dict[str, Any], path: str) -> None:
super().__init__(sig_name, provider_config) super().__init__(sig_name, provider_config)
self._path = path self._path = path
def start(self): def start(self) -> None:
logger.debug(f"Loading {self.sig_name} activity ref data from file.") logger.debug(f"Loading {self.sig_name} activity ref data from file.")
try: try:
new_data = self._file_to_data(self._path) new_data = self._file_to_data(self._path)
@@ -30,7 +34,7 @@ class LocalFileActivityRefDataProvider(ActivityRefDataProvider):
self.status = "Error" self.status = "Error"
logger.exception(f"Exception in local file Activity Ref Data Provider ({self.sig_name})") logger.exception(f"Exception in local file Activity Ref Data Provider ({self.sig_name})")
def _file_to_data(self, path): def _file_to_data(self, path: str) -> list[ActivityRef]:
"""Load a file on the given path and turn it into activity ref data.""" """Load a file on the given path and turn it into activity ref data."""
raise NotImplementedError("Subclasses must implement this method") raise NotImplementedError("Subclasses must implement this method")
+8 -3
View File
@@ -1,5 +1,10 @@
from __future__ import annotations
import csv import csv
from time import sleep from time import sleep
from typing import Any
import requests
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -15,11 +20,11 @@ class MOTA(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.MOTA ACTIVITY = ActivityName.MOTA
DATA_URL = "https://www.gma.rocks/download/mills.csv" DATA_URL = "https://www.gma.rocks/download/mills.csv"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
ref_id = row["Reference"] ref_id = row["Reference"]
new_data.append( new_data.append(
+8 -4
View File
@@ -1,5 +1,9 @@
from time import sleep from __future__ import annotations
from time import sleep
from typing import Any
import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
@@ -14,11 +18,11 @@ class PGA(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.PGA ACTIVITY = ActivityName.PGA
DATA_URL = "http://www.spga.pl/lista_pga2.php" DATA_URL = "http://www.spga.pl/lista_pga2.php"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
soup = BeautifulSoup(http_response.text, "html.parser") soup = BeautifulSoup(http_response.text, "html.parser")
# Iterate through tables in the page # Iterate through tables in the page
@@ -1,6 +1,10 @@
from __future__ import annotations
import re import re
from time import sleep from time import sleep
from typing import Any
import requests
from fastkml import kml from fastkml import kml
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
@@ -17,12 +21,12 @@ class ParksNPeaksKMLActivityRefDataProvider(FileDownloadActivityRefDataProvider)
REF_PATTERN = re.compile(r"VKFF-\d+") REF_PATTERN = re.compile(r"VKFF-\d+")
def __init__(self, sig_name, provider_config, url, poll_interval): def __init__(self, sig_name: str, provider_config: dict[str, Any], url: str, poll_interval: float) -> None:
"""Set up the provider, note poll_interval is in *days*.""" """Set up the provider, note poll_interval is in *days*."""
super().__init__(sig_name, provider_config, url, poll_interval) super().__init__(sig_name, provider_config, url, poll_interval)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
k = kml.KML.from_string(http_response.content) k = kml.KML.from_string(http_response.content)
+8 -3
View File
@@ -1,5 +1,10 @@
from __future__ import annotations
import csv import csv
from time import sleep from time import sleep
from typing import Any
import requests
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -15,11 +20,11 @@ class POTA(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.POTA ACTIVITY = ActivityName.POTA
DATA_URL = "https://pota.app/all_parks_ext.csv" DATA_URL = "https://pota.app/all_parks_ext.csv"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
ref_id = row["reference"] ref_id = row["reference"]
new_data.append( new_data.append(
+5 -1
View File
@@ -1,3 +1,7 @@
from __future__ import annotations
from typing import Any
from core.enums import ActivityName from core.enums import ActivityName
from providers.activityrefdata.pnp_kml_activity_ref_data_provider import ( from providers.activityrefdata.pnp_kml_activity_ref_data_provider import (
ParksNPeaksKMLActivityRefDataProvider, ParksNPeaksKMLActivityRefDataProvider,
@@ -11,5 +15,5 @@ class SANPCPA(ParksNPeaksKMLActivityRefDataProvider):
ACTIVITY = ActivityName.SANPCPA ACTIVITY = ActivityName.SANPCPA
DATA_URL = "https://parksnpeaks.org/getPOI.php?poiClass=SANPCPA&poiFormat=4" DATA_URL = "https://parksnpeaks.org/getPOI.php?poiClass=SANPCPA&poiFormat=4"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
+8 -3
View File
@@ -1,5 +1,10 @@
from __future__ import annotations
import csv import csv
from time import sleep from time import sleep
from typing import Any
import requests
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -15,11 +20,11 @@ class SIOTA(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.SIOTA ACTIVITY = ActivityName.SIOTA
DATA_URL = "https://www.silosontheair.com/data/silos.csv" DATA_URL = "https://www.silosontheair.com/data/silos.csv"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
ref_id = row["SILO_CODE"] ref_id = row["SILO_CODE"]
new_data.append( new_data.append(
+7 -3
View File
@@ -1,6 +1,10 @@
from __future__ import annotations
import csv import csv
from time import sleep from time import sleep
from typing import Any
import requests
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
@@ -17,11 +21,11 @@ class SOTA(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.SOTA ACTIVITY = ActivityName.SOTA
DATA_URL = "https://storage.sota.org.uk/summitslist.csv" DATA_URL = "https://storage.sota.org.uk/summitslist.csv"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
ref_id = row["SummitCode"] ref_id = row["SummitCode"]
latitude = float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None latitude = float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None
+6 -3
View File
@@ -1,4 +1,7 @@
from __future__ import annotations
import csv import csv
from typing import Any
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -13,11 +16,11 @@ class Toilets(LocalFileActivityRefDataProvider):
ACTIVITY = ActivityName.TOILETS ACTIVITY = ActivityName.TOILETS
PATH = "datafiles/toilets.csv" PATH = "datafiles/toilets.csv"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.PATH) super().__init__(self.ACTIVITY, provider_config, self.PATH)
def _file_to_data(self, path): def _file_to_data(self, path: str) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
with open(path) as _f: with open(path) as _f:
csv_data = _f.read() csv_data = _f.read()
dr = csv.DictReader(csv_data.splitlines()) dr = csv.DictReader(csv_data.splitlines())
+8 -3
View File
@@ -1,5 +1,10 @@
from __future__ import annotations
import csv import csv
from time import sleep from time import sleep
from typing import Any
import requests
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -15,11 +20,11 @@ class Towers(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.TOWERS ACTIVITY = ActivityName.TOWERS
DATA_URL = "https://wwtota.com/servis/generate_csv.php?ref=&filter=all" DATA_URL = "https://wwtota.com/servis/generate_csv.php?ref=&filter=all"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"):
ref_id = row["Ref"] ref_id = row["Ref"]
new_data.append( new_data.append(
+7 -3
View File
@@ -1,7 +1,11 @@
from __future__ import annotations
import csv import csv
import logging import logging
from time import sleep from time import sleep
from typing import Any
import requests
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
@@ -20,11 +24,11 @@ class WCA(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.WCA ACTIVITY = ActivityName.WCA
DATA_URL = "https://polo.ham2k.com/data/activities/wca/all-castles.csv" DATA_URL = "https://polo.ham2k.com/data/activities/wca/all-castles.csv"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
ref_id = row["REF"] ref_id = row["REF"]
+8 -3
View File
@@ -1,4 +1,9 @@
from __future__ import annotations
from time import sleep from time import sleep
from typing import Any
import requests
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -14,11 +19,11 @@ class WOTA(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.WOTA ACTIVITY = ActivityName.WOTA
DATA_URL = "https://www.wota.org.uk/mapping/data/summits.json" DATA_URL = "https://www.wota.org.uk/mapping/data/summits.json"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
for feature in http_response.json().get("features", []): for feature in http_response.json().get("features", []):
ref_id = feature["properties"]["wotaId"] ref_id = feature["properties"]["wotaId"]
# Fudge WOTA URLs. Outlying fell (LDO) URLs don't match their ID numbers but require 214 to be # Fudge WOTA URLs. Outlying fell (LDO) URLs don't match their ID numbers but require 214 to be
+8 -3
View File
@@ -1,5 +1,10 @@
from __future__ import annotations
import csv import csv
from time import sleep from time import sleep
from typing import Any
import requests
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -15,11 +20,11 @@ class WWBOTA(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.WWBOTA ACTIVITY = ActivityName.WWBOTA
DATA_URL = "https://api.wwbota.org/bunkers/?format=CSV" DATA_URL = "https://api.wwbota.org/bunkers/?format=CSV"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
ref_id = row["Reference"] ref_id = row["Reference"]
new_data.append( new_data.append(
+8 -3
View File
@@ -1,5 +1,10 @@
from __future__ import annotations
import csv import csv
from time import sleep from time import sleep
from typing import Any
import requests
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -15,11 +20,11 @@ class WWFF(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.WWFF ACTIVITY = ActivityName.WWFF
DATA_URL = "https://wwff.co/wwff-data/wwff_directory.csv" DATA_URL = "https://wwff.co/wwff-data/wwff_directory.csv"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
ref_id = row["reference"] ref_id = row["reference"]
new_data.append( new_data.append(
+8 -4
View File
@@ -1,5 +1,9 @@
from time import sleep from __future__ import annotations
from time import sleep
from typing import Any
import requests
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
@@ -16,11 +20,11 @@ class ZLOTA(FileDownloadActivityRefDataProvider):
ACTIVITY = ActivityName.ZLOTA ACTIVITY = ActivityName.ZLOTA
DATA_URL = "https://ontheair.nz/assets/assets.json" DATA_URL = "https://ontheair.nz/assets/assets.json"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data = [] new_data: list[ActivityRef] = []
data = http_response.json() data = http_response.json()
if isinstance(data, list): if isinstance(data, list):
for ref in data: for ref in data:
+15 -6
View File
@@ -1,28 +1,37 @@
from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Any
import pytz import pytz
from core.data_store import DATA_STORE from core.data_store import DATA_STORE
from core.live_data_cache import LiveDataCache
if TYPE_CHECKING:
# Deferred to avoid a circular import: data.alert imports core.call_lookup_helper, which imports
# core.data_providers, which imports this module.
from data.alert import Alert
class AlertProvider: class AlertProvider:
"""Generic alert provider class. Subclasses of this query the individual APIs for alerts.""" """Generic alert provider class. Subclasses of this query the individual APIs for alerts."""
def __init__(self, name, provider_config): def __init__(self, name: str, provider_config: dict[str, Any]) -> None:
"""Constructor""" """Constructor"""
self.name = name self.name = name
self.enabled = provider_config.get("enabled", True) self.enabled = provider_config.get("enabled", True)
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC) self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled" self.status = "Not Started" if self.enabled else "Disabled"
self._alerts = DATA_STORE.alerts self._alerts: LiveDataCache[Alert] = DATA_STORE.alerts
def start(self): def start(self) -> None:
"""Start the provider. This should return immediately after spawning threads to access the remote resources""" """Start the provider. This should return immediately after spawning threads to access the remote resources"""
raise NotImplementedError("Subclasses must implement this method") raise NotImplementedError("Subclasses must implement this method")
def _submit_batch(self, alerts): def _submit_batch(self, alerts: list[Alert]) -> None:
"""Submit a batch of alerts retrieved from the provider. There is no timestamp checking like there is for spots, """Submit a batch of alerts retrieved from the provider. There is no timestamp checking like there is for spots,
because alerts could be created at any point for any time in the future. Rely on hashcode-based id matching because alerts could be created at any point for any time in the future. Rely on hashcode-based id matching
to deal with duplicates.""" to deal with duplicates."""
@@ -35,11 +44,11 @@ class AlertProvider:
alert.infer_missing() alert.infer_missing()
self._add_alert(alert) self._add_alert(alert)
def _add_alert(self, alert): def _add_alert(self, alert: Alert) -> None:
if not alert.expired(): if not alert.expired():
self._alerts.set(alert.id, alert) self._alerts.set(alert.id, alert)
def stop(self): def stop(self) -> None:
"""Stop any threads and prepare for application shutdown""" """Stop any threads and prepare for application shutdown"""
raise NotImplementedError("Subclasses must implement this method") raise NotImplementedError("Subclasses must implement this method")
+6 -2
View File
@@ -1,6 +1,10 @@
from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any
import pytz import pytz
import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from core.enums import ActivityName from core.enums import ActivityName
@@ -15,10 +19,10 @@ class BOTA(HTTPAlertProvider):
POLL_INTERVAL_SEC = 1800 POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://www.beachesontheair.com/" ALERTS_URL = "https://www.beachesontheair.com/"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("BOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__("BOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
new_alerts = [] new_alerts = []
# Find the table of upcoming alerts # Find the table of upcoming alerts
bs = BeautifulSoup(http_response.content.decode("utf-8-sig"), features="lxml") bs = BeautifulSoup(http_response.content.decode("utf-8-sig"), features="lxml")
+6 -2
View File
@@ -1,6 +1,10 @@
from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
import requests
from core.enums import ActivityName from core.enums import ActivityName
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -14,10 +18,10 @@ class Hamsat(HTTPAlertProvider):
POLL_INTERVAL_SEC = 1800 POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://hams.at/api/alerts" ALERTS_URL = "https://hams.at/api/alerts"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("Hamsat", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__("Hamsat", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
new_alerts = [] new_alerts = []
# Iterate through source data # Iterate through source data
for source_alert in http_response.json()["data"]: for source_alert in http_response.json()["data"]:
+11 -7
View File
@@ -1,12 +1,16 @@
from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
from threading import Event, Thread from threading import Event, Thread
from typing import Any
import pytz import pytz
import requests import requests
from requests.exceptions import ConnectionError, ConnectTimeout, JSONDecodeError, ReadTimeout from requests.exceptions import ConnectionError, ConnectTimeout, JSONDecodeError, ReadTimeout
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from data.alert import Alert
from providers.alert.alert_provider import AlertProvider from providers.alert.alert_provider import AlertProvider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -16,34 +20,34 @@ class HTTPAlertProvider(AlertProvider):
"""Generic alert provider class for providers that request data via HTTP(S). Just for convenience to avoid code """Generic alert provider class for providers that request data via HTTP(S). Just for convenience to avoid code
duplication. Subclasses of this query the individual APIs for data.""" duplication. Subclasses of this query the individual APIs for data."""
def __init__(self, name, provider_config, url, poll_interval): def __init__(self, name: str, provider_config: dict[str, Any], url: str, poll_interval: int) -> None:
super().__init__(name, provider_config) super().__init__(name, provider_config)
self._url = url self._url = url
self._poll_interval = poll_interval self._poll_interval = poll_interval
self._thread = None self._thread: Thread | None = None
self._stop_event = Event() self._stop_event = Event()
def start(self): def start(self) -> None:
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between # Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting. # subsequent polls, so start() returns immediately and the application can continue starting.
logger.info(f"Set up query of {self.name} alert API every {self._poll_interval!s} seconds.") logger.info(f"Set up query of {self.name} alert API every {self._poll_interval!s} seconds.")
self._thread = Thread(target=self._run, name=f"HTTPAlertProvider-{self.name}", daemon=True) self._thread = Thread(target=self._run, name=f"HTTPAlertProvider-{self.name}", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self) -> None:
self._stop_event.set() self._stop_event.set()
if self._thread: if self._thread:
self._thread.join(timeout=12) self._thread.join(timeout=12)
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning(f"{self.name} alert worker thread did not exit on time and will be killed.") logger.warning(f"{self.name} alert worker thread did not exit on time and will be killed.")
def _run(self): def _run(self) -> None:
while True: while True:
self._poll() self._poll()
if self._stop_event.wait(timeout=self._poll_interval): if self._stop_event.wait(timeout=self._poll_interval):
break break
def _poll(self): def _poll(self) -> None:
try: try:
# Request data from API # Request data from API
logger.debug(f"Polling {self.name} alert API...") logger.debug(f"Polling {self.name} alert API...")
@@ -78,7 +82,7 @@ class HTTPAlertProvider(AlertProvider):
# Brief pause on error before the next poll, but still respond promptly to stop() # Brief pause on error before the next poll, but still respond promptly to stop()
self._stop_event.wait(timeout=1) self._stop_event.wait(timeout=1)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
"""Convert an HTTP response returned by the API into alert data. The whole response is provided here so the subclass """Convert an HTTP response returned by the API into alert data. The whole response is provided here so the subclass
implementations can check for HTTP status codes if necessary, and handle the response as JSON, XML, text, whatever implementations can check for HTTP status codes if necessary, and handle the response as JSON, XML, text, whatever
the API actually provides.""" the API actually provides."""
+8 -5
View File
@@ -1,7 +1,10 @@
from datetime import datetime, time from __future__ import annotations
from typing import cast
from datetime import date, datetime, time
from typing import Any, cast
import pytz import pytz
import requests
from icalendar import Calendar, Event from icalendar import Calendar, Event
from data.alert import Alert from data.alert import Alert
@@ -12,10 +15,10 @@ class ICALAlertProvider(HTTPAlertProvider):
"""Generic alert provider for iCal calendars. Defines an abstract method event_to_alert(event) that subclasses must """Generic alert provider for iCal calendars. Defines an abstract method event_to_alert(event) that subclasses must
implement, and use it to convert an iCal event to an Alert object based on whatever format their iCal events use.""" implement, and use it to convert an iCal event to an Alert object based on whatever format their iCal events use."""
def __init__(self, name, provider_config, url, poll_interval): def __init__(self, name: str, provider_config: dict[str, Any], url: str, poll_interval: int) -> None:
super().__init__(name, provider_config, url, poll_interval) super().__init__(name, provider_config, url, poll_interval)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
new_alerts = [] new_alerts = []
cal = Calendar.from_ical(http_response.content) cal = Calendar.from_ical(http_response.content)
@@ -34,7 +37,7 @@ class ICALAlertProvider(HTTPAlertProvider):
"""Convert an ICal event to an Alert object. Subclasses must implement this method.""" """Convert an ICal event to an Alert object. Subclasses must implement this method."""
@staticmethod @staticmethod
def _to_utc_timestamp(value): def _to_utc_timestamp(value: datetime | date) -> float:
"""Convert a date or datetime value from an iCal field into a UTC UNIX timestamp.""" """Convert a date or datetime value from an iCal field into a UTC UNIX timestamp."""
# Datetime object so we can treat it as-is, check if it has a non-UTC tz and convert it if necessary # Datetime object so we can treat it as-is, check if it has a non-UTC tz and convert it if necessary
+6 -3
View File
@@ -1,8 +1,11 @@
from __future__ import annotations
import re import re
from datetime import datetime from datetime import datetime
from typing import cast from typing import Any, cast
import pytz import pytz
import requests
from rss_parser import Parser from rss_parser import Parser
from rss_parser.models.rss import RSS from rss_parser.models.rss import RSS
@@ -18,10 +21,10 @@ class NG3K(HTTPAlertProvider):
ALERTS_URL = "https://www.ng3k.com/adxo.xml" ALERTS_URL = "https://www.ng3k.com/adxo.xml"
AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE) AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE)
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60) super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
new_alerts = [] new_alerts = []
rss = cast(RSS, Parser.parse(http_response.content.decode("utf-8-sig"))) rss = cast(RSS, Parser.parse(http_response.content.decode("utf-8-sig")))
# Iterate through source data # Iterate through source data
+6 -2
View File
@@ -1,7 +1,11 @@
from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
import requests
from core.enums import ActivityName from core.enums import ActivityName
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -17,10 +21,10 @@ class ParksNPeaks(HTTPAlertProvider):
POLL_INTERVAL_SEC = 1800 POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://parksnpeaks.org/api/ALERTS/" ALERTS_URL = "https://parksnpeaks.org/api/ALERTS/"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("ParksNPeaks", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__("ParksNPeaks", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
new_alerts = [] new_alerts = []
# Iterate through source data # Iterate through source data
for source_alert in http_response.json(): for source_alert in http_response.json():
+6 -2
View File
@@ -1,6 +1,10 @@
from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
import requests
from core.enums import ActivityName from core.enums import ActivityName
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -14,10 +18,10 @@ class POTA(HTTPAlertProvider):
POLL_INTERVAL_SEC = 1800 POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://api.pota.app/activation" ALERTS_URL = "https://api.pota.app/activation"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("POTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__("POTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
new_alerts = [] new_alerts = []
# Iterate through source data # Iterate through source data
for source_alert in http_response.json(): for source_alert in http_response.json():
+4 -1
View File
@@ -1,4 +1,7 @@
from __future__ import annotations
import re import re
from typing import Any
from icalendar import Event from icalendar import Event
@@ -12,7 +15,7 @@ class RSGBICALAlertProvider(ICALAlertProvider):
handling specific to how RSGB's iCal events are formatted. This is still effectively an abstract class itself; handling specific to how RSGB's iCal events are formatted. This is still effectively an abstract class itself;
RSGB has two contest calendars (HF & VHF) that each subclass this.""" RSGB has two contest calendars (HF & VHF) that each subclass this."""
def __init__(self, name, provider_config, url, poll_interval): def __init__(self, name: str, provider_config: dict[str, Any], url: str, poll_interval: int) -> None:
super().__init__(name, provider_config, url, poll_interval) super().__init__(name, provider_config, url, poll_interval)
FREQ_PATTERN = re.compile(r"([\d.]+(?:MHz|GHz))|SHF") FREQ_PATTERN = re.compile(r"([\d.]+(?:MHz|GHz))|SHF")
+5 -1
View File
@@ -1,3 +1,7 @@
from __future__ import annotations
from typing import Any
from providers.alert.rsgb_ical_alert_provider import RSGBICALAlertProvider from providers.alert.rsgb_ical_alert_provider import RSGBICALAlertProvider
@@ -7,5 +11,5 @@ class RSGBHFContests(RSGBICALAlertProvider):
POLL_INTERVAL_DAYS = 30 POLL_INTERVAL_DAYS = 30
ALERTS_URL = "https://calendar.google.com/calendar/ical/a5ff31ebb1b4834dc7fff4c5415ae8251c6a9aa11f98c6af6e472b6c552b1915%40group.calendar.google.com/public/basic.ics" ALERTS_URL = "https://calendar.google.com/calendar/ical/a5ff31ebb1b4834dc7fff4c5415ae8251c6a9aa11f98c6af6e472b6c552b1915%40group.calendar.google.com/public/basic.ics"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("RSGB HF Contests", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60) super().__init__("RSGB HF Contests", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
+5 -1
View File
@@ -1,3 +1,7 @@
from __future__ import annotations
from typing import Any
from providers.alert.rsgb_ical_alert_provider import RSGBICALAlertProvider from providers.alert.rsgb_ical_alert_provider import RSGBICALAlertProvider
@@ -7,5 +11,5 @@ class RSGBVHFContests(RSGBICALAlertProvider):
POLL_INTERVAL_DAYS = 30 POLL_INTERVAL_DAYS = 30
ALERTS_URL = "https://calendar.google.com/calendar/ical/40f3552bff39a016f1cdca205864177070dcad68d55be17eb061cb021f39f96c%40group.calendar.google.com/public/basic.ics" ALERTS_URL = "https://calendar.google.com/calendar/ical/40f3552bff39a016f1cdca205864177070dcad68d55be17eb061cb021f39f96c%40group.calendar.google.com/public/basic.ics"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("RSGB VHF Contests", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60) super().__init__("RSGB VHF Contests", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
+6 -2
View File
@@ -1,6 +1,10 @@
from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
import requests
from core.enums import ActivityName from core.enums import ActivityName
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -14,10 +18,10 @@ class SOTA(HTTPAlertProvider):
POLL_INTERVAL_SEC = 1800 POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://api-db2.sota.org.uk/api/alerts/365/all/all" ALERTS_URL = "https://api-db2.sota.org.uk/api/alerts/365/all/all"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("SOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__("SOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
new_alerts = [] new_alerts = []
# Iterate through source data # Iterate through source data
for source_alert in http_response.json(): for source_alert in http_response.json():
+5 -1
View File
@@ -1,3 +1,7 @@
from __future__ import annotations
from typing import Any
from icalendar import Event from icalendar import Event
from core.enums import ActivityName from core.enums import ActivityName
@@ -11,7 +15,7 @@ class WA7BNM(ICALAlertProvider):
POLL_INTERVAL_DAYS = 1 POLL_INTERVAL_DAYS = 1
ALERTS_URL = "https://contestcalendar.com/weeklycontcustom.php" ALERTS_URL = "https://contestcalendar.com/weeklycontcustom.php"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__( super().__init__(
"WA7BNM Contest Calendar", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60 "WA7BNM Contest Calendar", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60
) )
+6 -3
View File
@@ -1,9 +1,12 @@
from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
from typing import cast from typing import Any, cast
from xml.parsers.expat import ExpatError from xml.parsers.expat import ExpatError
import pytz import pytz
import requests
from rss_parser import Parser as RSSParser from rss_parser import Parser as RSSParser
from rss_parser.models.rss import RSS from rss_parser.models.rss import RSS
@@ -22,10 +25,10 @@ class WOTA(HTTPAlertProvider):
ALERTS_URL = "https://www.wota.org.uk/alerts_rss.php" ALERTS_URL = "https://www.wota.org.uk/alerts_rss.php"
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z" RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("WOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__("WOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
new_alerts = [] new_alerts = []
try: try:
+6 -2
View File
@@ -1,6 +1,10 @@
from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
import requests
from core.enums import ActivityName from core.enums import ActivityName
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -14,10 +18,10 @@ class WWFF(HTTPAlertProvider):
POLL_INTERVAL_SEC = 1800 POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://spots.wwff.co/static/agendas.json" ALERTS_URL = "https://spots.wwff.co/static/agendas.json"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("WWFF", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__("WWFF", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
new_alerts = [] new_alerts = []
# Iterate through source data # Iterate through source data
for source_alert in http_response.json(): for source_alert in http_response.json():
@@ -1,18 +1,24 @@
from __future__ import annotations
from typing import Any
import diskcache
from providers.callsigndata.callsign_data_provider import CallsignDataProvider from providers.callsigndata.callsign_data_provider import CallsignDataProvider
class APIQueryCallsignDataProvider(CallsignDataProvider): class APIQueryCallsignDataProvider(CallsignDataProvider):
"""Generic callsign data provider class for providers that fetch their data from the web on-demand using an API.""" """Generic callsign data provider class for providers that fetch their data from the web on-demand using an API."""
def __init__(self, name, provider_config, storage): def __init__(self, name: str, provider_config: dict[str, Any], storage: diskcache.Cache) -> None:
"""Set up the provider.""" """Set up the provider."""
super().__init__(name, provider_config, storage) super().__init__(name, provider_config, storage)
if self.enabled: if self.enabled:
self.status = "Ready" self.status = "Ready"
def start(self): def start(self) -> None:
pass pass
def stop(self): def stop(self) -> None:
pass pass
@@ -1,15 +1,21 @@
from datetime import datetime from __future__ import annotations
from datetime import datetime
from typing import Any
import diskcache
import pytz import pytz
from core.data_store import DATA_STORE from core.data_store import DATA_STORE
from data.callsign import Callsign
from data.lookup_credentials import LookupCredentials
class CallsignDataProvider: class CallsignDataProvider:
"""Generic callsign reference data provider class. Subclasses of this set up the various mechanisms via which """Generic callsign reference data provider class. Subclasses of this set up the various mechanisms via which
Spothole can look up data for callsigns.""" Spothole can look up data for callsigns."""
def __init__(self, name, provider_config, storage): def __init__(self, name: str, provider_config: dict[str, Any], storage: diskcache.Cache) -> None:
"""Constructor. As well as name and config, provide the storage object from DATA_STORE that will be used to """Constructor. As well as name and config, provide the storage object from DATA_STORE that will be used to
store the result of lookups to speed up future access.""" store the result of lookups to speed up future access."""
@@ -21,18 +27,18 @@ class CallsignDataProvider:
self.lookup_count = 0 self.lookup_count = 0
self._storage = storage self._storage = storage
def start(self): def start(self) -> None:
"""Start the provider. This should return immediately after spawning threads to access remote resources, if """Start the provider. This should return immediately after spawning threads to access remote resources, if
needed.""" needed."""
raise NotImplementedError("Subclasses must implement this method") raise NotImplementedError("Subclasses must implement this method")
def stop(self): def stop(self) -> None:
"""Stop any threads and prepare for application shutdown""" """Stop any threads and prepare for application shutdown"""
raise NotImplementedError("Subclasses must implement this method") raise NotImplementedError("Subclasses must implement this method")
def lookup(self, callsign, lookup_credentials): def lookup(self, callsign: str, lookup_credentials: LookupCredentials | None) -> Callsign | None:
"""Looks up data for the provided callsign. Takes a LookupCredentials object, which provides any credentials """Looks up data for the provided callsign. Takes a LookupCredentials object, which provides any credentials
that have been provided by the user for this session (QRZ.com/HamQTH) to allow us to look up using those that have been provided by the user for this session (QRZ.com/HamQTH) to allow us to look up using those
services on the user's behalf. (Clublog is looked up using an API key owned by the server and provided in its services on the user's behalf. (Clublog is looked up using an API key owned by the server and provided in its
@@ -57,7 +63,7 @@ class CallsignDataProvider:
else: else:
return None return None
def _perform_new_lookup(self, callsign, lookup_credentials): def _perform_new_lookup(self, callsign: str, lookup_credentials: LookupCredentials | None) -> Callsign | None:
"""Makes a new request to the data source for callsign data.""" """Makes a new request to the data source for callsign data."""
raise NotImplementedError("Subclasses must implement this method") raise NotImplementedError("Subclasses must implement this method")
+7 -3
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
from pyhamtools import Callinfo, LookupLib from pyhamtools import Callinfo, LookupLib
@@ -7,6 +10,7 @@ from pyhamtools import Callinfo, LookupLib
from core.data_store import DATA_STORE from core.data_store import DATA_STORE
from core.utils import get_callsign_object_from_pyhamtools_callinfo from core.utils import get_callsign_object_from_pyhamtools_callinfo
from data.callsign import Callsign from data.callsign import Callsign
from data.lookup_credentials import LookupCredentials
from providers.callsigndata.api_query_callsign_data_provider import ( from providers.callsigndata.api_query_callsign_data_provider import (
APIQueryCallsignDataProvider, APIQueryCallsignDataProvider,
) )
@@ -17,9 +21,9 @@ logger = logging.getLogger(__name__)
class ClublogAPI(APIQueryCallsignDataProvider): class ClublogAPI(APIQueryCallsignDataProvider):
"""Callsign data provider for Clublog's API.""" """Callsign data provider for Clublog's API."""
_callinfo = None _callinfo: Callinfo | None = None
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
# API key required for this provider # API key required for this provider
self._api_key = provider_config.get("api_key", "") self._api_key = provider_config.get("api_key", "")
if self._api_key != "": if self._api_key != "":
@@ -33,7 +37,7 @@ class ClublogAPI(APIQueryCallsignDataProvider):
super().__init__("Clublog API", provider_config, DATA_STORE.callsign_data_clublogapi) super().__init__("Clublog API", provider_config, DATA_STORE.callsign_data_clublogapi)
def _perform_new_lookup(self, callsign, lookup_credentials): def _perform_new_lookup(self, callsign: str, lookup_credentials: LookupCredentials | None) -> Callsign | None:
callsign_data = Callsign(call=callsign) callsign_data = Callsign(call=callsign)
try: try:
+8 -4
View File
@@ -1,11 +1,15 @@
from __future__ import annotations
import gzip import gzip
import logging import logging
from typing import Any
from pyhamtools import Callinfo, LookupLib from pyhamtools import Callinfo, LookupLib
from core.data_store import DATA_STORE from core.data_store import DATA_STORE
from core.utils import get_callsign_object_from_pyhamtools_callinfo from core.utils import get_callsign_object_from_pyhamtools_callinfo
from data.callsign import Callsign from data.callsign import Callsign
from data.lookup_credentials import LookupCredentials
from providers.callsigndata.file_download_callsign_data_provider import ( from providers.callsigndata.file_download_callsign_data_provider import (
FileDownloadCallsignDataProvider, FileDownloadCallsignDataProvider,
) )
@@ -20,9 +24,9 @@ class ClublogXML(FileDownloadCallsignDataProvider):
DATA_URL = "https://cdn.clublog.org/cty.php" DATA_URL = "https://cdn.clublog.org/cty.php"
CACHE_PATH_ZIPPED = "cache/cty.xml.gz" CACHE_PATH_ZIPPED = "cache/cty.xml.gz"
CACHE_PATH_UNZIPPED = "cache/cty.xml" CACHE_PATH_UNZIPPED = "cache/cty.xml"
_callinfo = None _callinfo: Callinfo | None = None
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
# API key required for this provider # API key required for this provider
self._api_key = provider_config.get("api_key", "") self._api_key = provider_config.get("api_key", "")
if self._api_key == "": if self._api_key == "":
@@ -40,7 +44,7 @@ class ClublogXML(FileDownloadCallsignDataProvider):
DATA_STORE.callsign_data_clublogxml, DATA_STORE.callsign_data_clublogxml,
) )
def _handle_file(self, path): def _handle_file(self, path: str) -> bool:
try: try:
# The download from Clublog is gzipped so we need to uncompress that and re-save as a separate file that # The download from Clublog is gzipped so we need to uncompress that and re-save as a separate file that
# the LookupLib can actually use. # the LookupLib can actually use.
@@ -60,7 +64,7 @@ class ClublogXML(FileDownloadCallsignDataProvider):
logger.exception("Exception when loading Clublog XML.") logger.exception("Exception when loading Clublog XML.")
return False return False
def _perform_new_lookup(self, callsign, lookup_credentials): def _perform_new_lookup(self, callsign: str, lookup_credentials: LookupCredentials | None) -> Callsign | None:
callsign_data = Callsign(call=callsign) callsign_data = Callsign(call=callsign)
try: try:
+8 -4
View File
@@ -1,10 +1,14 @@
from __future__ import annotations
import logging import logging
from typing import Any
from pyhamtools import Callinfo, LookupLib from pyhamtools import Callinfo, LookupLib
from core.data_store import DATA_STORE from core.data_store import DATA_STORE
from core.utils import get_callsign_object_from_pyhamtools_callinfo from core.utils import get_callsign_object_from_pyhamtools_callinfo
from data.callsign import Callsign from data.callsign import Callsign
from data.lookup_credentials import LookupCredentials
from providers.callsigndata.file_download_callsign_data_provider import ( from providers.callsigndata.file_download_callsign_data_provider import (
FileDownloadCallsignDataProvider, FileDownloadCallsignDataProvider,
) )
@@ -18,9 +22,9 @@ class CountryFiles(FileDownloadCallsignDataProvider):
POLL_INTERVAL_DAYS = 30 POLL_INTERVAL_DAYS = 30
DATA_URL = "https://www.country-files.com/cty/cty.plist" DATA_URL = "https://www.country-files.com/cty/cty.plist"
CACHE_PATH = "cache/cty.plist" CACHE_PATH = "cache/cty.plist"
_callinfo = None _callinfo: Callinfo | None = None
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__( super().__init__(
"CountryFiles.com", "CountryFiles.com",
provider_config, provider_config,
@@ -30,7 +34,7 @@ class CountryFiles(FileDownloadCallsignDataProvider):
DATA_STORE.callsign_data_countryfiles, DATA_STORE.callsign_data_countryfiles,
) )
def _handle_file(self, path): def _handle_file(self, path: str) -> bool:
try: try:
lookuplib = LookupLib(lookuptype="countryfile", filename=path) lookuplib = LookupLib(lookuptype="countryfile", filename=path)
self._callinfo = Callinfo(lookuplib) self._callinfo = Callinfo(lookuplib)
@@ -40,7 +44,7 @@ class CountryFiles(FileDownloadCallsignDataProvider):
logger.exception("Exception when loading Country Files cty.plist.") logger.exception("Exception when loading Country Files cty.plist.")
return False return False
def _perform_new_lookup(self, callsign, lookup_credentials): def _perform_new_lookup(self, callsign: str, lookup_credentials: LookupCredentials | None) -> Callsign | None:
callsign_data = Callsign(call=callsign) callsign_data = Callsign(call=callsign)
try: try:
@@ -1,7 +1,11 @@
from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
from threading import Event, Thread from threading import Event, Thread
from typing import Any
import diskcache
import pytz import pytz
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
@@ -15,40 +19,48 @@ logger = logging.getLogger(__name__)
class FileDownloadCallsignDataProvider(CallsignDataProvider): class FileDownloadCallsignDataProvider(CallsignDataProvider):
"""Generic callsign data provider class for providers that fetch their data from the web by downloading a file.""" """Generic callsign data provider class for providers that fetch their data from the web by downloading a file."""
def __init__(self, name, provider_config, url, cache_file_path, poll_interval, storage): def __init__(
self,
name: str,
provider_config: dict[str, Any],
url: str,
cache_file_path: str,
poll_interval: int,
storage: diskcache.Cache,
) -> None:
"""Set up the provider, note poll_interval is in *days*.""" """Set up the provider, note poll_interval is in *days*."""
super().__init__(name, provider_config, storage) super().__init__(name, provider_config, storage)
self._url = url self._url = url
self._cache_file_path = cache_file_path self._cache_file_path = cache_file_path
self._poll_interval = poll_interval self._poll_interval = poll_interval
self._thread = None self._thread: Thread | None = None
self._stop_event = Event() self._stop_event = Event()
self._url_data_cache = URLDataCache(f"callsigndata_{name}") self._url_data_cache = URLDataCache(f"callsigndata_{name}")
if self.enabled: if self.enabled:
self.status = "Ready" self.status = "Ready"
def start(self): def start(self) -> None:
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between # Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting. # subsequent polls, so start() returns immediately and the application can continue starting.
logger.info(f"Set up query of {self.name} callsign reference data every {self._poll_interval!s} days.") logger.info(f"Set up query of {self.name} callsign reference data every {self._poll_interval!s} days.")
self._thread = Thread(target=self._run, name=f"FileDownloadCallsignDataProvider-{self.name}", daemon=True) self._thread = Thread(target=self._run, name=f"FileDownloadCallsignDataProvider-{self.name}", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self) -> None:
self._stop_event.set() self._stop_event.set()
if self._thread: if self._thread:
self._thread.join(timeout=12) self._thread.join(timeout=12)
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning(f"{self.name} callsign data worker thread did not exit on time and will be killed.") logger.warning(f"{self.name} callsign data worker thread did not exit on time and will be killed.")
def _run(self): def _run(self) -> None:
while True: while True:
self._poll() self._poll()
if self._stop_event.wait(timeout=self._poll_interval * 60 * 60 * 24): if self._stop_event.wait(timeout=self._poll_interval * 60 * 60 * 24):
break break
def _poll(self): def _poll(self) -> None:
try: try:
# Request the file. Use the data cache (with a TTL of 1 day) here, not as the main mechanism for # Request the file. Use the data cache (with a TTL of 1 day) here, not as the main mechanism for
# caching, but just so continual restarts of the software during testing don't hammer the servers. # caching, but just so continual restarts of the software during testing don't hammer the servers.
@@ -87,7 +99,7 @@ class FileDownloadCallsignDataProvider(CallsignDataProvider):
logger.exception(f"Exception in callsign reference data provider ({self.name})") logger.exception(f"Exception in callsign reference data provider ({self.name})")
self._stop_event.wait(timeout=1) self._stop_event.wait(timeout=1)
def _handle_file(self, path): def _handle_file(self, path: str) -> bool:
"""Handle an updated file downloaded from the server. Return true if successful, false otherwise.""" """Handle an updated file downloaded from the server. Return true if successful, false otherwise."""
raise NotImplementedError("Subclasses must implement this method") raise NotImplementedError("Subclasses must implement this method")
+7 -3
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
import logging import logging
import urllib.parse import urllib.parse
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any
import pytz import pytz
import xmltodict import xmltodict
@@ -14,6 +17,7 @@ from core.data_store import CACHE_DIR, DATA_STORE
from core.enums import Continent from core.enums import Continent
from core.url_data_cache import URLDataCache from core.url_data_cache import URLDataCache
from data.callsign import Callsign, LocationSourceForCallsign from data.callsign import Callsign, LocationSourceForCallsign
from data.lookup_credentials import LookupCredentials
from providers.callsigndata.api_query_callsign_data_provider import ( from providers.callsigndata.api_query_callsign_data_provider import (
APIQueryCallsignDataProvider, APIQueryCallsignDataProvider,
) )
@@ -24,7 +28,7 @@ logger = logging.getLogger(__name__)
class HamQTH(APIQueryCallsignDataProvider): class HamQTH(APIQueryCallsignDataProvider):
"""Callsign data provider for HamQTH.""" """Callsign data provider for HamQTH."""
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("HamQTH", provider_config, DATA_STORE.callsign_data_hamqth) super().__init__("HamQTH", provider_config, DATA_STORE.callsign_data_hamqth)
self._HAMQTH_BASE_URL = "https://www.hamqth.com/xml.php" self._HAMQTH_BASE_URL = "https://www.hamqth.com/xml.php"
self._PRG = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}".replace(" ", "_") self._PRG = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}".replace(" ", "_")
@@ -33,7 +37,7 @@ class HamQTH(APIQueryCallsignDataProvider):
# and password, this is valid for an hour, so our cache stores this specifically for 55 minutes. # and password, this is valid for an hour, so our cache stores this specifically for 55 minutes.
self._CREDENTIALS_CACHE = CachedSession(f"{CACHE_DIR}/urls/hamqth-creds", expire_after=timedelta(minutes=55)) self._CREDENTIALS_CACHE = CachedSession(f"{CACHE_DIR}/urls/hamqth-creds", expire_after=timedelta(minutes=55))
def _perform_new_lookup(self, callsign, lookup_credentials): def _perform_new_lookup(self, callsign: str, lookup_credentials: LookupCredentials | None) -> Callsign | None:
# If we don't have HamQTH credentials, skip this lookup Return None so we don't *cache* the lack of data, because # If we don't have HamQTH credentials, skip this lookup Return None so we don't *cache* the lack of data, because
# # someone might provide credentials next time around. # # someone might provide credentials next time around.
if not lookup_credentials or not ( if not lookup_credentials or not (
@@ -117,7 +121,7 @@ class HamQTH(APIQueryCallsignDataProvider):
return None return None
@staticmethod @staticmethod
def hamqth_response_to_callsign(callsign, data): def hamqth_response_to_callsign(callsign: str, data: dict[str, Any]) -> Callsign:
"""Convert the "Callsign" block in HamQTH's API response to our own Callsign object.""" """Convert the "Callsign" block in HamQTH's API response to our own Callsign object."""
# Check for sensible latitudes # Check for sensible latitudes
+7 -3
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
import logging import logging
import urllib.parse import urllib.parse
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any
import pytz import pytz
import xmltodict import xmltodict
@@ -13,6 +16,7 @@ from core.data_store import CACHE_DIR, DATA_STORE
from core.enums import Continent, LocationSourceForCallsign from core.enums import Continent, LocationSourceForCallsign
from core.url_data_cache import URLDataCache from core.url_data_cache import URLDataCache
from data.callsign import Callsign from data.callsign import Callsign
from data.lookup_credentials import LookupCredentials
from providers.callsigndata.api_query_callsign_data_provider import APIQueryCallsignDataProvider from providers.callsigndata.api_query_callsign_data_provider import APIQueryCallsignDataProvider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -21,7 +25,7 @@ logger = logging.getLogger(__name__)
class QRZ(APIQueryCallsignDataProvider): class QRZ(APIQueryCallsignDataProvider):
"""Callsign data provider for QRZ.com.""" """Callsign data provider for QRZ.com."""
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("QRZ.com", provider_config, DATA_STORE.callsign_data_qrz) super().__init__("QRZ.com", provider_config, DATA_STORE.callsign_data_qrz)
self._QRZ_BASE_URL = "https://xmldata.qrz.com/xml/current/" self._QRZ_BASE_URL = "https://xmldata.qrz.com/xml/current/"
self._URL_DATA_CACHE = URLDataCache("qrz") self._URL_DATA_CACHE = URLDataCache("qrz")
@@ -29,7 +33,7 @@ class QRZ(APIQueryCallsignDataProvider):
# and password, this is valid for an hour, so our cache stores this specifically for 55 minutes. # and password, this is valid for an hour, so our cache stores this specifically for 55 minutes.
self._CREDENTIALS_CACHE = CachedSession(f"{CACHE_DIR}/urls/qrz-creds", expire_after=timedelta(minutes=55)) self._CREDENTIALS_CACHE = CachedSession(f"{CACHE_DIR}/urls/qrz-creds", expire_after=timedelta(minutes=55))
def _perform_new_lookup(self, callsign, lookup_credentials): def _perform_new_lookup(self, callsign: str, lookup_credentials: LookupCredentials | None) -> Callsign | None:
# If we don't have QRZ credentials, skip this lookup. Return None so we don't *cache* the lack of data, because # If we don't have QRZ credentials, skip this lookup. Return None so we don't *cache* the lack of data, because
# someone might provide credentials next time around. # someone might provide credentials next time around.
if not lookup_credentials or not ( if not lookup_credentials or not (
@@ -125,7 +129,7 @@ class QRZ(APIQueryCallsignDataProvider):
return None return None
@staticmethod @staticmethod
def qrz_response_to_callsign(callsign, data): def qrz_response_to_callsign(callsign: str, data: dict[str, Any] | list[Any]) -> Callsign:
"""Convert the "Callsign" block in QRZ's API response to our own Callsign object.""" """Convert the "Callsign" block in QRZ's API response to our own Callsign object."""
# I have encountered a user passing multiple callsigns to the QRZ lookup function in a way that QRZ actually # I have encountered a user passing multiple callsigns to the QRZ lookup function in a way that QRZ actually
+22 -17
View File
@@ -1,7 +1,10 @@
from __future__ import annotations
import csv import csv
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from threading import Event, Thread from threading import Event, Thread
from typing import Any
import pytz import pytz
import requests import requests
@@ -31,17 +34,17 @@ class GIROIonosonde(SolarConditionsProvider):
Designed to run alongside KC2GProp even though they produce similar data. GIRO has more stations and includes LUF Designed to run alongside KC2GProp even though they produce similar data. GIRO has more stations and includes LUF
data, but is less reliable and often offline.""" data, but is less reliable and often offline."""
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("GIRO Ionosonde Data", provider_config) super().__init__("GIRO Ionosonde Data", provider_config)
self._stations = self._load_stations() self._stations: list[dict[str, str]] = self._load_stations()
self._thread = None self._thread: Thread | None = None
self._stop_event = Event() self._stop_event: Event = Event()
# Pre-populate ionosonde_data with known station names for stations not already present, # Pre-populate ionosonde_data with known station names for stations not already present,
# so the station dropdown is available before the first poll. Does not overwrite existing # so the station dropdown is available before the first poll. Does not overwrite existing
# entries so KC2G cache data is preserved. # entries so KC2G cache data is preserved.
existing = self._solar_conditions.ionosonde_data or {} existing: dict[str, Any] = self._solar_conditions.ionosonde_data or {}
new_entries = { new_entries: dict[str, Any] = {
s["ursi"]: { s["ursi"]: {
"ursi": s["ursi"], "ursi": s["ursi"],
"name": s["name"], "name": s["name"],
@@ -57,27 +60,27 @@ class GIROIonosonde(SolarConditionsProvider):
self.update_data({"ionosonde_data": {**existing, **new_entries}}) self.update_data({"ionosonde_data": {**existing, **new_entries}})
@staticmethod @staticmethod
def _load_stations(): def _load_stations() -> list[dict[str, str]]:
stations = [] stations: list[dict[str, str]] = []
with open(STATIONS_INDEX, newline="") as f: with open(STATIONS_INDEX, newline="") as f:
for row in csv.reader(f): for row in csv.reader(f):
if len(row) >= 2: if len(row) >= 2:
stations.append({"ursi": row[0].strip(), "name": row[1].strip()}) stations.append({"ursi": row[0].strip(), "name": row[1].strip()})
return stations return stations
def start(self): def start(self) -> None:
logger.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.") logger.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
self._thread = Thread(target=self._run, name="GIROIonosondeDataProvider", daemon=True) self._thread = Thread(target=self._run, name="GIROIonosondeDataProvider", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self) -> None:
self._stop_event.set() self._stop_event.set()
if self._thread: if self._thread:
self._thread.join(timeout=12) self._thread.join(timeout=12)
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning("GIRO ionosonde worker thread did not exit on time and will be killed.") logger.warning("GIRO ionosonde worker thread did not exit on time and will be killed.")
def _run(self): def _run(self) -> None:
# Real interval at which we poll is the "once per hour" divided by the number of stations, so each one gets # Real interval at which we poll is the "once per hour" divided by the number of stations, so each one gets
# polled once per hour, just not all at once # polled once per hour, just not all at once
interval = POLL_INTERVAL / len(self._stations) interval = POLL_INTERVAL / len(self._stations)
@@ -88,7 +91,7 @@ class GIROIonosonde(SolarConditionsProvider):
if self._stop_event.wait(timeout=interval): if self._stop_event.wait(timeout=interval):
break break
def _poll_station(self, station): def _poll_station(self, station: dict[str, str]) -> None:
ursi = station["ursi"] ursi = station["ursi"]
name = station["name"] name = station["name"]
try: try:
@@ -139,7 +142,9 @@ class GIROIonosonde(SolarConditionsProvider):
self.status = "Error" self.status = "Error"
logger.exception(f"Exception fetching GIRO ionosonde data for {ursi} ({name})") logger.exception(f"Exception fetching GIRO ionosonde data for {ursi} ({name})")
def _fetch_station_data(self, ursi, from_time, to_time): def _fetch_station_data(
self, ursi: str, from_time: datetime, to_time: datetime
) -> tuple[dict[float, float] | None, dict[float, float] | None, dict[float, float] | None]:
"""Fetch foF2, MUF and LUF readings for a station. Returns (fof2_dict, muf_dict, luf_dict) keyed by UNIX timestamp.""" """Fetch foF2, MUF and LUF readings for a station. Returns (fof2_dict, muf_dict, luf_dict) keyed by UNIX timestamp."""
from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S") from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S")
@@ -159,12 +164,12 @@ class GIROIonosonde(SolarConditionsProvider):
return None, None, None return None, None, None
@staticmethod @staticmethod
def _parse_all(text): def _parse_all(text: str) -> tuple[dict[float, float], dict[float, float], dict[float, float]]:
"""Parse web server response and return (fof2_dict, muf_dict, luf_dict) keyed by UNIX timestamp.""" """Parse web server response and return (fof2_dict, muf_dict, luf_dict) keyed by UNIX timestamp."""
fof2_data = {} fof2_data: dict[float, float] = {}
muf_data = {} muf_data: dict[float, float] = {}
luf_data = {} luf_data: dict[float, float] = {}
for line in text.splitlines(): for line in text.splitlines():
line = line.strip() line = line.strip()
if not line or line.startswith("#"): if not line or line.startswith("#"):
+11 -7
View File
@@ -1,7 +1,11 @@
from __future__ import annotations
import logging import logging
from typing import Any
from xml.etree import ElementTree from xml.etree import ElementTree
import pytz import pytz
import requests
from dateutil import parser as dateutil_parser from dateutil import parser as dateutil_parser
from dateutil import tz as dateutil_tz from dateutil import tz as dateutil_tz
@@ -19,10 +23,10 @@ class HamQSL(HTTPSolarConditionsProvider):
"""Solar conditions provider using the HamQSL.com XML API (https://www.hamqsl.com/solarxml.php). """Solar conditions provider using the HamQSL.com XML API (https://www.hamqsl.com/solarxml.php).
Provides solar flux index, geomagnetic indices, and HF/VHF propagation condition summaries.""" Provides solar flux index, geomagnetic indices, and HF/VHF propagation condition summaries."""
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("HamQSL", provider_config, URL, POLL_INTERVAL) super().__init__("HamQSL", provider_config, URL, POLL_INTERVAL)
def _http_response_to_solar_conditions(self, http_response): def _http_response_to_solar_conditions(self, http_response: requests.Response) -> dict[str, Any] | None:
root = ElementTree.fromstring(http_response.text) root = ElementTree.fromstring(http_response.text)
sd = root.find("solardata") sd = root.find("solardata")
if sd is None: if sd is None:
@@ -31,27 +35,27 @@ class HamQSL(HTTPSolarConditionsProvider):
# Some error checking functions in case the data is janky. # Some error checking functions in case the data is janky.
def text(tag, default=None): def text(tag: str, default: str | None = None) -> str | None:
if sd is None: if sd is None:
logger.warning("HamQSL solar conditions API returned unexpected XML structure") logger.warning("HamQSL solar conditions API returned unexpected XML structure")
return default return default
el = sd.find(tag) el = sd.find(tag)
return el.text.strip() if el is not None and el.text else default return el.text.strip() if el is not None and el.text else default
def float_val(tag, default=None): def float_val(tag: str, default: float | None = None) -> float | None:
try: try:
return float(text(tag)) return float(text(tag))
except (ValueError, TypeError): except (ValueError, TypeError):
return default return default
def int_val(tag, default=None): def int_val(tag: str, default: int | None = None) -> int | None:
try: try:
return int(text(tag)) return int(text(tag))
except (ValueError, TypeError): except (ValueError, TypeError):
return default return default
# Process HF band conditions # Process HF band conditions
hf_conditions = {} hf_conditions: dict[str, str] = {}
calc = sd.find("calculatedconditions") calc = sd.find("calculatedconditions")
if calc is not None: if calc is not None:
for band_el in calc.findall("band"): for band_el in calc.findall("band"):
@@ -62,7 +66,7 @@ class HamQSL(HTTPSolarConditionsProvider):
hf_conditions[f"{name}-{time}"] = condition hf_conditions[f"{name}-{time}"] = condition
# Process VHF propagation conditions # Process VHF propagation conditions
vhf_map = {} vhf_map: dict[tuple[str | None, str | None], str | None] = {}
vhf = sd.find("calculatedvhfconditions") vhf = sd.find("calculatedvhfconditions")
if vhf is not None: if vhf is not None:
for ph_el in vhf.findall("phenomenon"): for ph_el in vhf.findall("phenomenon"):
@@ -1,6 +1,9 @@
from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
from threading import Event, Thread from threading import Event, Thread
from typing import Any
import pytz import pytz
import requests import requests
@@ -16,32 +19,32 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
"""Generic solar conditions provider for providers that request data via HTTP(S). Subclasses implement """Generic solar conditions provider for providers that request data via HTTP(S). Subclasses implement
_http_response_to_solar_conditions() to parse the specific API response format.""" _http_response_to_solar_conditions() to parse the specific API response format."""
def __init__(self, name, provider_config, url, poll_interval): def __init__(self, name: str, provider_config: dict[str, Any], url: str, poll_interval: float) -> None:
super().__init__(name, provider_config) super().__init__(name, provider_config)
self._url = url self._url: str = url
self._poll_interval = poll_interval self._poll_interval: float = poll_interval
self._thread = None self._thread: Thread | None = None
self._stop_event = Event() self._stop_event: Event = Event()
def start(self): def start(self) -> None:
logger.info(f"Set up query of {self.name} solar conditions API every {self._poll_interval!s} seconds.") logger.info(f"Set up query of {self.name} solar conditions API every {self._poll_interval!s} seconds.")
self._thread = Thread(target=self._run, name=f"HTTPSolarConditionsProvider-{self.name}", daemon=True) self._thread = Thread(target=self._run, name=f"HTTPSolarConditionsProvider-{self.name}", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self) -> None:
self._stop_event.set() self._stop_event.set()
if self._thread: if self._thread:
self._thread.join(timeout=12) self._thread.join(timeout=12)
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning(f"{self.name} solar conditions worker thread did not exit on time and will be killed.") logger.warning(f"{self.name} solar conditions worker thread did not exit on time and will be killed.")
def _run(self): def _run(self) -> None:
while True: while True:
self._poll() self._poll()
if self._stop_event.wait(timeout=self._poll_interval): if self._stop_event.wait(timeout=self._poll_interval):
break break
def _poll(self): def _poll(self) -> None:
try: try:
logger.debug(f"Polling {self.name} solar conditions API...") logger.debug(f"Polling {self.name} solar conditions API...")
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30)) http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
@@ -66,7 +69,7 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
logger.exception(f"Exception in HTTP Solar Conditions Provider ({self.name})") logger.exception(f"Exception in HTTP Solar Conditions Provider ({self.name})")
self._stop_event.wait(timeout=1) self._stop_event.wait(timeout=1)
def _http_response_to_solar_conditions(self, http_response): def _http_response_to_solar_conditions(self, http_response: requests.Response) -> dict[str, Any] | None:
"""Convert an HTTP response into solar conditions data. Returns a dict mapping SolarConditions field """Convert an HTTP response into solar conditions data. Returns a dict mapping SolarConditions field
names to their new values, or None if the response could not be parsed. Only the fields returned will names to their new values, or None if the response could not be parsed. Only the fields returned will
be updated on the shared SolarConditions object; any fields not included will be left unchanged.""" be updated on the shared SolarConditions object; any fields not included will be left unchanged."""
+10 -3
View File
@@ -1,9 +1,12 @@
from __future__ import annotations
from core.constants import BANDS from core.constants import BANDS
from data.band import Band
HF_BANDS = [b for b in BANDS if b.is_ham_hf] HF_BANDS: list[Band] = [b for b in BANDS if b.is_ham_hf]
def _latest(d) -> float | None: def _latest(d: dict[float, float | str] | None) -> float | None:
"""Given a map where the key is a timestamp and the value is a number represented as a string, find the latest """Given a map where the key is a timestamp and the value is a number represented as a string, find the latest
timestamp and return the corresponding value as a float.""" timestamp and return the corresponding value as a float."""
@@ -11,7 +14,11 @@ def _latest(d) -> float | None:
return float(val) if (val is not None and val != "None") else None return float(val) if (val is not None and val != "None") else None
def compute_band_states(fof2_dict, muf_dict, luf_dict): def compute_band_states(
fof2_dict: dict[float, float | str] | None,
muf_dict: dict[float, float | str] | None,
luf_dict: dict[float, float | str] | None,
) -> dict[str, str]:
"""Compute HF band states from the latest foF2, MUF and LUF values. """Compute HF band states from the latest foF2, MUF and LUF values.
Returns a map where the keys are HF bands and the values are as follows: Returns a map where the keys are HF bands and the values are as follows:
+11 -8
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from threading import Event, Thread from threading import Event, Thread
from typing import Any
import pytz import pytz
import requests import requests
@@ -25,30 +28,30 @@ class KC2GProp(SolarConditionsProvider):
Designed to run alongside GIROIonosonde even though they produce similar data. KC2G is more reliable and is always Designed to run alongside GIROIonosonde even though they produce similar data. KC2G is more reliable and is always
online, but has fewer stations and does not provide LUF data.""" online, but has fewer stations and does not provide LUF data."""
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("KC2G Propagation Data", provider_config) super().__init__("KC2G Propagation Data", provider_config)
self._thread = None self._thread: Thread | None = None
self._stop_event = Event() self._stop_event: Event = Event()
def start(self): def start(self) -> None:
logger.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.") logger.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.")
self._thread = Thread(target=self._run, name="KC2GPropProvider", daemon=True) self._thread = Thread(target=self._run, name="KC2GPropProvider", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self) -> None:
self._stop_event.set() self._stop_event.set()
if self._thread: if self._thread:
self._thread.join(timeout=12) self._thread.join(timeout=12)
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning("KC2G ionosonde worker thread did not exit on time and will be killed.") logger.warning("KC2G ionosonde worker thread did not exit on time and will be killed.")
def _run(self): def _run(self) -> None:
while True: while True:
self._poll() self._poll()
if self._stop_event.wait(timeout=POLL_INTERVAL): if self._stop_event.wait(timeout=POLL_INTERVAL):
break break
def _poll(self): def _poll(self) -> None:
try: try:
logger.debug("Polling KC2G ionosonde data...") logger.debug("Polling KC2G ionosonde data...")
http_response = requests.get(KC2G_URL, headers=HTTP_HEADERS, timeout=(5, 30)) http_response = requests.get(KC2G_URL, headers=HTTP_HEADERS, timeout=(5, 30))
@@ -61,7 +64,7 @@ class KC2GProp(SolarConditionsProvider):
# Start from existing ionosonde_data so the accumulated time series survives across polls and restarts and # Start from existing ionosonde_data so the accumulated time series survives across polls and restarts and
# stations provided only by GIROIonosonde are not discarded # stations provided only by GIROIonosonde are not discarded
ionosonde_data = dict(self._solar_conditions.ionosonde_data or {}) ionosonde_data: dict[str, Any] = dict(self._solar_conditions.ionosonde_data or {})
updated_count = 0 updated_count = 0
for reading in http_response.json(): for reading in http_response.json():
+17 -12
View File
@@ -1,6 +1,11 @@
from __future__ import annotations
import logging import logging
import re import re
from datetime import datetime, timezone from datetime import date, datetime, timezone
from typing import Any
import requests
from providers.solarconditions.http_solar_conditions_provider import ( from providers.solarconditions.http_solar_conditions_provider import (
HTTPSolarConditionsProvider, HTTPSolarConditionsProvider,
@@ -16,11 +21,11 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
"""Solar conditions provider using the NOAA 3-day forecast text file. Parses the NOAA forecast and populates """Solar conditions provider using the NOAA 3-day forecast text file. Parses the NOAA forecast and populates
corresponding fields in the solar conditions object..""" corresponding fields in the solar conditions object.."""
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("NOAA 3-day Forecast", provider_config, URL, POLL_INTERVAL) super().__init__("NOAA 3-day Forecast", provider_config, URL, POLL_INTERVAL)
@staticmethod @staticmethod
def _parse_percentage_table(lines, section_header, year): def _parse_percentage_table(lines: list[str], section_header: str, year: int) -> dict[str, dict[float, int]] | None:
"""Find and parse a forecast table using percentages, identified by section_header. This is common to the lookup """Find and parse a forecast table using percentages, identified by section_header. This is common to the lookup
of the solar storm and radio blackout forecast parsing.""" of the solar storm and radio blackout forecast parsing."""
@@ -48,7 +53,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
return None return None
# Figure out the date based on the line found # Figure out the date based on the line found
column_timestamps = [] column_timestamps: list[float] = []
for month_str, day_str in date_matches: for month_str, day_str in date_matches:
try: try:
dt = datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").replace(tzinfo=timezone.utc) dt = datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").replace(tzinfo=timezone.utc)
@@ -58,7 +63,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
return None return None
# Parse data rows. Each non-empty line should have a text label followed by percentage values # Parse data rows. Each non-empty line should have a text label followed by percentage values
result = {} result: dict[str, dict[float, int]] = {}
for line in lines[date_header_idx + 1 :]: for line in lines[date_header_idx + 1 :]:
line_stripped = line.strip() line_stripped = line.strip()
if not line_stripped: if not line_stripped:
@@ -73,7 +78,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
# Row label is everything before the first percentage value # Row label is everything before the first percentage value
row_label = line_stripped[: line_stripped.index(pct_matches[0].group())].strip() row_label = line_stripped[: line_stripped.index(pct_matches[0].group())].strip()
row_data = {} row_data: dict[float, int] = {}
for j, match in enumerate(pct_matches): for j, match in enumerate(pct_matches):
if j >= len(column_timestamps): if j >= len(column_timestamps):
break break
@@ -83,7 +88,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
return result if result else None return result if result else None
def _http_response_to_solar_conditions(self, http_response): def _http_response_to_solar_conditions(self, http_response: requests.Response) -> dict[str, Any] | None:
lines = http_response.text.splitlines() lines = http_response.text.splitlines()
# Find the "NOAA Kp index breakdown" section header # Find the "NOAA Kp index breakdown" section header
@@ -115,7 +120,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
logger.warning(f"NOAA K-index forecast: could not parse date headers from: {date_header_line}") logger.warning(f"NOAA K-index forecast: could not parse date headers from: {date_header_line}")
return None return None
column_dates = [] column_dates: list[date] = []
for month_str, day_str in date_matches: for month_str, day_str in date_matches:
try: try:
column_dates.append( column_dates.append(
@@ -126,7 +131,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
return None return None
# Parse each data row, e.g. "00-03UT 2.00 3.00 2.00" # Parse each data row, e.g. "00-03UT 2.00 3.00 2.00"
k_index_forecast = {} k_index_forecast: dict[float, float] = {}
for line in lines[start_idx + 3 :]: for line in lines[start_idx + 3 :]:
time_match = re.match(r"^(\d{2})-(\d{2})UT\s+(.*)", line.strip()) time_match = re.match(r"^(\d{2})-(\d{2})UT\s+(.*)", line.strip())
if not time_match: if not time_match:
@@ -167,14 +172,14 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
return None return None
# Parse Solar Radiation Storm Forecast (single row: "S1 or greater") # Parse Solar Radiation Storm Forecast (single row: "S1 or greater")
solar_storm_forecast = None solar_storm_forecast: dict[float, int] | None = None
radiation_table = self._parse_percentage_table(lines, "Solar Radiation Storm Forecast", year) radiation_table = self._parse_percentage_table(lines, "Solar Radiation Storm Forecast", year)
if radiation_table: if radiation_table:
solar_storm_forecast = radiation_table.get("S1 or greater") solar_storm_forecast = radiation_table.get("S1 or greater")
# Parse Radio Blackout Forecast (two rows: "R1-R2" and "R3 or greater") # Parse Radio Blackout Forecast (two rows: "R1-R2" and "R3 or greater")
blackout_forecast_r1r2 = None blackout_forecast_r1r2: dict[float, int] | None = None
blackout_forecast_r3_or_greater = None blackout_forecast_r3_or_greater: dict[float, int] | None = None
blackout_table = self._parse_percentage_table(lines, "Radio Blackout Forecast", year) blackout_table = self._parse_percentage_table(lines, "Radio Blackout Forecast", year)
if blackout_table: if blackout_table:
blackout_forecast_r1r2 = blackout_table.get("R1-R2") blackout_forecast_r1r2 = blackout_table.get("R1-R2")
@@ -1,34 +1,38 @@
from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
from core.data_store import DATA_STORE from core.data_store import DATA_STORE
from data.solar_conditions import SolarConditions
class SolarConditionsProvider: class SolarConditionsProvider:
"""Generic solar conditions provider class. Subclasses of this query individual APIs for space weather and """Generic solar conditions provider class. Subclasses of this query individual APIs for space weather and
propagation data.""" propagation data."""
def __init__(self, name, provider_config): def __init__(self, name: str, provider_config: dict[str, Any]) -> None:
"""Constructor""" """Constructor"""
self.name = name self.name: str = name
self.enabled = provider_config.get("enabled", True) self.enabled: bool = provider_config.get("enabled", True)
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC) self.last_update_time: datetime = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled" self.status: str = "Not Started" if self.enabled else "Disabled"
self._solar_conditions = DATA_STORE.solar_conditions.get() self._solar_conditions: SolarConditions = DATA_STORE.solar_conditions.get()
def start(self): def start(self) -> None:
"""Start the provider. This should return immediately after spawning threads to access the remote resources""" """Start the provider. This should return immediately after spawning threads to access the remote resources"""
raise NotImplementedError("Subclasses must implement this method") raise NotImplementedError("Subclasses must implement this method")
def stop(self): def stop(self) -> None:
"""Stop any threads and prepare for application shutdown""" """Stop any threads and prepare for application shutdown"""
raise NotImplementedError("Subclasses must implement this method") raise NotImplementedError("Subclasses must implement this method")
def update_data(self, new_data): def update_data(self, new_data: dict[str, Any] | None) -> None:
"""Update the solar conditions object with new data""" """Update the solar conditions object with new data"""
if new_data: if new_data:
+11 -8
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
from threading import Event, Thread from threading import Event, Thread
from typing import Any
import aprslib import aprslib
import pytz import pytz
@@ -15,17 +18,17 @@ logger = logging.getLogger(__name__)
class APRSIS(SpotProvider): class APRSIS(SpotProvider):
"""Spot provider for the APRS-IS.""" """Spot provider for the APRS-IS."""
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("APRS-IS", provider_config) super().__init__("APRS-IS", provider_config)
self._thread = None self._thread: Thread | None = None
self._aprsis = None self._aprsis: aprslib.IS | None = None
self._stop_event = Event() self._stop_event: Event = Event()
def start(self): def start(self) -> None:
self._thread = Thread(target=self._run, name="APRSISSpotProvider", daemon=True) self._thread = Thread(target=self._run, name="APRSISSpotProvider", daemon=True)
self._thread.start() self._thread.start()
def _run(self): def _run(self) -> None:
while not self._stop_event.is_set(): while not self._stop_event.is_set():
try: try:
self._aprsis = aprslib.IS(SERVER_OWNER_CALLSIGN) self._aprsis = aprslib.IS(SERVER_OWNER_CALLSIGN)
@@ -43,7 +46,7 @@ class APRSIS(SpotProvider):
if not self._stop_event.is_set(): if not self._stop_event.is_set():
self._stop_event.wait(timeout=5) self._stop_event.wait(timeout=5)
def stop(self): def stop(self) -> None:
self.status = "Shutting down" self.status = "Shutting down"
self._stop_event.set() self._stop_event.set()
if self._aprsis: if self._aprsis:
@@ -53,7 +56,7 @@ class APRSIS(SpotProvider):
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning("APRS-IS worker thread did not exit on time and will be killed.") logger.warning("APRS-IS worker thread did not exit on time and will be killed.")
def _handle(self, data): def _handle(self, data: dict[str, Any]) -> None:
try: try:
# Split SSID in "from" call and store separately # Split SSID in "from" call and store separately
from_parts = str(data["from"]).split("-") from_parts = str(data["from"]).split("-")
+18 -15
View File
@@ -1,16 +1,19 @@
from __future__ import annotations
import logging import logging
import re import re
import socket import socket
from datetime import datetime from datetime import datetime
from threading import Event, Lock, Thread from threading import Event, Lock, Thread
from typing import Any
import pytz import pytz
import telnetlib3 import telnetlib3
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from core.utils import decode_telnet_bytes
from data.spot import Spot from data.spot import Spot
from providers.spot.spot_provider import SpotProvider from providers.spot.spot_provider import SpotProvider
from core.utils import decode_telnet_bytes
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -28,29 +31,29 @@ class DXCluster(SpotProvider):
re.IGNORECASE, re.IGNORECASE,
) )
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
"""Constructor requires hostname and port""" """Constructor requires hostname and port"""
name = provider_config.get("name", "Cluster") name = provider_config.get("name", "Cluster")
super().__init__(name, provider_config) super().__init__(name, provider_config)
self._hostname = provider_config["host"] self._hostname: str = provider_config["host"]
self._port = provider_config["port"] self._port: int = provider_config["port"]
self._login_prompt = provider_config.get("login_prompt", "login:") self._login_prompt: str = provider_config.get("login_prompt", "login:")
self._login_callsign = provider_config.get("login_callsign", SERVER_OWNER_CALLSIGN) self._login_callsign: str = provider_config.get("login_callsign", SERVER_OWNER_CALLSIGN)
self._allow_rbn_spots = provider_config.get("allow_rbn_spots", False) self._allow_rbn_spots: bool = provider_config.get("allow_rbn_spots", False)
self._spot_line_pattern = ( self._spot_line_pattern: re.Pattern[str] = (
self._LINE_PATTERN_ALLOW_RBN if self._allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN self._LINE_PATTERN_ALLOW_RBN if self._allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN
) )
self._telnet = None self._telnet: telnetlib3.Telnet | None = None
self._telnet_lock = Lock() self._telnet_lock: Lock = Lock()
self._thread = None self._thread: Thread | None = None
self._stop_event = Event() self._stop_event: Event = Event()
def start(self): def start(self) -> None:
self._thread = Thread(target=self._handle, name=f"DXClusterSpotProvider-{self.name}", daemon=True) self._thread = Thread(target=self._handle, name=f"DXClusterSpotProvider-{self.name}", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self) -> None:
self._stop_event.set() self._stop_event.set()
with self._telnet_lock: with self._telnet_lock:
if self._telnet: if self._telnet:
@@ -64,7 +67,7 @@ class DXCluster(SpotProvider):
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning(f"DX Cluster {self._hostname} worker thread did not exit on time and will be killed.") logger.warning(f"DX Cluster {self._hostname} worker thread did not exit on time and will be killed.")
def _handle(self): def _handle(self) -> None:
while not self._stop_event.is_set(): while not self._stop_event.is_set():
connected = False connected = False
while not connected and not self._stop_event.is_set(): while not connected and not self._stop_event.is_set():
+11 -7
View File
@@ -1,7 +1,11 @@
from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
import requests
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.enums import ActivityName, ActivityRefType, Mode from core.enums import ActivityName, ActivityRefType, Mode
@@ -21,14 +25,14 @@ class GMA(HTTPSpotProvider):
# GMA spots don't contain the details of the programme they are for, we need a separate lookup for that # GMA spots don't contain the details of the programme they are for, we need a separate lookup for that
REF_INFO_URL_ROOT = "https://www.gma.rocks/api/ref/?" REF_INFO_URL_ROOT = "https://www.gma.rocks/api/ref/?"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
# Ensure there is an API key in our config, and set up the query URL using it. If no key is provided, # Ensure there is an API key in our config, and set up the query URL using it. If no key is provided,
# disable this spot provider. # disable this spot provider.
self._api_key = provider_config.get("api_key", "") self._api_key: str = provider_config.get("api_key", "")
if self._api_key == "": if self._api_key == "":
provider_config["enabled"] = False provider_config["enabled"] = False
logger.warning("GMA spot provider configured but no api key was provided, this API will not be queried.") logger.warning("GMA spot provider configured but no api key was provided, this API will not be queried.")
self._url_data_cache = URLDataCache("GMA") self._url_data_cache: URLDataCache = URLDataCache("GMA")
super().__init__( super().__init__(
"GMA", "GMA",
@@ -37,8 +41,8 @@ class GMA(HTTPSpotProvider):
self.POLL_INTERVAL_SEC, self.POLL_INTERVAL_SEC,
) )
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
new_spots = [] new_spots: list[Spot] = []
# Iterate through source data # Iterate through source data
if "RCD" in http_response.json(): if "RCD" in http_response.json():
for source_spot in http_response.json()["RCD"]: for source_spot in http_response.json()["RCD"]:
@@ -172,10 +176,10 @@ class GMA(HTTPSpotProvider):
return new_spots return new_spots
def can_submit_spot(self, activity): def can_submit_spot(self, activity: str) -> bool:
return activity == ActivityName.GMA return activity == ActivityName.GMA
def submit_spot(self, spot, credentials): def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
# TODO: Implement. # TODO: Implement.
# Spotting to GMA is documented: https://www.cqgma.org/api/doc/apigma_spot.pdf We (or the user) need a GMA account, and to send the password in plaintext(!!) # Spotting to GMA is documented: https://www.cqgma.org/api/doc/apigma_spot.pdf We (or the user) need a GMA account, and to send the password in plaintext(!!)
raise NotImplementedError("GMA upstream spot submission is not yet implemented") raise NotImplementedError("GMA upstream spot submission is not yet implemented")
+9 -6
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
import logging import logging
import re import re
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
import requests import requests
@@ -27,17 +30,17 @@ class HEMA(HTTPSpotProvider):
FREQ_MODE_PATTERN = re.compile("^([\\d.]*) \\((.*)\\)$") FREQ_MODE_PATTERN = re.compile("^([\\d.]*) \\((.*)\\)$")
SPOTTER_COMMENT_PATTERN = re.compile("^\\((.*)\\) (.*)$") SPOTTER_COMMENT_PATTERN = re.compile("^\\((.*)\\) (.*)$")
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("HEMA", provider_config, self.SPOT_SEED_URL, self.POLL_INTERVAL_SEC) super().__init__("HEMA", provider_config, self.SPOT_SEED_URL, self.POLL_INTERVAL_SEC)
self._spot_seed = "" self._spot_seed: str = ""
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
# OK, source data is actually just the spot seed at this point. We'll then go on to fetch real data if we know # OK, source data is actually just the spot seed at this point. We'll then go on to fetch real data if we know
# this has changed. # this has changed.
spot_seed_changed = http_response.text != self._spot_seed spot_seed_changed = http_response.text != self._spot_seed
self._spot_seed = http_response.text self._spot_seed = http_response.text
new_spots = [] new_spots: list[Spot] = []
# OK, if the spot seed actually changed, now we make the real request for data. # OK, if the spot seed actually changed, now we make the real request for data.
if spot_seed_changed: if spot_seed_changed:
try: try:
@@ -89,10 +92,10 @@ class HEMA(HTTPSpotProvider):
logger.warning("Connection error when accessing HEMA spots API.") logger.warning("Connection error when accessing HEMA spots API.")
return new_spots return new_spots
def can_submit_spot(self, activity): def can_submit_spot(self, activity: str) -> bool:
return activity == ActivityName.HEMA return activity == ActivityName.HEMA
def submit_spot(self, spot, credentials): def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
# TODO: Implement. Currently blocked awaiting their API team to make a change to allow us to spot with a # TODO: Implement. Currently blocked awaiting their API team to make a change to allow us to spot with a
# reference and not a reference *number*. # reference and not a reference *number*.
raise NotImplementedError("HEMA upstream spot submission is not yet implemented") raise NotImplementedError("HEMA upstream spot submission is not yet implemented")
+16 -12
View File
@@ -1,12 +1,16 @@
from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
from threading import Event, Thread from threading import Event, Thread
from typing import Any
import pytz import pytz
import requests import requests
from requests.exceptions import ConnectionError, ConnectTimeout, JSONDecodeError, ReadTimeout from requests.exceptions import ConnectionError, ConnectTimeout, JSONDecodeError, ReadTimeout
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from data.spot import Spot
from providers.spot.spot_provider import SpotProvider from providers.spot.spot_provider import SpotProvider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -16,22 +20,22 @@ class HTTPSpotProvider(SpotProvider):
"""Generic spot provider class for providers that request data via HTTP(S). Just for convenience to avoid code """Generic spot provider class for providers that request data via HTTP(S). Just for convenience to avoid code
duplication. Subclasses of this query the individual APIs for data.""" duplication. Subclasses of this query the individual APIs for data."""
def __init__(self, name, provider_config, url, poll_interval): def __init__(self, name: str, provider_config: dict[str, Any], url: str, poll_interval: float) -> None:
super().__init__(name, provider_config) super().__init__(name, provider_config)
self._url = url self._url: str = url
self._poll_interval = poll_interval self._poll_interval: float = poll_interval
self._thread = None self._thread: Thread | None = None
self._stop_event = Event() self._stop_event: Event = Event()
self._wakeup_event = Event() self._wakeup_event: Event = Event()
def start(self): def start(self) -> None:
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between # Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting. # subsequent polls, so start() returns immediately and the application can continue starting.
logger.info(f"Set up query of {self.name} spot API every {self._poll_interval!s} seconds.") logger.info(f"Set up query of {self.name} spot API every {self._poll_interval!s} seconds.")
self._thread = Thread(target=self._run, name=f"HTTPSpotProvider-{self.name}", daemon=True) self._thread = Thread(target=self._run, name=f"HTTPSpotProvider-{self.name}", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self) -> None:
self._stop_event.set() self._stop_event.set()
self._wakeup_event.set() self._wakeup_event.set()
if self._thread: if self._thread:
@@ -39,12 +43,12 @@ class HTTPSpotProvider(SpotProvider):
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning(f"{self.name} spot worker thread did not exit on time and will be killed.") logger.warning(f"{self.name} spot worker thread did not exit on time and will be killed.")
def force_poll(self): def force_poll(self) -> None:
"""Trigger an immediate poll without waiting for the normal interval.""" """Trigger an immediate poll without waiting for the normal interval."""
self._wakeup_event.set() self._wakeup_event.set()
def _run(self): def _run(self) -> None:
while True: while True:
self._wakeup_event.clear() self._wakeup_event.clear()
self._poll() self._poll()
@@ -52,7 +56,7 @@ class HTTPSpotProvider(SpotProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break break
def _poll(self): def _poll(self) -> None:
try: try:
# Request data from API # Request data from API
logger.debug(f"Polling {self.name} spot API...") logger.debug(f"Polling {self.name} spot API...")
@@ -86,7 +90,7 @@ class HTTPSpotProvider(SpotProvider):
logger.exception(f"Exception in HTTP Spot Provider ({self.name})") logger.exception(f"Exception in HTTP Spot Provider ({self.name})")
self._stop_event.wait(timeout=1) self._stop_event.wait(timeout=1)
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot] | None:
"""Convert an HTTP response returned by the API into spot data. The whole response is provided here so the subclass """Convert an HTTP response returned by the API into spot data. The whole response is provided here so the subclass
implementations can check for HTTP status codes if necessary, and handle the response as JSON, XML, text, whatever implementations can check for HTTP status codes if necessary, and handle the response as JSON, XML, text, whatever
the API actually provides.""" the API actually provides."""
+8 -3
View File
@@ -1,4 +1,9 @@
from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any
import requests
from core.enums import ActivityName, ActivityRefType, Mode from core.enums import ActivityName, ActivityRefType, Mode
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -12,11 +17,11 @@ class LLOTA(HTTPSpotProvider):
POLL_INTERVAL_SEC = 120 POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://llota.app/api/public/spots" SPOTS_URL = "https://llota.app/api/public/spots"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("LLOTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__("LLOTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
new_spots = [] new_spots: list[Spot] = []
# Iterate through source data # Iterate through source data
for source_spot in http_response.json(): for source_spot in http_response.json():
# Find the most recent spotter and comment from the history array # Find the most recent spotter and comment from the history array
+8 -6
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
import logging import logging
import re import re
from datetime import datetime from datetime import datetime
from typing import ClassVar from typing import Any, ClassVar
import pytz import pytz
import requests import requests
@@ -33,11 +35,11 @@ class ParksNPeaks(HTTPSpotProvider):
ActivityName.SANPCPA, ActivityName.SANPCPA,
] ]
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("ParksNPeaks", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__("ParksNPeaks", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
new_spots = [] new_spots: list[Spot] = []
# Iterate through source data # Iterate through source data
if http_response and http_response != "": if http_response and http_response != "":
for source_spot in http_response.json(): for source_spot in http_response.json():
@@ -117,10 +119,10 @@ class ParksNPeaks(HTTPSpotProvider):
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, activity): def can_submit_spot(self, activity: str) -> bool:
return activity in self.SUBMITTABLE_ACTIVITIES return activity in self.SUBMITTABLE_ACTIVITIES
def submit_spot(self, spot, credentials): def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
# TODO test this works # TODO test this works
user_id = credentials.get("user_id", "") user_id = credentials.get("user_id", "")
api_key = credentials.get("api_key", "") api_key = credentials.get("api_key", "")
+8 -5
View File
@@ -1,4 +1,7 @@
from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
import requests import requests
@@ -17,11 +20,11 @@ class POTA(HTTPSpotProvider):
SPOTS_URL = "https://api.pota.app/spot/activator" SPOTS_URL = "https://api.pota.app/spot/activator"
SUBMIT_URL = "https://api.pota.app/spot" SUBMIT_URL = "https://api.pota.app/spot"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("POTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__("POTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
new_spots = [] new_spots: list[Spot] = []
# Iterate through source data # Iterate through source data
for source_spot in http_response.json(): for source_spot in http_response.json():
# Convert to our spot format # Convert to our spot format
@@ -57,10 +60,10 @@ class POTA(HTTPSpotProvider):
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, activity): def can_submit_spot(self, activity: str) -> bool:
return activity == ActivityName.POTA return activity == ActivityName.POTA
def submit_spot(self, spot, credentials): def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
sig_ref = spot.sig_refs[0].id if spot.sig_refs else None sig_ref = spot.sig_refs[0].id if spot.sig_refs else None
if sig_ref: if sig_ref:
body = { body = {
+13 -10
View File
@@ -1,16 +1,19 @@
from __future__ import annotations
import logging import logging
import re import re
import socket import socket
from datetime import datetime from datetime import datetime
from threading import Event, Lock, Thread from threading import Event, Lock, Thread
from typing import Any
import pytz import pytz
import telnetlib3 import telnetlib3
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from core.utils import decode_telnet_bytes
from data.spot import Spot from data.spot import Spot
from providers.spot.spot_provider import SpotProvider from providers.spot.spot_provider import SpotProvider
from core.utils import decode_telnet_bytes
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -24,22 +27,22 @@ class RBN(SpotProvider):
re.IGNORECASE, re.IGNORECASE,
) )
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
"""Constructor requires port number.""" """Constructor requires port number."""
name = provider_config.get("name", "RBN") name = provider_config.get("name", "RBN")
super().__init__(name, provider_config) super().__init__(name, provider_config)
self._port = provider_config["port"] self._port: int = provider_config["port"]
self._telnet = None self._telnet: telnetlib3.Telnet | None = None
self._telnet_lock = Lock() self._telnet_lock: Lock = Lock()
self._thread = None self._thread: Thread | None = None
self._stop_event = Event() self._stop_event: Event = Event()
def start(self): def start(self) -> None:
self._thread = Thread(target=self._handle, name=f"RBNSpotProvider-{self.name}", daemon=True) self._thread = Thread(target=self._handle, name=f"RBNSpotProvider-{self.name}", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self) -> None:
self._stop_event.set() self._stop_event.set()
with self._telnet_lock: with self._telnet_lock:
if self._telnet: if self._telnet:
@@ -53,7 +56,7 @@ class RBN(SpotProvider):
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning(f"RBN (port {self._port!s}) worker thread did not exit on time and will be killed.") logger.warning(f"RBN (port {self._port!s}) worker thread did not exit on time and will be killed.")
def _handle(self): def _handle(self) -> None:
while not self._stop_event.is_set(): while not self._stop_event.is_set():
connected = False connected = False
while not connected and not self._stop_event.is_set(): while not connected and not self._stop_event.is_set():
+9 -7
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
from typing import ClassVar from typing import Any, ClassVar
import requests import requests
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
@@ -27,17 +29,17 @@ class SOTA(HTTPSpotProvider):
SUBMIT_URL = "https://api-db2.sota.org.uk/api/spots" SUBMIT_URL = "https://api-db2.sota.org.uk/api/spots"
VALID_MODES: ClassVar[list[str]] = ["AM", "CW", "Data", "DV", "FM", "SSB"] VALID_MODES: ClassVar[list[str]] = ["AM", "CW", "Data", "DV", "FM", "SSB"]
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("SOTA", provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC) super().__init__("SOTA", provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
self._api_epoch = "" self._api_epoch: str = ""
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
# OK, source data is actually just the epoch at this point. We'll then go on to fetch real data if we know this # OK, source data is actually just the epoch at this point. We'll then go on to fetch real data if we know this
# has changed. # has changed.
epoch_changed = http_response.text != self._api_epoch epoch_changed = http_response.text != self._api_epoch
self._api_epoch = http_response.text self._api_epoch = http_response.text
new_spots = [] new_spots: list[Spot] = []
# OK, if the epoch actually changed, now we make the real request for data. # OK, if the epoch actually changed, now we make the real request for data.
if epoch_changed: if epoch_changed:
try: try:
@@ -83,10 +85,10 @@ class SOTA(HTTPSpotProvider):
logger.warning("Timeout when accessing SOTA spots API.") logger.warning("Timeout when accessing SOTA spots API.")
return new_spots return new_spots
def can_submit_spot(self, activity): def can_submit_spot(self, activity: str) -> bool:
return activity == ActivityName.SOTA return activity == ActivityName.SOTA
def submit_spot(self, spot, credentials): def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
# TODO test this method works # TODO test this method works
access_token = credentials.get("access_token", "") access_token = credentials.get("access_token", "")
id_token = credentials.get("id_token", "") id_token = credentials.get("id_token", "")
+25 -16
View File
@@ -1,30 +1,39 @@
from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Any
import pytz import pytz
from core.data_store import DATA_STORE from core.data_store import DATA_STORE
from core.live_data_cache import LiveDataCache
if TYPE_CHECKING:
# Deferred to avoid a circular import: data.spot imports core.call_lookup_helper, which imports
# core.data_providers, which imports this module.
from data.spot import Spot
class SpotProvider: class SpotProvider:
"""Generic spot provider class. Subclasses of this query the individual APIs for data.""" """Generic spot provider class. Subclasses of this query the individual APIs for data."""
def __init__(self, name, provider_config): def __init__(self, name: str, provider_config: dict[str, Any]) -> None:
"""Constructor""" """Constructor"""
self.name = name self.name: str = name
self.enabled = provider_config.get("enabled", True) self.enabled: bool = provider_config.get("enabled", True)
self.enabled_by_default_in_web_ui = provider_config.get("enabled_by_default_in_web_ui", True) self.enabled_by_default_in_web_ui: bool = provider_config.get("enabled_by_default_in_web_ui", True)
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC) self.last_update_time: datetime = datetime.min.replace(tzinfo=pytz.UTC)
self.last_spot_time = datetime.min.replace(tzinfo=pytz.UTC) self.last_spot_time: datetime = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled" self.status: str = "Not Started" if self.enabled else "Disabled"
self._spots = DATA_STORE.spots self._spots: LiveDataCache[Spot] = DATA_STORE.spots
def start(self): def start(self) -> None:
"""Start the provider. This should return immediately after spawning threads to access the remote resources""" """Start the provider. This should return immediately after spawning threads to access the remote resources"""
raise NotImplementedError("Subclasses must implement this method") raise NotImplementedError("Subclasses must implement this method")
def _submit_batch(self, spots): def _submit_batch(self, spots: list[Spot]) -> None:
"""Submit a batch of spots retrieved from the provider. Only spots that are newer than the last spot retrieved """Submit a batch of spots retrieved from the provider. Only spots that are newer than the last spot retrieved
by this provider will be added to the spot list, to prevent duplications. Spots passing the check will also have by this provider will be added to the spot list, to prevent duplications. Spots passing the check will also have
their infer_missing() method called to complete their data set. This is called by the API-querying their infer_missing() method called to complete their data set. This is called by the API-querying
@@ -41,7 +50,7 @@ class SpotProvider:
if spots: if spots:
self.last_spot_time = datetime.fromtimestamp(max(s.time for s in spots), pytz.UTC) self.last_spot_time = datetime.fromtimestamp(max(s.time for s in spots), pytz.UTC)
def _submit(self, spot): def _submit(self, spot: Spot) -> None:
"""Submit a single spot retrieved from the provider. This will be added to the list regardless of its age. Spots """Submit a single spot retrieved from the provider. This will be added to the list regardless of its age. Spots
passing the check will also have their infer_missing() method called to complete their data set. This is called by passing the check will also have their infer_missing() method called to complete their data set. This is called by
the data streaming subclasses, which can be relied upon not to re-provide old spots.""" the data streaming subclasses, which can be relied upon not to re-provide old spots."""
@@ -51,27 +60,27 @@ class SpotProvider:
self._add_spot(spot) self._add_spot(spot)
self.last_spot_time = datetime.fromtimestamp(spot.time, pytz.UTC) self.last_spot_time = datetime.fromtimestamp(spot.time, pytz.UTC)
def _add_spot(self, spot): def _add_spot(self, spot: Spot) -> None:
if not spot.expired(): if not spot.expired():
self._spots.set(spot.id, spot) self._spots.set(spot.id, spot)
def stop(self): def stop(self) -> None:
"""Stop any threads and prepare for application shutdown""" """Stop any threads and prepare for application shutdown"""
raise NotImplementedError("Subclasses must implement this method") raise NotImplementedError("Subclasses must implement this method")
def can_submit_spot(self, activity): def can_submit_spot(self, activity: str) -> bool:
"""Return True if this provider supports submitting spots upstream for the given activity.""" """Return True if this provider supports submitting spots upstream for the given activity."""
return False return False
def submit_spot(self, spot, credentials): def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
"""Submit a spot upstream to this provider's API. credentials is a dict with provider-specific keys. """Submit a spot upstream to this provider's API. credentials is a dict with provider-specific keys.
Raises an exception with a descriptive message on failure.""" Raises an exception with a descriptive message on failure."""
raise NotImplementedError("This provider does not support spot submission") raise NotImplementedError("This provider does not support spot submission")
def force_poll(self): def force_poll(self) -> None:
"""Trigger an immediate poll without waiting for the normal interval. Default implementation here does nothing """Trigger an immediate poll without waiting for the normal interval. Default implementation here does nothing
because not all spot providers have a polling mechanism. Providers that do should override this method.""" because not all spot providers have a polling mechanism. Providers that do should override this method."""
+18 -14
View File
@@ -1,11 +1,15 @@
from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
from threading import Event, Lock, Thread from threading import Event, Lock, Thread
from typing import Any
import pytz import pytz
from requests_sse import EventSource, InvalidStatusCodeError from requests_sse import EventSource, InvalidStatusCodeError
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from data.spot import Spot
from providers.spot.spot_provider import SpotProvider from providers.spot.spot_provider import SpotProvider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -14,23 +18,23 @@ logger = logging.getLogger(__name__)
class SSESpotProvider(SpotProvider): class SSESpotProvider(SpotProvider):
"""Spot provider using Server-Sent Events.""" """Spot provider using Server-Sent Events."""
def __init__(self, name, provider_config, url): def __init__(self, name: str, provider_config: dict[str, Any], url: str) -> None:
super().__init__(name, provider_config) super().__init__(name, provider_config)
self._url = url self._url: str = url
self._thread = None self._thread: Thread | None = None
self._last_event_id = None self._last_event_id: str | None = None
self._stop_event = Event() self._stop_event: Event = Event()
self._event_source_lock = Lock() self._event_source_lock: Lock = Lock()
self._event_source = None self._event_source: EventSource | None = None
def start(self): def start(self) -> None:
logger.info(f"Set up SSE connection to {self.name} spot API.") logger.info(f"Set up SSE connection to {self.name} spot API.")
self._stop_event.clear() self._stop_event.clear()
self._thread = Thread(target=self._run, name=f"SSESpotProvider-{self.name}") self._thread = Thread(target=self._run, name=f"SSESpotProvider-{self.name}")
self._thread.daemon = True self._thread.daemon = True
self._thread.start() self._thread.start()
def stop(self): def stop(self) -> None:
self._stop_event.set() self._stop_event.set()
with self._event_source_lock: with self._event_source_lock:
@@ -46,17 +50,17 @@ class SSESpotProvider(SpotProvider):
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning(f"{self.name} SSE worker thread did not exit on time and will be killed.") logger.warning(f"{self.name} SSE worker thread did not exit on time and will be killed.")
def _on_open(self): def _on_open(self) -> None:
self.status = "Waiting for Data" self.status = "Waiting for Data"
def _on_error(self): def _on_error(self) -> None:
self.status = "Connecting" self.status = "Connecting"
def _set_event_source(self, event_source): def _set_event_source(self, event_source: EventSource | None) -> None:
with self._event_source_lock: with self._event_source_lock:
self._event_source = event_source self._event_source = event_source
def _run(self): def _run(self) -> None:
while not self._stop_event.is_set(): while not self._stop_event.is_set():
try: try:
logger.debug(f"Connecting to {self.name} spot API...") logger.debug(f"Connecting to {self.name} spot API...")
@@ -102,7 +106,7 @@ class SSESpotProvider(SpotProvider):
self.status = "Disconnected" self.status = "Disconnected"
self._stop_event.wait(timeout=5) # Wait before trying to reconnect self._stop_event.wait(timeout=5) # Wait before trying to reconnect
def _sse_message_to_spot(self, message_data): def _sse_message_to_spot(self, message_data: str) -> Spot | None:
"""Convert an SSE message received from the API into a spot. The whole message data is provided here so the subclass """Convert an SSE message received from the API into a spot. The whole message data is provided here so the subclass
implementations can handle the message as JSON, XML, text, whatever the API actually provides.""" implementations can handle the message as JSON, XML, text, whatever the API actually provides."""
+9 -7
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
from typing import ClassVar from typing import Any, ClassVar
import requests import requests
@@ -36,11 +38,11 @@ class Tiles(HTTPSpotProvider):
"Other", "Other",
] ]
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("Tiles", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__("Tiles", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
new_spots = [] new_spots: list[Spot] = []
# Iterate through source data # Iterate through source data
for source_spot in http_response.json()["spots"]: for source_spot in http_response.json()["spots"]:
# Convert to our spot format # Convert to our spot format
@@ -84,10 +86,10 @@ class Tiles(HTTPSpotProvider):
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, activity): def can_submit_spot(self, activity: str) -> bool:
return activity == ActivityName.TILES return activity == ActivityName.TILES
def submit_spot(self, spot, credentials): def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
# Tiles on the air currently only supports *self* spots # Tiles on the air currently only supports *self* spots
if spot.dx_call == spot.de_call: if spot.dx_call == spot.de_call:
# Figure out a valid mode. Borrowed this from PoLo :) # Figure out a valid mode. Borrowed this from PoLo :)
@@ -127,7 +129,7 @@ class Tiles(HTTPSpotProvider):
# Utility function to keep the first decimal point in a given string but remove any others. Used to parse Tiles' # Utility function to keep the first decimal point in a given string but remove any others. Used to parse Tiles'
# strange frequency format where we can sometimes have e.g. "14.123.5". # strange frequency format where we can sometimes have e.g. "14.123.5".
def strip_extra_decimal_points(s): def strip_extra_decimal_points(s: str) -> str:
parts = s.split(".", 1) parts = s.split(".", 1)
if len(parts) == 1: if len(parts) == 1:
return s return s
+7 -3
View File
@@ -1,7 +1,11 @@
from __future__ import annotations
import json import json
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
import requests
from core.enums import ActivityName, ActivityRefType from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef from data.activity_ref import ActivityRef
@@ -15,11 +19,11 @@ class Towers(HTTPSpotProvider):
POLL_INTERVAL_SEC = 120 POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://wwtota.com/api/cluster_live.php" SPOTS_URL = "https://wwtota.com/api/cluster_live.php"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("Towers", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__("Towers", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
new_spots = [] new_spots: list[Spot] = []
response_fixed = http_response.text.replace("\\/", "/") response_fixed = http_response.text.replace("\\/", "/")
response_json = json.loads(response_fixed) response_json = json.loads(response_fixed)
+7 -3
View File
@@ -1,7 +1,11 @@
from __future__ import annotations
import re import re
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
import requests
from core.enums import Mode from core.enums import Mode
from data.spot import Spot from data.spot import Spot
@@ -14,11 +18,11 @@ class UKPacketNet(HTTPSpotProvider):
POLL_INTERVAL_SEC = 600 POLL_INTERVAL_SEC = 600
SPOTS_URL = "https://nodes.ukpacketradio.network/api/nodedata" SPOTS_URL = "https://nodes.ukpacketradio.network/api/nodedata"
def __init__(self, provider_config): def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("UK Packet Net", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__("UK Packet Net", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
new_spots = [] new_spots: list[Spot] = []
# Iterate through source data # Iterate through source data
nodes = http_response.json()["nodes"] nodes = http_response.json()["nodes"]
for node in nodes.values(): for node in nodes.values():

Some files were not shown because too many files have changed in this diff Show More