python-midas 1.0.0__tar.gz → 1.0.1__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.
Files changed (22) hide show
  1. {python_midas-1.0.0 → python_midas-1.0.1}/CHANGELOG.md +14 -0
  2. {python_midas-1.0.0 → python_midas-1.0.1}/PKG-INFO +2 -1
  3. {python_midas-1.0.0 → python_midas-1.0.1}/pyproject.toml +4 -1
  4. {python_midas-1.0.0 → python_midas-1.0.1}/src/midas/entities/models.py +10 -7
  5. {python_midas-1.0.0 → python_midas-1.0.1}/src/midas/enums.py +38 -0
  6. {python_midas-1.0.0 → python_midas-1.0.1}/tests/test_entities.py +59 -0
  7. {python_midas-1.0.0 → python_midas-1.0.1}/tests/test_integration.py +102 -2
  8. {python_midas-1.0.0 → python_midas-1.0.1}/.github/workflows/ci.yml +0 -0
  9. {python_midas-1.0.0 → python_midas-1.0.1}/.github/workflows/publish.yml +0 -0
  10. {python_midas-1.0.0 → python_midas-1.0.1}/.gitignore +0 -0
  11. {python_midas-1.0.0 → python_midas-1.0.1}/CONTRIBUTING.md +0 -0
  12. {python_midas-1.0.0 → python_midas-1.0.1}/LICENSE +0 -0
  13. {python_midas-1.0.0 → python_midas-1.0.1}/README.md +0 -0
  14. {python_midas-1.0.0 → python_midas-1.0.1}/doc/v2-migration.md +0 -0
  15. {python_midas-1.0.0 → python_midas-1.0.1}/src/midas/__init__.py +0 -0
  16. {python_midas-1.0.0 → python_midas-1.0.1}/src/midas/auth.py +0 -0
  17. {python_midas-1.0.0 → python_midas-1.0.1}/src/midas/client.py +0 -0
  18. {python_midas-1.0.0 → python_midas-1.0.1}/src/midas/entities/__init__.py +0 -0
  19. {python_midas-1.0.0 → python_midas-1.0.1}/src/midas/py.typed +0 -0
  20. {python_midas-1.0.0 → python_midas-1.0.1}/src/midas/time.py +0 -0
  21. {python_midas-1.0.0 → python_midas-1.0.1}/tests/test_auth.py +0 -0
  22. {python_midas-1.0.0 → python_midas-1.0.1}/tests/test_client.py +0 -0
@@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the library was in early development (0.x), breaking changes appeared between minor versions when needed to fix correctness issues or align with the MIDAS API. The 1.0.0 release tracks the California Energy Commission's MIDAS v2.0 API.
6
6
 
7
+ ## [1.0.1] - 2026-06-24
8
+
9
+ Patch release. Regenerating the spec examples against the live API surfaced two wire-shape details the lenient client silently tolerated; both are now handled and guarded by tests.
10
+
11
+ ### Fixed
12
+
13
+ - **Integer day-types are no longer dropped.** Live v2.0 SGIP GHG (MOER) and Flex Alert (ALRT) responses encode `DayStart` / `DayEnd` as integers (1=Monday through 7=Sunday, 8=Holiday: the upload-format code), where v1.0 returned weekday strings. `_parse_day_type` previously tried `DayType(value)` and returned `None` on the resulting `ValueError`, so every MOER/ALRT interval lost its `day_start` / `day_end`. `DayType.from_wire` now accepts the integer code, a digit string, or a weekday string; the electricity-rate wire form (unconfirmed pending utility-data migration) is covered by accepting both.
14
+
15
+ ### Added
16
+
17
+ - **`RateInfo.signal_type` and `RateInfo.description`.** v2.0 rate-values responses carry the per-RIN `SignalType` label and `Description` at the top level (as in the RIN list); these are now surfaced on the entity instead of being silently ignored.
18
+ - **Strict wire-contract validation in the integration suite.** New `TestWireContract` validates raw live responses against the `midas-api-specs` JSON Schemas (via `jsonschema`), and the coerced tests now assert day-type values, so future wire drift fails the suite rather than being absorbed by the lenient runtime models. Point `MIDAS_SPECS_DIR` at a `midas-api-specs` checkout to enable the schema checks (they skip cleanly if absent). Adds `jsonschema` as a dev dependency.
19
+
7
20
  ## [1.0.0] — 2026-06-22
8
21
 
