diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ba1c6b8..cfaddb1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,3 +9,7 @@ updates: directory: "/" # Location of package manifests schedule: interval: "daily" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 98ee95f..0000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,71 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ main ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ main ] - schedule: - - cron: '41 20 * * 0' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 6a61681..239d8b1 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -9,20 +9,22 @@ jobs: build-and-publish: name: Builds and publishes releases to PyPI runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - - uses: actions/checkout@v3.1.0 - - name: Set up Python 3.9 - uses: actions/setup-python@v4.3.0 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: - python-version: 3.9 - - name: Install wheel + persist-credentials: false + - name: Set up Python 3.12 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Install build run: >- - pip install wheel + pip install build - name: Build run: >- - python3 setup.py sdist bdist_wheel + python3 -m build - name: Publish release to PyPI - uses: pypa/gh-action-pypi-publish@v1.13.0 - with: - user: __token__ - password: ${{ secrets.PYPI_TOKEN }} + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index a3f43e1..e734965 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -8,8 +8,11 @@ on: jobs: update_release_draft: runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write steps: # Drafts your next Release notes as Pull Requests are merged into "main" - - uses: release-drafter/release-drafter@v5 + - uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c4ee3fe..e5077af 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,28 +12,29 @@ on: jobs: tests: runs-on: ubuntu-latest + permissions: + contents: read strategy: matrix: - python-version: ["3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: đŸ“Ĩ Checkout the repository - uses: actions/checkout@v4 - - name: đŸ› ī¸ Set up Python - uses: actions/setup-python@v5 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: - fetch-depth: 2 + fetch-depth: 2 + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} - name: đŸ“Ļ Install requirements run: | - pip install tox tox-gh-actions + pip install tox tox-gh-actions - name: 🏃 Test with tox run: tox - name: 📤 Upload coverage to Codecov - uses: "actions/upload-artifact@v4" + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-data path: "coverage.xml" @@ -41,16 +42,19 @@ jobs: coverage: runs-on: ubuntu-latest needs: tests + permissions: + contents: read steps: - name: đŸ“Ĩ Checkout the repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 2 + persist-credentials: false - name: đŸ“Ĩ Download coverage data - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: coverage-data - name: 📤 Upload coverage report - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f54040f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.14 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-added-large-files + - id: debug-statements diff --git a/.vscode/settings.json b/.vscode/settings.json index a04b218..4a954db 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,4 +2,4 @@ "files.associations": { "*.yaml": "home-assistant" } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 76610ce..1b5879d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,139 @@ # python-openei -Python Library for OpenEI.org Rest data -A python library for consuming OpenEI.org rest data and outputting it into an easy to use format. +[![PyPI version](https://img.shields.io/pypi/v/python-openei.svg)](https://pypi.org/project/python-openei/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +An asynchronous Python library for consuming utility rate data from the [OpenEI.org](https://openei.org) API and outputting it into an easy-to-use format. + +## Features + +- **Asynchronous API**: Fully built on `aiohttp` for non-blocking network calls. +- **Auto Caching**: Automatically caches API responses locally (24-hour expiration) to stay within rate limits. +- **Utility Plan Lookup**: Find utility rate plans by coordinates (latitude/longitude) or street address. +- **Rate Schedule Queries**: Calculates current and upcoming energy rates, demand rates, adjustments, and tier/sell rates for any given date and time. + +--- + +## Installation + +Install using `pip`: + +```bash +pip install python-openei +``` + +--- + +## Quick Start + +You will need an API key from [OpenEI.org](https://openei.org/wiki/Special:Register). + +### Basic Usage + +Here is a quick example of how to retrieve and query energy rates for a specific plan: + +```python +import asyncio +from openeihttp import Rates + +async def main(): + # Initialize Rates helper + # Retrieve a specific plan (e.g. "539fca56ec12157c50403bf6") + api = Rates( + api="YOUR_OPENEI_API_KEY", + plan="539fca56ec12157c50403bf6", + cache_file="my_rate_cache.json" # Optional local cache file + ) + + # Fetch/update the rate plan details + await api.update() + + print(f"Rate Plan Name: {api.rate_name}") + print(f"Current Energy Rate: ${api.current_rate}/kWh") + print(f"Current Sell Rate: ${api.current_sell_rate}/kWh") + + # Check what the next rate will be and when it changes + next_time = api.next_energy_rate_structure_time + next_rate = api.next_energy_rate_structure + print(f"Next rate change at: {next_time} (structure ID: {next_rate})") + +asyncio.run(main()) +``` + +--- + +## Plan Lookup + +If you do not know the plan ID, you can look up available plans using a latitude/longitude pair or a physical address: + +```python +import asyncio +from openeihttp import Rates + +async def lookup(): + # Set up lookup using latitude and longitude + api = Rates( + api="YOUR_OPENEI_API_KEY", + lat=37.7749, + lon=-122.4194, + radius=5.0 # Optional search radius in miles + ) + + plans = await api.lookup_plans() + + # plans will be grouped by utility name + for utility, plan_list in plans.items(): + print(f"\nUtility: {utility}") + for plan in plan_list: + print(f" - {plan['name']} (Plan Label: {plan['label']})") + +asyncio.run(lookup()) +``` + +--- + +## API Reference + +### Properties + +The `Rates` object exposes the following properties after a successful `update()`: + +| Property | Return Type | Description | +| :--- | :--- | :--- | +| `rate_name` | `str` | Name of the utility rate plan. | +| `approval` | `bool` | Approval status of the rate plan on OpenEI. | +| `current_rate` | `float \| None` | Current active energy rate in $/kWh. | +| `current_sell_rate` | `float \| None` | Current net-metering / sell rate in $/kWh. | +| `current_adjustment` | `float \| None` | Current rate adjustment value in $/kWh. | +| `next_energy_rate_structure` | `int \| None` | Upcoming energy rate structure ID. | +| `next_energy_rate_structure_time` | `datetime \| None` | The time at which the next energy rate structure starts. | +| `current_demand_rate` | `float \| None` | Current demand rate. | +| `current_demand_adjustment`| `float \| None` | Current demand rate adjustment. | +| `demand_unit` | `str \| None` | The unit of the demand rate. | +| `monthly_tier_rate` | `float \| None` | Current tier rate based on monthly meter reading. | +| `distributed_generation` | `str \| None` | Distributed generation rules / net-metering type. | +| `mincharge` | `tuple[float, str] \| None` | Minimum charge amount and units (e.g. `(10.0, "$/month")`). | +| `fixedchargefirstmeter` | `tuple[float, str] \| None` | Fixed charge amount and units for the first meter. | + +### Methods + +- `await api.update()`: Updates the internal data. Loads from cache if fresh, otherwise fetches from API and caches locally. +- `await api.update_data()`: Forces a fresh API call (bypassing cache) and rewrites the cache file. +- `await api.clear_cache()`: Deletes the cache file if one was configured. +- `api.rate(date: datetime)`: Look up the energy rate for a specific date and time. +- `api.sell_rate(date: datetime)`: Look up the sell/net-metering rate for a specific date and time. +- `api.demand_rate(date: datetime)`: Look up the demand rate for a specific date and time. + +--- + +## Development + +This project uses `tox` to run checks and tests across Python versions. + +### Run Tests and Linters + +Make sure you have `tox` installed, then run: + +```bash +tox +``` diff --git a/openeihttp/__init__.py b/openeihttp/__init__.py index 433041f..85b1a38 100644 --- a/openeihttp/__init__.py +++ b/openeihttp/__init__.py @@ -6,7 +6,7 @@ import json import logging import time -from typing import Any, Dict +from typing import Any import aiohttp # type: ignore from aiohttp.client_exceptions import ContentTypeError, ServerTimeoutError @@ -41,7 +41,6 @@ class InvalidCall(Exception): """Exception for invalid library calls.""" -# pylint: disable=too-many-positional-arguments class Rates: """Represent OpenEI Rates.""" @@ -78,7 +77,9 @@ async def process_request(self, params: dict, timeout: int = 90) -> dict[str, An _LOGGER.debug("URL: %s", BASE_URL) try: async with session.get( - BASE_URL, params=params, timeout=timeout + BASE_URL, + params=params, + timeout=aiohttp.ClientTimeout(total=timeout), ) as response: message: Any = {} try: @@ -99,8 +100,8 @@ async def process_request(self, params: dict, timeout: int = 90) -> dict[str, An if response.status == 401: raise NotAuthorized if response.status != 200: - _LOGGER.error( # pylint: disable-next=line-too-long - "An error reteiving data from the server, code: %s\nmessage: %s", # noqa: E501 + _LOGGER.error( + "An error reteiving data from the server, code: %s\nmessage: %s", response.status, message, ) @@ -117,7 +118,7 @@ async def process_request(self, params: dict, timeout: int = 90) -> dict[str, An await session.close() return message - async def lookup_plans(self) -> Dict[str, Any]: + 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.") @@ -143,16 +144,17 @@ async def lookup_plans(self) -> Dict[str, Any]: else: params["address"] = self._address - rate_names: Dict[str, Any] = {} + rate_names: dict[str, Any] = {} result = await self.process_request(params, timeout=90) - if "error" in result.keys(): - message = result["error"]["message"] + if "error" in result: + err = result["error"] + message = err["message"] if isinstance(err, dict) and "message" in err else str(err) _LOGGER.error("Error: %s", message) raise APIError - if "items" in result.keys(): + if "items" in result: for item in result["items"]: utility: str = item["utility"] if utility not in rate_names: @@ -168,10 +170,7 @@ async def update(self) -> None: """Update data only if we need to.""" if self._data is None: _LOGGER.debug("No data populated, refreshing data.") - if self._cache_file: - cache = OpenEICache(self._cache_file) - else: - cache = OpenEICache() + cache = OpenEICache(self._cache_file) if self._cache_file else OpenEICache() # Load cached file if one exists if await cache.cache_exists(): _LOGGER.debug("Cache file exists, reading...") @@ -200,31 +199,26 @@ async def update_data(self) -> None: result = await self.process_request(params, timeout=90) - if "error" in result.keys(): - message = result["error"]["message"] + if "error" in result: + err = result["error"] + message = err["message"] if isinstance(err, dict) and "message" in err else str(err) _LOGGER.error("Error: %s", message) if "You have exceeded your rate limit." in message: raise RateLimit raise APIError - if "items" in result.keys(): + if "items" in result: data = result["items"][0] self._data = data # Insert cache writing call here - if self._cache_file: - cache = OpenEICache(self._cache_file) - else: - cache = OpenEICache() + cache = OpenEICache(self._cache_file) if self._cache_file else OpenEICache() json_data = json.dumps(data).encode("utf-8") await cache.write_cache(json_data) _LOGGER.debug("Data updated, results: %s", self._data) async def clear_cache(self) -> None: """Clear cache file.""" - if self._cache_file: - cache = OpenEICache(self._cache_file) - else: - cache = OpenEICache() + cache = OpenEICache(self._cache_file) if self._cache_file else OpenEICache() await cache.clear_cache() @property @@ -240,11 +234,7 @@ def rate_structure(self, date, rate_type) -> int | None: hour = date.hour weekend = date.weekday() > 4 - table = ( - f"{rate_type}weekendschedule" - if weekend - else f"{rate_type}weekdayschedule" - ) + table = f"{rate_type}weekendschedule" if weekend else f"{rate_type}weekdayschedule" lookup_table = self._data[table] rate_structure = lookup_table[month][hour] @@ -306,16 +296,14 @@ def next_rate_schedule( return current_time.replace(hour=hour), rate_structure # Handle next day transition - if day_of_week not in [4, 6]: - if ( - current_time + datetime.timedelta(days=1) - ).month == current_time.month: - return ( - current_time.replace( - day=current_time.day + 1, hour=hour - ), - rate_structure, - ) + if ( + day_of_week not in [4, 6] + and (current_time + datetime.timedelta(days=1)).month == current_time.month + ): + return ( + current_time.replace(day=current_time.day + 1, hour=hour), + rate_structure, + ) days_to_move = 5 - day_of_week if day_of_week <= 4 else 7 - day_of_week @@ -324,9 +312,7 @@ def next_rate_schedule( ).month != current_time.month: break - current_time = current_time.replace( - hour=0, day=current_time.day + days_to_move - ) + current_time = current_time.replace(hour=0, day=current_time.day + days_to_move) current_time = current_time.replace(day=1) diff --git a/openeihttp/cache.py b/openeihttp/cache.py index ff66d7e..eba2ce2 100644 --- a/openeihttp/cache.py +++ b/openeihttp/cache.py @@ -34,7 +34,7 @@ async def read_cache(self) -> Any: """Read cache file.""" _LOGGER.debug("Attempting to read file: %s", self._cache_file) if exists(self._cache_file): - async with aiofiles.open(self._cache_file, mode="r") as file: + async with aiofiles.open(self._cache_file) as file: _LOGGER.debug("Reading file: %s", self._cache_file) value = await file.read() diff --git a/pylintrc b/pylintrc deleted file mode 100644 index ac70d6f..0000000 --- a/pylintrc +++ /dev/null @@ -1,37 +0,0 @@ -[MASTER] -ignore=tests -# Use a conservative default here; 2 should speed up most setups and not hurt -# any too bad. Override on command line as appropriate. -jobs=2 -persistent=no - -[BASIC] -good-names=id,i,j,k,ex,Run,_,fp -max-attributes=15 - -[MESSAGES CONTROL] -# Reasons disabled: -# locally-disabled - it spams too much -# too-many-* - are not enforced for the sake of readability -# too-few-* - same as too-many-* -# import-outside-toplevel - TODO -disable= - duplicate-code, - fixme, - import-outside-toplevel, - locally-disabled, - too-few-public-methods, - too-many-arguments, - too-many-public-methods, - too-many-instance-attributes, - too-many-branches, - -[REPORTS] -score=no - -[TYPECHECK] -# For attrs -ignored-classes=_CountingAttr - -[FORMAT] -expected-line-ending-format=LF \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f333a03 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,65 @@ +[build-system] +requires = ["setuptools>=61.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "python_openei" +version = "0.2.8" +description = "Python wrapper for OpenEI HTTP API" +readme = "README.md" +authors = [ + { name = "firstof9", email = "firstof9@gmail.com" } +] +requires-python = ">=3.8" +dependencies = [ + "aiofiles", + "aiohttp", + "requests", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "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", + "Programming Language :: Python :: 3.14", +] + +[project.urls] +Homepage = "https://github.com/firstof9/python-openei" + +[tool.setuptools.packages.find] +exclude = ["test*", "tests"] + +[tool.ruff] +target-version = "py39" +line-length = 100 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "D", # pydocstyle + "UP", # pyupgrade + "S", # flake8-bandit + "B", # flake8-bugbear + "SIM", # flake8-simplify + "LOG", # flake8-logging +] +ignore = [ + "D203", # one-blank-line-before-class (conflicts with D211) + "D213", # multi-line-docstring-summary-second-line (conflicts with D212) +] + +[tool.ruff.lint.per-file-ignores] +"openeihttp/*" = ["S101"] +"tests/*" = ["D", "S101", "E501", "SIM117"] + +[tool.ruff.format] diff --git a/requirements.txt b/requirements.txt index f72db6f..1ce9bba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -aiofiles \ No newline at end of file +aiofiles diff --git a/requirements_lint.txt b/requirements_lint.txt index f6a2a15..6e6df28 100644 --- a/requirements_lint.txt +++ b/requirements_lint.txt @@ -1,6 +1,4 @@ -r requirements.txt -black -flake8 mypy -pydocstyle -pylint \ No newline at end of file +ruff +types-aiofiles diff --git a/requirements_test.txt b/requirements_test.txt index acc4621..d0cffa5 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,4 +5,4 @@ pytest-cov pytest-timeout aiohttp aioresponses -freezegun \ No newline at end of file +freezegun diff --git a/setup.py b/setup.py deleted file mode 100644 index 0bfedd8..0000000 --- a/setup.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Setup module for python-openei-http.""" - -from pathlib import Path - -from setuptools import find_packages, setup - -PROJECT_DIR = Path(__file__).parent.resolve() -README_FILE = PROJECT_DIR / "README.md" -VERSION = "0.2.8" - - -setup( - name="python_openei", - version=VERSION, - url="https://github.com/firstof9/python-openei", - download_url="https://github.com/firstof9/python-openei", - author="firstof9", - author_email="firstof9@gmail.com", - description="Python wrapper for OpenEI HTTP API", - long_description=README_FILE.read_text(encoding="utf-8"), - long_description_content_type="text/markdown", - packages=find_packages(exclude=["test.*", "tests"]), - python_requires=">=3.8", - install_requires=["requests"], - entry_points={}, - include_package_data=True, - zip_safe=False, - classifiers=[ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "Natural Language :: English", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - ], -) diff --git a/tests/conftest.py b/tests/conftest.py index 8ff9d84..cfff018 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -102,14 +102,10 @@ def test_lookup_monthly_tier_high(): @pytest.fixture(name="test_rates") def test_rates(): """Load the charger data.""" - return openeihttp.Rates( - api="fakeAPIKey", lat="1", lon="1", plan="574613aa5457a3557e906f5b" - ) + return openeihttp.Rates(api="fakeAPIKey", lat="1", lon="1", plan="574613aa5457a3557e906f5b") @pytest.fixture(name="test_rates_address") def test_rates_address(): """Load the charger data.""" - return openeihttp.Rates( - api="fakeAPIKey", address="12345", plan="574613aa5457a3557e906f5b" - ) + return openeihttp.Rates(api="fakeAPIKey", address="12345", plan="574613aa5457a3557e906f5b") diff --git a/tests/fixtures/api_error.json b/tests/fixtures/api_error.json index 5a93608..2d6af64 100644 --- a/tests/fixtures/api_error.json +++ b/tests/fixtures/api_error.json @@ -3,4 +3,4 @@ "code": "API_KEY_MISSING", "message": "No api_key was supplied. Get one at https://api.openei.org:443" } -} \ No newline at end of file +} diff --git a/tests/fixtures/fixed_charge_rate.json b/tests/fixtures/fixed_charge_rate.json index e9518c3..7d354fe 100644 --- a/tests/fixtures/fixed_charge_rate.json +++ b/tests/fixtures/fixed_charge_rate.json @@ -665,4 +665,4 @@ "country": "USA" } ] -} \ No newline at end of file +} diff --git a/tests/fixtures/lookup.json b/tests/fixtures/lookup.json index fa4e55a..4706c58 100644 --- a/tests/fixtures/lookup.json +++ b/tests/fixtures/lookup.json @@ -666,4 +666,4 @@ "sourceparent": "https://www.aps.com/en/ourcompany/ratesregulationsresources/serviceplaninformation/Pages/residential-sheets.aspx" } ] -} \ No newline at end of file +} diff --git a/tests/fixtures/lookup_radius.json b/tests/fixtures/lookup_radius.json index 2b29cd8..5b7d9bd 100644 --- a/tests/fixtures/lookup_radius.json +++ b/tests/fixtures/lookup_radius.json @@ -1644,4 +1644,4 @@ "sourceparent": "https://www.srpnet.com/prices/home/evexport.aspx" } ] -} \ No newline at end of file +} diff --git a/tests/fixtures/plan_adjustments_data.json b/tests/fixtures/plan_adjustments_data.json index 1dfc68a..968f3ca 100644 --- a/tests/fixtures/plan_adjustments_data.json +++ b/tests/fixtures/plan_adjustments_data.json @@ -665,4 +665,4 @@ "country": "USA" } ] -} \ No newline at end of file +} diff --git a/tests/fixtures/plan_data.json b/tests/fixtures/plan_data.json index afdc9ce..64e7a83 100644 --- a/tests/fixtures/plan_data.json +++ b/tests/fixtures/plan_data.json @@ -687,4 +687,4 @@ "basicinformationcomments": "TOU with Demand - T. Harris" } ] -} \ No newline at end of file +} diff --git a/tests/fixtures/plan_demand_data.json b/tests/fixtures/plan_demand_data.json index 376c475..069514d 100644 --- a/tests/fixtures/plan_demand_data.json +++ b/tests/fixtures/plan_demand_data.json @@ -1327,4 +1327,4 @@ "sourceparent": "https://www.aps.com/en/residential/accountservices/serviceplans/Pages/saver-choice-max.aspx?src=RB" } ] -} \ No newline at end of file +} diff --git a/tests/fixtures/plan_tier_data.json b/tests/fixtures/plan_tier_data.json index d84d1a0..9f023c5 100644 --- a/tests/fixtures/plan_tier_data.json +++ b/tests/fixtures/plan_tier_data.json @@ -692,4 +692,4 @@ "minchargeunits": "$/month" } ] -} \ No newline at end of file +} diff --git a/tests/fixtures/rate_limit.json b/tests/fixtures/rate_limit.json index 099c829..39f4337 100644 --- a/tests/fixtures/rate_limit.json +++ b/tests/fixtures/rate_limit.json @@ -3,4 +3,4 @@ "code": "RATE_LIMIT", "message": "You have exceeded your rate limit. Try again later or contact us at https://api.openei.org:443/contact/ for assistance" } -} \ No newline at end of file +} diff --git a/tests/fixtures/sell_rate.json b/tests/fixtures/sell_rate.json index 19b4b8a..623a04c 100644 --- a/tests/fixtures/sell_rate.json +++ b/tests/fixtures/sell_rate.json @@ -689,4 +689,4 @@ "voltagecategory": "Secondary" } ] -} \ No newline at end of file +} diff --git a/tests/test_init.py b/tests/test_init.py index cbf859a..8a9ff35 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -182,7 +182,7 @@ async def test_get_rate_data(mock_aioclient, caplog): assert status == 0.06118 assert "No data populated, refreshing data." in caplog.text status = test_rates.current_sell_rate - assert status == None + assert status is None @freeze_time("2021-08-13 13:20:00") @@ -408,10 +408,7 @@ async def test_get_plan_data_api_err(mock_aioclient, caplog): with pytest.raises(openeihttp.APIError): await test_rates.clear_cache() await test_rates.update() - assert ( - "No api_key was supplied. Get one at https://api.openei.org:443" - in caplog.text - ) + assert "No api_key was supplied. Get one at https://api.openei.org:443" in caplog.text async def test_get_lookup_data_api_err(mock_aioclient, caplog): @@ -424,10 +421,7 @@ async def test_get_lookup_data_api_err(mock_aioclient, caplog): test_lookup = openeihttp.Rates(api="fakeAPIKey", lat="1", lon="1") with pytest.raises(openeihttp.APIError): await test_lookup.lookup_plans() - assert ( - "No api_key was supplied. Get one at https://api.openei.org:443" - in caplog.text - ) + assert "No api_key was supplied. Get one at https://api.openei.org:443" in caplog.text async def test_rate_limit_err(mock_aioclient, caplog): @@ -627,155 +621,131 @@ async def test_get_lookup_data_radius(test_lookup_radius, mock_aioclient, caplog "Salt River Project": [ { "label": "539f755cec4f024411ed1357", - "name": "E-23 STANDARD PRICE PLAN FOR RESIDENTIAL " "SERVICE", + "name": "E-23 STANDARD PRICE PLAN FOR RESIDENTIAL SERVICE", }, { "label": "539fb6a4ec4f024bc1dc028f", - "name": "E-24 M-POWER PRICE PLAN FOR PRE-PAY " "RESIDENTIAL SERVICE", + "name": "E-24 M-POWER PRICE PLAN FOR PRE-PAY RESIDENTIAL SERVICE", }, { "label": "539fb86aec4f024bc1dc1713", - "name": "E-21 PRICE PLAN FOR RESIDENTIAL SUPER PEAK " - "TIME-OF-USE SERVICE", + "name": "E-21 PRICE PLAN FOR RESIDENTIAL SUPER PEAK TIME-OF-USE SERVICE", }, { "label": "539fbec8ec4f024c27d88a91", - "name": "E-25 - Experimental Plan for Residential " - "Super Peak Time-of-Use Service", + "name": "E-25 - Experimental Plan for Residential Super Peak Time-of-Use Service", }, { "label": "539fc8d1ec4f024d2f53eab0", - "name": "E-22 - EXPERIMENTAL PLAN FOR RESIDENTIAL " - "SUPER PEAK TIME-OF-USE SERVICE", + "name": "E-22 - EXPERIMENTAL PLAN FOR RESIDENTIAL SUPER PEAK TIME-OF-USE SERVICE", }, { "label": "539fc9d6ec4f024d2f53f55c", - "name": "E-28 M-POWER PRICE PLAN FOR RESIDENTIAL " - "PRE-PAY TIME-OF-USE SERVICE", + "name": "E-28 M-POWER PRICE PLAN FOR RESIDENTIAL PRE-PAY TIME-OF-USE SERVICE", }, { "label": "539fca4dec4f024d2f53fa1e", - "name": "E-26 STANDARD PRICE PLAN FOR RESIDENTIAL " - "TIME-OF-USE SERVICE", + "name": "E-26 STANDARD PRICE PLAN FOR RESIDENTIAL TIME-OF-USE SERVICE", }, { "label": "548a03535357a3ff32f0c30b", - "name": "E-22 - EXPERIMENTAL PLAN FOR RESIDENTIAL " - "SUPER PEAK TIME-OF-USE SERVICE", + "name": "E-22 - EXPERIMENTAL PLAN FOR RESIDENTIAL SUPER PEAK TIME-OF-USE SERVICE", }, { "label": "56c77d785457a3410cb338bb", - "name": "E-23 BASIC PRICE PLAN FOR RESIDENTIAL " "SERVICE", + "name": "E-23 BASIC PRICE PLAN FOR RESIDENTIAL SERVICE", }, { "label": "56c77e775457a3fe28b338bb", - "name": "E-26 STANDARD PRICE PLAN FOR RESIDENTIAL " - "TIME-OF-USE SERVICE", + "name": "E-26 STANDARD PRICE PLAN FOR RESIDENTIAL TIME-OF-USE SERVICE", }, { "label": "56c780355457a30a29b338bb", - "name": "E-24 M-POWER PRICE PLAN FOR PRE-PAY " "RESIDENTIAL SERVICE", + "name": "E-24 M-POWER PRICE PLAN FOR PRE-PAY RESIDENTIAL SERVICE", }, { "label": "56c781055457a3672db338bb", - "name": "E-21 PRICE PLAN FOR RESIDENTIAL SUPER PEAK " - "TIME-OF-USE SERVICE", + "name": "E-21 PRICE PLAN FOR RESIDENTIAL SUPER PEAK TIME-OF-USE SERVICE", }, { "label": "56c781af5457a35833b338bc", - "name": "E-22 - EXPERIMENTAL PLAN FOR RESIDENTIAL " - "SUPER PEAK TIME-OF-USE SERVICE", + "name": "E-22 - EXPERIMENTAL PLAN FOR RESIDENTIAL SUPER PEAK TIME-OF-USE SERVICE", }, { "label": "56c7827e5457a3143ab338bb", - "name": "E-25 - Experimental Plan for Residential " - "Super Peak Time-of-Use Service", + "name": "E-25 - Experimental Plan for Residential Super Peak Time-of-Use Service", }, { "label": "56c783fe5457a35833b338bd", - "name": "E-28 M-POWER PRICE PLAN FOR RESIDENTIAL " - "PRE-PAY TIME-OF-USE SERVICE", + "name": "E-28 M-POWER PRICE PLAN FOR RESIDENTIAL PRE-PAY TIME-OF-USE SERVICE", }, { "label": "5880efe95457a35b73316ce6", - "name": "E-23 BASIC PRICE PLAN FOR RESIDENTIAL " "SERVICE", + "name": "E-23 BASIC PRICE PLAN FOR RESIDENTIAL SERVICE", }, { "label": "5880f0db5457a3c97b316ce6", - "name": "E-24 M-POWER PRICE PLAN FOR PRE-PAY " "RESIDENTIAL SERVICE", + "name": "E-24 M-POWER PRICE PLAN FOR PRE-PAY RESIDENTIAL SERVICE", }, { "label": "5880f1885457a3e863316ce6", - "name": "E-26 STANDARD PRICE PLAN FOR RESIDENTIAL " - "TIME-OF-USE SERVICE", + "name": "E-26 STANDARD PRICE PLAN FOR RESIDENTIAL TIME-OF-USE SERVICE", }, { "label": "5880f24d5457a33d48316ce6", - "name": "E-21 PRICE PLAN FOR RESIDENTIAL SUPER PEAK " - "TIME-OF-USE SERVICE", + "name": "E-21 PRICE PLAN FOR RESIDENTIAL SUPER PEAK TIME-OF-USE SERVICE", }, { "label": "5880ff0c5457a3d70e316ce6", - "name": "E-22 - EXPERIMENTAL PLAN FOR RESIDENTIAL " - "SUPER PEAK TIME-OF-USE SERVICE", + "name": "E-22 - EXPERIMENTAL PLAN FOR RESIDENTIAL SUPER PEAK TIME-OF-USE SERVICE", }, { "label": "5881012e5457a30f3e316ce6", - "name": "E-25 - Experimental Plan for Residential " - "Super Peak Time-of-Use Service", + "name": "E-25 - Experimental Plan for Residential Super Peak Time-of-Use Service", }, { "label": "588103e85457a3d70e316ce7", - "name": "E-27 P Pilot Price Plan for Residential " - "Demand Rate Service", + "name": "E-27 P Pilot Price Plan for Residential Demand Rate Service", }, { "label": "5881042b5457a35b73316ce7", - "name": "E-28 M-POWER PRICE PLAN FOR RESIDENTIAL " - "PRE-PAY TIME-OF-USE SERVICE", + "name": "E-28 M-POWER PRICE PLAN FOR RESIDENTIAL PRE-PAY TIME-OF-USE SERVICE", }, { "label": "59f8cac65457a32644c05083", - "name": "E-23 BASIC PRICE PLAN FOR RESIDENTIAL " - "SERVICE Low Income Rate", + "name": "E-23 BASIC PRICE PLAN FOR RESIDENTIAL SERVICE Low Income Rate", }, { "label": "5a55224a5457a3ac5d423a7d", - "name": "E-23 BASIC PRICE PLAN FOR RESIDENTIAL " "SERVICE", + "name": "E-23 BASIC PRICE PLAN FOR RESIDENTIAL SERVICE", }, { "label": "5a58f7fa5457a3ff19423a7c", - "name": "E-21 PRICE PLAN FOR RESIDENTIAL SUPER PEAK " - "TIME-OF-USE SERVICE", + "name": "E-21 PRICE PLAN FOR RESIDENTIAL SUPER PEAK TIME-OF-USE SERVICE", }, { "label": "5a58f8ec5457a3931d423a7c", - "name": "E-22 - EXPERIMENTAL PLAN FOR RESIDENTIAL " - "SUPER PEAK TIME-OF-USE SERVICE", + "name": "E-22 - EXPERIMENTAL PLAN FOR RESIDENTIAL SUPER PEAK TIME-OF-USE SERVICE", }, { "label": "5a58f9605457a30e35423a7c", - "name": "E-24 M-POWER PRICE PLAN FOR PRE-PAY " "RESIDENTIAL SERVICE", + "name": "E-24 M-POWER PRICE PLAN FOR PRE-PAY RESIDENTIAL SERVICE", }, { "label": "5a58f9cd5457a34151423a7c", - "name": "E-25 - Experimental Plan for Residential " - "Super Peak Time-of-Use Service", + "name": "E-25 - Experimental Plan for Residential Super Peak Time-of-Use Service", }, { "label": "5a58fa865457a3ec1b423a7c", - "name": "E-26 STANDARD PRICE PLAN FOR RESIDENTIAL " - "TIME-OF-USE SERVICE", + "name": "E-26 STANDARD PRICE PLAN FOR RESIDENTIAL TIME-OF-USE SERVICE", }, { "label": "5a58fb8e5457a3b71c423a7c", - "name": "E-27 P Pilot Price Plan for Residential " - "Demand Rate Service", + "name": "E-27 P Pilot Price Plan for Residential Demand Rate Service", }, { "label": "5a58fc915457a3ff19423a7d", - "name": "E-28 M-POWER PRICE PLAN FOR RESIDENTIAL " - "PRE-PAY TIME-OF-USE SERVICE", + "name": "E-28 M-POWER PRICE PLAN FOR RESIDENTIAL PRE-PAY TIME-OF-USE SERVICE", }, { "label": "5a59008c5457a3ad69423a7c", @@ -785,36 +755,31 @@ async def test_get_lookup_data_radius(test_lookup_radius, mock_aioclient, caplog }, { "label": "5cf58ee05457a3160d26c07e", - "name": "E-23 BASIC PRICE PLAN FOR RESIDENTIAL " "SERVICE", + "name": "E-23 BASIC PRICE PLAN FOR RESIDENTIAL SERVICE", }, { "label": "5cf815b25457a3be3326c07d", - "name": "E-21 PRICE PLAN FOR RESIDENTIAL SUPER PEAK " - "TIME-OF-USE SERVICE", + "name": "E-21 PRICE PLAN FOR RESIDENTIAL SUPER PEAK TIME-OF-USE SERVICE", }, { "label": "5cf816af5457a35b2e26c07e", - "name": "E-22 - EXPERIMENTAL PLAN FOR RESIDENTIAL " - "SUPER PEAK TIME-OF-USE SERVICE", + "name": "E-22 - EXPERIMENTAL PLAN FOR RESIDENTIAL SUPER PEAK TIME-OF-USE SERVICE", }, { "label": "5cf8174b5457a3562a26c07d", - "name": "E-24 M-POWER PRICE PLAN FOR PRE-PAY " "RESIDENTIAL SERVICE", + "name": "E-24 M-POWER PRICE PLAN FOR PRE-PAY RESIDENTIAL SERVICE", }, { "label": "5cf817db5457a32f3126c07d", - "name": "E-25 - Experimental Plan for Residential " - "Super Peak Time-of-Use Service", + "name": "E-25 - Experimental Plan for Residential Super Peak Time-of-Use Service", }, { "label": "5cf8264e5457a3063126c07d", - "name": "E-26 STANDARD PRICE PLAN FOR RESIDENTIAL " - "TIME-OF-USE SERVICE", + "name": "E-26 STANDARD PRICE PLAN FOR RESIDENTIAL TIME-OF-USE SERVICE", }, { "label": "5cf826d55457a3063126c07e", - "name": "E-27 P Pilot Price Plan for Residential " - "Demand Rate Service", + "name": "E-27 P Pilot Price Plan for Residential Demand Rate Service", }, { "label": "5cf82a395457a3d92b26c07d", @@ -824,13 +789,11 @@ async def test_get_lookup_data_radius(test_lookup_radius, mock_aioclient, caplog }, { "label": "5cf831715457a3612f26c07d", - "name": "E-28 M-POWER PRICE PLAN FOR RESIDENTIAL " - "PRE-PAY TIME-OF-USE SERVICE", + "name": "E-28 M-POWER PRICE PLAN FOR RESIDENTIAL PRE-PAY TIME-OF-USE SERVICE", }, { "label": "5d9b7fc95457a3d865598dce", - "name": "E-14 RESIDENTIAL CUSTOMER GENERATION " - "ELECTRIC VEHICLE EXPORT PRICE PLAN", + "name": "E-14 RESIDENTIAL CUSTOMER GENERATION ELECTRIC VEHICLE EXPORT PRICE PLAN", }, { "label": "5dc498485457a37b0cf6a951", @@ -868,9 +831,7 @@ async def test_get_tier_rate_data_low(test_lookup_tier_low, mock_aioclient): @freeze_time( "2021-11-01 10:21:34" ) # November 1 is the first day of a separate rate structure for this plan -async def test_get_tier_rate_data_low_second_period( - test_lookup_tier_low, mock_aioclient -): +async def test_get_tier_rate_data_low_second_period(test_lookup_tier_low, mock_aioclient): """Test rate schedules.""" mock_aioclient.get( re.compile(TEST_PATTERN), @@ -1107,9 +1068,7 @@ async def test_get_tier_rate_data_weekend(test_lookup_tier_low, mock_aioclient): @freeze_time("2021-08-13 10:21:34") -async def test_get_monthly_tier_rate_data_low( - test_lookup_monthly_tier_low, mock_aioclient -): +async def test_get_monthly_tier_rate_data_low(test_lookup_monthly_tier_low, mock_aioclient): """Test rate schedules.""" mock_aioclient.get( re.compile(TEST_PATTERN), @@ -1123,9 +1082,7 @@ async def test_get_monthly_tier_rate_data_low( @freeze_time("2021-08-13 10:21:34") -async def test_get_monthly_tier_rate_data_med( - test_lookup_monthly_tier_med, mock_aioclient -): +async def test_get_monthly_tier_rate_data_med(test_lookup_monthly_tier_med, mock_aioclient): """Test rate schedules.""" mock_aioclient.get( re.compile(TEST_PATTERN), @@ -1139,9 +1096,7 @@ async def test_get_monthly_tier_rate_data_med( @freeze_time("2021-08-13 10:21:34") -async def test_get_monthly_tier_rate_data_high( - test_lookup_monthly_tier_high, mock_aioclient -): +async def test_get_monthly_tier_rate_data_high(test_lookup_monthly_tier_high, mock_aioclient): """Test rate schedules.""" mock_aioclient.get( re.compile(TEST_PATTERN), @@ -1435,7 +1390,7 @@ async def test_get_sell_rate_data_2(mock_aioclient): @freeze_time("2021-11-13 09:20:00") -async def test_get_sell_rate_data_2(mock_aioclient): +async def test_get_sell_rate_data_3(mock_aioclient): """Test rate schedules.""" mock_aioclient.get( re.compile(TEST_PATTERN), @@ -1449,3 +1404,84 @@ async def test_get_sell_rate_data_2(mock_aioclient): await test_rates.update() status = test_rates.current_sell_rate assert status == 0.085252 + + +async def test_get_lookup_data_timeout(mock_aioclient, caplog): + """Test lookup_plans handles TimeoutError gracefully.""" + mock_aioclient.get( + re.compile(TEST_PATTERN), + exception=TimeoutError("Timeout"), + ) + test_lookup = openeihttp.Rates(api="fakeAPIKey", lat="1", lon="1") + with pytest.raises(openeihttp.APIError): + await test_lookup.lookup_plans() + assert "Timeout while updating" in caplog.text + + +async def test_get_plan_data_timeout(mock_aioclient, caplog): + """Test update handles TimeoutError gracefully.""" + mock_aioclient.get( + re.compile(TEST_PATTERN), + exception=TimeoutError("Timeout"), + ) + test_rates = openeihttp.Rates( + api="fakeAPIKey", lat="1", lon="1", plan="574613aa5457a3557e906f5b" + ) + with pytest.raises(openeihttp.APIError): + await test_rates.clear_cache() + await test_rates.update() + assert "Timeout while updating" in caplog.text + + +async def test_get_lookup_data_non_json(mock_aioclient, caplog): + """Test lookup_plans handles non-JSON response gracefully.""" + mock_aioclient.get( + re.compile(TEST_PATTERN), + status=200, + body="Not a JSON string", + ) + test_lookup = openeihttp.Rates(api="fakeAPIKey", lat="1", lon="1") + with pytest.raises(openeihttp.APIError): + await test_lookup.lookup_plans() + assert "Error: Not a JSON string" in caplog.text + + +async def test_get_plan_data_non_json(mock_aioclient, caplog): + """Test update handles non-JSON response gracefully.""" + mock_aioclient.get( + re.compile(TEST_PATTERN), + status=200, + body="Not a JSON string", + ) + test_rates = openeihttp.Rates( + api="fakeAPIKey", lat="1", lon="1", plan="574613aa5457a3557e906f5b" + ) + with pytest.raises(openeihttp.APIError): + await test_rates.clear_cache() + await test_rates.update() + assert "Error: Not a JSON string" in caplog.text + + +async def test_get_lookup_data_content_type_error(mock_aioclient, caplog): + """Test lookup_plans handles ContentTypeError gracefully.""" + from aiohttp import RequestInfo + from aiohttp.client_exceptions import ContentTypeError + from yarl import URL + + req_info = RequestInfo( + url=URL("https://api.openei.org/utility_rates"), + method="GET", + headers=None, + ) + mock_aioclient.get( + re.compile(TEST_PATTERN), + exception=ContentTypeError( + req_info, + (), + message="Attempt to decode JSON with unexpected mimetype", + ), + ) + test_lookup = openeihttp.Rates(api="fakeAPIKey", lat="1", lon="1") + with pytest.raises(openeihttp.APIError): + await test_lookup.lookup_plans() + assert "Attempt to decode JSON with unexpected mimetype" in caplog.text diff --git a/tox.ini b/tox.ini index 36262ba..d1346a8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py39, py310, py311, py312, py313, lint, mypy +envlist = py39, py310, py311, py312, py313, py314, lint, mypy skip_missing_interpreters = True [gh-actions] @@ -8,7 +8,8 @@ python = 3.10: py310 3.11: py311 3.12: py312 - 3.13: py313, lint, mypy + 3.13: py313 + 3.14: py314, lint, mypy [pytest] asyncio_default_fixture_loop_scope=function @@ -21,12 +22,9 @@ deps = [testenv:lint] basepython = python3 -ignore_errors = True commands = - black --check ./ - flake8 openeihttp - pylint openeihttp - pydocstyle openeihttp tests + ruff check openeihttp tests + ruff format --check openeihttp tests deps = -rrequirements_lint.txt -rrequirements_test.txt @@ -38,6 +36,3 @@ commands = mypy openeihttp deps = -rrequirements_lint.txt - -[flake8] -max-line-length = 100 \ No newline at end of file