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 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.1.0
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 monthly natural gas usage data from the PGW customer portal.
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 monthly natural gas usage data from the PGW customer portal.
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.1.0
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 monthly natural gas usage data from the PGW customer portal.
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.1.0"
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