pgw-api 1.2.1__tar.gz → 1.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgw-api
3
- Version: 1.2.1
3
+ Version: 1.4.0
4
4
  Summary: Python API client for Philadelphia Gas Works (PGW)
5
5
  Author: zackwag
6
6
  License: MIT
@@ -29,6 +29,7 @@ import asyncio
29
29
  import aiohttp
30
30
  from pgw_api import PGWApiClient
31
31
 
32
+
32
33
  async def main():
33
34
  client = PGWApiClient("your-email@example.com", "your-password")
34
35
 
@@ -40,6 +41,7 @@ async def main():
40
41
  if entry.period_start and entry.period_end:
41
42
  print(f" Period: {entry.period_start} to {entry.period_end}")
42
43
 
44
+
43
45
  asyncio.run(main())
44
46
  ```
45
47
 
@@ -15,6 +15,7 @@ import asyncio
15
15
  import aiohttp
16
16
  from pgw_api import PGWApiClient
17
17
 
18
+
18
19
  async def main():
19
20
  client = PGWApiClient("your-email@example.com", "your-password")
20
21
 
@@ -26,6 +27,7 @@ async def main():
26
27
  if entry.period_start and entry.period_end:
27
28
  print(f" Period: {entry.period_start} to {entry.period_end}")
28
29
 
30
+
29
31
  asyncio.run(main())
30
32
  ```
31
33
 
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import re
7
- from datetime import date
7
+ from datetime import date, datetime
8
8
 
9
9
  import aiohttp
10
10
 
@@ -33,18 +33,14 @@ class PGWApiClient:
33
33
  self._username = username
34
34
  self._password = password
35
35
 
36
- async def async_get_usage(
37
- self, session: aiohttp.ClientSession
38
- ) -> list[GasUsage]:
36
+ async def async_get_usage(self, session: aiohttp.ClientSession) -> list[GasUsage]:
39
37
  """Authenticate and fetch gas usage data from PGW."""
40
38
  await self._establish_session(session)
41
39
  await self._authenticate(session)
42
40
  csrf_token = await self._get_csrf_token(session)
43
41
  return await self._load_gas_usage(session, csrf_token)
44
42
 
45
- async def async_get_billing(
46
- self, session: aiohttp.ClientSession
47
- ) -> BillingSummary:
43
+ async def async_get_billing(self, session: aiohttp.ClientSession) -> BillingSummary:
48
44
  """Authenticate and fetch billing summary from PGW."""
49
45
  await self._establish_session(session)
50
46
  await self._authenticate(session)
@@ -84,9 +80,7 @@ class PGWApiClient:
84
80
  usage = await self._load_gas_usage(session, csrf_token)
85
81
  return usage, billing
86
82
 
87
- async def async_validate_credentials(
88
- self, session: aiohttp.ClientSession
89
- ) -> bool:
83
+ async def async_validate_credentials(self, session: aiohttp.ClientSession) -> bool:
90
84
  """Validate credentials without fetching usage data."""
91
85
  await self._establish_session(session)
92
86
  await self._authenticate(session)
@@ -97,9 +91,7 @@ class PGWApiClient:
97
91
  try:
98
92
  async with session.get(LOGIN_URL, allow_redirects=True) as resp:
99
93
  if resp.status != 200:
100
- raise PGWConnectionError(
101
- f"Login page returned status {resp.status}"
102
- )
94
+ raise PGWConnectionError(f"Login page returned status {resp.status}")
103
95
  await resp.text()
104
96
  except aiohttp.ClientError as err:
105
97
  raise PGWConnectionError(f"Failed to connect to PGW: {err}") from err
@@ -114,26 +106,18 @@ class PGWApiClient:
114
106
  headers = {**_HEADERS, "Referer": LOGIN_URL}
115
107
 
116
108
  try:
