Source code for datawrapper.charts.models.transforms
from typing import Any, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_serializer,
model_validator,
)
from ..enums import DateFormat, NumberDivisor, NumberFormat
[docs]
class ColumnFormat(BaseModel):
"""A data class for the Datawrapper API's 'column_format' attribute."""
model_config = ConfigDict(
populate_by_name=True,
strict=True,
json_schema_extra={
"examples": [
{
"column": "sales",
"type": "number",
"ignore": False,
"number-prepend": "$",
"number-append": "",
}
]
},
)
#: "The name of the data column for the line"
column: str = Field(
description="The name of the data column for the line",
min_length=1,
)
#: The data type of the column
type: Literal["auto", "text", "number", "date"] = Field(
default="auto", description="The data type of the column"
)
#: Whether to ignore the column
ignore: bool = Field(default=False, description="Whether to ignore the column")
#: What to prepend before the number
number_prepend: str = Field(
default="",
alias="number-prepend",
description="What to prepend before the number",
)
#: What to append after the number
number_append: str = Field(
default="", alias="number-append", description="What to append after the number"
)
#: Number divisor for scaling values (use NumberDivisor enum or raw API values)
number_divisor: NumberDivisor | int | str = Field(
default=0,
alias="number-divisor",
description="Number divisor for scaling values. Use NumberDivisor enum for readability or raw API values (0, 'auto', 3, 6, 9, -2, -3, -6, -9, -12).",
)
#: Number/date format for the column (use DateFormat or NumberFormat enum or raw format strings)
number_format: DateFormat | NumberFormat | str = Field(
default="-",
alias="number-format",
description="Number or date format for the column. Use DateFormat for temporal data, NumberFormat for numeric data, or provide custom format strings.",
)
[docs]
@field_validator("number_divisor")
@classmethod
def validate_number_divisor(
cls, v: NumberDivisor | int | str
) -> NumberDivisor | int | str:
"""Validate number_divisor is a valid value.
Accepts NumberDivisor enum values or raw API values (int or str).
"""
# If it's already a NumberDivisor enum, it's valid
if isinstance(v, NumberDivisor):
return v
# Define valid raw values (both int and string representations)
valid_values = {
0,
"0",
"auto",
3,
"3",
6,
"6",
9,
"9",
-2,
"-2",
-3,
"-3",
-6,
"-6",
-9,
"-9",
-12,
"-12",
}
if v not in valid_values:
raise ValueError(
f"Invalid number_divisor: {v}. Use NumberDivisor enum or valid API values: "
f"0, 'auto', 3, 6, 9, -2, -3, -6, -9, -12"
)
return v
[docs]
class ColumnFormatList(BaseModel):
"""A wrapper for a list of ColumnFormat objects that handles API serialization.
The Datawrapper API expects column-format as a dictionary where column names
are keys and format configs are values. This model handles the conversion
between the user-friendly list format and the API's dict format.
"""
model_config = ConfigDict(
populate_by_name=True,
json_schema_extra={
"examples": [
{
"formats": [
{"column": "sales", "type": "number", "number-prepend": "$"},
{"column": "date", "type": "date"},
]
}
]
},
)
#: The list of column format configurations
formats: list[ColumnFormat] = Field(
default_factory=list,
description="The list of column format configurations",
)
[docs]
@model_validator(mode="before")
@classmethod
def convert_from_dict_or_list(cls, data: Any) -> dict[str, Any]:
"""Convert dict format (from API) or list format to internal structure.
Handles three input formats:
1. Dict with 'formats' key (already in correct format)
2. Dict without 'formats' key (API format - column names as keys)
3. List of ColumnFormat objects or dicts (direct list format)
"""
# If it's already a dict with 'formats', use it as-is
if isinstance(data, dict) and "formats" in data:
formats = data["formats"]
# Ensure all items are ColumnFormat objects
if isinstance(formats, list):
data["formats"] = [
item
if isinstance(item, ColumnFormat)
else ColumnFormat.model_validate(item)
for item in formats
]
return data
# If it's a dict without 'formats', assume it's API format (column names as keys)
if isinstance(data, dict):
formats_list = []
for col_name, col_config in data.items():
if not isinstance(col_config, dict):
raise ValueError(
f"column_format values must be dictionaries, got {type(col_config).__name__} for column '{col_name}'"
)
formats_list.append({"column": col_name, **col_config})
return {"formats": formats_list}
# If it's a list, wrap it in the formats key
if isinstance(data, list):
return {"formats": data}
# For any other type, return as-is and let Pydantic validation handle it
return data
[docs]
@model_serializer
def serialize_to_dict(self) -> dict[str, dict[str, Any]]:
"""Serialize to API format (dict with column names as keys).
Converts the internal list format to the dictionary format expected
by the Datawrapper API, filtering out default values.
"""
if not self.formats:
return {}
result: dict[str, dict[str, Any]] = {}
for col_format in self.formats:
# Extract column name as key
col_name = col_format.column
# Serialize the format config (excluding the column field)
col_config = col_format.model_dump(by_alias=True, exclude={"column"})
# Only include non-default values to match API expectations
filtered_config = {}
for key, value in col_config.items():
# Include if not a default value
if key == "type" and value != "auto":
filtered_config[key] = value
elif key == "ignore" and value is not False:
filtered_config[key] = value
elif key in ("number-prepend", "number-append") and value != "":
filtered_config[key] = value
elif key == "number-divisor" and value not in (0, "0"):
# Convert NumberDivisor enum to its value for API
if isinstance(value, NumberDivisor):
filtered_config[key] = value.value
else:
filtered_config[key] = value
result[col_name] = filtered_config
return result
def __iter__(self):
"""Allow iteration over the formats list."""
return iter(self.formats)
def __len__(self):
"""Return the number of formats."""
return len(self.formats)
def __getitem__(self, index):
"""Allow indexing into the formats list."""
return self.formats[index]
class Transform(BaseModel):
"""A model for the Datawrapper API's 'data' metadata attribute."""
model_config = ConfigDict(
populate_by_name=True,
json_schema_extra={
"examples": [
{
"transpose": False,
"vertical-header": True,
"horizontal-header": True,
"column-order": [0, 1, 2],
"column-format": [
{"column": "sales", "type": "number", "number-prepend": "$"}
],
"external-data": "",
"use-datawrapper-cdn": True,
"upload-method": "copy",
}
]
},
)
#: Whether to transpose the data
transpose: bool = Field(default=False, description="Whether to transpose the data")
#: I don't know what this does
vertical_header: bool = Field(
default=True, alias="vertical-header", description="I don't know what this does"
)
#: I don't know what this does
horizontal_header: bool = Field(
default=True,
alias="horizontal-header",
description="I don't know what this does",
)
# The order of the columns
column_order: list[int] = Field(
default_factory=list,
alias="column-order",
description="The order of the columns",
)
# Use ColumnFormatList wrapper for column-format
column_format: ColumnFormatList = Field(
default_factory=ColumnFormatList,
alias="column-format",
description="The formatting options for the data columns",
)
#: An external data source URL
external_data: str = Field(
default="", alias="external-data", description="An external data source URL"
)
#: Whether or not the external data URL should use the datawrapper CDN
use_datawrapper_cdn: bool = Field(
default=True,
alias="use-datawrapper-cdn",
description="Whether or not the external data URL should use the datawrapper CDN",
)
#: The uploading method for the data
upload_method: Literal["copy", "upload", "google-spreadsheet", "external-data"] = (
Field(
default="copy",
alias="upload-method",
description="The uploading method for the data",
)
)