From 032996ee7d68c148b452cdfcfc49e76fbe8d7e27 Mon Sep 17 00:00:00 2001 From: Nolan Conaway Date: Sat, 9 Nov 2024 18:02:58 -0500 Subject: [PATCH] Update dependencies and modernize codebase (#37) * update dependencies - drop py3.7, add py3.13 - pydantic 2 - black -> ruff. ruff format. - push CI actions versions * cleaner push yaml * use makefile * ruff fixes * modernize pydantic validator. fix makefile * drop 3.8 --- .github/workflows/push.yml | 25 +++++---------- Makefile | 16 +++++++++ codecov.yml | 10 ------ pyproject.toml | 14 ++++++++ setup.py | 8 ++--- src/underground/__init__.py | 1 + src/underground/cli/__main__.py | 1 + src/underground/cli/stops.py | 5 +-- src/underground/feed.py | 4 +-- src/underground/metadata.py | 2 +- src/underground/models.py | 57 ++++++++++++++++----------------- tox.ini | 12 ------- 12 files changed, 74 insertions(+), 81 deletions(-) create mode 100644 Makefile delete mode 100644 codecov.yml create mode 100644 pyproject.toml delete mode 100644 tox.ini diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index d7b458c..22bc787 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -4,34 +4,25 @@ on: push jobs: build: - runs-on: ${{matrix.os}} + runs-on: ubuntu-latest strategy: matrix: - os: [ubuntu-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - - name: Set up Cache - uses: actions/cache@v3 - with: - path: ~/.cache/pip - key: | - ${{ format('pip-{0}-{1}', matrix.python-version, hashFiles('setup.py')) }} + cache: 'pip' + cache-dependency-path: setup.py - name: Install Dependencies run: | - pip install pip==23.* + pip install pip==24.* pip install .[dev] - - name: Black - run: black src test --check --verbose - - - name: Pytest - run: pytest --verbose + - name: Test + run: make lint-test \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1e10529 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: +lint: + @ ruff check + @ ruff format --check + +.PHONY: +format: + @ ruff check --select I --fix + @ ruff format + +.PHONY: +pytest: + pytest test --verbose + +.PHONY: +lint-test: lint pytest diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 13c6c6d..0000000 --- a/codecov.yml +++ /dev/null @@ -1,10 +0,0 @@ -ignore: - - "test/.*" - - "setup.py" - - .venv/ - - .tox/ - -coverage: - status: - project: off - patch: off diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e20de0f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[tool.ruff] +include = ["pyproject.toml", "src/**/*.py", "tests/**/*.py"] +line-length = 100 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle + "F", # Pyflakes + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify + "I", # isort + "RUF", # ruff +] \ No newline at end of file diff --git a/setup.py b/setup.py index f67ec15..8338f9b 100644 --- a/setup.py +++ b/setup.py @@ -14,14 +14,13 @@ "protobuf>=3.19.6,<=3.20.3", "protobuf3-to-dict==0.1.*", "click>=7,<9", - "pydantic~=1.9.2", + "pydantic==2.*", "pytz>=2019.2", ] DEV_REQUIRES = [ "pytest==7.*", - "tox==4.*", - "black==23.*", + "ruff==0.7.* ", "requests-mock==1.*", ] @@ -38,12 +37,11 @@ author_email="nolanbconaway@gmail.com", url="https://github.com/nolanbconaway/underground", classifiers=[ - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ], keywords=["nyc", "transit", "subway", "command-line", "cli"], license="MIT", diff --git a/src/underground/__init__.py b/src/underground/__init__.py index af502db..fa96c8b 100644 --- a/src/underground/__init__.py +++ b/src/underground/__init__.py @@ -1,4 +1,5 @@ """A realtime MTA module.""" + from pathlib import Path from .models import SubwayFeed diff --git a/src/underground/cli/__main__.py b/src/underground/cli/__main__.py index f97218e..9a65348 100644 --- a/src/underground/cli/__main__.py +++ b/src/underground/cli/__main__.py @@ -1,4 +1,5 @@ """Command line tools for the MTA module.""" + from . import cli if __name__ == "__main__": diff --git a/src/underground/cli/stops.py b/src/underground/cli/stops.py index a59fb82..d83e0ca 100644 --- a/src/underground/cli/stops.py +++ b/src/underground/cli/stops.py @@ -63,10 +63,7 @@ def main(route, fmt, retries, api_key, timezone, stalled_timeout): ) # figure out how to format it - if fmt == "epoch": - format_fun = datetime_to_epoch - else: - format_fun = lambda x: x.strftime(fmt) + format_fun = datetime_to_epoch if fmt == "epoch" else lambda x: x.strftime(fmt) # echo the result for stop_id, departures in stops.items(): diff --git a/src/underground/feed.py b/src/underground/feed.py index c40416d..42c09b1 100644 --- a/src/underground/feed.py +++ b/src/underground/feed.py @@ -38,7 +38,7 @@ def load_protobuf(protobuf_bytes: bytes) -> dict: return feed_dict -def request(route_or_url: str, api_key: str = None) -> bytes: +def request(route_or_url: str, api_key: typing.Optional[str] = None) -> bytes: """Send a HTTP GET request to the MTA for realtime feed data. Occassionally a feed is requested as the MTA is writing updated data to the file, @@ -80,7 +80,7 @@ def request(route_or_url: str, api_key: str = None) -> bytes: def request_robust( route_or_url: str, retries: int = 100, - api_key: str = None, + api_key: typing.Optional[str] = None, return_dict: bool = False, ) -> typing.Union[dict, bytes]: """Request feed data with validations and retries. diff --git a/src/underground/metadata.py b/src/underground/metadata.py index adf1b94..05b0901 100644 --- a/src/underground/metadata.py +++ b/src/underground/metadata.py @@ -65,6 +65,6 @@ def resolve_url(route_or_url: str) -> str: return route_or_url if route_or_url not in ROUTE_REMAP: - raise ValueError("Unknown route or url: %s" % route_or_url) + raise ValueError(f"Unknown route or url: {route_or_url}") return ROUTE_FEED_MAP[ROUTE_REMAP[route_or_url]] diff --git a/src/underground/models.py b/src/underground/models.py index 65ae75c..835bc5d 100644 --- a/src/underground/models.py +++ b/src/underground/models.py @@ -12,10 +12,10 @@ class UnixTimestamp(pydantic.BaseModel): """A unix timestamp model.""" - time: datetime.datetime = None + time: typing.Optional[datetime.datetime] = None @property - def timestamp_nyc(self): + def timestamp_nyc(self) -> typing.Optional[datetime.datetime]: """Return the NYC datetime.""" if not self.time: return None @@ -29,7 +29,7 @@ class FeedHeader(pydantic.BaseModel): timestamp: datetime.datetime @property - def timestamp_nyc(self): + def timestamp_nyc(self) -> datetime.datetime: """Return the NYC datetime of the header.""" return self.timestamp.astimezone(pytz.timezone(metadata.DEFAULT_TIMEZONE)) @@ -38,11 +38,11 @@ class Trip(pydantic.BaseModel): """Model describing a train trip.""" trip_id: str - start_time: datetime.time = None + start_time: typing.Optional[datetime.time] = None start_date: int route_id: str - @pydantic.validator("start_date") + @pydantic.field_validator("start_date") def check_start_date(cls, start_date): """Start_date is an int, so check it conforms to date expectations.""" if start_date < 19000101: @@ -50,19 +50,18 @@ def check_start_date(cls, start_date): return start_date - @pydantic.validator("route_id") + @pydantic.field_validator("route_id") def check_route(cls, route_id): """Check for a valid route ID value.""" if route_id not in metadata.ROUTE_REMAP: raise ValueError( - "Invalid route (%s). Must be one of %s." - % (route_id, str(set(metadata.ROUTE_REMAP.keys()))) + f"Invalid route ({route_id}). Must be one of {set(metadata.ROUTE_REMAP.keys())}." ) return route_id @property - def route_id_mapped(self): + def route_id_mapped(self) -> str: """Find the parent route ID. This is helpful for grabbing the, e.g., 5 Train when you might have a 5X. @@ -70,7 +69,7 @@ def route_id_mapped(self): return metadata.ROUTE_REMAP[self.route_id] @property - def route_is_assigned(self): + def route_is_assigned(self) -> bool: """Return a flag indicating that there is a route.""" return self.route_id != "" @@ -93,11 +92,11 @@ class StopTimeUpdate(pydantic.BaseModel): """ stop_id: str - arrival: UnixTimestamp = None - departure: UnixTimestamp = None + arrival: typing.Optional[UnixTimestamp] = None + departure: typing.Optional[UnixTimestamp] = None @property - def depart_or_arrive(self) -> UnixTimestamp: + def depart_or_arrive(self) -> typing.Optional[UnixTimestamp]: """Return the departure or arrival time if either are specified. This OR should usually be called because the MTA is inconsistent about when @@ -123,7 +122,7 @@ class TripUpdate(pydantic.BaseModel): """ trip: Trip - stop_time_update: typing.List[StopTimeUpdate] = None + stop_time_update: typing.Optional[list[StopTimeUpdate]] = None class Vehicle(pydantic.BaseModel): @@ -153,9 +152,9 @@ class Vehicle(pydantic.BaseModel): """ trip: Trip - timestamp: datetime.datetime = None - current_stop_sequence: int = None - stop_id: str = None + timestamp: typing.Optional[datetime.datetime] = None + current_stop_sequence: typing.Optional[int] = None + stop_id: typing.Optional[str] = None class Entity(pydantic.BaseModel): @@ -166,8 +165,8 @@ class Entity(pydantic.BaseModel): """ id: str - vehicle: Vehicle = None - trip_update: TripUpdate = None + vehicle: typing.Optional[Vehicle] = None + trip_update: typing.Optional[TripUpdate] = None class SubwayFeed(pydantic.BaseModel): @@ -177,10 +176,12 @@ class SubwayFeed(pydantic.BaseModel): """ header: FeedHeader - entity: typing.List[Entity] + entity: list[Entity] @staticmethod - def get(route_or_url: str, retries: int = 100, api_key: str = None) -> "SubwayFeed": + def get( + route_or_url: str, retries: int = 100, api_key: typing.Optional[str] = None + ) -> "SubwayFeed": """Request feed data from the MTA. Parameters @@ -213,7 +214,7 @@ def get(route_or_url: str, retries: int = 100, api_key: str = None) -> "SubwayFe def extract_stop_dict( self, timezone: str = metadata.DEFAULT_TIMEZONE, stalled_timeout: int = 90 - ) -> dict: + ) -> dict[str, dict[str, list[datetime.datetime]]]: """Get the departure times for all stops in the feed. Parameters @@ -234,11 +235,7 @@ def extract_stop_dict( """ trip_updates = (x.trip_update for x in self.entity if x.trip_update is not None) - vehicles = { - e.vehicle.trip.trip_id: e.vehicle - for e in self.entity - if e.vehicle is not None - } + vehicles = {e.vehicle.trip.trip_id: e.vehicle for e in self.entity if e.vehicle is not None} def is_trip_active(update: TripUpdate) -> bool: has_route = update.trip.route_is_assigned @@ -249,9 +246,9 @@ def is_trip_active(update: TripUpdate) -> bool: return has_route and has_stops # as recommended by the MTA, we use these timestamps to determine if a train is stalled - train_stalled = ( - self.header.timestamp - vehicle.timestamp - ) > datetime.timedelta(seconds=stalled_timeout) + train_stalled = (self.header.timestamp - vehicle.timestamp) > datetime.timedelta( + seconds=stalled_timeout + ) return has_route and has_stops and not train_stalled # grab the updates with routes and stop times diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 28327d9..0000000 --- a/tox.ini +++ /dev/null @@ -1,12 +0,0 @@ -# content of: tox.ini , put in same dir as setup.py -[tox] -envlist = py37,py38,p39,py310,py311,py312 -skip_missing_interpreters = true - -[testenv] -deps = - .[dev] -usedevelop = true -commands = - pytest . -v -s - black src test --check --verbose