Skip to content

Commit

Permalink
Initial version
Browse files Browse the repository at this point in the history
  • Loading branch information
tronikos committed Mar 27, 2023
1 parent c236412 commit 76dd65d
Show file tree
Hide file tree
Showing 13 changed files with 934 additions and 1 deletion.
45 changes: 45 additions & 0 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# https://github.com/actions/starter-workflows/blob/main/ci/python-package.yml
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

name: Python package

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_dispatch:

jobs:
build:

runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]

steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install .
python -m pip install flake8 pytest ruff
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
# exit-zero treats all errors as warnings.
flake8 . --count --exit-zero --show-source --statistics
- name: Lint with ruff
run: |
ruff .
- name: Test with pytest
run: |
pytest
40 changes: 40 additions & 0 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# https://github.com/actions/starter-workflows/blob/main/ci/python-publish.yml
# This workflow will upload a Python Package using Twine when a release is created
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.

name: Upload Python Package

on:
release:
types: [published]

permissions:
contents: read

jobs:
deploy:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install build
- name: Build package
run: python -m build
- name: Publish package
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,46 @@
# opower
Python library to get historical and forecasted usage/cost from utilities that use opower.com such as PG&E
A Python library for getting historical and forecasted usage/cost from utilities that use opower.com such as PG&E.

