Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions datawrapper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
SymbolDisplay,
SymbolShape,
SymbolStyle,
TextAlign,
ValueLabelAlignment,
ValueLabelDisplay,
ValueLabelMode,
Expand Down Expand Up @@ -136,6 +137,7 @@
"SymbolDisplay",
"SymbolShape",
"SymbolStyle",
"TextAlign",
"ValueLabelAlignment",
"ValueLabelDisplay",
"ValueLabelMode",
Expand Down
2 changes: 2 additions & 0 deletions datawrapper/charts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
SymbolDisplay,
SymbolShape,
SymbolStyle,
TextAlign,
ValueLabelAlignment,
ValueLabelDisplay,
ValueLabelMode,
Expand Down Expand Up @@ -102,6 +103,7 @@
"SymbolDisplay",
"SymbolShape",
"SymbolStyle",
"TextAlign",
"Transform",
"Describe",
"Logo",
Expand Down
58 changes: 56 additions & 2 deletions datawrapper/charts/annos.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
LineInterpolation,
StrokeType,
StrokeWidth,
TextAlign,
)


Expand Down Expand Up @@ -88,12 +89,33 @@ def validate_stroke(cls, v: StrokeWidth | int) -> StrokeWidth | int:
)

#: The style of the circle at the end of the connector line
circle_style: Literal["solid", "dashed"] = Field(
circle_style: StrokeType | str = Field(
Comment thread
palewire marked this conversation as resolved.
default="solid",
alias="circleStyle",
description="The style of the circle at the end of the connector line",
)

@field_validator("circle_style")
@classmethod
def validate_circle_style(cls, v: StrokeType | str) -> StrokeType | str:
"""Validate that circle_style is either solid or dashed (not dotted).

Handles both string and enum inputs. DOTTED is not allowed.
"""
# Handle enum inputs
if isinstance(v, StrokeType):
if v not in [StrokeType.SOLID, StrokeType.DASHED]:
raise ValueError(
f"Invalid circle style: {v.value}. Must be either 'solid' or 'dashed'"
)
# Handle string inputs
elif isinstance(v, str):
if v not in ["solid", "dashed"]:
raise ValueError(
f"Invalid circle style: {v}. Must be either 'solid' or 'dashed'"
)
return v

#: The radius of the circle at the end of the connector line
circle_radius: int = Field(
default=15,
Expand Down Expand Up @@ -164,10 +186,22 @@ class TextAnnotation(BaseModel):
text: str = Field(min_length=1, description="The text to display")

#: The alignment of the text
align: Literal["tl", "tc", "tr", "ml", "mc", "mr", "bl", "bc", "br"] = Field(
align: TextAlign | str = Field(
default="tl", description="The alignment of the text"
)

@field_validator("align")
@classmethod
def validate_align(cls, v: TextAlign | str) -> TextAlign | str:
"""Validate that align is a valid TextAlign value."""
if isinstance(v, str):
valid_values = [e.value for e in TextAlign]
if v not in valid_values:
raise ValueError(
f"Invalid text alignment: {v}. Must be one of {valid_values}"
)
return v
Comment thread
palewire marked this conversation as resolved.

#: The color of the text
color: str | bool = Field(
default=False, # If you don't set a color, it will default to the Datawrapper standard
Expand All @@ -180,6 +214,16 @@ class TextAnnotation(BaseModel):
description="The width of the text as a percentage of the chart width",
)

@field_validator("width")
@classmethod
def validate_width(cls, v: float) -> float:
"""Validate that width is between 0.0 and 100.0."""
if not 0.0 <= v <= 100.0:
raise ValueError(
f"Invalid width: {v}. Must be between 0.0 and 100.0 (inclusive)"
)
return v

#: Whether or not to italicize the text
italic: bool = Field(
default=False, description="Whether or not to italicize the text"
Expand Down Expand Up @@ -329,6 +373,16 @@ class AreaFill(BaseModel):
#: The opacity of the fill
opacity: float = Field(default=0.3, description="The opacity of the fill")

@field_validator("opacity")
@classmethod
def validate_opacity(cls, v: float) -> float:
"""Validate that opacity is between 0.0 and 1.0."""
if not 0.0 <= v <= 1.0:
raise ValueError(
f"Invalid opacity: {v}. Must be between 0.0 and 1.0 (inclusive)"
)
return v

#: Whether to use different colors when there are negative values
use_mixed_colors: bool = Field(
default=False,
Expand Down
2 changes: 2 additions & 0 deletions datawrapper/charts/enums/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
ScatterSize,
)
from .symbol_shape import SymbolDisplay, SymbolShape, SymbolStyle
from .text_align import TextAlign
from .value_label import (
ValueLabelAlignment,
ValueLabelDisplay,
Expand Down Expand Up @@ -50,6 +51,7 @@
"SymbolDisplay",
"SymbolShape",
"SymbolStyle",
"TextAlign",
"ValueLabelAlignment",
"ValueLabelDisplay",
"ValueLabelMode",
Expand Down
50 changes: 50 additions & 0 deletions datawrapper/charts/enums/text_align.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Text alignment enums for annotations."""

from enum import Enum


class TextAlign(str, Enum):
"""Text alignment positions for annotations.

Represents a 3x3 grid of alignment positions combining vertical and horizontal alignment.

Examples:
>>> from datawrapper.charts import TextAnnotation, TextAlign
>>> anno = TextAnnotation(
... text="Top left corner", x=10, y=20, align=TextAlign.TOP_LEFT
... )
>>> anno.align
<TextAlign.TOP_LEFT: 'tl'>

>>> # Using raw string (backwards compatible)
>>> anno = TextAnnotation(text="Center", x=50, y=50, align="mc")
>>> anno.align
'mc'
"""

#: Top-left alignment
TOP_LEFT = "tl"

#: Top-center alignment
TOP_CENTER = "tc"

#: Top-right alignment
TOP_RIGHT = "tr"

#: Middle-left alignment
MIDDLE_LEFT = "ml"

#: Middle-center alignment
MIDDLE_CENTER = "mc"

#: Middle-right alignment
MIDDLE_RIGHT = "mr"

#: Bottom-left alignment
BOTTOM_LEFT = "bl"

#: Bottom-center alignment
BOTTOM_CENTER = "bc"

#: Bottom-right alignment
BOTTOM_RIGHT = "br"
10 changes: 10 additions & 0 deletions docs/user-guide/api/enums.rst
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,16 @@ SymbolStyle

.. enum-table:: datawrapper.charts.enums.SymbolStyle

TextAlign
---------

.. code-block:: python

import datawrapper as dw
chart = dw.TextAnnotation(align=dw.TextAlign.TOP_LEFT)

.. enum-table:: datawrapper.charts.enums.TextAlign

ValueLabelAlignment
-------------------

Expand Down
94 changes: 94 additions & 0 deletions tests/unit/test_area_fill_opacity_validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Unit tests for AreaFill opacity field validator."""

import pytest
from pydantic import ValidationError

from datawrapper.charts.annos import AreaFill


def test_opacity_valid_default():
"""Test that the default opacity value (0.3) is valid."""
fill = AreaFill(**{"from": "baseline", "to": "new"})
assert fill.opacity == 0.3


def test_opacity_valid_zero():
"""Test that opacity=0.0 is valid (minimum boundary)."""
fill = AreaFill(**{"from": "baseline", "to": "new", "opacity": 0.0})
assert fill.opacity == 0.0


def test_opacity_valid_one():
"""Test that opacity=1.0 is valid (maximum boundary)."""
fill = AreaFill(**{"from": "baseline", "to": "new", "opacity": 1.0})
assert fill.opacity == 1.0


def test_opacity_valid_middle():
"""Test that a middle value like 0.5 is valid."""
fill = AreaFill(**{"from": "baseline", "to": "new", "opacity": 0.5})
assert fill.opacity == 0.5


def test_opacity_valid_decimal():
"""Test that decimal values are valid."""
fill = AreaFill(**{"from": "baseline", "to": "new", "opacity": 0.75})
assert fill.opacity == 0.75


def test_opacity_invalid_negative():
"""Test that negative opacity values raise ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AreaFill(**{"from": "baseline", "to": "new", "opacity": -0.1})

error = exc_info.value.errors()[0]
assert "Invalid opacity: -0.1" in str(error.get("ctx", {}).get("error", ""))
assert "Must be between 0.0 and 1.0" in str(error.get("ctx", {}).get("error", ""))


def test_opacity_invalid_over_one():
"""Test that opacity values over 1.0 raise ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AreaFill(**{"from": "baseline", "to": "new", "opacity": 1.1})

error = exc_info.value.errors()[0]
assert "Invalid opacity: 1.1" in str(error.get("ctx", {}).get("error", ""))
assert "Must be between 0.0 and 1.0" in str(error.get("ctx", {}).get("error", ""))


def test_opacity_invalid_large_value():
"""Test that very large opacity values raise ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AreaFill(**{"from": "baseline", "to": "new", "opacity": 2.0})

error = exc_info.value.errors()[0]
assert "Invalid opacity: 2.0" in str(error.get("ctx", {}).get("error", ""))
assert "Must be between 0.0 and 1.0" in str(error.get("ctx", {}).get("error", ""))


def test_opacity_invalid_very_negative():
"""Test that very negative opacity values raise ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AreaFill(**{"from": "baseline", "to": "new", "opacity": -1.0})

error = exc_info.value.errors()[0]
assert "Invalid opacity: -1.0" in str(error.get("ctx", {}).get("error", ""))
assert "Must be between 0.0 and 1.0" in str(error.get("ctx", {}).get("error", ""))


def test_opacity_boundary_just_below_zero():
"""Test that opacity just below 0.0 raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AreaFill(**{"from": "baseline", "to": "new", "opacity": -0.01})

error = exc_info.value.errors()[0]
assert "Invalid opacity: -0.01" in str(error.get("ctx", {}).get("error", ""))


def test_opacity_boundary_just_above_one():
"""Test that opacity just above 1.0 raises ValidationError."""
with pytest.raises(ValidationError) as exc_info:
AreaFill(**{"from": "baseline", "to": "new", "opacity": 1.01})

error = exc_info.value.errors()[0]
assert "Invalid opacity: 1.01" in str(error.get("ctx", {}).get("error", ""))
Loading