117
- async with session.post(
118
- VALIDATE_LOGIN_URL, json=payload, headers=headers
119
- ) as resp:
109
+ async with session.post(VALIDATE_LOGIN_URL, json=payload, headers=headers) as resp:
120
110
  if resp.status != 200:
121
- raise PGWAuthError(
122
- f"Login endpoint returned status {resp.status}"
123
- )
111
+ raise PGWAuthError(f"Login endpoint returned status {resp.status}")
124
112
  body = await resp.text()
125
113
  except aiohttp.ClientError as err:
126
- raise PGWConnectionError(
127
- f"Failed during authentication: {err}"
128
- ) from err
114
+ raise PGWConnectionError(f"Failed during authentication: {err}") from err
129
115
 
130
116
  try:
131
117
  data = json.loads(body)
132
118
  inner = json.loads(data["d"])
133
119
  except (json.JSONDecodeError, KeyError) as err:
134
- raise PGWConnectionError(
135
- "Unexpected response from login endpoint"
136
- ) from err
120
+ raise PGWConnectionError("Unexpected response from login endpoint") from err
137
121
 
138
122
  if isinstance(inner, dict) and "dtException" in inner:
139
123
  msg = inner["dtException"][0].get("MessageInformation", "Unknown error")
@@ -144,9 +128,7 @@ class PGWApiClient:
144
128
 
145
129
  first = inner[0]
146
130
  if isinstance(first, dict) and first.get("STATUS") == 0:
147
- raise PGWAuthError(
148
- first.get("Message", "Invalid username or password")
149
- )
131
+ raise PGWAuthError(first.get("Message", "Invalid username or password"))
150
132
 
151
133
  async def _get_csrf_token(self, session: aiohttp.ClientSession) -> str:
152
134
  """Navigate to the usage page and extract the CSRF token."""
@@ -154,18 +136,12 @@ class PGWApiClient:
154
136
  async with session.get(DASHBOARD_URL, allow_redirects=True) as resp:
155
137
  await resp.text()
156
138
 
157
- async with session.get(
158
- USAGE_URL, params={"type": "GU"}, allow_redirects=True
159
- ) as resp:
139
+ async with session.get(USAGE_URL, params={"type": "GU"}, allow_redirects=True) as resp:
160
140
  if resp.status != 200:
161
- raise PGWConnectionError(
162
- f"Usage page returned status {resp.status}"
163
- )
141
+ raise PGWConnectionError(f"Usage page returned status {resp.status}")
164
142
  html = await resp.text()
165
143
  except aiohttp.ClientError as err:
166
- raise PGWConnectionError(
167
- f"Failed to fetch usage page: {err}"
168
- ) from err
144
+ raise PGWConnectionError(f"Failed to fetch usage page: {err}") from err
169
145
 
170
146
  match = re.search(r'id="hdnCSRFToken"[^>]*value="([^"]+)"', html)
171
147
  if not match:
@@ -173,31 +149,21 @@ class PGWApiClient:
173
149
 
174
150
  return match.group(1)
175
151
 
176
- async def _load_billing(
177
- self, session: aiohttp.ClientSession
178
- ) -> BillingSummary:
152
+ async def _load_billing(self, session: aiohttp.ClientSession) -> BillingSummary:
179
153
  """Fetch billing summary from the BillDashboard page."""
180
154
  try:
181
155
  async with session.get(DASHBOARD_URL, allow_redirects=True) as resp:
182
156
  await resp.text()
183
157
 
184
- async with session.get(
185
- BILL_DASHBOARD_URL, allow_redirects=True
186
- ) as resp:
158
+ async with session.get(BILL_DASHBOARD_URL, allow_redirects=True) as resp:
187
159
  if resp.status != 200:
188
- raise PGWConnectionError(
189
- f"BillDashboard returned status {resp.status}"
190
- )
160
+ raise PGWConnectionError(f"BillDashboard returned status {resp.status}")
191
161
  html = await resp.text()
