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"] == (