9
22
  The California Energy Commission released **MIDAS v2.0 on 2026-06-22**, a breaking change to the live API. v1.0 was removed from the live service that day, so python-midas 1.0.0 is a **v2-only release** — v1.0 compatibility is intentionally dropped. See [doc/v2-migration.md](doc/v2-migration.md) for the v1.0→v2.0 upgrade guide, and the upstream [`midas-api-specs`](https://github.com/grid-coordination/midas-api-specs) `v2` branch for the spec-level delta. Every change below was verified by a live smoke-test against the production v2.0 API on release day. python-midas is a **read-only consumer client**: v2.0 GET endpoints are unauthenticated, so `create_anonymous_client` needs no credentials; the authenticated constructors remain for the utility upload path only.
@@ -43,6 +56,7 @@ The California Energy Commission released **MIDAS v2.0 on 2026-06-22**, a breaki
43
56
 
44
57
  Initial implementation. Two-layer raw/coerced data model: raw `httpx.Response` accessors plus coerced Pydantic entities (`RateInfo`, `ValueData`, `RinListEntry`, `Holiday`, `LookupEntry`) with `Decimal` prices and pendulum datetimes, each carrying its original wire dict on `_raw`. httpx-based `MIDASClient` with HTTP Basic → bearer-token auth and transparent auto-refresh (`AutoTokenAuth`); RIN list, rate values, lookup tables, holidays, and historical endpoints; signal-type helpers (`ghg`, `flex_alert`, `flex_alert_active`); domain enums (`SignalType`, `RateType`, `Unit`, `DayType`).
45
58
 
59
+ [1.0.1]: https://github.com/grid-coordination/python-midas/releases/tag/v1.0.1
46
60
  [1.0.0]: https://github.com/grid-coordination/python-midas/releases/tag/v1.0.0
47
61
  [0.1.1]: https://github.com/grid-coordination/python-midas/releases/tag/v0.1.1
48
62
  [0.1.0]: https://github.com/grid-coordination/python-midas/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-midas
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: Python client library for the California Energy Commission MIDAS API
5
5
  Project-URL: Homepage, https://grid-coordination.energy
6
6
  Project-URL: Repository, https://github.com/grid-coordination/python-midas
@@ -23,6 +23,7 @@ Requires-Dist: httpx>=0.27
23
23
  Requires-Dist: pendulum>=3.0
24
24
  Requires-Dist: pydantic>=2.5
25
25
  Provides-Extra: dev
26
+ Requires-Dist: jsonschema>=4.18; extra == 'dev'
26
27
  Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
27
28
  Requires-Dist: pytest>=8.0; extra == 'dev'
28
29
  Requires-Dist: ruff>=0.3; extra == 'dev'
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-midas"
7
- version = "1.0.0"
7
+ version = "1.0.1"
8
8
  description = "Python client library for the California Energy Commission MIDAS API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -35,6 +35,9 @@ dev = [
35
35
  "pytest>=8.0",
36
36
  "pytest-httpx>=0.30",
37
37
  "ruff>=0.3",
38
+ # Strict wire-contract validation in the integration suite (validates raw
39
+ # responses against the midas-api-specs JSON Schemas).
40
+ "jsonschema>=4.18",
38
41
  ]
39
42
 
40
43
  [project.urls]
@@ -38,13 +38,10 @@ def _parse_decimal(n: int | float | None) -> Decimal | None:
38
38
  return Decimal(str(n))
39
39
 
40
40
 
41
- def _parse_day_type(s: str | None) -> DayType | None:
42
- if not s:
43
- return None
44
- try:
45
- return DayType(s)
46
- except ValueError:
47
- return None
41
+ def _parse_day_type(v: object) -> DayType | None:
42
+ # v2.0 MOER/ALRT send an integer code (1=Mon..8=Holiday); v1.0/electricity
43
+ # send a weekday string. DayType.from_wire accepts both.
44
+ return DayType.from_wire(v)
48
45
 
49
46
 
50
47
  def _parse_unit(s: str | None) -> Unit | str | None:
@@ -114,6 +111,8 @@ class RateInfo(MIDASBase):
114
111
  id: str | None = None
115
112
  system_time: PendulumDateTime = None
116
113
  name: str | None = None
114
+ signal_type: SignalType | None = None
115
+ description: str | None = None
117
116
  type: RateType | str | None = None
118
117
  sector: str | None = None
119
118
  end_use: str | None = None
@@ -137,6 +136,10 @@ class RateInfo(MIDASBase):
137
136
  id=raw.get("RateID"),
138
137
  system_time=parse_instant(raw.get("SystemTime_UTC")),
