From d44d94964419af07501ffe2f94506c382951d058 Mon Sep 17 00:00:00 2001 From: Michael duPont Date: Sun, 26 May 2024 00:59:59 -0400 Subject: [PATCH] 3.9+ updates for main lib --- avwx/current/airsigmet.py | 4 +- avwx/current/metar.py | 6 +- avwx/current/notam.py | 4 +- avwx/current/pirep.py | 4 +- avwx/data/__init__.py | 4 +- avwx/data/build_aircraft.py | 7 +- avwx/data/build_navaids.py | 10 +- avwx/data/build_stations.py | 67 ++-- avwx/data/mappers.py | 6 +- avwx/forecast/base.py | 93 +++--- avwx/forecast/gfs.py | 41 ++- avwx/forecast/nbm.py | 48 ++- avwx/parsing/core.py | 250 +++++++------- avwx/parsing/remarks.py | 57 ++-- avwx/parsing/sanitization/base.py | 30 +- avwx/parsing/sanitization/cleaners/base.py | 62 ++-- .../parsing/sanitization/cleaners/cleaners.py | 10 +- avwx/parsing/sanitization/cleaners/cloud.py | 10 +- avwx/parsing/sanitization/cleaners/joined.py | 40 +-- avwx/parsing/sanitization/cleaners/remove.py | 13 +- avwx/parsing/sanitization/cleaners/replace.py | 13 +- .../sanitization/cleaners/separated.py | 79 ++--- .../sanitization/cleaners/temperature.py | 4 + .../sanitization/cleaners/visibility.py | 11 +- avwx/parsing/sanitization/cleaners/wind.py | 34 +- avwx/parsing/sanitization/metar.py | 31 +- avwx/parsing/sanitization/pirep.py | 8 +- avwx/parsing/sanitization/taf.py | 37 +- avwx/parsing/speech.py | 80 ++--- avwx/parsing/summary.py | 8 +- avwx/parsing/translate/base.py | 55 ++- avwx/parsing/translate/metar.py | 6 +- avwx/parsing/translate/remarks.py | 35 +- avwx/parsing/translate/taf.py | 30 +- avwx/service/__init__.py | 47 ++- avwx/service/base.py | 62 ++-- avwx/service/bulk.py | 47 ++- avwx/service/files.py | 108 +++--- avwx/service/scrape.py | 316 +++++++++--------- avwx/static/__init__.py | 4 +- avwx/static/airsigmet.py | 4 +- avwx/static/core.py | 3 +- avwx/static/gfs.py | 4 +- avwx/static/glossary.py | 3 +- avwx/static/metar.py | 4 +- avwx/static/notam.py | 4 +- avwx/static/taf.py | 4 +- avwx/station/__init__.py | 10 +- avwx/station/meta.py | 31 +- avwx/station/search.py | 31 +- avwx/station/station.py | 180 +++++----- 51 files changed, 953 insertions(+), 1106 deletions(-) create mode 100644 avwx/parsing/sanitization/cleaners/temperature.py diff --git a/avwx/current/airsigmet.py b/avwx/current/airsigmet.py index ca51c05..ffcbe6a 100644 --- a/avwx/current/airsigmet.py +++ b/avwx/current/airsigmet.py @@ -36,7 +36,7 @@ from avwx.flight_path import to_coordinates from avwx.load_utils import LazyLoad from avwx.parsing import core -from avwx.service.bulk import NOAA_Bulk, NOAA_Intl, Service +from avwx.service.bulk import NoaaBulk, NoaaIntl, Service from avwx.static.airsigmet import BULLETIN_TYPES, INTENSITY, WEATHER_TYPES from avwx.static.core import CARDINAL_DEGREES, CARDINALS from avwx.structs import ( @@ -152,7 +152,7 @@ class AirSigManager: reports: list[AirSigmet] | None = None def __init__(self): # type: ignore - self._services = [NOAA_Bulk("airsigmet"), NOAA_Intl("airsigmet")] + self._services = [NoaaBulk("airsigmet"), NoaaIntl("airsigmet")] self._raw, self.raw = [], [] async def _update(self, index: int, timeout: int) -> list[tuple[str, str | None]]: diff --git a/avwx/current/metar.py b/avwx/current/metar.py index 326ae30..1205e94 100644 --- a/avwx/current/metar.py +++ b/avwx/current/metar.py @@ -17,7 +17,7 @@ from avwx.parsing import core, remarks, speech, summary from avwx.parsing.sanitization.metar import clean_metar_list, clean_metar_string from avwx.parsing.translate.metar import translate_metar -from avwx.service import NOAA +from avwx.service import Noaa from avwx.static.core import FLIGHT_RULES from avwx.static.metar import METAR_RMK from avwx.station import uses_na_format, valid_station @@ -83,7 +83,7 @@ class Metar(Report): async def _pull_from_default(self) -> None: """Check for a more recent report from NOAA.""" - service = NOAA(self.__class__.__name__.lower()) + service = Noaa(self.__class__.__name__.lower()) if self.code is None: return report = await service.async_fetch(self.code) @@ -98,7 +98,7 @@ async def _pull_from_default(self) -> None: @property def _should_check_default(self) -> bool: """Return True if pulled from regional source and potentially out of date.""" - if isinstance(self.service, NOAA) or self.source is None: + if isinstance(self.service, Noaa) or self.source is None: return False if self.data is None or self.data.time is None or self.data.time.dt is None: diff --git a/avwx/current/notam.py b/avwx/current/notam.py index 3f1cb54..eb16a6a 100644 --- a/avwx/current/notam.py +++ b/avwx/current/notam.py @@ -36,7 +36,7 @@ from avwx import exceptions from avwx.current.base import Reports from avwx.parsing import core -from avwx.service import FAA_NOTAM +from avwx.service import FaaNotam from avwx.static.core import SPECIAL_NUMBERS from avwx.static.notam import ( CODES, @@ -139,7 +139,7 @@ class Notams(Reports): def __init__(self, code: str | None = None, coord: Coord | None = None): super().__init__(code, coord) - self.service = FAA_NOTAM("notam") + self.service = FaaNotam("notam") async def _post_update(self) -> None: self._post_parse() diff --git a/avwx/current/pirep.py b/avwx/current/pirep.py index 2dbe552..ab533ba 100644 --- a/avwx/current/pirep.py +++ b/avwx/current/pirep.py @@ -17,7 +17,7 @@ from avwx.current.base import Reports, get_wx_codes from avwx.parsing import core from avwx.parsing.sanitization.pirep import clean_pirep_string -from avwx.service.scrape import NOAA_ScrapeList +from avwx.service.scrape import NoaaScrapeList from avwx.static.core import CARDINALS, CLOUD_LIST from avwx.structs import ( Aircraft, @@ -69,7 +69,7 @@ class Pireps(Reports): def __init__(self, code: str | None = None, coord: Coord | None = None): super().__init__(code, coord) - self.service = NOAA_ScrapeList("pirep") + self.service = NoaaScrapeList("pirep") @staticmethod def _report_filter(reports: list[str]) -> list[str]: diff --git a/avwx/data/__init__.py b/avwx/data/__init__.py index 8812e8e..a2e2a83 100644 --- a/avwx/data/__init__.py +++ b/avwx/data/__init__.py @@ -30,9 +30,7 @@ def update_all() -> bool: """Update all local data. Requires a reimport to guarentee update""" - return not any( - func() for func in (update_aircraft, update_navaids, update_stations) - ) + return not any(func() for func in (update_aircraft, update_navaids, update_stations)) __all__ = ["update_all", "update_aircraft", "update_navaids", "update_stations"] diff --git a/avwx/data/build_aircraft.py b/avwx/data/build_aircraft.py index f744dd0..dc42791 100644 --- a/avwx/data/build_aircraft.py +++ b/avwx/data/build_aircraft.py @@ -1,6 +1,4 @@ -""" -Builds the aircraft code dict -""" +"""Builds the aircraft code dict.""" # stdlib import json @@ -10,14 +8,13 @@ # library import httpx - URL = "https://en.wikipedia.org/wiki/List_of_ICAO_aircraft_type_designators" OUTPUT_PATH = Path(__file__).parent / "files" / "aircraft.json" TAG_PATTERN = re.compile(r"<[^>]*>") def main() -> int: - """Builds/updates aircraft.json codes""" + """Build/update aircraft.json codes.""" resp = httpx.get(URL) if resp.status_code != 200: return 1 diff --git a/avwx/data/build_navaids.py b/avwx/data/build_navaids.py index e279178..5632ae2 100644 --- a/avwx/data/build_navaids.py +++ b/avwx/data/build_navaids.py @@ -1,10 +1,8 @@ -""" -Build navaid coordinate map -""" +"""Build navaid coordinate map.""" import json from pathlib import Path -from typing import Dict, Set, Tuple + import httpx # redirect https://ourairports.com/data/navaids.csv @@ -13,11 +11,11 @@ def main() -> None: - """Builds the navaid coordinate map""" + """Build the navaid coordinate map.""" text = httpx.get(URL).text lines = text.strip().split("\n") lines.pop(0) - data: Dict[str, Set[Tuple[float, float]]] = {} + data: dict[str, set[tuple[float, float]]] = {} for line_str in lines: line = line_str.split(",") try: diff --git a/avwx/data/build_stations.py b/avwx/data/build_stations.py index 19a183b..598053d 100644 --- a/avwx/data/build_stations.py +++ b/avwx/data/build_stations.py @@ -1,5 +1,4 @@ -""" -Builds the main station list +"""Builds the main station list. Source file for airports.csv and runways.csv can be downloaded from http://ourairports.com/data/ @@ -9,13 +8,15 @@ """ # stdlib +from __future__ import annotations + import csv import json import logging from contextlib import suppress -from datetime import date +from datetime import datetime, timezone from pathlib import Path -from typing import Dict, Iterable, List, Optional, Tuple +from typing import TYPE_CHECKING # library import httpx @@ -23,6 +24,8 @@ # module from avwx.data.mappers import FILE_REPLACE, SURFACE_TYPES +if TYPE_CHECKING: + from collections.abc import Iterable LOG = logging.getLogger("avwx.data.build_stations") @@ -40,7 +43,7 @@ def load_stations(path: Path) -> Iterable[str]: DATA_ROOT = "https://davidmegginson.github.io/ourairports-data/" REPO_ROOT = "https://raw.githubusercontent.com/avwx-rest/avwx-engine/main/data/" -_SOURCE: Dict[str, str] = {} +_SOURCE: dict[str, str] = {} _SOURCES = { "airports": f"{DATA_ROOT}airports.csv", "runways": f"{DATA_ROOT}runways.csv", @@ -51,9 +54,9 @@ def load_stations(path: Path) -> Iterable[str]: # Managed list of official ICAO idents -ICAO: List[str] = [] +ICAO: list[str] = [] # Allow-listed AWOS stations not covered by ICAO-GPS codes -AWOS: List[str] = [] +AWOS: list[str] = [] ACCEPTED_STATION_TYPES = [ @@ -68,7 +71,7 @@ def load_stations(path: Path) -> Iterable[str]: def nullify(data: dict) -> dict: - """Nullify empty strings in a dict""" + """Nullify empty strings in a dict.""" for key, val in data.items(): if isinstance(val, str) and not val.strip(): data[key] = None @@ -76,26 +79,26 @@ def nullify(data: dict) -> dict: def format_coord(coord: str) -> float: - """Convert coord string to float""" + """Convert coord string to float.""" neg = -1 if coord[-1] in ("S", "W") else 1 return neg * float(coord[:-1].strip().replace(" ", ".")) def load_codes() -> None: - """Load ident lists""" + """Load ident lists.""" # Global can't assign for key, out in (("icaos", ICAO), ("awos", AWOS)): for code in json.loads(_SOURCE[key]): out.append(code) -def validate_icao(code: str) -> Optional[str]: - """Validates a given station ident""" +def validate_icao(code: str) -> str | None: + """Validate a given station ident.""" return None if len(code) != 4 and code not in AWOS else code.upper() -def get_icao(station: List[str]) -> Optional[str]: - """Finds the ICAO by checking ident and GPS code""" +def get_icao(station: list[str]) -> str | None: + """Find the ICAO by checking ident and GPS code.""" gps_code = validate_icao(station[12]) if gps_code and gps_code in ICAO: return gps_code @@ -104,15 +107,15 @@ def get_icao(station: List[str]) -> Optional[str]: def clean_source_files() -> None: - """Cleans the source data files before parsing""" + """Clean the source data files before parsing.""" text = _SOURCE["airports"] for find, replace in FILE_REPLACE.items(): text = text.replace(find, replace) _SOURCE["airports"] = text -def format_station(code: str, station: List[str]) -> dict: - """Converts source station list into info dict""" +def format_station(code: str, station: list[str]) -> dict: + """Convert source station list into info dict.""" try: elev_ft = float(station[6]) elev_m = round(elev_ft * 0.3048) @@ -142,8 +145,8 @@ def format_station(code: str, station: List[str]) -> dict: return nullify(ret) -def build_stations() -> Tuple[dict, dict]: - """Builds the station dict from source file""" +def build_stations() -> tuple[dict, dict]: + """Build the station dict from source file.""" stations, code_map = {}, {} data = csv.reader(_SOURCE["airports"].splitlines()) next(data) # Skip header @@ -156,18 +159,18 @@ def build_stations() -> Tuple[dict, dict]: def add_missing_stations(stations: dict) -> dict: - """Add non-airport stations from NOAA extract""" - stations.update(json.loads(_SOURCE["weather_stations"])) + """Add non-airport stations from NOAA extract.""" + stations |= json.loads(_SOURCE["weather_stations"]) return stations -def get_surface_type(surface: str) -> Optional[str]: - """Returns the normalize surface type value""" +def get_surface_type(surface: str) -> str | None: + """Return the normalize surface type value.""" return next((key for key, items in SURFACE_TYPES.items() if surface in items), None) def add_runways(stations: dict, code_map: dict) -> dict: - """Add runway information to station if availabale""" + """Add runway information to station if availabale.""" data = csv.reader(_SOURCE["runways"].splitlines()) next(data) # Skip header for runway in data: @@ -200,7 +203,7 @@ def add_runways(stations: dict, code_map: dict) -> dict: def add_reporting(stations: dict) -> dict: - """Add reporting boolean to station if available""" + """Add reporting boolean to station if available.""" good = load_stations(GOOD_PATH) for code in stations: stations[code]["reporting"] = code in good @@ -208,7 +211,7 @@ def add_reporting(stations: dict) -> dict: def check_local_icaos() -> None: - """Load local ICAO file if available. Not included in distro""" + """Load local ICAO file if available. Not included in distro.""" icao_path = _FILE_DIR.parent.parent / "data" / "icaos.json" if not icao_path.exists(): return @@ -216,7 +219,7 @@ def check_local_icaos() -> None: def check_local_awos() -> None: - """Load local AWOS file if available. Not included in distro""" + """Load local AWOS file if available. Not included in distro.""" awos_path = _FILE_DIR.parent.parent / "data" / "awos.json" if not awos_path.exists(): return @@ -224,7 +227,7 @@ def check_local_awos() -> None: def download_source_files() -> bool: - """Returns True if source files updated successfully""" + """Return True if source files updated successfully.""" for key, route in _SOURCES.items(): resp = httpx.get(route) if resp.status_code != 200: @@ -234,20 +237,20 @@ def download_source_files() -> bool: def update_station_info_date() -> None: - """Update the package's station meta date""" + """Update the package's station meta date.""" meta_path = _FILE_DIR.parent / "station" / "meta.py" meta = meta_path.open().read() target = '__LAST_UPDATED__ = "' start = meta.find(target) + len(target) prefix = meta[:start] end = start + 10 - output = prefix + date.today().strftime(r"%Y-%m-%d") + meta[end:] + output = prefix + datetime.now(tz=timezone.utc).date().strftime(r"%Y-%m-%d") + meta[end:] with meta_path.open("w") as out: out.write(output) def save_station_data(stations: dict) -> None: - """Save stations to JSON package data""" + """Save stations to JSON package data.""" json.dump( stations, OUTPUT_PATH.open("w", encoding="utf8"), @@ -258,7 +261,7 @@ def save_station_data(stations: dict) -> None: def main() -> int: - """Build/update the stations.json main file""" + """Build/update the stations.json main file.""" LOG.info("Fetching") if not download_source_files(): LOG.error("Unable to update source files") diff --git a/avwx/data/mappers.py b/avwx/data/mappers.py index 0b1e476..067d10f 100644 --- a/avwx/data/mappers.py +++ b/avwx/data/mappers.py @@ -1,6 +1,6 @@ -""" -Type mappings -""" +"""Type mappings.""" + +# ruff: noqa: RUF001 # Runway surface type matching with counts (2020-06) # Uniques with < 10 occurrences omitted diff --git a/avwx/forecast/base.py b/avwx/forecast/base.py index 5356889..3c64e6e 100644 --- a/avwx/forecast/base.py +++ b/avwx/forecast/base.py @@ -1,30 +1,28 @@ -""" -Forecast report shared resources -""" - -# pylint: disable=too-many-arguments +"""Forecast report shared resources.""" # stdlib +from __future__ import annotations + from datetime import datetime, timedelta, timezone -from typing import Callable, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Callable # module from avwx.base import ManagedReport from avwx.parsing import core -from avwx.service import Service from avwx.structs import Code, Number, ReportData, Timestamp +if TYPE_CHECKING: + from avwx.service import Service + -def _trim_lines(lines: List[str], target: int) -> List[str]: - """Trim all lines to match the trimmed length of the target line""" +def _trim_lines(lines: list[str], target: int) -> list[str]: + """Trim all lines to match the trimmed length of the target line.""" length = len(lines[target].strip()) return [line[:length] for line in lines] -def _split_line( - line: str, size: int = 3, prefix: int = 4, strip: str = " |" -) -> List[str]: - """Evenly split a string while stripping elements""" +def _split_line(line: str, size: int = 3, prefix: int = 4, strip: str = " |") -> list[str]: + """Evenly split a string while stripping elements.""" line = line[prefix:] ret = [] while len(line) >= size: @@ -36,16 +34,16 @@ def _split_line( def _timestamp(line: str) -> Timestamp: - """Returns the report timestamp from the first line""" + """Return the report timestamp from the first line.""" start = line.find("GUIDANCE") + 11 text = line[start : start + 16].strip() - timestamp = datetime.strptime(text, r"%m/%d/%Y %H%M") + timestamp = datetime.strptime(text, r"%m/%d/%Y %H%M").replace(tzinfo=timezone.utc) return Timestamp(text, timestamp.replace(tzinfo=timezone.utc)) -def _find_time_periods(line: List[str], timestamp: Optional[datetime]) -> List[dict]: - """Find and create the empty time periods""" - periods: List[Optional[Timestamp]] = [] +def _find_time_periods(line: list[str], timestamp: datetime | None) -> list[dict]: + """Find and create the empty time periods.""" + periods: list[Timestamp | None] = [] if timestamp is None: periods = [None] * len(line) else: @@ -62,8 +60,8 @@ def _find_time_periods(line: List[str], timestamp: Optional[datetime]) -> List[d return [{"time": time} for time in periods] -def _init_parse(report: str) -> Tuple[ReportData, List[str]]: - """Returns the meta data and lines from a report string""" +def _init_parse(report: str) -> tuple[ReportData, list[str]]: + """Return the meta data and lines from a report string.""" report = report.strip() lines = report.split("\n") struct = ReportData( @@ -81,15 +79,16 @@ def _numbers( size: int = 3, prefix: str = "", postfix: str = "", - decimal: Optional[int] = None, + *, + decimal: int | None = None, literal: bool = False, - special: Optional[dict] = None, -) -> List[Optional[Number]]: - """Parse line into Number objects + special: dict | None = None, +) -> list[Number | None]: + """Parse line into Number objects. - Prefix, postfix, and decimal location are applied to value, not repr + Prefix, postfix, and decimal location are applied to value, not repr. - Decimal is applied after prefix and postfix + Decimal is applied after prefix and postfix. """ ret = [] for item in _split_line(line, size=size): @@ -104,36 +103,36 @@ def _numbers( return ret -def _decimal_10(line: str, size: int = 3) -> List[Optional[Number]]: - """Parse line into Number objects with 10ths decimal location""" +def _decimal_10(line: str, size: int = 3) -> list[Number | None]: + """Parse line into Number objects with 10ths decimal location.""" return _numbers(line, size, decimal=-1) -def _decimal_100(line: str, size: int = 3) -> List[Optional[Number]]: - """Parse line into Number objects with 100ths decimal location""" +def _decimal_100(line: str, size: int = 3) -> list[Number | None]: + """Parse line into Number objects with 100ths decimal location.""" return _numbers(line, size, decimal=-2) -def _number_10(line: str, size: int = 3) -> List[Optional[Number]]: - """Parse line into Number objects in tens""" +def _number_10(line: str, size: int = 3) -> list[Number | None]: + """Parse line into Number objects in tens.""" return _numbers(line, size, postfix="0") -def _number_100(line: str, size: int = 3) -> List[Optional[Number]]: - """Parse line into Number objects in hundreds""" +def _number_100(line: str, size: int = 3) -> list[Number | None]: + """Parse line into Number objects in hundreds.""" return _numbers(line, size, postfix="00") -def _direction(line: str, size: int = 3) -> List[Optional[Number]]: - """Parse line into Number objects in hundreds""" +def _direction(line: str, size: int = 3) -> list[Number | None]: + """Parse line into Number objects in hundreds.""" return _numbers(line, size, postfix="0", literal=True) def _code(mapping: dict) -> Callable: - """Generates a conditional code mapping function""" + """Generate a conditional code mapping function.""" - def func(line: str, size: int = 3) -> List[Union[Code, str, None]]: - ret: List[Union[Code, str, None]] = [] + def func(line: str, size: int = 3) -> list[Code | str | None]: + ret: list[Code | str | None] = [] for key in _split_line(line, size=size): try: ret.append(Code(key, mapping[key])) @@ -145,21 +144,19 @@ def func(line: str, size: int = 3) -> List[Union[Code, str, None]]: def _parse_lines( - periods: List[dict], - lines: List[str], - handlers: Union[dict, Callable], + periods: list[dict], + lines: list[str], + handlers: dict | Callable, size: int = 3, ) -> None: - """Add data to time periods by parsing each line (element type) + """Add data to time periods by parsing each line (element type). - Adds data in place + Adds data in place. """ for line in lines: try: key = line[:3] - *keys, handler = ( - handlers[key] if isinstance(handlers, dict) else handlers(key) - ) + *keys, handler = handlers[key] if isinstance(handlers, dict) else handlers(key) except (IndexError, KeyError): continue values = handler(line, size=size) @@ -178,7 +175,7 @@ def _parse_lines( class Forecast(ManagedReport): - """Forecast base class""" + """Forecast base class.""" # pylint: disable=abstract-method diff --git a/avwx/forecast/gfs.py b/avwx/forecast/gfs.py index ac3d7f7..c69faf1 100644 --- a/avwx/forecast/gfs.py +++ b/avwx/forecast/gfs.py @@ -18,21 +18,11 @@ """ # stdlib -from typing import List, Optional, Tuple +from __future__ import annotations # module import avwx.static.gfs as static -from avwx.parsing import core -from avwx.service import NOAA_GFS -from avwx.structs import ( - MavData, - MavPeriod, - MexData, - MexPeriod, - Number, - Units, -) -from .base import ( +from avwx.forecast.base import ( Forecast, _code, _direction, @@ -43,6 +33,16 @@ _split_line, _trim_lines, ) +from avwx.parsing import core +from avwx.service import NoaaGfs +from avwx.structs import ( + MavData, + MavPeriod, + MexData, + MexPeriod, + Number, + Units, +) class Mav(Forecast): @@ -97,7 +97,7 @@ class Mav(Forecast): ''' report_type = "mav" - _service_class = NOAA_GFS # type: ignore + _service_class = NoaaGfs # type: ignore async def _post_update(self) -> None: if self.raw is None: @@ -161,7 +161,7 @@ class Mex(Forecast): ''' report_type = "mex" - _service_class = NOAA_GFS # type: ignore + _service_class = NoaaGfs # type: ignore async def _post_update(self) -> None: if self.raw is None: @@ -176,11 +176,11 @@ def _post_parse(self) -> None: self.units = Units(**static.UNITS) -_ThunderList = List[Optional[Tuple[Optional[Number], Optional[Number]]]] +_ThunderList = list[tuple[Number | None, Number | None] | None] def _thunder(line: str, size: int = 3) -> _ThunderList: - """Parse thunder line into Number tuples""" + """Parse thunder line into Number tuples.""" ret: _ThunderList = [] previous = None for item in _split_line(line, size=size, prefix=5, strip=" /"): @@ -195,7 +195,6 @@ def _thunder(line: str, size: int = 3) -> _ThunderList: return ret -# pylint: disable=invalid-name _precip_amount = _code(static.PRECIPITATION_AMOUNT) _HANDLERS = { @@ -238,8 +237,8 @@ def _thunder(line: str, size: int = 3) -> _ThunderList: } -def parse_mav(report: str) -> Optional[MavData]: - """Parser for GFS MAV reports""" +def parse_mav(report: str) -> MavData | None: + """Parser for GFS MAV reports.""" if not report: return None data, lines = _init_parse(report) @@ -258,8 +257,8 @@ def parse_mav(report: str) -> Optional[MavData]: ) -def parse_mex(report: str) -> Optional[MexData]: - """Parser for GFS MEX reports""" +def parse_mex(report: str) -> MexData | None: + """Parser for GFS MEX reports.""" if not report: return None data, lines = _init_parse(report) diff --git a/avwx/forecast/nbm.py b/avwx/forecast/nbm.py index 1130829..83a7b73 100644 --- a/avwx/forecast/nbm.py +++ b/avwx/forecast/nbm.py @@ -34,8 +34,10 @@ # pylint: disable=too-many-arguments # stdlib +from __future__ import annotations + from contextlib import suppress -from typing import Callable, Dict, List, Optional, Tuple, Union +from typing import Callable try: from typing import TypeAlias @@ -44,28 +46,24 @@ # module from avwx import structs -from avwx.service.files import NOAA_NBM -from avwx.static.gfs import UNITS -from .base import ( +from avwx.forecast.base import ( Forecast, _decimal_10, _decimal_100, _direction, _find_time_periods, _init_parse, - _numbers, _number_100, + _numbers, _parse_lines, _split_line, _trim_lines, ) +from avwx.service.files import NoaaNbm +from avwx.static.gfs import UNITS -DataT: TypeAlias = type[ - Union[structs.NbhData, structs.NbsData, structs.NbeData, structs.NbxData] -] -PeriodT: TypeAlias = type[ - Union[structs.NbhPeriod, structs.NbsPeriod, structs.NbePeriod, structs.NbxPeriod] -] +DataT: TypeAlias = type[structs.NbhData | structs.NbsData | structs.NbeData | structs.NbxData] +PeriodT: TypeAlias = type[structs.NbhPeriod | structs.NbsPeriod | structs.NbePeriod | structs.NbxPeriod] _UNITS = { **UNITS, @@ -83,17 +81,17 @@ _WIND = {"NG": (0, "zero")} -def _ceiling(line: str, size: int = 3) -> List[Optional[structs.Number]]: - """Parse line into Number objects handling ceiling special units""" +def _ceiling(line: str, size: int = 3) -> list[structs.Number | None]: + """Parse line into Number objects handling ceiling special units.""" return _numbers(line, size, postfix="00", special=_CEILING) -def _wind(line: str, size: int = 3) -> List[Optional[structs.Number]]: - """Parse line into Number objects handling wind special units""" +def _wind(line: str, size: int = 3) -> list[structs.Number | None]: + """Parse line into Number objects handling wind special units.""" return _numbers(line, size, special=_WIND) -_HANDLERS: Dict[str, Tuple[str, Callable]] = { +_HANDLERS: dict[str, tuple[str, Callable]] = { "X/N": ("temperature_minmax", _numbers), "TMP": ("temperature", _numbers), "DPT": ("dewpoint", _numbers), @@ -111,7 +109,7 @@ def _wind(line: str, size: int = 3) -> List[Optional[structs.Number]]: "SWH": ("wave_height", _numbers), } -_HOUR_HANDLERS: Dict[str, Tuple[str, Callable]] = { +_HOUR_HANDLERS: dict[str, tuple[str, Callable]] = { "P": ("precip_chance", _numbers), "Q": ("precip_amount", _decimal_100), "T": ("thunderstorm", _numbers), @@ -119,7 +117,7 @@ def _wind(line: str, size: int = 3) -> List[Optional[structs.Number]]: "I": ("icing_amount", _decimal_100), } -_NBHS_HANDLERS: Dict[str, Tuple[str, Callable]] = { +_NBHS_HANDLERS: dict[str, tuple[str, Callable]] = { "CIG": ("ceiling", _ceiling), "VIS": ("visibility", _decimal_10), "LCB": ("cloud_base", _number_100), @@ -133,17 +131,17 @@ def _wind(line: str, size: int = 3) -> List[Optional[structs.Number]]: def _parse_factory( data_class: DataT, period_class: PeriodT, - handlers: Dict[str, Tuple[str, Callable]], + handlers: dict[str, tuple[str, Callable]], hours: int = 2, size: int = 3, prefix: int = 4, ) -> Callable: - """Creates handler function for static and computed keys""" + """Create handler function for static and computed keys.""" handlers = {**_HANDLERS, **handlers} - def handle(key: str) -> Tuple: - """Returns response key(s) and value handler for a line key""" + def handle(key: str) -> tuple[str, Callable]: + """Return response key(s) and value handler for a line key.""" with suppress(KeyError): return handlers[key] if not key[1:].isdigit(): @@ -151,8 +149,8 @@ def handle(key: str) -> Tuple: root, handler = _HOUR_HANDLERS[key[0]] return f"{root}_{key[1:].lstrip('0')}", handler - def parse(report: str) -> Optional[structs.ReportData]: - """Parser for NBM reports""" + def parse(report: str) -> structs.ReportData | None: + """Parser for NBM reports.""" if not report: return None data, lines = _init_parse(report) @@ -208,7 +206,7 @@ def parse(report: str) -> Optional[structs.ReportData]: class _Nbm(Forecast): units = structs.NbmUnits(**_UNITS) - _service_class = NOAA_NBM # type: ignore + _service_class = NoaaNbm # type: ignore _parser: staticmethod async def _post_update(self) -> None: diff --git a/avwx/parsing/core.py b/avwx/parsing/core.py index 943f87e..3205921 100644 --- a/avwx/parsing/core.py +++ b/avwx/parsing/core.py @@ -1,17 +1,15 @@ -""" -Contains the core parsing and indent functions of avwx -""" - -# pylint: disable=redefined-builtin +"""Contains the core parsing and indent functions of avwx.""" # stdlib -import re +from __future__ import annotations + import datetime as dt import math +import re from calendar import monthrange from contextlib import suppress from copy import copy -from typing import Any, Iterable, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any # library from dateutil.relativedelta import relativedelta @@ -27,13 +25,16 @@ ) from avwx.structs import Cloud, Fraction, Number, Timestamp, Units +if TYPE_CHECKING: + from collections.abc import Iterable -def dedupe(items: Iterable[Any], only_neighbors: bool = False) -> List[Any]: - """Deduplicate a list while keeping order - If only_neighbors is True, dedupe will only check neighboring values +def dedupe(items: Iterable[Any], *, only_neighbors: bool = False) -> list[Any]: + """Deduplicate a list while keeping order. + + If only_neighbors is True, dedupe will only check neighboring values. """ - ret: List[Any] = [] + ret: list[Any] = [] for item in items: if (only_neighbors and ret and ret[-1] != item) or item not in ret: ret.append(item) @@ -41,7 +42,7 @@ def dedupe(items: Iterable[Any], only_neighbors: bool = False) -> List[Any]: def is_unknown(value: str) -> bool: - """Returns True if val represents and unknown value""" + """Return True if val represents and unknown value.""" if not isinstance(value, str): raise TypeError if not value or value.upper() in {"UNKN", "UNK", "UKN"}: @@ -54,9 +55,9 @@ def is_unknown(value: str) -> bool: return False -def get_digit_list(data: List[str], from_index: int) -> Tuple[List[str], List[str]]: - """Returns a list of items removed from a given list of strings - that are all digits from 'from_index' until hitting a non-digit item +def get_digit_list(data: list[str], from_index: int) -> tuple[list[str], list[str]]: + """Return a list of items removed from a given list of strings + that are all digits from 'from_index' until hitting a non-digit item. """ ret = [] data.pop(from_index) @@ -66,7 +67,7 @@ def get_digit_list(data: List[str], from_index: int) -> Tuple[List[str], List[st def unpack_fraction(num: str) -> str: - """Returns unpacked fraction string 5/2 -> 2 1/2""" + """Return unpacked fraction string 5/2 -> 2 1/2.""" numbers = [int(n) for n in num.split("/") if n] if len(numbers) != 2 or numbers[0] <= numbers[1]: return num @@ -77,7 +78,7 @@ def unpack_fraction(num: str) -> str: def remove_leading_zeros(num: str) -> str: - """Strips zeros while handling -, M, and empty strings""" + """Strip zeros while handling -, M, and empty strings.""" if not num: return num if num.startswith("M"): @@ -95,8 +96,8 @@ def remove_leading_zeros(num: str) -> str: ) -def spoken_number(num: str, literal: bool = False) -> str: - """Returns the spoken version of a number +def spoken_number(num: str, *, literal: bool = False) -> str: + """Return the spoken version of a number. If literal, no conversion to hundreds/thousands @@ -119,9 +120,13 @@ def spoken_number(num: str, literal: bool = False) -> str: def make_fraction( - num: str, repr: Optional[str] = None, literal: bool = False, speak_prefix: str = "" + num: str, + repr: str | None = None, # noqa: A002 + *, + literal: bool = False, + speak_prefix: str = "", ) -> Fraction: - """Returns a fraction dataclass for numbers with / in them""" + """Return a fraction dataclass for numbers with / in them.""" num_str, den_str = num.split("/") # 2-1/2 but not -2 1/2 if "-" in num_str and not num_str.startswith("-"): @@ -135,23 +140,24 @@ def make_fraction( numerator = int(num_str) value = numerator / denominator unpacked = unpack_fraction(num) - spoken = speak_prefix + spoken_number(unpacked, literal) + spoken = speak_prefix + spoken_number(unpacked, literal=literal) return Fraction(repr or num, value, spoken, numerator, denominator, unpacked) def make_number( - num: Optional[str], - repr: Optional[str] = None, - speak: Optional[str] = None, + num: str | None, + repr: str | None = None, # noqa: A002 + speak: str | None = None, + *, literal: bool = False, - special: Optional[dict] = None, + special: dict | None = None, m_minus: bool = True, -) -> Union[Number, Fraction, None]: # sourcery skip: avoid-builtin-shadow - """Returns a Number or Fraction dataclass for a number string +) -> Number | Fraction | None: # sourcery skip: avoid-builtin-shadow + """Return a Number or Fraction dataclass for a number string. - If literal, spoken string will not convert to hundreds/thousands + If literal, spoken string will not convert to hundreds/thousands. - NOTE: Numerators are assumed to have a single digit. Additional are whole numbers + NOTE: Numerators are assumed to have a single digit. Additional are whole numbers. """ # pylint: disable=too-many-branches if not num or is_unknown(num): @@ -168,12 +174,12 @@ def make_number( # Check cardinal direction if num in CARDINALS: if not repr: - repr = num + repr = num # noqa: A001 num = str(CARDINALS[num]) val_str = num # Remove unit suffixes if val_str.endswith("SM"): - repr = val_str[:] + repr = val_str[:] # noqa: A001 val_str = val_str[:-2] # Remove spurious characters from the end num = num.rstrip("M.") @@ -198,23 +204,23 @@ def make_number( val_str, literal = val_str[2:], True if val_str.startswith("M"): speak_prefix += "less than " - repr = repr or val_str + repr = repr or val_str # noqa: A001 val_str = val_str[1:] if val_str.startswith("P"): speak_prefix += "greater than " - repr = repr or val_str + repr = repr or val_str # noqa: A001 val_str = val_str[1:] # Create Number if not val_str: return None - ret: Union[Number, Fraction, None] = None + ret: Number | Fraction | None = None # Create Fraction if "/" in val_str: - ret = make_fraction(val_str, repr, literal, speak_prefix=speak_prefix) + ret = make_fraction(val_str, repr, literal=literal, speak_prefix=speak_prefix) else: # Overwrite float 0 due to "0.0" literal value = float(val_str) or 0 if "." in num else int(val_str) - spoken = speak_prefix + spoken_number(speak or str(value), literal) + spoken = speak_prefix + spoken_number(speak or str(value), literal=literal) ret = Number(repr or num, value, spoken) # Null the value if "greater than"/"less than" if ret and not m_minus and repr and repr.startswith(("M", "P")): @@ -222,8 +228,8 @@ def make_number( return ret -def find_first_in_list(txt: str, str_list: List[str]) -> int: - """Returns the index of the earliest occurrence of an item from a list in a string +def find_first_in_list(txt: str, str_list: list[str]) -> int: + """Return the index of the earliest occurrence of an item from a list in a string. Ex: find_first_in_list('foobar', ['bar', 'fin']) -> 3 """ @@ -235,32 +241,28 @@ def find_first_in_list(txt: str, str_list: List[str]) -> int: def is_timestamp(item: str) -> bool: - """Returns True if the item matches the timestamp format""" + """Return True if the item matches the timestamp format.""" return len(item) == 7 and item[-1] == "Z" and item[:-1].isdigit() def is_timerange(item: str) -> bool: - """Returns True if the item is a TAF to-from time range""" - return ( - len(item) == 9 and item[4] == "/" and item[:4].isdigit() and item[5:].isdigit() - ) + """Return True if the item is a TAF to-from time range.""" + return len(item) == 9 and item[4] == "/" and item[:4].isdigit() and item[5:].isdigit() def is_possible_temp(temp: str) -> bool: - """Returns True if all characters are digits or 'M' (for minus)""" + """Return True if all characters are digits or 'M' for minus.""" return all((char.isdigit() or char == "M") for char in temp) -_Numeric = Union[int, float] +_Numeric = int | float -def relative_humidity( - temperature: _Numeric, dewpoint: _Numeric, unit: str = "C" -) -> float: - """Calculates the relative humidity as a 0 to 1 percentage""" +def relative_humidity(temperature: _Numeric, dewpoint: _Numeric, unit: str = "C") -> float: + """Calculate the relative humidity as a 0 to 1 percentage.""" - def saturation(value: Union[int, float]) -> float: - """Returns the saturation vapor pressure without the C constant for humidity calc""" + def saturation(value: _Numeric) -> float: + """Return the saturation vapor pressure without the C constant for humidity calc.""" return math.exp((17.67 * value) / (243.5 + value)) if unit == "F": @@ -273,16 +275,14 @@ def saturation(value: Union[int, float]) -> float: def pressure_altitude(pressure: float, altitude: _Numeric, unit: str = "inHg") -> int: - """Calculates the pressure altitude in feet. Converts pressure units""" + """Calculate the pressure altitude in feet. Converts pressure units.""" if unit == "hPa": pressure *= 0.02953 return round((29.92 - pressure) * 1000 + altitude) -def density_altitude( - pressure: float, temperature: _Numeric, altitude: _Numeric, units: Units -) -> int: - """Calculates the density altitude in feet. Converts pressure and temperature units""" +def density_altitude(pressure: float, temperature: _Numeric, altitude: _Numeric, units: Units) -> int: + """Calculate the density altitude in feet. Converts pressure and temperature units.""" if units.temperature == "F": temperature = (temperature - 32) * 5 / 9 if units.altimeter == "hPa": @@ -293,9 +293,9 @@ def density_altitude( def get_station_and_time( - data: List[str], -) -> Tuple[List[str], Optional[str], Optional[str]]: - """Returns the report list and removed station ident and time strings""" + data: list[str], +) -> tuple[list[str], str | None, str | None]: + """Return the report list and removed station ident and time strings.""" if not data: return data, None, None station = data.pop(0) @@ -310,7 +310,7 @@ def get_station_and_time( def is_wind(text: str) -> bool: - """Returns True if the text is likely a normal wind element""" + """Return True if the text is likely a normal wind element.""" # Ignore wind shear if text.startswith("WS"): return False @@ -330,14 +330,14 @@ def is_wind(text: str) -> bool: def is_variable_wind_direction(text: str) -> bool: - """Returns True if element looks like 350V040""" + """Return True if element looks like 350V040.""" if len(text) < 7: return False return VARIABLE_DIRECTION_PATTERN.match(text[:7]) is not None -def separate_wind(text: str) -> Tuple[str, str, str]: - """Extracts the direction, speed, and gust from a wind element""" +def separate_wind(text: str) -> tuple[str, str, str]: + """Extract the direction, speed, and gust from a wind element.""" direction, speed, gust = "", "", "" # Remove gust if "G" in text: @@ -359,20 +359,17 @@ def separate_wind(text: str) -> Tuple[str, str, str]: def get_wind( - data: List[str], units: Units -) -> Tuple[ - List[str], - Optional[Number], - Optional[Number], - Optional[Number], - List[Number], + data: list[str], units: Units +) -> tuple[ + list[str], + Number | None, + Number | None, + Number | None, + list[Number], ]: - """Returns the report list and removed: - - Direction string, speed string, gust string, variable direction list - """ + """Return the report list, direction string, speed string, gust string, and variable direction list.""" direction, speed, gust = "", "", "" - variable: List[Number] = [] + variable: list[Number] = [] # Remove unit and split elements if data: item = copy(data[0]) @@ -400,8 +397,8 @@ def get_wind( return data, direction_value, speed_value, gust_value, variable -def get_visibility(data: List[str], units: Units) -> Tuple[List[str], Optional[Number]]: - """Returns the report list and removed visibility string""" +def get_visibility(data: list[str], units: Units) -> tuple[list[str], Number | None]: + """Return the report list and removed visibility string.""" visibility = "" if data: item = copy(data[0]) @@ -419,11 +416,7 @@ def get_visibility(data: List[str], units: Units) -> Tuple[List[str], Optional[N elif len(item) == 4 and item.isdigit(): visibility = data.pop(0) units.visibility = "m" - elif ( - 7 >= len(item) >= 5 - and item[:4].isdigit() - and (item[4] in ["M", "N", "S", "E", "W"] or item[4:] == "NDV") - ): + elif 7 >= len(item) >= 5 and item[:4].isdigit() and (item[4] in ["M", "N", "S", "E", "W"] or item[4:] == "NDV"): visibility = data.pop(0)[:4] units.visibility = "m" elif len(item) == 5 and item[1:].isdigit() and item[0] in ["M", "P", "B"]: @@ -434,12 +427,7 @@ def get_visibility(data: List[str], units: Units) -> Tuple[List[str], Optional[N data.pop(0) units.visibility = "m" # Vis statute miles but split Ex: 2 1/2SM - elif ( - len(data) > 1 - and data[1].endswith("SM") - and "/" in data[1] - and item.isdigit() - ): + elif len(data) > 1 and data[1].endswith("SM") and "/" in data[1] and item.isdigit(): vis1 = data.pop(0) # 2 vis2 = data.pop(0).replace("SM", "") # 1/2 visibility = str(int(vis1) * int(vis2[2]) + int(vis2[0])) + vis2[1:] # 5/2 @@ -448,7 +436,7 @@ def get_visibility(data: List[str], units: Units) -> Tuple[List[str], Optional[N def sanitize_cloud(cloud: str) -> str: - """Fix rare cloud layer issues""" + """Fix rare cloud layer issues.""" if len(cloud) < 4: return cloud if not cloud[3].isdigit() and cloud[3] not in ("/", "-"): @@ -461,8 +449,8 @@ def sanitize_cloud(cloud: str) -> str: return cloud -def _null_or_int(val: Optional[str]) -> Optional[int]: - """Nullify unknown elements and convert ints""" +def _null_or_int(val: str | None) -> int | None: + """Nullify unknown elements and convert ints.""" return None if not isinstance(val, str) or is_unknown(val) else int(val) @@ -470,15 +458,14 @@ def _null_or_int(val: Optional[str]) -> Optional[int]: def make_cloud(cloud: str) -> Cloud: - """Returns a Cloud dataclass for a cloud string + """Return a Cloud dataclass for a cloud string. - This function assumes the input is potentially valid + This function assumes the input is potentially valid. """ raw_cloud = cloud cloud_type = "" - base: Optional[str] = None - top: Optional[str] = None - modifier: Optional[str] = None + base: str | None = None + top: str | None = None cloud = sanitize_cloud(cloud).replace("/", "") # Separate top for target in _TOP_OFFSETS: @@ -508,16 +495,13 @@ def make_cloud(cloud: str) -> Cloud: elif len(cloud) >= 4 and cloud[:4] == "UNKN": cloud = cloud[4:] # Remainder is considered modifiers - if cloud: - modifier = cloud + modifier = cloud or None # Make Cloud - return Cloud( - raw_cloud, cloud_type or None, _null_or_int(base), _null_or_int(top), modifier - ) + return Cloud(raw_cloud, cloud_type or None, _null_or_int(base), _null_or_int(top), modifier) -def get_clouds(data: List[str]) -> Tuple[List[str], list]: - """Returns the report list and removed list of split cloud layers""" +def get_clouds(data: list[str]) -> tuple[list[str], list]: + """Return the report list and removed list of split cloud layers.""" clouds = [] for i, item in reversed(list(enumerate(data))): if item[:3] in CLOUD_LIST or item[:2] == "VV": @@ -531,16 +515,16 @@ def get_clouds(data: List[str]) -> Tuple[List[str], list]: return data, clouds -def get_flight_rules(visibility: Optional[Number], ceiling: Optional[Cloud]) -> int: +def get_flight_rules(visibility: Number | None, ceiling: Cloud | None) -> int: # sourcery skip: assign-if-exp, reintroduce-else - """Returns int based on current flight rules from parsed METAR data + """Return int based on current flight rules from parsed METAR data. 0=VFR, 1=MVFR, 2=IFR, 3=LIFR - Note: Common practice is to report no higher than IFR if visibility unavailable + Note: Common practice is to report no higher than IFR if visibility unavailable. """ # Parse visibility - vis: Union[int, float] + vis: _Numeric if visibility is None: vis = 2 elif visibility.repr == "CAVOK" or visibility.repr.startswith("P6"): @@ -566,12 +550,12 @@ def get_flight_rules(visibility: Optional[Number], ceiling: Optional[Cloud]) -> return 0 # VFR -def get_ceiling(clouds: List[Cloud]) -> Optional[Cloud]: - """Returns ceiling layer from Cloud-List or None if none found +def get_ceiling(clouds: list[Cloud]) -> Cloud | None: + """Return ceiling layer from Cloud-List or None if none found. - Assumes that the clouds are already sorted lowest to highest + Assumes that the clouds are already sorted lowest to highest. - Only 'Broken', 'Overcast', and 'Vertical Visibility' are considered ceilings + Only 'Broken', 'Overcast', and 'Vertical Visibility' are considered ceilings. Prevents errors due to lack of cloud information (eg. '' or 'FEW///') """ @@ -579,7 +563,7 @@ def get_ceiling(clouds: List[Cloud]) -> Optional[Cloud]: def is_altitude(value: str) -> bool: - """Returns True if the value is a possible altitude""" + """Return True if the value is a possible altitude.""" if len(value) < 5: return False if value.startswith("SFC/"): @@ -591,9 +575,13 @@ def is_altitude(value: str) -> bool: def make_altitude( - value: str, units: Units, repr: Optional[str] = None, force_fl: bool = False -) -> Tuple[Optional[Number], Units]: - """Convert altitude string into a number""" + value: str, + units: Units, + repr: str | None = None, # noqa: A002 + *, + force_fl: bool = False, +) -> tuple[Number | None, Units]: + """Convert altitude string into a number.""" if not value: return None, units raw = repr or value @@ -601,8 +589,7 @@ def make_altitude( if value.endswith(end): force_fl = False units.altitude = end.lower() - # post 3.8 value = value.removesuffix(end) - value = value[: -len(end)] + value = value.removesuffix(end) # F430 if value[0] == "F" and value[1:].isdigit(): value = f"FL{value[1:]}" @@ -614,16 +601,16 @@ def make_altitude( def parse_date( date: str, hour_threshold: int = 200, + *, time_only: bool = False, - target: Optional[dt.date] = None, -) -> Optional[dt.datetime]: - """Parses a report timestamp in ddhhZ or ddhhmmZ format + target: dt.date | None = None, +) -> dt.datetime | None: + """Parse a report timestamp in ddhhZ or ddhhmmZ format. - If time_only, assumes hhmm format with current or previous day + If time_only, assumes hhmm format with current or previous day. - This function assumes the given timestamp is within the hour threshold from current date + This function assumes the given timestamp is within the hour threshold from current date. """ - # pylint: disable=too-many-branches # Format date string date = date.strip("Z") if not date.isdigit(): @@ -640,9 +627,7 @@ def parse_date( index_hour = 2 # Create initial guess if target: - target = dt.datetime( - target.year, target.month, target.day, tzinfo=dt.timezone.utc - ) + target = dt.datetime(target.year, target.month, target.day, tzinfo=dt.timezone.utc) else: target = dt.datetime.now(tz=dt.timezone.utc) day = target.day if time_only else int(date[:2]) @@ -677,11 +662,12 @@ def parse_date( def make_timestamp( - timestamp: Optional[str], + timestamp: str | None, + *, time_only: bool = False, - target_date: Optional[dt.date] = None, -) -> Optional[Timestamp]: - """Returns a Timestamp dataclass for a report timestamp in ddhhZ or ddhhmmZ format""" + target_date: dt.date | None = None, +) -> Timestamp | None: + """Return a Timestamp dataclass for a report timestamp in ddhhZ or ddhhmmZ format.""" if not timestamp: return None date_obj = parse_date(timestamp, time_only=time_only, target=target_date) @@ -689,7 +675,7 @@ def make_timestamp( def is_runway_visibility(item: str) -> bool: - """Returns True if the item is a runway visibility range string""" + """Return True if the item is a runway visibility range string.""" return ( len(item) > 4 and item[0] == "R" diff --git a/avwx/parsing/remarks.py b/avwx/parsing/remarks.py index a38919f..52bce2a 100644 --- a/avwx/parsing/remarks.py +++ b/avwx/parsing/remarks.py @@ -1,12 +1,9 @@ -""" -Contains functions for handling and translating remarks -""" - -# pylint: disable=redefined-builtin +"""Contains functions for handling and translating remarks.""" # stdlib +from __future__ import annotations + from contextlib import suppress -from typing import List, Optional, Tuple # module from avwx.parsing import core @@ -14,12 +11,11 @@ from avwx.static.taf import PRESSURE_TENDENCIES from avwx.structs import Code, FiveDigitCodes, Number, PressureTendency, RemarksData - -Codes = List[str] +Codes = list[str] -def decimal_code(code: str, repr: Optional[str] = None) -> Optional[Number]: - """Parses a 4-digit decimal temperature representation +def decimal_code(code: str, repr: str | None = None) -> Number | None: # noqa: A002 + """Parse a 4-digit decimal temperature representation. Ex: 1045 -> -4.5 0237 -> 23.7 """ @@ -29,8 +25,8 @@ def decimal_code(code: str, repr: Optional[str] = None) -> Optional[Number]: return core.make_number(number, repr or code) -def temp_dew_decimal(codes: Codes) -> Tuple[Codes, Optional[Number], Optional[Number]]: - """Returns the decimal temperature and dewpoint values""" +def temp_dew_decimal(codes: Codes) -> tuple[Codes, Number | None, Number | None]: + """Return the decimal temperature and dewpoint values.""" temp, dew = None, None for i, code in reversed(list(enumerate(codes))): if len(code) in {5, 9} and code[0] == "T" and code[1:].isdigit(): @@ -40,8 +36,8 @@ def temp_dew_decimal(codes: Codes) -> Tuple[Codes, Optional[Number], Optional[Nu return codes, temp, dew -def temp_minmax(codes: Codes) -> Tuple[Codes, Optional[Number], Optional[Number]]: - """Returns the 24-hour minimum and maximum temperatures""" +def temp_minmax(codes: Codes) -> tuple[Codes, Number | None, Number | None]: + """Return the 24-hour minimum and maximum temperatures.""" maximum, minimum = None, None for i, code in enumerate(codes): if len(code) == 9 and code[0] == "4" and code.isdigit(): @@ -51,8 +47,8 @@ def temp_minmax(codes: Codes) -> Tuple[Codes, Optional[Number], Optional[Number] return codes, maximum, minimum -def precip_snow(codes: Codes) -> Tuple[Codes, Optional[Number], Optional[Number]]: - """Returns the hourly precipitation and snow depth""" +def precip_snow(codes: Codes) -> tuple[Codes, Number | None, Number | None]: + """Return the hourly precipitation and snow depth.""" precip, snow = None, None for i, code in reversed(list(enumerate(codes))): if len(code) != 5: @@ -68,8 +64,8 @@ def precip_snow(codes: Codes) -> Tuple[Codes, Optional[Number], Optional[Number] return codes, precip, snow -def sea_level_pressure(codes: Codes) -> Tuple[Codes, Optional[Number]]: - """Returns the sea level pressure always in hPa""" +def sea_level_pressure(codes: Codes) -> tuple[Codes, Number | None]: + """Return the sea level pressure always in hPa.""" sea = None for i, code in enumerate(codes): if len(code) == 6 and code.startswith("SLP") and code[-3:].isdigit(): @@ -81,7 +77,7 @@ def sea_level_pressure(codes: Codes) -> Tuple[Codes, Optional[Number]]: def parse_pressure(code: str) -> PressureTendency: - """Parse a 5-digit pressure tendency""" + """Parse a 5-digit pressure tendency.""" return PressureTendency( repr=code, tendency=PRESSURE_TENDENCIES[code[1]], @@ -89,13 +85,13 @@ def parse_pressure(code: str) -> PressureTendency: ) -def parse_precipitation(code: str) -> Optional[Number]: - """Parse a 5-digit precipitation amount""" +def parse_precipitation(code: str) -> Number | None: + """Parse a 5-digit precipitation amount.""" return core.make_number(f"{code[1:3]}.{code[3:]}", code) -def five_digit_codes(codes: Codes) -> Tuple[Codes, FiveDigitCodes]: - """Returns a 5-digit min/max temperature code""" +def five_digit_codes(codes: Codes) -> tuple[Codes, FiveDigitCodes]: + """Return a 5-digit min/max temperature code.""" values = FiveDigitCodes() for i, code in reversed(list(enumerate(codes))): if len(code) == 5 and code.isdigit(): @@ -118,8 +114,8 @@ def five_digit_codes(codes: Codes) -> Tuple[Codes, FiveDigitCodes]: return codes, values -def find_codes(rmk: str) -> Tuple[Codes, List[Code]]: - """Find a remove known static codes from the starting remarks list""" +def find_codes(rmk: str) -> tuple[Codes, list[Code]]: + """Find a remove known static codes from the starting remarks list.""" ret = [] for key, value in REMARKS_GROUPS.items(): if key in rmk: @@ -131,12 +127,7 @@ def find_codes(rmk: str) -> Tuple[Codes, List[Code]]: ret.append(Code(code, REMARKS_ELEMENTS[code])) codes.pop(i) # Weather began/ended - if ( - len(code) == 5 - and code[2] in ("B", "E") - and code[3:].isdigit() - and code[:2] in WX_TRANSLATIONS - ): + if len(code) == 5 and code[2] in ("B", "E") and code[3:].isdigit() and code[:2] in WX_TRANSLATIONS: state = "began" if code[2] == "B" else "ended" value = f"{WX_TRANSLATIONS[code[:2]]} {state} at :{code[3:]}" ret.append(Code(code, value)) @@ -145,8 +136,8 @@ def find_codes(rmk: str) -> Tuple[Codes, List[Code]]: return codes, ret -def parse(rmk: str) -> Optional[RemarksData]: - """Finds temperature and dewpoint decimal values from the remarks""" +def parse(rmk: str) -> RemarksData | None: + """Find temperature and dewpoint decimal values from the remarks.""" if not rmk: return None codes, parsed_codes = find_codes(rmk) diff --git a/avwx/parsing/sanitization/base.py b/avwx/parsing/sanitization/base.py index fd5c30c..ae8c55a 100644 --- a/avwx/parsing/sanitization/base.py +++ b/avwx/parsing/sanitization/base.py @@ -1,30 +1,28 @@ -""" -Core sanitiation functions that accept report-specific elements -""" +"""Core sanitiation functions that accept report-specific elements.""" -from typing import Callable, Dict, List +from typing import Callable from avwx.parsing.core import dedupe, is_variable_wind_direction, is_wind -from avwx.structs import Sanitization -from .cleaners.base import ( +from avwx.parsing.sanitization.cleaners.base import ( CleanerListType, CleanItem, CleanPair, + CombineItems, RemoveItem, SplitItem, - CombineItems, ) -from .cleaners.cloud import separate_cloud_layers -from .cleaners.wind import sanitize_wind +from avwx.parsing.sanitization.cleaners.cloud import separate_cloud_layers +from avwx.parsing.sanitization.cleaners.wind import sanitize_wind +from avwx.structs import Sanitization def sanitize_string_with( - replacements: Dict[str, str], + replacements: dict[str, str], ) -> Callable[[str, Sanitization], str]: - """Returns a function to sanitize the report string with a given list of replacements""" + """Return a function to sanitize the report string with a given list of replacements.""" def sanitize_report_string(text: str, sans: Sanitization) -> str: - """Provides sanitization for operations that work better when the report is a string""" + """Provide sanitization for operations that work better when the report is a string.""" text = text.strip().upper().rstrip("=") if len(text) < 4: return text @@ -47,12 +45,12 @@ def sanitize_report_string(text: str, sans: Sanitization) -> str: def sanitize_list_with( cleaners: CleanerListType, -) -> Callable[[List[str], Sanitization], List[str]]: - """Returns a function to sanitize the report list with a given list of cleaners""" +) -> Callable[[list[str], Sanitization], list[str]]: + """Return a function to sanitize the report list with a given list of cleaners.""" _cleaners = [o() for o in cleaners] - def sanitize_report_list(wxdata: List[str], sans: Sanitization) -> List[str]: - """Provides sanitization for operations that work better when the report is a list""" + def sanitize_report_list(wxdata: list[str], sans: Sanitization) -> list[str]: + """Provide sanitization for operations that work better when the report is a list.""" for i, item in reversed(list(enumerate(wxdata))): for cleaner in _cleaners: # TODO: Py3.10 change to match/case on type diff --git a/avwx/parsing/sanitization/cleaners/base.py b/avwx/parsing/sanitization/cleaners/base.py index a5555d9..4932291 100644 --- a/avwx/parsing/sanitization/cleaners/base.py +++ b/avwx/parsing/sanitization/cleaners/base.py @@ -1,79 +1,67 @@ -""" -Cleaning base classes -""" +"""Cleaning base classes.""" -# pylint: disable=too-few-public-methods,abstract-method - -from typing import List, Optional, Type, Union +from __future__ import annotations class Cleaner: - """Base Cleaner type""" + """Base Cleaner type.""" # Set to True if no more cleaners should check this item should_break: bool = False class SingleItem(Cleaner): - """Cleaner looks at a single item""" + """Cleaner looks at a single item.""" def can_handle(self, item: str) -> bool: - """Returns True if the element can and needs to be cleaned""" - raise NotImplementedError() + """Return True if the element can and needs to be cleaned.""" + raise NotImplementedError class DoubleItem(Cleaner): - """Cleaner that looks at two neighboring items""" + """Cleaner that looks at two neighboring items.""" def can_handle(self, first: str, second: str) -> bool: - """Return True if neighboring pairs need to be cleaned""" - raise NotImplementedError() + """Return True if neighboring pairs need to be cleaned.""" + raise NotImplementedError class RemoveItem(SingleItem): - """Sanitization should remove item if handled""" + """Sanitization should remove item if handled.""" should_break = True class CleanItem(SingleItem): - """Sanitization should clean/replace item if handled""" + """Sanitization should clean/replace item if handled.""" def clean(self, item: str) -> str: - """Cleans the raw string""" - raise NotImplementedError() + """Clean the raw string.""" + raise NotImplementedError class CleanPair(DoubleItem): - """Sanitization should clean both paired items""" + """Sanitization should clean both paired items.""" def clean(self, first: str, second: str) -> tuple[str, str]: - """Clean both raw strings""" - raise NotImplementedError() + """Clean both raw strings.""" + raise NotImplementedError class SplitItem(Cleaner): """Sanitization should split the item in two at an index if handled""" - def split_at(self, item: str) -> Optional[int]: - """Returns the string index where the item should be split""" - raise NotImplementedError() + def split_at(self, item: str) -> int | None: + """Return the string index where the item should be split.""" + raise NotImplementedError class CombineItems(Cleaner): - """Sanitization should combine two different items if handled""" + """Sanitization should combine two different items if handled.""" def can_handle(self, first: str, second: str) -> bool: - """Returns True if both elements can and need to be combined""" - raise NotImplementedError() - - -CleanerListType = List[ - Union[ - Type[CleanItem], - Type[CleanPair], - Type[RemoveItem], - Type[SplitItem], - Type[CombineItems], - ] -] + """Return True if both elements can and need to be combined.""" + raise NotImplementedError + + +CleanerListType = list[type[CleanItem] | type[CleanPair] | type[RemoveItem] | type[SplitItem] | type[CombineItems]] diff --git a/avwx/parsing/sanitization/cleaners/cleaners.py b/avwx/parsing/sanitization/cleaners/cleaners.py index 12df580..87d1c15 100644 --- a/avwx/parsing/sanitization/cleaners/cleaners.py +++ b/avwx/parsing/sanitization/cleaners/cleaners.py @@ -1,8 +1,4 @@ -""" -Cleaners for elements not found in other files -""" - -# pylint: disable=too-few-public-methods +"""Cleaners for elements not found in other files.""" from textwrap import wrap @@ -12,14 +8,14 @@ class OnlySlashes(RemoveItem): - """Remove elements containing only '/'""" + """Remove elements containing only '/'.""" def can_handle(self, item: str) -> bool: return is_unknown(item) class TrimWxCode(CleanItem): - """Remove RE from wx codes: REVCTS -> VCTS""" + """Remove RE from wx codes: REVCTS -> VCTS.""" def can_handle(self, item: str) -> bool: if not item.startswith("RE") or item == "RE": diff --git a/avwx/parsing/sanitization/cleaners/cloud.py b/avwx/parsing/sanitization/cleaners/cloud.py index 0c2e67e..a7888db 100644 --- a/avwx/parsing/sanitization/cleaners/cloud.py +++ b/avwx/parsing/sanitization/cleaners/cloud.py @@ -1,12 +1,10 @@ -""" -Cleaners for cloud elements -""" +"""Cleaners for cloud elements.""" from avwx.static.core import CLOUD_LIST def separate_cloud_layers(text: str) -> str: - """Check for missing spaces in front of cloud layers + """Check for missing spaces in front of cloud layers. Ex: TSFEW004SCT012FEW///CBBKN080 """ for cloud in CLOUD_LIST: @@ -15,9 +13,7 @@ def separate_cloud_layers(text: str) -> str: while text.count(cloud) != text.count(f" {cloud}"): cloud_index = start + text[start:].find(cloud) if len(text[cloud_index:]) >= 3: - target = text[ - cloud_index + len(cloud) : cloud_index + len(cloud) + 3 - ] + target = text[cloud_index + len(cloud) : cloud_index + len(cloud) + 3] if target.isdigit() or not target.strip("/"): text = f"{text[:cloud_index]} {text[cloud_index:]}" start = cloud_index + len(cloud) + 1 diff --git a/avwx/parsing/sanitization/cleaners/joined.py b/avwx/parsing/sanitization/cleaners/joined.py index 0911d94..3a26a11 100644 --- a/avwx/parsing/sanitization/cleaners/joined.py +++ b/avwx/parsing/sanitization/cleaners/joined.py @@ -1,18 +1,14 @@ -""" -Cleaners where two items are joined -""" +"""Cleaners where two items are joined.""" -# pylint: disable=too-few-public-methods +from __future__ import annotations import re -from typing import Optional from avwx.parsing.core import is_timerange, is_timestamp from avwx.parsing.sanitization.base import SplitItem from avwx.static.core import CLOUD_LIST from avwx.static.taf import TAF_NEWLINE, TAF_NEWLINE_STARTSWITH - _CLOUD_GROUP = "(" + "|".join(CLOUD_LIST) + ")" CLOUD_SPACE_PATTERNS = [ re.compile(pattern) @@ -24,9 +20,9 @@ class JoinedCloud(SplitItem): - """For items starting with cloud list""" + """For items starting with cloud list.""" - def split_at(self, item: str) -> Optional[int]: + def split_at(self, item: str) -> int | None: if item[:3] in CLOUD_LIST: for pattern in CLOUD_SPACE_PATTERNS: match = pattern.search(item) @@ -41,23 +37,19 @@ def split_at(self, item: str) -> Optional[int]: class JoinedTimestamp(SplitItem): - """Connected timestamp""" + """Connected timestamp.""" - def split_at(self, item: str) -> Optional[int]: + def split_at(self, item: str) -> int | None: return next( - ( - loc - for loc, check in _TIMESTAMP_BREAKS - if len(item) > loc and check(item[:loc]) - ), + (loc for loc, check in _TIMESTAMP_BREAKS if len(item) > loc and check(item[:loc])), None, ) class JoinedWind(SplitItem): - """Connected to wind""" + """Connected to wind.""" - def split_at(self, item: str) -> Optional[int]: + def split_at(self, item: str) -> int | None: if len(item) > 5 and "KT" in item and not item.endswith("KT"): index = item.find("KT") if index > 4: @@ -66,9 +58,9 @@ def split_at(self, item: str) -> Optional[int]: class JoinedTafNewLine(SplitItem): - """TAF newline connected to previous element""" + """TAF newline connected to previous element.""" - def split_at(self, item: str) -> Optional[int]: + def split_at(self, item: str) -> int | None: for key in TAF_NEWLINE: if key in item and not item.startswith(key): return item.find(key) @@ -81,9 +73,9 @@ def split_at(self, item: str) -> Optional[int]: class JoinedMinMaxTemperature(SplitItem): - """Connected TAF min/max temp""" + """Connected TAF min/max temp.""" - def split_at(self, item: str) -> Optional[int]: + def split_at(self, item: str) -> int | None: if "TX" in item and "TN" in item and item.endswith("Z") and "/" in item: tx_index, tn_index = item.find("TX"), item.find("TN") return max(tx_index, tn_index) @@ -94,7 +86,9 @@ def split_at(self, item: str) -> Optional[int]: class JoinedRunwayVisibility(SplitItem): - """Connected RVR elements Ex: R36/1500DR18/P2000""" + """Connected RVR elements. + Ex: R36/1500DR18/P2000 + """ - def split_at(self, item: str) -> Optional[int]: + def split_at(self, item: str) -> int | None: return match.start() + 1 if (match := RVR_PATTERN.search(item[1:])) else None diff --git a/avwx/parsing/sanitization/cleaners/remove.py b/avwx/parsing/sanitization/cleaners/remove.py index 834b5e4..4c96e34 100644 --- a/avwx/parsing/sanitization/cleaners/remove.py +++ b/avwx/parsing/sanitization/cleaners/remove.py @@ -1,10 +1,5 @@ -""" -Cleaners for elements that should be removed -""" +"""Cleaners for elements that should be removed.""" -# pylint: disable=too-few-public-methods - -from typing import Set, Type from avwx.parsing.sanitization.base import RemoveItem _SHARED = { @@ -41,8 +36,8 @@ } -def remove_items_in(filter_out: Set[str]) -> Type[RemoveItem]: - """Generate a RemoveItem cleaner to filter a given set of strings""" +def remove_items_in(filter_out: set[str]) -> type[RemoveItem]: + """Generate a RemoveItem cleaner to filter a given set of strings.""" class RemoveInList(RemoveItem): """Cleaner to remove items in a list""" @@ -58,7 +53,7 @@ def can_handle(self, item: str) -> bool: class RemoveTafAmend(RemoveItem): - """Remove amend signifier from start of report ('CCA', 'CCB',etc)""" + """Remove amend signifier from start of report ('CCA', 'CCB', etc).""" def can_handle(self, item: str) -> bool: return len(item) == 3 and item.startswith("CC") and item[2].isalpha() diff --git a/avwx/parsing/sanitization/cleaners/replace.py b/avwx/parsing/sanitization/cleaners/replace.py index 7e5de6a..d1675aa 100644 --- a/avwx/parsing/sanitization/cleaners/replace.py +++ b/avwx/parsing/sanitization/cleaners/replace.py @@ -1,6 +1,4 @@ -""" -Cleaners for elements that should be replaced -""" +"""Cleaners for elements that should be replaced.""" from avwx.parsing.sanitization.base import CleanItem @@ -55,12 +53,7 @@ "OVERCAST": "OVC", } -CURRENT = { - **_SHARED, - **_WIND, - **_VISIBILITY, - **_CLOUD, -} +CURRENT = _SHARED | _WIND | _VISIBILITY | _CLOUD # These are item replacements after the report has been split @@ -69,7 +62,7 @@ class ReplaceItem(CleanItem): - """Replace report elements after splitting""" + """Replace report elements after splitting.""" def can_handle(self, item: str) -> bool: return item in ITEM_REPL diff --git a/avwx/parsing/sanitization/cleaners/separated.py b/avwx/parsing/sanitization/cleaners/separated.py index 97a53d2..62bcb49 100644 --- a/avwx/parsing/sanitization/cleaners/separated.py +++ b/avwx/parsing/sanitization/cleaners/separated.py @@ -1,53 +1,49 @@ -""" -Cleaners where an element is separated -""" - -# pylint: disable=too-few-public-methods +"""Cleaners where an element is separated.""" from avwx.parsing.sanitization.base import CombineItems from avwx.static.core import CLOUD_LIST, CLOUD_TRANSLATIONS, WIND_UNITS class SeparatedDistance(CombineItems): - """Distance digit and/or unit Ex: 10 SM""" + """Distance digit and/or unit. + Ex: 10 SM + """ def can_handle(self, first: str, second: str) -> bool: return first.isdigit() and second in {"SM", "0SM"} class SeparatedFirstTemperature(CombineItems): - """Temperature before slash Ex: 12 /10""" + """Temperature before slash. + Ex: 12 /10 + """ def can_handle(self, first: str, second: str) -> bool: - return ( - first.isdigit() - and len(second) > 2 - and second[0] == "/" - and second[1:].isdigit() - ) + return first.isdigit() and len(second) > 2 and second[0] == "/" and second[1:].isdigit() class SeparatedCloudAltitude(CombineItems): - """Known cloud types Ex: OVC 040""" + """Known cloud types. + Ex: OVC 040 + """ def can_handle(self, first: str, second: str) -> bool: return second.isdigit() and first in CLOUD_LIST class SeparatedSecondTemperature(CombineItems): - """Temperature after slash Ex: 12/ 10""" + """Temperature after slash. + Ex: 12/ 10 + """ def can_handle(self, first: str, second: str) -> bool: - return ( - second.isdigit() - and len(first) > 2 - and first.endswith("/") - and first[:-1].isdigit() - ) + return second.isdigit() and len(first) > 2 and first.endswith("/") and first[:-1].isdigit() class SeparatedAltimeterLetter(CombineItems): - """Altimeter letter prefix Ex: Q 1001""" + """Altimeter letter prefix. + Ex: Q 1001 + """ def can_handle(self, first: str, second: str) -> bool: if not second.isdigit(): @@ -58,7 +54,9 @@ def can_handle(self, first: str, second: str) -> bool: class SeparatedTemperatureTrailingDigit(CombineItems): - """Dewpoint split Ex: 12/1 0""" + """Dewpoint split. + Ex: 12/1 0 + """ def can_handle(self, first: str, second: str) -> bool: return ( @@ -72,54 +70,47 @@ def can_handle(self, first: str, second: str) -> bool: class SeparatedWindUnit(CombineItems): - """Wind unit disconnected or split in two""" + """Wind unit disconnected or split in two.""" def can_handle(self, first: str, second: str) -> bool: # 36010G20 KT if ( second in WIND_UNITS and first[-1].isdigit() - and ( - first[:5].isdigit() - or (first.startswith("VRB") and first[3:5].isdigit()) - ) + and (first[:5].isdigit() or (first.startswith("VRB") and first[3:5].isdigit())) ): return True # 36010K T return ( second == "T" and len(first) >= 6 - and ( - first[:5].isdigit() - or (first.startswith("VRB") and first[3:5].isdigit()) - ) + and (first[:5].isdigit() or (first.startswith("VRB") and first[3:5].isdigit())) and first[-1] == "K" ) class SeparatedCloudQualifier(CombineItems): - """Cloud descriptors Ex: OVC022 CB""" + """Cloud descriptors. + Ex: OVC022 CB + """ def can_handle(self, first: str, second: str) -> bool: - return ( - second in CLOUD_TRANSLATIONS - and second not in CLOUD_LIST - and len(first) >= 3 - and first[:3] in CLOUD_LIST - ) + return second in CLOUD_TRANSLATIONS and second not in CLOUD_LIST and len(first) >= 3 and first[:3] in CLOUD_LIST class SeparatedTafTimePrefix(CombineItems): - """TAF new time period Ex: FM 122400""" + """TAF new time period. + Ex: FM 122400 + """ def can_handle(self, first: str, second: str) -> bool: - return first in {"FM", "TL"} and ( - second.isdigit() or (second.endswith("Z") and second[:-1].isdigit()) - ) + return first in {"FM", "TL"} and (second.isdigit() or (second.endswith("Z") and second[:-1].isdigit())) class SeparatedMinMaxTemperaturePrefix(CombineItems): - """TAF min max temperature prefix Ex: TX 20/10""" + """TAF min max temperature prefix. + Ex: TX 20/10 + """ def can_handle(self, first: str, second: str) -> bool: return first in {"TX", "TN"} and "/" in second diff --git a/avwx/parsing/sanitization/cleaners/temperature.py b/avwx/parsing/sanitization/cleaners/temperature.py new file mode 100644 index 0000000..9c4a53a --- /dev/null +++ b/avwx/parsing/sanitization/cleaners/temperature.py @@ -0,0 +1,4 @@ +"""Cleaners for temperature elements.""" + + +# T15/0913Z T32/0923Z diff --git a/avwx/parsing/sanitization/cleaners/visibility.py b/avwx/parsing/sanitization/cleaners/visibility.py index 4d9d1ef..2be499c 100644 --- a/avwx/parsing/sanitization/cleaners/visibility.py +++ b/avwx/parsing/sanitization/cleaners/visibility.py @@ -1,20 +1,19 @@ -""" -Cleaners for visibility elements -""" +"""Cleaners for visibility elements.""" from itertools import permutations from avwx.parsing.core import is_runway_visibility from avwx.parsing.sanitization.base import CleanItem - VIS_PERMUTATIONS = ["".join(p) for p in permutations("P6SM")] VIS_PERMUTATIONS.remove("6MPS") VIS_PERMUTATIONS += ["6+SM"] class VisibilityGreaterThan(CleanItem): - """Fix inconsistent 'P6SM' Ex: TP6SM or 6PSM -> P6SM""" + """Fix inconsistent 'P6SM'. + Ex: TP6SM or 6PSM -> P6SM + """ def can_handle(self, item: str) -> bool: return len(item) > 3 and item[-4:] in VIS_PERMUTATIONS @@ -24,7 +23,7 @@ def clean(self, _: str) -> str: class RunwayVisibilityUnit(CleanItem): - """Fix RVR where FT unit is cut short""" + """Fix RVR where FT unit is cut short.""" def can_handle(self, item: str) -> bool: return is_runway_visibility(item) and item.endswith("F") diff --git a/avwx/parsing/sanitization/cleaners/wind.py b/avwx/parsing/sanitization/cleaners/wind.py index 5801bca..c484d03 100644 --- a/avwx/parsing/sanitization/cleaners/wind.py +++ b/avwx/parsing/sanitization/cleaners/wind.py @@ -1,8 +1,4 @@ -""" -Cleaners for wind elements -""" - -# pylint: disable=too-few-public-methods +"""Cleaners for wind elements.""" from avwx.parsing.core import is_unknown from avwx.parsing.sanitization.base import CleanItem, RemoveItem @@ -40,7 +36,7 @@ def sanitize_wind(text: str) -> str: - """Fix rare wind issues that may be too broad otherwise""" + """Fix rare wind issues that may be too broad otherwise.""" for rep in WIND_REMV: text = text.replace(rep, "") for key, rep in WIND_REPL.items(): @@ -67,7 +63,7 @@ def sanitize_wind(text: str) -> str: class EmptyWind(RemoveItem): - """Remove empty wind /////KT""" + """Remove empty wind /////KT.""" def can_handle(self, item: str) -> bool: return item.endswith("KT") and is_unknown(item[:-2]) @@ -75,7 +71,7 @@ def can_handle(self, item: str) -> bool: # TODO: Generalize to find anywhere in wind. Maybe add to other wind sans? class MisplaceWindKT(CleanItem): - """Fix misplaced KT 22022KTG40""" + """Fix misplaced KT 22022KTG40.""" def can_handle(self, item: str) -> bool: return len(item) == 10 and "KTG" in item and item[:5].isdigit() @@ -85,7 +81,9 @@ def clean(self, item: str) -> str: class DoubleGust(CleanItem): - """Fix gust double G Ex: 360G17G32KT""" + """Fix gust double G. + Ex: 360G17G32KT + """ def can_handle(self, item: str) -> bool: return len(item) > 10 and item.endswith("KT") and item[3] == "G" @@ -95,7 +93,7 @@ def clean(self, item: str) -> str: class WindLeadingMistype(CleanItem): - """Fix leading character mistypes in wind""" + """Fix leading character mistypes in wind.""" def can_handle(self, item: str) -> bool: return ( @@ -113,7 +111,9 @@ def clean(self, item: str) -> str: class NonGGust(CleanItem): - """Fix non-G gust Ex: 14010-15KT""" + """Fix non-G gust. + Ex: 14010-15KT + """ def can_handle(self, item: str) -> bool: return len(item) == 10 and item.endswith("KT") and item[5] != "G" @@ -123,16 +123,12 @@ def clean(self, item: str) -> str: class RemoveVrbLeadingDigits(CleanItem): - """Fix leading digits on VRB wind Ex: 2VRB02KT""" + """Fix leading digits on VRB wind. + Ex: 2VRB02KT + """ def can_handle(self, item: str) -> bool: - return ( - len(item) > 7 - and item.endswith("KT") - and "VRB" in item - and item[0].isdigit() - and "Z" not in item - ) + return len(item) > 7 and item.endswith("KT") and "VRB" in item and item[0].isdigit() and "Z" not in item def clean(self, item: str) -> str: while item[0].isdigit(): diff --git a/avwx/parsing/sanitization/metar.py b/avwx/parsing/sanitization/metar.py index 3211a9e..679e9f2 100644 --- a/avwx/parsing/sanitization/metar.py +++ b/avwx/parsing/sanitization/metar.py @@ -1,30 +1,29 @@ -""" -METAR sanitization support -""" +"""METAR sanitization support.""" # module -from .cleaners.base import CleanerListType -from .cleaners.cleaners import OnlySlashes, TrimWxCode -from .cleaners.joined import ( +from avwx.parsing.sanitization.base import sanitize_list_with, sanitize_string_with +from avwx.parsing.sanitization.cleaners.base import CleanerListType +from avwx.parsing.sanitization.cleaners.cleaners import OnlySlashes, TrimWxCode +from avwx.parsing.sanitization.cleaners.joined import ( JoinedCloud, + JoinedRunwayVisibility, JoinedTimestamp, JoinedWind, - JoinedRunwayVisibility, ) -from .cleaners.remove import RemoveFromMetar -from .cleaners.replace import CURRENT, ReplaceItem -from .cleaners.separated import ( +from avwx.parsing.sanitization.cleaners.remove import RemoveFromMetar +from avwx.parsing.sanitization.cleaners.replace import CURRENT, ReplaceItem +from avwx.parsing.sanitization.cleaners.separated import ( + SeparatedAltimeterLetter, + SeparatedCloudAltitude, + SeparatedCloudQualifier, SeparatedDistance, SeparatedFirstTemperature, - SeparatedCloudAltitude, SeparatedSecondTemperature, - SeparatedAltimeterLetter, SeparatedTemperatureTrailingDigit, SeparatedWindUnit, - SeparatedCloudQualifier, ) -from .cleaners.visibility import RunwayVisibilityUnit, VisibilityGreaterThan -from .cleaners.wind import ( +from avwx.parsing.sanitization.cleaners.visibility import RunwayVisibilityUnit, VisibilityGreaterThan +from avwx.parsing.sanitization.cleaners.wind import ( DoubleGust, EmptyWind, MisplaceWindKT, @@ -32,8 +31,6 @@ RemoveVrbLeadingDigits, WindLeadingMistype, ) -from .base import sanitize_list_with, sanitize_string_with - METAR_REPL = { **CURRENT, diff --git a/avwx/parsing/sanitization/pirep.py b/avwx/parsing/sanitization/pirep.py index a07a12a..cb6d772 100644 --- a/avwx/parsing/sanitization/pirep.py +++ b/avwx/parsing/sanitization/pirep.py @@ -1,8 +1,6 @@ -""" -PIREP sanitization support -""" +"""PIREP sanitization support.""" -from .cleaners.replace import CURRENT -from .base import sanitize_string_with +from avwx.parsing.sanitization.base import sanitize_string_with +from avwx.parsing.sanitization.cleaners.replace import CURRENT clean_pirep_string = sanitize_string_with(CURRENT) diff --git a/avwx/parsing/sanitization/taf.py b/avwx/parsing/sanitization/taf.py index bf22fed..9854745 100644 --- a/avwx/parsing/sanitization/taf.py +++ b/avwx/parsing/sanitization/taf.py @@ -1,33 +1,32 @@ -""" -TAF sanitization support -""" +"""TAF sanitization support.""" # module -from .cleaners.base import CleanerListType -from .cleaners.cleaners import OnlySlashes, TrimWxCode -from .cleaners.joined import ( +from avwx.parsing.sanitization.base import sanitize_list_with, sanitize_string_with +from avwx.parsing.sanitization.cleaners.base import CleanerListType +from avwx.parsing.sanitization.cleaners.cleaners import OnlySlashes, TrimWxCode +from avwx.parsing.sanitization.cleaners.joined import ( JoinedCloud, + JoinedMinMaxTemperature, + JoinedTafNewLine, JoinedTimestamp, JoinedWind, - JoinedTafNewLine, - JoinedMinMaxTemperature, ) -from .cleaners.remove import RemoveFromTaf, RemoveTafAmend -from .cleaners.replace import CURRENT, ReplaceItem -from .cleaners.separated import ( +from avwx.parsing.sanitization.cleaners.remove import RemoveFromTaf, RemoveTafAmend +from avwx.parsing.sanitization.cleaners.replace import CURRENT, ReplaceItem +from avwx.parsing.sanitization.cleaners.separated import ( + SeparatedAltimeterLetter, + SeparatedCloudAltitude, + SeparatedCloudQualifier, SeparatedDistance, SeparatedFirstTemperature, - SeparatedCloudAltitude, + SeparatedMinMaxTemperaturePrefix, SeparatedSecondTemperature, - SeparatedAltimeterLetter, + SeparatedTafTimePrefix, SeparatedTemperatureTrailingDigit, SeparatedWindUnit, - SeparatedCloudQualifier, - SeparatedTafTimePrefix, - SeparatedMinMaxTemperaturePrefix, ) -from .cleaners.visibility import VisibilityGreaterThan -from .cleaners.wind import ( +from avwx.parsing.sanitization.cleaners.visibility import VisibilityGreaterThan +from avwx.parsing.sanitization.cleaners.wind import ( DoubleGust, EmptyWind, MisplaceWindKT, @@ -35,8 +34,6 @@ RemoveVrbLeadingDigits, WindLeadingMistype, ) -from .base import sanitize_list_with, sanitize_string_with - TAF_REPL = { **CURRENT, diff --git a/avwx/parsing/speech.py b/avwx/parsing/speech.py index a3df8e7..257b664 100644 --- a/avwx/parsing/speech.py +++ b/avwx/parsing/speech.py @@ -1,24 +1,25 @@ +"""Contains functions for converting translations into a speech string. +Currently only supports METAR. """ -Contains functions for converting translations into a speech string -Currently only supports METAR -""" - -# pylint: disable=redefined-builtin # stdlib +from __future__ import annotations + import re -from typing import List, Optional +from typing import TYPE_CHECKING # module import avwx.parsing.translate.base as translate_base import avwx.parsing.translate.taf as translate_taf from avwx.parsing import core from avwx.static.core import SPOKEN_UNITS -from avwx.structs import Code, MetarData, Number, TafData, TafLineData, Timestamp, Units + +if TYPE_CHECKING: + from avwx.structs import Code, MetarData, Number, TafData, TafLineData, Timestamp, Units -def ordinal(n: int) -> Optional[str]: # pylint: disable=invalid-name - """Converts an int to it spoken ordinal representation""" +def ordinal(n: int) -> str | None: + """Convert an int to it spoken ordinal representation.""" if n < 0: return None return str(n) + "tsnrhtdd"[(n / 10 % 10 != 1) * (n % 10 < 4) * n % 10 :: 4] @@ -27,28 +28,25 @@ def ordinal(n: int) -> Optional[str]: # pylint: disable=invalid-name def _format_plural_unit(value: str, unit: str) -> str: spoken = SPOKEN_UNITS.get(unit, unit) value = re.sub(r"(?<=\b1)" + unit, f" {spoken}", value) # 1 knot - value = re.sub(r"(?<=\d)+" + unit, f" {spoken}s", value) # 2 knots - return value + return re.sub(r"(?<=\d)+" + unit, f" {spoken}s", value) # 2 knots def wind( direction: Number, speed: Number, - gust: Optional[Number], - vardir: Optional[List[Number]] = None, + gust: Number | None, + vardir: list[Number] | None = None, unit: str = "kt", ) -> str: - """Format wind details into a spoken word string""" - val = translate_base.wind( - direction, speed, gust, vardir, unit, cardinals=False, spoken=True - ) + """Format wind details into a spoken word string.""" + val = translate_base.wind(direction, speed, gust, vardir, unit, cardinals=False, spoken=True) if val and unit in SPOKEN_UNITS: val = _format_plural_unit(val, unit) return "Winds " + (val or "unknown") def temperature(header: str, temp: Number, unit: str = "C") -> str: - """Format temperature details into a spoken word string""" + """Format temperature details into a spoken word string.""" if not temp or temp.value is None: return f"{header} unknown" unit = SPOKEN_UNITS.get(unit, unit) @@ -57,7 +55,7 @@ def temperature(header: str, temp: Number, unit: str = "C") -> str: def visibility(vis: Number, unit: str = "m") -> str: - """Format visibility details into a spoken word string""" + """Format visibility details into a spoken word string.""" if not vis: return "Visibility unknown" if vis.value is None or "/" in vis.repr: @@ -81,37 +79,35 @@ def visibility(vis: Number, unit: str = "m") -> str: def altimeter(alt: Number, unit: str = "inHg") -> str: - """Format altimeter details into a spoken word string""" + """Format altimeter details into a spoken word string.""" ret = "Altimeter " if not alt: ret += "unknown" elif unit == "inHg": - ret += core.spoken_number(str(alt.value).ljust(5, "0"), True) + ret += core.spoken_number(str(alt.value).ljust(5, "0"), literal=True) elif unit == "hPa": - ret += core.spoken_number(str(alt.value).zfill(4), True) + ret += core.spoken_number(str(alt.value).zfill(4), literal=True) return ret -def wx_codes(codes: List[Code]) -> str: - """ - Format wx codes into a spoken word string - """ +def wx_codes(codes: list[Code]) -> str: + """Format wx codes into a spoken word string.""" ret = [] for code in codes: item = code.value if item.startswith("Vicinity"): - item = item.lstrip("Vicinity ") + " in the Vicinity" + item = item.removeprefix("Vicinity ") + " in the Vicinity" ret.append(item) return ". ".join(ret) def type_and_times( - type: str, - start: Optional[Timestamp], - end: Optional[Timestamp], - probability: Optional[Number] = None, + type: str, # noqa: A002 + start: Timestamp | None, + end: Timestamp | None, + probability: Number | None = None, ) -> str: - """Format line type and times into the beginning of a spoken line string""" + """Format line type and times into the beginning of a spoken line string.""" if not type: return "" start_time = start.dt.hour if start and start.dt else "an unknown start time" @@ -129,7 +125,7 @@ def type_and_times( def wind_shear(shear: str, unit_alt: str = "ft", unit_wind: str = "kt") -> str: - """Format wind shear string into a spoken word string""" + """Format wind shear string into a spoken word string.""" value = translate_taf.wind_shear(shear, unit_alt, unit_wind, spoken=True) if not value: return "Wind shear unknown" @@ -140,7 +136,7 @@ def wind_shear(shear: str, unit_alt: str = "ft", unit_wind: str = "kt") -> str: def metar(data: MetarData, units: Units) -> str: - """Convert MetarData into a string for text-to-speech""" + """Convert MetarData into a string for text-to-speech.""" speech = [] if data.wind_direction and data.wind_speed: speech.append( @@ -154,11 +150,7 @@ def metar(data: MetarData, units: Units) -> str: ) if data.visibility: speech.append(visibility(data.visibility, units.visibility)) - speech.append( - translate_base.clouds(data.clouds, units.altitude).replace( - " - Reported AGL", "" - ) - ) + speech.append(translate_base.clouds(data.clouds, units.altitude).replace(" - Reported AGL", "")) if data.wx_codes: speech.append(wx_codes(data.wx_codes)) if data.temperature: @@ -171,7 +163,7 @@ def metar(data: MetarData, units: Units) -> str: def taf_line(line: TafLineData, units: Units) -> str: - """Convert TafLineData into a string for text-to-speech""" + """Convert TafLineData into a string for text-to-speech.""" speech = [] start = type_and_times(line.type, line.start_time, line.end_time, line.probability) if line.wind_direction and line.wind_speed: @@ -192,11 +184,7 @@ def taf_line(line: TafLineData, units: Units) -> str: speech.append(altimeter(line.altimeter, units.altimeter)) if line.wx_codes: speech.append(wx_codes(line.wx_codes)) - speech.append( - translate_base.clouds(line.clouds, units.altitude).replace( - " - Reported AGL", "" - ) - ) + speech.append(translate_base.clouds(line.clouds, units.altitude).replace(" - Reported AGL", "")) if line.turbulence: speech.append(translate_taf.turb_ice(line.turbulence, units.altitude)) if line.icing: @@ -205,7 +193,7 @@ def taf_line(line: TafLineData, units: Units) -> str: def taf(data: TafData, units: Units) -> str: - """Convert TafData into a string for text-to-speech""" + """Convert TafData into a string for text-to-speech.""" try: month = data.start_time.dt.strftime(r"%B") # type: ignore day = ordinal(data.start_time.dt.day) or "Unknown" # type: ignore diff --git a/avwx/parsing/summary.py b/avwx/parsing/summary.py index 950ccb2..676450e 100644 --- a/avwx/parsing/summary.py +++ b/avwx/parsing/summary.py @@ -1,13 +1,11 @@ -""" -Contains functions for combining translations into a summary string -""" +"""Contains functions for combining translations into a summary string.""" # module from avwx.structs import MetarTrans, TafLineTrans def metar(trans: MetarTrans) -> str: - """Condense the translation strings into a single report summary string""" + """Condense the translation strings into a single report summary string.""" summary = [] if trans.wind: summary.append(f"Winds {trans.wind}") @@ -27,7 +25,7 @@ def metar(trans: MetarTrans) -> str: def taf(trans: TafLineTrans) -> str: - """Condense the translation strings into a single forecast summary string""" + """Condense the translation strings into a single forecast summary string.""" summary = [] if trans.wind: summary.append(f"Winds {trans.wind}") diff --git a/avwx/parsing/translate/base.py b/avwx/parsing/translate/base.py index bc5115c..a7b66ea 100644 --- a/avwx/parsing/translate/base.py +++ b/avwx/parsing/translate/base.py @@ -1,18 +1,17 @@ -""" -Contains functions for translating report data -""" +"""Functions for translating report data.""" # stdlib +from __future__ import annotations + from contextlib import suppress -from typing import List, Optional, Union # module from avwx.static.core import CLOUD_TRANSLATIONS from avwx.structs import Cloud, Code, Number, ReportTrans, SharedData, Units -def get_cardinal_direction(direction: Union[int, float]) -> str: - """Returns the cardinal direction (NSEW) for a degree direction +def get_cardinal_direction(direction: float) -> str: + """Return the cardinal direction (NSEW) for a degree direction. Wind Direction - Cheat Sheet: @@ -24,7 +23,6 @@ def get_cardinal_direction(direction: Union[int, float]) -> str: (270) -- 281/282 -- 303/304 -- (315) -- 326/327 -- 348/349 -- (360) """ - # pylint: disable=too-many-branches ret = "" if not isinstance(direction, int): direction = int(direction) @@ -70,18 +68,19 @@ def get_cardinal_direction(direction: Union[int, float]) -> str: WIND_DIR_REPR = {"000": "Calm", "VRB": "Variable"} -def wind( # pylint: disable=too-many-arguments - direction: Optional[Number], - speed: Optional[Number], - gust: Optional[Number], - vardir: Optional[List[Number]] = None, +def wind( + direction: Number | None, + speed: Number | None, + gust: Number | None, + vardir: list[Number] | None = None, unit: str = "kt", + *, cardinals: bool = True, spoken: bool = False, ) -> str: - """Format wind elements into a readable sentence + """Format wind elements into a readable sentence. - Returns the translation string + Returns the translation string. Ex: NNE-020 (variable 010 to 040) at 14kt gusting to 20kt """ @@ -118,8 +117,8 @@ def wind( # pylint: disable=too-many-arguments } -def visibility(vis: Optional[Number], unit: str = "m") -> str: - """Formats a visibility element into a string with both km and sm values +def visibility(vis: Number | None, unit: str = "m") -> str: + """Format a visibility element into a string with both km and sm values. Ex: 8km ( 5sm ) """ @@ -145,10 +144,10 @@ def visibility(vis: Optional[Number], unit: str = "m") -> str: return f"{value}{unit} ({converted})" -def temperature(temp: Optional[Number], unit: str = "C") -> str: - """Formats a temperature element into a string with both C and F values +def temperature(temp: Number | None, unit: str = "C") -> str: + """Format a temperature element into a string with both C and F values. - Used for both Temp and Dew + Used for both Temp and Dew. Ex: 34°C (93°F) """ @@ -166,8 +165,8 @@ def temperature(temp: Optional[Number], unit: str = "C") -> str: return f"{temp.value}°{unit} ({converted})" -def altimeter(alt: Optional[Number], unit: str = "hPa") -> str: - """Formats the altimeter element into a string with hPa and inHg values +def altimeter(alt: Number | None, unit: str = "hPa") -> str: + """Format the altimeter element into a string with hPa and inHg values. Ex: 30.11 inHg (10.20 hPa) """ @@ -186,10 +185,10 @@ def altimeter(alt: Optional[Number], unit: str = "hPa") -> str: return f"{value} {unit} ({converted})" -def clouds(values: Optional[List[Cloud]], unit: str = "ft") -> str: - """Format cloud list into a readable sentence +def clouds(values: list[Cloud] | None, unit: str = "ft") -> str: + """Format cloud list into a readable sentence. - Returns the translation string + Returns the translation string. Ex: Broken layer at 2200ft (Cumulonimbus), Overcast layer at 3600ft - Reported AGL """ @@ -206,16 +205,16 @@ def clouds(values: Optional[List[Cloud]], unit: str = "ft") -> str: return ", ".join(ret) + " - Reported AGL" if ret else "Sky clear" -def wx_codes(codes: List[Code]) -> str: - """Join WX code values +def wx_codes(codes: list[Code]) -> str: + """Join WX code values, - Returns the translation string + Returns the translation string, """ return ", ".join(code.value for code in codes) def current_shared(wxdata: SharedData, units: Units) -> ReportTrans: - """Translate Visibility, Altimeter, Clouds, and Other""" + """Translate Visibility, Altimeter, Clouds, and Other,""" return ReportTrans( visibility=visibility(wxdata.visibility, units.visibility), altimeter=altimeter(wxdata.altimeter, units.altimeter), diff --git a/avwx/parsing/translate/metar.py b/avwx/parsing/translate/metar.py index dfb1be3..1c4cae8 100644 --- a/avwx/parsing/translate/metar.py +++ b/avwx/parsing/translate/metar.py @@ -1,6 +1,4 @@ -""" -METAR data translation handlers -""" +"""METAR data translation handlers.""" import avwx.parsing.translate.base as _trans from avwx.parsing.translate import remarks @@ -8,7 +6,7 @@ def translate_metar(wxdata: MetarData, units: Units) -> MetarTrans: - """Returns translations for a MetarData object""" + """Return translations for a MetarData object.""" shared = _trans.current_shared(wxdata, units) return MetarTrans( altimeter=shared.altimeter, diff --git a/avwx/parsing/translate/remarks.py b/avwx/parsing/translate/remarks.py index 8e886b7..dfb8f7b 100644 --- a/avwx/parsing/translate/remarks.py +++ b/avwx/parsing/translate/remarks.py @@ -1,19 +1,20 @@ -""" -Remarks data translation handlers -""" +"""Remarks data translation handlers.""" -# stdlib -from typing import Dict, Optional -from avwx.structs import Number, PressureTendency, RemarksData +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from avwx.structs import Number, PressureTendency, RemarksData def temp_minmax(label: str, code: Number) -> str: - """Translates a minimum or maximum temperature value""" + """Translate a minimum or maximum temperature value.""" return f"6-hour {label} temperature {code.value}°C" def pressure_tendency(pressure: PressureTendency, unit: str = "mb") -> str: - """Translates a pressure outlook value + """Translate a pressure outlook value. Ex: "50123" -> 12.3 mb: Increasing, then decreasing """ @@ -22,27 +23,27 @@ def pressure_tendency(pressure: PressureTendency, unit: str = "mb") -> str: def precip(label: str, code: Number, unit: str = "in") -> str: - """Translates a labelled precipitation value""" + """Translate a labelled precipitation value.""" return f"Precipitation in the last {label}: {code.value} {unit}" def sunshine_duration(code: Number, unit: str = "minutes") -> str: - """Translates a sunlight duration value""" + """Translate a sunlight duration value.""" return f"Duration of sunlight: {code.value} {unit}" def snow_depth(code: Number, unit: str = "in") -> str: - """Translates a snow accumulation value""" + """Translate a snow accumulation value.""" return f"Snow accumulation: {code.value} {unit}" def sea_level_pressure(code: Number) -> str: - """Translates a sea level pressure value""" + """Translate a sea level pressure value.""" return f"Sea level pressure: {code.value} hPa" -def remarks_data(data: RemarksData) -> Dict[str, str]: - """Extract translations from parsed remarks data""" +def remarks_data(data: RemarksData) -> dict[str, str]: + """Extract translations from parsed remarks data.""" ret = {} if data.temperature_decimal and data.dewpoint_decimal: temp, dew = data.temperature_decimal, data.dewpoint_decimal @@ -75,12 +76,12 @@ def remarks_data(data: RemarksData) -> Dict[str, str]: return ret -def translate(raw: Optional[str], data: Optional[RemarksData]) -> Dict[str, str]: - """Translates elements in the remarks string""" +def translate(raw: str | None, data: RemarksData | None) -> dict[str, str]: + """Translate elements in the remarks string.""" if not (raw and data): return {} # Add static codes ret = {code.repr: code.value for code in data.codes} # Add features from the parsed remarks data - ret.update(remarks_data(data)) + ret |= remarks_data(data) return ret diff --git a/avwx/parsing/translate/taf.py b/avwx/parsing/translate/taf.py index ea0ef19..d843fe0 100644 --- a/avwx/parsing/translate/taf.py +++ b/avwx/parsing/translate/taf.py @@ -1,40 +1,36 @@ -""" -TAF data translation handlers -""" +"""TAF data translation handlers.""" # stdlib -from typing import List, Optional +from __future__ import annotations # module import avwx.parsing.translate.base as _trans from avwx.parsing import core - from avwx.parsing.translate import remarks from avwx.static.taf import ICING_CONDITIONS, TURBULENCE_CONDITIONS from avwx.structs import TafData, TafLineTrans, TafTrans, Units def wind_shear( - shear: Optional[str], + shear: str | None, unit_alt: str = "ft", unit_wind: str = "kt", + *, spoken: bool = False, ) -> str: - """Translate wind shear into a readable string + """Translate wind shear into a readable string. Ex: Wind shear 2000ft from 140 at 30kt """ if not shear or "WS" not in shear or "/" not in shear: return "" altitude, wind = shear[2:].rstrip(unit_wind.upper()).split("/") - wdir = core.spoken_number(wind[:3], True) if spoken else wind[:3] - return ( - f"Wind shear {int(altitude)*100}{unit_alt} from {wdir} at {wind[3:]}{unit_wind}" - ) + wdir = core.spoken_number(wind[:3], literal=True) if spoken else wind[:3] + return f"Wind shear {int(altitude)*100}{unit_alt} from {wdir} at {wind[3:]}{unit_wind}" -def turb_ice(values: List[str], unit: str = "ft") -> str: - """Translate the list of turbulence or icing into a readable sentence +def turb_ice(values: list[str], unit: str = "ft") -> str: + """Translate the list of turbulence or icing into a readable sentence. Ex: Occasional moderate turbulence in clouds from 3000ft to 14000ft """ @@ -65,8 +61,8 @@ def turb_ice(values: List[str], unit: str = "ft") -> str: ) -def min_max_temp(temp: Optional[str], unit: str = "C") -> str: - """Format the Min and Max temp elements into a readable string +def min_max_temp(temp: str | None, unit: str = "C") -> str: + """Format the Min and Max temp elements into a readable string. Ex: Maximum temperature of 23°C (73°F) at 18-15:00Z """ @@ -86,8 +82,8 @@ def min_max_temp(temp: Optional[str], unit: str = "C") -> str: def translate_taf(wxdata: TafData, units: Units) -> TafTrans: - """Returns translations for a TafData object""" - forecast: List[TafLineTrans] = [] + """Return translations for a TafData object.""" + forecast: list[TafLineTrans] = [] for line in wxdata.forecast: shared = _trans.current_shared(line, units) # Remove false 'Sky Clear' if line type is 'BECMG' diff --git a/avwx/service/__init__.py b/avwx/service/__init__.py index 3429e1e..24d16b2 100644 --- a/avwx/service/__init__.py +++ b/avwx/service/__init__.py @@ -1,33 +1,30 @@ -""" -.. include:: ../../docs/service.md -""" +""".. include:: ../../docs/service.md""" -from .base import Service -from .files import NOAA_GFS, NOAA_NBM -from .scrape import ( +from avwx.service.base import Service +from avwx.service.files import NoaaGfs, NoaaNbm +from avwx.service.scrape import ( + Amo, + Aubom, + Avt, + FaaNotam, + Mac, + Nam, + Noaa, + Olbs, get_service, - NOAA, - AMO, - AVT, - MAC, - AUBOM, - OLBS, - NAM, - FAA_NOTAM, ) - __all__ = ( - "AMO", - "AUBOM", - "AVT", - "FAA_NOTAM", + "Amo", + "Aubom", + "Avt", + "FaaNotam", "get_service", - "MAC", - "NAM", - "NOAA_GFS", - "NOAA_NBM", - "NOAA", - "OLBS", + "Mac", + "Nam", + "NoaaGfs", + "NoaaNbm", + "Noaa", + "Olbs", "Service", ) diff --git a/avwx/service/base.py b/avwx/service/base.py index 798e20d..9cdf69a 100644 --- a/avwx/service/base.py +++ b/avwx/service/base.py @@ -1,21 +1,19 @@ -""" -All service classes are based on the base Service class. Implementation is mostly left to the other high-level subclasses. -""" - -# pylint: disable=too-few-public-methods,unsubscriptable-object +"""All service classes are based on the base Service class. Implementation is mostly left to the other high-level subclasses.""" # stdlib +from __future__ import annotations + from socket import gaierror -from typing import Any, Optional, Tuple +from typing import Any, ClassVar + +import httpcore # library import httpx -import httpcore # module from avwx.exceptions import SourceError - _TIMEOUT_ERRORS = ( httpx.ConnectTimeout, httpx.ReadTimeout, @@ -34,22 +32,21 @@ class Service: - """Base Service class for fetching reports""" + """Base Service class for fetching reports.""" report_type: str - _url: str = "" - _valid_types: Tuple[str, ...] = tuple() + _url: ClassVar[str] = "" + _valid_types: ClassVar[tuple[str, ...]] = () def __init__(self, report_type: str): if self._valid_types and report_type not in self._valid_types: - raise ValueError( - f"'{report_type}' is not a valid report type for {self.__class__.__name__}. Expected {self._valid_types}" - ) + msg = f"'{report_type}' is not a valid report type for {self.__class__.__name__}. Expected {self._valid_types}" + raise ValueError(msg) self.report_type = report_type @property - def root(self) -> Optional[str]: - """Returns the service's root URL""" + def root(self) -> str | None: + """Return the service's root URL.""" if self._url is None: return None url = self._url[self._url.find("//") + 2 :] @@ -57,15 +54,15 @@ def root(self) -> Optional[str]: class CallsHTTP: - """Service mixin supporting HTTP requests""" + """Service mixin supporting HTTP requests.""" - method: str = "GET" + method: ClassVar[str] = "GET" - async def _call( # pylint: disable=too-many-arguments + async def _call( self, url: str, - params: Optional[dict] = None, - headers: Optional[dict] = None, + params: dict | None = None, + headers: dict | None = None, data: Any = None, timeout: int = 10, retries: int = 3, @@ -78,26 +75,25 @@ async def _call( # pylint: disable=too-many-arguments ) as client: for _ in range(retries): if self.method.lower() == "post": - resp = await client.post( - url, params=params, headers=headers, data=data - ) + resp = await client.post(url, params=params, headers=headers, data=data) else: resp = await client.get(url, params=params, headers=headers) if resp.status_code == 200: break # Skip retries if remote server error if resp.status_code >= 500: - raise SourceError(f"{name} server returned {resp.status_code}") + msg = f"{name} server returned {resp.status_code}" + raise SourceError(msg) else: - raise SourceError(f"{name} server returned {resp.status_code}") + msg = f"{name} server returned {resp.status_code}" + raise SourceError(msg) except _TIMEOUT_ERRORS as timeout_error: - raise TimeoutError(f"Timeout from {name} server") from timeout_error + msg = f"Timeout from {name} server" + raise TimeoutError(msg) from timeout_error except _CONNECTION_ERRORS as connect_error: - raise ConnectionError( - f"Unable to connect to {name} server" - ) from connect_error + msg = f"Unable to connect to {name} server" + raise ConnectionError(msg) from connect_error except _NETWORK_ERRORS as network_error: - raise ConnectionError( - f"Unable to read data from {name} server" - ) from network_error + msg = f"Unable to read data from {name} server" + raise ConnectionError(msg) from network_error return str(resp.text) diff --git a/avwx/service/bulk.py b/avwx/service/bulk.py index e7ee67a..c648254 100644 --- a/avwx/service/bulk.py +++ b/avwx/service/bulk.py @@ -8,18 +8,16 @@ `List[str]` instead. """ -# pylint: disable=invalid-name - # stdlib import asyncio as aio from contextlib import suppress -from typing import List +from typing import ClassVar from avwx.service.base import CallsHTTP, Service -class NOAA_Bulk(Service, CallsHTTP): - """Subclass for extracting current reports from NOAA CSV files +class NoaaBulk(Service, CallsHTTP): + """Subclass for extracting current reports from NOAA CSV files. This class accepts `"metar"`, `"taf"`, `"aircraftreport"`, and `"airsigmet"` as valid report types. @@ -27,8 +25,8 @@ class NOAA_Bulk(Service, CallsHTTP): _url = "https://aviationweather.gov/data/cache/{}s.cache.csv" _valid_types = ("metar", "taf", "aircraftreport", "airsigmet") - _rtype_map = {"airep": "aircraftreport", "pirep": "aircraftreport"} - _targets = {"aircraftreport": -2} # else 0 + _rtype_map: ClassVar[dict[str, str]] = {"airep": "aircraftreport", "pirep": "aircraftreport"} + _targets: ClassVar[dict[str, int]] = {"aircraftreport": -2} # else 0 def __init__(self, report_type: str): super().__init__(self._rtype_map.get(report_type, report_type)) @@ -40,55 +38,52 @@ def _clean_report(report: str) -> str: report = report.replace(remove, " ") return " ".join(report.split()) - def _extract(self, raw: str) -> List[str]: + def _extract(self, raw: str) -> list[str]: reports = [] index = self._targets.get(self.report_type, 0) for line in raw.split("\n")[6:]: with suppress(IndexError): - report = self._clean_report(line.split(",")[index]) - if report: + if report := self._clean_report(line.split(",")[index]): reports.append(report) return reports - def fetch(self, timeout: int = 10) -> List[str]: - """Bulk fetch report strings from the service""" + def fetch(self, timeout: int = 10) -> list[str]: + """Bulk fetch report strings from the service.""" return aio.run(self.async_fetch(timeout)) - async def async_fetch(self, timeout: int = 10) -> List[str]: - """Asynchronously bulk fetch report strings from the service""" + async def async_fetch(self, timeout: int = 10) -> list[str]: + """Asynchronously bulk fetch report strings from the service.""" url = self._url.format(self.report_type) text = await self._call(url, timeout=timeout) return self._extract(text) -class NOAA_Intl(Service, CallsHTTP): +class NoaaIntl(Service, CallsHTTP): """Scrapes international reports from NOAA. Designed to - accompany `NOAA_Bulk` for AIRMET / SIGMET fetch. + accompany `NoaaBulk` for AIRMET / SIGMET fetch. Currently, this class only accepts `"airsigmet"` as a valid report type. """ _url = "https://www.aviationweather.gov/api/data/{}" _valid_types = ("airsigmet",) - _url_map = {"airsigmet": "isigmet"} + _url_map: ClassVar[dict[str, str]] = {"airsigmet": "isigmet"} @staticmethod def _clean_report(report: str) -> str: lines = report.split() return " ".join([line for line in lines if not line.startswith("Hazard:")]) - def _extract(self, raw: str) -> List[str]: - reports = [] - for line in raw.split("----------------------"): - reports.append(self._clean_report(line.strip().strip('"'))) - return reports + def _extract(self, raw: str) -> list[str]: + split = "----------------------" + return [self._clean_report(line.strip().strip('"')) for line in raw.split(split)] - def fetch(self, timeout: int = 10) -> List[str]: - """Bulk fetch report strings from the service""" + def fetch(self, timeout: int = 10) -> list[str]: + """Bulk fetch report strings from the service.""" return aio.run(self.async_fetch(timeout)) - async def async_fetch(self, timeout: int = 10) -> List[str]: - """Asynchronously bulk fetch report strings from the service""" + async def async_fetch(self, timeout: int = 10) -> list[str]: + """Asynchronously bulk fetch report strings from the service.""" url = self._url.format(self._url_map[self.report_type]) text = await self._call(url, timeout=timeout) return self._extract(text) diff --git a/avwx/service/files.py b/avwx/service/files.py index fd196fc..83e1396 100644 --- a/avwx/service/files.py +++ b/avwx/service/files.py @@ -7,9 +7,9 @@ to all downloaded reports. """ -# pylint: disable=invalid-name,arguments-differ - # stdlib +from __future__ import annotations + import asyncio as aio import atexit import datetime as dt @@ -18,7 +18,7 @@ from contextlib import suppress from pathlib import Path from socket import gaierror -from typing import Dict, Iterator, List, Optional, TextIO, Tuple +from typing import TYPE_CHECKING, ClassVar, TextIO # library import httpx @@ -27,8 +27,10 @@ from avwx.service.base import Service from avwx.station import valid_station +if TYPE_CHECKING: + from collections.abc import Iterator -_TEMP_DIR = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with +_TEMP_DIR = tempfile.TemporaryDirectory() _TEMP = Path(_TEMP_DIR.name) @@ -37,12 +39,12 @@ @atexit.register def _cleanup() -> None: - """Deletes temporary files and directory at Python exit""" + """Deletes temporary files and directory at Python exit.""" _TEMP_DIR.cleanup() class FileService(Service): - """Service class for fetching reports via managed source files""" + """Service class for fetching reports via managed source files.""" update_interval: dt.timedelta = dt.timedelta(minutes=10) _updating: bool = False @@ -52,15 +54,15 @@ def _file_stem(self) -> str: return f"{self.__class__.__name__}.{self.report_type}" @property - def _file(self) -> Optional[Path]: - """Path object of the managed data file""" + def _file(self) -> Path | None: + """Path object of the managed data file.""" for path in _TEMP.glob(f"{self._file_stem}*"): return path return None @property - def last_updated(self) -> Optional[dt.datetime]: - """When the file was last updated""" + def last_updated(self) -> dt.datetime | None: + """When the file was last updated.""" file = self._file if file is None: return None @@ -72,7 +74,7 @@ def last_updated(self) -> Optional[dt.datetime]: @property def is_outdated(self) -> bool: - """If the file should be updated based on the update interval""" + """If the file should be updated based on the update interval.""" last = self.last_updated if last is None: return True @@ -89,19 +91,19 @@ async def _wait_until_updated(self) -> None: await aio.sleep(0.01) @property - def all(self) -> List[str]: - """All report strings available after updating""" - raise NotImplementedError() + def all(self) -> list[str]: + """All report strings available after updating.""" + raise NotImplementedError @property def _urls(self) -> Iterator[str]: - raise NotImplementedError() + raise NotImplementedError - def _extract(self, station: str, source: TextIO) -> Optional[str]: - raise NotImplementedError() + def _extract(self, station: str, source: TextIO) -> str | None: + raise NotImplementedError async def _update_file(self, timeout: int) -> bool: - """Finds and saves the most recent file""" + """Find and save the most recent file.""" # Find the most recent file async with httpx.AsyncClient(timeout=timeout) as client: for url in self._urls: @@ -121,10 +123,10 @@ async def _update_file(self, timeout: int) -> bool: new_file.write(resp.content) return True - async def update(self, wait: bool = False, timeout: int = 10) -> bool: - """Update the stored file and returns success + async def update(self, *, wait: bool = False, timeout: int = 10) -> bool: + """Update the stored file and returns success. - If wait, this will block if the file is already being updated + If wait, this will block if the file is already being updated. """ # Guard for other async calls if self._updating: @@ -144,45 +146,42 @@ async def update(self, wait: bool = False, timeout: int = 10) -> bool: self._updating = False return True - def fetch( - self, station: str, wait: bool = True, timeout: int = 10, force: bool = False - ) -> Optional[str]: - """Fetch a report string from the source file + def fetch(self, station: str, *, wait: bool = True, timeout: int = 10, force: bool = False) -> str | None: + """Fetch a report string from the source file. - If wait, this will block if the file is already being updated + If wait, this will block if the file is already being updated. - Can force the service to fetch a new file + Can force the service to fetch a new file. """ - return aio.run(self.async_fetch(station, wait, timeout, force)) + return aio.run(self.async_fetch(station, wait=wait, timeout=timeout, force=force)) async def async_fetch( - self, station: str, wait: bool = True, timeout: int = 10, force: bool = False - ) -> Optional[str]: - """Asynchronously fetch a report string from the source file + self, station: str, *, wait: bool = True, timeout: int = 10, force: bool = False + ) -> str | None: + """Asynchronously fetch a report string from the source file. - If wait, this will block if the file is already being updated + If wait, this will block if the file is already being updated. - Can force the service to fetch a new file + Can force the service to fetch a new file. """ valid_station(station) if wait and self._updating: await self._wait_until_updated() - if (force or self.is_outdated) and not await self.update(wait, timeout): + if (force or self.is_outdated) and not await self.update(wait=wait, timeout=timeout): return None file = self._file if file is None: return None with file.open() as fin: - report = self._extract(station, fin) - return report + return self._extract(station, fin) -class NOAA_Forecast(FileService): - """Subclass for extracting reports from NOAA FTP files""" +class NoaaForecast(FileService): + """Subclass for extracting reports from NOAA FTP files.""" @property - def all(self) -> List[str]: - """All report strings available after updating""" + def all(self) -> list[str]: + """All report strings available after updating.""" if self._file is None: return [] with self._file.open() as fin: @@ -198,11 +197,11 @@ def all(self) -> List[str]: report = "" return reports - def _index_target(self, station: str) -> Tuple[str, str]: - raise NotImplementedError() + def _index_target(self, station: str) -> tuple[str, str]: + raise NotImplementedError - def _extract(self, station: str, source: TextIO) -> Optional[str]: - """Returns report pulled from the saved file""" + def _extract(self, station: str, source: TextIO) -> str | None: + """Return report pulled from the saved file.""" start, end = self._index_target(station) txt = source.read() txt = txt[txt.find(start) :] @@ -210,22 +209,22 @@ def _extract(self, station: str, source: TextIO) -> Optional[str]: lines = [] for line in txt.split("\n"): if "CLIMO" not in line: - line = line.strip() + line = line.strip() # noqa: PLW2901 if not line: break lines.append(line) return "\n".join(lines) or None -class NOAA_NBM(NOAA_Forecast): - """Requests forecast data from NOAA NBM FTP servers""" +class NoaaNbm(NoaaForecast): + """Request forecast data from NOAA NBM FTP servers.""" _url = "https://nomads.ncep.noaa.gov/pub/data/nccf/com/blend/prod/blend.{}/{}/text/blend_{}tx.t{}z" _valid_types = ("nbh", "nbs", "nbe", "nbx") @property def _urls(self) -> Iterator[str]: - """Iterates through hourly updates no older than two days""" + """Iterate through hourly updates no older than two days.""" date = dt.datetime.now(tz=dt.timezone.utc) cutoff = date - dt.timedelta(days=1) while date > cutoff: @@ -234,24 +233,25 @@ def _urls(self) -> Iterator[str]: yield self._url.format(timestamp, hour, self.report_type, hour) date -= dt.timedelta(hours=1) - def _index_target(self, station: str) -> Tuple[str, str]: + def _index_target(self, station: str) -> tuple[str, str]: return f"{station} ", f"{self.report_type.upper()} GUIDANCE" -class NOAA_GFS(NOAA_Forecast): - """Requests forecast data from NOAA GFS FTP servers""" +class NoaaGfs(NoaaForecast): + """Request forecast data from NOAA GFS FTP servers.""" _url = "https://nomads.ncep.noaa.gov/pub/data/nccf/com/gfs/prod/gfsmos.{}/mdl_gfs{}.t{}z" _valid_types = ("mav", "mex") - _cycles: Dict[str, Tuple[int, ...]] = {"mav": (0, 6, 12, 18), "mex": (0, 12)} + _cycles: ClassVar[dict[str, tuple[int, ...]]] = {"mav": (0, 6, 12, 18), "mex": (0, 12)} @property def _urls(self) -> Iterator[str]: - """Iterates through update cycles no older than two days""" + """Iterate through update cycles no older than two days.""" warnings.warn( "GFS fetch has been deprecated due to NOAA retiring the format. Migrate to NBM for similar data", DeprecationWarning, + stacklevel=2, ) now = dt.datetime.now(tz=dt.timezone.utc) date = dt.datetime.now(tz=dt.timezone.utc) @@ -266,7 +266,7 @@ def _urls(self) -> Iterator[str]: yield self._url.format(timestamp, self.report_type, hour) date -= dt.timedelta(hours=1) - def _index_target(self, station: str) -> Tuple[str, str]: + def _index_target(self, station: str) -> tuple[str, str]: return f"{station} GFS", f"{self.report_type.upper()} GUIDANCE" diff --git a/avwx/service/scrape.py b/avwx/service/scrape.py index f58aebd..356083e 100644 --- a/avwx/service/scrape.py +++ b/avwx/service/scrape.py @@ -3,30 +3,29 @@ Requests are ephemeral and will call the selected service each time. """ -# pylint: disable=arguments-differ,invalid-name,too-many-arguments - # stdlib +from __future__ import annotations + import asyncio as aio import json -import random import re +import secrets from contextlib import suppress -from typing import Any, Dict, List, Optional, Tuple, TypeVar, Union +from typing import Any, ClassVar, TypeVar # library from xmltodict import parse as parsexml +from avwx.exceptions import InvalidRequest + # module from avwx.parsing.core import dedupe -from avwx.exceptions import InvalidRequest from avwx.service.base import CallsHTTP, Service -from avwx.station import valid_station, Station +from avwx.station import Station, valid_station from avwx.structs import Coord - _T = TypeVar("_T") - _USER_AGENTS = [ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Safari/605.1.15" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Safari/605.1.15", @@ -37,32 +36,32 @@ ] -class ScrapeService(Service, CallsHTTP): # pylint: disable=too-few-public-methods - """Service class for fetching reports via direct web requests +class ScrapeService(Service, CallsHTTP): + """Service class for fetching reports via direct web requests. - Unless overwritten, this class accepts `"metar"` and `"taf"` as valid report types + Unless overwritten, this class accepts `"metar"` and `"taf"` as valid report types. """ default_timeout = 10 - _valid_types: Tuple[str, ...] = ("metar", "taf") - _strip_whitespace: bool = True + _valid_types: ClassVar[tuple[str, ...]] = ("metar", "taf") + _strip_whitespace: ClassVar[bool] = True def _make_err(self, body: str, key: str = "report path") -> InvalidRequest: - """Returns an InvalidRequest exception with formatted error message""" - msg = f"Could not find {key} in {self.__class__.__name__} response\n" - return InvalidRequest(msg + body) + """Return an InvalidRequest exception with formatted error message.""" + msg = f"Could not find {key} in {self.__class__.__name__} response\n{body}" + return InvalidRequest(msg) @staticmethod def _make_headers() -> dict: - """Returns request headers""" + """Return request headers.""" return {} - def _post_data(self, station: str) -> dict: # pylint: disable=unused-argument - """Returns the POST form/data payload""" + def _post_data(self, station: str) -> dict: # noqa: ARG002 + """Return the POST form/data payload.""" return {} def _clean_report(self, report: _T) -> _T: - """Replaces all *whitespace elements with a single space if enabled""" + """Replace all *whitespace elements with a single space if enabled.""" if not self._strip_whitespace: return report if isinstance(report, list): @@ -71,23 +70,24 @@ def _clean_report(self, report: _T) -> _T: class StationScrape(ScrapeService): - """Service class fetching reports from a station code""" + """Service class fetching reports from a station code.""" - def _make_url(self, station: str) -> Tuple[str, dict]: - """Returns a formatted URL and parameters""" + def _make_url(self, station: str) -> tuple[str, dict]: # noqa: ARG002 + """Return a formatted URL and parameters.""" return self._url, {} def _extract(self, raw: str, station: str) -> str: - """Extracts the report string from the service response""" - raise NotImplementedError() + """Extract the report string from the service response.""" + raise NotImplementedError - def _simple_extract(self, raw: str, starts: Union[str, List[str]], end: str) -> str: - """Simple extract by cutting at sequential start and end points""" + def _simple_extract(self, raw: str, starts: str | list[str], end: str) -> str: + """Simple extract by cutting at sequential start and end points.""" targets = [starts] if isinstance(starts, str) else starts for target in targets: index = raw.find(target) if index == -1: - raise self._make_err("The station might not exist") + msg = "The station might not exist" + raise self._make_err(msg) raw = raw[index:] report = raw[: raw.find(end)].strip() return " ".join(dedupe(report.split())) @@ -95,22 +95,20 @@ def _simple_extract(self, raw: str, starts: Union[str, List[str]], end: str) -> async def _fetch(self, station: str, url: str, params: dict, timeout: int) -> str: headers = self._make_headers() data = self._post_data(station) if self.method.lower() == "post" else None - text = await self._call( - url, params=params, headers=headers, data=data, timeout=timeout - ) + text = await self._call(url, params=params, headers=headers, data=data, timeout=timeout) report = self._extract(text, station) return self._clean_report(report) def fetch( self, station: str, - timeout: Optional[int] = None, + timeout: int | None = None, ) -> str: """Fetches a report string from the service""" return aio.run(self.async_fetch(station, timeout)) - async def async_fetch(self, station: str, timeout: Optional[int] = None) -> str: - """Asynchronously fetch a report string from the service""" + async def async_fetch(self, station: str, timeout: int | None = None) -> str: + """Asynchronously fetch a report string from the service.""" if timeout is None: timeout = self.default_timeout valid_station(station) @@ -121,42 +119,40 @@ async def async_fetch(self, station: str, timeout: Optional[int] = None) -> str: # Multiple sources for NOAA data -class NOAA_FTP(StationScrape): - """Requests data from NOAA via FTP""" +class NoaaFtp(StationScrape): + """Request data from NOAA via FTP.""" _url = "https://tgftp.nws.noaa.gov/data/{}/{}/stations/{}.TXT" - def _make_url(self, station: str) -> Tuple[str, dict]: - """Returns a formatted URL and parameters""" + def _make_url(self, station: str) -> tuple[str, dict]: + """Return a formatted URL and parameters.""" root = "forecasts" if self.report_type == "taf" else "observations" return self._url.format(root, self.report_type, station), {} def _extract(self, raw: str, station: str) -> str: - """Extracts the report using string finding""" + """Extract the report using string finding.""" raw = raw[raw.find(station) :] return raw[: raw.find('"')] -class _NOAA_ScrapeURL: - """Mixin implementing NOAA scrape service URL""" - - # pylint: disable=too-few-public-methods +class _NoaaScrapeUrl: + """Mixin implementing NOAA scrape service URL.""" report_type: str _url = "https://aviationweather.gov/cgi-bin/data/{}.php" - def _make_url(self, station: str, **kwargs: Union[int, str]) -> Tuple[str, dict]: - """Returns a formatted URL and parameters""" + def _make_url(self, station: str, **kwargs: int | str) -> tuple[str, dict]: + """Return a formatted URL and parameters.""" hours = 7 if self.report_type == "taf" else 2 params = {"ids": station, "format": "raw", "hours": hours, **kwargs} return self._url.format(self.report_type), params -class NOAA_Scrape(_NOAA_ScrapeURL, StationScrape): - """Requests data from NOAA via response scraping""" +class NoaaScrape(_NoaaScrapeUrl, StationScrape): + """Request data from NOAA via response scraping.""" - def _extract(self, raw: str, station: str) -> str: - """Extracts the first report""" + def _extract(self, raw: str, station: str) -> str: # noqa: ARG002 + """Extract the first report.""" report = "" for line in raw.strip().split("\n"): # Break when seeing the second non-indented line (next report) @@ -166,44 +162,40 @@ def _extract(self, raw: str, station: str) -> str: return report -class NOAA_ScrapeList(_NOAA_ScrapeURL, ScrapeService): - """Request listed data from NOAA via response scraping""" +class NoaaScrapeList(_NoaaScrapeUrl, ScrapeService): + """Request listed data from NOAA via response scraping.""" _valid_types = ("pirep",) - def _extract(self, raw: str, station: str) -> List[str]: - """Extracts the report strings""" + def _extract(self, raw: str, station: str) -> list[str]: # noqa: ARG002 + """Extract the report strings.""" return raw.strip().split("\n") - async def _fetch( - self, station: str, url: str, params: dict, timeout: int - ) -> List[str]: + async def _fetch(self, station: str, url: str, params: dict, timeout: int) -> list[str]: headers = self._make_headers() data = self._post_data(station) if self.method.lower() == "post" else None - text = await self._call( - url, params=params, headers=headers, data=data, timeout=timeout - ) + text = await self._call(url, params=params, headers=headers, data=data, timeout=timeout) report = self._extract(text, station) return self._clean_report(report) def fetch( self, - icao: Optional[str] = None, - coord: Optional[Coord] = None, + icao: str | None = None, + coord: Coord | None = None, radius: int = 10, - timeout: Optional[int] = None, - ) -> List[str]: - """Fetches a report string from the service""" + timeout: int | None = None, + ) -> list[str]: + """Fetche a report string from the service.""" return aio.run(self.async_fetch(icao, coord, radius, timeout)) async def async_fetch( self, - icao: Optional[str] = None, - coord: Optional[Coord] = None, + icao: str | None = None, + coord: Coord | None = None, radius: int = 10, - timeout: Optional[int] = None, - ) -> List[str]: - """Asynchronously fetch a report string from the service""" + timeout: int | None = None, + ) -> list[str]: + """Asynchronously fetch a report string from the service.""" if timeout is None: timeout = self.default_timeout station: str @@ -214,40 +206,38 @@ async def async_fetch( if ret := Station.nearest(coord.lat, coord.lon, max_coord_distance=radius): station = ret[0].icao or "" else: - raise ValueError( - f"No reference station near enough to {coord} to call service" - ) + msg = f"No reference station near enough to {coord} to call service" + raise ValueError(msg) url, params = self._make_url(station, distance=radius) return await self._fetch(station, url, params, timeout) -NOAA = NOAA_Scrape +Noaa = NoaaScrape # Regional data sources -class AMO(StationScrape): - """Requests data from AMO KMA for Korean stations""" +class Amo(StationScrape): + """Request data from AMO KMA for Korean stations.""" _url = "http://amoapi.kma.go.kr/amoApi/{}" default_timeout = 60 - def _make_url(self, station: str) -> Tuple[str, dict]: - """Returns a formatted URL and parameters""" + def _make_url(self, station: str) -> tuple[str, dict]: + """Return a formatted URL and parameters.""" return self._url.format(self.report_type), {"icao": station} - def _extract(self, raw: str, station: str) -> str: - """Extracts the report message from XML response""" + def _extract(self, raw: str, station: str) -> str: # noqa: ARG002 + """Extract the report message from XML response.""" resp = parsexml(raw) try: - report = resp["response"]["body"]["items"]["item"][ - f"{self.report_type.lower()}Msg" - ] + report = resp["response"]["body"]["items"]["item"][f"{self.report_type.lower()}Msg"] except KeyError as key_error: raise self._make_err(raw) from key_error if not report: - raise self._make_err("The station might not exist") + msg = "The station might not exist" + raise self._make_err(msg) # Replace line breaks report = report.replace("\n", "") # Remove excess leading and trailing data @@ -259,35 +249,35 @@ def _extract(self, raw: str, station: str) -> str: return " ".join(report.split()) -class MAC(StationScrape): - """Requests data from Meteorologia Aeronautica Civil for Columbian stations""" +class Mac(StationScrape): + """Request data from Meteorologia Aeronautica Civil for Columbian stations.""" _url = "https://meteorologia.aerocivil.gov.co/expert_text_query/parse" method = "POST" @staticmethod def _make_headers() -> dict: - """Returns request headers""" + """Return request headers.""" return {"X-Requested-With": "XMLHttpRequest"} def _post_data(self, station: str) -> dict: - """Returns the POST form/data payload""" + """Return the POST form/data payload.""" return {"query": f"{self.report_type} {station}"} def _extract(self, raw: str, station: str) -> str: - """Extracts the report message using string finding""" + """Extract the report message using string finding.""" return self._simple_extract(raw, f"{station.upper()} ", "=") -class AUBOM(StationScrape): - """Requests data from the Australian Bureau of Meteorology""" +class Aubom(StationScrape): + """Request data from the Australian Bureau of Meteorology.""" _url = "http://www.bom.gov.au/aviation/php/process.php" method = "POST" @staticmethod def _make_headers() -> dict: - """Returns request headers""" + """Return request headers.""" return { "Content-Type": "application/x-www-form-urlencoded", "Accept": "*/*", @@ -295,30 +285,31 @@ def _make_headers() -> dict: "Accept-Encoding": "gzip, deflate", "Host": "www.bom.gov.au", "Origin": "http://www.bom.gov.au", - "User-Agent": random.choice(_USER_AGENTS), + "User-Agent": secrets.choice(_USER_AGENTS), "Connection": "keep-alive", } def _post_data(self, station: str) -> dict: - """Returns the POST form""" + """Return the POST form.""" return {"keyword": station, "type": "search", "page": "TAF"} - def _extract(self, raw: str, station: str) -> str: - """Extracts the reports from HTML response""" + def _extract(self, raw: str, station: str) -> str: # noqa: ARG002 + """Extract the reports from HTML response.""" index = 1 if self.report_type == "taf" else 2 try: report = raw.split("") + 1 :] except IndexError as index_error: - raise self._make_err("The station might not exist") from index_error + msg = "The station might not exist" + raise self._make_err(msg) from index_error if report.startswith("<"): return "" report = report[: report.find("

")] return report.replace("
", " ") -class OLBS(StationScrape): - """Requests data from India OLBS flight briefing""" +class Olbs(StationScrape): + """Request data from India OLBS flight briefing.""" # _url = "https://olbs.amsschennai.gov.in/nsweb/FlightBriefing/showopmetquery.php" # method = "POST" @@ -326,25 +317,25 @@ class OLBS(StationScrape): # Temp redirect _url = "https://avbrief3.el.r.appspot.com/" - def _make_url(self, station: str) -> Tuple[str, dict]: - """Returns a formatted URL and empty parameters""" + def _make_url(self, station: str) -> tuple[str, dict]: + """Return a formatted URL and empty parameters.""" return self._url, {"icao": station} def _post_data(self, station: str) -> dict: - """Returns the POST form""" + """Return the POST form.""" # Can set icaos to "V*" to return all results return {"icaos": station, "type": self.report_type} @staticmethod def _make_headers() -> dict: - """Returns request headers""" + """Return request headers.""" return { # "Content-Type": "application/x-www-form-urlencoded", # "Accept": "text/html, */*; q=0.01", # "Accept-Language": "en-us", "Accept-Encoding": "gzip, deflate, br", # "Host": "olbs.amsschennai.gov.in", - "User-Agent": random.choice(_USER_AGENTS), + "User-Agent": secrets.choice(_USER_AGENTS), "Connection": "keep-alive", # "Referer": "https://olbs.amsschennai.gov.in/nsweb/FlightBriefing/", # "X-Requested-With": "XMLHttpRequest", @@ -355,49 +346,48 @@ def _make_headers() -> dict: } def _extract(self, raw: str, station: str) -> str: - """Extracts the reports from HTML response""" + """Extract the reports from HTML response.""" # start = raw.find(f"{self.report_type.upper()} {station} ") - return self._simple_extract( - raw, [f">{self.report_type.upper()}", station], "=" - ) + return self._simple_extract(raw, [f">{self.report_type.upper()}", station], "=") -class NAM(StationScrape): - """Requests data from NorthAviMet for North Atlantic and Nordic countries""" +class Nam(StationScrape): + """Request data from NorthAviMet for North Atlantic and Nordic countries.""" _url = "https://www.northavimet.com/NamConWS/rest/opmet/command/0/" - def _make_url(self, station: str) -> Tuple[str, dict]: - """Returns a formatted URL and empty parameters""" + def _make_url(self, station: str) -> tuple[str, dict]: + """Return a formatted URL and empty parameters.""" return self._url + station, {} def _extract(self, raw: str, station: str) -> str: - """Extracts the reports from HTML response""" + """Extract the reports from HTML response.""" starts = [f"{self.report_type.upper()} <", f">{station.upper()}<", " "] report = self._simple_extract(raw, starts, "=") return station + report[3:] -class AVT(StationScrape): - """Requests data from AVT/XiamenAir for China - NOTE: This should be replaced later with a gov+https source +class Avt(StationScrape): + """Request data from AVT/XiamenAir for China. + NOTE: This should be replaced later with a gov+https source. """ _url = "http://www.avt7.com/Home/AirportMetarInfo?airport4Code=" - def _make_url(self, station: str) -> Tuple[str, dict]: - """Returns a formatted URL and empty parameters""" + def _make_url(self, station: str) -> tuple[str, dict]: + """Return a formatted URL and empty parameters.""" return self._url + station, {} - def _extract(self, raw: str, station: str) -> str: - """Extracts the reports from HTML response""" + def _extract(self, raw: str, station: str) -> str: # noqa: ARG002 + """Extract the reports from HTML response.""" try: data = json.loads(raw) key = f"{self.report_type.lower()}ContentList" text: str = data[key]["rows"][0]["content"] - return text except (TypeError, json.decoder.JSONDecodeError, KeyError, IndexError): return "" + else: + return text # Ancilary scrape services @@ -408,8 +398,8 @@ def _extract(self, raw: str, station: str) -> str: # Search fields https://notams.aim.faa.gov/NOTAM_Search_User_Guide_V33.pdf -class FAA_NOTAM(ScrapeService): - """Sources NOTAMs from official FAA portal""" +class FaaNotam(ScrapeService): + """Source NOTAMs from official FAA portal.""" _url = "https://notams.aim.faa.gov/notamSearch/search" method = "POST" @@ -421,7 +411,7 @@ def _make_headers() -> dict: @staticmethod def _split_coord(prefix: str, value: float) -> dict: - """Adds coordinate deg/min/sec fields per float value""" + """Add coordinate deg/min/sec fields per float value.""" degree, minute, second = Coord.to_dms(value) if prefix == "lat": key = "latitude" @@ -438,21 +428,21 @@ def _split_coord(prefix: str, value: float) -> dict: def _post_for( self, - icao: Optional[str] = None, - coord: Optional[Coord] = None, - path: Optional[List[str]] = None, + icao: str | None = None, + coord: Coord | None = None, + path: list[str] | None = None, radius: int = 10, ) -> dict: - """Generate POST payload for search params in location order""" - data: Dict[str, Any] = {"notamsOnly": False, "radius": radius} + """Generate POST payload for search params in location order.""" + data: dict[str, Any] = {"notamsOnly": False, "radius": radius} if icao: data["searchType"] = 0 data["designatorsForLocation"] = icao elif coord: data["searchType"] = 3 data["radiusSearchOnDesignator"] = False - data.update(self._split_coord("lat", coord.lat)) - data.update(self._split_coord("long", coord.lon)) + data |= self._split_coord("lat", coord.lat) + data |= self._split_coord("long", coord.lon) elif path: data["searchType"] = 6 data["flightPathText"] = " ".join(path) @@ -463,29 +453,30 @@ def _post_for( data["flightPathIncludeRegulatory"] = False data["flightPathResultsType"] = "All NOTAMs" else: - raise InvalidRequest("Not enough info to request NOTAM data") + msg = "Not enough info to request NOTAM data" + raise InvalidRequest(msg) return data def fetch( self, - icao: Optional[str] = None, - coord: Optional[Coord] = None, - path: Optional[List[str]] = None, + icao: str | None = None, + coord: Coord | None = None, + path: list[str] | None = None, radius: int = 10, timeout: int = 10, - ) -> List[str]: - """Fetch NOTAM list from the service via ICAO, coordinate, or ident path""" + ) -> list[str]: + """Fetch NOTAM list from the service via ICAO, coordinate, or ident path.""" return aio.run(self.async_fetch(icao, coord, path, radius, timeout)) async def async_fetch( self, - icao: Optional[str] = None, - coord: Optional[Coord] = None, - path: Optional[List[str]] = None, + icao: str | None = None, + coord: Coord | None = None, + path: list[str] | None = None, radius: int = 10, timeout: int = 10, - ) -> List[str]: - """Async fetch NOTAM list from the service via ICAO, coordinate, or ident path""" + ) -> list[str]: + """Async fetch NOTAM list from the service via ICAO, coordinate, or ident path.""" headers = self._make_headers() data = self._post_for(icao, coord, path, radius) notams = [] @@ -493,7 +484,8 @@ async def async_fetch( text = await self._call(self._url, None, headers, data, timeout) resp: dict = json.loads(text) if resp.get("error"): - raise self._make_err("Search criteria appears to be invalid") + msg = "Search criteria appears to be invalid" + raise self._make_err(msg) for item in resp["notamList"]: if report := item.get("icaoMessage", "").strip(): report = _TAG_PATTERN.sub("", report).strip() @@ -508,39 +500,39 @@ async def async_fetch( PREFERRED = { - "RK": AMO, - "SK": MAC, + "RK": Amo, + "SK": Mac, } BY_COUNTRY = { - "AU": AUBOM, - # "CN": AVT, - "DK": NAM, - "EE": NAM, - "FI": NAM, - "FO": NAM, - "GL": NAM, - "IN": OLBS, - "IS": NAM, - "LV": NAM, - "NO": NAM, - "SE": NAM, + "AU": Aubom, + # "CN": Avt, + "DK": Nam, + "EE": Nam, + "FI": Nam, + "FO": Nam, + "GL": Nam, + "IN": Olbs, + "IS": Nam, + "LV": Nam, + "NO": Nam, + "SE": Nam, } def get_service(station: str, country_code: str) -> ScrapeService: - """Returns the preferred scrape service for a given station + """Return the preferred scrape service for a given station. ```python # Fetch Australian reports - station = 'YWOL' - country = 'AU' # can source from avwx.Station.country + station = "YWOL" + country = "AU" # can source from avwx.Station.country # Get the station's preferred service and initialize to fetch METARs - service = avwx.service.get_service(station, country)('metar') - # service is now avwx.service.AUBOM init'd to fetch METARs + service = avwx.service.get_service(station, country)("metar") + # service is now avwx.service.Aubom init'd to fetch METARs # Fetch the current METAR report = service.fetch(station) ``` """ with suppress(KeyError): return PREFERRED[station[:2]] # type: ignore - return BY_COUNTRY.get(country_code, NOAA) # type: ignore + return BY_COUNTRY.get(country_code, Noaa) # type: ignore diff --git a/avwx/static/__init__.py b/avwx/static/__init__.py index bfa5dfd..b76cb58 100644 --- a/avwx/static/__init__.py +++ b/avwx/static/__init__.py @@ -1,3 +1 @@ -""" -Contains static objects for internal and external use -""" +"""Contains static objects for internal and external use.""" diff --git a/avwx/static/airsigmet.py b/avwx/static/airsigmet.py index 59c16ea..13763eb 100644 --- a/avwx/static/airsigmet.py +++ b/avwx/static/airsigmet.py @@ -1,6 +1,4 @@ -""" -AIRMET / SIGMET static vales -""" +"""AIRMET / SIGMET static vales.""" AIRMET_KEY = "airmet" diff --git a/avwx/static/core.py b/avwx/static/core.py index 36da420..1b6c35c 100644 --- a/avwx/static/core.py +++ b/avwx/static/core.py @@ -1,5 +1,4 @@ -""" -Core static values for internal and external use +"""Core static values for internal and external use. METAR and TAF reports come in two variants depending on the station's location: North American & International. This affects both element diff --git a/avwx/static/gfs.py b/avwx/static/gfs.py index 7183509..2a97867 100644 --- a/avwx/static/gfs.py +++ b/avwx/static/gfs.py @@ -1,6 +1,4 @@ -""" -GFX service static values -""" +"""GFX service static values.""" #: UNITS = { diff --git a/avwx/static/glossary.py b/avwx/static/glossary.py index 1b2088c..0e76523 100644 --- a/avwx/static/glossary.py +++ b/avwx/static/glossary.py @@ -1,5 +1,4 @@ -""" -AVWX includes a compiled glossary of common report abbreviations that are +"""AVWX includes a compiled glossary of common report abbreviations that are listed separate from any other parsing mechanism. This is provided just for you to assist in translating the original reports or any item left in the `other` element. diff --git a/avwx/static/metar.py b/avwx/static/metar.py index 8abd6a8..dd7bb69 100644 --- a/avwx/static/metar.py +++ b/avwx/static/metar.py @@ -1,6 +1,4 @@ -""" -METAR static values -""" +"""METAR static values.""" METAR_RMK = [ " BLU", diff --git a/avwx/static/notam.py b/avwx/static/notam.py index 88d55b2..fe0dc5a 100644 --- a/avwx/static/notam.py +++ b/avwx/static/notam.py @@ -1,6 +1,4 @@ -""" -NOTAM static values -""" +"""NOTAM static values.""" # Q Codes sourced from FAA apprendix # https://www.faa.gov/air_traffic/publications/atpubs/notam_html/appendix_b.html diff --git a/avwx/static/taf.py b/avwx/static/taf.py index fdb1473..c7d1e8b 100644 --- a/avwx/static/taf.py +++ b/avwx/static/taf.py @@ -1,6 +1,4 @@ -""" -TAF static values -""" +"""TAF static values.""" TURBULENCE_CONDITIONS = { "0": "None", diff --git a/avwx/station/__init__.py b/avwx/station/__init__.py index 790007d..ba8b2d9 100644 --- a/avwx/station/__init__.py +++ b/avwx/station/__init__.py @@ -1,5 +1,4 @@ -""" -This module contains station/airport dataclasses and search functions. +"""This module contains station/airport dataclasses and search functions. For the purposes of AVWX, a station is any physical location that has an ICAO or GPS identification code. These are usually airports, but smaller locations @@ -13,10 +12,9 @@ - [avwx.Station](./station/station.html#Station) """ -from .meta import __LAST_UPDATED__, station_list, uses_na_format, valid_station -from .station import Station, nearest -from .search import search - +from avwx.station.meta import __LAST_UPDATED__, station_list, uses_na_format, valid_station +from avwx.station.search import search +from avwx.station.station import Station, nearest __all__ = ( "__LAST_UPDATED__", diff --git a/avwx/station/meta.py b/avwx/station/meta.py index f2d1d76..bdf5c3f 100644 --- a/avwx/station/meta.py +++ b/avwx/station/meta.py @@ -1,10 +1,9 @@ -""" -Shared list and metadata -""" +"""Shared list and metadata.""" # stdlib +from __future__ import annotations + from functools import lru_cache -from typing import List, Optional # module from avwx.exceptions import BadStation @@ -19,17 +18,13 @@ # maxsize = 2 ** number of boolean options @lru_cache(maxsize=2) -def station_list(reporting: bool = True) -> List[str]: - """Returns a list of station idents matching the search criteria""" - return [ - code - for code, station in STATIONS.items() - if not reporting or station["reporting"] - ] +def station_list(*, reporting: bool = True) -> list[str]: + """Return a list of station idents matching the search criteria.""" + return [code for code, station in STATIONS.items() if not reporting or station["reporting"]] -def uses_na_format(station: str, default: Optional[bool] = None) -> bool: - """Returns True if the station uses the North American format, +def uses_na_format(station: str, default: bool | None = None) -> bool: + """Return True if the station uses the North American format. False if the International format """ @@ -43,15 +38,17 @@ def uses_na_format(station: str, default: Optional[bool] = None) -> bool: return False if default is not None: return default - raise BadStation("Station doesn't start with a recognized character set") + msg = "Station doesn't start with a recognized character set" + raise BadStation(msg) def valid_station(station: str) -> None: - """Checks the validity of a station ident + """Check the validity of a station ident. - This function doesn't return anything. It merely raises a BadStation error if needed + This function doesn't return anything. It merely raises a BadStation error if needed. """ station = station.strip() if len(station) != 4: - raise BadStation("Report station ident must be four characters long") + msg = "Report station ident must be four characters long" + raise BadStation(msg) uses_na_format(station) diff --git a/avwx/station/search.py b/avwx/station/search.py index 9f2cd74..8896893 100644 --- a/avwx/station/search.py +++ b/avwx/station/search.py @@ -1,17 +1,20 @@ -""" -Station text-based search -""" +"""Station text-based search.""" # stdlib +from __future__ import annotations + from contextlib import suppress from functools import lru_cache -from typing import Iterable, List, Optional, Tuple +from typing import TYPE_CHECKING # module +from avwx.exceptions import MissingExtraModule from avwx.load_utils import LazyCalc from avwx.station.meta import STATIONS from avwx.station.station import Station, station_filter +if TYPE_CHECKING: + from collections.abc import Iterable # Catch import error only if user attemps a text search with suppress(ModuleNotFoundError): @@ -29,7 +32,7 @@ ] -def _format_search(airport: dict, keys: Iterable[str]) -> Optional[str]: +def _format_search(airport: dict, keys: Iterable[str]) -> str | None: values = [airport.get(k) for k in keys] code = values[0] or values[2] if not code: @@ -38,7 +41,7 @@ def _format_search(airport: dict, keys: Iterable[str]) -> Optional[str]: return " - ".join(k for k in values if k) -def _build_corpus() -> List[str]: +def _build_corpus() -> list[str]: keys = ("icao", "iata", "gps", "local", "city", "state", "name") return [text for s in STATIONS.values() if (text := _format_search(s, keys))] @@ -46,7 +49,7 @@ def _build_corpus() -> List[str]: _CORPUS = LazyCalc(_build_corpus) -def _sort_key(result: Tuple[Station, int]) -> Tuple[int, ...]: +def _sort_key(result: tuple[Station, int]) -> tuple[int, ...]: station, score = result try: type_order = TYPE_ORDER.index(station.type) @@ -59,12 +62,13 @@ def _sort_key(result: Tuple[Station, int]) -> Tuple[int, ...]: def search( text: str, limit: int = 10, + *, is_airport: bool = False, sends_reports: bool = True, -) -> List[Station]: - """Text search for stations against codes, name, city, and state +) -> list[Station]: + """Text search for stations against codes, name, city, and state. - Results may be shorter than limit value + Results may be shorter than limit value. """ try: results = process.extract( @@ -75,10 +79,9 @@ def search( processor=utils.default_process, ) except NameError as name_error: - raise ModuleNotFoundError( - 'rapidfuzz must be installed to use text search. Run "pip install avwx-engine[fuzz]" to enable this' - ) from name_error + extra = "fuzz" + raise MissingExtraModule(extra) from name_error results = [(Station.from_code(k[:4]), s) for k, s, _ in results] results.sort(key=_sort_key, reverse=True) - results = [s for s, _ in results if station_filter(s, is_airport, sends_reports)] + results = [s for s, _ in results if station_filter(s, is_airport=is_airport, reporting=sends_reports)] return results[:limit] if len(results) > limit else results diff --git a/avwx/station/station.py b/avwx/station/station.py index defce4a..0be718c 100644 --- a/avwx/station/station.py +++ b/avwx/station/station.py @@ -1,36 +1,34 @@ -""" -Station handling and coordinate search -""" - -# pylint: disable=invalid-name,too-many-arguments,too-many-instance-attributes +"""Station handling and coordinate search.""" # stdlib +from __future__ import annotations + from contextlib import suppress from copy import copy from dataclasses import dataclass from functools import lru_cache -from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar, Union +from typing import Any, Self # library import httpx -from geopy.distance import great_circle, Distance # type: ignore +from geopy.distance import Distance, great_circle # type: ignore # module -from avwx.exceptions import BadStation +from avwx.exceptions import BadStation, MissingExtraModule from avwx.load_utils import LazyCalc from avwx.station.meta import STATIONS from avwx.structs import Coord def _get_ip_location() -> Coord: - """Returns the current location according to ipinfo.io""" + """Return the current location according to ipinfo.io.""" lat, lon = httpx.get("https://ipinfo.io/loc").text.strip().split(",") return Coord(float(lat), float(lon)) @dataclass class Runway: - """Represents a runway at an airport""" + """Represent a runway at an airport.""" length_ft: int width_ft: int @@ -42,7 +40,6 @@ class Runway: bearing2: float -T = TypeVar("T", bound="Station") _ICAO = LazyCalc(lambda: {v["icao"]: k for k, v in STATIONS.items() if v["icao"]}) _IATA = LazyCalc(lambda: {v["iata"]: k for k, v in STATIONS.items() if v["iata"]}) _GPS = LazyCalc(lambda: {v["gps"]: k for k, v in STATIONS.items() if v["gps"]}) @@ -88,40 +85,39 @@ class Station: # pylint: disable=too-many-instance-attributes - city: Optional[str] + city: str | None country: str - elevation_ft: Optional[int] - elevation_m: Optional[int] - gps: Optional[str] - iata: Optional[str] - icao: Optional[str] + elevation_ft: int | None + elevation_m: int | None + gps: str | None + iata: str | None + icao: str | None latitude: float - local: Optional[str] + local: str | None longitude: float name: str - note: Optional[str] + note: str | None reporting: bool - runways: List[Runway] - state: Optional[str] + runways: list[Runway] + state: str | None type: str - website: Optional[str] - wiki: Optional[str] + website: str | None + wiki: str | None @classmethod - def _from_code(cls: Type[T], ident: str) -> T: + def _from_code(cls, ident: str) -> Self: try: - info: Dict[str, Any] = copy(STATIONS[ident]) + info: dict[str, Any] = copy(STATIONS[ident]) if info["runways"]: info["runways"] = [Runway(**r) for r in info["runways"]] return cls(**info) except (KeyError, AttributeError) as not_found: - raise BadStation( - f"Could not find station with ident {ident}" - ) from not_found + msg = f"Could not find station with ident {ident}" + raise BadStation(msg) from not_found @classmethod - def from_code(cls: Type[T], ident: str) -> T: - """Load a Station from ICAO, GPS, or IATA code in that order""" + def from_code(cls, ident: str) -> Self: + """Load a Station from ICAO, GPS, or IATA code in that order.""" if ident and isinstance(ident, str): if len(ident) == 4: with suppress(BadStation): @@ -133,66 +129,66 @@ def from_code(cls: Type[T], ident: str) -> T: return cls.from_iata(ident) with suppress(BadStation): return cls.from_local(ident) - raise BadStation(f"Could not find station with ident {ident}") + msg = f"Could not find station with ident {ident}" + raise BadStation(msg) @classmethod - def from_icao(cls: Type[T], ident: str) -> T: - """Load a Station from an ICAO station ident""" + def from_icao(cls, ident: str) -> Self: + """Load a Station from an ICAO station ident.""" try: return cls._from_code(_ICAO.value[ident.upper()]) except (KeyError, AttributeError) as not_found: - raise BadStation( - f"Could not find station with ICAO ident {ident}" - ) from not_found + msg = f"Could not find station with ICAO ident {ident}" + raise BadStation(msg) from not_found @classmethod - def from_iata(cls: Type[T], ident: str) -> T: - """Load a Station from an IATA code""" + def from_iata(cls, ident: str) -> Self: + """Load a Station from an IATA code.""" try: return cls._from_code(_IATA.value[ident.upper()]) except (KeyError, AttributeError) as not_found: - raise BadStation( - f"Could not find station with IATA ident {ident}" - ) from not_found + msg = f"Could not find station with IATA ident {ident}" + raise BadStation(msg) from not_found @classmethod - def from_gps(cls: Type[T], ident: str) -> T: - """Load a Station from a GPS code""" + def from_gps(cls, ident: str) -> Self: + """Load a Station from a GPS code.""" try: return cls._from_code(_GPS.value[ident.upper()]) except (KeyError, AttributeError) as not_found: - raise BadStation( - f"Could not find station with GPS ident {ident}" - ) from not_found + msg = f"Could not find station with GPS ident {ident}" + raise BadStation(msg) from not_found @classmethod - def from_local(cls: Type[T], ident: str) -> T: - """Load a Station from a local code""" + def from_local(cls, ident: str) -> Self: + """Load a Station from a local code.""" try: return cls._from_code(_LOCAL.value[ident.upper()]) except (KeyError, AttributeError) as not_found: - raise BadStation( - f"Could not find station with local ident {ident}" - ) from not_found + msg = f"Could not find station with local ident {ident}" + raise BadStation(msg) from not_found @classmethod def nearest( - cls: Type[T], - lat: Optional[float] = None, - lon: Optional[float] = None, + cls, + lat: float | None = None, + lon: float | None = None, + *, is_airport: bool = False, sends_reports: bool = True, max_coord_distance: float = 10, - ) -> Optional[Tuple[T, dict]]: - """Load the Station nearest to your location or a lat,lon coordinate pair + ) -> tuple[Self, dict] | None: + """Load the Station nearest to your location or a lat,lon coordinate pair. - Returns the Station and distances from source + Returns the Station and distances from source. NOTE: Becomes less accurate toward poles and doesn't cross +/-180 """ if not (lat and lon): lat, lon = _get_ip_location().pair - ret = nearest(lat, lon, 1, is_airport, sends_reports, max_coord_distance) + ret = nearest( + lat, lon, 1, is_airport=is_airport, sends_reports=sends_reports, max_coord_distance=max_coord_distance + ) if not isinstance(ret, dict): return None station = ret.pop("station") @@ -200,16 +196,17 @@ def nearest( @property def lookup_code(self) -> str: - """Returns the ICAO or GPS code for report fetch""" + """The ICAO or GPS code for report fetch.""" if self.icao: return self.icao if self.gps: return self.gps - raise BadStation("Station does not have a valid lookup code") + msg = "Station does not have a valid lookup code" + raise BadStation(msg) @property def storage_code(self) -> str: - """Returns the first unique-ish code from what's available""" + """The first unique-ish code from what's available.""" if self.icao: return self.icao if self.iata: @@ -218,29 +215,31 @@ def storage_code(self) -> str: return self.gps if self.local: return self.local - raise BadStation("Station does not have any useable codes") + msg = "Station does not have any useable codes" + raise BadStation(msg) @property def sends_reports(self) -> bool: - """Returns whether or not a Station likely sends weather reports""" + """Whether or not a Station likely sends weather reports.""" return self.reporting is True @property def coord(self) -> Coord: - """Returns the station location as a Coord""" + """The station location as a Coord.""" return Coord(lat=self.latitude, lon=self.longitude, repr=self.icao) def distance(self, lat: float, lon: float) -> Distance: - """Returns a geopy Distance using the great circle method""" + """Geopy Distance using the great circle method.""" return great_circle((lat, lon), (self.latitude, self.longitude)) def nearby( self, + *, is_airport: bool = False, sends_reports: bool = True, max_coord_distance: float = 10, - ) -> List[Tuple[T, dict]]: - """Returns Stations nearest to current station and their distances + ) -> list[tuple[Self, dict]]: + """Return Stations nearest to current station and their distances. NOTE: Becomes less accurate toward poles and doesn't cross +/-180 """ @@ -248,9 +247,9 @@ def nearby( self.latitude, self.longitude, 11, - is_airport, - sends_reports, - max_coord_distance, + is_airport=is_airport, + sends_reports=sends_reports, + max_coord_distance=max_coord_distance, ) if isinstance(stations, dict): return [] @@ -260,7 +259,7 @@ def nearby( # Coordinate search and resources -def _make_coords() -> List[Tuple]: +def _make_coords() -> list[tuple[str, float, float]]: return [ ( s["icao"] or s["gps"] or s["iata"] or s["local"], @@ -275,33 +274,29 @@ def _make_coords() -> List[Tuple]: def _make_coord_tree(): # type: ignore - # pylint: disable=import-outside-toplevel try: from scipy.spatial import KDTree # type: ignore return KDTree([c[1:] for c in _COORDS.value]) except (NameError, ModuleNotFoundError) as name_error: - raise ModuleNotFoundError( - 'scipy must be installed to use coordinate lookup. Run "pip install avwx-engine[scipy]" to enable this feature' - ) from name_error + extra = "scipy" + raise MissingExtraModule(extra) from name_error _COORD_TREE = LazyCalc(_make_coord_tree) -def _query_coords(lat: float, lon: float, n: int, d: float) -> List[Tuple[str, float]]: +def _query_coords(lat: float, lon: float, n: int, d: float) -> list[tuple[str, float]]: """Returns <= n number of ident, dist tuples <= d coord distance from lat,lon""" dist, index = _COORD_TREE.value.query([lat, lon], n, distance_upper_bound=d) if n == 1: dist, index = [dist], [index] # NOTE: index == len of list means Tree ran out of items - return [ - (_COORDS.value[i][0], d) for i, d in zip(index, dist) if i < len(_COORDS.value) - ] + return [(_COORDS.value[i][0], d) for i, d in zip(index, dist) if i < len(_COORDS.value)] -def station_filter(station: Station, is_airport: bool, reporting: bool) -> bool: - """Return True if station matches given criteria""" +def station_filter(station: Station, *, is_airport: bool, reporting: bool) -> bool: + """Return True if station matches given criteria.""" if is_airport and "airport" not in station.type: return False return bool(not reporting or station.sends_reports) @@ -309,12 +304,12 @@ def station_filter(station: Station, is_airport: bool, reporting: bool) -> bool: @lru_cache(maxsize=128) def _query_filter( - lat: float, lon: float, n: int, d: float, is_airport: bool, reporting: bool -) -> List[Tuple[Station, float]]: - """Returns <= n number of stations <= d distance from lat,lon matching the query params""" + lat: float, lon: float, n: int, d: float, *, is_airport: bool, reporting: bool +) -> list[tuple[Station, float]]: + """Return <= n number of stations <= d distance from lat,lon matching the query params.""" k = n * 20 last = 0 - stations: List[Tuple[Station, float]] = [] + stations: list[tuple[Station, float]] = [] while True: nodes = _query_coords(lat, lon, k, d)[last:] # Ran out of new stations @@ -324,7 +319,7 @@ def _query_filter( if not code: continue stn = Station.from_code(code) - if station_filter(stn, is_airport, reporting): + if station_filter(stn, is_airport=is_airport, reporting=reporting): stations.append((stn, dist)) # Reached the desired number of stations if len(stations) >= n: @@ -337,21 +332,20 @@ def nearest( lat: float, lon: float, n: int = 1, + *, is_airport: bool = False, sends_reports: bool = True, max_coord_distance: float = 10, -) -> Union[dict, List[dict]]: - """Finds the nearest n Stations to a lat,lon coordinate pair +) -> dict | list[dict]: + """Find the nearest n Stations to a lat,lon coordinate pair. - Returns the Station and coordinate distance from source + Returns the Station and coordinate distance from source. - NOTE: Becomes less accurate toward poles and doesn't cross +/-180 + NOTE: Becomes less accurate toward poles and doesn't cross +/-180. """ # Default state includes all, no filtering necessary if is_airport or sends_reports: - stations = _query_filter( - lat, lon, n, max_coord_distance, is_airport, sends_reports - ) + stations = _query_filter(lat, lon, n, max_coord_distance, is_airport, sends_reports) else: data = _query_coords(lat, lon, n, max_coord_distance) stations = [(Station.from_code(code), d) for code, d in data]