To add support for a new utility that uses opower JSON API (you can tell if the energy dashboard of your utility is hosted on opower.com, e.g. pge.opower.com) add a file similar to [pge.py](https://github.com/tronikos/opower/blob/main/src/opower/utilities/pge.py).

## Example

See [demo.py](https://github.com/tronikos/opower/blob/main/src/demo.py)

## Development environment

```sh
python3 -m venv .venv
source .venv/bin/activate
# for Windows CMD:
# .venv\Scripts\activate.bat
# for Windows PowerShell:
# .venv\Scripts\Activate.ps1

# Install dependencies
python -m pip install --upgrade pip
python -m pip install .

# Run formatter
python -m pip install isort black
isort .
black .

# Run lint
python -m pip install flake8 ruff
flake8 .
ruff .

# Run tests
python -m pip install pytest
pytest

# Run demo
python src/demo.py --help
# To output debug logs to a file:
python src/demo.py --verbose 2> out.txt

# Build package
python -m pip install build
python -m build
```
78 changes: 78 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
[project]
name = "opower"
version = "0.0.1"
license = {text = "Apache-2.0"}
authors = [
{ name="tronikos", email="[email protected]" },
]
description = "A Python library for getting historical and forecasted usage/cost from utilities that use opower.com such as PG&E"
readme = "README.md"
requires-python = ">=3.7"
dependencies = [
"aiohttp>=3.8",
"asyncio>=3.4.3",
]

[project.urls]
"Homepage" = "https://github.com/tronikos/opower"
"Bug Tracker" = "https://github.com/tronikos/opower/issues"

[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

[tool.black]
extend-exclude = "_pb2.py|_pb2_grpc.py"

[tool.isort]
profile = "black"
force_sort_within_sections = true
combine_as_imports = true
extend_skip_glob = ["*_pb2.py", "*_pb2_grpc.py"]

[tool.ruff]
target-version = "py311"
exclude = ["*_pb2.py", "*_pb2_grpc.py", "*.pyi"]
line-length = 127

select = [
"B007", # Loop control variable {name} not used within loop body
"B014", # Exception handler with duplicate exception
"C", # complexity
"D", # docstrings
"E", # pycodestyle
"F", # pyflakes/autoflake
"ICN001", # import concentions; {name} should be imported as {asname}
"PGH004", # Use specific rule codes when using noqa
"PLC0414", # Useless import alias. Import alias does not rename original package.
"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}
"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
"T20", # flake8-print
"TRY004", # Prefer TypeError exception for invalid type
"RUF006", # Store a reference to the return value of asyncio.create_task
"UP", # pyupgrade
"W", # pycodestyle
]

ignore = [
"D203", # 1 blank line required before class docstring
"D213", # Multi-line docstring summary should start at the second line
]

[tool.ruff.flake8-pytest-style]
fixture-parentheses = false

[tool.ruff.pyupgrade]
keep-runtime-typing = true

[tool.ruff.per-file-ignores]
# Allow for demo script to write to stdout
"demo.py" = ["T201"]

[tool.ruff.mccabe]
max-complexity = 25
17 changes: 17 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[flake8]
exclude = .venv,.git,docs,venv,bin,lib,deps,build,*_pb2.py,*_pb2_grpc.py
max-complexity = 25
doctests = True
# To work with Black
# E501: line too long
# W503: Line break occurred before a binary operator
# E203: Whitespace before ':'
# D202 No blank lines allowed after function docstring
# W504 line break after binary operator
ignore =
E501,
W503,
E203,
D202,
W504
noqa-require-code = True
138 changes: 138 additions & 0 deletions src/demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Demo usage of Opower library."""

import argparse
import asyncio
from datetime import datetime, timedelta
from getpass import getpass
import logging

import aiohttp

from opower import AggregateType, Opower, get_supported_utility_subdomains


async def _main():
parser = argparse.ArgumentParser()
parser.add_argument(
"--utility",
help="Utility (subdomain of opower.com). Defaults to pge",
choices=get_supported_utility_subdomains(),
default="pge",
)
parser.add_argument(
"--username",
help="Username for logging into the utility's website. "
"If not provided, you will be asked for it",
)
parser.add_argument(
"--password",
help="Password for logging into the utility's website. "
"If not provided, you will be asked for it",
)
parser.add_argument(
"--aggregate_type",
help="How to aggregate historical data. Defaults to day",
choices=list(AggregateType),
default=AggregateType.DAY,
)
parser.add_argument(
"--start_date",
help="Start datetime for historical data. Defaults to 7 days ago",
type=lambda s: datetime.fromisoformat(s),
default=datetime.now() - timedelta(days=7),
)
parser.add_argument(
"--end_date",
help="end datetime for historical data. Defaults to now",
type=lambda s: datetime.fromisoformat(s),
default=datetime.now(),
)
parser.add_argument(
"--usage_only",
help="If true will output usage only, not cost",
action="store_true",
)
parser.add_argument(
"-v", "--verbose", help="enable verbose logging", action="store_true"
)
args = parser.parse_args()

logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO)

username = args.username or input("Username: ")
password = args.password or getpass("Password: ")

async with aiohttp.ClientSession() as session:
opower = Opower(session, args.utility, username, password)
await opower.async_login()
forecasts = await opower.async_get_forecast()
for forecast in forecasts:
print("\nData for meter:", forecast.account.meter_type)
print("\nCurrent bill forecast:", forecast)
print(
"\nGetting historical data: aggregate_type=",
args.aggregate_type,
"start_date=",
args.start_date,
"end_date=",
args.end_date,
)
if args.usage_only:
usage_data = await opower.async_get_usage_reads(
forecast.account,
args.aggregate_type,
args.start_date,
args.end_date,
)
prev_end = None
print(
"start_time\tend_time\tconsumption"
"\tstart_minus_prev_end\tend_minus_prev_end"
)
for usage_read in usage_data:
start_minus_prev_end = (
None if prev_end is None else usage_read.start_time - prev_end
)
end_minus_prev_end = (
None if prev_end is None else usage_read.end_time - prev_end
)
prev_end = usage_read.end_time
print(
f"{usage_read.start_time}"
f"\t{usage_read.end_time}"
f"\t{usage_read.consumption}"
f"\t{start_minus_prev_end}"
f"\t{end_minus_prev_end}"
)
else:
cost_data = await opower.async_get_cost_reads(
forecast.account,
args.aggregate_type,
args.start_date,
args.end_date,
)
prev_end = None
print(
"start_time\tend_time\tconsumption\tprovided_cost"
"\tstart_minus_prev_end\tend_minus_prev_end"
)
for cost_read in cost_data:
start_minus_prev_end = (
None if prev_end is None else cost_read.start_time - prev_end
)
end_minus_prev_end = (
None if prev_end is None else cost_read.end_time - prev_end
)
prev_end = cost_read.end_time
print(
f"{cost_read.start_time}"
f"\t{cost_read.end_time}"
f"\t{cost_read.consumption}"
f"\t{cost_read.provided_cost}"
f"\t{start_minus_prev_end}"
f"\t{end_minus_prev_end}"
)
print()


asyncio.run(_main())
27 changes: 27 additions & 0 deletions src/opower/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Library for getting historical and forecasted usage/cost from an utility using opower.com JSON API."""

from .opower import (
Account,
AggregateType,
CostRead,
Forecast,
MeterType,
Opower,
UnitOfMeasure,
UsageRead,
get_supported_utility_names,
get_supported_utility_subdomains,
)

__all__ = [
"Account",
"AggregateType",
"CostRead",
"Forecast",
"MeterType",
"Opower",
"UnitOfMeasure",
"UsageRead",
"get_supported_utility_names",
"get_supported_utility_subdomains",
]
Loading

0 comments on commit 76dd65d

Please sign in to comment.