139
138
  name=raw.get("RateName"),
139
+ # v2.0 rate-values responses carry the per-RIN SignalType label and
140
+ # Description at the top level (as in the RIN list).
141
+ signal_type=_parse_signal_type(raw.get("SignalType")),
142
+ description=raw.get("Description"),
140
143
  type=_parse_rate_type(raw.get("RateType")),
141
144
  sector=raw.get("Sector"),
142
145
  end_use=raw.get("EndUse"),
@@ -71,3 +71,41 @@ class DayType(str, Enum):
71
71
  SATURDAY = "Saturday"
72
72
  SUNDAY = "Sunday"
73
73
  HOLIDAY = "Holiday"
74
+
75
+ @classmethod
76
+ def from_wire(cls, v: object) -> "DayType | None":
77
+ """Coerce a wire day-type value to a DayType, or None if unmappable.
78
+
79
+ v2.0 SGIP GHG (MOER) and Flex Alert (ALRT) responses encode the day
80
+ type as an INTEGER upload code (1=Monday ... 7=Sunday, 8=Holiday); v1.0
81
+ and electricity rates use the weekday string ("Monday"). Both forms are
82
+ accepted (a digit string such as "1" is treated as the integer code).
83
+ """
84
+ if isinstance(v, bool) or v is None:
85
+ return None
86
+ if isinstance(v, int):
87
+ return _DAY_TYPE_BY_CODE.get(v)
88
+ if isinstance(v, str):
89
+ s = v.strip()
90
+ if not s:
91
+ return None
92
+ if s.isdigit():
93
+ return _DAY_TYPE_BY_CODE.get(int(s))
94
+ try:
95
+ return cls(s)
96
+ except ValueError:
97
+ return None
98
+ return None
99
+
100
+
101
+ #: v2.0 integer day-type upload codes (1=Monday ... 7=Sunday, 8=Holiday).
102
+ _DAY_TYPE_BY_CODE: dict[int, DayType] = {
103
+ 1: DayType.MONDAY,
104
+ 2: DayType.TUESDAY,
105
+ 3: DayType.WEDNESDAY,
106
+ 4: DayType.THURSDAY,
107
+ 5: DayType.FRIDAY,
108
+ 6: DayType.SATURDAY,
109
+ 7: DayType.SUNDAY,
110
+ 8: DayType.HOLIDAY,
111
+ }
@@ -12,6 +12,7 @@ from midas.entities import (
12
12
  coerce_rin_list,
13
13
  )
