pax_global_header00006660000000000000000000000064150131573340014514gustar00rootroot0000000000000052 comment=de2e33d4b9639508d159dce869a1cf3fa9b75620 wsdot-0.0.1/000077500000000000000000000000001501315733400126525ustar00rootroot00000000000000wsdot-0.0.1/.github/000077500000000000000000000000001501315733400142125ustar00rootroot00000000000000wsdot-0.0.1/.github/workflows/000077500000000000000000000000001501315733400162475ustar00rootroot00000000000000wsdot-0.0.1/.github/workflows/python-publish.yml000066400000000000000000000020171501315733400217570ustar00rootroot00000000000000name: Upload Python Package on: release: types: [published] permissions: contents: read jobs: release-build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.x" - name: Build release distributions run: | python -m pip install build python -m build - name: Upload distributions uses: actions/upload-artifact@v4 with: name: release-dists path: dist/ pypi-publish: runs-on: ubuntu-latest needs: - release-build permissions: id-token: write environment: name: pypi url: https://pypi.org/p/wsdot steps: - name: Retrieve release distributions uses: actions/download-artifact@v4 with: name: release-dists path: dist/ - name: Publish release distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: dist/ wsdot-0.0.1/.gitignore000066400000000000000000000000251501315733400146370ustar00rootroot00000000000000dist/ **/__pycache__ wsdot-0.0.1/LICENSE000066400000000000000000000026671501315733400136720ustar00rootroot00000000000000Copyright 2025 Jeremiah Paige Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. wsdot-0.0.1/README.md000066400000000000000000000004501501315733400141300ustar00rootroot00000000000000# WSDOT An asynchronous Python wrapper of the Washington State Department of Transportation APIs hosted at . ## Setup Usage of these APIs requires an API key. Anyone can obtain a free API key by providing their email at . wsdot-0.0.1/pyproject.toml000066400000000000000000000013171501315733400155700ustar00rootroot00000000000000[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "wsdot" version = "0.0.1" readme = "README.md" license = "BSD-3-Clause" authors = [{name = "Jeremiah Paige", email = "ucodery@gmail.com"}] description = "A Python wrapper of the wsdot.wa.gov APIs" dependencies = [ "aiohttp", "pydantic", ] [project.urls] Home = "https://github.com/ucodery/wsdot" [tool.hatch.envs.check] dependencies = [ "ruff", ] [tool.hatch.envs.check.scripts] run = "ruff check" fmt = "ruff format" [tool.hatch.envs.test] dependencies = [ "mypy", "pytest", "pytest-asyncio", ] [tool.hatch.envs.test.scripts] run = "pytest" type = "mypy ." [tool.ruff.format] quote-style = "single" wsdot-0.0.1/src/000077500000000000000000000000001501315733400134415ustar00rootroot00000000000000wsdot-0.0.1/src/wsdot/000077500000000000000000000000001501315733400146015ustar00rootroot00000000000000wsdot-0.0.1/src/wsdot/__init__.py000066400000000000000000000105011501315733400167070ustar00rootroot00000000000000import asyncio import atexit from datetime import datetime, timedelta, timezone import re from typing import Annotated, Any import aiohttp from pydantic import BaseModel, PlainValidator __session: aiohttp.ClientSession | None = None def get_long_lived_session() -> aiohttp.ClientSession: global __session if __session is None: session = aiohttp.ClientSession() def close_long_lived_session(ses=session, aio=asyncio): try: loop = aio.get_event_loop() if loop.is_running(): loop.create_task(ses.close()) else: loop.run_until_complete(ses.close()) except Exception: pass atexit.register(close_long_lived_session) __session = session return __session class WsdotTravelError(Exception): def __init__( self, msg: str = '', url: str | None = None, status: int | None = None ) -> None: self.msg = msg self.url = url self.status = status def __str__(self) -> str: message = self.msg if self.url: message = f'{message}{" " if message else ""}calling {self.url}' if self.status is not None: message = f'{message} got {self.status}' return message class WsdotTravel: def __init__( self, api_key: str, *, session: aiohttp.ClientSession | None = None ) -> None: self.api_key = api_key self.url = 'http://www.wsdot.wa.gov/traffic/api/' self.data_path = '' self.session = session if session is not None else get_long_lived_session() async def get_json( self, subpath: str = '', params: dict[Any, Any] | None = None ) -> dict[Any, Any]: auth_params = {'AccessCode': self.api_key} if params: auth_params |= params travel_url = self.url + self.data_path + subpath async with self.session.get(travel_url, params=auth_params) as response: if response.status != 200: raise WsdotTravelError( 'unexpected status', url=travel_url, status=response.status ) if not response.content_type == 'application/json': raise WsdotTravelError( 'unexpected data type', url=travel_url, status=response.status ) if not await response.text(): raise WsdotTravelError('received no data', url=travel_url) return await response.json() class TravelLocation(BaseModel): Description: str | None Direction: str | None Latitude: float Longitude: float MilePost: float RoadName: str | None def _updated_datetime(time_updated: Any) -> datetime: if not isinstance(time_updated, str): raise ValueError('string required') timestamp_parts = re.match( r'/Date\((?P\d{13})(?P[+-]\d{2})(?P\d{2})\)/', time_updated ) if timestamp_parts is None: raise ValueError('unsupported datetime format (expected "/Date(%s%f%z)/")') ts = int(timestamp_parts.group('mseconds')) / 1000 tz = timezone( timedelta( hours=int(timestamp_parts.group('zhr')), minutes=int(timestamp_parts.group('zmin')), ) ) return datetime.fromtimestamp(ts, tz=tz) class TravelTime(BaseModel): AverageTime: int CurrentTime: int Description: str | None Distance: float EndPoint: TravelLocation | None Name: str | None StartPoint: TravelLocation | None TimeUpdated: Annotated[datetime, PlainValidator(_updated_datetime)] TravelTimeID: int class WsdotTravelTimes(WsdotTravel): def __init__( self, api_key: str, *, session: aiohttp.ClientSession | None = None ) -> None: super().__init__(api_key=api_key, session=session) self.data_path = 'TravelTimes/TravelTimesREST.svc/' async def get_all_travel_times(self) -> list[TravelTime]: return [ TravelTime(**travel_time) for travel_time in await self.get_json(subpath='GetTravelTimesAsJson') ] async def get_travel_time(self, travel_time_id: int) -> TravelTime: return TravelTime( **await self.get_json( subpath='GetTravelTimeAsJson', params={'TravelTimeID': travel_time_id} ) ) wsdot-0.0.1/test/000077500000000000000000000000001501315733400136315ustar00rootroot00000000000000wsdot-0.0.1/test/conftest.py000066400000000000000000000015211501315733400160270ustar00rootroot00000000000000from contextlib import asynccontextmanager from functools import partial import json import os import unittest.mock import aiohttp import pytest @asynccontextmanager async def local_get(url, params=None, *, local_data=''): data_path = os.path.join( os.path.dirname(__file__), 'data', local_data, ) with open(data_path) as response: data = response.read() class response: status = 200 content_type = 'application/json' if data else 'application/octet-stream' async def json(): return json.loads(data) async def text(): return data yield response @pytest.fixture def local_session(local_data): local = unittest.mock.MagicMock(spec=aiohttp.ClientResponse) local.get = partial(local_get, local_data=local_data) return local wsdot-0.0.1/test/data/000077500000000000000000000000001501315733400145425ustar00rootroot00000000000000wsdot-0.0.1/test/data/GetTravelTimeAsJson-valid000066400000000000000000000011701501315733400214130ustar00rootroot00000000000000{ "AverageTime":2147483647, "CurrentTime":2147483647, "Description":"String content", "Distance":12678967.543233, "EndPoint":{ "Description":"String content", "Direction":"String content", "Latitude":12678967.543233, "Longitude":12678967.543233, "MilePost":12678967.543233, "RoadName":"String content" }, "Name":"String content", "StartPoint":{ "Description":"String content", "Direction":"String content", "Latitude":12678967.543233, "Longitude":12678967.543233, "MilePost":12678967.543233, "RoadName":"String content" }, "TimeUpdated":"\/Date(1745003700000-0700)\/", "TravelTimeID":2147483647 } wsdot-0.0.1/test/data/GetTravelTimesAsJson-invalid000066400000000000000000000011641501315733400221300ustar00rootroot00000000000000[{ "AverageTime":2147483647, "CurrentTime":2147483647, "Description":"String content", "Distance":12678967.543233, "EndPoint":{ "Description":"String content", "Direction":"String content", "Latitude":12678967.543233, "Longitude":12678967.543233, "MilePost":12678967.543233, "RoadName":"String content" }, "Name":"String content", "StartPoint":{ "Description":"String content", "Direction":"String content", "Latitude":12678967.543233, "Longitude":12678967.543233, "MilePost":12678967.543233, "RoadName":"String content" }, "TimeUpdated":"\/Date(1745003700000-0700)\/", "TravelTimeID":null }] wsdot-0.0.1/test/data/GetTravelTimesAsJson-invalid-date000066400000000000000000000011621501315733400230410ustar00rootroot00000000000000[{ "AverageTime":2147483647, "CurrentTime":2147483647, "Description":"String content", "Distance":12678967.543233, "EndPoint":{ "Description":"String content", "Direction":"String content", "Latitude":12678967.543233, "Longitude":12678967.543233, "MilePost":12678967.543233, "RoadName":"String content" }, "Name":"String content", "StartPoint":{ "Description":"String content", "Direction":"String content", "Latitude":12678967.543233, "Longitude":12678967.543233, "MilePost":12678967.543233, "RoadName":"String content" }, "TimeUpdated":"\/Date(2025-20-25)\/", "TravelTimeID":2147483647 }] wsdot-0.0.1/test/data/GetTravelTimesAsJson-valid000066400000000000000000000011721501315733400216000ustar00rootroot00000000000000[{ "AverageTime":2147483647, "CurrentTime":2147483647, "Description":"String content", "Distance":12678967.543233, "EndPoint":{ "Description":"String content", "Direction":"String content", "Latitude":12678967.543233, "Longitude":12678967.543233, "MilePost":12678967.543233, "RoadName":"String content" }, "Name":"String content", "StartPoint":{ "Description":"String content", "Direction":"String content", "Latitude":12678967.543233, "Longitude":12678967.543233, "MilePost":12678967.543233, "RoadName":"String content" }, "TimeUpdated":"\/Date(1745003700000-0700)\/", "TravelTimeID":2147483647 }] wsdot-0.0.1/test/data/GetTravelTimesAsJson-valid-location-nulls000066400000000000000000000010621501315733400245370ustar00rootroot00000000000000[{ "AverageTime":2147483647, "CurrentTime":2147483647, "Description":"String content", "Distance":12678967.543233, "EndPoint":{ "Description":null, "Direction":null, "Latitude":12678967.543233, "Longitude":12678967.543233, "MilePost":12678967.543233, "RoadName":null }, "Name":"String content", "StartPoint":{ "Description":null, "Direction":null, "Latitude":12678967.543233, "Longitude":12678967.543233, "MilePost":12678967.543233, "RoadName":null }, "TimeUpdated":"\/Date(1745003700000-0700)\/", "TravelTimeID":2147483647 }] wsdot-0.0.1/test/data/GetTravelTimesAsJson-valid-nulls000066400000000000000000000003541501315733400227340ustar00rootroot00000000000000[{ "AverageTime":2147483647, "CurrentTime":2147483647, "Description":null, "Distance":12678967.543233, "EndPoint":null, "Name":null, "StartPoint":null, "TimeUpdated":"\/Date(1745003700000-0700)\/", "TravelTimeID":2147483647 }] wsdot-0.0.1/test/data/empty000066400000000000000000000000001501315733400156110ustar00rootroot00000000000000wsdot-0.0.1/test/data/empty-list000066400000000000000000000000031501315733400165650ustar00rootroot00000000000000[] wsdot-0.0.1/test/test_travel_times.py000066400000000000000000000030041501315733400177350ustar00rootroot00000000000000from pydantic import ValidationError import pytest from wsdot import TravelTime, WsdotTravelError, WsdotTravelTimes @pytest.mark.parametrize( 'local_data', [ 'GetTravelTimesAsJson-valid', 'GetTravelTimesAsJson-valid-nulls', 'GetTravelTimesAsJson-valid-location-nulls', 'empty-list', ], ) @pytest.mark.asyncio async def test_travel_times(local_session): dot = WsdotTravelTimes(api_key='dead_beef', session=local_session) all_times = await dot.get_all_travel_times() assert isinstance(all_times, list) assert all(isinstance(entry, TravelTime) for entry in all_times) @pytest.mark.parametrize( 'local_data', ['GetTravelTimesAsJson-invalid', 'GetTravelTimesAsJson-invalid-date'] ) @pytest.mark.asyncio async def test_invalid_travel_times(local_session): dot = WsdotTravelTimes(api_key='dead_beef', session=local_session) with pytest.raises(ValidationError): await dot.get_all_travel_times() @pytest.mark.parametrize('local_data', ['empty']) @pytest.mark.asyncio async def test_empty_travel_times(local_session): dot = WsdotTravelTimes(api_key='dead_beef', session=local_session) with pytest.raises(WsdotTravelError): await dot.get_all_travel_times() @pytest.mark.parametrize('local_data', ['GetTravelTimeAsJson-valid']) @pytest.mark.asyncio async def test_travel_time(local_session): dot = WsdotTravelTimes(api_key='dead_beef', session=local_session) time = await dot.get_travel_time(1) assert isinstance(time, TravelTime)