diff --git a/tests/test_ercot_http_products.py b/tests/test_ercot_http_products.py index 5cb1d98..bb3a2fb 100644 --- a/tests/test_ercot_http_products.py +++ b/tests/test_ercot_http_products.py @@ -41,6 +41,58 @@ def test_get_list_for_products_dispatches_correct_url(self): request = route.calls.last.request assert request.url.path == "/api/public-reports/" + +class TestProductsResponseShapes: + """Regression tests for products response parsing.""" + + def test_products_to_dataframe_supports_hal_embedded_shape(self): + """HAL responses store the list under _embedded.products.""" + ercot = ERCOT() + response = { + "_embedded": { + "products": [ + { + "emilId": "np6-905-cd", + "name": "SPP Node Zone Hub", + "description": "Settlement Point Prices", + } + ] + }, + "_links": {"self": {"href": "/api/public-reports/"}}, + } + df = ercot._products_to_dataframe(response) + assert not df.empty + assert df.loc[0, "emilId"] == "np6-905-cd" + + def test_products_to_dataframe_supports_nested_additional_properties_embedded_shape( + self, + ): + """Some pyercot model to_dict() outputs keep HAL under additional_properties.""" + ercot = ERCOT() + response = { + "additional_properties": { + "_embedded": { + "products": [ + { + "emilId": "np6-905-cd", + "name": "SPP Node Zone Hub", + } + ] + } + } + } + df = ercot._products_to_dataframe(response) + assert not df.empty + assert df.loc[0, "emilId"] == "np6-905-cd" + + def test_products_to_dataframe_supports_raw_list_shape(self): + """Some clients can return a raw list of product dicts.""" + ercot = ERCOT() + response = [{"emilId": "np6-905-cd", "name": "SPP Node Zone Hub"}] + df = ercot._products_to_dataframe(response) + assert not df.empty + assert df.loc[0, "emilId"] == "np6-905-cd" + @respx.mock def test_get_product_dispatches_correct_url_with_emil_id(self): """Test get_product calls the correct endpoint with emil_id in path.""" diff --git a/tinygrid/ercot/client.py b/tinygrid/ercot/client.py index 72c2220..2e58b63 100644 --- a/tinygrid/ercot/client.py +++ b/tinygrid/ercot/client.py @@ -644,12 +644,61 @@ def _call_endpoint_model( """ return self._call_with_retry(endpoint_module, endpoint_name, **kwargs) - def _products_to_dataframe(self, response: dict[str, Any]) -> pd.DataFrame: - """Convert products list response to DataFrame.""" - products = response.get("products", []) - if not products: + def _products_to_dataframe(self, response: Any) -> pd.DataFrame: + """Convert products list response to DataFrame. + + The ERCOT products endpoint can return multiple shapes depending on the + upstream client (pyercot) and API format: + - Plain dict: {"products": [...]} + - HAL dict: {"_embedded": {"products": [...]}, ...} + - Nested HAL in to_dict(): {"additional_properties": {"_embedded": {"products": [...]}}} + - Raw list: [...] + """ + + def _as_products_list(value: Any) -> list[dict[str, Any]]: + if not value: + return [] + if isinstance(value, list): + # Best effort: only keep mapping-like entries + return [item for item in value if isinstance(item, dict)] + return [] + + if response is None: return pd.DataFrame() - return pd.DataFrame(products) + + # Some clients can return a raw list response + if isinstance(response, list): + products = _as_products_list(response) + return pd.DataFrame(products) if products else pd.DataFrame() + + if not isinstance(response, dict): + return pd.DataFrame() + + # Common shape: {"products": [...]} + products = _as_products_list(response.get("products")) + if products: + return pd.DataFrame(products) + + # HAL shape: {"_embedded": {"products": [...]}} + embedded = response.get("_embedded") + if isinstance(embedded, dict): + products = _as_products_list(embedded.get("products")) + if products: + return pd.DataFrame(products) + + # Some model to_dict() outputs store HAL payload under additional_properties + additional_properties = response.get("additional_properties") + if isinstance(additional_properties, dict): + products = _as_products_list(additional_properties.get("products")) + if products: + return pd.DataFrame(products) + embedded = additional_properties.get("_embedded") + if isinstance(embedded, dict): + products = _as_products_list(embedded.get("products")) + if products: + return pd.DataFrame(products) + + return pd.DataFrame() def _model_to_dataframe(self, response: dict[str, Any]) -> pd.DataFrame: """Convert a single model response to a one-row DataFrame."""