pax_global_header00006660000000000000000000000064150675510760014526gustar00rootroot0000000000000052 comment=0b3ca5429b5b0b2c8d85b184f798b5cf92676870 ovoenergy-3.0.2/000077500000000000000000000000001506755107600135455ustar00rootroot00000000000000ovoenergy-3.0.2/.codecov.yml000066400000000000000000000002711506755107600157700ustar00rootroot00000000000000--- codecov: branch: master coverage: status: project: default: target: 60 threshold: "0.09" patch: default: target: auto comment: false ovoenergy-3.0.2/.cursor/000077500000000000000000000000001506755107600151405ustar00rootroot00000000000000ovoenergy-3.0.2/.cursor/rules/000077500000000000000000000000001506755107600162725ustar00rootroot00000000000000ovoenergy-3.0.2/.cursor/rules/linting.mdc000066400000000000000000000026711506755107600204310ustar00rootroot00000000000000--- alwaysApply: true description: Linting and code quality rules for the OVO Energy project --- # Linting and Code Quality Rules ## Linting Tools This project uses **Ruff** for all linting, formatting, and import sorting. Do not use Black or isort separately. ### Ruff Configuration - Configuration is in [pyproject.toml](mdc:pyproject.toml) under `[tool.ruff.lint]` - Ruff handles: linting, formatting, and import sorting - Run `python -m ruff check .` to check for issues - Run `python -m ruff check --fix .` to auto-fix issues - Run `python -m ruff format .` to format code ### Pylint Configuration - Additional code analysis with [pyproject.toml](mdc:pyproject.toml) under `[tool.pylint.MAIN]` - Run `python -m pylint ovoenergy/` to check the main package - Ignores tests directory by default ## Code Style Guidelines - Follow the Ruff configuration rules in [pyproject.toml](mdc:pyproject.toml) - Use single quotes for strings (Q000 rule) - Maximum line length: 88 characters (handled by Ruff) - Import sorting: handled automatically by Ruff - No hardcoded version numbers in code ## Before Committing Always run: ```bash python -m ruff check . && python -m ruff format . && python -m pylint ovoenergy/ ``` ## Version Management - Version is defined in [setup.py](mdc:setup.py) only - No `__version__` variable in [ovoenergy/**init**.py](mdc:ovoenergy/__init__.py) - No version CLI commands in [ovoenergy/**main**.py](mdc:ovoenergy/__main__.py) ovoenergy-3.0.2/.cursor/rules/project-structure.mdc000066400000000000000000000035631506755107600224720ustar00rootroot00000000000000--- alwaysApply: true description: Project structure and architecture guidelines --- # Project Structure Guide ## Main Package Structure - **Entry point**: [ovoenergy/**init**.py](mdc:ovoenergy/__init__.py) - Main OVOEnergy class and API - **CLI interface**: [ovoenergy/**main**.py](mdc:ovoenergy/__main__.py) - Command-line interface using Typer - **Models**: [ovoenergy/models/](mdc:ovoenergy/models/) - Data models and schemas - **Exceptions**: [ovoenergy/exceptions.py](mdc:ovoenergy/exceptions.py) - Custom exceptions ## Configuration Files - **Package config**: [pyproject.toml](mdc:pyproject.toml) - Ruff, Pylint, and pytest configuration - **Setup**: [setup.py](mdc:setup.py) - Package installation configuration - **Dependencies**: [requirements.txt](mdc:requirements.txt) - Runtime dependencies - **Test dependencies**: [requirements_test.txt](mdc:requirements_test.txt) - Testing dependencies ## Testing - **Test directory**: [tests/](mdc:tests/) - All test files - **Test fixtures**: [tests/conftest.py](mdc:tests/conftest.py) - Pytest fixtures and mocks - **Main tests**: [tests/test**init**.py](mdc:tests/test__init__.py) - Core functionality tests ## Key Classes and Modules - `OVOEnergy` - Main API client class in [ovoenergy/**init**.py](mdc:ovoenergy/__init__.py) - `Account`, `BootstrapAccounts` - Account management in [ovoenergy/models/accounts.py](mdc:ovoenergy/models/accounts.py) - `OVODailyUsage`, `OVOHalfHourUsage` - Usage data models - `OVOPlans` - Energy plan information - `OVOFootprint`, `OVOCarbonIntensity` - Carbon footprint and intensity data ## API Endpoints The client interacts with OVO Energy's API endpoints: - Authentication: `https://my.ovoenergy.com/api/v2/auth/` - Usage data: `https://smartpaymapi.ovoenergy.com/usage/api/` - Plans: `https://smartpaymapi.ovoenergy.com/orex/api/plans/` - Carbon data: `https://smartpaymapi.ovoenergy.com/carbon-api/` ovoenergy-3.0.2/.cursor/rules/python-code-style.mdc000066400000000000000000000032131506755107600223450ustar00rootroot00000000000000--- globs: *.py description: Python code style and best practices --- # Python Code Style Guidelines ## Async/Await Patterns - Use `async def` for all API methods - Use `aiohttp.ClientSession` for HTTP requests - Always close client sessions properly with `await session.close()` - Use `asyncio.run_until_complete()` in CLI functions ## Error Handling - Use custom exceptions from [ovoenergy/exceptions.py](mdc:ovoenergy/exceptions.py) - Raise `OVOEnergyNoAccount` when no account is found - Raise `OVOEnergyAPINotAuthorized` for authentication issues - Raise `OVOEnergyAPINoCookies` when cookies are missing ## Data Models - Use dataclasses for API response models - Include proper type hints for all parameters - Use `| None` for optional fields instead of `Optional[T]` - Use `Literal` types for string constants ## API Client Patterns - Store authentication state in instance variables (`_oauth`, `_cookies`) - Use property methods for computed values (`account_id`, `oauth_expired`) - Implement proper token refresh logic - Use context managers where appropriate ## CLI Interface - Use Typer for command-line interface - Provide helpful option descriptions - Use `typer.secho()` for colored output - Handle authentication failures gracefully ## Testing Patterns - Use `aioresponses` for mocking HTTP requests - Use `pytest-asyncio` for async test functions - Use `syrupy` for snapshot testing - Mock all external API calls in tests ## Code Organization - Group related functionality in the same class - Use private methods (prefixed with `_`) for internal logic - Keep public API methods simple and focused - Document complex business logic with comments ovoenergy-3.0.2/.cursor/rules/testing.mdc000066400000000000000000000034701506755107600204400ustar00rootroot00000000000000--- globs: tests/*.py,test_*.py description: Testing guidelines and patterns --- # Testing Guidelines ## Test Structure - All tests in [tests/](mdc:tests/) directory - Use descriptive test function names: `test__` - Group related tests in the same file - Use fixtures from [tests/conftest.py](mdc:tests/conftest.py) ## Async Testing - Use `@pytest.mark.asyncio` for async test functions - Use `async def` for test functions that test async code - Use `await` when calling async methods in tests ## Mocking HTTP Requests - Use `aioresponses` to mock all HTTP requests - Mock all external API endpoints in [tests/conftest.py](mdc:tests/conftest.py) - Use `mock_aioresponse` fixture for individual test mocking - Always mock both successful and error responses ## Snapshot Testing - Use `syrupy` for snapshot testing of complex data structures - Store snapshots in [tests/**snapshots**/](mdc:tests/__snapshots__/) - Use descriptive snapshot names: `snapshot(name="descriptive_name")` - Test both success and error scenarios ## Test Data - Use constants for test data (USERNAME, PASSWORD, ACCOUNT) - Store mock response data in [tests/**init**.py](mdc:tests/__init__.py) - Use realistic test data that matches actual API responses ## Error Testing - Test all exception scenarios - Use `pytest.raises()` for expected exceptions - Test both authentication and authorization failures - Test network error scenarios ## Fixtures - Use `ovoenergy_client` fixture for clean client instances - Use `mock_aioresponse` fixture for HTTP mocking - Use `snapshot` fixture for snapshot testing - Keep fixtures focused and reusable ## Test Coverage - Aim for high test coverage of the main API client - Test both happy path and error scenarios - Test edge cases and boundary conditions - Use `pytest-cov` to measure coverage ovoenergy-3.0.2/.cursorignore000066400000000000000000000001761506755107600162730ustar00rootroot00000000000000.env .env.local .env.development.local .env.test.local .env.production.local .env.development .env.test .env.production .env* ovoenergy-3.0.2/.editorconfig000066400000000000000000000007651506755107600162320ustar00rootroot00000000000000; EditorConfig helps developers define and maintain consistent ; coding styles between different editors and IDEs. ; For more visit http://editorconfig.org. root = true ; Choose between lf or rf on "end_of_line" property [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.{js,css,scss}] indent_size = 2 [*.html] indent_style = space [*.{py,qss,html,md}] indent_size = 4 [*.md] trim_trailing_whitespace = true ovoenergy-3.0.2/.env.example000066400000000000000000000000401506755107600157620ustar00rootroot00000000000000OVO_USERNAME="" OVO_PASSWORD="" ovoenergy-3.0.2/.github/000077500000000000000000000000001506755107600151055ustar00rootroot00000000000000ovoenergy-3.0.2/.github/.tmp/000077500000000000000000000000001506755107600157635ustar00rootroot00000000000000ovoenergy-3.0.2/.github/.tmp/.generated-actions/000077500000000000000000000000001506755107600214355ustar00rootroot00000000000000ovoenergy-3.0.2/.github/.tmp/.generated-actions/run-pypi-publish-in-docker-container/000077500000000000000000000000001506755107600305155ustar00rootroot00000000000000ovoenergy-3.0.2/.github/.tmp/.generated-actions/run-pypi-publish-in-docker-container/action.yml000066400000000000000000000021331506755107600325140ustar00rootroot00000000000000{"name": "πŸƒ", "description": "Run Docker container to upload Python distribution packages to PyPI", "inputs": {"user": {"description": "PyPI user", "required": false}, "password": {"description": "Password for your PyPI user or an access token", "required": false}, "repository-url": {"description": "The repository URL to use", "required": false}, "packages-dir": {"description": "The target directory for distribution", "required": false}, "verify-metadata": {"description": "Check metadata before uploading", "required": false}, "skip-existing": {"description": "Do not fail if a Python package distribution exists in the target package index", "required": false}, "verbose": {"description": "Show verbose output.", "required": false}, "print-hash": {"description": "Show hash values of files to be uploaded", "required": false}, "attestations": {"description": "[EXPERIMENTAL] Enable experimental support for PEP 740 attestations. Only works with PyPI and TestPyPI via Trusted Publishing.", "required": false}}, "runs": {"using": "docker", "image": "docker://ghcr.io/pypa/gh-action-pypi-publish:release-v1"}}ovoenergy-3.0.2/.github/CODEOWNERS000066400000000000000000000000241506755107600164740ustar00rootroot00000000000000.github/* @timmo001 ovoenergy-3.0.2/.github/FUNDING.yml000066400000000000000000000000451506755107600167210ustar00rootroot00000000000000--- github: timmo001 ko_fi: timmo001 ovoenergy-3.0.2/.github/dependabot.yaml000066400000000000000000000003561506755107600201020ustar00rootroot00000000000000--- version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: daily - package-ecosystem: "pip" directory: "/" open-pull-requests-limit: 20 schedule: interval: "daily" ovoenergy-3.0.2/.github/labels.yml000066400000000000000000000066461506755107600171060ustar00rootroot00000000000000--- - name: "breaking-change" color: ee0701 description: "A breaking change for existing users." - name: "bug" color: ee0701 description: "Inconsistencies or issues which will cause a problem for users or implementors." - name: "bugfix" color: ee0701 description: "Inconsistencies or issues which will cause a problem for users or implementors." - name: "documentation" color: 0052cc description: "Solely about the documentation of the project." - name: "enhancement" color: 1d76db description: "Enhancement of the code, not introducing new features." - name: "refactor" color: 1d76db description: "Improvement of existing code, not introducing new features." - name: "performance" color: 1d76db description: "Improving performance, not introducing new features." - name: "new-feature" color: 0e8a16 description: "New features or options." - name: "maintenance" color: 2af79e description: "Generic maintenance tasks." - name: "ci" color: 1d76db description: "Work that improves the continue integration." - name: "dependencies" color: 1d76db description: "Upgrade or downgrade of project dependencies." - name: "in-progress" color: fbca04 description: "Issue is currently being resolved by a developer." - name: "stale" color: fef2c0 description: "There has not been activity on this issue or PR for quite some time." - name: "no-stale" color: fef2c0 description: "This issue or PR is exempted from the stable bot." - name: "security" color: ee0701 description: "Marks a security issue that needs to be resolved asap." - name: "incomplete" color: fef2c0 description: "Marks a PR or issue that is missing information." - name: "invalid" color: fef2c0 description: "Marks a PR or issue that is missing information." - name: "beginner-friendly" color: 0e8a16 description: "Good first issue for people wanting to contribute to the project." - name: "help-wanted" color: 0e8a16 description: "We need some extra helping hands or expertise in order to resolve this." - name: "hacktoberfest" description: "Issues/PRs are participating in the Hacktoberfest." color: fbca04 - name: "hacktoberfest-accepted" description: "Issues/PRs are participating in the Hacktoberfest." color: fbca04 - name: "priority-critical" color: ee0701 description: "This should be dealt with ASAP. Not fixing this issue would be a serious error." - name: "priority-high" color: b60205 description: "After critical issues are fixed, these should be dealt with before any further issues." - name: "priority-medium" color: 0e8a16 description: "This issue may be useful, and needs some attention." - name: "priority-low" color: e4ea8a description: "Nice addition, maybe... someday..." - name: "major" color: b60205 description: "This PR causes a major version bump in the version number." - name: "minor" color: 0e8a16 description: "This PR causes a minor version bump in the version number." - name: "investigation" color: 0e8a16 description: "An investigation is needed to determine what causes this." - name: "waiting-for-response" color: ffee58 description: "This issue is waiting for a response." - name: "unable-to-reproduce" color: ffee58 description: "This issue cannot be reproduced. It may be configuration or system related." - name: "linux" color: ffeb3b description: "This issue is related to Linux." - name: "windows" color: 03a9f4 description: "This issue is related to Windows." ovoenergy-3.0.2/.github/release-drafter.yml000066400000000000000000000020451506755107600206760ustar00rootroot00000000000000--- name-template: "$RESOLVED_VERSION" tag-template: "$RESOLVED_VERSION" change-template: "- $TITLE @$AUTHOR (#$NUMBER)" sort-direction: ascending categories: - title: "🚨 Breaking changes" labels: - "breaking-change" - title: "✨ New features" labels: - "new-feature" - title: "πŸ› Bug fixes" labels: - "bugfix" - title: "πŸš€ Enhancements" labels: - "enhancement" - "refactor" - "performance" - title: "🧰 Maintenance" labels: - "maintenance" - "ci" - title: "πŸ“š Documentation" labels: - "documentation" - title: "⬆️ Dependency updates" labels: - "dependencies" version-resolver: major: labels: - "major" - "breaking-change" minor: labels: - "minor" - "new-feature" patch: labels: - "bugfix" - "chore" - "ci" - "dependencies" - "documentation" - "enhancement" - "performance" - "refactor" default: patch template: | ## What’s changed $CHANGES ovoenergy-3.0.2/.github/workflows/000077500000000000000000000000001506755107600171425ustar00rootroot00000000000000ovoenergy-3.0.2/.github/workflows/build.yml000066400000000000000000000006171506755107600207700ustar00rootroot00000000000000--- name: "Build" # yamllint disable-line rule:truthy on: push: branches: - master pull_request: types: - opened - reopened - synchronize workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: build: uses: timmo001/workflows/.github/workflows/build-python-linux.yml@master ovoenergy-3.0.2/.github/workflows/codeql.yml000066400000000000000000000007461506755107600211430ustar00rootroot00000000000000--- name: "CodeQL" # yamllint disable-line rule:truthy on: push: branches: - master pull_request: branches: - master schedule: - cron: "0 12 * * 4" workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true permissions: actions: read contents: read security-events: write jobs: codeql-analyze-python: uses: timmo001/workflows/.github/workflows/codeql-python.yml@master ovoenergy-3.0.2/.github/workflows/dependabot-automerge.yml000066400000000000000000000004151506755107600237600ustar00rootroot00000000000000--- name: "Dependabot - Auto-merge" # yamllint disable-line rule:truthy on: pull_request_target: permissions: pull-requests: write contents: write jobs: dependabot-automerge: uses: timmo001/workflows/.github/workflows/dependabot-automerge-any.yml@master ovoenergy-3.0.2/.github/workflows/dependency-review.yml000066400000000000000000000003361506755107600233040ustar00rootroot00000000000000--- name: "Dependency Review" # yamllint disable-line rule:truthy on: - pull_request permissions: contents: read jobs: dependency-review: uses: timmo001/workflows/.github/workflows/depedency-review.yml@master ovoenergy-3.0.2/.github/workflows/deploy.yml000066400000000000000000000055631506755107600211720ustar00rootroot00000000000000--- name: "Deploy" # yamllint disable-line rule:truthy on: release: types: - published workflow_dispatch: env: MODULE_NAME: ovoenergy jobs: deploy: name: πŸš€ Linux - Deploy Module runs-on: ubuntu-latest permissions: id-token: write steps: - name: ‡️ Check out code from GitHub uses: actions/checkout@v5 with: ref: "master" token: ${{ secrets.PUSH_TOKEN }} - name: πŸ— Set up Python uses: actions/setup-python@v6 with: python-version: "3.12" architecture: "x64" cache: "pip" - name: πŸ— Install build tooling run: | python -m pip install --upgrade setuptools wheel twine - name: πŸ”’ Get old version id: get-version-old run: | result=$(python script/get_version.py) echo "version=$result" >> $GITHUB_OUTPUT - name: πŸ”’ Set correct version - Development if: ${{ github.event_name != 'release' }} run: | python script/set_dev_version.py - name: πŸ”’ Set correct version - Release if: ${{ github.event_name == 'release' }} run: | python script/set_release_version.py - name: πŸ”’ Get current version id: get-version-current run: | result=$(python script/get_version.py) echo "version=$result" >> $GITHUB_OUTPUT - name: ‡️ Pull latest changes from GitHub run: | git pull --ff - name: πŸ–Š Commit uses: stefanzweifel/git-auto-commit-action@v6.0.1 env: GITHUB_TOKEN: ${{ secrets.PUSH_TOKEN }} with: commit_message: | Bump ${{ env.MODULE_NAME }} version to ${{ steps.get-version-current.outputs.version }} - name: πŸ— Build distributions run: | python setup.py sdist bdist_wheel - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: verbose: true - name: πŸ”’ Increment version - Development if: ${{ github.event_name != 'release' }} run: | python script/increment_version_dev.py - name: πŸ”’ Increment version - Release if: ${{ github.event_name == 'release' }} run: | python script/increment_version_release.py - name: πŸ”’ Get new version id: get-version-new run: | result=$(python script/get_version.py) echo "version=$result" >> $GITHUB_OUTPUT - name: ‡️ Pull latest changes from GitHub run: | git pull --ff - name: πŸ–Š Commit uses: stefanzweifel/git-auto-commit-action@v6.0.1 env: GITHUB_TOKEN: ${{ secrets.PUSH_TOKEN }} with: commit_message: | Bump ${{ env.MODULE_NAME }} version to ${{ steps.get-version-new.outputs.version }} ovoenergy-3.0.2/.github/workflows/labels.yml000066400000000000000000000004301506755107600211240ustar00rootroot00000000000000--- name: "Sync labels" # yamllint disable-line rule:truthy on: push: branches: - master paths: - .github/labels.yml schedule: - cron: "34 5 * * *" workflow_dispatch: jobs: labels: uses: timmo001/workflows/.github/workflows/labels.yml@master ovoenergy-3.0.2/.github/workflows/lint.yml000066400000000000000000000023621506755107600206360ustar00rootroot00000000000000--- name: "Lint" # yamllint disable-line rule:truthy on: push: branches: - master pull_request: types: - opened - reopened - synchronize workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: lint-jsonlint: uses: timmo001/workflows/.github/workflows/lint-jsonlint.yml@master lint-markdown-links: uses: timmo001/workflows/.github/workflows/lint-markdown-links.yml@master lint-markdownlint: uses: timmo001/workflows/.github/workflows/lint-markdownlint.yml@master lint-pylint: name: Lint (pylint) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.13" - name: Install dependencies run: | python -m pip install --upgrade pip pip install -e . pip install -r requirements.txt -r requirements_test.txt pip install "pylint>=3.2" - name: Run pylint (uses pyproject.toml) run: | pylint --rcfile=pyproject.toml ovoenergy lint-ruff: uses: timmo001/workflows/.github/workflows/lint-ruff.yml@master ovoenergy-3.0.2/.github/workflows/release-drafter.yml000066400000000000000000000004321506755107600227310ustar00rootroot00000000000000--- name: "Release Drafter" # yamllint disable-line rule:truthy on: push: branches: - master workflow_dispatch: permissions: contents: write pull-requests: write jobs: release-drafter: uses: timmo001/workflows/.github/workflows/release-drafter.yml@master ovoenergy-3.0.2/.github/workflows/test.yml000066400000000000000000000007561506755107600206540ustar00rootroot00000000000000--- name: "Test" # yamllint disable-line rule:truthy on: push: branches: - master pull_request: types: - opened - reopened - synchronize workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: test: uses: timmo001/workflows/.github/workflows/test-pytest.yml@master secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: module-name: "ovoenergy" ovoenergy-3.0.2/.gitignore000066400000000000000000000062441506755107600155430ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # 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/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ # Additional !public/app/.gitkeep !rootfs/**/downloads/ .env .qt* *.apk *.app *.db* *.deb *.dmg *.key *.rpm *setup.exe junit.xml out/ public/app/* test.json ovoenergy-3.0.2/.markdownlint.json000066400000000000000000000000261506755107600172250ustar00rootroot00000000000000{ "MD028": false }ovoenergy-3.0.2/.mdl.rb000066400000000000000000000002141506755107600147210ustar00rootroot00000000000000all rule 'MD013', :tables => false exclude_rule 'MD002' exclude_rule 'MD013' exclude_rule 'MD024' exclude_rule 'MD041' exclude_rule 'MD028' ovoenergy-3.0.2/.mdlrc000066400000000000000000000000201506755107600146370ustar00rootroot00000000000000style '.mdl.rb' ovoenergy-3.0.2/.vscode/000077500000000000000000000000001506755107600151065ustar00rootroot00000000000000ovoenergy-3.0.2/.vscode/launch.json000066400000000000000000000004141506755107600172520ustar00rootroot00000000000000{ "version": "0.2.0", "configurations": [ { "name": "Module", "type": "debugpy", "request": "launch", "module": "ovoenergy", "preLaunchTask": "Module: pip install", "justMyCode": false, "args": ["version"] } ] } ovoenergy-3.0.2/.vscode/tasks.json000066400000000000000000000003011506755107600171200ustar00rootroot00000000000000{ "version": "2.0.0", "tasks": [ { "type": "shell", "label": "Module: pip install", "command": "pip install .", "dependsOn": [], "options": {} } ] } ovoenergy-3.0.2/.yamllint.yml000066400000000000000000000001611506755107600161750ustar00rootroot00000000000000--- extends: default rules: line-length: ignore: | .gitlab-ci.yml .github/ level: warning ovoenergy-3.0.2/LICENSE000066400000000000000000000261351506755107600145610ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ovoenergy-3.0.2/README.md000066400000000000000000000007111506755107600150230ustar00rootroot00000000000000# OVO Energy Python Client [![PyPI](https://img.shields.io/pypi/v/ovoenergy)](https://pypi.org/project/ovoenergy/) Get energy data from the OVO Energy APIs. > [!IMPORTANT] > This package is not officially supported by OVO Energy and is provided as-is. > If OVO change their APIs, this package may break. > > This has only been tested with a UK account. > Some endpoints are pointing to EU servers. > If you are outside the UK, this package may not work. ovoenergy-3.0.2/build.json000066400000000000000000000005571506755107600155460ustar00rootroot00000000000000{ "build_from": { "aarch64": "ghcr.io/timmo001/container-debian-base/aarch64:stable", "amd64": "ghcr.io/timmo001/container-debian-base/amd64:stable", "armhf": "ghcr.io/timmo001/container-debian-base/armhf:stable", "armv7": "ghcr.io/timmo001/container-debian-base/armv7:stable", "i386": "ghcr.io/timmo001/container-debian-base/i386:stable" } } ovoenergy-3.0.2/mlc_config.json000066400000000000000000000004111506755107600165340ustar00rootroot00000000000000{ "ignorePatterns": [ { "pattern": "^http://localhost" }, { "pattern": "^aidan@timmo" }, { "pattern": "^https://www.buymeacoffee.com" }, { "pattern": "^https://microbadger.com" } ], "retryOn429": true } ovoenergy-3.0.2/ovoenergy/000077500000000000000000000000001506755107600155625ustar00rootroot00000000000000ovoenergy-3.0.2/ovoenergy/__init__.py000066400000000000000000000572171506755107600177070ustar00rootroot00000000000000"""Get energy data from OVO's API.""" import contextlib from datetime import datetime, timedelta from http.cookies import SimpleCookie import logging from typing import Literal from uuid import UUID import aiohttp import jwt from .const import ( AUTH_LOGIN_URL, AUTH_TOKEN_URL, BOOTSTRAP_GRAPHQL_URL, BOOTSTRAP_QUERY, CARBON_FOOTPRINT_URL, CARBON_INTENSITY_URL, USAGE_DAILY_URL, USAGE_HALF_HOURLY_URL, ) from .exceptions import ( OVOEnergyAPIInvalidResponse, OVOEnergyAPINoCookies, OVOEnergyAPINotAuthorized, OVOEnergyAPINotFound, OVOEnergyNoAccount, OVOEnergyNoCustomer, ) from .models import ( OVOCost, OVODailyElectricity, OVODailyGas, OVODailyUsage, OVOHalfHour, OVOHalfHourUsage, OVOInterval, OVOMeterReadings, OVORates, ) from .models.accounts import Account, BootstrapAccounts, Supply, SupplyPointInfo from .models.carbon_intensity import OVOCarbonIntensity, OVOCarbonIntensityForecast from .models.footprint import ( OVOCarbonFootprint, OVOFootprint, OVOFootprintBreakdown, OVOFootprintElectricity, OVOFootprintGas, ) from .models.oauth import OAuth _LOGGER = logging.getLogger(__name__) class OVOEnergy: """Class for OVOEnergy.""" custom_account_id: int | None = None def __init__( self, client_session: aiohttp.ClientSession, ) -> None: """Initilalize.""" self._client_session = client_session self._customer_id: UUID | None = None self._bootstrap_accounts: BootstrapAccounts | None = None self._cookies: SimpleCookie | None = None self._oauth: OAuth | None = None self._username: str | None = None self._account_ids: list[int] | None = None @property def account_id(self) -> int | None: """Return account id.""" if self.custom_account_id is None and ( self.account_ids is None or len(self.account_ids) == 0 ): raise OVOEnergyNoAccount("No account id set") return ( self.custom_account_id if self.custom_account_id else self.account_ids[0] if self.account_ids and len(self.account_ids) > 0 else None ) @property def account_ids(self) -> list[int] | None: """Return account ids.""" return self._account_ids @property def customer_id(self) -> UUID | None: """Return customer id.""" if self._customer_id is None: raise OVOEnergyNoCustomer("No customer id set") return self._customer_id @property def oauth(self) -> OAuth | None: """Return OAuth.""" return self._oauth @property def oauth_expired(self) -> bool: """Return True if OAuth token has expired.""" return self.oauth is None or self.oauth.expires_at < datetime.now() @property def username(self) -> str | None: """Return username.""" return self._username async def _request( self, url: str, method: Literal["GET"] | Literal["POST"], with_cookies: bool = True, with_authorization: bool = True, **kwargs, ): """Request.""" if with_cookies and self._cookies is None: raise OVOEnergyAPINoCookies("No cookies set") if with_authorization and self._oauth is None: raise OVOEnergyAPINotAuthorized("No OAuth token set") if with_authorization and self.oauth_expired: _LOGGER.debug("OAuth token expired, refreshing: %s", self.oauth) if not await self.get_token() or self.oauth is None: raise OVOEnergyAPINotAuthorized("No OAuth token set after refresh") _LOGGER.debug("OAuth token refreshed: %s", self.oauth) response = await self._client_session.request( method, url, cookies=self._cookies if with_cookies else None, headers=( { "Authorization": f"Bearer {self.oauth.access_token}", } if with_authorization and self.oauth else None ), **kwargs, ) with contextlib.suppress(aiohttp.ClientResponseError): response.raise_for_status() if with_authorization and response.status in [401, 403]: raise OVOEnergyAPINotAuthorized(f"Not authorized: {response.status}") if response.status == 404: raise OVOEnergyAPINotFound(f"Endpoint not found: {response.status}") return response async def authenticate( self, username: str, password: str, ) -> bool: """Authenticate.""" response = await self._request( AUTH_LOGIN_URL, "POST", json={ "username": username, "password": password, "rememberMe": True, }, with_cookies=False, with_authorization=False, ) if response.status != 200: return False json_response = await response.json() if "code" in json_response and json_response["code"] == "Unknown": return False self._cookies = response.cookies self._username = username if not await self.get_token(): return False return True async def get_token(self) -> OAuth | Literal[False]: """Get token.""" response = await self._request( AUTH_TOKEN_URL, "GET", with_authorization=False, ) if response.status != 200: return False json_response = await response.json() self._oauth = OAuth( access_token=json_response["accessToken"]["value"], expires_in=json_response["expiresIn"], refresh_expires_in=json_response["refreshExpiresIn"], # Set expires_at to current time plus expiresIn (minutes) expires_at=datetime.now() + timedelta(minutes=json_response["expiresIn"]), ) # Read JWT token decoded_token = jwt.decode( self._oauth.access_token, options={"verify_signature": False} ) # Set customer id from sub claim self._customer_id = decoded_token.get("sub") # Set fallback account ids from permissions array if it is set ids: list[int] = [] for permission in decoded_token.get("permissions", []): if "account::" not in permission: continue account_id = permission.split("account::")[1] if not account_id.isdigit(): continue ids.append(int(account_id)) # Set fallback account ids if they are set if len(ids) > 0: self._account_ids = ids else: self._account_ids = None return self._oauth async def bootstrap_accounts(self) -> BootstrapAccounts: """Bootstrap accounts.""" response = await self._request( BOOTSTRAP_GRAPHQL_URL, "POST", json={ "operationName": "Bootstrap", "query": BOOTSTRAP_QUERY, "variables": { "customerId": self.customer_id, }, }, ) json_response = await response.json() if "data" not in json_response: raise OVOEnergyAPIInvalidResponse("Missing 'data' key in response") if "customer_nextV1" not in json_response["data"]: raise OVOEnergyAPIInvalidResponse( "Missing 'data.customer_nextV1' key in response" ) if "id" not in json_response["data"]["customer_nextV1"]: raise OVOEnergyAPIInvalidResponse( "Missing 'data.customer_nextV1.id' key in response" ) if ( "customerAccountRelationships" not in json_response["data"]["customer_nextV1"] ): raise OVOEnergyAPIInvalidResponse( "Missing 'data.customer_nextV1.customerAccountRelationships' key in response" ) if ( "edges" not in json_response["data"]["customer_nextV1"][ "customerAccountRelationships" ] ): raise OVOEnergyAPIInvalidResponse( "Missing 'data.customer_nextV1.customerAccountRelationships.edges' key in response" ) accounts: list[Account] = [] for edge in json_response["data"]["customer_nextV1"][ "customerAccountRelationships" ]["edges"]: if "node" not in edge: _LOGGER.warning( "Missing 'data.customer_nextV1.customerAccountRelationships.edges[X].node' key in response" ) continue if "account" not in edge["node"]: _LOGGER.warning( "Missing 'data.customer_nextV1.customerAccountRelationships.edges[X].node.account' key in response" ) continue if "id" not in edge["node"]["account"]: _LOGGER.warning( "Missing 'data.customer_nextV1.customerAccountRelationships.edges[X].node.account.id' key in response" ) continue if "accountSupplyPoints" not in edge["node"]["account"]: _LOGGER.warning( "Missing 'data.customer_nextV1.customerAccountRelationships.edges[X].node.account.accountSupplyPoints' key in response" ) continue supplies: list[Supply] = [] for supply in edge["node"]["account"]["accountSupplyPoints"]: if "supplyPoint" not in supply: _LOGGER.warning( "Missing 'data.customer_nextV1.customerAccountRelationships.edges[X].node.account.accountSupplyPoints[X].supplyPoint' key in response" ) continue if "meterTechnicalDetails" not in supply["supplyPoint"]: _LOGGER.warning( "Missing 'data.customer_nextV1.customerAccountRelationships.edges[X].node.account.accountSupplyPoints[X].supplyPoint.meterTechnicalDetails' key in response" ) continue active_meter_technical_details = None for meter_detail in supply["supplyPoint"]["meterTechnicalDetails"]: if "status" not in meter_detail: _LOGGER.warning( "Missing 'data.customer_nextV1.customerAccountRelationships.edges[X].node.account.accountSupplyPoints[X].supplyPoint.meterTechnicalDetails[X].status' key in response" ) continue if meter_detail["status"].lower() == "active": active_meter_technical_details = meter_detail break supply_point_address_lines: list[str] = [] if "address" not in supply["supplyPoint"]: _LOGGER.warning( "Missing 'data.customer_nextV1.customerAccountRelationships.edges[X].node.account.accountSupplyPoints[X].supplyPoint.address' key in response. Allowing empty address." ) else: supply_point_address_lines = supply["supplyPoint"]["address"].get( "addressLines", [] ) if "postCode" in supply["supplyPoint"]["address"]: supply_point_address_lines.append( supply["supplyPoint"]["address"].get("postCode") ) supplies.append( Supply( mpxn=active_meter_technical_details["meterSerialNumber"] if active_meter_technical_details else None, fuel=supply["supplyPoint"].get("fuelType", None), is_onboarding=supply["supplyPoint"].get("isOnboarding", None), start=supply["supplyPoint"].get("startDate", None), is_payg=supply["supplyPoint"].get("isPayg", None), supply_point_info=SupplyPointInfo( meter_type=active_meter_technical_details["type"] if active_meter_technical_details else None, meter_not_found=active_meter_technical_details[ "status" ].lower() == "removed" if active_meter_technical_details else None, address=supply_point_address_lines, ), ) ) accounts.append( Account( account_id=edge["node"]["account"]["id"], is_payg=None, # No longer supplied is_blocked=None, # No longer supplied supplies=supplies, ) ) self._bootstrap_accounts = BootstrapAccounts( account_ids=[account.account_id for account in accounts], customer_id=json_response["data"]["customer_nextV1"]["id"], selected_account_id=accounts[ 0 ].account_id, # We no longer get this, so pick the first one, the user should specify otherwise is_first_login=False, # We no longer get this, so assume false accounts=accounts, ) return self._bootstrap_accounts async def get_daily_usage( self, date: str, ) -> OVODailyUsage: """Get daily usage data.""" ovo_usage = OVODailyUsage( electricity=None, gas=None, ) response = await self._request( f"{USAGE_DAILY_URL}/{self.account_id}?date={date}", "GET", ) json_response = await response.json() if "electricity" in json_response: electricity = json_response["electricity"] if electricity and "data" in electricity: ovo_usage.electricity = [] for usage in electricity["data"]: if usage is not None: ovo_usage.electricity.append( OVODailyElectricity( consumption=usage.get("consumption", None), interval=( OVOInterval( start=datetime.fromisoformat( usage["interval"]["start"] ), end=datetime.fromisoformat( usage["interval"]["end"] ), ) if "interval" in usage else None ), meter_readings=( OVOMeterReadings( start=usage["meterReadings"]["start"], end=usage["meterReadings"]["end"], ) if "meterReadings" in usage else None ), has_half_hour_data=usage.get("hasHalfHourData", None), cost=( OVOCost( amount=usage["cost"]["amount"], currency_unit=usage["cost"]["currencyUnit"], ) if "cost" in usage else None ), rates=OVORates( anytime=usage["rates"].get("anytime", None), standing=usage["rates"].get("standing", None), ) if "rates" in usage else None, ) ) if "gas" in json_response: gas = json_response["gas"] if gas and "data" in gas: ovo_usage.gas = [] for usage in gas["data"]: if usage is not None: ovo_usage.gas.append( OVODailyGas( consumption=usage.get("consumption", None), volume=usage.get("volume", None), interval=( OVOInterval( start=datetime.fromisoformat( usage["interval"]["start"] ), end=datetime.fromisoformat( usage["interval"]["end"] ), ) if "interval" in usage else None ), meter_readings=( OVOMeterReadings( start=usage["meterReadings"]["start"], end=usage["meterReadings"]["end"], ) if "meterReadings" in usage else None ), has_half_hour_data=usage.get("hasHalfHourData", None), cost=OVOCost( amount=usage["cost"]["amount"], currency_unit=usage["cost"]["currencyUnit"], ) if "cost" in usage else None, rates=OVORates( anytime=usage["rates"].get("anytime", None), standing=usage["rates"].get("standing", None), ) if "rates" in usage else None, ) ) return ovo_usage async def get_half_hourly_usage( self, date: str, ) -> OVOHalfHourUsage: """Get half hourly usage data.""" ovo_usage = OVOHalfHourUsage( electricity=None, gas=None, ) response = await self._request( f"{USAGE_HALF_HOURLY_URL}/{self.account_id}?date={date}", "GET", ) json_response = await response.json() if "electricity" in json_response: electricity = json_response["electricity"] if electricity and "data" in electricity: ovo_usage.electricity = [] for usage in electricity["data"]: if usage is not None: ovo_usage.electricity.append( OVOHalfHour( consumption=usage["consumption"], interval=OVOInterval( start=datetime.fromisoformat( usage["interval"]["start"] ), end=datetime.fromisoformat( usage["interval"]["end"] ), ), unit=usage["unit"], ) ) if "gas" in json_response: gas = json_response["gas"] if gas and "data" in gas: ovo_usage.gas = [] for usage in gas["data"]: if usage is not None: ovo_usage.gas.append( OVOHalfHour( consumption=usage["consumption"], interval=OVOInterval( start=datetime.fromisoformat( usage["interval"]["start"] ), end=datetime.fromisoformat( usage["interval"]["end"] ), ), unit=usage["unit"], ) ) return ovo_usage async def get_footprint(self) -> OVOFootprint: """Get footprint.""" response = await self._request( f"{CARBON_FOOTPRINT_URL}/{self.account_id}/footprint", "GET", ) json_response = await response.json() return OVOFootprint( from_=json_response["from"], to=json_response["to"], carbon_reduction_product_ids=json_response["carbonReductionProductIds"], carbon_footprint=OVOCarbonFootprint( carbon_kg=json_response["carbonFootprint"]["carbonKg"], carbon_saved_kg=json_response["carbonFootprint"]["carbonSavedKg"], k_wh=json_response["carbonFootprint"]["kWh"], breakdown=OVOFootprintBreakdown( electricity=OVOFootprintElectricity( carbon_kg=json_response["carbonFootprint"]["breakdown"][ "electricity" ]["carbonKg"], carbon_saved_kg=json_response["carbonFootprint"]["breakdown"][ "electricity" ]["carbonSavedKg"], k_wh=json_response["carbonFootprint"]["breakdown"][ "electricity" ]["kWh"], ), gas=OVOFootprintGas( carbon_kg=json_response["carbonFootprint"]["breakdown"]["gas"][ "carbonKg" ], carbon_saved_kg=json_response["carbonFootprint"]["breakdown"][ "gas" ]["carbonSavedKg"], k_wh=json_response["carbonFootprint"]["breakdown"]["gas"][ "kWh" ], ), ), ), ) async def get_carbon_intensity(self): """Get carbon intensity.""" response = await self._request( CARBON_INTENSITY_URL, "GET", ) json_response = await response.json() return OVOCarbonIntensity( forecast=[ OVOCarbonIntensityForecast( time_from=forecast["from"], intensity=forecast["intensity"], level=forecast["level"], colour=forecast["colour"], colour_v2=forecast["colourV2"], ) for forecast in json_response["forecast"] ], current=json_response["current"], greentime=json_response["greentime"], ) ovoenergy-3.0.2/ovoenergy/__main__.py000066400000000000000000000122411506755107600176540ustar00rootroot00000000000000"""Main.""" import asyncio from dataclasses import asdict from datetime import datetime, timedelta import os from pathlib import Path import aiohttp import typer from . import OVOEnergy def _load_env_file(env_path: str = ".env") -> None: """Load environment variables from .env file.""" env_file = Path(env_path) if not env_file.exists(): return with open(env_file, encoding="utf-8") as f: for line in f: line = line.strip() # Skip empty lines and comments if not line or line.startswith("#"): continue # Split on first = sign if "=" in line: key, value = line.split("=", 1) key = key.strip() value = value.strip() # Remove quotes if present if value.startswith('"') and value.endswith('"'): value = value[1:-1] elif value.startswith("'") and value.endswith("'"): value = value[1:-1] # Only set if not already in environment if key not in os.environ: os.environ[key] = value # Load environment variables from .env file _load_env_file() app = typer.Typer() loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) async def _setup_client( account: int | None = None, ) -> tuple[OVOEnergy, aiohttp.ClientSession]: """Set up OVO Energy client.""" # Get credentials from environment variables username = os.getenv("OVO_USERNAME") password = os.getenv("OVO_PASSWORD") if not username or not password: typer.secho( "Error: OVO_USERNAME and OVO_PASSWORD must be set in .env file", fg=typer.colors.RED, ) raise typer.Abort() client_session = aiohttp.ClientSession() client = OVOEnergy( client_session=client_session, ) if not await client.authenticate(username, password): typer.secho("Authentication failed", fg=typer.colors.RED) raise typer.Abort() await client.bootstrap_accounts() if account is not None: client.custom_account_id = account return (client, client_session) @app.command(name="daily", short_help="Get daily usage from OVO Energy") def daily( account: int = typer.Option( None, help="OVO Energy account number (default: first account)" ), date: str = typer.Option( None, help="Date to retrieve data for (default: this month)" ), ) -> None: """Get daily usage from OVO Energy.""" if date is None: # Get this month date = datetime.now().strftime("%Y-%m") [client, client_session] = loop.run_until_complete(_setup_client(account)) ovo_usage = loop.run_until_complete(client.get_daily_usage(date)) typer.secho( asdict(ovo_usage) if ovo_usage is not None else '{"message": "No data"}', fg=typer.colors.GREEN, ) loop.run_until_complete(client_session.close()) @app.command(name="halfhourly", short_help="Get half hourly usage from OVO Energy") def half_hourly( account: int = typer.Option( None, help="OVO Energy account number (default: first account)" ), date: str = typer.Option( None, help="Date to retrieve data for (default: this month)" ), ) -> None: """Get half hourly usage from OVO Energy.""" if date is None: # Get yesterday's date date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") [client, client_session] = loop.run_until_complete(_setup_client(account)) ovo_usage = loop.run_until_complete(client.get_half_hourly_usage(date)) typer.secho( asdict(ovo_usage) if ovo_usage is not None else '{"message": "No data"}', fg=typer.colors.GREEN, ) loop.run_until_complete(client_session.close()) @app.command(name="carbon-footprint", short_help="Get carbon footprint from OVO Energy") def carbon_footprint( account: int = typer.Option( None, help="OVO Energy account number (default: first account)" ), ) -> None: """Get carbon footprint from OVO Energy.""" [client, client_session] = loop.run_until_complete(_setup_client(account)) ovo_footprint = loop.run_until_complete(client.get_footprint()) typer.secho( ( asdict(ovo_footprint) if ovo_footprint is not None else '{"message": "No data"}' ), fg=typer.colors.GREEN, ) loop.run_until_complete(client_session.close()) @app.command(name="carbon-intensity", short_help="Get carbon intensity from OVO Energy") def carbon_intensity( account: int = typer.Option( None, help="OVO Energy account number (default: first account)" ), ) -> None: """Get carbon intensity from OVO Energy.""" [client, client_session] = loop.run_until_complete(_setup_client(account)) ovo_carbon_intensity = loop.run_until_complete(client.get_carbon_intensity()) typer.secho( ( asdict(ovo_carbon_intensity) if ovo_carbon_intensity is not None else '{"message": "No data"}' ), fg=typer.colors.GREEN, ) loop.run_until_complete(client_session.close()) if __name__ == "__main__": app() ovoenergy-3.0.2/ovoenergy/const.py000066400000000000000000000030221506755107600172570ustar00rootroot00000000000000"""Constants for the OVO Energy API client.""" # Base URLs AUTH_BASE_URL = "https://my.ovoenergy.com/api/v2/auth" SMARTPAY_BASE_URL = "https://smartpaymapi.ovoenergy.com" KALUZA_BASE_URL = "https://api.eu1.prod.kaluza.com" # Authentication endpoints AUTH_LOGIN_URL = f"{AUTH_BASE_URL}/login" AUTH_TOKEN_URL = f"{AUTH_BASE_URL}/token" # Bootstrap endpoints BOOTSTRAP_GRAPHQL_URL = f"{KALUZA_BASE_URL}/graphql/1" # Usage endpoints USAGE_DAILY_URL = f"{SMARTPAY_BASE_URL}/usage/api/daily" USAGE_HALF_HOURLY_URL = f"{SMARTPAY_BASE_URL}/usage/api/half-hourly" # Carbon endpoints CARBON_FOOTPRINT_URL = f"{SMARTPAY_BASE_URL}/carbon-api" CARBON_INTENSITY_URL = f"{SMARTPAY_BASE_URL}/carbon-bff/carbonintensity" # GraphQL query for bootstrapping customer accounts BOOTSTRAP_QUERY = """ query Bootstrap($customerId: ID!) { customer_nextV1(id: $customerId) { id customerAccountRelationships { edges { node { account { accountNo id accountSupplyPoints { ...AccountSupplyPoint __typename } __typename } __typename } __typename } __typename } __typename } } fragment AccountSupplyPoint on AccountSupplyPoint { startDate supplyPoint { sprn fuelType meterTechnicalDetails { meterSerialNumber mode type status __typename } address { addressLines postCode __typename } __typename } __typename } """ ovoenergy-3.0.2/ovoenergy/exceptions.py000066400000000000000000000015131506755107600203150ustar00rootroot00000000000000"""Exceptions for the OVO Energy API.""" # Base Exceptions class OVOEnergyException(Exception): """Base exception for OVO Energy.""" class OVOEnergyNoAccount(OVOEnergyException): """Exception for no account found.""" # API Exceptions class OVOEnergyAPIException(OVOEnergyException): """Exception for API exceptions.""" class OVOEnergyAPIInvalidResponse(OVOEnergyAPIException): """Exception for invalid response.""" class OVOEnergyAPINotAuthorized(OVOEnergyAPIException): """Exception for API client not authorized.""" class OVOEnergyAPINotFound(OVOEnergyAPIException): """Exception for API endpoint not found (404).""" class OVOEnergyAPINoCookies(OVOEnergyAPIException): """Exception for no cookies found.""" class OVOEnergyNoCustomer(OVOEnergyException): """Exception for no customer found.""" ovoenergy-3.0.2/ovoenergy/models/000077500000000000000000000000001506755107600170455ustar00rootroot00000000000000ovoenergy-3.0.2/ovoenergy/models/__init__.py000066400000000000000000000030731506755107600211610ustar00rootroot00000000000000"""Models.""" from dataclasses import dataclass from datetime import datetime @dataclass class OVOInterval: """Interval model.""" start: datetime end: datetime @dataclass class OVOMeterReadings: """Meter readings model.""" start: float end: float @dataclass class OVOCost: """Cost model.""" amount: float | None currency_unit: str | None @dataclass class OVORates: """Rates model.""" anytime: float | None standing: float | None @dataclass class OVODailyElectricity: """Daily electricity model.""" consumption: float | None interval: OVOInterval | None meter_readings: OVOMeterReadings | None has_half_hour_data: bool | None cost: OVOCost | None rates: OVORates | None @dataclass class OVODailyGas: """Daily gas model.""" consumption: float | None volume: float | None interval: OVOInterval | None meter_readings: OVOMeterReadings | None has_half_hour_data: bool | None cost: OVOCost | None rates: OVORates | None @dataclass class OVOHalfHour: """Half hour model.""" consumption: float interval: OVOInterval unit: str @dataclass class OVODailyUsage: """Daily usage model.""" electricity: list[OVODailyElectricity] | None gas: list[OVODailyGas] | None @dataclass class OVOHalfHourUsage: """Half hour usage model.""" electricity: list[OVOHalfHour] | None gas: list[OVOHalfHour] | None @dataclass class OVOPlan: """Plan model.""" standing_charge: float | None unit_rate: float | None tariff: str | None ovoenergy-3.0.2/ovoenergy/models/accounts.py000066400000000000000000000017341506755107600212430ustar00rootroot00000000000000"""Dataclasses for the bootstrap/accounts endpoint.""" from dataclasses import dataclass from datetime import datetime from typing import Literal from uuid import UUID @dataclass class SupplyPointInfo: """Supply point info model.""" meter_type: str | None = None meter_not_found: bool | None = None address: list[str] | None = None @dataclass class Supply: """Supply model.""" mpxn: str | None fuel: Literal["ELECTRICITY", "GAS"] | str | None is_onboarding: bool | None start: datetime | None is_payg: bool | None supply_point_info: SupplyPointInfo | None @dataclass class Account: """Account model.""" account_id: int is_payg: bool | None is_blocked: bool | None supplies: list[Supply] | None @dataclass class BootstrapAccounts: """Bootstrap Accounts model.""" account_ids: list[int] customer_id: UUID selected_account_id: int accounts: list[Account] | None is_first_login: bool | None ovoenergy-3.0.2/ovoenergy/models/carbon_intensity.py000066400000000000000000000006621506755107600227750ustar00rootroot00000000000000"""Footprint Models.""" from dataclasses import dataclass from typing import Any @dataclass class OVOCarbonIntensityForecast: """Carbon intensity forecast model.""" time_from: str intensity: float level: str colour: str colour_v2: str @dataclass class OVOCarbonIntensity: """Carbon intensity model.""" forecast: list[OVOCarbonIntensityForecast] current: str | None greentime: Any | None ovoenergy-3.0.2/ovoenergy/models/footprint.py000066400000000000000000000015551506755107600214510ustar00rootroot00000000000000"""Footprint Models.""" from dataclasses import dataclass from typing import Any @dataclass class OVOFootprintElectricity: """Electricity footprint model.""" carbon_kg: float carbon_saved_kg: float k_wh: float @dataclass class OVOFootprintGas: """Gas footprint model.""" carbon_kg: float carbon_saved_kg: float k_wh: float @dataclass class OVOFootprintBreakdown: """Footprint breakdown model.""" electricity: OVOFootprintElectricity gas: OVOFootprintGas @dataclass class OVOCarbonFootprint: """Carbon footprint model.""" carbon_kg: float carbon_saved_kg: float k_wh: float breakdown: OVOFootprintBreakdown @dataclass class OVOFootprint: """Footprint model.""" from_: str | None to: str | None carbon_reduction_product_ids: list[Any] carbon_footprint: OVOCarbonFootprint | None ovoenergy-3.0.2/ovoenergy/models/oauth.py000066400000000000000000000003451506755107600205410ustar00rootroot00000000000000"""OAuth model.""" from dataclasses import dataclass from datetime import datetime @dataclass class OAuth: """OAuth model.""" access_token: str expires_in: int refresh_expires_in: int expires_at: datetime ovoenergy-3.0.2/ovoenergy/models/plan.py000066400000000000000000000024531506755107600203550ustar00rootroot00000000000000"""Plan Models.""" from dataclasses import dataclass from typing import Any @dataclass class OVOPlanRate: """Plan rate model.""" amount: float currency_unit: str @dataclass class OVOPlanStatus: """Plan status model.""" active: bool in_renewal: bool in_loss: bool loss_complete: bool has_future_contracts: bool @dataclass class OVOPlanUnitRate: """Unit rate model.""" name: str unit_rate: OVOPlanRate @dataclass class OVOPlanElectricity: """Plan electricity model.""" name: str exit_fee: OVOPlanRate contract_start_date: str contract_end_date: Any contract_type: str is_in_renewal: bool has_future_contracts: bool mpxn: str msn: str personal_projection: float standing_charge: OVOPlanRate unit_rates: list[OVOPlanUnitRate] @dataclass class OVOPlanGas: """Plan gas model.""" name: str exit_fee: OVOPlanRate contract_start_date: str contract_end_date: Any contract_type: str is_in_renewal: bool has_future_contracts: bool mpxn: str msn: str personal_projection: float standing_charge: OVOPlanRate unit_rates: list[OVOPlanUnitRate] @dataclass class OVOPlans: """Plan model.""" electricity: list[OVOPlanElectricity] gas: list[OVOPlanGas] | None ovoenergy-3.0.2/pyproject.toml000066400000000000000000000167571506755107600165010ustar00rootroot00000000000000[tool.black] extend-exclude = "/generated/" [tool.pylint.MAIN] py-version = "3.12" 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 init-hook = """\ from pathlib import Path; \ import sys; \ from pylint.config import find_default_config_files; \ sys.path.append( \ str(Path(next(find_default_config_files())).parent.joinpath('pylint/plugins')) ) \ """ load-plugins = [ "pylint.extensions.code_style", "pylint.extensions.typing", ] persistent = false extension-pkg-allow-list = [ "av.audio.stream", "av.logging", "av.stream", "ciso8601", "orjson", "cv2", ] fail-on = [ "I", ] [tool.pylint.BASIC] class-const-naming-style = "any" [tool.pylint."MESSAGES CONTROL"] # Reasons disabled: # format - handled by black # locally-disabled - it spams too much # duplicate-code - unavoidable # cyclic-import - doesn't test if both import on load # unused-argument - generic callbacks and setup methods create a lot of warnings # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* # abstract-method - with intro of async there are always methods missing # inconsistent-return-statements - doesn't handle raise # too-many-ancestors - it's too strict. # wrong-import-order - isort guards this disable = [ "abstract-method", "consider-alternative-union-syntax", "cyclic-import", "duplicate-code", "fixme", "format", "inconsistent-return-statements", "locally-disabled", "not-context-manager", "too-few-public-methods", "too-many-ancestors", "too-many-arguments", "too-many-boolean-expressions", "too-many-branches", "too-many-instance-attributes", "too-many-lines", "too-many-locals", "too-many-public-methods", "too-many-return-statements", "too-many-statements", "unused-argument", "wrong-import-order", ] enable = [ #"useless-suppression", # temporarily every now and then to clean them up "use-symbolic-message-instead", ] [tool.isort] profile = "black" line_length = 88 [tool.pylint.REPORTS] score = false [tool.pylint.TYPECHECK] ignored-classes = [ "_CountingAttr", # for attrs ] mixin-class-rgx = ".*[Mm]ix[Ii]n" [tool.pylint.FORMAT] expected-line-ending-format = "LF" [tool.pylint.EXCEPTIONS] overgeneral-exceptions = [ "builtins.BaseException", "builtins.Exception", ] [tool.pylint.TYPING] runtime-typing = false [tool.pylint.CODE_STYLE] max-line-length-suggestions = 72 [tool.pytest.ini_options] testpaths = [ "tests", ] norecursedirs = [ ".git", "testing_config", ] log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s" log_date_format = "%Y-%m-%d %H:%M:%S" asyncio_mode = "auto" [tool.ruff.lint] select = [ "B002", # Python does not support the unary prefix increment "B007", # Loop control variable {name} not used within loop body "B014", # Exception handler with duplicate exception "B023", # Function definition does not bind loop variable {name} "B026", # Star-arg unpacking after a keyword argument is strongly discouraged "C", # complexity "COM818", # Trailing comma on bare tuple prohibited "D", # docstrings "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) "E", # pycodestyle "F", # pyflakes/autoflake "G", # flake8-logging-format "I", # isort "ICN001", # import concentions; {name} should be imported as {asname} "ISC001", # Implicitly concatenated string literals on one line "N804", # First argument of a class method should be named cls "N805", # First argument of a method should be named self "N815", # Variable {name} in class scope should not be mixedCase "S307", # suspicious-eval-usage (remapped from PGH001) "PGH004", # Use specific rule codes when using noqa "PLC", # pylint "PLC0414", # Useless import alias. Import alias does not rename original package. "PLE", # pylint "PLR", # pylint "PLW", # pylint "Q000", # Double quotes found but single quotes preferred "RUF006", # Store a reference to the return value of asyncio.create_task "S102", # Use of exec detected "S103", # bad-file-permissions "S108", # hardcoded-temp-file "S306", # suspicious-mktemp-usage "S313", # suspicious-xmlc-element-tree-usage "S314", # suspicious-xml-element-tree-usage "S315", # suspicious-xml-expat-reader-usage "S316", # suspicious-xml-expat-builder-usage "S317", # suspicious-xml-sax-usage "S318", # suspicious-xml-mini-dom-usage "S319", # suspicious-xml-pull-dom-usage "S601", # paramiko-call "S602", # subprocess-popen-with-shell-equals-true "S604", # call-with-shell-equals-true "S608", # hardcoded-sql-expression "S609", # unix-command-wildcard-injection "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass "SIM117", # Merge with-statements that use the same scope "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() "SIM201", # Use {left} != {right} instead of not {left} == {right} "SIM208", # Use {expr} instead of not (not {expr}) "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. "SIM401", # Use get from dict with default instead of an if block "T100", # Trace found: {name} used "T20", # flake8-print "TID251", # Banned imports "TRY004", # Prefer TypeError exception for invalid type "B904", # Use raise from to specify exception cause (remapped from TRY200) "TRY203", # Remove exception handler; error is immediately re-raised (remapped from TRY302) "UP", # pyupgrade "W", # pycodestyle ] ignore = [ "D202", # No blank lines allowed after function docstring "D203", # 1 blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line "D406", # Section name should end with a newline "D407", # Section name underlining "E501", # line too long "E731", # do not assign a lambda expression, use a def "PLC0208", # Use a sequence type instead of a `set` when iterating over values "PLR0911", # Too many return statements ({returns} > {max_returns}) "PLR0912", # Too many branches ({branches} > {max_branches}) "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) "PLR0915", # Too many statements ({statements} > {max_statements}) "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target "UP006", # keep type annotation style as is "UP007", # keep type annotation style as is ] [tool.ruff.lint.flake8-import-conventions.extend-aliases] voluptuous = "vol" [tool.ruff.lint.flake8-pytest-style] fixture-parentheses = false [tool.ruff.lint.flake8-tidy-imports.banned-api] "async_timeout".msg = "use asyncio.timeout instead" "pytz".msg = "use zoneinfo instead" [tool.ruff.lint.isort] force-sort-within-sections = true combine-as-imports = true split-on-trailing-comma = false [tool.ruff.lint.per-file-ignores] "_version.py" = ["D200", "D212"] # Allow for main entry & scripts to write to stdout "script/*" = ["T20"] [tool.ruff.lint.mccabe] max-complexity = 25 ovoenergy-3.0.2/requirements.txt000066400000000000000000000001471506755107600170330ustar00rootroot00000000000000aiohttp>=3.8.5;python_version<'3.12' aiohttp>=3.9.0b0;python_version>='3.12' typer>=0.6.1 PyJWT>=2.0.0 ovoenergy-3.0.2/requirements_test.txt000066400000000000000000000002561506755107600200730ustar00rootroot00000000000000aioresponses==0.7.6 pytest-aiohttp==1.0.5 pytest-asyncio==0.24.0 pytest-cov==5.0.0 pytest-socket==0.7.0 pytest-sugar==1.0.0 pytest-timeout==2.3.1 pytest==8.3.2 syrupy==5.0.0 ovoenergy-3.0.2/script/000077500000000000000000000000001506755107600150515ustar00rootroot00000000000000ovoenergy-3.0.2/script/get_version.py000066400000000000000000000013021506755107600177430ustar00rootroot00000000000000#!/usr/bin/env python3 """Print the current package version from setup.py. This script reads the version string from the repository's setup.py and prints only the version to stdout. """ from __future__ import annotations from pathlib import Path import re def main() -> None: """Print the current package version from setup.py.""" project_root = Path(__file__).resolve().parents[1] setup_path = project_root / "setup.py" setup_contents = setup_path.read_text(encoding="utf-8") match = re.search(r'version="([^"]+)"', setup_contents) if not match: raise SystemExit("version field not found in setup.py") print(match.group(1)) if __name__ == "__main__": main() ovoenergy-3.0.2/script/increment_version_dev.py000066400000000000000000000023371506755107600220170ustar00rootroot00000000000000#!/usr/bin/env python3 """Increment development version in setup.py. If version is X.Y.Z.devN -> X.Y.Z.dev(N+1) If version is X.Y.Z -> X.Y.(Z+1).dev0 """ from __future__ import annotations from pathlib import Path import re def main() -> None: """Increment development version in setup.py.""" project_root = Path(__file__).resolve().parents[1] setup_path = project_root / "setup.py" contents = setup_path.read_text(encoding="utf-8") match = re.search(r'version="([^"]+)"', contents) if not match: raise SystemExit("version field not found in setup.py") version = match.group(1) dev_match = re.search(r"^(\d+)\.(\d+)\.(\d+)(?:\.dev(\d+))?$", version) if not dev_match: raise SystemExit(f"Unexpected version format: {version}") major, minor, patch, dev = dev_match.groups() if dev is None: new_version = f"{major}.{minor}.{int(patch) + 1}.dev0" else: new_version = f"{major}.{minor}.{patch}.dev{int(dev) + 1}" new_contents = contents.replace( f'version="{version}"', f'version="{new_version}"', 1 ) setup_path.write_text(new_contents, encoding="utf-8") print(f"Updated version to {new_version}") if __name__ == "__main__": main() ovoenergy-3.0.2/script/increment_version_release.py000066400000000000000000000022531506755107600226560ustar00rootroot00000000000000#!/usr/bin/env python3 """Increment release version and set to next development cycle in setup.py. X.Y.Z (or X.Y.Z.devN) -> X.Y.(Z+1).dev0 """ from __future__ import annotations from pathlib import Path import re def main() -> None: """Increment release version and set to next development cycle in setup.py.""" project_root = Path(__file__).resolve().parents[1] setup_path = project_root / "setup.py" contents = setup_path.read_text(encoding="utf-8") match = re.search(r'version="([^"]+)"', contents) if not match: raise SystemExit("version field not found in setup.py") version = match.group(1) base = re.sub(r"\.dev\d*$", "", version) base_match = re.fullmatch(r"^(\d+)\.(\d+)\.(\d+)$", base) if not base_match: raise SystemExit(f"Unexpected base version format: {base}") major, minor, patch = base_match.groups() new_version = f"{major}.{minor}.{int(patch) + 1}.dev0" new_contents = contents.replace( f'version="{version}"', f'version="{new_version}"', 1 ) setup_path.write_text(new_contents, encoding="utf-8") print(f"Updated version to {new_version}") if __name__ == "__main__": main() ovoenergy-3.0.2/script/set_dev_version.py000066400000000000000000000020061506755107600206170ustar00rootroot00000000000000#!/usr/bin/env python3 """Ensure the package version in setup.py has a `.dev0` suffix. If the version already includes a `.dev` suffix, no change is made. """ from __future__ import annotations from pathlib import Path import re def main() -> None: """Ensure the package version in setup.py has a `.dev0` suffix.""" project_root = Path(__file__).resolve().parents[1] setup_path = project_root / "setup.py" contents = setup_path.read_text(encoding="utf-8") match = re.search(r'version="([^"]+)"', contents) if not match: raise SystemExit("version field not found in setup.py") version = match.group(1) if "dev" in version: print("Version already has dev suffix, no change") return new_version = f"{version}.dev0" new_contents = contents.replace( f'version="{version}"', f'version="{new_version}"', 1 ) setup_path.write_text(new_contents, encoding="utf-8") print(f"Updated version to {new_version}") if __name__ == "__main__": main() ovoenergy-3.0.2/script/set_release_version.py000066400000000000000000000017101506755107600214620ustar00rootroot00000000000000#!/usr/bin/env python3 """Strip any `.devN` suffix from the package version in setup.py.""" from __future__ import annotations from pathlib import Path import re def main() -> None: """Strip any `.devN` suffix from the package version in setup.py.""" project_root = Path(__file__).resolve().parents[1] setup_path = project_root / "setup.py" contents = setup_path.read_text(encoding="utf-8") match = re.search(r'version="([^"]+)"', contents) if not match: raise SystemExit("version field not found in setup.py") version = match.group(1) new_version = re.sub(r"\.dev\d*$", "", version) if new_version == version: print("No dev suffix to strip") return new_contents = contents.replace( f'version="{version}"', f'version="{new_version}"', 1 ) setup_path.write_text(new_contents, encoding="utf-8") print(f"Updated version to {new_version}") if __name__ == "__main__": main() ovoenergy-3.0.2/setup.py000066400000000000000000000013411506755107600152560ustar00rootroot00000000000000"""Setup.""" from setuptools import find_packages, setup # Get packages from requirements.txt with open("requirements.txt", encoding="utf-8") as f: requirements = f.read().splitlines() with open("README.md", encoding="utf-8") as f: readme = f.read() setup( name="ovoenergy", author="Aidan Timson (Timmo)", author_email="aidan@timmo.dev", description="OVO Energy", keywords="python,ovoenergy,api", license="Apache-2.0", long_description=readme, long_description_content_type="text/markdown", url="https://github.com/timmo001/ovoenergy", install_requires=requirements, packages=find_packages(exclude=["tests", "generator"]), python_requires=">=3.11", version="3.0.2.dev0", ) ovoenergy-3.0.2/tests/000077500000000000000000000000001506755107600147075ustar00rootroot00000000000000ovoenergy-3.0.2/tests/__init__.py000066400000000000000000000156201506755107600170240ustar00rootroot00000000000000"""Setup for tests.""" from typing import Final USERNAME: Final[str] = "test" PASSWORD: Final[str] = "test" ACCOUNT: Final[int] = 123456789 ACCOUNT_BAD: Final[int] = 654321789 RESPONSE_JSON_BASIC: Final[dict] = {"test": "test"} RESPONSE_JSON_AUTH: Final[dict] = {"code": "test"} # The token value is fake, don't panic. # # Decoded token: # # { # "alg": "HS256", # "typ": "JWT" # } # # { # "sub": "5cafe9c4-a942-46b5-a67c-5882eba0a03c", # "permissions": [ # "account::123456789" # ], # "iat": 1640995200, # "exp": 1640998800 # } # RESPONSE_JSON_TOKEN: Final[dict] = { "accessToken": { "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1Y2FmZTljNC1hOTQyLTQ2YjUtYTY3Yy01ODgyZWJhMGEwM2MiLCJwZXJtaXNzaW9ucyI6WyJhY2NvdW50OjoxMjM0NTY3ODkiXSwiaWF0IjoxNjQwOTk1MjAwLCJleHAiOjE2NDA5OTg4MDB9.ZXZcYyG6vT0NMdKlUy9KDnCj4DJyC7o3rX_AmPef6hw" }, "expiresIn": 3600, "refreshExpiresIn": 0, } RESPONSE_JSON_BOOTSTRAP_ACCOUNTS: Final[dict] = { "data": { "customer_nextV1": { "id": "5cafe9c4-a942-46b5-a67c-5882eba0a03c", "customerAccountRelationships": { "edges": [ { "node": { "account": { "id": ACCOUNT, "accountNo": str(ACCOUNT), "accountSupplyPoints": [ { "startDate": "2024-01-01T23:00:00Z", "supplyPoint": { "sprn": "3456766576", "fuelType": "gas", "isOnboarding": False, "isPayg": False, "address": { "addressLines": ["ADDR"], "postCode": "SW1A 1AA", }, "meterTechnicalDetails": [ { "meterSerialNumber": "3456766576", "mode": "credit", "type": "AB123", "status": "active", } ], }, }, { "startDate": "2024-01-01T23:00:00Z", "supplyPoint": { "sprn": "4536756746", "fuelType": "electricity", "isOnboarding": False, "isPayg": False, "address": { "addressLines": ["ADDR"], "postCode": "SW1A 1AA", }, "meterTechnicalDetails": [ { "meterSerialNumber": "4536756746", "mode": "credit", "type": "AB123", "status": "active", } ], }, }, ], }, }, } ], }, }, }, } RESPONSE_JSON_DAILY_USAGE: Final[dict] = { "electricity": { "data": [ { "consumption": 10.24, "interval": { "start": "2024-01-01T00:00:00Z", "end": "2024-01-01T23:59:59.999000Z", }, "meterReadings": { "start": "12345", "end": "67890", }, "hasHalfHourData": None, "cost": {"amount": "2.94", "currencyUnit": "GBP"}, "rates": { "anytime": 0.25, "standing": 0.45, }, } ], }, "gas": { "data": [ { "consumption": 14.68, "volume": None, "interval": { "start": "2024-01-01T00:00:00Z", "end": "2024-01-01T23:59:59.999000Z", }, "meterReadings": { "start": "12345", "end": "67890", }, "hasHalfHourData": None, "cost": {"amount": "2.56", "currencyUnit": "GBP"}, "rates": { "anytime": 0.18, "standing": 0.35, }, } ], }, } RESPONSE_JSON_HALF_HOURLY_USAGE: Final[dict] = { "electricity": { "data": [ { "consumption": 0.5, "interval": { "start": "2024-01-01T00:00:00Z", "end": "2024-01-01T00:30:00Z", }, "unit": "kWh", } ], }, "gas": { "data": [ { "consumption": 0.2, "interval": { "start": "2024-01-01T00:00:00Z", "end": "2024-01-01T00:30:00Z", }, "unit": "mΒ³", } ], }, } RESPONSE_JSON_FOOTPRINT: Final[dict] = { "from": "2024-01-01T00:00:00Z", "to": "2024-01-01T23:59:59.999000Z", "carbonReductionProductIds": [], "carbonFootprint": { "carbonKg": 2200.1234, "carbonSavedKg": 0.0, "kWh": 1578.3246, "breakdown": { "electricity": { "carbonKg": 200.1234, "carbonSavedKg": 230.02, "kWh": 65645.92, }, "gas": { "carbonKg": 2000.1234, "carbonSavedKg": 340.02, "kWh": 10664.74363579, }, }, }, } RESPONSE_JSON_INTENSITY: Final[dict] = { "forecast": [ { "from": "2pm", "intensity": 82, "level": "low", "colour": "#0A9928", "colourV2": "#0D8426", }, ], "current": "low", "greentime": None, } ovoenergy-3.0.2/tests/__snapshots__/000077500000000000000000000000001506755107600175255ustar00rootroot00000000000000ovoenergy-3.0.2/tests/__snapshots__/test__init__.ambr000066400000000000000000000105571506755107600230370ustar00rootroot00000000000000# serializer version: 1 # name: test_authorize[authorize_account_id] 123456789 # --- # name: test_authorize[authorize_account_ids] list([ 123456789, ]) # --- # name: test_authorize[authorize_customer_id] '5cafe9c4-a942-46b5-a67c-5882eba0a03c' # --- # name: test_authorize[authorize_oauth_access_token] 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1Y2FmZTljNC1hOTQyLTQ2YjUtYTY3Yy01ODgyZWJhMGEwM2MiLCJwZXJtaXNzaW9ucyI6WyJhY2NvdW50OjoxMjM0NTY3ODkiXSwiaWF0IjoxNjQwOTk1MjAwLCJleHAiOjE2NDA5OTg4MDB9.ZXZcYyG6vT0NMdKlUy9KDnCj4DJyC7o3rX_AmPef6hw' # --- # name: test_authorize[authorize_oauth_expires_in] 3600 # --- # name: test_authorize[authorize_oauth_refresh_expires_in] 0 # --- # name: test_authorize[authorize_username] 'test' # --- # name: test_bootstrap[bootstrap_accounts] BootstrapAccounts(account_ids=[123456789], customer_id='5cafe9c4-a942-46b5-a67c-5882eba0a03c', selected_account_id=123456789, accounts=[Account(account_id=123456789, is_payg=None, is_blocked=None, supplies=[Supply(mpxn='3456766576', fuel='gas', is_onboarding=False, start=None, is_payg=False, supply_point_info=SupplyPointInfo(meter_type='AB123', meter_not_found=False, address=['ADDR', 'SW1A 1AA'])), Supply(mpxn='4536756746', fuel='electricity', is_onboarding=False, start=None, is_payg=False, supply_point_info=SupplyPointInfo(meter_type='AB123', meter_not_found=False, address=['ADDR', 'SW1A 1AA']))])], is_first_login=False) # --- # name: test_bootstrap_custom_account[bootstrap_accounts_custom_account] OVODailyUsage(electricity=[OVODailyElectricity(consumption=10.24, interval=OVOInterval(start=datetime.datetime(2024, 1, 1, 0, 0, tzinfo=datetime.timezone.utc), end=datetime.datetime(2024, 1, 1, 23, 59, 59, 999000, tzinfo=datetime.timezone.utc)), meter_readings=OVOMeterReadings(start='12345', end='67890'), has_half_hour_data=None, cost=OVOCost(amount='2.94', currency_unit='GBP'), rates=OVORates(anytime=0.25, standing=0.45))], gas=[OVODailyGas(consumption=14.68, volume=None, interval=OVOInterval(start=datetime.datetime(2024, 1, 1, 0, 0, tzinfo=datetime.timezone.utc), end=datetime.datetime(2024, 1, 1, 23, 59, 59, 999000, tzinfo=datetime.timezone.utc)), meter_readings=OVOMeterReadings(start='12345', end='67890'), has_half_hour_data=None, cost=OVOCost(amount='2.56', currency_unit='GBP'), rates=OVORates(anytime=0.18, standing=0.35))]) # --- # name: test_get_carbon_intensity[carbon_intensity] OVOCarbonIntensity(forecast=[OVOCarbonIntensityForecast(time_from='2pm', intensity=82, level='low', colour='#0A9928', colour_v2='#0D8426')], current='low', greentime=None) # --- # name: test_get_daily_usage[daily_usage] OVODailyUsage(electricity=[OVODailyElectricity(consumption=10.24, interval=OVOInterval(start=datetime.datetime(2024, 1, 1, 0, 0, tzinfo=datetime.timezone.utc), end=datetime.datetime(2024, 1, 1, 23, 59, 59, 999000, tzinfo=datetime.timezone.utc)), meter_readings=OVOMeterReadings(start='12345', end='67890'), has_half_hour_data=None, cost=OVOCost(amount='2.94', currency_unit='GBP'), rates=OVORates(anytime=0.25, standing=0.45))], gas=[OVODailyGas(consumption=14.68, volume=None, interval=OVOInterval(start=datetime.datetime(2024, 1, 1, 0, 0, tzinfo=datetime.timezone.utc), end=datetime.datetime(2024, 1, 1, 23, 59, 59, 999000, tzinfo=datetime.timezone.utc)), meter_readings=OVOMeterReadings(start='12345', end='67890'), has_half_hour_data=None, cost=OVOCost(amount='2.56', currency_unit='GBP'), rates=OVORates(anytime=0.18, standing=0.35))]) # --- # name: test_get_footprint[footprint] OVOFootprint(from_='2024-01-01T00:00:00Z', to='2024-01-01T23:59:59.999000Z', carbon_reduction_product_ids=[], carbon_footprint=OVOCarbonFootprint(carbon_kg=2200.1234, carbon_saved_kg=0.0, k_wh=1578.3246, breakdown=OVOFootprintBreakdown(electricity=OVOFootprintElectricity(carbon_kg=200.1234, carbon_saved_kg=230.02, k_wh=65645.92), gas=OVOFootprintGas(carbon_kg=2000.1234, carbon_saved_kg=340.02, k_wh=10664.74363579)))) # --- # name: test_get_half_hourly_usage[half_hourly_usage] OVOHalfHourUsage(electricity=[OVOHalfHour(consumption=0.5, interval=OVOInterval(start=datetime.datetime(2024, 1, 1, 0, 0, tzinfo=datetime.timezone.utc), end=datetime.datetime(2024, 1, 1, 0, 30, tzinfo=datetime.timezone.utc)), unit='kWh')], gas=[OVOHalfHour(consumption=0.2, interval=OVOInterval(start=datetime.datetime(2024, 1, 1, 0, 0, tzinfo=datetime.timezone.utc), end=datetime.datetime(2024, 1, 1, 0, 30, tzinfo=datetime.timezone.utc)), unit='mΒ³')]) # --- ovoenergy-3.0.2/tests/conftest.py000066400000000000000000000044431506755107600171130ustar00rootroot00000000000000"""Fixtures for testing.""" from collections.abc import AsyncGenerator from aiohttp import ClientSession from aioresponses import aioresponses import pytest from ovoenergy import OVOEnergy from ovoenergy.const import ( AUTH_LOGIN_URL, AUTH_TOKEN_URL, BOOTSTRAP_GRAPHQL_URL, CARBON_FOOTPRINT_URL, CARBON_INTENSITY_URL, USAGE_DAILY_URL, USAGE_HALF_HOURLY_URL, ) from . import ( ACCOUNT, ACCOUNT_BAD, RESPONSE_JSON_AUTH, RESPONSE_JSON_BOOTSTRAP_ACCOUNTS, RESPONSE_JSON_DAILY_USAGE, RESPONSE_JSON_FOOTPRINT, RESPONSE_JSON_HALF_HOURLY_USAGE, RESPONSE_JSON_INTENSITY, RESPONSE_JSON_TOKEN, ) @pytest.fixture(autouse=True) def mock_aioresponse(): """Return a client session.""" with aioresponses() as mocker: mocker.post( AUTH_LOGIN_URL, payload=RESPONSE_JSON_AUTH, status=200, repeat=True, ) mocker.get( AUTH_TOKEN_URL, payload=RESPONSE_JSON_TOKEN, status=200, repeat=True, ) mocker.post( BOOTSTRAP_GRAPHQL_URL, payload=RESPONSE_JSON_BOOTSTRAP_ACCOUNTS, status=200, repeat=True, ) mocker.get( f"{USAGE_DAILY_URL}/{ACCOUNT}?date=2024-01", payload=RESPONSE_JSON_DAILY_USAGE, status=200, repeat=True, ) mocker.get( f"{USAGE_HALF_HOURLY_URL}/{ACCOUNT}?date=2024-01-01", payload=RESPONSE_JSON_HALF_HOURLY_USAGE, status=200, repeat=True, ) mocker.get( f"{CARBON_FOOTPRINT_URL}/{ACCOUNT}/footprint", payload=RESPONSE_JSON_FOOTPRINT, status=200, repeat=True, ) mocker.get( f"{USAGE_DAILY_URL}/{ACCOUNT_BAD}?date=2024-01", status=404, repeat=True, ) mocker.get( CARBON_INTENSITY_URL, payload=RESPONSE_JSON_INTENSITY, status=200, repeat=True, ) yield mocker @pytest.fixture async def ovoenergy_client() -> AsyncGenerator[OVOEnergy, None]: """Return a OVOEnergy client.""" async with ClientSession() as session: yield OVOEnergy(client_session=session) ovoenergy-3.0.2/tests/test__init__.py000066400000000000000000000211321506755107600177170ustar00rootroot00000000000000"""Tests for the client module.""" from datetime import datetime, timedelta from aioresponses import aioresponses import pytest from syrupy.assertion import SnapshotAssertion from ovoenergy import OVOEnergy from ovoenergy.const import AUTH_LOGIN_URL, AUTH_TOKEN_URL, USAGE_DAILY_URL from ovoenergy.exceptions import ( OVOEnergyAPINoCookies, OVOEnergyAPINotAuthorized, OVOEnergyAPINotFound, OVOEnergyNoAccount, ) from . import ACCOUNT, ACCOUNT_BAD, PASSWORD, RESPONSE_JSON_AUTH, USERNAME @pytest.mark.asyncio async def test_authorize( ovoenergy_client: OVOEnergy, mock_aioresponse: aioresponses, snapshot: SnapshotAssertion, ) -> None: """Test authorize.""" await ovoenergy_client.authenticate(USERNAME, PASSWORD) assert not ovoenergy_client.oauth_expired assert ovoenergy_client.oauth is not None assert ovoenergy_client.oauth.access_token == snapshot( name="authorize_oauth_access_token", ) assert ovoenergy_client.oauth.expires_in == snapshot( name="authorize_oauth_expires_in", ) assert ovoenergy_client.oauth.refresh_expires_in == snapshot( name="authorize_oauth_refresh_expires_in", ) assert ovoenergy_client.account_id == snapshot( name="authorize_account_id", ) assert ovoenergy_client.account_ids == snapshot( name="authorize_account_ids", ) assert ovoenergy_client.customer_id == snapshot( name="authorize_customer_id", ) assert ovoenergy_client.username == snapshot( name="authorize_username", ) @pytest.mark.asyncio async def test_bootstrap( ovoenergy_client: OVOEnergy, mock_aioresponse: aioresponses, snapshot: SnapshotAssertion, ) -> None: """Test bootstrap.""" await ovoenergy_client.authenticate(USERNAME, PASSWORD) assert await ovoenergy_client.bootstrap_accounts() == snapshot( name="bootstrap_accounts", ) @pytest.mark.asyncio async def test_get_daily_usage( ovoenergy_client: OVOEnergy, mock_aioresponse: aioresponses, snapshot: SnapshotAssertion, ) -> None: """Test get daily usage.""" with pytest.raises(OVOEnergyNoAccount): await ovoenergy_client.get_daily_usage("2024-01") await ovoenergy_client.authenticate(USERNAME, PASSWORD) await ovoenergy_client.bootstrap_accounts() assert await ovoenergy_client.get_daily_usage("2024-01") == snapshot( name="daily_usage", ) @pytest.mark.asyncio async def test_get_half_hourly_usage( ovoenergy_client: OVOEnergy, mock_aioresponse: aioresponses, snapshot: SnapshotAssertion, ) -> None: """Test get half hourly usage.""" with pytest.raises(OVOEnergyNoAccount): await ovoenergy_client.get_half_hourly_usage("2024-01-01") await ovoenergy_client.authenticate(USERNAME, PASSWORD) await ovoenergy_client.bootstrap_accounts() assert await ovoenergy_client.get_half_hourly_usage("2024-01-01") == snapshot( name="half_hourly_usage", ) @pytest.mark.asyncio async def test_get_footprint( ovoenergy_client: OVOEnergy, mock_aioresponse: aioresponses, snapshot: SnapshotAssertion, ) -> None: """Test get footprint.""" with pytest.raises(OVOEnergyNoAccount): await ovoenergy_client.get_footprint() await ovoenergy_client.authenticate(USERNAME, PASSWORD) await ovoenergy_client.bootstrap_accounts() assert await ovoenergy_client.get_footprint() == snapshot( name="footprint", ) @pytest.mark.asyncio async def test_get_carbon_intensity( ovoenergy_client: OVOEnergy, mock_aioresponse: aioresponses, snapshot: SnapshotAssertion, ) -> None: """Test get carbon intensity.""" await ovoenergy_client.authenticate(USERNAME, PASSWORD) assert await ovoenergy_client.get_carbon_intensity() == snapshot( name="carbon_intensity", ) @pytest.mark.asyncio async def test_bootstrap_custom_account( ovoenergy_client: OVOEnergy, mock_aioresponse: aioresponses, snapshot: SnapshotAssertion, ) -> None: """Test bootstrap custom account.""" await ovoenergy_client.authenticate(USERNAME, PASSWORD) ovoenergy_client.custom_account_id = ACCOUNT await ovoenergy_client.bootstrap_accounts() assert await ovoenergy_client.get_daily_usage("2024-01") == snapshot( name="bootstrap_accounts_custom_account", ) @pytest.mark.asyncio async def test_bad_account( ovoenergy_client: OVOEnergy, mock_aioresponse: aioresponses, ) -> None: """Test bad account.""" await ovoenergy_client.authenticate(USERNAME, PASSWORD) await ovoenergy_client.bootstrap_accounts() ovoenergy_client.custom_account_id = ACCOUNT_BAD with pytest.raises(OVOEnergyAPINotFound): await ovoenergy_client.get_daily_usage("2024-01") # pylint: disable=protected-access @pytest.mark.asyncio async def test_no_cookies( ovoenergy_client: OVOEnergy, mock_aioresponse: aioresponses, ) -> None: """Test no cookies.""" await ovoenergy_client.authenticate(USERNAME, PASSWORD) await ovoenergy_client.bootstrap_accounts() ovoenergy_client._cookies = None with pytest.raises(OVOEnergyAPINoCookies): await ovoenergy_client.get_daily_usage("2024-01") # pylint: disable=protected-access @pytest.mark.asyncio async def test_no_auth( ovoenergy_client: OVOEnergy, mock_aioresponse: aioresponses, ) -> None: """Test no auth.""" await ovoenergy_client.authenticate(USERNAME, PASSWORD) await ovoenergy_client.bootstrap_accounts() ovoenergy_client._oauth = None with pytest.raises(OVOEnergyAPINotAuthorized): await ovoenergy_client.get_daily_usage("2024-01") # pylint: disable=protected-access @pytest.mark.asyncio async def test_oauth_expired( ovoenergy_client: OVOEnergy, mock_aioresponse: aioresponses, ) -> None: """Test oauth expired.""" an_hour_ago = datetime.now() - timedelta(hours=1) await ovoenergy_client.authenticate(USERNAME, PASSWORD) await ovoenergy_client.bootstrap_accounts() assert ovoenergy_client._oauth is not None ovoenergy_client._oauth.expires_at = an_hour_ago await ovoenergy_client.get_daily_usage("2024-01") ovoenergy_client._oauth.expires_at = an_hour_ago mock_aioresponse.clear() mock_aioresponse.get( AUTH_TOKEN_URL, status=403, repeat=True, ) with pytest.raises(OVOEnergyAPINotAuthorized): await ovoenergy_client.get_daily_usage("2024-01") @pytest.mark.asyncio async def test_forbidden( ovoenergy_client: OVOEnergy, mock_aioresponse: aioresponses, ) -> None: """Test forbidden.""" await ovoenergy_client.authenticate(USERNAME, PASSWORD) await ovoenergy_client.bootstrap_accounts() mock_aioresponse.clear() mock_aioresponse.get( f"{USAGE_DAILY_URL}/{ACCOUNT}?date=2024-01", status=403, repeat=True, ) with pytest.raises(OVOEnergyAPINotAuthorized): await ovoenergy_client.get_daily_usage("2024-01") @pytest.mark.asyncio async def test_auth_not_found( ovoenergy_client: OVOEnergy, mock_aioresponse: aioresponses, ) -> None: """Test auth endpoint not found.""" mock_aioresponse.clear() mock_aioresponse.post( AUTH_LOGIN_URL, status=404, repeat=True, ) with pytest.raises(OVOEnergyAPINotFound): await ovoenergy_client.authenticate(USERNAME, PASSWORD) @pytest.mark.asyncio async def test_auth_code_not_found( ovoenergy_client: OVOEnergy, mock_aioresponse: aioresponses, ) -> None: """Test auth endpoint code not found.""" mock_aioresponse.clear() mock_aioresponse.post( AUTH_LOGIN_URL, status=204, repeat=True, ) assert not await ovoenergy_client.authenticate(USERNAME, PASSWORD) @pytest.mark.asyncio async def test_auth_code_unknown( ovoenergy_client: OVOEnergy, mock_aioresponse: aioresponses, ) -> None: """Test auth token not found.""" mock_aioresponse.clear() mock_aioresponse.post( AUTH_LOGIN_URL, status=200, payload={"code": "Unknown"}, repeat=True, ) assert not await ovoenergy_client.authenticate(USERNAME, PASSWORD) @pytest.mark.asyncio async def test_auth_token_not_found( ovoenergy_client: OVOEnergy, mock_aioresponse: aioresponses, ) -> None: """Test auth token not found.""" mock_aioresponse.clear() mock_aioresponse.post( AUTH_LOGIN_URL, payload=RESPONSE_JSON_AUTH, status=200, repeat=True, ) mock_aioresponse.get( AUTH_TOKEN_URL, status=404, repeat=True, ) with pytest.raises(OVOEnergyAPINotFound): await ovoenergy_client.authenticate(USERNAME, PASSWORD)