pax_global_header 0000666 0000000 0000000 00000000064 15013157334 0014514 g ustar 00root root 0000000 0000000 52 comment=de2e33d4b9639508d159dce869a1cf3fa9b75620
wsdot-0.0.1/ 0000775 0000000 0000000 00000000000 15013157334 0012652 5 ustar 00root root 0000000 0000000 wsdot-0.0.1/.github/ 0000775 0000000 0000000 00000000000 15013157334 0014212 5 ustar 00root root 0000000 0000000 wsdot-0.0.1/.github/workflows/ 0000775 0000000 0000000 00000000000 15013157334 0016247 5 ustar 00root root 0000000 0000000 wsdot-0.0.1/.github/workflows/python-publish.yml 0000664 0000000 0000000 00000002017 15013157334 0021757 0 ustar 00root root 0000000 0000000 name: 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/.gitignore 0000664 0000000 0000000 00000000025 15013157334 0014637 0 ustar 00root root 0000000 0000000 dist/
**/__pycache__
wsdot-0.0.1/LICENSE 0000664 0000000 0000000 00000002667 15013157334 0013672 0 ustar 00root root 0000000 0000000 Copyright 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.md 0000664 0000000 0000000 00000000450 15013157334 0014130 0 ustar 00root root 0000000 0000000 # 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.toml 0000664 0000000 0000000 00000001317 15013157334 0015570 0 ustar 00root root 0000000 0000000 [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/ 0000775 0000000 0000000 00000000000 15013157334 0013441 5 ustar 00root root 0000000 0000000 wsdot-0.0.1/src/wsdot/ 0000775 0000000 0000000 00000000000 15013157334 0014601 5 ustar 00root root 0000000 0000000 wsdot-0.0.1/src/wsdot/__init__.py 0000664 0000000 0000000 00000010501 15013157334 0016707 0 ustar 00root root 0000000 0000000 import 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/ 0000775 0000000 0000000 00000000000 15013157334 0013631 5 ustar 00root root 0000000 0000000 wsdot-0.0.1/test/conftest.py 0000664 0000000 0000000 00000001521 15013157334 0016027 0 ustar 00root root 0000000 0000000 from 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/ 0000775 0000000 0000000 00000000000 15013157334 0014542 5 ustar 00root root 0000000 0000000 wsdot-0.0.1/test/data/GetTravelTimeAsJson-valid 0000664 0000000 0000000 00000001170 15013157334 0021413 0 ustar 00root root 0000000 0000000 {
"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-invalid 0000664 0000000 0000000 00000001164 15013157334 0022130 0 ustar 00root root 0000000 0000000 [{
"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-date 0000664 0000000 0000000 00000001162 15013157334 0023041 0 ustar 00root root 0000000 0000000 [{
"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-valid 0000664 0000000 0000000 00000001172 15013157334 0021600 0 ustar 00root root 0000000 0000000 [{
"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-nulls 0000664 0000000 0000000 00000001062 15013157334 0024537 0 ustar 00root root 0000000 0000000 [{
"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-nulls 0000664 0000000 0000000 00000000354 15013157334 0022734 0 ustar 00root root 0000000 0000000 [{
"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/empty 0000664 0000000 0000000 00000000000 15013157334 0015611 0 ustar 00root root 0000000 0000000 wsdot-0.0.1/test/data/empty-list 0000664 0000000 0000000 00000000003 15013157334 0016565 0 ustar 00root root 0000000 0000000 []
wsdot-0.0.1/test/test_travel_times.py 0000664 0000000 0000000 00000003004 15013157334 0017735 0 ustar 00root root 0000000 0000000 from 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)