Skip to content

Commit 37a69e4

Browse files
dgunningclaude
andcommitted
Fix RenderedStatement pickle and JSON serialization bugs
Replace unpicklable closures (format_func, lambda) with picklable callable classes (CellFormatter, PreformattedValue), and handle ElementCatalog objects in to_dict() so json.dumps() succeeds when dimension_metadata is present. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0a20c95 commit 37a69e4

File tree

2 files changed

+126
-8
lines changed

2 files changed

+126
-8
lines changed

edgar/xbrl/rendering.py

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,51 @@ class StatementCell:
183183
def get_formatted_value(self) -> str:
184184
return self.formatter(self.value)
185185

186+
187+
class CellFormatter:
188+
"""Picklable callable that replaces the format_func closure in render_statement().
189+
190+
Stores all parameters needed by ``_format_value_for_display_as_string``
191+
as instance attributes so pickle can serialize it.
192+
"""
193+
__slots__ = ('item', 'period_key', 'is_monetary_statement',
194+
'dominant_scale', 'shares_scale', 'comparison_info',
195+
'currency_symbol')
196+
197+
def __init__(self, item, period_key, is_monetary_statement,
198+
dominant_scale, shares_scale, comparison_info,
199+
currency_symbol):
200+
self.item = item
201+
self.period_key = period_key
202+
self.is_monetary_statement = is_monetary_statement
203+
self.dominant_scale = dominant_scale
204+
self.shares_scale = shares_scale
205+
self.comparison_info = comparison_info
206+
self.currency_symbol = currency_symbol
207+
208+
def __call__(self, value):
209+
return _format_value_for_display_as_string(
210+
value, self.item, self.period_key,
211+
self.is_monetary_statement, self.dominant_scale,
212+
self.shares_scale, self.comparison_info, self.currency_symbol
213+
)
214+
215+
216+
class PreformattedValue:
217+
"""Picklable callable that returns a pre-computed formatted string.
218+
219+
Used by ``RenderedStatement.from_dict()`` to replace the unpicklable
220+
lambda that was previously used.
221+
"""
222+
__slots__ = ('formatted',)
223+
224+
def __init__(self, formatted):
225+
self.formatted = formatted
226+
227+
def __call__(self, value):
228+
return self.formatted
229+
230+
186231
@dataclass
187232
class StatementRow:
188233
"""A row in a financial statement."""
@@ -236,6 +281,8 @@ def to_dict(self) -> Dict[str, Any]:
236281
"""
237282
from datetime import date as _date
238283

284+
from edgar.xbrl.models import ElementCatalog
285+
239286
def _json_safe(obj):
240287
"""Recursively convert non-JSON-safe types to primitives."""
241288
if isinstance(obj, dict):
@@ -246,6 +293,8 @@ def _json_safe(obj):
246293
return obj.isoformat()
247294
if isinstance(obj, _date):
248295
return obj.isoformat()
296+
if isinstance(obj, ElementCatalog):
297+
return {"name": obj.name, "labels": obj.labels}
249298
return obj
250299

251300
def _period_to_dict(p: PeriodData) -> Dict[str, Any]:
@@ -337,7 +386,7 @@ def from_dict(cls, data: Dict[str, Any]) -> 'RenderedStatement':
337386
value=cd.get('value'),
338387
style=cd.get('style', {}),
339388
comparison=cd.get('comparison'),
340-
formatter=lambda v, fv=fmt_val: fv,
389+
formatter=PreformattedValue(fmt_val),
341390
)
342391
cells.append(cell)
343392

@@ -1826,13 +1875,11 @@ def render_statement(
18261875
if currency_measure:
18271876
cell_currency_symbol = get_currency_symbol(currency_measure)
18281877

1829-
def format_func(value, item=current_item, pk=current_period_key,
1830-
_currency=cell_currency_symbol):
1831-
return _format_value_for_display_as_string(
1832-
value, item, pk,
1833-
is_monetary_statement, dominant_scale, shares_scale,
1834-
comparison_info, _currency
1835-
)
1878+
format_func = CellFormatter(
1879+
current_item, current_period_key,
1880+
is_monetary_statement, dominant_scale, shares_scale,
1881+
comparison_info, cell_currency_symbol
1882+
)
18361883

18371884
# Create a cell and add it to the row
18381885
cell = StatementCell(

tests/test_rendered_statement_serialization.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Tests for RenderedStatement.to_dict() / from_dict() serialization.
33
"""
44
import json
5+
import pickle
56