192
162
  except aiohttp.ClientError as err:
193
- raise PGWConnectionError(
194
- f"Failed to fetch billing data: {err}"
195
- ) from err
163
+ raise PGWConnectionError(f"Failed to fetch billing data: {err}") from err
196
164
 
197
165
  def field(name: str, default: str = "0") -> str:
198
- match = re.search(
199
- rf'id="{name}"[^>]*value="([^"]*)"', html
200
- )
166
+ match = re.search(rf'id="{name}"[^>]*value="([^"]*)"', html)
201
167
  return match.group(1) if match else default
202
168
 
203
169
  current_bill = _parse_dollar(field("hdnTotalBillOFCurrentMonth"))
@@ -206,12 +172,8 @@ class PGWApiClient:
206
172
  previous_bill = _parse_dollar(field("hdnTotalBillOFPreviousMonth"))
207
173
  previous_usage = _parse_float(field("hdnGasUsageOFPreviousMonth"))
208
174
  previous_days = _parse_int(field("hdnnumOfDaysPreviousMonth"))
209
- prev_year_bill = _parse_dollar(
210
- field("hdnTotalBillOFPreviousYearPreviousMonth")
211
- )
212
- prev_year_usage = _parse_float(
213
- field("hdnGasUsageOFPreviousYearPreviousMonth")
214
- )
175
+ prev_year_bill = _parse_dollar(field("hdnTotalBillOFPreviousYearPreviousMonth"))
176
+ prev_year_usage = _parse_float(field("hdnGasUsageOFPreviousYearPreviousMonth"))
215
177
  balance = _parse_dollar(field("hdnPrevAmount"))
216
178
 
217
179
  # Parse period from billing comparison JSON
@@ -271,26 +233,18 @@ class PGWApiClient:
271
233
  }
272
234
 
273
235
  try:
274
- async with session.post(
275
- LOAD_GAS_URL, json=payload, headers=headers
276
- ) as resp:
236
+ async with session.post(LOAD_GAS_URL, json=payload, headers=headers) as resp:
277
237
  if resp.status != 200:
278
- raise PGWConnectionError(
279
- f"LoadGasUsage returned status {resp.status}"
280
- )
238
+ raise PGWConnectionError(f"LoadGasUsage returned status {resp.status}")
281
239
  body = await resp.text()
282
240
  except aiohttp.ClientError as err:
283
- raise PGWConnectionError(
284
- f"Failed to fetch gas usage: {err}"
285
- ) from err
241
+ raise PGWConnectionError(f"Failed to fetch gas usage: {err}") from err
286
242
 
287
243
  try:
288
244
  data = json.loads(body)
289
245
  inner = json.loads(data["d"])
290
246
  except (json.JSONDecodeError, KeyError) as err:
291
- raise PGWConnectionError(
292
- "Unexpected response from LoadGasUsage"
293
- ) from err
247
+ raise PGWConnectionError("Unexpected response from LoadGasUsage") from err
294
248
 
295
249
  if isinstance(inner, dict) and "dtException" in inner:
296
250
  msg = inner["dtException"][0].get("MessageInformation", "Unknown error")
@@ -356,26 +310,18 @@ class PGWApiClient:
356
310
  }
357
311
 
358
312
  try:
359
- async with session.post(
360
- LOAD_GAS_URL, json=payload, headers=headers
361
- ) as resp:
313
+ async with session.post(LOAD_GAS_URL, json=payload, headers=headers) as resp:
362
314
  if resp.status != 200:
363
- raise PGWConnectionError(
364
- f"LoadGasUsage (daily) returned status {resp.status}"
365
- )
315
+ raise PGWConnectionError(f"LoadGasUsage (daily) returned status {resp.status}")
366
316
  body = await resp.text()
367
317
  except aiohttp.ClientError as err:
368
- raise PGWConnectionError(
369
- f"Failed to fetch daily gas usage: {err}"
370
- ) from err
318
+ raise PGWConnectionError(f"Failed to fetch daily gas usage: {err}") from err
371
319
 
372
320
  try:
373
321
  data = json.loads(body)
374
322
  inner = json.loads(data["d"])
375
323
  except (json.JSONDecodeError, KeyError) as err:
376
- raise PGWConnectionError(
377
- "Unexpected response from LoadGasUsage (daily)"
378
- ) from err
324
+ raise PGWConnectionError("Unexpected response from LoadGasUsage (daily)") from err
379
325
 
380
326
  if isinstance(inner, dict) and "dtException" in inner:
381
327
  msg = inner["dtException"][0].get("MessageInformation", "Unknown error")
@@ -406,7 +352,6 @@ class PGWApiClient:
406
352
  usage_date: date,
407
353
  ) -> list[HourlyGasUsage]:
408
354
  """Call LoadGasUsage in daily mode with hourlyType to get hourly data."""
409
- from datetime import datetime
410
355
 
411
356
  payload = {
412
357
  "Type": "C",
@@ -429,26 +374,18 @@ class PGWApiClient:
429
374
  }
430
375
 
431
376
  try:
432
- async with session.post(
433
- LOAD_GAS_URL, json=payload, headers=headers
434
- ) as resp:
377
+ async with session.post(LOAD_GAS_URL, json=payload, headers=headers) as resp:
435
378
  if resp.status != 200:
436
- raise PGWConnectionError(
437
- f"LoadGasUsage (hourly) returned status {resp.status}"
438
- )
379
+ raise PGWConnectionError(f"LoadGasUsage (hourly) returned status {resp.status}")
439
380
  body = await resp.text()
440
381
  except aiohttp.ClientError as err:
441
- raise PGWConnectionError(
442
- f"Failed to fetch hourly gas usage: {err}"
443
- ) from err
382
+ raise PGWConnectionError(f"Failed to fetch hourly gas usage: {err}") from err
444
383
 
445
384
  try:
446
385
  data = json.loads(body)
447
386
  inner = json.loads(data["d"])
448
387
  except (json.JSONDecodeError, KeyError) as err:
449
- raise PGWConnectionError(
450
- "Unexpected response from LoadGasUsage (hourly)"
451
- ) from err
388
+ raise PGWConnectionError("Unexpected response from LoadGasUsage (hourly)") from err
452
389
 
453
390
  if isinstance(inner, dict) and "dtException" in inner:
454
391
  msg = inner["dtException"][0].get("MessageInformation", "Unknown error")
@@ -474,20 +411,24 @@ class PGWApiClient:
474
411
 
475
412
 
476
413
  def _parse_datetime(dt_str: str | None) -> datetime | None:
477
- """Parse a datetime string from the portal (various formats)."""
414
+ """Parse a datetime string from the portal (various formats).
415
+
416
+ Returns a naive datetime deliberately: the portal reports these in its
417
+ own local time with no timezone offset in the source string, so there is
418
+ no correct tzinfo to attach without guessing.
419
+ """
478
420
  if not dt_str:
479
421
  return None
480
- from datetime import datetime
481
422
 
482
423
  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
424
  try:
484
- return datetime.strptime(dt_str, fmt)
425
+ return datetime.strptime(dt_str, fmt) # noqa: DTZ007 - naive by design, see docstring
485
426
  except ValueError:
486
427
  continue
487
428
 
488
429
  parsed_date = _parse_date(dt_str)
489
430
  if parsed_date:
490
- return datetime(parsed_date.year, parsed_date.month, parsed_date.day)
431
+ return datetime(parsed_date.year, parsed_date.month, parsed_date.day) # noqa: DTZ001 - naive by design, see docstring
491
432
  return None
492
433
 
493
434
 
@@ -5,7 +5,6 @@ from __future__ import annotations
5
5
  from dataclasses import dataclass
