diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 67b38db..9b508db 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v3 diff --git a/README.md b/README.md index c2255d6..a9a8345 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,22 @@ The library features multiple clients for retrieving incidents: a web scraper, a ## Example ```python - +import asyncio import aiohttp -from lcwc.feed import Client +from lcwc.feed import FeedClient + + +async def main(): + client = FeedClient() + + async with aiohttp.ClientSession() as session: + incidents = await client.get_incidents(session) -client = Client() + for incident in incidents: + print(f'{incident.date} - {incident.description}') -async with aiohttp.ClientSession() as session: - incidents = await client.get_incidents(session) - for incident in incidents: - print(f'{incident.date} - {incident.description}') +asyncio.run(main()) ``` ## Notes diff --git a/pyproject.toml b/pyproject.toml index 43b2248..92f70a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,23 +1,30 @@ [project] name = "lcwc" -version = "0.14.0" +version = "0.15.0" authors = [ { name="Nate Shoffner", email="nate.shoffner@gmail.com" }, ] description = "Python library for fetching the Lancaster County-Wide Communications live incident list." keywords = ["lcwc", "lancaster", "police", "fire", "ems", "dispatch", "911", "incident"] readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] dependencies = [ - "bs4", - "aiohttp", - "feedparser", + "beautifulsoup4", + # 3.14.3 is the first release without a known advisory + "aiohttp>=3.14.3", + # 6.0.11 dropped the cgi import, which python 3.13 removed + "feedparser>=6.0.11", + "pydantic>=2,<3", "pytz" ] diff --git a/requirements.txt b/requirements.txt index a732a3e..4a0e3be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -aiohttp==3.10.2 -bs4==0.0.1 -feedparser==6.0.10 +aiohttp==3.14.3 +beautifulsoup4==4.12.2 +feedparser==6.0.14 pytz pydantic \ No newline at end of file diff --git a/src/lcwc/__init__.py b/src/lcwc/__init__.py index 1f3c7c9..d607537 100644 --- a/src/lcwc/__init__.py +++ b/src/lcwc/__init__.py @@ -2,7 +2,7 @@ __author__ = "Nate Shoffner" __license__ = "MIT" __copyright__ = "Copyright 2023-present Nate Shoffner" -__version__ = "0.14.0" +__version__ = "0.15.0" from .agencies.agency import Agency from .agencies.agencyclient import AgencyClient diff --git a/src/lcwc/agencies/agencyresolver.py b/src/lcwc/agencies/agencyresolver.py index 1a35da4..1c7b869 100644 --- a/src/lcwc/agencies/agencyresolver.py +++ b/src/lcwc/agencies/agencyresolver.py @@ -1,17 +1,17 @@ -import re +from typing import Optional + from lcwc.agencies import ALL_KNOWN_AGENCIES from lcwc.agencies.agency import Agency -from lcwc.agencies.exceptions import OutOfCountyException, PendingUnitException from lcwc.category import IncidentCategory -from lcwc.incident import Incident -from lcwc.unit import Unit class AgencyResolver: """Collection of dispatch and various lookup methods""" def __init__(self, load_known: bool = True): - self.agencies = ALL_KNOWN_AGENCIES if load_known else [] + # copy the compiled roster so add_agency/remove_agency only affect this + # resolver rather than every resolver in the process + self.agencies = list(ALL_KNOWN_AGENCIES) if load_known else [] def add_agency(self, agency: Agency): self.agencies.append(agency) @@ -19,7 +19,9 @@ def add_agency(self, agency: Agency): def remove_agency(self, agency: Agency): self.agencies.remove(agency) - def get_agency(self, station_id: str, category: IncidentCategory) -> Agency: + def get_agency( + self, station_id: str, category: IncidentCategory + ) -> Optional[Agency]: """Attempts to find the agency associated with the given station id and category within the list of agencies provided""" for agency in self.agencies: if agency.station_number == station_id and agency.category == category: @@ -30,11 +32,3 @@ def get_agencies(self, category: IncidentCategory) -> list[Agency]: def get_all_agencies(self) -> list[Agency]: return self.agencies - - def get_unit_agency(self, unit: Unit, category: IncidentCategory) -> Agency: - """Attempts to find the agency associated with the given unit and category within the list of agencies provided""" - - if unit.is_shorthand(): - return self.__unit_short_name_to_agency(unit, category) - else: - return self.__unit_name_to_agency(unit, category) diff --git a/src/lcwc/agencies/exceptions.py b/src/lcwc/agencies/exceptions.py deleted file mode 100644 index 2eb30cd..0000000 --- a/src/lcwc/agencies/exceptions.py +++ /dev/null @@ -1,6 +0,0 @@ -class OutOfCountyException(Exception): - pass - - -class PendingUnitException(Exception): - pass diff --git a/src/lcwc/arcgis/client.py b/src/lcwc/arcgis/client.py index 7d6c075..54a7a87 100644 --- a/src/lcwc/arcgis/client.py +++ b/src/lcwc/arcgis/client.py @@ -2,10 +2,10 @@ import aiohttp import datetime import re +from typing import Optional from lcwc import Client from lcwc.agencies.agencyresolver import AgencyResolver -from lcwc.agencies.exceptions import OutOfCountyException, PendingUnitException from lcwc.arcgis.incident import ArcGISIncident, Coordinates from lcwc.category import IncidentCategory from lcwc.unit import Unit @@ -20,9 +20,11 @@ class ArcGISException(Exception): class ArcGISClient(Client): """Client for the ArcGIS REST API""" - def __init__(self, agency_resolver: AgencyResolver = AgencyResolver()) -> None: + def __init__(self, agency_resolver: Optional[AgencyResolver] = None) -> None: super().__init__() - self.agency_resolver = agency_resolver + self.agency_resolver = ( + agency_resolver if agency_resolver is not None else AgencyResolver() + ) self.logger = logging.getLogger(__name__) @property diff --git a/src/lcwc/feed/client.py b/src/lcwc/feed/client.py index a30b761..7aaf3ce 100644 --- a/src/lcwc/feed/client.py +++ b/src/lcwc/feed/client.py @@ -1,7 +1,8 @@ +from typing import Optional + import aiohttp from lcwc import Client from lcwc.agencies.agencyresolver import AgencyResolver -from lcwc.agencies.exceptions import OutOfCountyException from lcwc.feed.incident import FeedIncident from lcwc.feed.parser import FeedParser @@ -13,8 +14,10 @@ class FeedClient(Client): URL = "https://webcad.lcwc911.us/Pages/Public/LiveIncidentsFeed.aspx" """ The URL of the live incident feed """ - def __init__(self, agency_resolver: AgencyResolver = AgencyResolver()) -> None: - self.agency_resolver = agency_resolver + def __init__(self, agency_resolver: Optional[AgencyResolver] = None) -> None: + self.agency_resolver = ( + agency_resolver if agency_resolver is not None else AgencyResolver() + ) self.parser = FeedParser() @property diff --git a/src/lcwc/feed/parser.py b/src/lcwc/feed/parser.py index c0ee4f9..4972aee 100644 --- a/src/lcwc/feed/parser.py +++ b/src/lcwc/feed/parser.py @@ -3,7 +3,6 @@ import feedparser as FP import pytz from lcwc.agencies.agencyresolver import AgencyResolver -from lcwc.agencies.exceptions import OutOfCountyException, PendingUnitException from lcwc.feed.incident import FeedIncident from lcwc.unit import Unit from lcwc.utils.unitparser import UnitParser, UnitParserException @@ -90,10 +89,6 @@ def has_unit_names(details_segment: str) -> bool: try: u = UnitParser.parse_unit(unit_name, category, agency_resolver) units.append(u) - except OutOfCountyException: - self.logger.debug(f"Unit {unit_name} is out of county") - except PendingUnitException: - self.logger.debug(f"Unit {unit_name} is pending") except UnitParserException: self.logger.debug(f"Unable to parse unit {unit_name}") diff --git a/src/lcwc/utils/unitconverter.py b/src/lcwc/utils/unitconverter.py new file mode 100644 index 0000000..82b2f1c --- /dev/null +++ b/src/lcwc/utils/unitconverter.py @@ -0,0 +1,57 @@ +from typing import Optional + + +class UnitConverter: + """Converts shorthand unit abbreviations into their long-hand names + + The ArcGIS feed condenses unit names into an abbreviation and an id + (ex: "ENG531"), so the abbreviation has to be expanded separately to render + a unit the way the web and RSS feeds spell it out. + """ + + def __init__(self): + self.mapping = { + # Fire + "BRU": "Brush", + "DEP": "Deputy", + "ENG": "Engine", + "RES": "Rescue", + "SQU": "Squad", + "TAC": "Tactical", + "TAN": "Tanker", + "TRA": "Tractor", + "TRK": "Truck", + "UTV": "Utility Vehicle", + # Medical + "AMB": "Ambulance", + "MED": "Medic", + "QRS": "Quick Response Service", + # Air + "AIR": "Air", + # Traffic + "P": "Police", + } + + def register(self, short_hand: str, long_hand: str) -> None: + """Registers a shorthand abbreviation, replacing any existing entry + + :param short_hand: The abbreviation as it appears in a unit name + :param long_hand: The name the abbreviation expands to + """ + self.mapping[short_hand.upper()] = long_hand + + def unregister(self, short_hand: str) -> None: + """Removes a shorthand abbreviation, ignoring unknown ones + + :param short_hand: The abbreviation to remove + """ + self.mapping.pop(short_hand.upper(), None) + + def convert(self, short_hand: str) -> Optional[str]: + """Expands the given abbreviation into its long-hand name + + :param short_hand: The abbreviation as it appears in a unit name + :return: The long-hand name, or None if the abbreviation is unknown + :rtype: Optional[str] + """ + return self.mapping.get(short_hand.upper()) diff --git a/src/lcwc/utils/unitparser.py b/src/lcwc/utils/unitparser.py index fb85aa1..cd53fe2 100644 --- a/src/lcwc/utils/unitparser.py +++ b/src/lcwc/utils/unitparser.py @@ -1,4 +1,6 @@ import re +from typing import Optional + from lcwc.agencies.agencyresolver import AgencyResolver from lcwc.category import IncidentCategory from lcwc.unit import Unit @@ -8,22 +10,40 @@ class UnitParserException(Exception): pass +_default_resolver: Optional[AgencyResolver] = None + + +def _get_default_resolver() -> AgencyResolver: + """Returns the lazily-built resolver used when a caller supplies none. + + Built on first use rather than at import time so that the roster is not + copied for every module that imports this one. + """ + global _default_resolver + if _default_resolver is None: + _default_resolver = AgencyResolver() + return _default_resolver + + class UnitParser: @staticmethod def parse_unit( unit_str: str, category: IncidentCategory, - agency_resolver: AgencyResolver = AgencyResolver(), + agency_resolver: Optional[AgencyResolver] = None, ) -> Unit: """Parses the given unit string and returns a Unit object :param unit_str: The unit string to parse :param category: The category for for the unit - :param agency_resolver: The agency resolver to use for agency lookups (required for shorthand unit names) + :param agency_resolver: The agency resolver to use for agency lookups (required for shorthand unit names). Defaults to a resolver holding the known agencies. :return: A Unit object :rtype: Unit """ + if agency_resolver is None: + agency_resolver = _get_default_resolver() + is_shorthand = " " not in unit_str if is_shorthand: return UnitParser.__parse_short_name(unit_str, category, agency_resolver) diff --git a/src/lcwc/web/client.py b/src/lcwc/web/client.py index fc31dfb..8c20634 100644 --- a/src/lcwc/web/client.py +++ b/src/lcwc/web/client.py @@ -1,3 +1,5 @@ +from typing import Optional + import aiohttp from lcwc import Client from lcwc.agencies.agencyresolver import AgencyResolver @@ -11,8 +13,10 @@ class WebClient(Client): URL = "https://www.lcwc911.us/live-incident-list" """ The URL of the live incident page """ - def __init__(self, agency_resolver: AgencyResolver = AgencyResolver()) -> None: - self.agency_resolver = agency_resolver + def __init__(self, agency_resolver: Optional[AgencyResolver] = None) -> None: + self.agency_resolver = ( + agency_resolver if agency_resolver is not None else AgencyResolver() + ) self.parser = WebParser() @property diff --git a/src/lcwc/web/parser.py b/src/lcwc/web/parser.py index defb1a9..251ac88 100644 --- a/src/lcwc/web/parser.py +++ b/src/lcwc/web/parser.py @@ -3,7 +3,6 @@ from bs4 import BeautifulSoup import pytz from lcwc.agencies.agencyresolver import AgencyResolver -from lcwc.agencies.exceptions import OutOfCountyException, PendingUnitException from lcwc.category import IncidentCategory from lcwc.utils.unitparser import UnitParser, UnitParserException @@ -80,10 +79,6 @@ def parse(self, html: str, agency_resolver: AgencyResolver) -> list[WebIncident] try: u = UnitParser.parse_unit(unit_name, category, agency_resolver) units.append(u) - except OutOfCountyException: - self.logger.debug(f"Unit {unit_name} is out of county") - except PendingUnitException: - self.logger.debug(f"Unit {unit_name} is pending") except UnitParserException: self.logger.debug(f"Unable to parse unit {unit_name}")