diff --git a/plots/altair/boxplot/box-basic/default.py b/plots/altair/boxplot/box-basic/default.py
new file mode 100644
index 00000000000..b2835f2ef08
--- /dev/null
+++ b/plots/altair/boxplot/box-basic/default.py
@@ -0,0 +1,179 @@
+"""
+box-basic: Basic Box Plot
+Implementation for: altair
+Variant: default
+Python: 3.10+
+"""
+
+from typing import TYPE_CHECKING, Optional
+
+import altair as alt
+import numpy as np
+import pandas as pd
+
+
+if TYPE_CHECKING:
+ from altair import Chart
+
+
+def create_plot(
+ data: pd.DataFrame,
+ values: str,
+ groups: str,
+ title: Optional[str] = None,
+ xlabel: Optional[str] = None,
+ ylabel: Optional[str] = None,
+ color_scheme: str = "set2",
+ width: int = 800,
+ height: int = 450,
+ **kwargs,
+) -> Chart:
+ """
+ Create a basic box plot showing statistical distribution of multiple groups using altair.
+
+ Args:
+ data: Input DataFrame with required columns
+ values: Column name containing numeric values
+ groups: Column name containing group categories
+ title: Plot title (optional)
+ xlabel: Custom x-axis label (optional, defaults to groups column name)
+ ylabel: Custom y-axis label (optional, defaults to values column name)
+ color_scheme: Color scheme for boxes (default: 'set2')
+ width: Figure width in pixels (default: 800)
+ height: Figure height in pixels (default: 450)
+ **kwargs: Additional parameters for altair chart configuration
+
+ Returns:
+ Altair Chart object
+
+ Raises:
+ ValueError: If data is empty
+ KeyError: If required columns not found
+
+ Example:
+ >>> data = pd.DataFrame({
+ ... 'Group': ['A', 'A', 'B', 'B', 'C', 'C'],
+ ... 'Value': [1, 2, 2, 3, 3, 4]
+ ... })
+ >>> chart = create_plot(data, values='Value', groups='Group')
+ """
+ # Input validation
+ if data.empty:
+ raise ValueError("Data cannot be empty")
+
+ # Check required columns
+ for col in [values, groups]:
+ if col not in data.columns:
+ available = ", ".join(data.columns)
+ raise KeyError(f"Column '{col}' not found. Available columns: {available}")
+
+ # Create the box plot using Altair's mark_boxplot
+ base = (
+ alt.Chart(data)
+ .mark_boxplot(
+ extent=1.5, # 1.5 * IQR for whiskers
+ outliers=True,
+ size=40,
+ opacity=0.7,
+ )
+ .encode(
+ x=alt.X(
+ f"{groups}:N",
+ title=xlabel or groups,
+ axis=alt.Axis(labelAngle=0 if data[groups].nunique() <= 5 else -45, labelLimit=200),
+ ),
+ y=alt.Y(f"{values}:Q", title=ylabel or values, scale=alt.Scale(zero=False)),
+ color=alt.Color(
+ f"{groups}:N",
+ scale=alt.Scale(scheme=color_scheme),
+ legend=None, # Hide legend as it's redundant with x-axis
+ ),
+ tooltip=[
+ alt.Tooltip(f"{groups}:N", title="Group"),
+ alt.Tooltip(f"count({values}):Q", title="Count"),
+ alt.Tooltip(f"min({values}):Q", title="Min", format=".2f"),
+ alt.Tooltip(f"q1({values}):Q", title="Q1", format=".2f"),
+ alt.Tooltip(f"median({values}):Q", title="Median", format=".2f"),
+ alt.Tooltip(f"q3({values}):Q", title="Q3", format=".2f"),
+ alt.Tooltip(f"max({values}):Q", title="Max", format=".2f"),
+ ],
+ )
+ )
+
+ # Add sample size annotations
+ text = (
+ alt.Chart(data)
+ .mark_text(align="center", baseline="top", dy=10, fontSize=10, opacity=0.7)
+ .encode(x=alt.X(f"{groups}:N"), y=alt.Y(f"min({values}):Q"), text=alt.Text("count():Q", format="d"))
+ .transform_aggregate(count="count()", groupby=[groups])
+ )
+
+ # Combine box plot with annotations
+ chart = (
+ (base + text)
+ .properties(
+ width=width,
+ height=height,
+ title=alt.TitleParams(text=title or "Box Plot Distribution", fontSize=16, anchor="middle"),
+ )
+ .configure_view(strokeWidth=0)
+ .configure_axis(grid=True, gridOpacity=0.3, gridDash=[3, 3], domainWidth=1, tickWidth=1)
+ .configure_boxplot(
+ median={"color": "red", "strokeWidth": 2},
+ box={"strokeWidth": 1.5},
+ outliers={"fill": "red", "fillOpacity": 0.5, "size": 50},
+ )
+ )
+
+ return chart
+
+
+if __name__ == "__main__":
+ # Sample data for testing with different distributions per group
+ np.random.seed(42) # For reproducibility
+
+ # Generate sample data with 4 groups
+ data_dict = {"Group": [], "Value": []}
+
+ # Group A: Normal distribution, mean=50, std=10
+ group_a_data = np.random.normal(50, 10, 40)
+ # Add some outliers
+ group_a_data = np.append(group_a_data, [80, 85, 15])
+
+ # Group B: Normal distribution, mean=60, std=15
+ group_b_data = np.random.normal(60, 15, 35)
+ # Add outliers
+ group_b_data = np.append(group_b_data, [100, 10])
+
+ # Group C: Normal distribution, mean=45, std=8
+ group_c_data = np.random.normal(45, 8, 45)
+
+ # Group D: Skewed distribution
+ group_d_data = np.random.gamma(2, 2, 30) + 40
+ # Add outliers
+ group_d_data = np.append(group_d_data, [75, 78, 20])
+
+ # Combine all data
+ for group, values in zip(
+ ["Group A", "Group B", "Group C", "Group D"],
+ [group_a_data, group_b_data, group_c_data, group_d_data],
+ strict=False,
+ ):
+ data_dict["Group"].extend([group] * len(values))
+ data_dict["Value"].extend(values)
+
+ data = pd.DataFrame(data_dict)
+
+ # Create plot
+ chart = create_plot(
+ data,
+ values="Value",
+ groups="Group",
+ title="Statistical Distribution Comparison Across Groups",
+ ylabel="Measurement Value",
+ xlabel="Categories",
+ )
+
+ # Save as PNG
+ chart.save("plot.png", scale_factor=2.0)
+ print("Plot saved to plot.png")
diff --git a/plots/bokeh/custom/box-basic/default.py b/plots/bokeh/custom/box-basic/default.py
new file mode 100644
index 00000000000..4760fc06591
--- /dev/null
+++ b/plots/bokeh/custom/box-basic/default.py
@@ -0,0 +1,248 @@
+"""
+box-basic: Basic Box Plot
+Implementation for: bokeh
+Variant: default
+Python: 3.10+
+"""
+
+from typing import TYPE_CHECKING, Optional
+
+import numpy as np
+import pandas as pd
+from bokeh.models import ColumnDataSource, FixedTicker, Label, Whisker
+from bokeh.plotting import figure
+
+
+if TYPE_CHECKING:
+ from bokeh.plotting import Figure
+
+
+def create_plot(
+ data: pd.DataFrame,
+ values: str,
+ groups: str,
+ title: Optional[str] = None,
+ xlabel: Optional[str] = None,
+ ylabel: Optional[str] = None,
+ colors: Optional[list] = None,
+ width: int = 1600,
+ height: int = 900,
+ **kwargs,
+) -> Figure:
+ """
+ Create a basic box plot showing statistical distribution of multiple groups using bokeh.
+
+ Args:
+ data: Input DataFrame with required columns
+ values: Column name containing numeric values
+ groups: Column name containing group categories
+ title: Plot title (optional)
+ xlabel: Custom x-axis label (optional, defaults to groups column name)
+ ylabel: Custom y-axis label (optional, defaults to values column name)
+ colors: List of colors for each box (optional)
+ width: Figure width in pixels (default: 1600)
+ height: Figure height in pixels (default: 900)
+ **kwargs: Additional parameters
+
+ Returns:
+ Bokeh Figure object
+
+ Raises:
+ ValueError: If data is empty
+ KeyError: If required columns not found
+
+ Example:
+ >>> data = pd.DataFrame({
+ ... 'Group': ['A', 'A', 'B', 'B', 'C', 'C'],
+ ... 'Value': [1, 2, 2, 3, 3, 4]
+ ... })
+ >>> fig = create_plot(data, values='Value', groups='Group')
+ """
+ # Input validation
+ if data.empty:
+ raise ValueError("Data cannot be empty")
+
+ # Check required columns
+ for col in [values, groups]:
+ if col not in data.columns:
+ available = ", ".join(data.columns)
+ raise KeyError(f"Column '{col}' not found. Available columns: {available}")
+
+ # Calculate box plot statistics for each group
+ group_names = sorted(data[groups].unique())
+ n_groups = len(group_names)
+
+ # Prepare data for box plot
+ stats = {"x": [], "q1": [], "q2": [], "q3": [], "upper": [], "lower": [], "group": []}
+ outliers = {"x": [], "y": []}
+
+ for i, group in enumerate(group_names):
+ group_data = data[data[groups] == group][values].dropna()
+
+ q1 = group_data.quantile(0.25)
+ q2 = group_data.quantile(0.5)
+ q3 = group_data.quantile(0.75)
+ iqr = q3 - q1
+ upper = min(group_data.max(), q3 + 1.5 * iqr)
+ lower = max(group_data.min(), q1 - 1.5 * iqr)
+
+ stats["x"].append(i)
+ stats["q1"].append(q1)
+ stats["q2"].append(q2)
+ stats["q3"].append(q3)
+ stats["upper"].append(upper)
+ stats["lower"].append(lower)
+ stats["group"].append(group)
+
+ # Find outliers
+ outlier_data = group_data[(group_data < lower) | (group_data > upper)]
+ for val in outlier_data:
+ outliers["x"].append(i)
+ outliers["y"].append(val)
+
+ # Set colors
+ if not colors:
+ from bokeh.palettes import Set2_8
+
+ colors = Set2_8[:n_groups]
+
+ # Create figure with numeric x-axis
+ p = figure(
+ width=width,
+ height=height,
+ title=title or "Box Plot Distribution",
+ toolbar_location="above",
+ tools="pan,wheel_zoom,box_zoom,reset,save",
+ )
+
+ source = ColumnDataSource(data=stats)
+
+ # Draw boxes (Q1 to Q3)
+ box_width = 0.5
+ for i, color in enumerate(colors):
+ p.vbar(
+ x=i,
+ width=box_width,
+ bottom=stats["q1"][i],
+ top=stats["q3"][i],
+ fill_color=color,
+ line_color="black",
+ alpha=0.7,
+ )
+
+ # Draw median lines
+ for i in range(n_groups):
+ p.segment(
+ x0=i - box_width / 2,
+ y0=stats["q2"][i],
+ x1=i + box_width / 2,
+ y1=stats["q2"][i],
+ line_color="red",
+ line_width=2,
+ )
+
+ # Draw whiskers
+ upper_whisker = Whisker(base="x", upper="upper", lower="q3", source=source, line_color="black")
+ upper_whisker.upper_head.size = 10
+ upper_whisker.lower_head.size = 0
+ p.add_layout(upper_whisker)
+
+ lower_whisker = Whisker(base="x", upper="q1", lower="lower", source=source, line_color="black")
+ lower_whisker.upper_head.size = 0
+ lower_whisker.lower_head.size = 10
+ p.add_layout(lower_whisker)
+
+ # Draw outliers
+ if outliers["x"]:
+ outlier_source = ColumnDataSource(data=outliers)
+ p.scatter(x="x", y="y", source=outlier_source, size=8, color="red", alpha=0.5, line_color="black", line_width=1)
+
+ # Set x-axis to show group names
+ p.xaxis.ticker = FixedTicker(ticks=list(range(n_groups)))
+ p.xaxis.major_label_overrides = dict(enumerate(group_names))
+
+ # Labels
+ p.xaxis.axis_label = xlabel or groups
+ p.yaxis.axis_label = ylabel or values
+
+ # Styling
+ p.title.text_font_size = "14pt"
+ p.title.align = "center"
+ p.ygrid.grid_line_alpha = 0.3
+ p.ygrid.grid_line_dash = [6, 4]
+ p.xgrid.visible = False
+
+ # Add sample size annotations
+ group_counts = data.groupby(groups)[values].count()
+ y_min = data[values].min()
+ y_range = data[values].max() - y_min
+ for i, group in enumerate(group_names):
+ count = group_counts[group]
+ label = Label(
+ x=i, y=y_min - y_range * 0.08, text=f"n={count}", text_align="center", text_font_size="9pt", text_alpha=0.7
+ )
+ p.add_layout(label)
+
+ return p
+
+
+if __name__ == "__main__":
+ # Sample data for testing with different distributions per group
+ np.random.seed(42)
+
+ data_dict = {"Group": [], "Value": []}
+
+ # Group A: Normal distribution
+ group_a_data = np.random.normal(50, 10, 40)
+ group_a_data = np.append(group_a_data, [80, 85, 15])
+
+ # Group B: Normal distribution
+ group_b_data = np.random.normal(60, 15, 35)
+ group_b_data = np.append(group_b_data, [100, 10])
+
+ # Group C: Normal distribution
+ group_c_data = np.random.normal(45, 8, 45)
+
+ # Group D: Skewed distribution
+ group_d_data = np.random.gamma(2, 2, 30) + 40
+ group_d_data = np.append(group_d_data, [75, 78, 20])
+
+ # Combine all data
+ for group, values in zip(
+ ["Group A", "Group B", "Group C", "Group D"],
+ [group_a_data, group_b_data, group_c_data, group_d_data],
+ strict=False,
+ ):
+ data_dict["Group"].extend([group] * len(values))
+ data_dict["Value"].extend(values)
+
+ data = pd.DataFrame(data_dict)
+
+ # Create plot
+ fig = create_plot(
+ data,
+ values="Value",
+ groups="Group",
+ title="Statistical Distribution Comparison Across Groups",
+ ylabel="Measurement Value",
+ xlabel="Categories",
+ )
+
+ # Save as PNG using webdriver-manager for automatic chromedriver
+ from bokeh.io import export_png
+ from selenium import webdriver
+ from selenium.webdriver.chrome.options import Options
+ from selenium.webdriver.chrome.service import Service
+ from webdriver_manager.chrome import ChromeDriverManager
+
+ chrome_options = Options()
+ chrome_options.add_argument("--headless")
+ chrome_options.add_argument("--no-sandbox")
+ chrome_options.add_argument("--disable-dev-shm-usage")
+
+ service = Service(ChromeDriverManager().install())
+ driver = webdriver.Chrome(service=service, options=chrome_options)
+
+ export_png(fig, filename="plot.png", webdriver=driver)
+ driver.quit()
+ print("Plot saved to plot.png")
diff --git a/plots/highcharts/boxplot/box-basic/default.py b/plots/highcharts/boxplot/box-basic/default.py
new file mode 100644
index 00000000000..aac3b70725c
--- /dev/null
+++ b/plots/highcharts/boxplot/box-basic/default.py
@@ -0,0 +1,275 @@
+"""
+box-basic: Basic Box Plot
+Implementation for: highcharts
+Variant: default
+Python: 3.10+
+
+Note: Highcharts requires a license for commercial use.
+"""
+
+from typing import Optional
+
+import numpy as np
+import pandas as pd
+from highcharts_core.chart import Chart
+from highcharts_core.options import HighchartsOptions
+from highcharts_core.options.series.boxplot import BoxPlotSeries
+
+
+def create_plot(
+ data: pd.DataFrame,
+ values: str,
+ groups: str,
+ title: Optional[str] = None,
+ xlabel: Optional[str] = None,
+ ylabel: Optional[str] = None,
+ colors: Optional[list] = None,
+ width: int = 1600,
+ height: int = 900,
+ **kwargs,
+) -> Chart:
+ """
+ Create a basic box plot showing statistical distribution of multiple groups using Highcharts.
+
+ Args:
+ data: Input DataFrame with required columns
+ values: Column name containing numeric values
+ groups: Column name containing group categories
+ title: Plot title (optional)
+ xlabel: Custom x-axis label (optional, defaults to groups column name)
+ ylabel: Custom y-axis label (optional, defaults to values column name)
+ colors: List of colors for each box (optional)
+ width: Figure width in pixels (default: 1600)
+ height: Figure height in pixels (default: 900)
+ **kwargs: Additional parameters for Highcharts configuration
+
+ Returns:
+ Highcharts Chart object
+
+ Raises:
+ ValueError: If data is empty
+ KeyError: If required columns not found
+
+ Example:
+ >>> data = pd.DataFrame({
+ ... 'Group': ['A', 'A', 'B', 'B', 'C', 'C'],
+ ... 'Value': [1, 2, 2, 3, 3, 4]
+ ... })
+ >>> chart = create_plot(data, values='Value', groups='Group')
+ """
+ # Input validation
+ if data.empty:
+ raise ValueError("Data cannot be empty")
+
+ # Check required columns
+ for col in [values, groups]:
+ if col not in data.columns:
+ available = ", ".join(data.columns)
+ raise KeyError(f"Column '{col}' not found. Available columns: {available}")
+
+ # Prepare box plot data
+ group_names = sorted(data[groups].unique())
+ box_data = []
+ outliers_data = []
+
+ for i, group in enumerate(group_names):
+ group_data = data[data[groups] == group][values].dropna()
+
+ # Calculate statistics
+ q1 = float(group_data.quantile(0.25))
+ median = float(group_data.quantile(0.5))
+ q3 = float(group_data.quantile(0.75))
+ iqr = q3 - q1
+ lower_whisker = max(float(group_data.min()), q1 - 1.5 * iqr)
+ upper_whisker = min(float(group_data.max()), q3 + 1.5 * iqr)
+
+ # Box plot data: [low, q1, median, q3, high]
+ box_data.append([lower_whisker, q1, median, q3, upper_whisker])
+
+ # Find outliers
+ outliers = group_data[(group_data < lower_whisker) | (group_data > upper_whisker)]
+ for outlier in outliers:
+ outliers_data.append([i, float(outlier)])
+
+ # Create chart
+ chart = Chart()
+
+ # Configure chart options
+ chart.options = HighchartsOptions()
+
+ # Title
+ chart.options.title = {
+ "text": title or "Box Plot Distribution",
+ "style": {"fontSize": "16px", "fontWeight": "bold"},
+ }
+
+ # X-axis
+ chart.options.x_axis = {"categories": list(group_names), "title": {"text": xlabel or groups}}
+
+ # Y-axis
+ chart.options.y_axis = {
+ "title": {"text": ylabel or values},
+ "gridLineWidth": 1,
+ "gridLineDashStyle": "Dot",
+ "gridLineColor": "#e0e0e0",
+ }
+
+ # Colors
+ if colors:
+ chart.options.colors = colors
+ else:
+ chart.options.colors = ["#66c2a5", "#fc8d62", "#8da0cb", "#e78ac3", "#a6d854"]
+
+ # Plot options
+ chart.options.plot_options = {
+ "boxplot": {
+ "fillColor": None,
+ "lineWidth": 2,
+ "medianWidth": 3,
+ "medianColor": "#FF0000",
+ "stemWidth": 1,
+ "whiskerWidth": 2,
+ "whiskerLength": "50%",
+ }
+ }
+
+ # Tooltip
+ chart.options.tooltip = {
+ "shared": False,
+ "useHTML": True,
+ "headerFormat": "{point.key}
",
+ "pointFormat": (
+ "Max: {point.high}
"
+ "Q3: {point.q3}
"
+ 'Median: {point.median}
'
+ "Q1: {point.q1}
"
+ "Min: {point.low}
"
+ ),
+ }
+
+ # Chart dimensions
+ chart.options.chart = {"type": "boxplot", "width": width, "height": height, "backgroundColor": "white"}
+
+ # Add box plot series
+ box_series = BoxPlotSeries()
+ box_series.data = box_data
+ box_series.name = "Distribution"
+ box_series.color_by_point = True
+ chart.add_series(box_series)
+
+ # Add outliers as scatter series if any exist
+ if outliers_data:
+ from highcharts_core.options.series.scatter import ScatterSeries
+
+ scatter_series = ScatterSeries()
+ scatter_series.data = outliers_data
+ scatter_series.name = "Outliers"
+ scatter_series.color = "rgba(255, 0, 0, 0.5)"
+ scatter_series.marker = {
+ "fillColor": "rgba(255, 0, 0, 0.5)",
+ "lineWidth": 1,
+ "lineColor": "#000000",
+ "radius": 4,
+ }
+ scatter_series.tooltip = {"pointFormat": "Outlier: {point.y}"}
+ chart.add_series(scatter_series)
+
+ # Legend
+ chart.options.legend = {
+ "enabled": False # Hide legend for cleaner look
+ }
+
+ # Credits
+ chart.options.credits = {"enabled": False}
+
+ return chart
+
+
+if __name__ == "__main__":
+ # Sample data for testing with different distributions per group
+ np.random.seed(42) # For reproducibility
+
+ # Generate sample data with 4 groups
+ data_dict = {"Group": [], "Value": []}
+
+ # Group A: Normal distribution, mean=50, std=10
+ group_a_data = np.random.normal(50, 10, 40)
+ # Add some outliers
+ group_a_data = np.append(group_a_data, [80, 85, 15])
+
+ # Group B: Normal distribution, mean=60, std=15
+ group_b_data = np.random.normal(60, 15, 35)
+ # Add outliers
+ group_b_data = np.append(group_b_data, [100, 10])
+
+ # Group C: Normal distribution, mean=45, std=8
+ group_c_data = np.random.normal(45, 8, 45)
+
+ # Group D: Skewed distribution
+ group_d_data = np.random.gamma(2, 2, 30) + 40
+ # Add outliers
+ group_d_data = np.append(group_d_data, [75, 78, 20])
+
+ # Combine all data
+ for group, values in zip(
+ ["Group A", "Group B", "Group C", "Group D"],
+ [group_a_data, group_b_data, group_c_data, group_d_data],
+ strict=False,
+ ):
+ data_dict["Group"].extend([group] * len(values))
+ data_dict["Value"].extend(values)
+
+ data = pd.DataFrame(data_dict)
+
+ # Create plot
+ chart = create_plot(
+ data,
+ values="Value",
+ groups="Group",
+ title="Statistical Distribution Comparison Across Groups",
+ ylabel="Measurement Value",
+ xlabel="Categories",
+ )
+
+ # Export to PNG via Selenium screenshot
+ import tempfile
+ import time
+ from pathlib import Path
+
+ from selenium import webdriver
+ from selenium.webdriver.chrome.options import Options
+
+ # Generate HTML content
+ html_str = chart.to_js_literal()
+ html_content = f"""
+
+
+
+
+
+
+
+
+
+
+"""
+
+ # Write temp HTML and take screenshot
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f:
+ f.write(html_content)
+ temp_path = f.name
+
+ chrome_options = Options()
+ chrome_options.add_argument("--headless")
+ chrome_options.add_argument("--no-sandbox")
+ chrome_options.add_argument("--disable-dev-shm-usage")
+ chrome_options.add_argument("--window-size=1600,900")
+
+ driver = webdriver.Chrome(options=chrome_options)
+ driver.get(f"file://{temp_path}")
+ time.sleep(1) # Wait for chart to render
+ driver.save_screenshot("plot.png")
+ driver.quit()
+
+ Path(temp_path).unlink() # Clean up temp file
+ print("Plot saved to plot.png")
diff --git a/plots/matplotlib/boxplot/box-basic/default.py b/plots/matplotlib/boxplot/box-basic/default.py
new file mode 100644
index 00000000000..4fe6fa2776c
--- /dev/null
+++ b/plots/matplotlib/boxplot/box-basic/default.py
@@ -0,0 +1,181 @@
+"""
+box-basic: Basic Box Plot
+Implementation for: matplotlib
+Variant: default
+Python: 3.10+
+"""
+
+from typing import TYPE_CHECKING, Optional
+
+import matplotlib.pyplot as plt
+import numpy as np
+import pandas as pd
+
+
+if TYPE_CHECKING:
+ from matplotlib.figure import Figure
+
+
+def create_plot(
+ data: pd.DataFrame,
+ values: str,
+ groups: str,
+ title: Optional[str] = None,
+ xlabel: Optional[str] = None,
+ ylabel: Optional[str] = None,
+ colors: Optional[list] = None,
+ figsize: tuple[float, float] = (16, 9),
+ **kwargs,
+) -> Figure:
+ """
+ Create a basic box plot showing statistical distribution of multiple groups.
+
+ Args:
+ data: Input DataFrame with required columns
+ values: Column name containing numeric values
+ groups: Column name containing group categories
+ title: Plot title (optional)
+ xlabel: Custom x-axis label (optional, defaults to groups column name)
+ ylabel: Custom y-axis label (optional, defaults to values column name)
+ colors: List of colors for each box (optional)
+ figsize: Figure size as (width, height) in inches (default: (16, 9))
+ **kwargs: Additional parameters passed to boxplot function
+
+ Returns:
+ Matplotlib Figure object
+
+ Raises:
+ ValueError: If data is empty
+ KeyError: If required columns not found
+
+ Example:
+ >>> data = pd.DataFrame({
+ ... 'Group': ['A', 'A', 'B', 'B', 'C', 'C'],
+ ... 'Value': [1, 2, 2, 3, 3, 4]
+ ... })
+ >>> fig = create_plot(data, values='Value', groups='Group')
+ """
+ # Input validation
+ if data.empty:
+ raise ValueError("Data cannot be empty")
+
+ # Check required columns
+ for col in [values, groups]:
+ if col not in data.columns:
+ available = ", ".join(data.columns)
+ raise KeyError(f"Column '{col}' not found. Available columns: {available}")
+
+ # Prepare data for boxplot
+ grouped_data = [group[values].dropna().values for name, group in data.groupby(groups)]
+ group_names = data[groups].unique()
+
+ # Create figure
+ fig, ax = plt.subplots(figsize=figsize)
+
+ # Create boxplot
+ bp = ax.boxplot(
+ grouped_data,
+ tick_labels=group_names,
+ patch_artist=True, # Enable filling boxes with colors
+ showmeans=False,
+ notch=False,
+ widths=0.7,
+ **kwargs,
+ )
+
+ # Apply colors if provided
+ if colors:
+ for patch, color in zip(bp["boxes"], colors * len(bp["boxes"]), strict=False):
+ patch.set_facecolor(color)
+ patch.set_alpha(0.7)
+ else:
+ # Use a default color scheme
+ default_colors = plt.cm.Set2(np.linspace(0, 1, len(bp["boxes"])))
+ for patch, color in zip(bp["boxes"], default_colors, strict=False):
+ patch.set_facecolor(color)
+ patch.set_alpha(0.7)
+
+ # Customize whiskers, caps, medians, and outliers
+ for whisker in bp["whiskers"]:
+ whisker.set(color="#8B8B8B", linewidth=1.5, linestyle="-")
+
+ for cap in bp["caps"]:
+ cap.set(color="#8B8B8B", linewidth=2)
+
+ for median in bp["medians"]:
+ median.set(color="#FF0000", linewidth=2)
+
+ for flier in bp["fliers"]:
+ flier.set(marker="o", markerfacecolor="#FF0000", markersize=8, alpha=0.5, markeredgecolor="#8B8B8B")
+
+ # Labels and title
+ ax.set_xlabel(xlabel or groups)
+ ax.set_ylabel(ylabel or values)
+
+ if title:
+ ax.set_title(title, fontsize=14, fontweight="bold")
+
+ # Grid for better readability
+ ax.grid(True, axis="y", alpha=0.3, linestyle="--")
+ ax.set_axisbelow(True)
+
+ # Rotate x-axis labels if there are many groups
+ if len(group_names) > 5:
+ plt.xticks(rotation=45, ha="right")
+
+ # Layout
+ plt.tight_layout()
+
+ return fig
+
+
+if __name__ == "__main__":
+ # Sample data for testing with different distributions per group
+ np.random.seed(42) # For reproducibility
+
+ # Generate sample data with 4 groups
+ group_names = ["Group A", "Group B", "Group C", "Group D"]
+ data_dict = {"Group": [], "Value": []}
+
+ # Group A: Normal distribution, mean=50, std=10
+ group_a_data = np.random.normal(50, 10, 40)
+ # Add some outliers
+ group_a_data = np.append(group_a_data, [80, 85, 15])
+
+ # Group B: Normal distribution, mean=60, std=15
+ group_b_data = np.random.normal(60, 15, 35)
+ # Add outliers
+ group_b_data = np.append(group_b_data, [100, 10])
+
+ # Group C: Normal distribution, mean=45, std=8
+ group_c_data = np.random.normal(45, 8, 45)
+
+ # Group D: Skewed distribution
+ group_d_data = np.random.gamma(2, 2, 30) + 40
+ # Add outliers
+ group_d_data = np.append(group_d_data, [75, 78, 20])
+
+ # Combine all data
+ for group, values in zip(
+ ["Group A", "Group B", "Group C", "Group D"],
+ [group_a_data, group_b_data, group_c_data, group_d_data],
+ strict=False,
+ ):
+ data_dict["Group"].extend([group] * len(values))
+ data_dict["Value"].extend(values)
+
+ data = pd.DataFrame(data_dict)
+
+ # Create plot
+ fig = create_plot(
+ data,
+ values="Value",
+ groups="Group",
+ title="Statistical Distribution Comparison Across Groups",
+ ylabel="Measurement Value",
+ xlabel="Groups",
+ )
+
+ # Save for inspection
+ plt.savefig("plot.png", dpi=300, bbox_inches="tight")
+ print("Plot saved to plot.png")
diff --git a/plots/plotly/box/box-basic/default.py b/plots/plotly/box/box-basic/default.py
new file mode 100644
index 00000000000..1cf05b7dfeb
--- /dev/null
+++ b/plots/plotly/box/box-basic/default.py
@@ -0,0 +1,206 @@
+"""
+box-basic: Basic Box Plot
+Implementation for: plotly
+Variant: default
+Python: 3.10+
+"""
+
+from typing import TYPE_CHECKING, Optional
+
+import numpy as np
+import pandas as pd
+import plotly.express as px
+
+
+if TYPE_CHECKING:
+ from plotly.graph_objects import Figure
+
+
+def create_plot(
+ data: pd.DataFrame,
+ values: str,
+ groups: str,
+ title: Optional[str] = None,
+ xlabel: Optional[str] = None,
+ ylabel: Optional[str] = None,
+ color_discrete_sequence: Optional[list] = None,
+ height: int = 900,
+ width: int = 1600,
+ showlegend: bool = False,
+ **kwargs,
+) -> Figure:
+ """
+ Create an interactive box plot showing statistical distribution of multiple groups using plotly.
+
+ Args:
+ data: Input DataFrame with required columns
+ values: Column name containing numeric values
+ groups: Column name containing group categories
+ title: Plot title (optional)
+ xlabel: Custom x-axis label (optional, defaults to groups column name)
+ ylabel: Custom y-axis label (optional, defaults to values column name)
+ color_discrete_sequence: List of colors for each box (optional)
+ height: Figure height in pixels (default: 900)
+ width: Figure width in pixels (default: 1600)
+ showlegend: Whether to show legend (default: False)
+ **kwargs: Additional parameters passed to plotly box trace
+
+ Returns:
+ Plotly Figure object
+
+ Raises:
+ ValueError: If data is empty
+ KeyError: If required columns not found
+
+ Example:
+ >>> data = pd.DataFrame({
+ ... 'Group': ['A', 'A', 'B', 'B', 'C', 'C'],
+ ... 'Value': [1, 2, 2, 3, 3, 4]
+ ... })
+ >>> fig = create_plot(data, values='Value', groups='Group')
+ """
+ # Input validation
+ if data.empty:
+ raise ValueError("Data cannot be empty")
+
+ # Check required columns
+ for col in [values, groups]:
+ if col not in data.columns:
+ available = ", ".join(data.columns)
+ raise KeyError(f"Column '{col}' not found. Available columns: {available}")
+
+ # Use plotly.express for easier box plot creation
+ fig = px.box(
+ data,
+ x=groups,
+ y=values,
+ color=groups,
+ color_discrete_sequence=color_discrete_sequence or px.colors.qualitative.Set2,
+ notched=False,
+ points="outliers", # Show only outliers as points
+ **kwargs,
+ )
+
+ # Update traces for better styling
+ fig.update_traces(
+ boxmean="sd", # Show mean and standard deviation
+ marker={"size": 8, "opacity": 0.5, "line": {"width": 1}},
+ line={"width": 1.5},
+ fillcolor=None,
+ opacity=0.7,
+ )
+
+ # Update layout
+ fig.update_layout(
+ title={
+ "text": title or "Box Plot Distribution",
+ "font": {"size": 16, "family": "Arial, sans-serif"},
+ "x": 0.5,
+ "xanchor": "center",
+ },
+ xaxis={
+ "title": xlabel or groups,
+ "gridcolor": "lightgray",
+ "gridwidth": 0.5,
+ "showgrid": False,
+ "zeroline": False,
+ },
+ yaxis={
+ "title": ylabel or values,
+ "gridcolor": "lightgray",
+ "gridwidth": 0.5,
+ "showgrid": True,
+ "zeroline": True,
+ "zerolinewidth": 1,
+ "zerolinecolor": "lightgray",
+ },
+ plot_bgcolor="white",
+ paper_bgcolor="white",
+ height=height,
+ width=width,
+ showlegend=showlegend,
+ hovermode="x unified",
+ hoverlabel={"bgcolor": "white", "font_size": 12, "font_family": "Arial, sans-serif"},
+ )
+
+ # Add annotations with sample sizes
+ group_counts = data.groupby(groups)[values].count()
+ annotations = []
+ for _i, (group_name, count) in enumerate(group_counts.items()):
+ annotations.append(
+ {
+ "x": group_name,
+ "y": data[data[groups] == group_name][values].min() - (data[values].max() - data[values].min()) * 0.05,
+ "text": f"n={count}",
+ "showarrow": False,
+ "font": {"size": 10, "color": "gray"},
+ "xanchor": "center",
+ "yanchor": "top",
+ }
+ )
+
+ fig.update_layout(annotations=annotations)
+
+ # Update hover template for better information
+ fig.update_traces(
+ hovertemplate="%{x}
"
+ + "Max: %{y}
"
+ + "Q3: %{upperfence}
"
+ + "Median: %{median}
"
+ + "Q1: %{lowerfence}
"
+ + "Min: %{y}
"
+ + ""
+ )
+
+ return fig
+
+
+if __name__ == "__main__":
+ # Sample data for testing with different distributions per group
+ np.random.seed(42) # For reproducibility
+
+ # Generate sample data with 4 groups
+ data_dict = {"Group": [], "Value": []}
+
+ # Group A: Normal distribution, mean=50, std=10
+ group_a_data = np.random.normal(50, 10, 40)
+ # Add some outliers
+ group_a_data = np.append(group_a_data, [80, 85, 15])
+
+ # Group B: Normal distribution, mean=60, std=15
+ group_b_data = np.random.normal(60, 15, 35)
+ # Add outliers
+ group_b_data = np.append(group_b_data, [100, 10])
+
+ # Group C: Normal distribution, mean=45, std=8
+ group_c_data = np.random.normal(45, 8, 45)
+
+ # Group D: Skewed distribution
+ group_d_data = np.random.gamma(2, 2, 30) + 40
+ # Add outliers
+ group_d_data = np.append(group_d_data, [75, 78, 20])
+
+ # Combine all data
+ for group, values in zip(
+ ["Group A", "Group B", "Group C", "Group D"],
+ [group_a_data, group_b_data, group_c_data, group_d_data],
+ strict=False,
+ ):
+ data_dict["Group"].extend([group] * len(values))
+ data_dict["Value"].extend(values)
+
+ data = pd.DataFrame(data_dict)
+
+ # Create plot
+ fig = create_plot(
+ data,
+ values="Value",
+ groups="Group",
+ title="Statistical Distribution Comparison Across Groups",
+ ylabel="Measurement Value",
+ xlabel="Categories",
+ )
+
+ # Save as PNG
+ fig.write_image("plot.png", width=1600, height=900, scale=2)
+ print("Plot saved to plot.png")
diff --git a/plots/plotnine/boxplot/box-basic/default.py b/plots/plotnine/boxplot/box-basic/default.py
new file mode 100644
index 00000000000..65226940b6a
--- /dev/null
+++ b/plots/plotnine/boxplot/box-basic/default.py
@@ -0,0 +1,183 @@
+"""
+box-basic: Basic Box Plot
+Implementation for: plotnine
+Variant: default
+Python: 3.10+
+"""
+
+from typing import TYPE_CHECKING, Optional
+
+import numpy as np
+import pandas as pd
+from plotnine import (
+ aes,
+ element_line,
+ element_text,
+ geom_boxplot,
+ ggplot,
+ labs,
+ scale_fill_brewer,
+ theme,
+ theme_minimal,
+)
+
+
+if TYPE_CHECKING:
+ from plotnine import ggplot as GGPlot
+
+
+def create_plot(
+ data: pd.DataFrame,
+ values: str,
+ groups: str,
+ title: Optional[str] = None,
+ xlabel: Optional[str] = None,
+ ylabel: Optional[str] = None,
+ fill_palette: str = "Set2",
+ width: int = 16,
+ height: int = 9,
+ show_outliers: bool = True,
+ **kwargs,
+) -> GGPlot:
+ """
+ Create a basic box plot showing statistical distribution of multiple groups using plotnine (ggplot2 syntax).
+
+ Args:
+ data: Input DataFrame with required columns
+ values: Column name containing numeric values
+ groups: Column name containing group categories
+ title: Plot title (optional)
+ xlabel: Custom x-axis label (optional, defaults to groups column name)
+ ylabel: Custom y-axis label (optional, defaults to values column name)
+ fill_palette: Color palette for boxes (default: 'Set2')
+ width: Figure width in inches (default: 16)
+ height: Figure height in inches (default: 9)
+ show_outliers: Whether to show outliers (default: True)
+ **kwargs: Additional parameters for geom_boxplot
+
+ Returns:
+ plotnine ggplot object
+
+ Raises:
+ ValueError: If data is empty
+ KeyError: If required columns not found
+
+ Example:
+ >>> data = pd.DataFrame({
+ ... 'Group': ['A', 'A', 'B', 'B', 'C', 'C'],
+ ... 'Value': [1, 2, 2, 3, 3, 4]
+ ... })
+ >>> plot = create_plot(data, values='Value', groups='Group')
+ """
+ # Input validation
+ if data.empty:
+ raise ValueError("Data cannot be empty")
+
+ # Check required columns
+ for col in [values, groups]:
+ if col not in data.columns:
+ available = ", ".join(data.columns)
+ raise KeyError(f"Column '{col}' not found. Available columns: {available}")
+
+ # Create the ggplot object
+ plot = (
+ ggplot(data, aes(x=groups, y=values, fill=groups))
+ + geom_boxplot(
+ alpha=0.7,
+ outlier_alpha=0.5 if show_outliers else 0,
+ outlier_size=2,
+ outlier_color="red",
+ width=0.6,
+ **kwargs,
+ )
+ + scale_fill_brewer(type="qual", palette=fill_palette, guide=None) # Hide legend
+ + labs(title=title or "Box Plot Distribution", x=xlabel or groups, y=ylabel or values)
+ + theme_minimal()
+ + theme(
+ figure_size=(width, height),
+ plot_title=element_text(size=14, weight="bold", ha="center"),
+ axis_title=element_text(size=11),
+ axis_text=element_text(size=10),
+ panel_grid_major_x=element_line(alpha=0),
+ panel_grid_major_y=element_line(alpha=0.3, linetype="dashed"),
+ panel_grid_minor=element_line(alpha=0),
+ )
+ )
+
+ # Rotate x-axis labels if there are many groups
+ unique_groups = data[groups].nunique()
+ if unique_groups > 5:
+ plot = plot + theme(axis_text_x=element_text(angle=45, ha="right"))
+
+ # Add sample size annotations
+ # plotnine doesn't have easy text annotations like ggplot2's annotate,
+ # but we can add them as a separate layer
+ from plotnine import geom_text
+
+ # Calculate group statistics for annotations
+ group_stats = data.groupby(groups).agg(count=(values, "count"), min_val=(values, "min")).reset_index()
+
+ # Adjust y position for annotations
+ y_range = data[values].max() - data[values].min()
+ y_position = data[values].min() - y_range * 0.05
+
+ group_stats["y_pos"] = y_position
+ group_stats["label"] = "n=" + group_stats["count"].astype(str)
+
+ # Add annotations as a separate layer
+ plot = plot + geom_text(
+ aes(x=groups, y="y_pos", label="label"), data=group_stats, size=9, alpha=0.7, va="top", ha="center"
+ )
+
+ return plot
+
+
+if __name__ == "__main__":
+ # Sample data for testing with different distributions per group
+ np.random.seed(42) # For reproducibility
+
+ # Generate sample data with 4 groups
+ data_dict = {"Group": [], "Value": []}
+
+ # Group A: Normal distribution, mean=50, std=10
+ group_a_data = np.random.normal(50, 10, 40)
+ # Add some outliers
+ group_a_data = np.append(group_a_data, [80, 85, 15])
+
+ # Group B: Normal distribution, mean=60, std=15
+ group_b_data = np.random.normal(60, 15, 35)
+ # Add outliers
+ group_b_data = np.append(group_b_data, [100, 10])
+
+ # Group C: Normal distribution, mean=45, std=8
+ group_c_data = np.random.normal(45, 8, 45)
+
+ # Group D: Skewed distribution
+ group_d_data = np.random.gamma(2, 2, 30) + 40
+ # Add outliers
+ group_d_data = np.append(group_d_data, [75, 78, 20])
+
+ # Combine all data
+ for group, values in zip(
+ ["Group A", "Group B", "Group C", "Group D"],
+ [group_a_data, group_b_data, group_c_data, group_d_data],
+ strict=False,
+ ):
+ data_dict["Group"].extend([group] * len(values))
+ data_dict["Value"].extend(values)
+
+ data = pd.DataFrame(data_dict)
+
+ # Create plot
+ plot = create_plot(
+ data,
+ values="Value",
+ groups="Group",
+ title="Statistical Distribution Comparison Across Groups",
+ ylabel="Measurement Value",
+ xlabel="Categories",
+ )
+
+ # Save for inspection
+ plot.save("plot.png", dpi=300, verbose=False)
+ print("Plot saved to plot.png")
diff --git a/plots/pygal/box/box-basic/default.py b/plots/pygal/box/box-basic/default.py
new file mode 100644
index 00000000000..a71dedf8872
--- /dev/null
+++ b/plots/pygal/box/box-basic/default.py
@@ -0,0 +1,166 @@
+"""
+box-basic: Basic Box Plot
+Implementation for: pygal
+Variant: default
+Python: 3.10+
+"""
+
+from typing import TYPE_CHECKING, Optional
+
+import numpy as np
+import pandas as pd
+import pygal
+from pygal.style import Style
+
+
+if TYPE_CHECKING:
+ from pygal import Box
+
+
+def create_plot(
+ data: pd.DataFrame,
+ values: str,
+ groups: str,
+ title: Optional[str] = None,
+ xlabel: Optional[str] = None,
+ ylabel: Optional[str] = None,
+ width: int = 1600,
+ height: int = 900,
+ show_legend: bool = True,
+ **kwargs,
+) -> Box:
+ """
+ Create a basic box plot showing statistical distribution of multiple groups using pygal.
+
+ Args:
+ data: Input DataFrame with required columns
+ values: Column name containing numeric values
+ groups: Column name containing group categories
+ title: Plot title (optional)
+ xlabel: Custom x-axis label (optional, defaults to groups column name)
+ ylabel: Custom y-axis label (optional, defaults to values column name)
+ width: Figure width in pixels (default: 1600)
+ height: Figure height in pixels (default: 900)
+ show_legend: Whether to show legend (default: True)
+ **kwargs: Additional parameters for pygal configuration
+
+ Returns:
+ pygal Box chart object
+
+ Raises:
+ ValueError: If data is empty
+ KeyError: If required columns not found
+
+ Example:
+ >>> data = pd.DataFrame({
+ ... 'Group': ['A', 'A', 'B', 'B', 'C', 'C'],
+ ... 'Value': [1, 2, 2, 3, 3, 4]
+ ... })
+ >>> chart = create_plot(data, values='Value', groups='Group')
+ """
+ # Input validation
+ if data.empty:
+ raise ValueError("Data cannot be empty")
+
+ # Check required columns
+ for col in [values, groups]:
+ if col not in data.columns:
+ available = ", ".join(data.columns)
+ raise KeyError(f"Column '{col}' not found. Available columns: {available}")
+
+ # Create custom style
+ custom_style = Style(
+ background="white",
+ plot_background="white",
+ foreground="#333",
+ foreground_strong="#333",
+ foreground_subtle="#555",
+ opacity=0.7,
+ opacity_hover=0.9,
+ colors=("#66c2a5", "#fc8d62", "#8da0cb", "#e78ac3", "#a6d854", "#ffd92f", "#e5c494", "#b3b3b3"),
+ font_family="Arial, sans-serif",
+ major_guide_stroke_dasharray="3,3",
+ guide_stroke_dasharray="1,1",
+ )
+
+ # Create box plot
+ box_chart = pygal.Box(
+ title=title or "Box Plot Distribution",
+ x_title=xlabel or groups,
+ y_title=ylabel or values,
+ width=width,
+ height=height,
+ show_legend=show_legend,
+ style=custom_style,
+ box_mode="tukey", # Use Tukey method (1.5 * IQR for whiskers)
+ print_values=False,
+ print_zeroes=False,
+ **kwargs,
+ )
+
+ # Calculate box plot data for each group
+ group_names = sorted(data[groups].unique())
+
+ for group in group_names:
+ group_data = data[data[groups] == group][values].dropna()
+
+ # Pygal's Box chart expects data in a specific format:
+ # [min, Q1, median, Q3, max] or the raw values (pygal will calculate)
+ # We'll provide the raw values and let pygal handle the calculations
+ values_list = group_data.tolist()
+
+ # Add the series with label
+ box_chart.add(f"{group} (n={len(values_list)})", values_list)
+
+ return box_chart
+
+
+if __name__ == "__main__":
+ # Sample data for testing with different distributions per group
+ np.random.seed(42) # For reproducibility
+
+ # Generate sample data with 4 groups
+ data_dict = {"Group": [], "Value": []}
+
+ # Group A: Normal distribution, mean=50, std=10
+ group_a_data = np.random.normal(50, 10, 40)
+ # Add some outliers
+ group_a_data = np.append(group_a_data, [80, 85, 15])
+
+ # Group B: Normal distribution, mean=60, std=15
+ group_b_data = np.random.normal(60, 15, 35)
+ # Add outliers
+ group_b_data = np.append(group_b_data, [100, 10])
+
+ # Group C: Normal distribution, mean=45, std=8
+ group_c_data = np.random.normal(45, 8, 45)
+
+ # Group D: Skewed distribution
+ group_d_data = np.random.gamma(2, 2, 30) + 40
+ # Add outliers
+ group_d_data = np.append(group_d_data, [75, 78, 20])
+
+ # Combine all data
+ for group, values in zip(
+ ["Group A", "Group B", "Group C", "Group D"],
+ [group_a_data, group_b_data, group_c_data, group_d_data],
+ strict=False,
+ ):
+ data_dict["Group"].extend([group] * len(values))
+ data_dict["Value"].extend(values)
+
+ data = pd.DataFrame(data_dict)
+
+ # Create plot
+ chart = create_plot(
+ data,
+ values="Value",
+ groups="Group",
+ title="Statistical Distribution Comparison Across Groups",
+ ylabel="Measurement Value",
+ xlabel="Categories",
+ )
+
+ # Save as PNG
+ chart.render_to_png("plot.png")
+ print("Plot saved to plot.png")
diff --git a/plots/seaborn/boxplot/box-basic/default.py b/plots/seaborn/boxplot/box-basic/default.py
new file mode 100644
index 00000000000..d2a1cd1f30d
--- /dev/null
+++ b/plots/seaborn/boxplot/box-basic/default.py
@@ -0,0 +1,189 @@
+"""
+box-basic: Basic Box Plot
+Implementation for: seaborn
+Variant: default
+Python: 3.10+
+"""
+
+from typing import TYPE_CHECKING, Optional
+
+import matplotlib.pyplot as plt
+import numpy as np
+import pandas as pd
+import seaborn as sns
+
+
+if TYPE_CHECKING:
+ from matplotlib.figure import Figure
+
+
+def create_plot(
+ data: pd.DataFrame,
+ values: str,
+ groups: str,
+ title: Optional[str] = None,
+ xlabel: Optional[str] = None,
+ ylabel: Optional[str] = None,
+ palette: Optional[str] = "Set2",
+ figsize: tuple[float, float] = (16, 9),
+ showfliers: bool = True,
+ **kwargs,
+) -> Figure:
+ """
+ Create a basic box plot showing statistical distribution of multiple groups using seaborn.
+
+ Args:
+ data: Input DataFrame with required columns
+ values: Column name containing numeric values
+ groups: Column name containing group categories
+ title: Plot title (optional)
+ xlabel: Custom x-axis label (optional, defaults to groups column name)
+ ylabel: Custom y-axis label (optional, defaults to values column name)
+ palette: Color palette name for boxes (default: 'Set2')
+ figsize: Figure size as (width, height) in inches (default: (16, 9))
+ showfliers: Whether to show outliers (default: True)
+ **kwargs: Additional parameters passed to seaborn boxplot function
+
+ Returns:
+ Matplotlib Figure object
+
+ Raises:
+ ValueError: If data is empty
+ KeyError: If required columns not found
+
+ Example:
+ >>> data = pd.DataFrame({
+ ... 'Group': ['A', 'A', 'B', 'B', 'C', 'C'],
+ ... 'Value': [1, 2, 2, 3, 3, 4]
+ ... })
+ >>> fig = create_plot(data, values='Value', groups='Group')
+ """
+ # Input validation
+ if data.empty:
+ raise ValueError("Data cannot be empty")
+
+ # Check required columns
+ for col in [values, groups]:
+ if col not in data.columns:
+ available = ", ".join(data.columns)
+ raise KeyError(f"Column '{col}' not found. Available columns: {available}")
+
+ # Create figure
+ fig, ax = plt.subplots(figsize=figsize)
+
+ # Create boxplot with seaborn
+ sns.boxplot(
+ data=data,
+ x=groups,
+ y=values,
+ hue=groups,
+ palette=palette,
+ ax=ax,
+ showfliers=showfliers,
+ width=0.7,
+ linewidth=1.5,
+ fliersize=6,
+ legend=False,
+ **kwargs,
+ )
+
+ # Customize the appearance
+ # Set median line color to be more visible
+ for patch in ax.artists:
+ # Get the current face color
+ r, g, b, a = patch.get_facecolor()
+ # Set the box face color with some transparency
+ patch.set_facecolor((r, g, b, 0.7))
+ # Set edge color
+ patch.set_edgecolor("black")
+ patch.set_linewidth(1.2)
+
+ # Style the median lines
+ for line in ax.lines:
+ # Median lines are the ones inside the boxes
+ if line.get_linestyle() == "-" and line.get_marker() == "None":
+ line.set_color("red")
+ line.set_linewidth(2)
+
+ # Labels and title
+ ax.set_xlabel(xlabel or groups)
+ ax.set_ylabel(ylabel or values)
+
+ if title:
+ ax.set_title(title, fontsize=14, fontweight="bold", pad=20)
+
+ # Grid for better readability
+ ax.grid(True, axis="y", alpha=0.3, linestyle="--")
+ ax.set_axisbelow(True)
+
+ # Rotate x-axis labels if there are many groups
+ unique_groups = data[groups].nunique()
+ if unique_groups > 5:
+ plt.xticks(rotation=45, ha="right")
+
+ # Add some statistical annotations
+ # Calculate and display the number of data points per group
+ group_counts = data.groupby(groups)[values].count()
+ y_bottom = ax.get_ylim()[0]
+ for i, (_group_name, count) in enumerate(group_counts.items()):
+ ax.text(i, y_bottom, f"n={count}", ha="center", va="top", fontsize=9, alpha=0.7)
+
+ # Apply seaborn style for better aesthetics
+ sns.despine(ax=ax)
+
+ # Layout
+ plt.tight_layout()
+
+ return fig
+
+
+if __name__ == "__main__":
+ # Sample data for testing with different distributions per group
+ np.random.seed(42) # For reproducibility
+
+ # Generate sample data with 4 groups
+ data_dict = {"Group": [], "Value": []}
+
+ # Group A: Normal distribution, mean=50, std=10
+ group_a_data = np.random.normal(50, 10, 40)
+ # Add some outliers
+ group_a_data = np.append(group_a_data, [80, 85, 15])
+
+ # Group B: Normal distribution, mean=60, std=15
+ group_b_data = np.random.normal(60, 15, 35)
+ # Add outliers
+ group_b_data = np.append(group_b_data, [100, 10])
+
+ # Group C: Normal distribution, mean=45, std=8
+ group_c_data = np.random.normal(45, 8, 45)
+
+ # Group D: Skewed distribution
+ group_d_data = np.random.gamma(2, 2, 30) + 40
+ # Add outliers
+ group_d_data = np.append(group_d_data, [75, 78, 20])
+
+ # Combine all data
+ for group, values in zip(
+ ["Group A", "Group B", "Group C", "Group D"],
+ [group_a_data, group_b_data, group_c_data, group_d_data],
+ strict=False,
+ ):
+ data_dict["Group"].extend([group] * len(values))
+ data_dict["Value"].extend(values)
+
+ data = pd.DataFrame(data_dict)
+
+ # Create plot
+ fig = create_plot(
+ data,
+ values="Value",
+ groups="Group",
+ title="Statistical Distribution Comparison Across Groups",
+ ylabel="Measurement Value",
+ xlabel="Categories",
+ palette="Set2",
+ )
+
+ # Save for inspection
+ plt.savefig("plot.png", dpi=300, bbox_inches="tight")
+ print("Plot saved to plot.png")
diff --git a/pyproject.toml b/pyproject.toml
index fb45f97fb8e..a3fe29e367a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,6 +63,12 @@ plotting = [
"plotnine>=0.13.0",
"pygal>=3.0.0",
"highcharts-core>=1.10.0",
+ # PNG export dependencies
+ "vl-convert-python>=1.3.0", # altair PNG export
+ "kaleido>=0.2.1", # plotly PNG export
+ "selenium>=4.15.0", # bokeh PNG export
+ "webdriver-manager>=4.0.0", # auto-install chromedriver
+ "cairosvg>=2.7.0", # pygal PNG export
]
all = [
"pyplots[test,dev,plotting,typecheck]",
diff --git a/rules/generation/v1.0.0-draft/code-generation-rules.md b/rules/generation/v1.0.0-draft/code-generation-rules.md
index 2854167fd66..5a113fc69e7 100644
--- a/rules/generation/v1.0.0-draft/code-generation-rules.md
+++ b/rules/generation/v1.0.0-draft/code-generation-rules.md
@@ -19,7 +19,7 @@ Define how to generate plot implementation code from Markdown specifications.
### Required
1. **Spec Markdown**: Complete spec file from `specs/{spec-id}.md`
-2. **Target Library**: matplotlib, seaborn, plotly, bokeh, or altair
+2. **Target Library**: matplotlib, seaborn, plotly, bokeh, altair, plotnine, pygal, or highcharts
3. **Variant**: default, {style}_style, or py{version}
### Optional
@@ -31,6 +31,23 @@ Define how to generate plot implementation code from Markdown specifications.
## Output Requirements
+### Directory Structure
+
+The folder name must match the library's API function name for the plot type:
+
+| Library | Function | Folder Example |
+|---------|----------|----------------|
+| matplotlib | `ax.boxplot()` | `plots/matplotlib/boxplot/` |
+| seaborn | `sns.boxplot()` | `plots/seaborn/boxplot/` |
+| plotly | `go.Box()` | `plots/plotly/box/` |
+| pygal | `pygal.Box()` | `plots/pygal/box/` |
+| altair | `mark_boxplot()` | `plots/altair/boxplot/` |
+| plotnine | `geom_boxplot()` | `plots/plotnine/boxplot/` |
+| highcharts | `BoxPlotSeries` | `plots/highcharts/boxplot/` |
+
+**Fallback for libraries without native function:** Use `custom/`
+- Example: Bokeh has no native boxplot → `plots/bokeh/custom/`
+
### File Structure
```python
@@ -119,6 +136,28 @@ if __name__ == '__main__':
> Namen. Verwende NIEMALS `test_output_matplotlib.png`, `test_output_seaborn.png` oder
> ähnliche library-spezifische Namen.
+### Output Format Requirements
+
+**Current Phase: PNG only**
+
+All plots must output `plot.png`. No HTML, SVG, or interactive outputs.
+
+| Library | PNG Export Method |
+|---------|-------------------|
+| matplotlib | `plt.savefig('plot.png', dpi=300, bbox_inches='tight')` |
+| seaborn | `plt.savefig('plot.png', dpi=300, bbox_inches='tight')` |
+| plotly | `fig.write_image('plot.png', width=1000, height=600, scale=2)` |
+| bokeh | `export_png(fig, filename='plot.png')` |
+| altair | `chart.save('plot.png', scale_factor=2.0)` |
+| plotnine | `plot.save('plot.png', dpi=300)` |
+| pygal | `chart.render_to_png('plot.png')` |
+| highcharts | Selenium screenshot (see example below) |
+
+**Future Phase: Interactive HTML** *(not yet implemented)*
+- Interactive plots (HTML) planned for future release
+- Will enable hover, zoom, pan for plotly/bokeh/altair
+- SVG output also planned for pygal
+
---
## Generation Process
@@ -156,19 +195,24 @@ From `docs/architecture/specs-guide.md`:
### Step 2: Library Selection
-From `docs/workflow.md` (lines 119-127):
+From `docs/workflow.md`:
-**Rules**:
+**Supported Libraries**:
- **matplotlib**: Always implement (universal support)
-- **seaborn**: Auto-select for: heatmap, violin, box, pair plots, distributions
-- **plotly**: Auto-select for: interactive needs, 3D plots, animations
-- **bokeh/altair**: Future support
+- **seaborn**: Statistical visualizations (heatmap, violin, box, pair plots, distributions)
+- **plotly**: Interactive plots, 3D plots, animations
+- **bokeh**: Interactive web-based visualizations
+- **altair**: Declarative statistical visualization
+- **plotnine**: ggplot2-style plotting (Grammar of Graphics)
+- **pygal**: SVG-based charts for web
+- **highcharts**: Professional web charts (requires license for commercial use)
**Selection Logic**:
```
-if spec mentions "interactive" → plotly
+if spec mentions "interactive" → plotly, bokeh
else if plot_type in ["heatmap", "violin", "box", "pair"] → seaborn + matplotlib
-else → matplotlib (default)
+else if spec mentions "ggplot" or "grammar of graphics" → plotnine
+else → all libraries (default)
```
### Step 3: Code Structure
@@ -198,7 +242,7 @@ From `docs/development.md` (lines 182-241):
- ✅ Imports: Organized (standard, third-party, local)
**Visual Quality**:
-- ✅ Figure size: `figsize=(16, 9)` by default (16:9 aspect ratio)
+- ✅ Figure size: 16:9 aspect ratio for all libraries (see Standard Plot Sizes below)
- ✅ Axis labels: From column names or custom
- ✅ Grid: `ax.grid(True, alpha=0.3)` (subtle)
- ✅ Font sizes: Readable (≥10pt)
@@ -206,6 +250,23 @@ From `docs/development.md` (lines 182-241):
- ✅ Tight layout: `plt.tight_layout()` to avoid clipping
- ✅ DPI: Always use `dpi=300` when saving for high-quality output
+### Standard Plot Sizes (16:9, 300 DPI equivalent)
+
+All plots must use 16:9 aspect ratio with 300 DPI equivalent quality:
+
+| Library | Size Parameters | DPI/Scale | Final Resolution |
+|---------|-----------------|-----------|------------------|
+| matplotlib | `figsize=(16, 9)` | `dpi=300` | 4800×2700 |
+| seaborn | `figsize=(16, 9)` | `dpi=300` | 4800×2700 |
+| plotnine | `figure_size=(16, 9)` | `dpi=300` | 4800×2700 |
+| bokeh | `width=1600, height=900` | (pixel-based) | 1600×900 |
+| plotly | `width=1600, height=900` | `scale=2` | 3200×1800 |
+| altair | `width=800, height=450` | `scale_factor=2.0` | 1600×900 |
+| pygal | `width=1600, height=900` | (SVG/PNG) | 1600×900 |
+| highcharts | `width=1600, height=900` | (Screenshot) | 1600×900 |
+
+**Rationale**: 16:9 is the standard widescreen format, optimal for web display and presentations. 300 DPI ensures print-quality output for publications
+
---
## Library-Specific Guidelines
@@ -272,6 +333,160 @@ fig.update_layout(
return fig
```
+### bokeh
+
+```python
+from bokeh.plotting import figure, output_file, save
+from bokeh.models import ColumnDataSource
+
+# Create figure with categorical x-axis
+p = figure(x_range=categories, ...)
+
+# IMPORTANT: For categorical axes, use ColumnDataSource
+source = ColumnDataSource(data={'x': cat_data, 'y': num_data})
+p.scatter(x='x', y='y', source=source) # Use scatter, not circle with categorical
+
+# Save output
+output_file('plot.html')
+save(p)
+
+# PNG export (requires selenium)
+try:
+ from bokeh.io import export_png
+ export_png(p, filename='plot.png')
+except ImportError:
+ print("Note: Install 'selenium' for PNG export")
+```
+
+### altair
+
+```python
+import altair as alt
+
+# Create chart
+chart = alt.Chart(data).mark_point().encode(x='x:Q', y='y:Q')
+
+# Save HTML (always works)
+chart.save('plot.html')
+
+# PNG export (requires vl-convert-python)
+try:
+ chart.save('plot.png', scale_factor=2.0)
+except Exception:
+ print("Note: Install 'vl-convert-python' for PNG export")
+```
+
+### plotnine
+
+```python
+from plotnine import ggplot, aes, scale_fill_brewer
+
+# IMPORTANT: Palette types must match the palette name
+# - Qualitative: Set1, Set2, Set3, Paired, Pastel1, Pastel2, Dark2, Accent
+# - Sequential: Blues, Greens, Reds, Oranges, Purples, Greys, etc.
+# - Diverging: RdBu, PiYG, PRGn, BrBG, RdYlBu, etc.
+
+# ✅ Correct: Set2 is qualitative
++ scale_fill_brewer(type='qual', palette='Set2')
+
+# ❌ Wrong: Set2 is NOT sequential
++ scale_fill_brewer(type='seq', palette='Set2')
+```
+
+### highcharts
+
+**Note:** Highcharts requires a license for commercial use.
+
+```python
+# IMPORTANT: Use correct import path
+from highcharts_core.chart import Chart # ✅ Correct
+# NOT: from highcharts_core import Chart # ❌ Wrong
+
+from highcharts_core.options import HighchartsOptions
+
+# Create chart
+chart = Chart()
+chart.options = HighchartsOptions()
+
+# Export to HTML (always works)
+html_str = chart.to_js_literal()
+
+# Static image export requires Highcharts Export Server
+```
+
+### pygal
+
+```python
+import pygal
+
+# Create chart
+chart = pygal.Bar()
+chart.title = 'Title'
+chart.add('Series', [1, 2, 3])
+
+# Save as SVG (native format)
+chart.render_to_file('plot.svg')
+
+# PNG export (requires cairosvg)
+try:
+ chart.render_to_png('plot.png')
+except ImportError:
+ print("Note: Install 'cairosvg' for PNG export")
+```
+
+---
+
+## API Version Compatibility
+
+### matplotlib 3.9+
+
+```python
+# DEPRECATED: labels parameter in boxplot
+ax.boxplot(data, labels=group_names) # ❌ Deprecated
+
+# USE: tick_labels parameter
+ax.boxplot(data, tick_labels=group_names) # ✅ Correct
+```
+
+### seaborn 0.14+
+
+```python
+# When using palette, always specify hue
+# Otherwise seaborn raises a warning
+
+# ❌ Warning: palette without hue
+sns.boxplot(data=df, x='group', y='value', palette='Set2')
+
+# ✅ Correct: hue with palette
+sns.boxplot(data=df, x='group', y='value', hue='group', palette='Set2', legend=False)
+```
+
+---
+
+## Code Quality Checks (Required Before PR)
+
+Before creating a pull request, **always** run these checks and fix any issues:
+
+```bash
+# 1. Check for linting issues
+uv run ruff check .
+
+# 2. Auto-fix issues (safe fixes)
+uv run ruff check . --fix
+
+# 3. Auto-fix with unsafe fixes if needed
+uv run ruff check . --fix --unsafe-fixes
+
+# 4. Format code
+uv run ruff format .
+```
+
+**Common issues to watch for:**
+- `C408`: Use dict literals `{}` instead of `dict()`
+- `B905`: Add `strict=False` to `zip()` calls
+- `B007`: Prefix unused loop variables with `_` (e.g., `_group`)
+- Import ordering (auto-fixed by ruff)
+
---
## Self-Optimization Loop
diff --git a/specs/box-basic.md b/specs/box-basic.md
new file mode 100644
index 00000000000..0475384e3d8
--- /dev/null
+++ b/specs/box-basic.md
@@ -0,0 +1,54 @@
+# box-basic
+
+## Description
+A basic box plot (box-and-whisker plot) showing the statistical distribution of multiple groups. The plot displays quartiles (Q1, median, Q3) as boxes, whiskers extending to show the range within 1.5 * IQR (interquartile range), and individual points for outliers beyond the whiskers.
+
+## Data Requirements
+- **Structure**: One numeric column for values and one categorical column for groups
+- **Minimum Data**: At least 5 data points per group for meaningful statistics
+- **Data Types**:
+ - Values: Numeric (float or int)
+ - Groups: Categorical (string or numeric)
+
+## Visual Requirements
+### Core Elements
+- **Boxes**: Rectangle from Q1 to Q3 for each group
+- **Median Line**: Horizontal line at median within each box
+- **Whiskers**: Lines extending from box to min(Q3 + 1.5*IQR, max_value) and max(Q1 - 1.5*IQR, min_value)
+- **Outliers**: Individual points for values beyond whiskers
+- **X-axis**: Categorical groups
+- **Y-axis**: Value scale
+
+### Styling
+- **Colors**: Different colors for each box (optional, but enhances readability)
+- **Box Width**: Proportional to available space, with gaps between boxes
+- **Grid**: Horizontal grid lines for value reference
+- **Labels**: Clear axis labels and title
+
+## Implementation Requirements
+### Data Generation
+Generate sample data with:
+- 4-5 groups (e.g., "Group A", "Group B", "Group C", "Group D")
+- 30-50 data points per group
+- Different distributions per group (e.g., different means and spreads)
+- Some outliers in at least 2 groups
+- Use deterministic random seed for reproducibility
+
+### Key Features
+1. **Statistical Display**: Show quartiles, median, range, and outliers
+2. **Multiple Groups**: Compare distributions across categories
+3. **Clear Labeling**: Title, axis labels, and group names
+4. **Visual Clarity**: Distinguish boxes, whiskers, and outliers
+
+## Example Use Cases
+- Comparing performance metrics across different teams
+- Analyzing price distributions across product categories
+- Examining test scores across different classes
+- Visualizing sensor readings from multiple devices
+- Comparing response times across server regions
+
+## Notes
+- Box plots are excellent for comparing distributions and identifying outliers
+- They provide a compact summary of data distribution
+- Particularly useful when sample sizes vary across groups
+- Non-parametric visualization (doesn't assume normal distribution)
\ No newline at end of file
diff --git a/uv.lock b/uv.lock
index 146d9fd6041..c858ae9e521 100644
--- a/uv.lock
+++ b/uv.lock
@@ -145,6 +145,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" },
]
+[[package]]
+name = "cairocffi"
+version = "1.7.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/70/c5/1a4dc131459e68a173cbdab5fad6b524f53f9c1ef7861b7698e998b837cc/cairocffi-1.7.1.tar.gz", hash = "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b", size = 88096, upload-time = "2024-06-18T10:56:06.741Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl", hash = "sha256:9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f", size = 75611, upload-time = "2024-06-18T10:55:59.489Z" },
+]
+
+[[package]]
+name = "cairosvg"
+version = "2.8.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cairocffi" },
+ { name = "cssselect2" },
+ { name = "defusedxml" },
+ { name = "pillow" },
+ { name = "tinycss2" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ab/b9/5106168bd43d7cd8b7cc2a2ee465b385f14b63f4c092bb89eee2d48c8e67/cairosvg-2.8.2.tar.gz", hash = "sha256:07cbf4e86317b27a92318a4cac2a4bb37a5e9c1b8a27355d06874b22f85bef9f", size = 8398590, upload-time = "2025-05-15T06:56:32.653Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/48/816bd4aaae93dbf9e408c58598bc32f4a8c65f4b86ab560864cb3ee60adb/cairosvg-2.8.2-py3-none-any.whl", hash = "sha256:eab46dad4674f33267a671dce39b64be245911c901c70d65d2b7b0821e852bf5", size = 45773, upload-time = "2025-05-15T06:56:28.552Z" },
+]
+
[[package]]
name = "certifi"
version = "2025.11.12"
@@ -154,6 +182,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
]
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
+]
+
[[package]]
name = "cfgv"
version = "3.5.0"
@@ -188,6 +249,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
+[[package]]
+name = "choreographer"
+version = "1.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "logistro" },
+ { name = "simplejson" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/74/47/64a035c6f764450ea9f902cbeba14c8c70316c2641125510066d8f912bfa/choreographer-1.2.1.tar.gz", hash = "sha256:022afd72b1e9b0bcb950420b134e70055a294c791b6f36cfb47d89745b701b5f", size = 43399, upload-time = "2025-11-09T23:04:44.749Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/9f/d73dfb85d7a5b1a56a99adc50f2074029468168c970ff5daeade4ad819e4/choreographer-1.2.1-py3-none-any.whl", hash = "sha256:9af5385effa3c204dbc337abf7ac74fd8908ced326a15645dc31dde75718c77e", size = 49338, upload-time = "2025-11-09T23:04:43.154Z" },
+]
+
[[package]]
name = "click"
version = "8.3.1"
@@ -277,6 +351,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/a3/43b749004e3c09452e39bb56347a008f0a0668aad37324a99b5c8ca91d9e/coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", size = 209503, upload-time = "2025-11-18T13:34:18.892Z" },
]
+[[package]]
+name = "cssselect2"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "tinycss2" },
+ { name = "webencodings" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9f/86/fd7f58fc498b3166f3a7e8e0cddb6e620fe1da35b02248b1bd59e95dbaaa/cssselect2-0.8.0.tar.gz", hash = "sha256:7674ffb954a3b46162392aee2a3a0aedb2e14ecf99fcc28644900f4e6e3e9d3a", size = 35716, upload-time = "2025-03-05T14:46:07.988Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0f/e7/aa315e6a749d9b96c2504a1ba0ba031ba2d0517e972ce22682e3fccecb09/cssselect2-0.8.0-py3-none-any.whl", hash = "sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e", size = 15454, upload-time = "2025-03-05T14:46:06.463Z" },
+]
+
[[package]]
name = "cycler"
version = "0.12.1"
@@ -286,6 +373,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
]
+[[package]]
+name = "defusedxml"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
+]
+
[[package]]
name = "distlib"
version = "0.4.0"
@@ -652,6 +748,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
+[[package]]
+name = "kaleido"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "choreographer" },
+ { name = "logistro" },
+ { name = "orjson" },
+ { name = "packaging" },
+ { name = "pytest-timeout" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/38/ad/76eec859b71eda803a88ea50ed3f270281254656bb23d19eb0a39aa706a0/kaleido-1.2.0.tar.gz", hash = "sha256:fa621a14423e8effa2895a2526be00af0cf21655be1b74b7e382c171d12e71ef", size = 64160, upload-time = "2025-11-04T21:24:23.833Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4b/97/f6de8d4af54d6401d6581a686cce3e3e2371a79ba459a449104e026c08bc/kaleido-1.2.0-py3-none-any.whl", hash = "sha256:c27ed82b51df6b923d0e656feac221343a0dbcd2fb9bc7e6b1db97f61e9a1513", size = 68997, upload-time = "2025-11-04T21:24:21.704Z" },
+]
+
[[package]]
name = "kiwisolver"
version = "1.4.9"
@@ -686,6 +798,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" },
]
+[[package]]
+name = "logistro"
+version = "2.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/08/90/bfd7a6fab22bdfafe48ed3c4831713cb77b4779d18ade5e248d5dbc0ca22/logistro-2.0.1.tar.gz", hash = "sha256:8446affc82bab2577eb02bfcbcae196ae03129287557287b6a070f70c1985047", size = 8398, upload-time = "2025-11-01T02:41:18.81Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/6aa79ba3570bddd1bf7e951c6123f806751e58e8cce736bad77b2cf348d7/logistro-2.0.1-py3-none-any.whl", hash = "sha256:06ffa127b9fb4ac8b1972ae6b2a9d7fde57598bf5939cd708f43ec5bba2d31eb", size = 8555, upload-time = "2025-11-01T02:41:17.587Z" },
+]
+
[[package]]
name = "mako"
version = "1.3.10"
@@ -853,6 +974,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/fd/4b5eb0b3e888d86aee4d198c23acec7d214baaf17ea93c1adec94c9518b9/numpy-2.3.5-cp314-cp314t-win_arm64.whl", hash = "sha256:6203fdf9f3dc5bdaed7319ad8698e685c7a3be10819f41d32a0723e611733b42", size = 10545459, upload-time = "2025-11-16T22:52:20.55Z" },
]
+[[package]]
+name = "orjson"
+version = "3.11.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c6/fe/ed708782d6709cc60eb4c2d8a361a440661f74134675c72990f2c48c785f/orjson-3.11.4.tar.gz", hash = "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d", size = 5945188, upload-time = "2025-10-24T15:50:38.027Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/25/e3/54ff63c093cc1697e758e4fceb53164dd2661a7d1bcd522260ba09f54533/orjson-3.11.4-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:42d43a1f552be1a112af0b21c10a5f553983c2a0938d2bbb8ecd8bc9fb572803", size = 243501, upload-time = "2025-10-24T15:49:54.288Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/7d/e2d1076ed2e8e0ae9badca65bf7ef22710f93887b29eaa37f09850604e09/orjson-3.11.4-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:26a20f3fbc6c7ff2cb8e89c4c5897762c9d88cf37330c6a117312365d6781d54", size = 128862, upload-time = "2025-10-24T15:49:55.961Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/37/ca2eb40b90621faddfa9517dfe96e25f5ae4d8057a7c0cdd613c17e07b2c/orjson-3.11.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e3f20be9048941c7ffa8fc523ccbd17f82e24df1549d1d1fe9317712d19938e", size = 130047, upload-time = "2025-10-24T15:49:57.406Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/62/1021ed35a1f2bad9040f05fa4cc4f9893410df0ba3eaa323ccf899b1c90a/orjson-3.11.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aac364c758dc87a52e68e349924d7e4ded348dedff553889e4d9f22f74785316", size = 129073, upload-time = "2025-10-24T15:49:58.782Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/3f/f84d966ec2a6fd5f73b1a707e7cd876813422ae4bf9f0145c55c9c6a0f57/orjson-3.11.4-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d5c54a6d76e3d741dcc3f2707f8eeb9ba2a791d3adbf18f900219b62942803b1", size = 136597, upload-time = "2025-10-24T15:50:00.12Z" },
+ { url = "https://files.pythonhosted.org/packages/32/78/4fa0aeca65ee82bbabb49e055bd03fa4edea33f7c080c5c7b9601661ef72/orjson-3.11.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f28485bdca8617b79d44627f5fb04336897041dfd9fa66d383a49d09d86798bc", size = 137515, upload-time = "2025-10-24T15:50:01.57Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/9d/0c102e26e7fde40c4c98470796d050a2ec1953897e2c8ab0cb95b0759fa2/orjson-3.11.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bfc2a484cad3585e4ba61985a6062a4c2ed5c7925db6d39f1fa267c9d166487f", size = 136703, upload-time = "2025-10-24T15:50:02.944Z" },
+ { url = "https://files.pythonhosted.org/packages/df/ac/2de7188705b4cdfaf0b6c97d2f7849c17d2003232f6e70df98602173f788/orjson-3.11.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e34dbd508cb91c54f9c9788923daca129fe5b55c5b4eebe713bf5ed3791280cf", size = 136311, upload-time = "2025-10-24T15:50:04.441Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/52/847fcd1a98407154e944feeb12e3b4d487a0e264c40191fb44d1269cbaa1/orjson-3.11.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b13c478fa413d4b4ee606ec8e11c3b2e52683a640b006bb586b3041c2ca5f606", size = 140127, upload-time = "2025-10-24T15:50:07.398Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/ae/21d208f58bdb847dd4d0d9407e2929862561841baa22bdab7aea10ca088e/orjson-3.11.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:724ca721ecc8a831b319dcd72cfa370cc380db0bf94537f08f7edd0a7d4e1780", size = 406201, upload-time = "2025-10-24T15:50:08.796Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/55/0789d6de386c8366059db098a628e2ad8798069e94409b0d8935934cbcb9/orjson-3.11.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:977c393f2e44845ce1b540e19a786e9643221b3323dae190668a98672d43fb23", size = 149872, upload-time = "2025-10-24T15:50:10.234Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/1d/7ff81ea23310e086c17b41d78a72270d9de04481e6113dbe2ac19118f7fb/orjson-3.11.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e539e382cf46edec157ad66b0b0872a90d829a6b71f17cb633d6c160a223155", size = 139931, upload-time = "2025-10-24T15:50:11.623Z" },
+ { url = "https://files.pythonhosted.org/packages/77/92/25b886252c50ed64be68c937b562b2f2333b45afe72d53d719e46a565a50/orjson-3.11.4-cp314-cp314-win32.whl", hash = "sha256:d63076d625babab9db5e7836118bdfa086e60f37d8a174194ae720161eb12394", size = 136065, upload-time = "2025-10-24T15:50:13.025Z" },
+ { url = "https://files.pythonhosted.org/packages/63/b8/718eecf0bb7e9d64e4956afaafd23db9f04c776d445f59fe94f54bdae8f0/orjson-3.11.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a54d6635fa3aaa438ae32e8570b9f0de36f3f6562c308d2a2a452e8b0592db1", size = 131310, upload-time = "2025-10-24T15:50:14.46Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/bf/def5e25d4d8bfce296a9a7c8248109bf58622c21618b590678f945a2c59c/orjson-3.11.4-cp314-cp314-win_arm64.whl", hash = "sha256:78b999999039db3cf58f6d230f524f04f75f129ba3d1ca2ed121f8657e575d3d", size = 126151, upload-time = "2025-10-24T15:50:15.878Z" },
+]
+
+[[package]]
+name = "outcome"
+version = "1.3.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" },
+]
+
[[package]]
name = "packaging"
version = "25.0"
@@ -1068,6 +1224,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
]
+[[package]]
+name = "pycparser"
+version = "2.23"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
+]
+
[[package]]
name = "pydantic"
version = "2.12.4"
@@ -1179,8 +1344,10 @@ dependencies = [
all = [
{ name = "altair" },
{ name = "bokeh" },
+ { name = "cairosvg" },
{ name = "highcharts-core" },
{ name = "httpx" },
+ { name = "kaleido" },
{ name = "mypy" },
{ name = "plotly" },
{ name = "plotnine" },
@@ -1191,6 +1358,9 @@ all = [
{ name = "pytest-cov" },
{ name = "ruff" },
{ name = "seaborn" },
+ { name = "selenium" },
+ { name = "vl-convert-python" },
+ { name = "webdriver-manager" },
]
dev = [
{ name = "pre-commit" },
@@ -1199,11 +1369,16 @@ dev = [
plotting = [
{ name = "altair" },
{ name = "bokeh" },
+ { name = "cairosvg" },
{ name = "highcharts-core" },
+ { name = "kaleido" },
{ name = "plotly" },
{ name = "plotnine" },
{ name = "pygal" },
{ name = "seaborn" },
+ { name = "selenium" },
+ { name = "vl-convert-python" },
+ { name = "webdriver-manager" },
]
test = [
{ name = "httpx" },
@@ -1222,11 +1397,13 @@ requires-dist = [
{ name = "anthropic", specifier = ">=0.39.0" },
{ name = "asyncpg", specifier = ">=0.29.0" },
{ name = "bokeh", marker = "extra == 'plotting'", specifier = ">=3.3.0" },
+ { name = "cairosvg", marker = "extra == 'plotting'", specifier = ">=2.7.0" },
{ name = "fastapi", specifier = ">=0.104.0" },
{ name = "google-cloud-storage", specifier = ">=2.10.0" },
{ name = "highcharts-core", marker = "extra == 'plotting'", specifier = ">=1.10.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" },
+ { name = "kaleido", marker = "extra == 'plotting'", specifier = ">=0.2.1" },
{ name = "matplotlib", specifier = ">=3.8.0" },
{ name = "mypy", marker = "extra == 'typecheck'", specifier = ">=1.8.0" },
{ name = "numpy", specifier = ">=1.26.0" },
@@ -1246,11 +1423,23 @@ requires-dist = [
{ name = "pyyaml", specifier = ">=6.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11.13" },
{ name = "seaborn", marker = "extra == 'plotting'", specifier = ">=0.13.0" },
+ { name = "selenium", marker = "extra == 'plotting'", specifier = ">=4.15.0" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" },
+ { name = "vl-convert-python", marker = "extra == 'plotting'", specifier = ">=1.3.0" },
+ { name = "webdriver-manager", marker = "extra == 'plotting'", specifier = ">=4.0.0" },
]
provides-extras = ["test", "dev", "typecheck", "plotting", "all"]
+[[package]]
+name = "pysocks"
+version = "1.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" },
+]
+
[[package]]
name = "pytest"
version = "9.0.1"
@@ -1293,6 +1482,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
+[[package]]
+name = "pytest-timeout"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" },
+]
+
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -1497,6 +1698,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" },
]
+[[package]]
+name = "selenium"
+version = "4.38.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "trio" },
+ { name = "trio-websocket" },
+ { name = "typing-extensions" },
+ { name = "urllib3", extra = ["socks"] },
+ { name = "websocket-client" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/a0/60a5e7e946420786d57816f64536e21a29f0554706b36f3cba348107024c/selenium-4.38.0.tar.gz", hash = "sha256:c117af6727859d50f622d6d0785b945c5db3e28a45ec12ad85cee2e7cc84fc4c", size = 924101, upload-time = "2025-10-25T02:13:06.752Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/d3/76c8f4a8d99b9f1ebcf9a611b4dd992bf5ee082a6093cfc649af3d10f35b/selenium-4.38.0-py3-none-any.whl", hash = "sha256:ed47563f188130a6fd486b327ca7ba48c5b11fb900e07d6457befdde320e35fd", size = 9694571, upload-time = "2025-10-25T02:13:04.417Z" },
+]
+
+[[package]]
+name = "simplejson"
+version = "3.20.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/41/f4/a1ac5ed32f7ed9a088d62a59d410d4c204b3b3815722e2ccfb491fa8251b/simplejson-3.20.2.tar.gz", hash = "sha256:5fe7a6ce14d1c300d80d08695b7f7e633de6cd72c80644021874d985b3393649", size = 85784, upload-time = "2025-09-26T16:29:36.64Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/05/5b/83e1ff87eb60ca706972f7e02e15c0b33396e7bdbd080069a5d1b53cf0d8/simplejson-3.20.2-py3-none-any.whl", hash = "sha256:3b6bb7fb96efd673eac2e4235200bfffdc2353ad12c54117e1e4e2fc485ac017", size = 57309, upload-time = "2025-09-26T16:29:35.312Z" },
+]
+
[[package]]
name = "six"
version = "1.17.0"
@@ -1515,6 +1742,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
+]
+
[[package]]
name = "sqlalchemy"
version = "2.0.44"
@@ -1566,6 +1802,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/c0/b28d0fd0347ea38d3610052f479e4b922eb33bb8790817f93cd89e6e08ba/statsmodels-0.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:95af7a9c4689d514f4341478b891f867766f3da297f514b8c4adf08f4fa61d03", size = 9648961, upload-time = "2025-10-30T13:47:24.303Z" },
]
+[[package]]
+name = "tinycss2"
+version = "1.5.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "webencodings" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" },
+]
+
[[package]]
name = "tornado"
version = "6.5.2"
@@ -1585,6 +1833,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456, upload-time = "2025-08-08T18:26:59.207Z" },
]
+[[package]]
+name = "trio"
+version = "0.32.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" },
+ { name = "idna" },
+ { name = "outcome" },
+ { name = "sniffio" },
+ { name = "sortedcontainers" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d8/ce/0041ddd9160aac0031bcf5ab786c7640d795c797e67c438e15cfedf815c8/trio-0.32.0.tar.gz", hash = "sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b", size = 605323, upload-time = "2025-10-31T07:18:17.466Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/bf/945d527ff706233636c73880b22c7c953f3faeb9d6c7e2e85bfbfd0134a0/trio-0.32.0-py3-none-any.whl", hash = "sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5", size = 512030, upload-time = "2025-10-31T07:18:15.885Z" },
+]
+
+[[package]]
+name = "trio-websocket"
+version = "0.12.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "outcome" },
+ { name = "trio" },
+ { name = "wsproto" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" },
+]
+
[[package]]
name = "types-pytz"
version = "2025.2.0.20251108"
@@ -1633,6 +1912,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
+[package.optional-dependencies]
+socks = [
+ { name = "pysocks" },
+]
+
[[package]]
name = "uvicorn"
version = "0.38.0"
@@ -1703,6 +1987,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" },
]
+[[package]]
+name = "vl-convert-python"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/15/08/06945bff9655c5b0520a8d1b2550cd8007e106ebec45a33840035420e0d2/vl_convert_python-1.8.0.tar.gz", hash = "sha256:ceca613ca5551c55270a15ca48d0f3a7de1e949e0f127310e9b0f6570ea3fbbb", size = 4651586, upload-time = "2025-05-28T00:06:47.6Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/5a/9dca7d8ff56e82c298e9ef381cfc803e262b85b7c59f2515d0e9f81a75b6/vl_convert_python-1.8.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f663317fc280b07553534195c1e31c4ca882d9c8601430211b078196db5ed227", size = 29956698, upload-time = "2025-05-28T00:06:29.533Z" },
+ { url = "https://files.pythonhosted.org/packages/42/e2/325e6b5895482b2534e7462c012f237c66ffb02fb3af45eec0accab2f8d4/vl_convert_python-1.8.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:81f6380019ceadf070a79f85aa624475a6568093f70de0e151a32e91ecbcaacf", size = 28831173, upload-time = "2025-05-28T00:06:32.925Z" },
+ { url = "https://files.pythonhosted.org/packages/09/fa/1dd944c9e9898e59e31c385bdce215aca543acc555de20b8bf4dc60ddb89/vl_convert_python-1.8.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3388e3913287867b3553c10f81ca2d85268216a5a75e7c71b9c1b59887c1977e", size = 31668750, upload-time = "2025-05-28T00:06:36.158Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/6b/48f6d47a92eaf6f0dd235146307a7eb0d179b78d2faebc53aca3f1e49177/vl_convert_python-1.8.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b51264998e8fcc43dbce801484a950cfe6513cdc4c46b20604ef50989855a617", size = 32970141, upload-time = "2025-05-28T00:06:41.323Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/6f/29dce05f9167e3a01ab74d79eeadd531bc24cf59e3a7fc3736af476ca431/vl_convert_python-1.8.0-cp37-abi3-win_amd64.whl", hash = "sha256:9f1146b791ed27916f54c45e1d66af53a40eb26e5aaea1892f33eb9a935039ab", size = 31318167, upload-time = "2025-05-28T00:06:44.881Z" },
+]
+
[[package]]
name = "watchfiles"
version = "1.1.1"
@@ -1737,6 +2034,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
+[[package]]
+name = "webdriver-manager"
+version = "4.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+ { name = "python-dotenv" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/24/4f/6e44478908c5133f680378d687f14ecaa99feed2c535344fcf68d8d21500/webdriver_manager-4.0.2.tar.gz", hash = "sha256:efedf428f92fd6d5c924a0d054e6d1322dd77aab790e834ee767af392b35590f", size = 25940, upload-time = "2024-07-25T08:13:49.331Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/b5/3bd0b038d80950ec13e6a2c8d03ed8354867dc60064b172f2f4ffac8afbe/webdriver_manager-4.0.2-py2.py3-none-any.whl", hash = "sha256:75908d92ecc45ff2b9953614459c633db8f9aa1ff30181cefe8696e312908129", size = 27778, upload-time = "2024-07-25T08:13:47.917Z" },
+]
+
+[[package]]
+name = "webencodings"
+version = "0.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" },
+]
+
+[[package]]
+name = "websocket-client"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
+]
+
[[package]]
name = "websockets"
version = "15.0.1"
@@ -1746,6 +2075,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
+[[package]]
+name = "wsproto"
+version = "1.3.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
+]
+
[[package]]
name = "xyzservices"
version = "2025.11.0"