67
import pytest
78

@@ -434,3 +435,73 @@ def test_complete_round_trip(self, render_fn, expected_title, expected_type):
434435
assert rest_cell.style == orig_cell.style
435436
# Compare via JSON normalization (tuples become lists in JSON)
436437
assert json.loads(json.dumps(rest_cell.comparison)) == json.loads(json.dumps(orig_cell.comparison))
438+
439+
440+
class TestPickleSerialization:
441+
"""Tests for pickle round-trip of RenderedStatement."""
442+
443+
def test_pickle_balance_sheet(self):
444+
"""Balance sheet survives pickle round-trip with correct formatted values."""
445+
rendered = _render_balance_sheet()
446+
data = pickle.dumps(rendered)
447+
restored = pickle.loads(data)
448+
449+
assert restored.title == rendered.title
450+
assert len(restored.rows) == len(rendered.rows)
451+
for orig_row, rest_row in zip(rendered.rows, restored.rows):
452+
for orig_cell, rest_cell in zip(orig_row.cells, rest_row.cells):
453+
assert rest_cell.get_formatted_value() == orig_cell.get_formatted_value()
454+
455+
def test_pickle_income_statement(self):
456+
"""Income statement survives pickle round-trip."""
457+
rendered = _render_income_statement()
458+
data = pickle.dumps(rendered)
459+
restored = pickle.loads(data)
460+
461+
assert restored.title == rendered.title
462+
assert restored.statement_type == rendered.statement_type
463+
for orig_row, rest_row in zip(rendered.rows, restored.rows):
464+
for orig_cell, rest_cell in zip(orig_row.cells, rest_row.cells):
465+
assert rest_cell.value == orig_cell.value
466+
assert rest_cell.get_formatted_value() == orig_cell.get_formatted_value()
467+
468+
def test_pickle_from_dict_round_trip(self):
469+
"""RenderedStatement restored via from_dict() is also picklable."""
470+
rendered = _render_balance_sheet()
471+
restored_from_dict = RenderedStatement.from_dict(rendered.to_dict())
472+
data = pickle.dumps(restored_from_dict)
473+
restored = pickle.loads(data)
474+
475+
for orig_row, rest_row in zip(restored_from_dict.rows, restored.rows):
476+
for orig_cell, rest_cell in zip(orig_row.cells, rest_row.cells):
477+
assert rest_cell.get_formatted_value() == orig_cell.get_formatted_value()
478+
479+
480+
class TestElementCatalogSerialization:
481+
"""Tests for to_dict() when metadata contains ElementCatalog objects."""
482+
483+
def test_element_catalog_in_metadata(self):
484+
"""ElementCatalog objects in row metadata are converted to JSON-safe dicts."""
485+
from edgar.xbrl.models import ElementCatalog
486+
487+
rendered = _render_balance_sheet()
488+
# Inject an ElementCatalog into row metadata (simulates dimension_metadata)
489+
elem = ElementCatalog(
490+
name="us-gaap_ProductMember",
491+
data_type="domainItemType",
492+
period_type="duration",
493+
labels={"standard": "Product"},
494+
)
495+
rendered.rows[0].metadata["dimension_metadata"] = [
496+
{"member_element": elem, "dimension_element": elem}
497+
]
498+
499+
d = rendered.to_dict()
500+
# Must be JSON-serializable
501+
serialized = json.dumps(d)
502+
assert isinstance(serialized, str)
503+
504+
# Verify the ElementCatalog was converted to a dict with name and labels
505+
dim_meta = d["rows"][0]["metadata"]["dimension_metadata"][0]
506+
assert dim_meta["member_element"] == {"name": "us-gaap_ProductMember", "labels": {"standard": "Product"}}
507+
assert dim_meta["dimension_element"] == {"name": "us-gaap_ProductMember", "labels": {"standard": "Product"}}

0 commit comments

Comments
 (0)