diff --git a/datawrapper/__init__.py b/datawrapper/__init__.py
index c2d0f00..495bcbd 100644
--- a/datawrapper/__init__.py
+++ b/datawrapper/__init__.py
@@ -29,6 +29,7 @@
LineSymbol,
LineValueLabel,
MultipleColumnChart,
+ MultipleColumnPanel,
MultipleColumnRangeAnnotation,
MultipleColumnTextAnnotation,
MultipleColumnXLineAnnotation,
@@ -109,6 +110,7 @@
"AreaChart",
"ArrowChart",
"MultipleColumnChart",
+ "MultipleColumnPanel",
"MultipleColumnTextAnnotation",
"MultipleColumnRangeAnnotation",
"MultipleColumnXLineAnnotation",
diff --git a/datawrapper/charts/__init__.py b/datawrapper/charts/__init__.py
index 53fde8d..bf8316d 100644
--- a/datawrapper/charts/__init__.py
+++ b/datawrapper/charts/__init__.py
@@ -60,6 +60,7 @@
from .models.text_annotations import ConnectorLine, TextAnnotation
from .multiple_column import (
MultipleColumnChart,
+ MultipleColumnPanel,
MultipleColumnRangeAnnotation,
MultipleColumnTextAnnotation,
MultipleColumnXLineAnnotation,
@@ -132,6 +133,7 @@
"AreaChart",
"ArrowChart",
"MultipleColumnChart",
+ "MultipleColumnPanel",
"MultipleColumnTextAnnotation",
"MultipleColumnRangeAnnotation",
"MultipleColumnXLineAnnotation",
diff --git a/datawrapper/charts/multiple_column.py b/datawrapper/charts/multiple_column.py
index b8433eb..2c121cf 100644
--- a/datawrapper/charts/multiple_column.py
+++ b/datawrapper/charts/multiple_column.py
@@ -3,6 +3,7 @@
import pandas as pd
from pydantic import (
+ BaseModel,
ConfigDict,
Field,
field_validator,
@@ -37,6 +38,59 @@
)
+class MultipleColumnPanel(BaseModel):
+ """Panel configuration for a :class:`MultipleColumnChart` column.
+
+ Datawrapper stores multiple-column panel settings as a mapping in
+ ``metadata.visualize.panels`` where each key is a data column name and each
+ value is that panel's configuration. This model keeps the column name as a
+ first-class Python attribute while serializing back to the keyed API shape.
+
+ Unknown extra fields are preserved so new Datawrapper panel options can
+ round-trip before this wrapper has explicit typed attributes for them.
+ """
+
+ model_config = ConfigDict(
+ populate_by_name=True,
+ strict=True,
+ extra="allow",
+ )
+
+ #: The data column this panel configures. Serialized as the API mapping key.
+ column: str = Field(
+ description="The data column this panel configures",
+ min_length=1,
+ )
+
+ #: Optional custom panel title, which can include Datawrapper-supported HTML.
+ title: str | None = Field(
+ default=None,
+ description="Optional custom panel title",
+ )
+
+ #: Whether to show this panel on mobile layouts.
+ show_on_mobile: bool | None = Field(
+ default=None,
+ alias="showOnMobile",
+ description="Whether to show this panel on mobile layouts",
+ )
+
+ #: Whether to show this panel on desktop layouts.
+ show_on_desktop: bool | None = Field(
+ default=None,
+ alias="showOnDesktop",
+ description="Whether to show this panel on desktop layouts",
+ )
+
+ def serialize_model(self) -> dict[str, Any]:
+ """Serialize the panel to the value stored under its column key."""
+ return self.model_dump(
+ by_alias=True,
+ exclude={"column"},
+ exclude_none=True,
+ )
+
+
class MultipleColumnTextAnnotation(TextAnnotation):
"""Text annotation with additional fields specific to MultipleColumnChart.
@@ -366,11 +420,57 @@ class MultipleColumnChart(
)
#: Panels configuration
- panels: list[dict[str, Any]] = Field(
+ panels: list[MultipleColumnPanel] = Field(
default_factory=list,
description="Panel configurations for the chart",
)
+ @field_validator("panels", mode="before")
+ @classmethod
+ def convert_panels(
+ cls,
+ value: (
+ dict[str, dict[str, Any]]
+ | Sequence[MultipleColumnPanel | dict[str, Any]]
+ | None
+ ),
+ ) -> list[MultipleColumnPanel]:
+ """Convert API and legacy panel inputs to MultipleColumnPanel objects.
+
+ Accepts both the Datawrapper API's keyed dictionary shape and the
+ wrapper's historical list-of-dicts shape for backwards compatibility.
+ """
+ if not value:
+ return []
+
+ if isinstance(value, dict):
+ result = []
+ for column, panel in value.items():
+ if isinstance(panel, MultipleColumnPanel):
+ result.append(panel.model_copy(update={"column": column}))
+ elif isinstance(panel, dict):
+ panel_data = {**panel, "column": column}
+ result.append(MultipleColumnPanel(**panel_data))
+ else:
+ raise TypeError(
+ "panels keyed-dict values must be MultipleColumnPanel "
+ f"or dict instances, got {type(panel).__name__}"
+ )
+ return result
+
+ result = []
+ for item in value:
+ if isinstance(item, MultipleColumnPanel):
+ result.append(item)
+ elif isinstance(item, dict):
+ result.append(MultipleColumnPanel(**item))
+ else:
+ raise TypeError(
+ "panels list items must be MultipleColumnPanel or dict "
+ f"instances, got {type(item).__name__}"
+ )
+ return result
+
#
# Layout
#
@@ -701,7 +801,7 @@ def serialize_model(self) -> dict:
self.plot_height_fixed,
self.plot_height_ratio,
),
- "panels": {panel["column"]: panel for panel in self.panels},
+ "panels": {panel.column: panel.serialize_model() for panel in self.panels},
# Tooltips
"show-tooltips": self.show_tooltips,
"syncMultipleTooltips": self.sync_multiple_tooltips,
@@ -837,14 +937,9 @@ def deserialize_model(cls, api_response: dict[str, Any]) -> dict[str, Any]:
# Plot height
init_data.update(PlotHeight.deserialize(visualize))
- # Parse panels (dict to list)
- panels_obj = visualize.get("panels", {})
- if isinstance(panels_obj, dict):
- init_data["panels"] = [
- {"column": col, **config} for col, config in panels_obj.items()
- ]
- else:
- init_data["panels"] = []
+ # Parse panels. The field validator accepts the API's keyed dict shape
+ # and converts values to MultipleColumnPanel objects.
+ init_data["panels"] = visualize.get("panels", {})
# Tooltips
if "show-tooltips" in visualize:
diff --git a/docs/user-guide/api/models.rst b/docs/user-guide/api/models.rst
index 4ecc5aa..b4c2817 100644
--- a/docs/user-guide/api/models.rst
+++ b/docs/user-guide/api/models.rst
@@ -41,6 +41,14 @@ Annotations
:members:
:show-inheritance:
+Multiple Column Panels
+----------------------
+.. currentmodule:: datawrapper.charts.multiple_column
+
+.. autoclass:: MultipleColumnPanel
+ :members:
+ :show-inheritance:
+
Column Format
-------------
.. currentmodule:: datawrapper.charts.models
diff --git a/docs/user-guide/charts/multiple-column-charts.md b/docs/user-guide/charts/multiple-column-charts.md
index 2989c44..d7cc1b9 100644
--- a/docs/user-guide/charts/multiple-column-charts.md
+++ b/docs/user-guide/charts/multiple-column-charts.md
@@ -44,14 +44,14 @@ chart = dw.MultipleColumnChart(
y_grid_label_align="left",
# Custom panel titles with city and country information
panels=[
- {"column": "Delhi", "title": "Delhi, India"},
- {"column": "Dhaka", "title": "Dhaka, Bangladesh "},
- {"column": "Lagos", "title": "Lagos, Nigeria"},
- {"column": "Paris", "title": "Paris, France"},
- {"column": "Tokyo", "title": "Tokyo, Japan"},
- {"column": "Beijing", "title": "Beijing, China"},
- {"column": "Mumbai (Bombay)", "title": "Mumbai, India"},
- {"column": "New York-Newark", "title": "New York/Newark, U.S."},
+ dw.MultipleColumnPanel(column="Delhi", title="Delhi, India"),
+ dw.MultipleColumnPanel(column="Dhaka", title="Dhaka, Bangladesh "),
+ dw.MultipleColumnPanel(column="Lagos", title="Lagos, Nigeria"),
+ dw.MultipleColumnPanel(column="Paris", title="Paris, France"),
+ dw.MultipleColumnPanel(column="Tokyo", title="Tokyo, Japan"),
+ dw.MultipleColumnPanel(column="Beijing", title="Beijing, China"),
+ dw.MultipleColumnPanel(column="Mumbai (Bombay)", title="Mumbai, India"),
+ dw.MultipleColumnPanel(column="New York-Newark", title="New York/Newark, U.S."),
],
# Add text annotations to label specific panels
text_annotations=[
diff --git a/tests/integration/test_multiple_column_chart.py b/tests/integration/test_multiple_column_chart.py
index 7839ffc..bb760fa 100644
--- a/tests/integration/test_multiple_column_chart.py
+++ b/tests/integration/test_multiple_column_chart.py
@@ -6,7 +6,7 @@
import pandas as pd
-from datawrapper import MultipleColumnChart
+from datawrapper import MultipleColumnChart, MultipleColumnPanel
# Helper functions to load sample data
@@ -305,24 +305,143 @@ def test_serialize_color_category(self):
assert viz["color-by-column"] is True
def test_serialize_panels(self):
- """Test that panels list is converted to dict."""
+ """Test that panels list is converted to the keyed API dict."""
data = pd.DataFrame({"Year": [2020, 2021], "Value": [100, 110]})
chart = MultipleColumnChart(
title="Test",
data=data,
panels=[
- {"column": "Series A", "config": "value1"},
- {"column": "Series B", "config": "value2"},
+ {"column": "Series A", "title": "Custom A", "config": "value1"},
+ MultipleColumnPanel(
+ column="Series B",
+ show_on_mobile=False,
+ show_on_desktop=True,
+ config="value2",
+ ),
],
)
+ assert all(isinstance(panel, MultipleColumnPanel) for panel in chart.panels)
+
serialized = chart.serialize_model()
panels = serialized["metadata"]["visualize"]["panels"]
assert isinstance(panels, dict)
assert "Series A" in panels
assert "Series B" in panels
- assert panels["Series A"]["config"] == "value1"
+ assert "column" not in panels["Series A"]
+ assert panels["Series A"] == {"title": "Custom A", "config": "value1"}
+ assert panels["Series B"] == {
+ "showOnMobile": False,
+ "showOnDesktop": True,
+ "config": "value2",
+ }
+
+ def test_panels_accept_api_dict_shape(self):
+ """Test panels can be initialized from Datawrapper's keyed API shape."""
+ data = pd.DataFrame({"Year": [2020, 2021], "Value": [100, 110]})
+ chart = MultipleColumnChart(
+ title="Test",
+ data=data,
+ panels={
+ "Health": {},
+ "Transport": {"showOnMobile": False, "showOnDesktop": False},
+ },
+ )
+
+ assert chart.panels == [
+ MultipleColumnPanel(column="Health"),
+ MultipleColumnPanel(
+ column="Transport",
+ show_on_mobile=False,
+ show_on_desktop=False,
+ ),
+ ]
+ assert chart.serialize_model()["metadata"]["visualize"]["panels"] == {
+ "Health": {},
+ "Transport": {"showOnMobile": False, "showOnDesktop": False},
+ }
+
+ def test_panels_accept_legacy_keyed_dict_with_column_value(self):
+ """Test panels tolerate the wrapper's previous keyed dict output shape."""
+ data = pd.DataFrame({"Year": [2020, 2021], "Value": [100, 110]})
+ chart = MultipleColumnChart(
+ title="Test",
+ data=data,
+ panels={"Health": {"column": "Health", "title": "Custom Health"}},
+ )
+
+ assert chart.panels == [
+ MultipleColumnPanel(column="Health", title="Custom Health")
+ ]
+ assert chart.serialize_model()["metadata"]["visualize"]["panels"] == {
+ "Health": {"title": "Custom Health"}
+ }
+
+ def test_panels_keyed_dict_column_key_overrides_value_column(self):
+ """Test the API dict key is authoritative when column values conflict."""
+ data = pd.DataFrame({"Year": [2020, 2021], "Value": [100, 110]})
+ chart = MultipleColumnChart(
+ title="Test",
+ data=data,
+ panels={"Health": {"column": "Transport", "title": "Custom Health"}},
+ )
+
+ assert chart.panels == [
+ MultipleColumnPanel(column="Health", title="Custom Health")
+ ]
+ assert chart.serialize_model()["metadata"]["visualize"]["panels"] == {
+ "Health": {"title": "Custom Health"}
+ }
+
+ def test_panels_keyed_dict_panel_object_uses_mapping_key_column(self):
+ """Test keyed-dict panel objects also treat the key as authoritative."""
+ data = pd.DataFrame({"Year": [2020, 2021], "Value": [100, 110]})
+ chart = MultipleColumnChart(
+ title="Test",
+ data=data,
+ panels={
+ "Health": MultipleColumnPanel(
+ column="Transport",
+ title="Custom Health",
+ )
+ },
+ )
+
+ assert chart.panels == [
+ MultipleColumnPanel(column="Health", title="Custom Health")
+ ]
+ assert chart.serialize_model()["metadata"]["visualize"]["panels"] == {
+ "Health": {"title": "Custom Health"}
+ }
+
+ def test_panels_reject_unsupported_keyed_dict_values(self):
+ """Test invalid API dict panel values raise a clear error."""
+ data = pd.DataFrame({"Year": [2020, 2021], "Value": [100, 110]})
+
+ try:
+ MultipleColumnChart(title="Test", data=data, panels={"Health": 123})
+ except TypeError as exc:
+ assert (
+ "panels keyed-dict values must be MultipleColumnPanel "
+ "or dict instances, got int"
+ ) in str(exc)
+ else:
+ raise AssertionError("Expected TypeError for invalid keyed panel value")
+
+ def test_panels_reject_unsupported_list_items(self):
+ """Test invalid panel list items raise a clear error."""
+ data = pd.DataFrame({"Year": [2020, 2021], "Value": [100, 110]})
+
+ try:
+ MultipleColumnChart(title="Test", data=data, panels=[123])
+ except TypeError as exc:
+ assert (
+ "panels list items must be MultipleColumnPanel or dict "
+ "instances, got int"
+ ) in str(exc)
+ else:
+ raise AssertionError("Expected TypeError for invalid panel list item")
def test_serialize_value_labels(self):
"""Test that valueLabels is serialized correctly."""
@@ -481,6 +600,18 @@ def mock_get(url):
assert chart.chart_type == "multiple-columns"
assert chart.grid_layout == "fixedCount"
+ assert all(isinstance(panel, MultipleColumnPanel) for panel in chart.panels)
+ assert chart.panels[0] == MultipleColumnPanel(column="Health")
+
+ transport = next(
+ panel for panel in chart.panels if panel.column == "Transport"
+ )
+ assert transport.show_on_mobile is False
+ assert transport.show_on_desktop is False
+ assert transport.serialize_model() == {
+ "showOnMobile": False,
+ "showOnDesktop": False,
+ }
def test_parse_preserves_all_fields(self):
"""Test that parsing preserves all important fields."""
diff --git a/tests/integration/test_scatter_chart.py b/tests/integration/test_scatter_chart.py
index 943daf7..ee3c08f 100644
--- a/tests/integration/test_scatter_chart.py
+++ b/tests/integration/test_scatter_chart.py
@@ -475,7 +475,9 @@ def test_automation_fixture_loads_with_explicit_utf8_encoding(self):
chart_metadata = load_sample_json("automation.json")
sample_csv = load_sample_csv("automation.csv")
- encoding_kwargs = [call.kwargs["encoding"] for call in mocked_open.call_args_list]
+ encoding_kwargs = [
+ call.kwargs["encoding"] for call in mocked_open.call_args_list
+ ]
assert encoding_kwargs == ["utf-8", "utf-8"]
assert chart_metadata["title"] == (