Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
]

Expand Down
6 changes: 3 additions & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion src/lcwc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 8 additions & 14 deletions src/lcwc/agencies/agencyresolver.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
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)

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:
Expand All @@ -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)
6 changes: 0 additions & 6 deletions src/lcwc/agencies/exceptions.py

This file was deleted.

8 changes: 5 additions & 3 deletions src/lcwc/arcgis/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
9 changes: 6 additions & 3 deletions src/lcwc/feed/client.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
5 changes: 0 additions & 5 deletions src/lcwc/feed/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")

Expand Down
57 changes: 57 additions & 0 deletions src/lcwc/utils/unitconverter.py
Original file line number Diff line number Diff line change
@@ -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())
24 changes: 22 additions & 2 deletions src/lcwc/utils/unitparser.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions src/lcwc/web/client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Optional

import aiohttp
from lcwc import Client
from lcwc.agencies.agencyresolver import AgencyResolver
Expand All @@ -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
Expand Down
5 changes: 0 additions & 5 deletions src/lcwc/web/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}")

Expand Down
Loading