Skip to content

Commit

Permalink
refactor: convert to async and use aiohttp (#187)
Browse files Browse the repository at this point in the history
* refactor: convert to async and use aiohttp

* sorting
  • Loading branch information
firstof9 authored Dec 6, 2024
1 parent c0dcc0c commit 9360d60
Show file tree
Hide file tree
Showing 8 changed files with 446 additions and 267 deletions.
143 changes: 94 additions & 49 deletions openeihttp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@
from __future__ import annotations

import datetime
import json
import logging
import time
from typing import Any, Dict
import requests # type: ignore

import aiohttp # type: ignore
from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError

from .const import BASE_URL

_LOGGER = logging.getLogger(__name__)
DEFAULT_HEADERS = {
"Content-Type": "application/json",
}
ERROR_TIMEOUT = "Timeout while updating"


class UrlNotFound(Exception):
Expand Down Expand Up @@ -61,47 +69,88 @@ def __init__(
]
self._timestamp = datetime.datetime(1990, 1, 1, 0, 0, 0)

def lookup_plans(self) -> Dict[str, Any]:
async def process_request(self, params: dict, timeout: int = 90) -> dict[str, Any]:
"""Process API requests."""
async with aiohttp.ClientSession(headers=DEFAULT_HEADERS) as session:
_LOGGER.debug("URL: %s", BASE_URL)
try:
async with session.get(
BASE_URL, params=params, timeout=timeout
) as response:
message: Any = {}
try:
message = await response.text()
except UnicodeDecodeError:
_LOGGER.debug("Decoding error.")
data = await response.read()
message = data.decode(errors="replace")

try:
message = json.loads(message)
except ValueError:
_LOGGER.warning("Non-JSON response: %s", message)
message = {"error": message}

if response.status == 404:
raise UrlNotFound
elif response.status == 401:
raise NotAuthorized
elif response.status != 200:
_LOGGER.error( # pylint: disable-next=line-too-long
"An error reteiving data from the server, code: %s\nmessage: %s", # noqa: E501
response.status,
message,
)
message = {"error": message}
return message

except (TimeoutError, ServerTimeoutError):
_LOGGER.error("%s: %s", ERROR_TIMEOUT, self._url)
message = {"error": ERROR_TIMEOUT}
except ContentTypeError as err:
_LOGGER.error("%s", err)
message = {"error": err}

await session.close()
return message

async def lookup_plans(self) -> Dict[str, Any]:
"""Return the rate plan names per utility in the area."""
if self._address == "" and (self._lat == 9000 and self._lon == 9000):
_LOGGER.error("Missing location data for a plan lookup.")
raise InvalidCall

thetime = time.time()

url = f"{BASE_URL}version=latest&format=json"
url = f"{url}&api_key={self._api}&orderby=startdate"
url = f"{url}&sector=Residential&effective_on_date={thetime}"
params = {
"version": "latest",
"format": "json",
"api_key": self._api,
"orderby": "startdate",
"sector": "Residential",
"effective_on_date": thetime,
}

if self._radius != 0.0:
url = f"{url}&radius={self._radius}"
params["radius"] = self._radius

if self._address == "":
url = f"{url}&lat={self._lat}&lon={self._lon}"
params["lat"] = self._lat
params["lon"] = self._lon
else:
url = f"{url}&address={self._address}"
params["address"] = self._address

rate_names: Dict[str, Any] = {}
msg = url
for redact in self._redact:
if redact:
msg = msg.replace(str(redact), "[REDACTED]")
redact_msg = "&lat=[REDACTED]&lon=[REDACTED]"
msg = msg.replace(f"&lat={self._lat}&lon={self._lon}", redact_msg)
_LOGGER.debug("Looking up plans via URL: %s", msg)

result = requests.get(url, timeout=90)
if result.status_code == 404:
raise UrlNotFound
if result.status_code == 401:
raise NotAuthorized

if "error" in result.json():
message = result.json()["error"]["message"]

result = await self.process_request(params, timeout=90)

if "error" in result.keys():
message = result["error"]["message"]
_LOGGER.error("Error: %s", message)
raise APIError

if "items" in result.json():
for item in result.json()["items"]:
if "items" in result.keys():
for item in result["items"]:
utility: str = item["utility"]
if utility not in rate_names:
rate_names[utility] = []
Expand All @@ -112,46 +161,42 @@ def lookup_plans(self) -> Dict[str, Any]:
rate_names[notlisted] = [{"name": notlisted, "label": notlisted}]
return rate_names

def update(self) -> None:
async def update(self) -> None:
"""Update data only if we need to."""
if self._data is None:
_LOGGER.debug("No data populated, refreshing data.")
self.update_data()
await self.update_data()
self._timestamp = datetime.datetime.now()
else:
elapsedtime = datetime.datetime.now() - self._timestamp
past = datetime.timedelta(hours=24)
if elapsedtime >= past:
_LOGGER.debug("Data stale, refreshing from API.")
self.update_data()
await self.update_data()
self._timestamp = datetime.datetime.now()

def update_data(self) -> None:
async def update_data(self) -> None:
"""Update the data."""
url = f"{BASE_URL}version=latest&format=json&detail=full"
url = f"{url}&api_key={self._api}&getpage={self._plan}"

msg = url
for redact in self._redact:
if redact:
msg = msg.replace(str(redact), "[REDACTED]")
_LOGGER.debug("Updating data via URL: %s", msg)

result = requests.get(url, timeout=90)
if result.status_code == 404:
raise UrlNotFound
if result.status_code == 401:
raise NotAuthorized

if "error" in result.json():
message = result.json()["error"]["message"]

params = {
"version": "latest",
"format": "json",
"detail": "full",
"api_key": self._api,
"getpage": self._plan,
}

result = await self.process_request(params, timeout=90)

if "error" in result.keys():
message = result["error"]["message"]
_LOGGER.error("Error: %s", message)
if "You have exceeded your rate limit." in message:
raise RateLimit
raise APIError

if "items" in result.json():
data = result.json()["items"][0]
if "items" in result.keys():
data = result["items"][0]
self._data = data
_LOGGER.debug("Data updated, results: %s", self._data)

Expand Down
2 changes: 1 addition & 1 deletion openeihttp/const.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Cosntants for python-openei."""

BASE_URL = "https://api.openei.org/utility_rates?"
BASE_URL = "https://api.openei.org/utility_rates"
10 changes: 5 additions & 5 deletions requirements_lint.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
black==24.10.0
flake8==7.1.1
mypy==1.13.0
pydocstyle==6.3.0
pylint==3.3.2
black
flake8
mypy
pydocstyle
pylint
10 changes: 6 additions & 4 deletions requirements_test.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
pytest==8.3.4
pytest-cov==6.0.0
pytest-timeout==2.3.1
requests_mock
pytest
pytest-asyncio
pytest-cov
pytest-timeout
aiohttp
aioresponses
freezegun
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

PROJECT_DIR = Path(__file__).parent.resolve()
README_FILE = PROJECT_DIR / "README.md"
VERSION = "0.1.24"
VERSION = "0.2.0"


setup(
Expand Down
137 changes: 10 additions & 127 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
"""Provide common pytest fixtures."""

import pytest
from aioresponses import aioresponses

import openeihttp
from tests.common import load_fixture

pytestmark = pytest.mark.asyncio


@pytest.fixture
def mock_aioclient():
"""Fixture to mock aioclient calls."""
with aioresponses() as m:
yield m


@pytest.fixture(name="test_lookup")
Expand Down Expand Up @@ -104,129 +113,3 @@ def test_rates_address():
return openeihttp.Rates(
api="fakeAPIKey", address="12345", plan="574613aa5457a3557e906f5b"
)


@pytest.fixture(name="lookup_mock")
def mock_lookup(requests_mock):
"""Mock the status reply."""
requests_mock.get(
"https://api.openei.org/utility_rates?version=latest&format=json&api_key=fakeAPIKey&lat=1&lon=1&sector=Residential",
text=load_fixture("lookup.json"),
)


@pytest.fixture(name="lookup_mock_address")
def mock_lookup_address(requests_mock):
"""Mock the status reply."""
requests_mock.get(
"https://api.openei.org/utility_rates?version=latest&format=json&api_key=fakeAPIKey&address=12345&sector=Residential",
text=load_fixture("lookup.json"),
)


@pytest.fixture(name="lookup_mock_radius")
def mock_lookup_radius(requests_mock):
"""Mock the status reply."""
requests_mock.get(
"https://api.openei.org/utility_rates?version=latest&format=json&api_key=fakeAPIKey&lat=1&lon=1&sector=Residential&radius=20",
text=load_fixture("lookup_radius.json"),
)


@pytest.fixture(name="plandata_mock")
def mock_plandata(requests_mock):
"""Mock the status reply."""
requests_mock.get(
"https://api.openei.org/utility_rates?version=latest&format=json&api_key=fakeAPIKey&detail=full&getpage=574613aa5457a3557e906f5b",
text=load_fixture("plan_data.json"),
)


@pytest.fixture(name="plandata_mock_address")
def mock_plandata_address(requests_mock):
"""Mock the status reply."""
requests_mock.get(
"https://api.openei.org/utility_rates?version=latest&format=json&api_key=fakeAPIKey&detail=full&getpage=574613aa5457a3557e906f5b",
text=load_fixture("plan_data.json"),
)


@pytest.fixture(name="demand_plandata_mock")
def mock_demand_plandata(requests_mock):
"""Mock the status reply."""
requests_mock.get(
"https://api.openei.org/utility_rates?version=latest&format=json&api_key=fakeAPIKey&detail=full&getpage=574613aa5457a3557e906f5b",
text=load_fixture("plan_demand_data.json"),
)


@pytest.fixture(name="tier_plandata_mock")
def mock_tier_plandata(requests_mock):
"""Mock the status reply."""
requests_mock.get(
"https://api.openei.org/utility_rates?version=latest&format=json&api_key=fakeAPIKey&detail=full&getpage=574613aa5457a3557e906f5b",
text=load_fixture("plan_tier_data.json"),
)


@pytest.fixture(name="lookup_mock_404")
def mock_lookup_404(requests_mock):
"""Mock the status reply."""
requests_mock.get(
"https://api.openei.org/utility_rates?version=latest&format=json&api_key=fakeAPIKey&lat=1&lon=1&sector=Residential",
status_code=404,
)


@pytest.fixture(name="plandata_mock_404")
def mock_plandata_404(requests_mock):
"""Mock the status reply."""
requests_mock.get(
"https://api.openei.org/utility_rates?version=latest&format=json&api_key=fakeAPIKey&detail=full&getpage=574613aa5457a3557e906f5b",
status_code=404,
)


@pytest.fixture(name="lookup_mock_401")
def mock_lookup_401(requests_mock):
"""Mock the status reply."""
requests_mock.get(
"https://api.openei.org/utility_rates?version=latest&format=json&api_key=fakeAPIKey&lat=1&lon=1&sector=Residential",
status_code=401,
)


@pytest.fixture(name="plandata_mock_401")
def mock_plandata_401(requests_mock):
"""Mock the status reply."""
requests_mock.get(
"https://api.openei.org/utility_rates?version=latest&format=json&api_key=fakeAPIKey&detail=full&getpage=574613aa5457a3557e906f5b",
status_code=401,
)


@pytest.fixture(name="plandata_mock_api_err")
def mock_plandata_api_err(requests_mock):
"""Mock the status reply."""
requests_mock.get(
"https://api.openei.org/utility_rates?version=latest&format=json&api_key=fakeAPIKey&detail=full&getpage=574613aa5457a3557e906f5b",
text=load_fixture("api_error.json"),
)


@pytest.fixture(name="lookup_mock_api_err")
def mock_lookup_api_err(requests_mock):
"""Mock the status reply."""
requests_mock.get(
"https://api.openei.org/utility_rates?version=latest&format=json&api_key=fakeAPIKey&lat=1&lon=1&sector=Residential",
text=load_fixture("api_error.json"),
)


@pytest.fixture(name="mock_rate_limit_err")
def mock_rate_limit(requests_mock):
"""Mock the status reply."""
requests_mock.get(
"https://api.openei.org/utility_rates?version=latest&format=json&api_key=fakeAPIKey&detail=full&getpage=574613aa5457a3557e906f5b",
text=load_fixture("rate_limit.json"),
)
Loading

0 comments on commit 9360d60

Please sign in to comment.