pax_global_header00006660000000000000000000000064150231203230014501gustar00rootroot0000000000000052 comment=34b92be5f7a86c38a9fee5b94061791a6883685b py_nextbusnext-2.3.0/000077500000000000000000000000001502312032300146025ustar00rootroot00000000000000py_nextbusnext-2.3.0/.gitattributes000066400000000000000000000005721502312032300175010ustar00rootroot00000000000000# Auto detect text files and perform LF normalization * text=auto # Custom for Visual Studio *.cs diff=csharp # Standard to msysgit *.doc diff=astextplain *.DOC diff=astextplain *.docx diff=astextplain *.DOCX diff=astextplain *.dot diff=astextplain *.DOT diff=astextplain *.pdf diff=astextplain *.PDF diff=astextplain *.rtf diff=astextplain *.RTF diff=astextplain py_nextbusnext-2.3.0/.github/000077500000000000000000000000001502312032300161425ustar00rootroot00000000000000py_nextbusnext-2.3.0/.github/workflows/000077500000000000000000000000001502312032300201775ustar00rootroot00000000000000py_nextbusnext-2.3.0/.github/workflows/publish.yaml000066400000000000000000000006761502312032300225420ustar00rootroot00000000000000--- name: Publish Python Package on: release: types: [published] permissions: contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v3 with: python-version: '3.x' - name: Upload env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | make upload py_nextbusnext-2.3.0/.github/workflows/test.yaml000066400000000000000000000006611502312032300220450ustar00rootroot00000000000000--- name: Tests on: push: branches: [master] pull_request: branches: [master] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v3 with: python-version: '3.x' - name: Run unit tests run: | make test - name: Run acceptance tests run: | make acceptance py_nextbusnext-2.3.0/.gitignore000066400000000000000000000026061502312032300165760ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject # Rope project settings .ropeproject # ========================= # Operating System Files # ========================= # Windows # ========================= # Windows thumbnail cache files Thumbs.db ehthumbs.db ehthumbs_vista.db # Folder config file Desktop.ini # Recycle Bin used on file shares $RECYCLE.BIN/ # Windows Installer files *.cab *.msi *.msm *.msp # Windows shortcuts *.lnk py_nextbusnext-2.3.0/.pre-commit-config.yaml000066400000000000000000000017471502312032300210740ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: debug-statements language_version: python3 - id: check-merge-conflict - id: name-tests-test exclude: tests/(common.py|util.py|(helpers|integration/factories)/(.+).py) - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. rev: v0.11.12 hooks: # Run the linter. - id: ruff-check args: [ --fix ] # Run the formatter. - id: ruff-format - repo: https://github.com/psf/black rev: 25.1.0 hooks: - id: black - repo: https://github.com/pycqa/isort rev: 6.0.1 hooks: - id: isort args: ["--profile", "black", "--force-single-line-imports", "--filter-files"] - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.16.0 hooks: - id: mypy additional_dependencies: - types-requests py_nextbusnext-2.3.0/LICENSE000066400000000000000000000020551502312032300156110ustar00rootroot00000000000000MIT License Copyright (c) 2018 Pierre Maris Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. py_nextbusnext-2.3.0/Makefile000066400000000000000000000040301502312032300162370ustar00rootroot00000000000000ENV := env .PHONY: default default: test # Creates virtualenv $(ENV): python3 -m venv $(ENV) $(ENV)/bin/pip install -e . # Install wheel for building packages $(ENV)/bin/wheel: $(ENV) $(ENV)/bin/pip install wheel setuptools # Install twine for uploading packages $(ENV)/bin/twine: $(ENV) $(ENV)/bin/pip install twine setuptools # Install pre-commit and other devenv items $(ENV)/bin/pre-commit: $(ENV) $(ENV)/bin/pip install -r ./requirements-dev.txt # Installs dev requirements to virtualenv .PHONY: devenv devenv: $(ENV)/bin/pre-commit # Generates a small build env for building and uploading dists .PHONY: build-env build-env: $(ENV)/bin/twine $(ENV)/bin/wheel # Runs unit tests .PHONY: test test: $(ENV) $(ENV)/bin/pre-commit $(ENV)/bin/tox $(ENV)/bin/pre-commit run --all-files # Runs acceptance tests .PHONY: acceptance acceptance: $(ENV) $(ENV)/bin/tox -e acceptance # Builds wheel for package to upload .PHONY: build build: $(ENV)/bin/wheel $(ENV)/bin/python setup.py sdist $(ENV)/bin/python setup.py bdist_wheel # Verify that the python version matches the git tag so we don't push bad shas .PHONY: verify-tag-version verify-tag-version: $(ENV)/bin/wheel $(eval TAG_NAME = $(shell [ -n "$(DRONE_TAG)" ] && echo $(DRONE_TAG) || git describe --tags --exact-match)) test "v$(shell $(ENV)/bin/python setup.py -V)" = "$(TAG_NAME)" # Uses twine to upload to pypi .PHONY: upload upload: verify-tag-version build $(ENV)/bin/twine $(ENV)/bin/twine upload dist/* # Uses twine to upload to test pypi .PHONY: upload-test upload-test: verify-tag-version build $(ENV)/bin/twine $(ENV)/bin/twine upload --repository-url https://test.pypi.org/legacy/ dist/* # Cleans all build, runtime, and test artifacts .PHONY: clean clean: rm -fr ./build ./py_nextbus.egg-info find . -name '*.pyc' -delete find . -name '__pycache__' -delete # Cleans dist and env .PHONY: dist-clean dist-clean: clean rm -fr ./dist $(ENV) # Install pre-commit hooks .PHONY: install-hooks install-hooks: $(ENV)/bin/pre-commit $(ENV)/bin/pre-commit install -f --install-hooks py_nextbusnext-2.3.0/README.md000066400000000000000000000006031502312032300160600ustar00rootroot00000000000000# py_nextbusnext ## This is forked from py_nextbus for maintanence _It is no longer API compatible to the upstream_ A minimalistic Python 3 client to get routes and predictions from the NextBus API. Installation --- Install with pip: `pip install py-nextbusnext` Usage --- ``` >>> import py_nextbus >>> client = py_nextbus.NextBusClient() >>> agencies = client.get_agencies() ``` py_nextbusnext-2.3.0/acceptance/000077500000000000000000000000001502312032300166705ustar00rootroot00000000000000py_nextbusnext-2.3.0/acceptance/__init__.py000066400000000000000000000000001502312032300207670ustar00rootroot00000000000000py_nextbusnext-2.3.0/acceptance/client_test.py000066400000000000000000000044751502312032300215710ustar00rootroot00000000000000import unittest from py_nextbus import NextBusClient TEST_AGENCY = "sfmta-cis" TEST_ROUTE = "F" TEST_STOP = "4513" class ClientTest(unittest.TestCase): client: NextBusClient def setUp(self): self.client = NextBusClient() def test_list_agencies(self): agencies = self.client.agencies() # Check critical agency keys for agency in agencies: self.assertIsNotNone(agency["id"]) self.assertIsNotNone(agency["name"]) # Check test agency name self.assertIn(TEST_AGENCY, [agency["id"] for agency in agencies]) self.assertGreater(self.client.rate_limit, 0) self.assertGreater(self.client.rate_limit_remaining, 0) self.assertGreater(self.client.rate_limit_percent, 0) def test_list_routes(self): routes = self.client.routes(TEST_AGENCY) # Check critical route keys for route in routes: self.assertIsNotNone(route["id"]) self.assertIsNotNone(route["title"]) # Check test route id self.assertIn(TEST_ROUTE, [route["id"] for route in routes]) def test_route_details(self): route_details = self.client.route_details(TEST_ROUTE, agency_id=TEST_AGENCY) # Check critical route detail keys for stop in route_details["stops"]: self.assertIsNotNone(stop["id"]) self.assertIsNotNone(stop["name"]) for direction in route_details["directions"]: self.assertIsNotNone(direction["name"]) self.assertIsNotNone(direction["useForUi"]) self.assertIsNotNone(direction["stops"]) self.assertIn(TEST_STOP, [stop["id"] for stop in route_details["stops"]]) def test_predictions_for_stop(self): predictions = self.client.predictions_for_stop( TEST_STOP, TEST_ROUTE, agency_id=TEST_AGENCY ) # Check critical prediction keys for prediction in predictions: self.assertIsNotNone(prediction["stop"]["id"]) self.assertIsNotNone(prediction["stop"]["name"]) self.assertIsNotNone(prediction["route"]["id"]) self.assertIsNotNone(prediction["route"]["title"]) for value in prediction["values"]: self.assertIsNotNone(value["minutes"]) self.assertIsNotNone(value["timestamp"]) py_nextbusnext-2.3.0/gen_mock.py000066400000000000000000000011341502312032300167350ustar00rootroot00000000000000from py_nextbus import NextBusClient from tests.mock_responses import TEST_AGENCY_ID from tests.mock_responses import TEST_ROUTE_ID from tests.mock_responses import TEST_STOP_ID client = NextBusClient() agencies = client.agencies() print("Agencies:") print(agencies) routes = client.routes(TEST_AGENCY_ID) print("\nRoutes:") print(routes) route_details = client.route_details(TEST_ROUTE_ID, TEST_AGENCY_ID) print("\nRoute Details:") print(route_details) predictions = client.predictions_for_stop( TEST_STOP_ID, TEST_ROUTE_ID, agency_id=TEST_AGENCY_ID ) print("\nPredictions:") print(predictions) py_nextbusnext-2.3.0/py_nextbus/000077500000000000000000000000001502312032300170025ustar00rootroot00000000000000py_nextbusnext-2.3.0/py_nextbus/__init__.py000066400000000000000000000000771502312032300211170ustar00rootroot00000000000000from .client import NextBusClient # NOQA name = "py_nextbus" py_nextbusnext-2.3.0/py_nextbus/client.py000066400000000000000000000151441502312032300206370ustar00rootroot00000000000000from __future__ import annotations import json import logging from datetime import datetime from typing import Any from typing import NamedTuple from typing import cast import requests from requests.exceptions import HTTPError from py_nextbus.models import AgencyInfo from py_nextbus.models import RouteDetails from py_nextbus.models import RouteInfo from py_nextbus.models import StopPrediction LOG = logging.getLogger() class NextBusError(Exception): pass class NextBusHTTPError(NextBusError): def __init__(self, message: str, http_err: HTTPError): super().__init__() self.__dict__.update(http_err.__dict__) self.message: str = message class NextBusValidationError(ValueError, NextBusError): """Error with missing fields for a NextBus request.""" class NextBusFormatError(ValueError, NextBusError): """Error with parsing a NextBus response.""" class NextBusAuthError(NextBusError): """Error with authentication to the NextBus API.""" class RouteStop(NamedTuple): route_tag: str stop_tag: str | int def __str__(self) -> str: return f"{self.route_tag}|{self.stop_tag}" @classmethod def from_dict(cls, legacy_dict: dict[str, str]) -> RouteStop: return cls(legacy_dict["route_tag"], legacy_dict["stop_tag"]) class NextBusClient: base_url: str = "https://api.prd-1.iq.live.umoiq.com/v2.0/riders" def __init__( self, agency_id: str | None = None, ) -> None: self.agency_id: str | None = agency_id self._session: requests.Session = requests.Session() self._session.headers.update( { "Accept": "application/json", "Accept-Encoding": "gzip, deflate, br, zstd", "Accept-Language": "en-US,en;q=0.5", "Compress": "true", "Connection": "keep-alive", "DNT": "1", "Origin": "https://rider.umoiq.com", "Referer": "https://rider.umoiq.com/", "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0", } ) self._rate_limit: int = 0 self._rate_limit_remaining: int = 0 self._rate_limit_reset: datetime | None = None @property def rate_limit(self) -> int: """Returns the rate limit for the API.""" return self._rate_limit @property def rate_limit_remaining(self) -> int: """Returns the remaining rate limit for the API.""" return self._rate_limit_remaining @property def rate_limit_reset(self) -> datetime | None: """Returns the time when the rate limit will reset.""" return self._rate_limit_reset @property def rate_limit_percent(self) -> float: """Returns the percentage of the rate limit remaining.""" if self.rate_limit == 0: return 0.0 return self.rate_limit_remaining / self.rate_limit * 100 def agencies(self) -> list[AgencyInfo]: return cast(list[AgencyInfo], self._get("agencies")) def routes(self, agency_id: str | None = None) -> list[RouteInfo]: if not agency_id: agency_id = self.agency_id return cast(list[RouteInfo], self._get(f"agencies/{agency_id}/routes")) def route_details( self, route_id: str, agency_id: str | None = None ) -> RouteDetails: """Includes stops and directions.""" agency_id = agency_id or self.agency_id if not agency_id: raise NextBusValidationError("Agency ID is required") return cast(RouteDetails, self._get(f"agencies/{agency_id}/routes/{route_id}")) def predictions_for_stop( self, stop_id: str | int, route_id: str | None = None, direction_id: str | None = None, agency_id: str | None = None, ) -> list[StopPrediction]: """Returns predictions for a stop.""" agency_id = agency_id or self.agency_id if not agency_id: raise NextBusValidationError("Agency ID is required") if direction_id: if not route_id: raise NextBusValidationError("Direction ID provided without route ID") if route_id: predictions = cast( list[StopPrediction], self._get( f"agencies/{agency_id}/nstops/{route_id}:{stop_id}/predictions" ), ) else: predictions = cast( list[StopPrediction], self._get(f"agencies/{agency_id}/stops/{stop_id}/predictions"), ) # If route not provided, return all predictions as the API returned them if not route_id: return predictions # HACK: Filter predictions based on stop and route because the API seems to ignore the route predictions = [ prediction_result for prediction_result in predictions if ( prediction_result["stop"]["id"] == stop_id and prediction_result["route"]["id"] == route_id ) ] # HACK: Filter predictions based on direction in case the API returns extra predictions if direction_id: for prediction_result in predictions: prediction_result["values"] = [ prediction for prediction in prediction_result["values"] if prediction["direction"]["id"] == direction_id ] return predictions def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any: if params is None: params = {} try: url = f"{self.base_url}/{endpoint}" LOG.debug("GET %s", url) response = self._session.get(url, params=params) response.raise_for_status() # Track rate limit information self._rate_limit = int(response.headers.get("X-RateLimit-Limit", 0)) self._rate_limit_remaining = int( response.headers.get("X-RateLimit-Remaining", 0) ) reset_time = response.headers.get("X-RateLimit-Reset") self._rate_limit_reset = ( datetime.fromtimestamp(int(reset_time)) if reset_time else None ) return response.json() except HTTPError as exc: raise NextBusHTTPError("Error from the NextBus API", exc) from exc except json.decoder.JSONDecodeError as exc: raise NextBusFormatError("Failed to parse JSON from request") from exc py_nextbusnext-2.3.0/py_nextbus/models.py000066400000000000000000000037731502312032300206510ustar00rootroot00000000000000from typing import TypedDict class AgencyInfo(TypedDict): id: str name: str shortName: str region: str website: str logo: str nxbs2RedirectUrl: str class PredictionRouteInfo(TypedDict): id: str title: str description: str color: str textColor: str hidden: bool class RouteInfo(PredictionRouteInfo): rev: int timestamp: str class RouteBoundingBox(TypedDict): latMin: float latMax: float lonMin: float lonMax: float class StopInfo(TypedDict): id: str lat: float lon: float name: str code: str hidden: bool showDestinationSelector: bool directions: list[str] class PredictionStopInfo(TypedDict): id: str lat: float lon: float name: str code: str hidden: bool showDestinationSelector: bool route: str class Point(TypedDict): lat: float lon: float class RoutePath(TypedDict): id: str points: list[Point] class DirectionInfo(TypedDict): id: str shortName: str name: str useForUi: bool stops: list[str] class RouteDetails(TypedDict): id: str rev: int title: str description: str color: str textColor: str hidden: bool boundingBox: RouteBoundingBox stops: list[StopInfo] directions: list[DirectionInfo] paths: list[RoutePath] timestamp: str class PredictionDirection(TypedDict): id: str name: str destinationName: str class PredictionValue(TypedDict): timestamp: int minutes: int affectedByLayover: bool isDeparture: bool occupancyStatus: int occupancyDescription: str vehiclesInConsist: int linkedVehicleIds: str vehicleId: str vehicleType: str | None direction: PredictionDirection tripId: str delay: int predUsingNavigationTm: bool departure: bool class StopPrediction(TypedDict): serverTimestamp: int nxbs2RedirectUrl: str route: PredictionRouteInfo stop: PredictionStopInfo values: list[PredictionValue] py_nextbusnext-2.3.0/requirements-dev.txt000066400000000000000000000000521502312032300206370ustar00rootroot00000000000000tox pre-commit mypy==1.10.1 black==24.4.2 py_nextbusnext-2.3.0/setup.py000066400000000000000000000014551502312032300163210ustar00rootroot00000000000000from setuptools import find_packages from setuptools import setup with open("README.md", "r") as readme: long_description = readme.read() setup( name="py_nextbusnext", version="2.3.0", author="ViViDboarder", description="Minimalistic Python client for the NextBus public API for real-time transit " "arrival data", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/vividboarder/py_nextbus", packages=find_packages( exclude=[ "build", "dist", "tests", ] ), python_requires=">=3.10", install_requires=[ "requests", ], classifiers=[ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ], ) py_nextbusnext-2.3.0/tests/000077500000000000000000000000001502312032300157445ustar00rootroot00000000000000py_nextbusnext-2.3.0/tests/__init__.py000066400000000000000000000000001502312032300200430ustar00rootroot00000000000000py_nextbusnext-2.3.0/tests/client_test.py000066400000000000000000000107251502312032300206400ustar00rootroot00000000000000from __future__ import annotations import unittest.mock from unittest.mock import MagicMock from py_nextbus.client import NextBusClient from tests.helpers.mock_responses import MOCK_AGENCY_LIST_RESPONSE from tests.helpers.mock_responses import MOCK_PREDICTIONS_RESPONSE_NO_ROUTE from tests.helpers.mock_responses import MOCK_PREDICTIONS_RESPONSE_WITH_ROUTE from tests.helpers.mock_responses import MOCK_ROUTE_DETAILS_RESPONSE from tests.helpers.mock_responses import MOCK_ROUTE_LIST_RESPONSE from tests.helpers.mock_responses import TEST_AGENCY_ID from tests.helpers.mock_responses import TEST_DIRECTION_ID from tests.helpers.mock_responses import TEST_ROUTE_ID from tests.helpers.mock_responses import TEST_STOP_ID class TestNextBusClient(unittest.TestCase): def setUp(self): self.client = NextBusClient() @unittest.mock.patch("py_nextbus.client.NextBusClient._get") def test_list_agencies(self, mock_get: MagicMock): mock_get.return_value = MOCK_AGENCY_LIST_RESPONSE agencies = self.client.agencies() # Check critical agency keys for agency in agencies: self.assertIsNotNone(agency["id"]) self.assertIsNotNone(agency["name"]) # Check test agency name self.assertIn(TEST_AGENCY_ID, [agency["id"] for agency in agencies]) mock_get.assert_called_once_with("agencies") @unittest.mock.patch("py_nextbus.client.NextBusClient._get") def test_list_routes(self, mock_get: MagicMock): mock_get.return_value = MOCK_ROUTE_LIST_RESPONSE routes = self.client.routes(TEST_AGENCY_ID) # Check critical route keys for route in routes: self.assertIsNotNone(route["id"]) self.assertIsNotNone(route["title"]) # Check test route id self.assertIn(TEST_ROUTE_ID, [route["id"] for route in routes]) mock_get.assert_called_once_with(f"agencies/{TEST_AGENCY_ID}/routes") @unittest.mock.patch("py_nextbus.client.NextBusClient._get") def test_route_details(self, mock_get: MagicMock): mock_get.return_value = MOCK_ROUTE_DETAILS_RESPONSE route_details = self.client.route_details( TEST_ROUTE_ID, agency_id=TEST_AGENCY_ID ) # Check critical route detail keys for stop in route_details["stops"]: self.assertIsNotNone(stop["id"]) self.assertIsNotNone(stop["name"]) for direction in route_details["directions"]: self.assertIsNotNone(direction["name"]) self.assertIsNotNone(direction["useForUi"]) self.assertIsNotNone(direction["stops"]) self.assertIn(TEST_STOP_ID, [stop["id"] for stop in route_details["stops"]]) mock_get.assert_called_once_with( f"agencies/{TEST_AGENCY_ID}/routes/{TEST_ROUTE_ID}" ) @unittest.mock.patch("py_nextbus.client.NextBusClient._get") def test_predictions_for_stop_no_route(self, mock_get: MagicMock): mock_get.return_value = MOCK_PREDICTIONS_RESPONSE_NO_ROUTE result = self.client.predictions_for_stop( TEST_STOP_ID, agency_id=TEST_AGENCY_ID ) self.assertEqual({r["stop"]["id"] for r in result}, {TEST_STOP_ID}) self.assertEqual(len(result), 3) # Results include all routes mock_get.assert_called_once_with( f"agencies/{TEST_AGENCY_ID}/stops/{TEST_STOP_ID}/predictions", ) @unittest.mock.patch("py_nextbus.client.NextBusClient._get") def test_predictions_for_stop_with_route(self, mock_get: MagicMock): mock_get.return_value = MOCK_PREDICTIONS_RESPONSE_WITH_ROUTE result = self.client.predictions_for_stop( TEST_STOP_ID, TEST_ROUTE_ID, agency_id=TEST_AGENCY_ID, direction_id=TEST_DIRECTION_ID, ) # Assert all predictions are for the correct stop self.assertEqual({r["stop"]["id"] for r in result}, {TEST_STOP_ID}) self.assertEqual({r["route"]["id"] for r in result}, {TEST_ROUTE_ID}) # Assert all predictions are for the correct direction self.assertEqual( {p["direction"]["id"] for r in result for p in r["values"]}, {TEST_DIRECTION_ID}, ) # Assert we only have the one prediction for the stop and route self.assertEqual(len(result), 1) mock_get.assert_called_once_with( f"agencies/{TEST_AGENCY_ID}/nstops/{TEST_ROUTE_ID}:{TEST_STOP_ID}/predictions", ) if __name__ == "__main__": unittest.main() py_nextbusnext-2.3.0/tests/helpers/000077500000000000000000000000001502312032300174065ustar00rootroot00000000000000py_nextbusnext-2.3.0/tests/helpers/mock_responses.py000066400000000000000000000243331502312032300230170ustar00rootroot00000000000000from py_nextbus.models import AgencyInfo from py_nextbus.models import RouteDetails from py_nextbus.models import RouteInfo from py_nextbus.models import StopPrediction TEST_AGENCY_ID = "sfmta-cis" TEST_ROUTE_ID = "F" TEST_STOP_ID = "5184" TEST_DIRECTION_ID = "F_0_var0" MOCK_AGENCY_LIST_RESPONSE: list[AgencyInfo] = [ { "id": "sfmta-cis", "name": "San Francisco Muni CIS", "shortName": "SF Muni CIS", "region": "California-Northern", "website": "http://www.sfmta.com", "logo": "/logos/muniLogoSmall.gif", "nxbs2RedirectUrl": "", }, ] MOCK_ROUTE_LIST_RESPONSE: list[RouteInfo] = [ { "id": "F", "rev": 1057, "title": "F Market & Wharves", "description": "7am-10pm daily", "color": "b49a36", "textColor": "000000", "hidden": False, "timestamp": "2024-06-23T03:06:58Z", }, ] MOCK_ROUTE_DETAILS_RESPONSE: RouteDetails = { "id": "F", "rev": 1057, "title": "F Market & Wharves", "description": "7am-10pm daily", "color": "b49a36", "textColor": "000000", "hidden": False, "boundingBox": { "latMin": 37.7625799, "latMax": 37.8085899, "lonMin": -122.43498, "lonMax": -122.39344, }, "stops": [ { "id": "5184", "lat": 37.8071299, "lon": -122.41732, "name": "Jones St & Beach St", "code": "15184", "hidden": False, "showDestinationSelector": True, "directions": ["F_0_var1", "F_0_var0"], }, ], "directions": [ { "id": "F_0_var1", "shortName": "Castro + Market", "name": "Castro + Market", "useForUi": False, "stops": [ "5184", ], }, { "id": "F_0_var0", "shortName": "Castro", "name": "Castro", "useForUi": True, "stops": [ "5184", ], }, { "id": "F_1_var0", "shortName": "Fisherman`s Wharf", "name": "Fisherman`s Wharf", "useForUi": True, "stops": [ "5184_ar", ], }, ], "paths": [ { "id": "F_1_var0_16_4530_5184_ar", "points": [ {"lat": 37.80835, "lon": -122.41029}, {"lat": 37.80829, "lon": -122.41032}, {"lat": 37.80833, "lon": -122.4105}, {"lat": 37.80862, "lon": -122.4124}, {"lat": 37.80862, "lon": -122.41253}, {"lat": 37.80859, "lon": -122.41336}, {"lat": 37.8085, "lon": -122.41337}, {"lat": 37.80842, "lon": -122.41417}, {"lat": 37.80832, "lon": -122.41551}, {"lat": 37.80801, "lon": -122.41745}, {"lat": 37.80724, "lon": -122.41732}, {"lat": 37.80713, "lon": -122.41732}, ], }, ], "timestamp": "2024-06-23T03:06:58Z", } MOCK_PREDICTIONS_RESPONSE_NO_ROUTE: list[StopPrediction] = [ { "serverTimestamp": 1724038210798, "nxbs2RedirectUrl": "", "route": { "id": "LOWL", "title": "Lowl Owl Taraval", "description": "10pm-5am nightly", "color": "666666", "textColor": "ffffff", "hidden": False, }, "stop": { "id": "5184", "lat": 37.8071299, "lon": -122.41732, "name": "Jones St & Beach St", "code": "15184", "hidden": False, "showDestinationSelector": True, "route": "LOWL", }, "values": [], }, { "serverTimestamp": 1724038210798, "nxbs2RedirectUrl": "", "route": { "id": "FBUS", "title": "Fbus Market & Wharves", "description": "", "color": "b49a36", "textColor": "000000", "hidden": False, }, "stop": { "id": "5184", "lat": 37.8071299, "lon": -122.41732, "name": "Jones St & Beach St", "code": "15184", "hidden": False, "showDestinationSelector": True, "route": "FBUS", }, "values": [], }, { "serverTimestamp": 1724038210798, "nxbs2RedirectUrl": "", "route": { "id": "F", "title": "F Market & Wharves", "description": "7am-10pm daily", "color": "b49a36", "textColor": "000000", "hidden": False, }, "stop": { "id": "5184", "lat": 37.8071299, "lon": -122.41732, "name": "Jones St & Beach St", "code": "15184", "hidden": False, "showDestinationSelector": True, "route": "F", }, "values": [ { "timestamp": 1724038309178, "minutes": 1, "affectedByLayover": True, "isDeparture": True, "occupancyStatus": -1, "occupancyDescription": "Unknown", "vehiclesInConsist": 1, "linkedVehicleIds": "1078", "vehicleId": "1078", "vehicleType": "Historic Street Car_VC1", "direction": { "id": "F_0_var1", "name": "Castro + Market", "destinationName": "Castro + Market", }, "tripId": "11593249_M13", "delay": 0, "predUsingNavigationTm": False, "departure": True, }, { "timestamp": 1724039160000, "minutes": 15, "affectedByLayover": True, "isDeparture": True, "occupancyStatus": -1, "occupancyDescription": "Unknown", "vehiclesInConsist": 1, "linkedVehicleIds": "1080", "vehicleId": "1080", "vehicleType": "Historic Street Car_VC1", "direction": { "id": "F_0_var0", "name": "Castro", "destinationName": "Castro", }, "tripId": "11593252_M13", "delay": 0, "predUsingNavigationTm": False, "departure": True, }, { "timestamp": 1724041320000, "minutes": 51, "affectedByLayover": True, "isDeparture": True, "occupancyStatus": -1, "occupancyDescription": "Unknown", "vehiclesInConsist": 1, "linkedVehicleIds": "1056", "vehicleId": "1056", "vehicleType": "Historic Street Car_VC1", "direction": { "id": "F_0_var1", "name": "Castro + Market", "destinationName": "Castro + Market", }, "tripId": "11593256_M13", "delay": 0, "predUsingNavigationTm": False, "departure": True, }, ], }, ] MOCK_PREDICTIONS_RESPONSE_WITH_ROUTE: list[StopPrediction] = [ { "serverTimestamp": 1720034290432, "nxbs2RedirectUrl": "", "route": { "id": "F", "title": "F Market & Wharves", "description": "7am-10pm daily", "color": "b49a36", "textColor": "000000", "hidden": False, }, "stop": { "id": "5184", "lat": 37.8071299, "lon": -122.41732, "name": "Jones St & Beach St", "code": "15184", "hidden": False, "showDestinationSelector": True, "route": "F", }, "values": [ { "timestamp": 1720034640000, "minutes": 5, "affectedByLayover": True, "isDeparture": True, "occupancyStatus": 0, "occupancyDescription": "Empty", "vehiclesInConsist": 1, "linkedVehicleIds": "1080", "vehicleId": "1080", "vehicleType": None, "direction": { "id": "F_0_var0", "name": "Castro", "destinationName": "Castro", }, "tripId": "11618999_M31", "delay": 0, "predUsingNavigationTm": False, "departure": True, }, { "timestamp": 1720035360000, "minutes": 17, "affectedByLayover": True, "isDeparture": True, "occupancyStatus": -1, "occupancyDescription": "Unknown", "vehiclesInConsist": 1, "linkedVehicleIds": "1070", "vehicleId": "1070", "vehicleType": None, "direction": { "id": "F_0_var0", "name": "Castro", "destinationName": "Castro", }, "tripId": "11619000_M31", "delay": 0, "predUsingNavigationTm": False, "departure": True, }, { "timestamp": 1720036080000, "minutes": 29, "affectedByLayover": True, "isDeparture": True, "occupancyStatus": -1, "occupancyDescription": "Unknown", "vehiclesInConsist": 1, "linkedVehicleIds": "1079", "vehicleId": "1079", "vehicleType": None, "direction": { "id": "F_0_var0", "name": "Castro", "destinationName": "Castro", }, "tripId": "11619001_M31", "delay": 0, "predUsingNavigationTm": False, "departure": True, }, ], } ] py_nextbusnext-2.3.0/tox.ini000066400000000000000000000005051502312032300161150ustar00rootroot00000000000000[tox] envlist = py{310, 311, 312, 313, 3} skip_missing_interpreters = true [testenv] description = Run unit tests commands = python -m unittest discover -p "*_test.py" -s ./tests [testenv:acceptance] envlist = py3 description = Run acceptance tests commands = python -m unittest discover -p "*_test.py" -s ./acceptance