6
6
  from datetime import date, datetime
7
7
 
8
-
9
8
  CCF_TO_CF = 100.0
10
9
 
11
10
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgw-api
3
- Version: 1.2.1
3
+ Version: 1.4.0
4
4
  Summary: Python API client for Philadelphia Gas Works (PGW)
5
5
  Author: zackwag
6
6
  License: MIT
@@ -29,6 +29,7 @@ import asyncio
29
29
  import aiohttp
30
30
  from pgw_api import PGWApiClient
31
31
 
32
+
32
33
  async def main():
33
34
  client = PGWApiClient("your-email@example.com", "your-password")
34
35
 
@@ -40,6 +41,7 @@ async def main():
40
41
  if entry.period_start and entry.period_end:
41
42
  print(f" Period: {entry.period_start} to {entry.period_end}")
42
43
 
44
+
43
45
  asyncio.run(main())
44
46
  ```
45
47
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pgw-api"
7
- version = "1.2.1"
7
+ version = "1.4.0"
8
8
  description = "Python API client for Philadelphia Gas Works (PGW)"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -25,3 +25,14 @@ asyncio_mode = "auto"
25
25
 
26
26
  [tool.setuptools.packages.find]
27
27
  include = ["pgw_api*"]
28
+
29
+ [tool.ruff]
30
+ line-length = 100
31
+
32
+ [tool.ruff.lint.per-file-ignores]
33
+ # These construct naive datetimes to match _parse_datetime's deliberately
34
+ # naive output (the portal's timestamps carry no timezone offset) -- see
35
+ # the docstring on _parse_datetime in pgw_api/client.py.
36
+ "tests/test_client.py" = ["DTZ001"]
37
+ "tests/test_models.py" = ["DTZ001"]
38
+ "tests/test_parsing.py" = ["DTZ001"]
@@ -7,8 +7,7 @@ import pytest
7
7
 
8
8
  from pgw_api.client import PGWApiClient
9
9
  from pgw_api.models import BillingSummary
10
- from tests.conftest import make_response, make_login_success, make_webmethod_response
11
-
10
+ from tests.conftest import make_login_success, make_response
12
11
 
13
12
  BILLING_HTML = """
14
13
  <input type="hidden" id="hdnTotalBillOFCurrentMonth" value="$120.50" />
@@ -47,12 +46,14 @@ def _setup_session(responses):
47
46
  class TestBillingParsing:
48
47
  @pytest.mark.asyncio
49
48
  async def test_parses_all_fields(self):
50
- session = _setup_session([
51
- make_response(), # GET login page
52
- make_response(text=make_login_success()), # POST validateLogin
53
- make_response(), # GET Dashboard
54
- make_response(text=BILLING_HTML), # GET BillDashboard
55
- ])
49
+ session = _setup_session(
50
+ [
51
+ make_response(), # GET login page
52
+ make_response(text=make_login_success()), # POST validateLogin
53
+ make_response(), # GET Dashboard
54
+ make_response(text=BILLING_HTML), # GET BillDashboard
55
+ ]
56
+ )
56
57
 
57
58
  client = PGWApiClient("user", "pass")
58
59
  billing = await client.async_get_billing(session)
@@ -72,12 +73,14 @@ class TestBillingParsing:
72
73
  @pytest.mark.asyncio
73
74
  async def test_missing_fields_default_to_zero(self):
74
75
  html = "<html><body>no hidden fields here</body></html>"
75
- session = _setup_session([
76
- make_response(),
77
- make_response(text=make_login_success()),
78
- make_response(),
79
- make_response(text=html),
80
- ])
76
+ session = _setup_session(
77
+ [
78
+ make_response(),
79
+ make_response(text=make_login_success()),
80
+ make_response(),
81
+ make_response(text=html),
82
+ ]
83
+ )
81
84
 
82
85
  client = PGWApiClient("user", "pass")
83
86
  billing = await client.async_get_billing(session)
@@ -91,12 +94,14 @@ class TestBillingParsing:
91
94
  @pytest.mark.asyncio
92
95
  async def test_malformed_period_json(self):
93
96
  html = '<input type="hidden" id="hdnbillComparisionOFCurrentMonth" value="not json" />'
94
- session = _setup_session([
95
- make_response(),
96
- make_response(text=make_login_success()),
97
- make_response(),
98
- make_response(text=html),
99
- ])
97
+ session = _setup_session(
98
+ [
99
+ make_response(),
100
+ make_response(text=make_login_success()),
101
+ make_response(),
102
+ make_response(text=html),
103
+ ]
104
+ )
100
105
 
101
106
  client = PGWApiClient("user", "pass")
102
107
  billing = await client.async_get_billing(session)
@@ -107,32 +112,56 @@ class TestBillingParsing:
107
112
  class TestBillingSummaryModel:
108
113
  def test_current_usage_cf(self):
109
114
  b = BillingSummary(
110
- current_bill=100, current_usage_ccf=50, current_period_days=30,
111
- previous_bill=80, previous_usage_ccf=40, previous_period_days=30,
112
- previous_year_bill=90, previous_year_usage_ccf=45, balance_due=100,
115
+ current_bill=100,
116
+ current_usage_ccf=50,
117
+ current_period_days=30,
118
+ previous_bill=80,
119
+ previous_usage_ccf=40,
120
+ previous_period_days=30,
121
+ previous_year_bill=90,
122
+ previous_year_usage_ccf=45,
123
+ balance_due=100,
113
124
  )
114
125
  assert b.current_usage_cf == 5000.0
115
126
 
116
127
  def test_previous_usage_cf(self):
117
128
  b = BillingSummary(
118
- current_bill=100, current_usage_ccf=50, current_period_days=30,
119
- previous_bill=80, previous_usage_ccf=40, previous_period_days=30,
120
- previous_year_bill=90, previous_year_usage_ccf=45, balance_due=100,
129
+ current_bill=100,
130
+ current_usage_ccf=50,
131
+ current_period_days=30,
132
+ previous_bill=80,
133
+ previous_usage_ccf=40,
134
+ previous_period_days=30,
135
+ previous_year_bill=90,
136
+ previous_year_usage_ccf=45,
137
+ balance_due=100,
121
138
  )
122
139
  assert b.previous_usage_cf == 4000.0
123
140
 
124
141
  def test_current_rate(self):
125
142
  b = BillingSummary(
126
- current_bill=200, current_usage_ccf=100, current_period_days=30,
127
- previous_bill=80, previous_usage_ccf=40, previous_period_days=30,
128
- previous_year_bill=90, previous_year_usage_ccf=45, balance_due=200,
143
+ current_bill=200,
144
+ current_usage_ccf=100,
145
+ current_period_days=30,
146
+ previous_bill=80,
147
+ previous_usage_ccf=40,
148
+ previous_period_days=30,
149
+ previous_year_bill=90,
150
+ previous_year_usage_ccf=45,
151
+ balance_due=200,
129
152
  )
130
153
  assert b.current_rate == 2.0
131
154
 
132
155
  def test_current_rate_zero_usage(self):
133
156
  b = BillingSummary(
134
- current_bill=0, current_usage_ccf=0, current_period_days=0,
135
- previous_bill=0, previous_usage_ccf=0, previous_period_days=0,
136
- previous_year_bill=0, previous_year_usage_ccf=0, balance_due=0,
157
+ current_bill=0,
158
+ current_usage_ccf=0,
159
+ current_period_days=0,
160
+ previous_bill=0,
161
+ previous_usage_ccf=0,
162
+ previous_period_days=0,
163
+ previous_year_bill=0,
164
+ previous_year_usage_ccf=0,
165
+ balance_due=0,
137
166
  )
138
167
  assert b.current_rate is None