pgw-api 1.1.0__tar.gz → 1.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pgw_api-1.2.0/LICENSE +21 -0
- {pgw_api-1.1.0 → pgw_api-1.2.0}/PKG-INFO +44 -2
- {pgw_api-1.1.0 → pgw_api-1.2.0}/README.md +41 -1
- {pgw_api-1.1.0 → pgw_api-1.2.0}/pgw_api/__init__.py +3 -1
- {pgw_api-1.1.0 → pgw_api-1.2.0}/pgw_api/client.py +187 -1
- {pgw_api-1.1.0 → pgw_api-1.2.0}/pgw_api/models.py +27 -1
- {pgw_api-1.1.0 → pgw_api-1.2.0}/pgw_api.egg-info/PKG-INFO +44 -2
- {pgw_api-1.1.0 → pgw_api-1.2.0}/pgw_api.egg-info/SOURCES.txt +5 -1
- {pgw_api-1.1.0 → pgw_api-1.2.0}/pyproject.toml +4 -1
- pgw_api-1.2.0/tests/test_client.py +199 -0
- pgw_api-1.2.0/tests/test_models.py +36 -0
- pgw_api-1.2.0/tests/test_parsing.py +69 -0
- {pgw_api-1.1.0 → pgw_api-1.2.0}/pgw_api/exceptions.py +0 -0
- {pgw_api-1.1.0 → pgw_api-1.2.0}/pgw_api.egg-info/dependency_links.txt +0 -0
- {pgw_api-1.1.0 → pgw_api-1.2.0}/pgw_api.egg-info/requires.txt +0 -0
- {pgw_api-1.1.0 → pgw_api-1.2.0}/pgw_api.egg-info/top_level.txt +0 -0
- {pgw_api-1.1.0 → pgw_api-1.2.0}/setup.cfg +0 -0
pgw_api-1.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zackwag
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pgw-api
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Python API client for Philadelphia Gas Works (PGW)
|
|
5
5
|
Author: zackwag
|
|
6
6
|
License: MIT
|
|
@@ -8,11 +8,13 @@ Project-URL: Homepage, https://github.com/zackwag/pgw-api
|
|
|
8
8
|
Project-URL: Issues, https://github.com/zackwag/pgw-api/issues
|
|
9
9
|
Requires-Python: >=3.12
|
|
10
10
|
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
11
12
|
Requires-Dist: aiohttp>=3.9.0
|
|
13
|
+
Dynamic: license-file
|
|
12
14
|
|
|
13
15
|
# pgw-api
|
|
14
16
|
|
|
15
|
-
Python API client for [Philadelphia Gas Works (PGW)](https://www.pgworks.com/). Fetches
|
|
17
|
+
Python API client for [Philadelphia Gas Works (PGW)](https://www.pgworks.com/). Fetches natural gas usage data (monthly, daily, and hourly) from the PGW customer portal.
|
|
16
18
|
|
|
17
19
|
## Installation
|
|
18
20
|
|
|
@@ -51,6 +53,30 @@ Creates a client instance with PGW portal credentials.
|
|
|
51
53
|
|
|
52
54
|
Authenticates and returns a list of `GasUsage` objects sorted by month (newest first).
|
|
53
55
|
|
|
56
|
+
### `await client.async_get_daily_usage(session, start, end)`
|
|
57
|
+
|
|
58
|
+
Authenticates and returns a list of `DailyGasUsage` objects for the given date range (newest first). Requires a smart meter — most residential accounts only have monthly data and will return an empty list.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from datetime import date
|
|
62
|
+
|
|
63
|
+
daily = await client.async_get_daily_usage(session, date(2024, 1, 1), date(2024, 1, 31))
|
|
64
|
+
for entry in daily:
|
|
65
|
+
print(f"{entry.date}: {entry.ccf} CCF")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### `await client.async_get_hourly_usage(session, usage_date)`
|
|
69
|
+
|
|
70
|
+
Authenticates and returns a list of `HourlyGasUsage` objects for a single day (newest first). Requires a smart meter that reports interval data. Most residential accounts only have monthly data and will return an empty list.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from datetime import date
|
|
74
|
+
|
|
75
|
+
hourly = await client.async_get_hourly_usage(session, date(2024, 1, 15))
|
|
76
|
+
for entry in hourly:
|
|
77
|
+
print(f"{entry.timestamp}: {entry.ccf} CCF")
|
|
78
|
+
```
|
|
79
|
+
|
|
54
80
|
### `await client.async_validate_credentials(session)`
|
|
55
81
|
|
|
56
82
|
Validates credentials without fetching usage data. Returns `True` or raises `PGWAuthError`.
|
|
@@ -65,6 +91,22 @@ Validates credentials without fetching usage data. Returns `True` or raises `PGW
|
|
|
65
91
|
| `period_start` | `date \| None` | Meter read start date |
|
|
66
92
|
| `period_end` | `date \| None` | Meter read end date |
|
|
67
93
|
|
|
94
|
+
### `DailyGasUsage`
|
|
95
|
+
|
|
96
|
+
| Property | Type | Description |
|
|
97
|
+
|----------|------|-------------|
|
|
98
|
+
| `date` | `date` | Usage date |
|
|
99
|
+
| `ccf` | `float` | Usage in hundreds of cubic feet |
|
|
100
|
+
| `cf` | `float` | Usage in cubic feet (CCF × 100) |
|
|
101
|
+
|
|
102
|
+
### `HourlyGasUsage`
|
|
103
|
+
|
|
104
|
+
| Property | Type | Description |
|
|
105
|
+
|----------|------|-------------|
|
|
106
|
+
| `timestamp` | `datetime` | Usage timestamp |
|
|
107
|
+
| `ccf` | `float` | Usage in hundreds of cubic feet |
|
|
108
|
+
| `cf` | `float` | Usage in cubic feet (CCF × 100) |
|
|
109
|
+
|
|
68
110
|
### Exceptions
|
|
69
111
|
|
|
70
112
|
- `PGWAuthError` — invalid credentials or expired session
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pgw-api
|
|
2
2
|
|
|
3
|
-
Python API client for [Philadelphia Gas Works (PGW)](https://www.pgworks.com/). Fetches
|
|
3
|
+
Python API client for [Philadelphia Gas Works (PGW)](https://www.pgworks.com/). Fetches natural gas usage data (monthly, daily, and hourly) from the PGW customer portal.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -39,6 +39,30 @@ Creates a client instance with PGW portal credentials.
|
|
|
39
39
|
|
|
40
40
|
Authenticates and returns a list of `GasUsage` objects sorted by month (newest first).
|
|
41
41
|
|
|
42
|
+
### `await client.async_get_daily_usage(session, start, end)`
|
|
43
|
+
|
|
44
|
+
Authenticates and returns a list of `DailyGasUsage` objects for the given date range (newest first). Requires a smart meter — most residential accounts only have monthly data and will return an empty list.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from datetime import date
|
|
48
|
+
|
|
49
|
+
daily = await client.async_get_daily_usage(session, date(2024, 1, 1), date(2024, 1, 31))
|
|
50
|
+
for entry in daily:
|
|
51
|
+
print(f"{entry.date}: {entry.ccf} CCF")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### `await client.async_get_hourly_usage(session, usage_date)`
|
|
55
|
+
|
|
56
|
+
Authenticates and returns a list of `HourlyGasUsage` objects for a single day (newest first). Requires a smart meter that reports interval data. Most residential accounts only have monthly data and will return an empty list.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from datetime import date
|
|
60
|
+
|
|
61
|
+
hourly = await client.async_get_hourly_usage(session, date(2024, 1, 15))
|
|
62
|
+
for entry in hourly:
|
|
63
|
+
print(f"{entry.timestamp}: {entry.ccf} CCF")
|
|
64
|
+
```
|
|
65
|
+
|
|
42
66
|
### `await client.async_validate_credentials(session)`
|
|
43
67
|
|
|
44
68
|
Validates credentials without fetching usage data. Returns `True` or raises `PGWAuthError`.
|
|
@@ -53,6 +77,22 @@ Validates credentials without fetching usage data. Returns `True` or raises `PGW
|
|
|
53
77
|
| `period_start` | `date \| None` | Meter read start date |
|
|
54
78
|
| `period_end` | `date \| None` | Meter read end date |
|
|
55
79
|
|
|
80
|
+
### `DailyGasUsage`
|
|
81
|
+
|
|
82
|
+
| Property | Type | Description |
|
|
83
|
+
|----------|------|-------------|
|
|
84
|
+
| `date` | `date` | Usage date |
|
|
85
|
+
| `ccf` | `float` | Usage in hundreds of cubic feet |
|
|
86
|
+
| `cf` | `float` | Usage in cubic feet (CCF × 100) |
|
|
87
|
+
|
|
88
|
+
### `HourlyGasUsage`
|
|
89
|
+
|
|
90
|
+
| Property | Type | Description |
|
|
91
|
+
|----------|------|-------------|
|
|
92
|
+
| `timestamp` | `datetime` | Usage timestamp |
|
|
93
|
+
| `ccf` | `float` | Usage in hundreds of cubic feet |
|
|
94
|
+
| `cf` | `float` | Usage in cubic feet (CCF × 100) |
|
|
95
|
+
|
|
56
96
|
### Exceptions
|
|
57
97
|
|
|
58
98
|
- `PGWAuthError` — invalid credentials or expired session
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from .client import PGWApiClient
|
|
4
4
|
from .exceptions import PGWAuthError, PGWConnectionError, PGWError
|
|
5
|
-
from .models import BillingSummary, GasUsage
|
|
5
|
+
from .models import BillingSummary, DailyGasUsage, GasUsage, HourlyGasUsage
|
|
6
6
|
|
|
7
7
|
__all__ = [
|
|
8
8
|
"BillingSummary",
|
|
9
|
+
"DailyGasUsage",
|
|
9
10
|
"GasUsage",
|
|
11
|
+
"HourlyGasUsage",
|
|
10
12
|
"PGWApiClient",
|
|
11
13
|
"PGWAuthError",
|
|
12
14
|
"PGWConnectionError",
|
|
@@ -9,7 +9,7 @@ from datetime import date
|
|
|
9
9
|
import aiohttp
|
|
10
10
|
|
|
11
11
|
from .exceptions import PGWAuthError, PGWConnectionError
|
|
12
|
-
from .models import BillingSummary, GasUsage
|
|
12
|
+
from .models import BillingSummary, DailyGasUsage, GasUsage, HourlyGasUsage
|
|
13
13
|
|
|
14
14
|
BASE_URL = "https://myaccount.pgworks.com/portal"
|
|
15
15
|
LOGIN_URL = f"{BASE_URL}/"
|
|
@@ -50,6 +50,29 @@ class PGWApiClient:
|
|
|
50
50
|
await self._authenticate(session)
|
|
51
51
|
return await self._load_billing(session)
|
|
52
52
|
|
|
53
|
+
async def async_get_daily_usage(
|
|
54
|
+
self,
|
|
55
|
+
session: aiohttp.ClientSession,
|
|
56
|
+
start: date,
|
|
57
|
+
end: date,
|
|
58
|
+
) -> list[DailyGasUsage]:
|
|
59
|
+
"""Authenticate and fetch daily gas usage for a date range."""
|
|
60
|
+
await self._establish_session(session)
|
|
61
|
+
await self._authenticate(session)
|
|
62
|
+
csrf_token = await self._get_csrf_token(session)
|
|
63
|
+
return await self._load_daily_gas_usage(session, csrf_token, start, end)
|
|
64
|
+
|
|
65
|
+
async def async_get_hourly_usage(
|
|
66
|
+
self,
|
|
67
|
+
session: aiohttp.ClientSession,
|
|
68
|
+
usage_date: date,
|
|
69
|
+
) -> list[HourlyGasUsage]:
|
|
70
|
+
"""Authenticate and fetch hourly gas usage for a single day."""
|
|
71
|
+
await self._establish_session(session)
|
|
72
|
+
await self._authenticate(session)
|
|
73
|
+
csrf_token = await self._get_csrf_token(session)
|
|
74
|
+
return await self._load_hourly_gas_usage(session, csrf_token, usage_date)
|
|
75
|
+
|
|
53
76
|
async def async_get_all(
|
|
54
77
|
self, session: aiohttp.ClientSession
|
|
55
78
|
) -> tuple[list[GasUsage], BillingSummary]:
|
|
@@ -304,6 +327,169 @@ class PGWApiClient:
|
|
|
304
327
|
|
|
305
328
|
return sorted(results, key=lambda u: u.month, reverse=True)
|
|
306
329
|
|
|
330
|
+
async def _load_daily_gas_usage(
|
|
331
|
+
self,
|
|
332
|
+
session: aiohttp.ClientSession,
|
|
333
|
+
csrf_token: str,
|
|
334
|
+
start: date,
|
|
335
|
+
end: date,
|
|
336
|
+
) -> list[DailyGasUsage]:
|
|
337
|
+
"""Call LoadGasUsage in daily mode for a date range."""
|
|
338
|
+
payload = {
|
|
339
|
+
"Type": "C",
|
|
340
|
+
"Mode": "D",
|
|
341
|
+
"strDate": "",
|
|
342
|
+
"hourlyType": "",
|
|
343
|
+
"seasonId": "",
|
|
344
|
+
"weatherOverlay": "0",
|
|
345
|
+
"usageyear": "",
|
|
346
|
+
"MeterNumber": "",
|
|
347
|
+
"DateFromDaily": start.strftime("%m/%d/%y"),
|
|
348
|
+
"DateToDaily": end.strftime("%m/%d/%y"),
|
|
349
|
+
"HistID": "0",
|
|
350
|
+
"requiredDataType": 0,
|
|
351
|
+
}
|
|
352
|
+
headers = {
|
|
353
|
+
**_HEADERS,
|
|
354
|
+
"Referer": f"{USAGE_URL}?type=GU",
|
|
355
|
+
"CSRFToken": csrf_token,
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
async with session.post(
|
|
360
|
+
LOAD_GAS_URL, json=payload, headers=headers
|
|
361
|
+
) as resp:
|
|
362
|
+
if resp.status != 200:
|
|
363
|
+
raise PGWConnectionError(
|
|
364
|
+
f"LoadGasUsage (daily) returned status {resp.status}"
|
|
365
|
+
)
|
|
366
|
+
body = await resp.text()
|
|
367
|
+
except aiohttp.ClientError as err:
|
|
368
|
+
raise PGWConnectionError(
|
|
369
|
+
f"Failed to fetch daily gas usage: {err}"
|
|
370
|
+
) from err
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
data = json.loads(body)
|
|
374
|
+
inner = json.loads(data["d"])
|
|
375
|
+
except (json.JSONDecodeError, KeyError) as err:
|
|
376
|
+
raise PGWConnectionError(
|
|
377
|
+
"Unexpected response from LoadGasUsage (daily)"
|
|
378
|
+
) from err
|
|
379
|
+
|
|
380
|
+
if isinstance(inner, dict) and "dtException" in inner:
|
|
381
|
+
msg = inner["dtException"][0].get("MessageInformation", "Unknown error")
|
|
382
|
+
if "CSRF" in msg:
|
|
383
|
+
raise PGWAuthError("CSRF token invalid - session may have expired")
|
|
384
|
+
raise PGWConnectionError(msg)
|
|
385
|
+
|
|
386
|
+
usage_entries = inner.get("objUsageGenerationResultSetTwo", [])
|
|
387
|
+
if not usage_entries:
|
|
388
|
+
return []
|
|
389
|
+
|
|
390
|
+
results: list[DailyGasUsage] = []
|
|
391
|
+
for entry in usage_entries:
|
|
392
|
+
entry_date = _parse_date(entry.get("FromDate"))
|
|
393
|
+
ccf = entry.get("UsageValue")
|
|
394
|
+
|
|
395
|
+
if entry_date is None or ccf is None:
|
|
396
|
+
continue
|
|
397
|
+
|
|
398
|
+
results.append(DailyGasUsage(date=entry_date, ccf=float(ccf)))
|
|
399
|
+
|
|
400
|
+
return sorted(results, key=lambda u: u.date, reverse=True)
|
|
401
|
+
|
|
402
|
+
async def _load_hourly_gas_usage(
|
|
403
|
+
self,
|
|
404
|
+
session: aiohttp.ClientSession,
|
|
405
|
+
csrf_token: str,
|
|
406
|
+
usage_date: date,
|
|
407
|
+
) -> list[HourlyGasUsage]:
|
|
408
|
+
"""Call LoadGasUsage in daily mode with hourlyType to get hourly data."""
|
|
409
|
+
from datetime import datetime
|
|
410
|
+
|
|
411
|
+
payload = {
|
|
412
|
+
"Type": "C",
|
|
413
|
+
"Mode": "D",
|
|
414
|
+
"strDate": usage_date.strftime("%m/%d/%y"),
|
|
415
|
+
"hourlyType": "H",
|
|
416
|
+
"seasonId": "",
|
|
417
|
+
"weatherOverlay": "0",
|
|
418
|
+
"usageyear": "",
|
|
419
|
+
"MeterNumber": "",
|
|
420
|
+
"DateFromDaily": "",
|
|
421
|
+
"DateToDaily": "",
|
|
422
|
+
"HistID": "0",
|
|
423
|
+
"requiredDataType": 0,
|
|
424
|
+
}
|
|
425
|
+
headers = {
|
|
426
|
+
**_HEADERS,
|
|
427
|
+
"Referer": f"{USAGE_URL}?type=GU",
|
|
428
|
+
"CSRFToken": csrf_token,
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
async with session.post(
|
|
433
|
+
LOAD_GAS_URL, json=payload, headers=headers
|
|
434
|
+
) as resp:
|
|
435
|
+
if resp.status != 200:
|
|
436
|
+
raise PGWConnectionError(
|
|
437
|
+
f"LoadGasUsage (hourly) returned status {resp.status}"
|
|
438
|
+
)
|
|
439
|
+
body = await resp.text()
|
|
440
|
+
except aiohttp.ClientError as err:
|
|
441
|
+
raise PGWConnectionError(
|
|
442
|
+
f"Failed to fetch hourly gas usage: {err}"
|
|
443
|
+
) from err
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
data = json.loads(body)
|
|
447
|
+
inner = json.loads(data["d"])
|
|
448
|
+
except (json.JSONDecodeError, KeyError) as err:
|
|
449
|
+
raise PGWConnectionError(
|
|
450
|
+
"Unexpected response from LoadGasUsage (hourly)"
|
|
451
|
+
) from err
|
|
452
|
+
|
|
453
|
+
if isinstance(inner, dict) and "dtException" in inner:
|
|
454
|
+
msg = inner["dtException"][0].get("MessageInformation", "Unknown error")
|
|
455
|
+
if "CSRF" in msg:
|
|
456
|
+
raise PGWAuthError("CSRF token invalid - session may have expired")
|
|
457
|
+
raise PGWConnectionError(msg)
|
|
458
|
+
|
|
459
|
+
usage_entries = inner.get("objUsageGenerationResultSetTwo", [])
|
|
460
|
+
if not usage_entries:
|
|
461
|
+
return []
|
|
462
|
+
|
|
463
|
+
results: list[HourlyGasUsage] = []
|
|
464
|
+
for entry in usage_entries:
|
|
465
|
+
ts = _parse_datetime(entry.get("FromDate"))
|
|
466
|
+
ccf = entry.get("UsageValue")
|
|
467
|
+
|
|
468
|
+
if ts is None or ccf is None:
|
|
469
|
+
continue
|
|
470
|
+
|
|
471
|
+
results.append(HourlyGasUsage(timestamp=ts, ccf=float(ccf)))
|
|
472
|
+
|
|
473
|
+
return sorted(results, key=lambda u: u.timestamp, reverse=True)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _parse_datetime(dt_str: str | None) -> datetime | None:
|
|
477
|
+
"""Parse a datetime string from the portal (various formats)."""
|
|
478
|
+
if not dt_str:
|
|
479
|
+
return None
|
|
480
|
+
from datetime import datetime
|
|
481
|
+
|
|
482
|
+
for fmt in ("%m/%d/%Y %I:%M:%S %p", "%m/%d/%Y %H:%M:%S", "%m/%d/%y %I:%M:%S %p", "%m/%d/%y"):
|
|
483
|
+
try:
|
|
484
|
+
return datetime.strptime(dt_str, fmt)
|
|
485
|
+
except ValueError:
|
|
486
|
+
continue
|
|
487
|
+
|
|
488
|
+
parsed_date = _parse_date(dt_str)
|
|
489
|
+
if parsed_date:
|
|
490
|
+
return datetime(parsed_date.year, parsed_date.month, parsed_date.day)
|
|
491
|
+
return None
|
|
492
|
+
|
|
307
493
|
|
|
308
494
|
def _parse_date(date_str: str | None) -> date | None:
|
|
309
495
|
"""Parse a date string in MM/DD/YY format."""
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
-
from datetime import date
|
|
6
|
+
from datetime import date, datetime
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
CCF_TO_CF = 100.0
|
|
@@ -24,6 +24,32 @@ class GasUsage:
|
|
|
24
24
|
return self.ccf * CCF_TO_CF
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
@dataclass
|
|
28
|
+
class DailyGasUsage:
|
|
29
|
+
"""A single day of gas usage data."""
|
|
30
|
+
|
|
31
|
+
date: date
|
|
32
|
+
ccf: float
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def cf(self) -> float:
|
|
36
|
+
"""Usage in cubic feet."""
|
|
37
|
+
return self.ccf * CCF_TO_CF
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class HourlyGasUsage:
|
|
42
|
+
"""A single hour (or 15-min interval) of gas usage data."""
|
|
43
|
+
|
|
44
|
+
timestamp: datetime
|
|
45
|
+
ccf: float
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def cf(self) -> float:
|
|
49
|
+
"""Usage in cubic feet."""
|
|
50
|
+
return self.ccf * CCF_TO_CF
|
|
51
|
+
|
|
52
|
+
|
|
27
53
|
@dataclass
|
|
28
54
|
class BillingSummary:
|
|
29
55
|
"""Current billing summary from the BillDashboard."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pgw-api
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Python API client for Philadelphia Gas Works (PGW)
|
|
5
5
|
Author: zackwag
|
|
6
6
|
License: MIT
|
|
@@ -8,11 +8,13 @@ Project-URL: Homepage, https://github.com/zackwag/pgw-api
|
|
|
8
8
|
Project-URL: Issues, https://github.com/zackwag/pgw-api/issues
|
|
9
9
|
Requires-Python: >=3.12
|
|
10
10
|
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
11
12
|
Requires-Dist: aiohttp>=3.9.0
|
|
13
|
+
Dynamic: license-file
|
|
12
14
|
|
|
13
15
|
# pgw-api
|
|
14
16
|
|
|
15
|
-
Python API client for [Philadelphia Gas Works (PGW)](https://www.pgworks.com/). Fetches
|
|
17
|
+
Python API client for [Philadelphia Gas Works (PGW)](https://www.pgworks.com/). Fetches natural gas usage data (monthly, daily, and hourly) from the PGW customer portal.
|
|
16
18
|
|
|
17
19
|
## Installation
|
|
18
20
|
|
|
@@ -51,6 +53,30 @@ Creates a client instance with PGW portal credentials.
|
|
|
51
53
|
|
|
52
54
|
Authenticates and returns a list of `GasUsage` objects sorted by month (newest first).
|
|
53
55
|
|
|
56
|
+
### `await client.async_get_daily_usage(session, start, end)`
|
|
57
|
+
|
|
58
|
+
Authenticates and returns a list of `DailyGasUsage` objects for the given date range (newest first). Requires a smart meter — most residential accounts only have monthly data and will return an empty list.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from datetime import date
|
|
62
|
+
|
|
63
|
+
daily = await client.async_get_daily_usage(session, date(2024, 1, 1), date(2024, 1, 31))
|
|
64
|
+
for entry in daily:
|
|
65
|
+
print(f"{entry.date}: {entry.ccf} CCF")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### `await client.async_get_hourly_usage(session, usage_date)`
|
|
69
|
+
|
|
70
|
+
Authenticates and returns a list of `HourlyGasUsage` objects for a single day (newest first). Requires a smart meter that reports interval data. Most residential accounts only have monthly data and will return an empty list.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from datetime import date
|
|
74
|
+
|
|
75
|
+
hourly = await client.async_get_hourly_usage(session, date(2024, 1, 15))
|
|
76
|
+
for entry in hourly:
|
|
77
|
+
print(f"{entry.timestamp}: {entry.ccf} CCF")
|
|
78
|
+
```
|
|
79
|
+
|
|
54
80
|
### `await client.async_validate_credentials(session)`
|
|
55
81
|
|
|
56
82
|
Validates credentials without fetching usage data. Returns `True` or raises `PGWAuthError`.
|
|
@@ -65,6 +91,22 @@ Validates credentials without fetching usage data. Returns `True` or raises `PGW
|
|
|
65
91
|
| `period_start` | `date \| None` | Meter read start date |
|
|
66
92
|
| `period_end` | `date \| None` | Meter read end date |
|
|
67
93
|
|
|
94
|
+
### `DailyGasUsage`
|
|
95
|
+
|
|
96
|
+
| Property | Type | Description |
|
|
97
|
+
|----------|------|-------------|
|
|
98
|
+
| `date` | `date` | Usage date |
|
|
99
|
+
| `ccf` | `float` | Usage in hundreds of cubic feet |
|
|
100
|
+
| `cf` | `float` | Usage in cubic feet (CCF × 100) |
|
|
101
|
+
|
|
102
|
+
### `HourlyGasUsage`
|
|
103
|
+
|
|
104
|
+
| Property | Type | Description |
|
|
105
|
+
|----------|------|-------------|
|
|
106
|
+
| `timestamp` | `datetime` | Usage timestamp |
|
|
107
|
+
| `ccf` | `float` | Usage in hundreds of cubic feet |
|
|
108
|
+
| `cf` | `float` | Usage in cubic feet (CCF × 100) |
|
|
109
|
+
|
|
68
110
|
### Exceptions
|
|
69
111
|
|
|
70
112
|
- `PGWAuthError` — invalid credentials or expired session
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
LICENSE
|
|
1
2
|
README.md
|
|
2
3
|
pyproject.toml
|
|
3
4
|
pgw_api/__init__.py
|
|
@@ -8,4 +9,7 @@ pgw_api.egg-info/PKG-INFO
|
|
|
8
9
|
pgw_api.egg-info/SOURCES.txt
|
|
9
10
|
pgw_api.egg-info/dependency_links.txt
|
|
10
11
|
pgw_api.egg-info/requires.txt
|
|
11
|
-
pgw_api.egg-info/top_level.txt
|
|
12
|
+
pgw_api.egg-info/top_level.txt
|
|
13
|
+
tests/test_client.py
|
|
14
|
+
tests/test_models.py
|
|
15
|
+
tests/test_parsing.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pgw-api"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.2.0"
|
|
8
8
|
description = "Python API client for Philadelphia Gas Works (PGW)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -20,5 +20,8 @@ dependencies = [
|
|
|
20
20
|
Homepage = "https://github.com/zackwag/pgw-api"
|
|
21
21
|
Issues = "https://github.com/zackwag/pgw-api/issues"
|
|
22
22
|
|
|
23
|
+
[tool.pytest.ini_options]
|
|
24
|
+
asyncio_mode = "auto"
|
|
25
|
+
|
|
23
26
|
[tool.setuptools.packages.find]
|
|
24
27
|
include = ["pgw_api*"]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Tests for PGWApiClient using mocked HTTP responses."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from pgw_api.client import PGWApiClient
|
|
10
|
+
from pgw_api.exceptions import PGWAuthError, PGWConnectionError
|
|
11
|
+
from tests.conftest import CSRF_HTML, make_login_success, make_response, make_webmethod_response
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _setup_session(responses):
|
|
15
|
+
"""Wire up a mock session with a sequence of responses."""
|
|
16
|
+
session = MagicMock()
|
|
17
|
+
idx = {"i": 0}
|
|
18
|
+
|
|
19
|
+
def _ctx(resp):
|
|
20
|
+
cm = MagicMock()
|
|
21
|
+
cm.__aenter__ = AsyncMock(return_value=resp)
|
|
22
|
+
cm.__aexit__ = AsyncMock(return_value=False)
|
|
23
|
+
return cm
|
|
24
|
+
|
|
25
|
+
def _next(*args, **kwargs):
|
|
26
|
+
r = responses[idx["i"]]
|
|
27
|
+
idx["i"] += 1
|
|
28
|
+
return _ctx(r)
|
|
29
|
+
|
|
30
|
+
session.get = MagicMock(side_effect=_next)
|
|
31
|
+
session.post = MagicMock(side_effect=_next)
|
|
32
|
+
return session
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestMonthlyUsage:
|
|
36
|
+
@pytest.mark.asyncio
|
|
37
|
+
async def test_parses_monthly_entries(self):
|
|
38
|
+
usage_payload = make_webmethod_response({
|
|
39
|
+
"objUsageGenerationResultSetTwo": [
|
|
40
|
+
{"Month": 1, "Year": 2024, "UsageValue": 85.0, "FromDate": "12/15/23", "ToDate": "01/16/24"},
|
|
41
|
+
{"Month": 12, "Year": 2023, "UsageValue": 72.0, "FromDate": "11/14/23", "ToDate": "12/15/23"},
|
|
42
|
+
]
|
|
43
|
+
})
|
|
44
|
+
session = _setup_session([
|
|
45
|
+
make_response(), # GET login page
|
|
46
|
+
make_response(text=make_login_success()), # POST validateLogin
|
|
47
|
+
make_response(), # GET Dashboard
|
|
48
|
+
make_response(text=CSRF_HTML), # GET usage page
|
|
49
|
+
make_response(text=usage_payload), # POST LoadGasUsage
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
client = PGWApiClient("user", "pass")
|
|
53
|
+
result = await client.async_get_usage(session)
|
|
54
|
+
|
|
55
|
+
assert len(result) == 2
|
|
56
|
+
assert result[0].month == date(2024, 1, 1)
|
|
57
|
+
assert result[0].ccf == 85.0
|
|
58
|
+
assert result[1].month == date(2023, 12, 1)
|
|
59
|
+
|
|
60
|
+
@pytest.mark.asyncio
|
|
61
|
+
async def test_empty_usage(self):
|
|
62
|
+
usage_payload = make_webmethod_response({"objUsageGenerationResultSetTwo": []})
|
|
63
|
+
session = _setup_session([
|
|
64
|
+
make_response(),
|
|
65
|
+
make_response(text=make_login_success()),
|
|
66
|
+
make_response(),
|
|
67
|
+
make_response(text=CSRF_HTML),
|
|
68
|
+
make_response(text=usage_payload),
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
client = PGWApiClient("user", "pass")
|
|
72
|
+
result = await client.async_get_usage(session)
|
|
73
|
+
assert result == []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestDailyUsage:
|
|
77
|
+
@pytest.mark.asyncio
|
|
78
|
+
async def test_parses_daily_entries(self):
|
|
79
|
+
usage_payload = make_webmethod_response({
|
|
80
|
+
"objUsageGenerationResultSetTwo": [
|
|
81
|
+
{"FromDate": "01/15/24", "UsageValue": 3.2},
|
|
82
|
+
{"FromDate": "01/14/24", "UsageValue": 4.1},
|
|
83
|
+
{"FromDate": "01/16/24", "UsageValue": 2.8},
|
|
84
|
+
]
|
|
85
|
+
})
|
|
86
|
+
session = _setup_session([
|
|
87
|
+
make_response(),
|
|
88
|
+
make_response(text=make_login_success()),
|
|
89
|
+
make_response(),
|
|
90
|
+
make_response(text=CSRF_HTML),
|
|
91
|
+
make_response(text=usage_payload),
|
|
92
|
+
])
|
|
93
|
+
|
|
94
|
+
client = PGWApiClient("user", "pass")
|
|
95
|
+
result = await client.async_get_daily_usage(
|
|
96
|
+
session, date(2024, 1, 14), date(2024, 1, 16)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
assert len(result) == 3
|
|
100
|
+
assert result[0].date == date(2024, 1, 16)
|
|
101
|
+
assert result[0].ccf == 2.8
|
|
102
|
+
assert result[-1].date == date(2024, 1, 14)
|
|
103
|
+
|
|
104
|
+
@pytest.mark.asyncio
|
|
105
|
+
async def test_skips_entries_missing_date(self):
|
|
106
|
+
usage_payload = make_webmethod_response({
|
|
107
|
+
"objUsageGenerationResultSetTwo": [
|
|
108
|
+
{"FromDate": "01/15/24", "UsageValue": 3.2},
|
|
109
|
+
{"FromDate": None, "UsageValue": 4.1},
|
|
110
|
+
]
|
|
111
|
+
})
|
|
112
|
+
session = _setup_session([
|
|
113
|
+
make_response(),
|
|
114
|
+
make_response(text=make_login_success()),
|
|
115
|
+
make_response(),
|
|
116
|
+
make_response(text=CSRF_HTML),
|
|
117
|
+
make_response(text=usage_payload),
|
|
118
|
+
])
|
|
119
|
+
|
|
120
|
+
client = PGWApiClient("user", "pass")
|
|
121
|
+
result = await client.async_get_daily_usage(
|
|
122
|
+
session, date(2024, 1, 1), date(2024, 1, 31)
|
|
123
|
+
)
|
|
124
|
+
assert len(result) == 1
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TestHourlyUsage:
|
|
128
|
+
@pytest.mark.asyncio
|
|
129
|
+
async def test_parses_hourly_entries(self):
|
|
130
|
+
usage_payload = make_webmethod_response({
|
|
131
|
+
"objUsageGenerationResultSetTwo": [
|
|
132
|
+
{"FromDate": "01/15/2024 01:00:00 AM", "UsageValue": 0.3},
|
|
133
|
+
{"FromDate": "01/15/2024 02:00:00 AM", "UsageValue": 0.4},
|
|
134
|
+
{"FromDate": "01/15/2024 12:00:00 PM", "UsageValue": 0.1},
|
|
135
|
+
]
|
|
136
|
+
})
|
|
137
|
+
session = _setup_session([
|
|
138
|
+
make_response(),
|
|
139
|
+
make_response(text=make_login_success()),
|
|
140
|
+
make_response(),
|
|
141
|
+
make_response(text=CSRF_HTML),
|
|
142
|
+
make_response(text=usage_payload),
|
|
143
|
+
])
|
|
144
|
+
|
|
145
|
+
client = PGWApiClient("user", "pass")
|
|
146
|
+
result = await client.async_get_hourly_usage(session, date(2024, 1, 15))
|
|
147
|
+
|
|
148
|
+
assert len(result) == 3
|
|
149
|
+
assert result[0].timestamp == datetime(2024, 1, 15, 12, 0, 0)
|
|
150
|
+
assert result[0].ccf == 0.1
|
|
151
|
+
assert result[-1].timestamp == datetime(2024, 1, 15, 1, 0, 0)
|
|
152
|
+
|
|
153
|
+
@pytest.mark.asyncio
|
|
154
|
+
async def test_empty_hourly(self):
|
|
155
|
+
usage_payload = make_webmethod_response({"objUsageGenerationResultSetTwo": []})
|
|
156
|
+
session = _setup_session([
|
|
157
|
+
make_response(),
|
|
158
|
+
make_response(text=make_login_success()),
|
|
159
|
+
make_response(),
|
|
160
|
+
make_response(text=CSRF_HTML),
|
|
161
|
+
make_response(text=usage_payload),
|
|
162
|
+
])
|
|
163
|
+
|
|
164
|
+
client = PGWApiClient("user", "pass")
|
|
165
|
+
result = await client.async_get_hourly_usage(session, date(2024, 1, 15))
|
|
166
|
+
assert result == []
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TestErrorHandling:
|
|
170
|
+
@pytest.mark.asyncio
|
|
171
|
+
async def test_csrf_error_raises_auth_error(self):
|
|
172
|
+
error_payload = make_webmethod_response({
|
|
173
|
+
"dtException": [{"MessageInformation": "Invalid CSRF Token"}]
|
|
174
|
+
})
|
|
175
|
+
session = _setup_session([
|
|
176
|
+
make_response(),
|
|
177
|
+
make_response(text=make_login_success()),
|
|
178
|
+
make_response(),
|
|
179
|
+
make_response(text=CSRF_HTML),
|
|
180
|
+
make_response(text=error_payload),
|
|
181
|
+
])
|
|
182
|
+
|
|
183
|
+
client = PGWApiClient("user", "pass")
|
|
184
|
+
with pytest.raises(PGWAuthError, match="CSRF"):
|
|
185
|
+
await client.async_get_usage(session)
|
|
186
|
+
|
|
187
|
+
@pytest.mark.asyncio
|
|
188
|
+
async def test_bad_credentials(self):
|
|
189
|
+
login_fail = make_webmethod_response(
|
|
190
|
+
[{"STATUS": 0, "Message": "Invalid username or password"}]
|
|
191
|
+
)
|
|
192
|
+
session = _setup_session([
|
|
193
|
+
make_response(),
|
|
194
|
+
make_response(text=login_fail),
|
|
195
|
+
])
|
|
196
|
+
|
|
197
|
+
client = PGWApiClient("user", "wrong")
|
|
198
|
+
with pytest.raises(PGWAuthError, match="Invalid username"):
|
|
199
|
+
await client.async_get_usage(session)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Tests for data models."""
|
|
2
|
+
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
|
|
5
|
+
from pgw_api.models import DailyGasUsage, GasUsage, HourlyGasUsage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestGasUsage:
|
|
9
|
+
def test_cf_conversion(self):
|
|
10
|
+
entry = GasUsage(month=date(2024, 1, 1), ccf=85.0)
|
|
11
|
+
assert entry.cf == 8500.0
|
|
12
|
+
|
|
13
|
+
def test_optional_fields_default_none(self):
|
|
14
|
+
entry = GasUsage(month=date(2024, 1, 1), ccf=10.0)
|
|
15
|
+
assert entry.period_start is None
|
|
16
|
+
assert entry.period_end is None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestDailyGasUsage:
|
|
20
|
+
def test_cf_conversion(self):
|
|
21
|
+
entry = DailyGasUsage(date=date(2024, 1, 15), ccf=5.0)
|
|
22
|
+
assert entry.cf == 500.0
|
|
23
|
+
|
|
24
|
+
def test_zero_usage(self):
|
|
25
|
+
entry = DailyGasUsage(date=date(2024, 7, 1), ccf=0.0)
|
|
26
|
+
assert entry.cf == 0.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestHourlyGasUsage:
|
|
30
|
+
def test_cf_conversion(self):
|
|
31
|
+
entry = HourlyGasUsage(timestamp=datetime(2024, 1, 15, 14, 0), ccf=0.5)
|
|
32
|
+
assert entry.cf == 50.0
|
|
33
|
+
|
|
34
|
+
def test_zero_usage(self):
|
|
35
|
+
entry = HourlyGasUsage(timestamp=datetime(2024, 7, 1, 12, 0), ccf=0.0)
|
|
36
|
+
assert entry.cf == 0.0
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Tests for parsing helpers and model construction."""
|
|
2
|
+
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
|
|
5
|
+
from pgw_api.client import _parse_date, _parse_datetime, _parse_dollar, _parse_float, _parse_int
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestParseDate:
|
|
9
|
+
def test_mm_dd_yy(self):
|
|
10
|
+
assert _parse_date("01/15/24") == date(2024, 1, 15)
|
|
11
|
+
|
|
12
|
+
def test_mm_dd_yyyy(self):
|
|
13
|
+
assert _parse_date("12/31/2023") == date(2023, 12, 31)
|
|
14
|
+
|
|
15
|
+
def test_none(self):
|
|
16
|
+
assert _parse_date(None) is None
|
|
17
|
+
|
|
18
|
+
def test_empty(self):
|
|
19
|
+
assert _parse_date("") is None
|
|
20
|
+
|
|
21
|
+
def test_invalid(self):
|
|
22
|
+
assert _parse_date("not-a-date") is None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestParseDatetime:
|
|
26
|
+
def test_12hr_format(self):
|
|
27
|
+
assert _parse_datetime("01/15/2024 02:30:00 PM") == datetime(2024, 1, 15, 14, 30, 0)
|
|
28
|
+
|
|
29
|
+
def test_24hr_format(self):
|
|
30
|
+
assert _parse_datetime("01/15/2024 14:30:00") == datetime(2024, 1, 15, 14, 30, 0)
|
|
31
|
+
|
|
32
|
+
def test_short_year(self):
|
|
33
|
+
assert _parse_datetime("01/15/24") == datetime(2024, 1, 15, 0, 0, 0)
|
|
34
|
+
|
|
35
|
+
def test_none(self):
|
|
36
|
+
assert _parse_datetime(None) is None
|
|
37
|
+
|
|
38
|
+
def test_empty(self):
|
|
39
|
+
assert _parse_datetime("") is None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestParseDollar:
|
|
43
|
+
def test_with_symbol(self):
|
|
44
|
+
assert _parse_dollar("$37.52") == 37.52
|
|
45
|
+
|
|
46
|
+
def test_without_symbol(self):
|
|
47
|
+
assert _parse_dollar("37.52") == 37.52
|
|
48
|
+
|
|
49
|
+
def test_with_comma(self):
|
|
50
|
+
assert _parse_dollar("$1,234.56") == 1234.56
|
|
51
|
+
|
|
52
|
+
def test_invalid(self):
|
|
53
|
+
assert _parse_dollar("N/A") == 0.0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TestParseFloat:
|
|
57
|
+
def test_valid(self):
|
|
58
|
+
assert _parse_float("85.5") == 85.5
|
|
59
|
+
|
|
60
|
+
def test_invalid(self):
|
|
61
|
+
assert _parse_float("bad") == 0.0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestParseInt:
|
|
65
|
+
def test_valid(self):
|
|
66
|
+
assert _parse_int("30") == 30
|
|
67
|
+
|
|
68
|
+
def test_invalid(self):
|
|
69
|
+
assert _parse_int("bad") == 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|