14
14
  from midas.entities.models import (
15
+ _parse_day_type,
15
16
  _parse_rate_type,
16
17
  _parse_signal_type,
17
18
  _parse_unit,
@@ -149,6 +150,23 @@ def test_rate_type_moer_parses():
149
150
  assert _parse_rate_type("Some Future Type") == "Some Future Type"
150
151
 
151
152
 
153
+ def test_day_type_integer_codes_parse():
154
+ # v2.0 MOER/ALRT send integer day-type codes (1=Mon..7=Sun, 8=Holiday).
155
+ assert _parse_day_type(1) == DayType.MONDAY
156
+ assert _parse_day_type(7) == DayType.SUNDAY
157
+ assert _parse_day_type(8) == DayType.HOLIDAY
158
+ # Digit strings are treated as the integer code.
159
+ assert _parse_day_type("3") == DayType.WEDNESDAY
160
+ # v1.0 / electricity weekday strings still parse.
161
+ assert _parse_day_type("Monday") == DayType.MONDAY
162
+ # Out-of-range, empty, None, and junk coerce to None (not an error).
163
+ assert _parse_day_type(0) is None
164
+ assert _parse_day_type(9) is None
165
+ assert _parse_day_type(None) is None
166
+ assert _parse_day_type("") is None
167
+ assert _parse_day_type("Funday") is None
168
+
169
+
152
170
  # -- RIN List tests --
153
171
 
154
172
 
@@ -239,6 +257,47 @@ def test_rate_info_raw_preserved():
239
257
  assert rate.values[0]._raw == RAW_RATE_INFO["ValueInformation"][0]
240
258
 
241
259
 
260
+ # v2.0 MOER/ALRT rate-values shape: top-level SignalType/Description, and
261
+ # integer day-type codes in ValueInformation.
262
+ RAW_MOER_RATE = {
263
+ "RateID": "USCA-SGIP-MOER-PGE",
264
+ "SystemTime_UTC": "2026-06-23T03:34:50.858999Z",
265
+ "RateName": "SGIP_CAISO_PGE Realtime GHG Emissions",
266
+ "RateType": "Greenhouse Gas emissions",
267
+ "SignalType": "Greenhouse Gas Emissions",
268
+ "Description": "SGIP_CAISO_PGE Realtime GHG Emissions - Greenhouse Gas emissions",
269
+ "ValueInformation": [
270
+ {
271
+ "ValueName": "Realtime SGIP GHG Emission",
272
+ "DateStart": "2026-06-22",
273
+ "DateEnd": "2026-06-22",
274
+ "DayStart": 1,
275
+ "DayEnd": 1,
276
+ "TimeStart": "07:00:00",
277
+ "TimeEnd": "07:04:59",
278
+ "Unit": "g/kWh CO2",
279
+ "Value": 643.19,
280
+ }
281
+ ],
282
+ }
283
+
284
+
285
+ def test_rate_info_surfaces_signal_type_and_description():
286
+ # v2.0 carries the per-RIN SignalType label and Description at the top level.
287
+ rate = coerce_rate_info(RAW_MOER_RATE)
288
+ assert rate.signal_type == SignalType.GHG_EMISSIONS
289
+ assert rate.description == RAW_MOER_RATE["Description"]
290
+
291
+
292
+ def test_rate_info_integer_day_types_not_dropped():
293
+ # Integer DayStart/DayEnd must coerce, not silently become None
294
+ # (regression: python-midas-ib9).
295
+ rate = coerce_rate_info(RAW_MOER_RATE)
296
+ v = rate.values[0]
297
+ assert v.day_start == DayType.MONDAY
298
+ assert v.day_end == DayType.MONDAY
299
+
300
+
242
301
  # -- Flex Alert tests --
243
302
 
244
303
 
@@ -16,14 +16,26 @@ paths; thin/absent data on a given RIN is expected this week, not a regression.
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import json
20
+ import os
19
21
  from decimal import Decimal
22
+ from pathlib import Path
20
23
 
21
24
  import httpx
22
25
  import pendulum
23
26
  import pytest
24
27
 
28
+ try:
29
+ import jsonschema
30
+ from referencing import Registry, Resource
31
+
32
+ _HAVE_JSONSCHEMA = True
33
+ except ImportError: # pragma: no cover - dev extra not installed
34
+ _HAVE_JSONSCHEMA = False
35
+
25
36
  from midas import (
26
37
  MIDASClient,
38
+ DayType,
27
39
  RateInfo,
28
40
  RinListEntry,
29
41
  LookupEntry,
@@ -138,6 +150,10 @@ class TestRateValues:
138
150
  rin = _first_ghg_rin(client)
139
151
  rate = client.rate_values(rin, query_type="realtime")
140
152
  assert len(rate.values) > 0
153
+ # v2.0 surfaces the per-RIN SignalType label and Description at the top
154
+ # level of a rate-values response.
155
+ assert rate.signal_type == SignalType.GHG_EMISSIONS
156
+ assert rate.description is not None
141
157
 
142
158
  v = rate.values[0]
143
159
  assert isinstance(v, ValueData)
@@ -152,6 +168,10 @@ class TestRateValues:
152
168
  assert start <= end
153
169
  assert isinstance(v.value, Decimal)
154
170
  assert v.unit is not None
171
+ # MOER sends integer day-type codes (1=Mon..8=Holiday); they must coerce
172
+ # to DayType, not silently drop to None (regression: python-midas-ib9).
173
+ assert v.day_start in DayType.__members__.values()
174
+ assert v.day_end in DayType.__members__.values()
155
175
 
156
176
  def test_realtime_query(self, client: MIDASClient):
157
177
  rate = client.rate_values(TOU_TEST_RIN, query_type="realtime")
@@ -170,6 +190,8 @@ class TestFlexAlert:
170
190
  assert client.flex_alert(rate) is True
171
191
  assert len(rate.values) > 0
172
192
  assert rate.values[0].unit == Unit.EVENT
193
+ # ALRT also sends integer day-types; confirm they coerce.
194
+ assert rate.values[0].day_start in DayType.__members__.values()
173
195
 
174
196
  def test_flex_alert_active_type(self, client: MIDASClient):
175
197
  rate = client.rate_values(FLEX_RIN)
@@ -251,7 +273,85 @@ class TestGHG:
251
273
  class TestRetiredRins:
252
274
  def test_legacy_flex_rin_errors(self, client: MIDASClient):
253
275
  # Legacy SGIP/Flex RINs are retired in v2.0. The live API returns
254
- # HTTP 404 ("RIN not found") note: NOT 410 Gone as the migration
255
- # notes state (filed as a midas-api-specs discrepancy).
276
+ # HTTP 404 ("RIN not found"), not 410 Gone as the migration notes
277
+ # state (filed as a midas-api-specs discrepancy).
256
278
  resp = client.get_rate_values(LEGACY_FLEX_RIN)
257
279
  assert resp.status_code == 404
280
+
281
+
282
+ # -- Strict wire-contract validation --
283
+ #
284
+ # The endpoint tests above assert on the lenient coerced model, which by design
285
+ # tolerates drift (extra='ignore', lenient day-type/rate-type passthrough). That
286
+ # leniency is correct for production but masks wire changes: it is how the
287
+ # integer-day-type bug (python-midas-ib9) shipped undetected. These tests
288
+ # validate the RAW response JSON against the authoritative midas-api-specs JSON
289
+ # Schemas, so any divergence from the documented wire contract (an unmodeled
290
+ # field, a wrong day-type encoding) fails the suite.
291
+
292
+
293
+ def _specs_schema_dir() -> Path | None:
294
+ """Locate the midas-api-specs value-data schema directory.
295
+
296
+ Honours MIDAS_SPECS_DIR (the midas-api-specs checkout root); otherwise
297
+ falls back to a sibling checkout next to this repo. Returns None if not
298
+ found, so the strict tests skip rather than fail off a missing sibling.
299
+ """
300
+ candidates = []
301
+ env = os.environ.get("MIDAS_SPECS_DIR")
302
+ if env:
303
+ candidates.append(Path(env) / "apis" / "value-data" / "schemas")
304
+ repo_root = Path(__file__).resolve().parents[1]
305
+ candidates.append(
306
+ repo_root.parent / "midas-api-specs" / "apis" / "value-data" / "schemas"
307
+ )
308
+ return next((c for c in candidates if c.is_dir()), None)
309
+
310
+
311
+ def _validator(schema_filename: str):
312
+ """Build a Draft 2020-12 validator for one schema, with a registry that
313
+ resolves the sibling ``*.schema.json`` ``$ref`` files by their ``$id``."""
314
+ schema_dir = _specs_schema_dir()
315
+ if schema_dir is None:
316
+ return None
317
+ resources = []
318
+ for path in schema_dir.glob("*.schema.json"):
319
+ contents = json.loads(path.read_text())
320
+ resources.append(
321
+ (contents.get("$id", path.name), Resource.from_contents(contents))
322
+ )
323
+ registry = Registry().with_resources(resources)
324
+ root = json.loads((schema_dir / schema_filename).read_text())
325
+ return jsonschema.Draft202012Validator(root, registry=registry)
326
+
327
+
328
+ @pytest.mark.skipif(not _HAVE_JSONSCHEMA, reason="jsonschema dev extra not installed")
329
+ class TestWireContract:
330
+ def _assert_valid(self, schema_filename: str, payload) -> None:
331
+ validator = _validator(schema_filename)
332
+ if validator is None:
333
+ pytest.skip(
334
+ "midas-api-specs schemas not found; set MIDAS_SPECS_DIR to the "
335
+ "midas-api-specs checkout to enable strict wire-contract checks"
336
+ )
337
+ errors = sorted(validator.iter_errors(payload), key=lambda e: list(e.path))
338
+ assert not errors, "wire diverges from midas-api-specs:\n" + "\n".join(
339
+ f" {list(e.absolute_path)}: {e.message}" for e in errors
340
+ )
341
+
342
+ def test_moer_rate_values_matches_schema(self, client: MIDASClient):
343
+ rin = _first_ghg_rin(client)
344
+ raw = client.get_rate_values(rin, query_type="realtime").json()
345
+ self._assert_valid("midas-rate-info.schema.json", raw)
346
+
347
+ def test_flex_rate_values_matches_schema(self, client: MIDASClient):
348
+ raw = client.get_rate_values(FLEX_RIN).json()
349
+ self._assert_valid("midas-rate-info.schema.json", raw)
350
+
351
+ def test_rin_list_matches_schema(self, client: MIDASClient):
352
+ raw = client.get_rin_list(signal_type=2).json()
353
+ self._assert_valid("midas-rin-list-response.schema.json", raw)
354
+
355
+ def test_unit_lookup_matches_schema(self, client: MIDASClient):
356
+ raw = client.get_lookup_table("Unit").json()
357
+ self._assert_valid("midas-lookup-table-response.schema.json", raw)
File without changes
File without changes
File without changes