Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ result = EnergyPrices(
).query_api()

# Convert to DataFrame-ready records
records = extract_records(result)
records = extract_records(result, decimal_to_float=True)
records = add_timestamps(records)
df = DataFrame(records)
```

!!! note
The [`extract_records`](utilities.md#entsoe.utils.extract_records) function can automatically parse pydantic `Decimal` data types to `float` values. Using `decimal_to_float=False` will not parse these types and give you raw `pydantic.BaseModel.model_dump(mode="json")` output.
Comment thread
simon-hirsch marked this conversation as resolved.
Outdated

!!! note
The [`extract_records`](utilities.md#entsoe.utils.extract_records) function automatically removes `m_rid` and `time_series.m_rid` metadata fields. This mitigates duplicates due to internal splitting of queries, see [#68](https://github.com/BerriJ/entsoe-apy/issues/68). You may extend the `ignore_fields` parameter as needed (for exampe with `"created_date_time", "time_period_time_interval.start","time_period_time_interval.end"`).
See [`extract_records`](utilities.md#entsoe.utils.extract_records) for more details.
Expand Down Expand Up @@ -59,7 +62,7 @@ result = EnergyPrices(
period_end=format_entsoe_datetime(period_end),
).query_api()

records = extract_records(result)
records = extract_records(result, decimal_to_float=True)
records = add_timestamps(records)
df = pd.DataFrame(records)
df = df.convert_dtypes()
Expand Down
30 changes: 28 additions & 2 deletions src/entsoe/utils/records.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
from decimal import Decimal
from typing import Any, Dict, List, Optional

from pydantic import BaseModel
from pydantic import BaseModel, TypeAdapter
Comment thread
simon-hirsch marked this conversation as resolved.

from ..config.config import logger

Check failure on line 6 in src/entsoe/utils/records.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (I001)

src/entsoe/utils/records.py:1:1: I001 Import block is un-sorted or un-formatted


_ANY_ADAPTER = TypeAdapter(Any)


def _deduplicate_records(records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Remove duplicate records while preserving order."""
seen = set()
Expand All @@ -17,6 +21,17 @@
return unique_records


def _coerce_decimals_to_float(value: Any) -> Any:
"""Recursively convert Decimal values to float for downstream processing."""
if isinstance(value, Decimal):
return float(value)
if isinstance(value, dict):
return {k: _coerce_decimals_to_float(v) for k, v in value.items()}
if isinstance(value, list):
return [_coerce_decimals_to_float(item) for item in value]
return value


def normalize_to_records(
data: Dict[str, Any] | List[Any] | Any,
parent_key: str = "",
Expand Down Expand Up @@ -117,6 +132,7 @@
"time_series.m_rid",
],
deduplicate: bool = True,
decimal_to_float: bool = True,
) -> List[Dict[str, int | float | str | None]]:
Comment thread
simon-hirsch marked this conversation as resolved.
"""
Convert a Pydantic model or list of Pydantic models to a list of flattened records suitable for pandas DataFrame.
Expand All @@ -138,6 +154,9 @@
Defaults to ["m_rid", "time_series.m_rid"].
Pass None to disable field filtering.
deduplicate: Whether to remove duplicate records while preserving order. Defaults to True.
decimal_to_float: Whether to convert Decimal values to float in the returned
records. If False, uses JSON serialization semantics where
Decimal values are represented as strings. Defaults to True.

Returns:
List of flattened dictionaries (records) from all BaseModel instances.
Expand Down Expand Up @@ -179,7 +198,14 @@
f"Expected data to be a BaseModel or list of BaseModel instances, got {type(data)}"
)

data_dict = [item.model_dump(mode="json") for item in data_list]
if decimal_to_float:
# Keep Decimal values typed, coerce them, then normalize to JSON-compatible
# values so the output structure stays aligned with mode="json".
data_dict = [item.model_dump(mode="python") for item in data_list]
data_dict = [_coerce_decimals_to_float(item_dict) for item_dict in data_dict]
data_dict = [_ANY_ADAPTER.dump_python(item_dict, mode="json") for item_dict in data_dict]

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The decimal_to_float=True path switches from model_dump(mode="json") to model_dump(mode="python") plus a generic TypeAdapter(Any) JSON dump. This is only covered by tests using built-in datetime; please add a regression test using one of the library’s real xsdata datatypes (e.g., xsdata.models.datatype.XmlDateTime/XmlDate) to confirm this serialization path remains JSON-compatible for actual API models.

Copilot uses AI. Check for mistakes.
else:
data_dict = [item.model_dump(mode="json") for item in data_list]
all_records = []

if domain:
Expand Down
85 changes: 85 additions & 0 deletions tests/test_extract_records.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,97 @@
# %%

from decimal import Decimal
from datetime import datetime, timezone

import pytest
from pydantic import BaseModel

from entsoe.config import get_config
from entsoe.Market import EnergyPrices
from entsoe.utils import extract_records

Check failure on line 11 in tests/test_extract_records.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (I001)

tests/test_extract_records.py:3:1: I001 Import block is un-sorted or un-formatted


def test_extract_records_converts_decimal_to_float():
class Point(BaseModel):
quantity: Decimal

class Document(BaseModel):
m_rid: str
point: list[Point]

data = Document(
m_rid="doc-1",
point=[Point(quantity=Decimal("10.25")), Point(quantity=Decimal("2.5"))],
)

records = extract_records(data, decimal_to_float=True)

assert len(records) == 2
assert all(isinstance(record["point.quantity"], float) for record in records)
assert records[0]["point.quantity"] == 10.25
assert records[1]["point.quantity"] == 2.5


def test_extract_records_domain_converts_decimal_to_float():
class TimeSeries(BaseModel):
quantity: Decimal

class Document(BaseModel):
time_series: list[TimeSeries]

data = Document(
time_series=[
TimeSeries(quantity=Decimal("3.14")),
TimeSeries(quantity=Decimal("6.28")),
]
)

records = extract_records(data, domain="time_series", decimal_to_float=True)

assert len(records) == 2
assert all(isinstance(record["quantity"], float) for record in records)
assert records[0]["quantity"] == 3.14
assert records[1]["quantity"] == 6.28


def test_extract_records_decimal_to_float_keeps_json_like_structure():
class Document(BaseModel):
timestamp: datetime
quantity: Decimal

data = Document(
timestamp=datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc),
quantity=Decimal("9.5"),
)

records = extract_records(data, decimal_to_float=True)

assert len(records) == 1
assert isinstance(records[0]["timestamp"], str)
assert records[0]["timestamp"].startswith("2026-01-01T00:00:00")
assert isinstance(records[0]["quantity"], float)
assert records[0]["quantity"] == 9.5


def test_extract_records_serializes_decimal_as_string_when_disabled():
class Point(BaseModel):
quantity: Decimal

class Document(BaseModel):
point: list[Point]

data = Document(
point=[Point(quantity=Decimal("1.1")), Point(quantity=Decimal("2.2"))]
)

records = extract_records(data, decimal_to_float=False)

assert len(records) == 2
assert all(isinstance(record["point.quantity"], str) for record in records)
assert records[0]["point.quantity"] == "1.1"
assert records[1]["point.quantity"] == "2.2"


@pytest.mark.skipif(
get_config().security_token is None,
reason="ENTSOE_API environment variable not set",
Expand Down
Loading