From 50f7de304b58da9cb4acc2b35005ab479919f505 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 18 Jan 2026 17:41:06 +1000 Subject: [PATCH 01/26] feat: add pycirclize plot wrappers --- ultraplot/axes/plot.py | 401 ++++++++++++++++++++++++++ ultraplot/axes/plot_types/circlize.py | 310 ++++++++++++++++++++ 2 files changed, 711 insertions(+) create mode 100644 ultraplot/axes/plot_types/circlize.py diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 0ff325691..efe592df7 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -206,6 +206,7 @@ docstring._snippet_manager["plot.curved_quiver"] = _curved_quiver_docstring +<<<<<<< HEAD _sankey_docstring = """ Draw a Sankey diagram. @@ -299,6 +300,187 @@ """ docstring._snippet_manager["plot.sankey"] = _sankey_docstring +======= +_chord_docstring = """ +Draw a chord diagram using pyCirclize. + +Parameters +---------- +matrix : str, Path, pandas.DataFrame, or Matrix + Input matrix for the chord diagram. +start, end : float, optional + Plot start and end degrees (-360 <= start < end <= 360). +space : float or sequence of float, optional + Space degrees between sectors. +endspace : bool, optional + If True, insert space after the final sector. +r_lim : 2-tuple of float, optional + Outer track radius limits (0 to 100). +cmap : str or dict, optional + Colormap name or name-to-color mapping for sectors and links. If omitted, + UltraPlot's color cycle is used. +link_cmap : list of (from, to, color), optional + Override link colors. +ticks_interval : int, optional + Tick interval for sector tracks. If None, no ticks are shown. +order : {'asc', 'desc'} or list, optional + Node ordering strategy or explicit node order. +label_kws, ticks_kws, link_kws : dict-like, optional + Keyword arguments passed to pyCirclize for labels, ticks, and links. +link_kws_handler : callable, optional + Callback to customize per-link keyword arguments. +tooltip : bool, optional + Enable interactive tooltips (requires ipympl). + +Returns +------- +pycirclize.Circos + The underlying Circos instance. +""" + +docstring._snippet_manager["plot.chord_diagram"] = _chord_docstring + +_radar_docstring = """ +Draw a radar chart using pyCirclize. + +Parameters +---------- +table : str, Path, pandas.DataFrame, or RadarTable + Input table for the radar chart. +r_lim : 2-tuple of float, optional + Radar chart radius limits (0 to 100). +vmin, vmax : float, optional + Value range for the radar chart. +fill : bool, optional + Whether to fill the radar polygons. +marker_size : int, optional + Marker size for radar points. +bg_color : color-spec or None, optional + Background fill color. +circular : bool, optional + Whether to draw circular grid lines. +cmap : str or dict, optional + Colormap name or row-name-to-color mapping. If omitted, UltraPlot's + color cycle is used. +show_grid_label : bool, optional + Whether to show radial grid labels. +grid_interval_ratio : float or None, optional + Grid interval ratio (0 to 1). +grid_line_kws, grid_label_kws : dict-like, optional + Keyword arguments passed to pyCirclize for grid lines and labels. +grid_label_formatter : callable, optional + Formatter for grid label values. +label_kws_handler, line_kws_handler, marker_kws_handler : callable, optional + Per-series styling callbacks passed to pyCirclize. + +Returns +------- +pycirclize.Circos + The underlying Circos instance. +""" + +docstring._snippet_manager["plot.radar_chart"] = _radar_docstring + +_circos_docstring = """ +Create a Circos instance using pyCirclize. + +Parameters +---------- +sectors : mapping + Sector name and size (or range) mapping. +start, end : float, optional + Plot start and end degrees (-360 <= start < end <= 360). +space : float or sequence of float, optional + Space degrees between sectors. +endspace : bool, optional + If True, insert space after the final sector. +sector2clockwise : dict, optional + Override clockwise settings per sector. +show_axis_for_debug : bool, optional + Show the polar axis for debug layout. +plot : bool, optional + If True, immediately render the circos figure on this axes. +tooltip : bool, optional + Enable interactive tooltips (requires ipympl). + +Returns +------- +pycirclize.Circos + The underlying Circos instance. +""" + +docstring._snippet_manager["plot.circos"] = _circos_docstring + +_phylogeny_docstring = """ +Draw a phylogenetic tree using pyCirclize. + +Parameters +---------- +tree_data : str, Path, or Tree + Tree data (file, URL, Tree object, or tree string). +start, end : float, optional + Plot start and end degrees (-360 <= start < end <= 360). +r_lim : 2-tuple of float, optional + Tree track radius limits (0 to 100). +format : str, optional + Tree format (`newick`, `phyloxml`, `nexus`, `nexml`, `cdao`). +outer : bool, optional + If True, plot tree on the outer side. +align_leaf_label : bool, optional + If True, align leaf labels. +ignore_branch_length : bool, optional + Ignore branch lengths when plotting. +leaf_label_size : float, optional + Leaf label size. +leaf_label_rmargin : float, optional + Leaf label radius margin. +reverse : bool, optional + Reverse tree direction. +ladderize : bool, optional + Ladderize tree. +line_kws, align_line_kws : dict-like, optional + Keyword arguments for tree line styling. +label_formatter : callable, optional + Formatter for leaf labels. +tooltip : bool, optional + Enable interactive tooltips (requires ipympl). + +Returns +------- +pycirclize.Circos, pycirclize.TreeViz + The Circos instance and TreeViz helper. +""" + +docstring._snippet_manager["plot.phylogeny"] = _phylogeny_docstring + +_circos_bed_docstring = """ +Create a Circos instance from a BED file using pyCirclize. + +Parameters +---------- +bed_file : str or Path + BED file describing chromosome ranges. +start, end : float, optional + Plot start and end degrees (-360 <= start < end <= 360). +space : float or sequence of float, optional + Space degrees between sectors. +endspace : bool, optional + If True, insert space after the final sector. +sector2clockwise : dict, optional + Override clockwise settings per sector. +plot : bool, optional + If True, immediately render the circos figure on this axes. +tooltip : bool, optional + Enable interactive tooltips (requires ipympl). + +Returns +------- +pycirclize.Circos + The underlying Circos instance. +""" + +docstring._snippet_manager["plot.circos_bed"] = _circos_bed_docstring +>>>>>>> 0e5e317b (feat: add pycirclize plot wrappers) # Auto colorbar and legend docstring _guide_docstring = """ colorbar : bool, int, or str, optional @@ -1944,6 +2126,7 @@ def curved_quiver( return stream_container @docstring._snippet_manager +<<<<<<< HEAD def sankey( self, flows: Any, @@ -2124,6 +2307,224 @@ def _looks_like_links(values): sankey.add(**add_kw, **kwargs) diagrams = sankey.finish() return diagrams[0] if len(diagrams) == 1 else diagrams +======= + def circos( + self, + sectors: Mapping[str, Any], + *, + start: float = 0, + end: float = 360, + space: float | Sequence[float] = 0, + endspace: bool = True, + sector2clockwise: Mapping[str, bool] | None = None, + show_axis_for_debug: bool = False, + plot: bool = False, + tooltip: bool = False, + ): + """ + %(plot.circos)s + """ + from .plot_types.circlize import circos + + return circos( + self, + sectors, + start=start, + end=end, + space=space, + endspace=endspace, + sector2clockwise=sector2clockwise, + show_axis_for_debug=show_axis_for_debug, + plot=plot, + tooltip=tooltip, + ) + + @docstring._snippet_manager + def phylogeny( + self, + tree_data: Any, + *, + start: float = 0, + end: float = 360, + r_lim: tuple[float, float] = (50, 100), + format: str = "newick", + outer: bool = True, + align_leaf_label: bool = True, + ignore_branch_length: bool = False, + leaf_label_size: float | None = None, + leaf_label_rmargin: float = 2.0, + reverse: bool = False, + ladderize: bool = False, + line_kws: Mapping[str, Any] | None = None, + label_formatter: Callable[[str], str] | None = None, + align_line_kws: Mapping[str, Any] | None = None, + tooltip: bool = False, + ): + """ + %(plot.phylogeny)s + """ + from .plot_types.circlize import phylogeny + + return phylogeny( + self, + tree_data, + start=start, + end=end, + r_lim=r_lim, + format=format, + outer=outer, + align_leaf_label=align_leaf_label, + ignore_branch_length=ignore_branch_length, + leaf_label_size=leaf_label_size, + leaf_label_rmargin=leaf_label_rmargin, + reverse=reverse, + ladderize=ladderize, + line_kws=line_kws, + label_formatter=label_formatter, + align_line_kws=align_line_kws, + tooltip=tooltip, + ) + + @docstring._snippet_manager + def circos_bed( + self, + bed_file: Any, + *, + start: float = 0, + end: float = 360, + space: float | Sequence[float] = 0, + endspace: bool = True, + sector2clockwise: Mapping[str, bool] | None = None, + plot: bool = False, + tooltip: bool = False, + ): + """ + %(plot.circos_bed)s + """ + from .plot_types.circlize import circos_bed + + return circos_bed( + self, + bed_file, + start=start, + end=end, + space=space, + endspace=endspace, + sector2clockwise=sector2clockwise, + plot=plot, + tooltip=tooltip, + ) + + def bed(self, *args, **kwargs): + """ + Alias for `~PlotAxes.circos_bed`. + """ + return self.circos_bed(*args, **kwargs) + + @docstring._snippet_manager + def chord_diagram( + self, + matrix: Any, + *, + start: float = 0, + end: float = 360, + space: float | Sequence[float] = 0, + endspace: bool = True, + r_lim: tuple[float, float] = (97, 100), + cmap: Any = None, + link_cmap: list[tuple[str, str, str]] | None = None, + ticks_interval: int | None = None, + order: str | list[str] | None = None, + label_kws: Mapping[str, Any] | None = None, + ticks_kws: Mapping[str, Any] | None = None, + link_kws: Mapping[str, Any] | None = None, + link_kws_handler: Callable[[str, str], Mapping[str, Any] | None] | None = None, + tooltip: bool = False, + ): + """ + %(plot.chord_diagram)s + """ + from .plot_types.circlize import chord_diagram + + return chord_diagram( + self, + matrix, + start=start, + end=end, + space=space, + endspace=endspace, + r_lim=r_lim, + cmap=cmap, + link_cmap=link_cmap, + ticks_interval=ticks_interval, + order=order, + label_kws=label_kws, + ticks_kws=ticks_kws, + link_kws=link_kws, + link_kws_handler=link_kws_handler, + tooltip=tooltip, + ) + + def chord(self, *args, **kwargs): + """ + Alias for `~PlotAxes.chord_diagram`. + """ + return self.chord_diagram(*args, **kwargs) + + @docstring._snippet_manager + def radar_chart( + self, + table: Any, + *, + r_lim: tuple[float, float] = (0, 100), + vmin: float = 0, + vmax: float = 100, + fill: bool = True, + marker_size: int = 0, + bg_color: str | None = "#eeeeee80", + circular: bool = False, + cmap: Any = None, + show_grid_label: bool = True, + grid_interval_ratio: float | None = 0.2, + grid_line_kws: Mapping[str, Any] | None = None, + grid_label_kws: Mapping[str, Any] | None = None, + grid_label_formatter: Callable[[float], str] | None = None, + label_kws_handler: Callable[[str], Mapping[str, Any]] | None = None, + line_kws_handler: Callable[[str], Mapping[str, Any]] | None = None, + marker_kws_handler: Callable[[str], Mapping[str, Any]] | None = None, + ): + """ + %(plot.radar_chart)s + """ + from .plot_types.circlize import radar_chart + + return radar_chart( + self, + table, + r_lim=r_lim, + vmin=vmin, + vmax=vmax, + fill=fill, + marker_size=marker_size, + bg_color=bg_color, + circular=circular, + cmap=cmap, + show_grid_label=show_grid_label, + grid_interval_ratio=grid_interval_ratio, + grid_line_kws=grid_line_kws, + grid_label_kws=grid_label_kws, + grid_label_formatter=grid_label_formatter, + label_kws_handler=label_kws_handler, + line_kws_handler=line_kws_handler, + marker_kws_handler=marker_kws_handler, + ) + + def radar(self, *args, **kwargs): + """ + Alias for `~PlotAxes.radar_chart`. + """ + return self.radar_chart(*args, **kwargs) +>>>>>>> 0e5e317b (feat: add pycirclize plot wrappers) def _call_native(self, name, *args, **kwargs): """ diff --git a/ultraplot/axes/plot_types/circlize.py b/ultraplot/axes/plot_types/circlize.py new file mode 100644 index 000000000..a13a07eb0 --- /dev/null +++ b/ultraplot/axes/plot_types/circlize.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +""" +Helpers for pyCirclize-backed circular plots. +""" +from __future__ import annotations + +import itertools +import sys +from pathlib import Path +from typing import Any, Mapping, Sequence + +from matplotlib.projections.polar import PolarAxes as MplPolarAxes + +from ... import constructor +from ...config import rc + + +def _import_pycirclize(): + try: + import pycirclize + except ImportError as exc: + base = Path(__file__).resolve().parents[3] / "pyCirclize" / "src" + if base.is_dir() and str(base) not in sys.path: + sys.path.insert(0, str(base)) + try: + import pycirclize + except ImportError as exc2: + raise ImportError( + "pycirclize is required for circos plots. Install it with " + "`pip install 'ultraplot[circos]'` or ensure " + "`pyCirclize/src` is on PYTHONPATH." + ) from exc2 + else: + raise ImportError( + "pycirclize is required for circos plots. Install it with " + "`pip install 'ultraplot[circos]'` or ensure " + "`pyCirclize/src` is on PYTHONPATH." + ) from exc + return pycirclize + + +def _ensure_polar(ax, label: str) -> None: + if not isinstance(ax, MplPolarAxes): + raise ValueError(f"{label} requires a polar axes (proj='polar').") + + +def _cycle_colors(n: int) -> list[str]: + cycle = constructor.Cycle(rc["cycle"]) + colors = list(cycle.by_key().get("color", [])) + if not colors: + colors = ["0.2"] + if len(colors) >= n: + return colors[:n] + return [color for _, color in zip(range(n), itertools.cycle(colors))] + + +def _resolve_chord_defaults(matrix: Any, cmap: Any): + pycirclize = _import_pycirclize() + from pycirclize.parser.matrix import Matrix + + if isinstance(matrix, Matrix): + matrix_obj = matrix + else: + matrix_obj = Matrix(matrix) + + if cmap is None: + names = matrix_obj.all_names + cmap = dict(zip(names, _cycle_colors(len(names)), strict=True)) + return pycirclize, matrix_obj, cmap + + +def _resolve_radar_defaults(table: Any, cmap: Any): + pycirclize = _import_pycirclize() + from pycirclize.parser.table import RadarTable + + if isinstance(table, RadarTable): + table_obj = table + else: + table_obj = RadarTable(table) + + if cmap is None: + names = table_obj.row_names + cmap = dict(zip(names, _cycle_colors(len(names)), strict=True)) + return pycirclize, table_obj, cmap + + +def circos( + ax, + sectors: Mapping[str, Any], + *, + start: float = 0, + end: float = 360, + space: float | Sequence[float] = 0, + endspace: bool = True, + sector2clockwise: Mapping[str, bool] | None = None, + show_axis_for_debug: bool = False, + plot: bool = False, + tooltip: bool = False, +): + """ + Create a pyCirclize Circos instance (optionally plot immediately). + """ + _ensure_polar(ax, "circos") + pycirclize = _import_pycirclize() + circos_obj = pycirclize.Circos( + sectors, + start=start, + end=end, + space=space, + endspace=endspace, + sector2clockwise=sector2clockwise, + show_axis_for_debug=show_axis_for_debug, + ) + if plot: + circos_obj.plotfig(ax=ax, tooltip=tooltip) + return circos_obj + + +def chord_diagram( + ax, + matrix: Any, + *, + start: float = 0, + end: float = 360, + space: float | Sequence[float] = 0, + endspace: bool = True, + r_lim: tuple[float, float] = (97, 100), + cmap: Any = None, + link_cmap: list[tuple[str, str, str]] | None = None, + ticks_interval: int | None = None, + order: str | list[str] | None = None, + label_kws: Mapping[str, Any] | None = None, + ticks_kws: Mapping[str, Any] | None = None, + link_kws: Mapping[str, Any] | None = None, + link_kws_handler=None, + tooltip: bool = False, +): + """ + Render a chord diagram using pyCirclize on the provided polar axes. + """ + _ensure_polar(ax, "chord_diagram") + + pycirclize, matrix_obj, cmap = _resolve_chord_defaults(matrix, cmap) + label_kws = {} if label_kws is None else dict(label_kws) + ticks_kws = {} if ticks_kws is None else dict(ticks_kws) + + label_kws.setdefault("size", rc["font.size"]) + label_kws.setdefault("color", rc["meta.color"]) + ticks_kws.setdefault("label_size", rc["font.size"]) + text_kws = ticks_kws.get("text_kws") + if text_kws is None: + ticks_kws["text_kws"] = {"color": rc["meta.color"]} + else: + text_kws = dict(text_kws) + text_kws.setdefault("color", rc["meta.color"]) + ticks_kws["text_kws"] = text_kws + + circos = pycirclize.Circos.chord_diagram( + matrix_obj, + start=start, + end=end, + space=space, + endspace=endspace, + r_lim=r_lim, + cmap=cmap, + link_cmap=link_cmap, + ticks_interval=ticks_interval, + order=order, + label_kws=label_kws, + ticks_kws=ticks_kws, + link_kws=link_kws, + link_kws_handler=link_kws_handler, + ) + circos.plotfig(ax=ax, tooltip=tooltip) + return circos + + +def radar_chart( + ax, + table: Any, + *, + r_lim: tuple[float, float] = (0, 100), + vmin: float = 0, + vmax: float = 100, + fill: bool = True, + marker_size: int = 0, + bg_color: str | None = "#eeeeee80", + circular: bool = False, + cmap: Any = None, + show_grid_label: bool = True, + grid_interval_ratio: float | None = 0.2, + grid_line_kws: Mapping[str, Any] | None = None, + grid_label_kws: Mapping[str, Any] | None = None, + grid_label_formatter=None, + label_kws_handler=None, + line_kws_handler=None, + marker_kws_handler=None, +): + """ + Render a radar chart using pyCirclize on the provided polar axes. + """ + _ensure_polar(ax, "radar_chart") + + pycirclize, table_obj, cmap = _resolve_radar_defaults(table, cmap) + grid_line_kws = {} if grid_line_kws is None else dict(grid_line_kws) + grid_label_kws = {} if grid_label_kws is None else dict(grid_label_kws) + + grid_line_kws.setdefault("color", rc["grid.color"]) + grid_label_kws.setdefault("size", rc["font.size"]) + grid_label_kws.setdefault("color", rc["meta.color"]) + + circos = pycirclize.Circos.radar_chart( + table_obj, + r_lim=r_lim, + vmin=vmin, + vmax=vmax, + fill=fill, + marker_size=marker_size, + bg_color=bg_color, + circular=circular, + cmap=cmap, + show_grid_label=show_grid_label, + grid_interval_ratio=grid_interval_ratio, + grid_line_kws=grid_line_kws, + grid_label_kws=grid_label_kws, + grid_label_formatter=grid_label_formatter, + label_kws_handler=label_kws_handler, + line_kws_handler=line_kws_handler, + marker_kws_handler=marker_kws_handler, + ) + circos.plotfig(ax=ax) + return circos + + +def phylogeny( + ax, + tree_data: Any, + *, + start: float = 0, + end: float = 360, + r_lim: tuple[float, float] = (50, 100), + format: str = "newick", + outer: bool = True, + align_leaf_label: bool = True, + ignore_branch_length: bool = False, + leaf_label_size: float | None = None, + leaf_label_rmargin: float = 2.0, + reverse: bool = False, + ladderize: bool = False, + line_kws: Mapping[str, Any] | None = None, + label_formatter=None, + align_line_kws: Mapping[str, Any] | None = None, + tooltip: bool = False, +): + """ + Render a phylogenetic tree using pyCirclize on the provided polar axes. + """ + _ensure_polar(ax, "phylogeny") + pycirclize = _import_pycirclize() + if leaf_label_size is None: + leaf_label_size = rc["font.size"] + circos_obj, treeviz = pycirclize.Circos.initialize_from_tree( + tree_data, + start=start, + end=end, + r_lim=r_lim, + format=format, + outer=outer, + align_leaf_label=align_leaf_label, + ignore_branch_length=ignore_branch_length, + leaf_label_size=leaf_label_size, + leaf_label_rmargin=leaf_label_rmargin, + reverse=reverse, + ladderize=ladderize, + line_kws=None if line_kws is None else dict(line_kws), + label_formatter=label_formatter, + align_line_kws=None if align_line_kws is None else dict(align_line_kws), + ) + circos_obj.plotfig(ax=ax, tooltip=tooltip) + return circos_obj, treeviz + + +def circos_bed( + ax, + bed_file: Any, + *, + start: float = 0, + end: float = 360, + space: float | Sequence[float] = 0, + endspace: bool = True, + sector2clockwise: Mapping[str, bool] | None = None, + plot: bool = False, + tooltip: bool = False, +): + """ + Create a Circos instance from a BED file (optionally plot immediately). + """ + _ensure_polar(ax, "circos_bed") + pycirclize = _import_pycirclize() + circos_obj = pycirclize.Circos.initialize_from_bed( + bed_file, + start=start, + end=end, + space=space, + endspace=endspace, + sector2clockwise=sector2clockwise, + ) + if plot: + circos_obj.plotfig(ax=ax, tooltip=tooltip) + return circos_obj From 0a4d1e807479095b04d17f94ea23d49abbe75334 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 18 Jan 2026 17:41:20 +1000 Subject: [PATCH 02/26] build: add pycirclize optional extras --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 9872f5853..c5157c4bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,10 @@ dependencies= [ ] dynamic = ["version"] +[project.optional-dependencies] +circos = ["pycirclize>=1.10.1"] +all = ["pycirclize>=1.10.1"] + [project.urls] "Documentation" = "https://ultraplot.readthedocs.io" "Issue Tracker" = "https://github.com/ultraplot/ultraplot/issues" From 07aabcb88b6d3b064c6561a82957be3264c818f8 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 18 Jan 2026 17:41:35 +1000 Subject: [PATCH 03/26] test: add pycirclize wrapper smoke tests --- ultraplot/tests/test_plot.py | 91 ++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index 92025e872..df932f809 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -1088,6 +1088,97 @@ def test_sankey_flow_label_text_callable(): assert text == "1.2" +def test_radar_chart_smoke(): + """Smoke test for pyCirclize radar chart wrapper.""" + try: + from ultraplot.axes.plot_types.circlize import _import_pycirclize + + _import_pycirclize() + except ImportError: + pytest.skip("pycirclize is not available") + + import pandas as pd + + df = pd.DataFrame({"A": [1, 2], "B": [3, 4], "C": [2, 1]}, index=["set1", "set2"]) + fig, ax = uplt.subplots(proj="polar") + circos = ax.radar_chart(df, vmin=0, vmax=4, fill=False, marker_size=3) + assert hasattr(circos, "plotfig") + uplt.close(fig) + + +def test_chord_diagram_smoke(): + """Smoke test for pyCirclize chord diagram wrapper.""" + try: + from ultraplot.axes.plot_types.circlize import _import_pycirclize + + _import_pycirclize() + except ImportError: + pytest.skip("pycirclize is not available") + + import pandas as pd + + df = pd.DataFrame( + [[5, 2, 1], [2, 6, 3], [1, 3, 4]], + index=["A", "B", "C"], + columns=["A", "B", "C"], + ) + fig, ax = uplt.subplots(proj="polar") + circos = ax.chord_diagram(df, ticks_interval=None) + assert hasattr(circos, "plotfig") + uplt.close(fig) + + +def test_phylogeny_smoke(): + """Smoke test for pyCirclize phylogeny wrapper.""" + try: + from ultraplot.axes.plot_types.circlize import _import_pycirclize + + _import_pycirclize() + except ImportError: + pytest.skip("pycirclize is not available") + + fig, ax = uplt.subplots(proj="polar") + circos, treeviz = ax.phylogeny("((A,B),C);", leaf_label_size=8) + assert hasattr(circos, "plotfig") + assert treeviz is not None + uplt.close(fig) + + +def test_circos_bed_smoke(tmp_path): + """Smoke test for BED-based circlize wrapper.""" + try: + from ultraplot.axes.plot_types.circlize import _import_pycirclize + + _import_pycirclize() + except ImportError: + pytest.skip("pycirclize is not available") + + bed_path = tmp_path / "mini.bed" + bed_path.write_text("chr1\t0\t100\nchr2\t0\t120\n", encoding="utf-8") + + fig, ax = uplt.subplots(proj="polar") + circos = ax.circos_bed(bed_path, plot=False) + assert len(circos.sectors) == 2 + circos.plotfig(ax=ax) + uplt.close(fig) + + +def test_circos_builder_smoke(): + """Smoke test for general Circos wrapper.""" + try: + from ultraplot.axes.plot_types.circlize import _import_pycirclize + + _import_pycirclize() + except ImportError: + pytest.skip("pycirclize is not available") + + fig, ax = uplt.subplots(proj="polar") + circos = ax.circos({"A": 10, "B": 12}, plot=False) + assert len(circos.sectors) == 2 + circos.plotfig(ax=ax) + uplt.close(fig) + + def test_histogram_norms(): """ Check that all histograms-like plotting functions From 6fb6d96618a021a6aeaf348da5d529e11bbb7a9c Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 18 Jan 2026 17:41:49 +1000 Subject: [PATCH 04/26] docs: add pycirclize plot type examples --- docs/examples/plot_types/07_radar.py | 32 +++++++++++++++++++ docs/examples/plot_types/08_chord_diagram.py | 21 +++++++++++++ docs/examples/plot_types/09_phylogeny.py | 15 +++++++++ docs/examples/plot_types/10_circos_bed.py | 33 ++++++++++++++++++++ 4 files changed, 101 insertions(+) create mode 100644 docs/examples/plot_types/07_radar.py create mode 100644 docs/examples/plot_types/08_chord_diagram.py create mode 100644 docs/examples/plot_types/09_phylogeny.py create mode 100644 docs/examples/plot_types/10_circos_bed.py diff --git a/docs/examples/plot_types/07_radar.py b/docs/examples/plot_types/07_radar.py new file mode 100644 index 000000000..c1b9627d7 --- /dev/null +++ b/docs/examples/plot_types/07_radar.py @@ -0,0 +1,32 @@ +""" +Radar chart +=========== + +UltraPlot wrapper around pyCirclize's radar chart helper. +""" + +import pandas as pd + +import ultraplot as uplt + +data = pd.DataFrame( + { + "Design": [3.5, 4.0], + "Speed": [4.2, 3.1], + "Reliability": [4.6, 4.1], + "Support": [3.2, 4.4], + }, + index=["Model A", "Model B"], +) + +fig, ax = uplt.subplots(proj="polar", refwidth=3.6) +ax.radar_chart( + data, + vmin=0, + vmax=5, + fill=True, + marker_size=4, + grid_interval_ratio=0.2, +) +ax.format(title="Product radar") +fig.show() diff --git a/docs/examples/plot_types/08_chord_diagram.py b/docs/examples/plot_types/08_chord_diagram.py new file mode 100644 index 000000000..4946c1b97 --- /dev/null +++ b/docs/examples/plot_types/08_chord_diagram.py @@ -0,0 +1,21 @@ +""" +Chord diagram +============= + +UltraPlot wrapper around pyCirclize chord diagrams. +""" + +import pandas as pd + +import ultraplot as uplt + +matrix = pd.DataFrame( + [[10, 6, 2], [6, 12, 4], [2, 4, 8]], + index=["A", "B", "C"], + columns=["A", "B", "C"], +) + +fig, ax = uplt.subplots(proj="polar", refwidth=3.6) +ax.chord_diagram(matrix, ticks_interval=None, space=4) +ax.format(title="Chord diagram") +fig.show() diff --git a/docs/examples/plot_types/09_phylogeny.py b/docs/examples/plot_types/09_phylogeny.py new file mode 100644 index 000000000..f1fa4a12b --- /dev/null +++ b/docs/examples/plot_types/09_phylogeny.py @@ -0,0 +1,15 @@ +""" +Phylogeny +========= + +UltraPlot wrapper around pyCirclize phylogeny plots. +""" + +import ultraplot as uplt + +newick = "((A,B),C);" + +fig, ax = uplt.subplots(proj="polar", refwidth=3.2) +ax.phylogeny(newick, leaf_label_size=10) +ax.format(title="Phylogeny") +fig.show() diff --git a/docs/examples/plot_types/10_circos_bed.py b/docs/examples/plot_types/10_circos_bed.py new file mode 100644 index 000000000..bcfda3e8a --- /dev/null +++ b/docs/examples/plot_types/10_circos_bed.py @@ -0,0 +1,33 @@ +""" +Circos from BED +=============== + +Build sectors from a BED file and render on UltraPlot polar axes. +""" + +import tempfile +from pathlib import Path + +import numpy as np + +import ultraplot as uplt + +bed_text = "chr1\t0\t100\nchr2\t0\t140\n" + +with tempfile.TemporaryDirectory() as tmpdir: + bed_path = Path(tmpdir) / "mini.bed" + bed_path.write_text(bed_text, encoding="utf-8") + + fig, ax = uplt.subplots(proj="polar", refwidth=3.6) + circos = ax.circos_bed(bed_path, plot=False) + + for sector in circos.sectors: + x = np.linspace(sector.start, sector.end, 8) + y = np.linspace(0, 50, 8) + track = sector.add_track((60, 90), r_pad_ratio=0.1) + track.axis() + track.line(x, y) + + circos.plotfig(ax=ax) + ax.format(title="BED sectors") + fig.show() From e326c5640f2d45764ef2c1f4c1a35681ff2725e9 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 18 Jan 2026 17:53:22 +1000 Subject: [PATCH 05/26] feat: integrate circos axes behavior --- ultraplot/axes/plot_types/circlize.py | 27 +++++++++++++++++++++------ ultraplot/figure.py | 8 +++++--- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/ultraplot/axes/plot_types/circlize.py b/ultraplot/axes/plot_types/circlize.py index a13a07eb0..a98461f2e 100644 --- a/ultraplot/axes/plot_types/circlize.py +++ b/ultraplot/axes/plot_types/circlize.py @@ -39,9 +39,24 @@ def _import_pycirclize(): return pycirclize -def _ensure_polar(ax, label: str) -> None: +def _unwrap_axes(ax, label: str): + if ax.__class__.__name__ == "SubplotGrid": + if len(ax) != 1: + raise ValueError(f"{label} expects a single axes, got {len(ax)}.") + ax = ax[0] + return ax + + +def _ensure_polar(ax, label: str): + ax = _unwrap_axes(ax, label) if not isinstance(ax, MplPolarAxes): raise ValueError(f"{label} requires a polar axes (proj='polar').") + if getattr(ax, "_sharex", None) is not None: + ax._unshare(which="x") + if getattr(ax, "_sharey", None) is not None: + ax._unshare(which="y") + ax._ultraplot_axis_type = ("circos", type(ax)) + return ax def _cycle_colors(n: int) -> list[str]: @@ -100,7 +115,7 @@ def circos( """ Create a pyCirclize Circos instance (optionally plot immediately). """ - _ensure_polar(ax, "circos") + ax = _ensure_polar(ax, "circos") pycirclize = _import_pycirclize() circos_obj = pycirclize.Circos( sectors, @@ -138,7 +153,7 @@ def chord_diagram( """ Render a chord diagram using pyCirclize on the provided polar axes. """ - _ensure_polar(ax, "chord_diagram") + ax = _ensure_polar(ax, "chord_diagram") pycirclize, matrix_obj, cmap = _resolve_chord_defaults(matrix, cmap) label_kws = {} if label_kws is None else dict(label_kws) @@ -199,7 +214,7 @@ def radar_chart( """ Render a radar chart using pyCirclize on the provided polar axes. """ - _ensure_polar(ax, "radar_chart") + ax = _ensure_polar(ax, "radar_chart") pycirclize, table_obj, cmap = _resolve_radar_defaults(table, cmap) grid_line_kws = {} if grid_line_kws is None else dict(grid_line_kws) @@ -255,7 +270,7 @@ def phylogeny( """ Render a phylogenetic tree using pyCirclize on the provided polar axes. """ - _ensure_polar(ax, "phylogeny") + ax = _ensure_polar(ax, "phylogeny") pycirclize = _import_pycirclize() if leaf_label_size is None: leaf_label_size = rc["font.size"] @@ -295,7 +310,7 @@ def circos_bed( """ Create a Circos instance from a BED file (optionally plot immediately). """ - _ensure_polar(ax, "circos_bed") + ax = _ensure_polar(ax, "circos_bed") pycirclize = _import_pycirclize() circos_obj = pycirclize.Circos.initialize_from_bed( bed_file, diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 01d449d36..1ec804241 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1259,9 +1259,11 @@ def _get_border_axes( xspan = xright - xleft + 1 yspan = yright - yleft + 1 number = axi.number - axis_type = type(axi) - if isinstance(axi, (paxes.GeoAxes)): - axis_type = axi.projection + axis_type = getattr(axi, "_ultraplot_axis_type", None) + if axis_type is None: + axis_type = type(axi) + if isinstance(axi, (paxes.GeoAxes)): + axis_type = axi.projection if axis_type not in seen_axis_type: seen_axis_type[axis_type] = len(seen_axis_type) type_number = seen_axis_type[axis_type] From 5f484258ba023a2688815ab6483703f3271aa683 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 18 Jan 2026 17:53:35 +1000 Subject: [PATCH 06/26] test: cover circos delegation and sharing --- ultraplot/tests/test_plot.py | 50 +++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index df932f809..647ff6408 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -1122,7 +1122,8 @@ def test_chord_diagram_smoke(): index=["A", "B", "C"], columns=["A", "B", "C"], ) - fig, ax = uplt.subplots(proj="polar") + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] circos = ax.chord_diagram(df, ticks_interval=None) assert hasattr(circos, "plotfig") uplt.close(fig) @@ -1137,7 +1138,8 @@ def test_phylogeny_smoke(): except ImportError: pytest.skip("pycirclize is not available") - fig, ax = uplt.subplots(proj="polar") + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] circos, treeviz = ax.phylogeny("((A,B),C);", leaf_label_size=8) assert hasattr(circos, "plotfig") assert treeviz is not None @@ -1156,7 +1158,8 @@ def test_circos_bed_smoke(tmp_path): bed_path = tmp_path / "mini.bed" bed_path.write_text("chr1\t0\t100\nchr2\t0\t120\n", encoding="utf-8") - fig, ax = uplt.subplots(proj="polar") + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] circos = ax.circos_bed(bed_path, plot=False) assert len(circos.sectors) == 2 circos.plotfig(ax=ax) @@ -1172,13 +1175,52 @@ def test_circos_builder_smoke(): except ImportError: pytest.skip("pycirclize is not available") - fig, ax = uplt.subplots(proj="polar") + fig, axs = uplt.subplots(proj="polar") + ax = axs[0] circos = ax.circos({"A": 10, "B": 12}, plot=False) assert len(circos.sectors) == 2 circos.plotfig(ax=ax) uplt.close(fig) +def test_circos_unshares_axes(): + """Circos wrappers should unshare axes if they were shared.""" + try: + from ultraplot.axes.plot_types.circlize import _import_pycirclize + + _import_pycirclize() + except ImportError: + pytest.skip("pycirclize is not available") + + fig, axs = uplt.subplots(ncols=2, proj="polar", share="all") + ax = axs[0] + x_siblings = list(ax._shared_axes["x"].get_siblings(ax)) + y_siblings = list(ax._shared_axes["y"].get_siblings(ax)) + if len(x_siblings) == 1 and len(y_siblings) == 1: + pytest.skip("polar axes are not shared in this configuration") + ax.circos({"A": 10, "B": 12}, plot=False) + x_siblings = list(ax._shared_axes["x"].get_siblings(ax)) + y_siblings = list(ax._shared_axes["y"].get_siblings(ax)) + assert len(x_siblings) == 1 + assert len(y_siblings) == 1 + uplt.close(fig) + + +def test_circos_delegation_subplots(): + """SubplotGrid should delegate circos calls for singleton grids.""" + try: + from ultraplot.axes.plot_types.circlize import _import_pycirclize + + _import_pycirclize() + except ImportError: + pytest.skip("pycirclize is not available") + + fig, axs = uplt.subplots(proj="polar") + circos = axs.circos({"A": 10, "B": 12}, plot=False) + assert len(circos.sectors) == 2 + uplt.close(fig) + + def test_histogram_norms(): """ Check that all histograms-like plotting functions From bd57153aa5af58947ded8b13e56f7f5e5418c020 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 24 Jan 2026 09:49:42 +1000 Subject: [PATCH 07/26] ci: add unit test coverage job --- .github/workflows/main.yml | 42 +++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c035214ed..bcb70e58e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -192,9 +192,49 @@ jobs: test-mode: ${{ needs.select-tests.outputs.mode }} test-nodeids: ${{ needs.select-tests.outputs.tests }} + unit-tests: + name: Unit Tests (coverage focus) + needs: + - run-if-changes + if: always() && needs.run-if-changes.outputs.run == 'true' + runs-on: ubuntu-latest + defaults: + run: + shell: bash -el {0} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: mamba-org/setup-micromamba@v2.0.7 + with: + environment-file: ./environment.yml + init-shell: bash + create-args: >- + --verbose + python=3.11 + matplotlib=3.9 + cache-environment: true + cache-downloads: false + + - name: Build Ultraplot + run: | + pip install --no-build-isolation --no-deps . + + - name: Run unit tests + run: | + pytest -n auto --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml -m "not mpl_image_compare" ultraplot/tests + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: Ultraplot/ultraplot + build-success: needs: - build + - unit-tests - run-if-changes if: always() runs-on: ubuntu-latest @@ -203,7 +243,7 @@ jobs: if [[ '${{ needs.run-if-changes.outputs.run }}' == 'false' ]]; then echo "No changes detected, tests skipped." else - if [[ '${{ needs.build.result }}' == 'success' ]]; then + if [[ '${{ needs.build.result }}' == 'success' && '${{ needs.unit-tests.result }}' == 'success' ]]; then echo "All tests passed successfully!" else echo "Tests failed!" From 3fc94f7cd3c8c22c21d370dea206766d762f114d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 24 Jan 2026 10:05:38 +1000 Subject: [PATCH 08/26] test: add pycirclize integration coverage --- ultraplot/tests/test_circlize_integration.py | 134 +++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 ultraplot/tests/test_circlize_integration.py diff --git a/ultraplot/tests/test_circlize_integration.py b/ultraplot/tests/test_circlize_integration.py new file mode 100644 index 000000000..39c7bb93f --- /dev/null +++ b/ultraplot/tests/test_circlize_integration.py @@ -0,0 +1,134 @@ +import sys +import types + +import pytest + +import ultraplot as uplt +from ultraplot import rc + + +@pytest.fixture() +def fake_pycirclize(monkeypatch): + class DummyCircos: + def __init__(self, sectors=None, **kwargs): + self.sectors = sectors + self.kwargs = kwargs + self.plot_called = False + self.plot_kwargs = None + + def plotfig(self, *args, **kwargs): + self.plot_called = True + self.plot_kwargs = kwargs + + @classmethod + def chord_diagram(cls, matrix_obj, **kwargs): + obj = cls({"matrix": True}) + obj.matrix_obj = matrix_obj + obj.kwargs = kwargs + return obj + + @classmethod + def radar_chart(cls, table_obj, **kwargs): + obj = cls({"table": True}) + obj.table_obj = table_obj + obj.kwargs = kwargs + return obj + + @classmethod + def initialize_from_tree(cls, *args, **kwargs): + obj = cls({"tree": True}) + obj.kwargs = kwargs + return obj, {"treeviz": True} + + @classmethod + def initialize_from_bed(cls, *args, **kwargs): + obj = cls({"bed": True}) + obj.kwargs = kwargs + return obj + + class DummyMatrix: + def __init__(self, data): + self.data = data + if isinstance(data, dict): + self.all_names = list(data.keys()) + else: + self.all_names = ["A", "B"] + + class DummyRadarTable: + def __init__(self, data): + self.data = data + if isinstance(data, dict): + self.row_names = list(data.keys()) + else: + self.row_names = ["A", "B"] + + pycirclize = types.ModuleType("pycirclize") + pycirclize.__path__ = [] + pycirclize.Circos = DummyCircos + parser = types.ModuleType("pycirclize.parser") + parser.__path__ = [] + matrix = types.ModuleType("pycirclize.parser.matrix") + table = types.ModuleType("pycirclize.parser.table") + matrix.Matrix = DummyMatrix + table.RadarTable = DummyRadarTable + parser.matrix = matrix + parser.table = table + pycirclize.parser = parser + + monkeypatch.setitem(sys.modules, "pycirclize", pycirclize) + monkeypatch.setitem(sys.modules, "pycirclize.parser", parser) + monkeypatch.setitem(sys.modules, "pycirclize.parser.matrix", matrix) + monkeypatch.setitem(sys.modules, "pycirclize.parser.table", table) + + yield pycirclize + + for name in ( + "pycirclize", + "pycirclize.parser", + "pycirclize.parser.matrix", + "pycirclize.parser.table", + ): + sys.modules.pop(name, None) + + +def test_circos_requires_polar_axes(): + fig, ax = uplt.subplots() + with pytest.raises(ValueError, match="requires a polar axes"): + ax.circos({"A": 1}) + uplt.close(fig) + + +def test_circos_delegates_grid(fake_pycirclize): + fig, axs = uplt.subplots(ncols=2, proj="polar") + result = axs.circos({"A": 1}, plot=False) + assert isinstance(result, tuple) + assert len(result) == 2 + assert all(hasattr(circos, "sectors") for circos in result) + uplt.close(fig) + + +def test_chord_diagram_defaults(fake_pycirclize): + fig, ax = uplt.subplots(proj="polar") + matrix = {"A": {"B": 1}, "B": {"A": 2}} + circos = ax.chord_diagram(matrix) + assert circos.plot_called is True + assert set(circos.kwargs["cmap"].keys()) == {"A", "B"} + uplt.close(fig) + + +def test_radar_chart_defaults(fake_pycirclize): + fig, ax = uplt.subplots(proj="polar") + table = {"A": [1, 2], "B": [3, 4]} + circos = ax.radar_chart(table, vmin=0, vmax=4, fill=False) + assert circos.plot_called is True + assert set(circos.kwargs["cmap"].keys()) == {"A", "B"} + uplt.close(fig) + + +def test_phylogeny_defaults(fake_pycirclize): + fig, ax = uplt.subplots(proj="polar") + circos, treeviz = ax.phylogeny("((A,B),C);") + assert circos.plot_called is True + assert treeviz["treeviz"] is True + assert circos.kwargs["leaf_label_size"] == rc["font.size"] + uplt.close(fig) From d1f651d9bdb60e9fd1fd4c6b77cbd1ba881d06e1 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 24 Jan 2026 12:34:07 +1000 Subject: [PATCH 09/26] test: expand pycirclize wrapper coverage --- ultraplot/tests/test_circlize_integration.py | 29 ++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/ultraplot/tests/test_circlize_integration.py b/ultraplot/tests/test_circlize_integration.py index 39c7bb93f..67a3b5c76 100644 --- a/ultraplot/tests/test_circlize_integration.py +++ b/ultraplot/tests/test_circlize_integration.py @@ -113,6 +113,12 @@ def test_chord_diagram_defaults(fake_pycirclize): circos = ax.chord_diagram(matrix) assert circos.plot_called is True assert set(circos.kwargs["cmap"].keys()) == {"A", "B"} + label_kws = circos.kwargs["label_kws"] + ticks_kws = circos.kwargs["ticks_kws"] + assert label_kws["color"] == rc["meta.color"] + assert label_kws["size"] == rc["font.size"] + assert ticks_kws["label_size"] == rc["font.size"] + assert ticks_kws["text_kws"]["color"] == rc["meta.color"] uplt.close(fig) @@ -122,6 +128,9 @@ def test_radar_chart_defaults(fake_pycirclize): circos = ax.radar_chart(table, vmin=0, vmax=4, fill=False) assert circos.plot_called is True assert set(circos.kwargs["cmap"].keys()) == {"A", "B"} + assert circos.kwargs["grid_line_kws"]["color"] == rc["grid.color"] + assert circos.kwargs["grid_label_kws"]["color"] == rc["meta.color"] + assert circos.kwargs["grid_label_kws"]["size"] == rc["font.size"] uplt.close(fig) @@ -132,3 +141,23 @@ def test_phylogeny_defaults(fake_pycirclize): assert treeviz["treeviz"] is True assert circos.kwargs["leaf_label_size"] == rc["font.size"] uplt.close(fig) + + +def test_circos_plot_and_tooltip(fake_pycirclize): + fig, ax = uplt.subplots(proj="polar") + circos = ax.circos({"A": 1, "B": 2}, plot=True, tooltip=True) + assert circos.plot_called is True + assert circos.plot_kwargs["tooltip"] is True + uplt.close(fig) + + +def test_circos_bed_plot_toggle(fake_pycirclize, tmp_path): + bed_path = tmp_path / "tiny.bed" + bed_path.write_text("chr1\t0\t10\n", encoding="utf-8") + fig, ax = uplt.subplots(proj="polar") + circos = ax.circos_bed(bed_path, plot=False) + assert circos.plot_called is False + circos = ax.circos_bed(bed_path, plot=True, tooltip=True) + assert circos.plot_called is True + assert circos.plot_kwargs["tooltip"] is True + uplt.close(fig) From 64e415cf27c43778ee92aa2fa06ca7f9a02a1877 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 24 Jan 2026 13:01:29 +1000 Subject: [PATCH 10/26] test: deepen pycirclize coverage --- ultraplot/tests/test_circlize_integration.py | 50 ++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/ultraplot/tests/test_circlize_integration.py b/ultraplot/tests/test_circlize_integration.py index 67a3b5c76..a8690fa0d 100644 --- a/ultraplot/tests/test_circlize_integration.py +++ b/ultraplot/tests/test_circlize_integration.py @@ -1,10 +1,13 @@ +import builtins import sys import types +from pathlib import Path import pytest import ultraplot as uplt from ultraplot import rc +from ultraplot.axes.plot_types import circlize as circlize_mod @pytest.fixture() @@ -161,3 +164,50 @@ def test_circos_bed_plot_toggle(fake_pycirclize, tmp_path): assert circos.plot_called is True assert circos.plot_kwargs["tooltip"] is True uplt.close(fig) + + +def test_import_pycirclize_error_message(monkeypatch): + orig_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "pycirclize": + raise ImportError("boom") + return orig_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + monkeypatch.setattr(Path, "is_dir", lambda self: False) + sys.modules.pop("pycirclize", None) + with pytest.raises(ImportError, match="pycirclize is required for circos plots"): + circlize_mod._import_pycirclize() + + +def test_resolve_defaults_with_existing_objects(fake_pycirclize): + matrix_mod = sys.modules["pycirclize.parser.matrix"] + table_mod = sys.modules["pycirclize.parser.table"] + matrix = matrix_mod.Matrix({"A": {"B": 1}, "B": {"A": 2}}) + table = table_mod.RadarTable({"A": [1, 2], "B": [3, 4]}) + + _, matrix_obj, cmap = circlize_mod._resolve_chord_defaults(matrix, cmap=None) + assert matrix_obj is matrix + assert set(cmap.keys()) == {"A", "B"} + + _, table_obj, cmap = circlize_mod._resolve_radar_defaults(table, cmap=None) + assert table_obj is table + assert set(cmap.keys()) == {"A", "B"} + + +def test_alias_methods(fake_pycirclize, tmp_path): + fig, ax = uplt.subplots(proj="polar") + matrix = {"A": {"B": 1}, "B": {"A": 2}} + circos = ax.chord(matrix) + assert circos.plot_called is True + + table = {"A": [1, 2], "B": [3, 4]} + circos = ax.radar(table, vmin=0, vmax=4, fill=False) + assert circos.plot_called is True + + bed_path = tmp_path / "mini.bed" + bed_path.write_text("chr1\t0\t10\n", encoding="utf-8") + circos = ax.bed(bed_path, plot=False) + assert hasattr(circos, "sectors") + uplt.close(fig) From c96a3ed18494339a47ece44d914bdf35a1057abe Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 24 Jan 2026 14:42:18 +1000 Subject: [PATCH 11/26] Add all to RTD build --- .readthedocs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.readthedocs.yml b/.readthedocs.yml index c3cfa3e57..170bc3def 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -22,3 +22,5 @@ python: install: - method: pip path: . + extra_requirements: + - all From 7df7fb1c1d6450f351243fb254e72579767c46c5 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 24 Jan 2026 14:43:25 +1000 Subject: [PATCH 12/26] Add to env.yml --- environment.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/environment.yml b/environment.yml index 904673230..5996f169f 100644 --- a/environment.yml +++ b/environment.yml @@ -35,3 +35,4 @@ dependencies: - lxml-html-clean - pip: - git+https://github.com/ultraplot/UltraTheme.git + - pycirclize From a3da0462ed62ae8096762cb2c2f1952e8ac18390 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 18 Jan 2026 17:41:06 +1000 Subject: [PATCH 13/26] feat: add pycirclize plot wrappers --- ultraplot/axes/plot.py | 223 +++++++++++++++++++++++++- ultraplot/axes/plot_types/circlize.py | 26 +++ 2 files changed, 246 insertions(+), 3 deletions(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index efe592df7..906be4f54 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -206,7 +206,6 @@ docstring._snippet_manager["plot.curved_quiver"] = _curved_quiver_docstring -<<<<<<< HEAD _sankey_docstring = """ Draw a Sankey diagram. @@ -300,7 +299,6 @@ """ docstring._snippet_manager["plot.sankey"] = _sankey_docstring -======= _chord_docstring = """ Draw a chord diagram using pyCirclize. @@ -480,7 +478,6 @@ """ docstring._snippet_manager["plot.circos_bed"] = _circos_bed_docstring ->>>>>>> 0e5e317b (feat: add pycirclize plot wrappers) # Auto colorbar and legend docstring _guide_docstring = """ colorbar : bool, int, or str, optional @@ -2524,7 +2521,227 @@ def radar(self, *args, **kwargs): Alias for `~PlotAxes.radar_chart`. """ return self.radar_chart(*args, **kwargs) +<<<<<<< HEAD >>>>>>> 0e5e317b (feat: add pycirclize plot wrappers) +======= + + def circos( + self, + sectors: Mapping[str, Any], + *, + start: float = 0, + end: float = 360, + space: float | Sequence[float] = 0, + endspace: bool = True, + sector2clockwise: Mapping[str, bool] | None = None, + show_axis_for_debug: bool = False, + plot: bool = False, + tooltip: bool = False, + ): + """ + %(plot.circos)s + """ + from .plot_types.circlize import circos + + return circos( + self, + sectors, + start=start, + end=end, + space=space, + endspace=endspace, + sector2clockwise=sector2clockwise, + show_axis_for_debug=show_axis_for_debug, + plot=plot, + tooltip=tooltip, + ) + + @docstring._snippet_manager + def phylogeny( + self, + tree_data: Any, + *, + start: float = 0, + end: float = 360, + r_lim: tuple[float, float] = (50, 100), + format: str = "newick", + outer: bool = True, + align_leaf_label: bool = True, + ignore_branch_length: bool = False, + leaf_label_size: float | None = None, + leaf_label_rmargin: float = 2.0, + reverse: bool = False, + ladderize: bool = False, + line_kws: Mapping[str, Any] | None = None, + label_formatter: Callable[[str], str] | None = None, + align_line_kws: Mapping[str, Any] | None = None, + tooltip: bool = False, + ): + """ + %(plot.phylogeny)s + """ + from .plot_types.circlize import phylogeny + + return phylogeny( + self, + tree_data, + start=start, + end=end, + r_lim=r_lim, + format=format, + outer=outer, + align_leaf_label=align_leaf_label, + ignore_branch_length=ignore_branch_length, + leaf_label_size=leaf_label_size, + leaf_label_rmargin=leaf_label_rmargin, + reverse=reverse, + ladderize=ladderize, + line_kws=line_kws, + label_formatter=label_formatter, + align_line_kws=align_line_kws, + tooltip=tooltip, + ) + + @docstring._snippet_manager + def circos_bed( + self, + bed_file: Any, + *, + start: float = 0, + end: float = 360, + space: float | Sequence[float] = 0, + endspace: bool = True, + sector2clockwise: Mapping[str, bool] | None = None, + plot: bool = False, + tooltip: bool = False, + ): + """ + %(plot.circos_bed)s + """ + from .plot_types.circlize import circos_bed + + return circos_bed( + self, + bed_file, + start=start, + end=end, + space=space, + endspace=endspace, + sector2clockwise=sector2clockwise, + plot=plot, + tooltip=tooltip, + ) + + def bed(self, *args, **kwargs): + """ + Alias for `~PlotAxes.circos_bed`. + """ + return self.circos_bed(*args, **kwargs) + + @docstring._snippet_manager + def chord_diagram( + self, + matrix: Any, + *, + start: float = 0, + end: float = 360, + space: float | Sequence[float] = 0, + endspace: bool = True, + r_lim: tuple[float, float] = (97, 100), + cmap: Any = None, + link_cmap: list[tuple[str, str, str]] | None = None, + ticks_interval: int | None = None, + order: str | list[str] | None = None, + label_kws: Mapping[str, Any] | None = None, + ticks_kws: Mapping[str, Any] | None = None, + link_kws: Mapping[str, Any] | None = None, + link_kws_handler: Callable[[str, str], Mapping[str, Any] | None] | None = None, + tooltip: bool = False, + ): + """ + %(plot.chord_diagram)s + """ + from .plot_types.circlize import chord_diagram + + return chord_diagram( + self, + matrix, + start=start, + end=end, + space=space, + endspace=endspace, + r_lim=r_lim, + cmap=cmap, + link_cmap=link_cmap, + ticks_interval=ticks_interval, + order=order, + label_kws=label_kws, + ticks_kws=ticks_kws, + link_kws=link_kws, + link_kws_handler=link_kws_handler, + tooltip=tooltip, + ) + + def chord(self, *args, **kwargs): + """ + Alias for `~PlotAxes.chord_diagram`. + """ + return self.chord_diagram(*args, **kwargs) + + @docstring._snippet_manager + def radar_chart( + self, + table: Any, + *, + r_lim: tuple[float, float] = (0, 100), + vmin: float = 0, + vmax: float = 100, + fill: bool = True, + marker_size: int = 0, + bg_color: str | None = "#eeeeee80", + circular: bool = False, + cmap: Any = None, + show_grid_label: bool = True, + grid_interval_ratio: float | None = 0.2, + grid_line_kws: Mapping[str, Any] | None = None, + grid_label_kws: Mapping[str, Any] | None = None, + grid_label_formatter: Callable[[float], str] | None = None, + label_kws_handler: Callable[[str], Mapping[str, Any]] | None = None, + line_kws_handler: Callable[[str], Mapping[str, Any]] | None = None, + marker_kws_handler: Callable[[str], Mapping[str, Any]] | None = None, + ): + """ + %(plot.radar_chart)s + """ + from .plot_types.circlize import radar_chart + + return radar_chart( + self, + table, + r_lim=r_lim, + vmin=vmin, + vmax=vmax, + fill=fill, + marker_size=marker_size, + bg_color=bg_color, + circular=circular, + cmap=cmap, + show_grid_label=show_grid_label, + grid_interval_ratio=grid_interval_ratio, + grid_line_kws=grid_line_kws, + grid_label_kws=grid_label_kws, + grid_label_formatter=grid_label_formatter, + label_kws_handler=label_kws_handler, + line_kws_handler=line_kws_handler, + marker_kws_handler=marker_kws_handler, + ) + + def radar(self, *args, **kwargs): + """ + Alias for `~PlotAxes.radar_chart`. + """ + return self.radar_chart(*args, **kwargs) +>>>>>>> 390ce66c (feat: add pycirclize plot wrappers) def _call_native(self, name, *args, **kwargs): """ diff --git a/ultraplot/axes/plot_types/circlize.py b/ultraplot/axes/plot_types/circlize.py index a98461f2e..728cda776 100644 --- a/ultraplot/axes/plot_types/circlize.py +++ b/ultraplot/axes/plot_types/circlize.py @@ -39,6 +39,7 @@ def _import_pycirclize(): return pycirclize +<<<<<<< HEAD def _unwrap_axes(ax, label: str): if ax.__class__.__name__ == "SubplotGrid": if len(ax) != 1: @@ -57,6 +58,11 @@ def _ensure_polar(ax, label: str): ax._unshare(which="y") ax._ultraplot_axis_type = ("circos", type(ax)) return ax +======= +def _ensure_polar(ax, label: str) -> None: + if not isinstance(ax, MplPolarAxes): + raise ValueError(f"{label} requires a polar axes (proj='polar').") +>>>>>>> e25e709f (feat: add pycirclize plot wrappers) def _cycle_colors(n: int) -> list[str]: @@ -115,7 +121,11 @@ def circos( """ Create a pyCirclize Circos instance (optionally plot immediately). """ +<<<<<<< HEAD ax = _ensure_polar(ax, "circos") +======= + _ensure_polar(ax, "circos") +>>>>>>> e25e709f (feat: add pycirclize plot wrappers) pycirclize = _import_pycirclize() circos_obj = pycirclize.Circos( sectors, @@ -153,7 +163,11 @@ def chord_diagram( """ Render a chord diagram using pyCirclize on the provided polar axes. """ +<<<<<<< HEAD ax = _ensure_polar(ax, "chord_diagram") +======= + _ensure_polar(ax, "chord_diagram") +>>>>>>> e25e709f (feat: add pycirclize plot wrappers) pycirclize, matrix_obj, cmap = _resolve_chord_defaults(matrix, cmap) label_kws = {} if label_kws is None else dict(label_kws) @@ -214,7 +228,11 @@ def radar_chart( """ Render a radar chart using pyCirclize on the provided polar axes. """ +<<<<<<< HEAD ax = _ensure_polar(ax, "radar_chart") +======= + _ensure_polar(ax, "radar_chart") +>>>>>>> e25e709f (feat: add pycirclize plot wrappers) pycirclize, table_obj, cmap = _resolve_radar_defaults(table, cmap) grid_line_kws = {} if grid_line_kws is None else dict(grid_line_kws) @@ -270,7 +288,11 @@ def phylogeny( """ Render a phylogenetic tree using pyCirclize on the provided polar axes. """ +<<<<<<< HEAD ax = _ensure_polar(ax, "phylogeny") +======= + _ensure_polar(ax, "phylogeny") +>>>>>>> e25e709f (feat: add pycirclize plot wrappers) pycirclize = _import_pycirclize() if leaf_label_size is None: leaf_label_size = rc["font.size"] @@ -310,7 +332,11 @@ def circos_bed( """ Create a Circos instance from a BED file (optionally plot immediately). """ +<<<<<<< HEAD ax = _ensure_polar(ax, "circos_bed") +======= + _ensure_polar(ax, "circos_bed") +>>>>>>> e25e709f (feat: add pycirclize plot wrappers) pycirclize = _import_pycirclize() circos_obj = pycirclize.Circos.initialize_from_bed( bed_file, From 4b3020b73ed268e00ae6e033b8cb226f9f55711e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 18 Jan 2026 17:41:35 +1000 Subject: [PATCH 14/26] test: add pycirclize wrapper smoke tests --- ultraplot/tests/test_plot.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index 647ff6408..ce61fa0ab 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -1158,8 +1158,12 @@ def test_circos_bed_smoke(tmp_path): bed_path = tmp_path / "mini.bed" bed_path.write_text("chr1\t0\t100\nchr2\t0\t120\n", encoding="utf-8") +<<<<<<< HEAD fig, axs = uplt.subplots(proj="polar") ax = axs[0] +======= + fig, ax = uplt.subplots(proj="polar") +>>>>>>> eaef636a (test: add pycirclize wrapper smoke tests) circos = ax.circos_bed(bed_path, plot=False) assert len(circos.sectors) == 2 circos.plotfig(ax=ax) @@ -1175,14 +1179,19 @@ def test_circos_builder_smoke(): except ImportError: pytest.skip("pycirclize is not available") +<<<<<<< HEAD fig, axs = uplt.subplots(proj="polar") ax = axs[0] +======= + fig, ax = uplt.subplots(proj="polar") +>>>>>>> eaef636a (test: add pycirclize wrapper smoke tests) circos = ax.circos({"A": 10, "B": 12}, plot=False) assert len(circos.sectors) == 2 circos.plotfig(ax=ax) uplt.close(fig) +<<<<<<< HEAD def test_circos_unshares_axes(): """Circos wrappers should unshare axes if they were shared.""" try: @@ -1221,6 +1230,8 @@ def test_circos_delegation_subplots(): uplt.close(fig) +======= +>>>>>>> eaef636a (test: add pycirclize wrapper smoke tests) def test_histogram_norms(): """ Check that all histograms-like plotting functions From e0a3d1cfc415b7ac97b56ed6721b283a06c52fc2 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 24 Jan 2026 14:51:08 +1000 Subject: [PATCH 15/26] Update circlize --- ultraplot/axes/plot_types/circlize.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/ultraplot/axes/plot_types/circlize.py b/ultraplot/axes/plot_types/circlize.py index 728cda776..a98461f2e 100644 --- a/ultraplot/axes/plot_types/circlize.py +++ b/ultraplot/axes/plot_types/circlize.py @@ -39,7 +39,6 @@ def _import_pycirclize(): return pycirclize -<<<<<<< HEAD def _unwrap_axes(ax, label: str): if ax.__class__.__name__ == "SubplotGrid": if len(ax) != 1: @@ -58,11 +57,6 @@ def _ensure_polar(ax, label: str): ax._unshare(which="y") ax._ultraplot_axis_type = ("circos", type(ax)) return ax -======= -def _ensure_polar(ax, label: str) -> None: - if not isinstance(ax, MplPolarAxes): - raise ValueError(f"{label} requires a polar axes (proj='polar').") ->>>>>>> e25e709f (feat: add pycirclize plot wrappers) def _cycle_colors(n: int) -> list[str]: @@ -121,11 +115,7 @@ def circos( """ Create a pyCirclize Circos instance (optionally plot immediately). """ -<<<<<<< HEAD ax = _ensure_polar(ax, "circos") -======= - _ensure_polar(ax, "circos") ->>>>>>> e25e709f (feat: add pycirclize plot wrappers) pycirclize = _import_pycirclize() circos_obj = pycirclize.Circos( sectors, @@ -163,11 +153,7 @@ def chord_diagram( """ Render a chord diagram using pyCirclize on the provided polar axes. """ -<<<<<<< HEAD ax = _ensure_polar(ax, "chord_diagram") -======= - _ensure_polar(ax, "chord_diagram") ->>>>>>> e25e709f (feat: add pycirclize plot wrappers) pycirclize, matrix_obj, cmap = _resolve_chord_defaults(matrix, cmap) label_kws = {} if label_kws is None else dict(label_kws) @@ -228,11 +214,7 @@ def radar_chart( """ Render a radar chart using pyCirclize on the provided polar axes. """ -<<<<<<< HEAD ax = _ensure_polar(ax, "radar_chart") -======= - _ensure_polar(ax, "radar_chart") ->>>>>>> e25e709f (feat: add pycirclize plot wrappers) pycirclize, table_obj, cmap = _resolve_radar_defaults(table, cmap) grid_line_kws = {} if grid_line_kws is None else dict(grid_line_kws) @@ -288,11 +270,7 @@ def phylogeny( """ Render a phylogenetic tree using pyCirclize on the provided polar axes. """ -<<<<<<< HEAD ax = _ensure_polar(ax, "phylogeny") -======= - _ensure_polar(ax, "phylogeny") ->>>>>>> e25e709f (feat: add pycirclize plot wrappers) pycirclize = _import_pycirclize() if leaf_label_size is None: leaf_label_size = rc["font.size"] @@ -332,11 +310,7 @@ def circos_bed( """ Create a Circos instance from a BED file (optionally plot immediately). """ -<<<<<<< HEAD ax = _ensure_polar(ax, "circos_bed") -======= - _ensure_polar(ax, "circos_bed") ->>>>>>> e25e709f (feat: add pycirclize plot wrappers) pycirclize = _import_pycirclize() circos_obj = pycirclize.Circos.initialize_from_bed( bed_file, From adca8bb00d8b7226bd22ef65b751d427b861b4b1 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 24 Jan 2026 15:01:05 +1000 Subject: [PATCH 16/26] Fix dirty branch --- ultraplot/axes/plot.py | 6 ------ ultraplot/tests/test_plot.py | 12 ------------ 2 files changed, 18 deletions(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 906be4f54..ea36ba7a0 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -2123,7 +2123,6 @@ def curved_quiver( return stream_container @docstring._snippet_manager -<<<<<<< HEAD def sankey( self, flows: Any, @@ -2304,7 +2303,6 @@ def _looks_like_links(values): sankey.add(**add_kw, **kwargs) diagrams = sankey.finish() return diagrams[0] if len(diagrams) == 1 else diagrams -======= def circos( self, sectors: Mapping[str, Any], @@ -2521,9 +2519,6 @@ def radar(self, *args, **kwargs): Alias for `~PlotAxes.radar_chart`. """ return self.radar_chart(*args, **kwargs) -<<<<<<< HEAD ->>>>>>> 0e5e317b (feat: add pycirclize plot wrappers) -======= def circos( self, @@ -2741,7 +2736,6 @@ def radar(self, *args, **kwargs): Alias for `~PlotAxes.radar_chart`. """ return self.radar_chart(*args, **kwargs) ->>>>>>> 390ce66c (feat: add pycirclize plot wrappers) def _call_native(self, name, *args, **kwargs): """ diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index ce61fa0ab..c64a72785 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -1158,12 +1158,8 @@ def test_circos_bed_smoke(tmp_path): bed_path = tmp_path / "mini.bed" bed_path.write_text("chr1\t0\t100\nchr2\t0\t120\n", encoding="utf-8") -<<<<<<< HEAD fig, axs = uplt.subplots(proj="polar") ax = axs[0] -======= - fig, ax = uplt.subplots(proj="polar") ->>>>>>> eaef636a (test: add pycirclize wrapper smoke tests) circos = ax.circos_bed(bed_path, plot=False) assert len(circos.sectors) == 2 circos.plotfig(ax=ax) @@ -1179,19 +1175,13 @@ def test_circos_builder_smoke(): except ImportError: pytest.skip("pycirclize is not available") -<<<<<<< HEAD fig, axs = uplt.subplots(proj="polar") ax = axs[0] -======= - fig, ax = uplt.subplots(proj="polar") ->>>>>>> eaef636a (test: add pycirclize wrapper smoke tests) circos = ax.circos({"A": 10, "B": 12}, plot=False) assert len(circos.sectors) == 2 circos.plotfig(ax=ax) uplt.close(fig) - -<<<<<<< HEAD def test_circos_unshares_axes(): """Circos wrappers should unshare axes if they were shared.""" try: @@ -1230,8 +1220,6 @@ def test_circos_delegation_subplots(): uplt.close(fig) -======= ->>>>>>> eaef636a (test: add pycirclize wrapper smoke tests) def test_histogram_norms(): """ Check that all histograms-like plotting functions From b753fca01f136a604b80c1afbad34192d3f208b7 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 24 Jan 2026 15:01:52 +1000 Subject: [PATCH 17/26] Black formatting --- ultraplot/axes/plot.py | 1 + ultraplot/tests/test_plot.py | 1 + 2 files changed, 2 insertions(+) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index ea36ba7a0..f59eafa53 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -2303,6 +2303,7 @@ def _looks_like_links(values): sankey.add(**add_kw, **kwargs) diagrams = sankey.finish() return diagrams[0] if len(diagrams) == 1 else diagrams + def circos( self, sectors: Mapping[str, Any], diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index c64a72785..647ff6408 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -1182,6 +1182,7 @@ def test_circos_builder_smoke(): circos.plotfig(ax=ax) uplt.close(fig) + def test_circos_unshares_axes(): """Circos wrappers should unshare axes if they were shared.""" try: From b958569175f39a61f33de1e98302b0425cd9a5a3 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 24 Jan 2026 18:15:05 +1000 Subject: [PATCH 18/26] Check if cache is causing visual diff --- .github/workflows/build-ultraplot.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index 9899017df..c682a7a8f 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -20,6 +20,7 @@ on: env: LC_ALL: en_US.UTF-8 LANG: en_US.UTF-8 + DISABLE_BASELINE_CACHE: "true" jobs: build-ultraplot: @@ -94,7 +95,7 @@ jobs: - name: Cache Baseline Figures id: cache-baseline uses: actions/cache@v4 - if: ${{ env.IS_PR }} + if: ${{ env.IS_PR && env.DISABLE_BASELINE_CACHE != 'true' }} with: path: ./ultraplot/tests/baseline # The directory to cache # Key is based on OS, Python/Matplotlib versions, and the base commit SHA From f2d78409a5ff824ae1dc9906e5b6bcfb41b7353a Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 24 Jan 2026 18:50:33 +1000 Subject: [PATCH 19/26] Use indexed version --- docs/examples/plot_types/10_circos_bed.py | 1 + ultraplot/tests/test_plot.py | 6 ++---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/examples/plot_types/10_circos_bed.py b/docs/examples/plot_types/10_circos_bed.py index bcfda3e8a..94bc0137c 100644 --- a/docs/examples/plot_types/10_circos_bed.py +++ b/docs/examples/plot_types/10_circos_bed.py @@ -19,6 +19,7 @@ bed_path.write_text(bed_text, encoding="utf-8") fig, ax = uplt.subplots(proj="polar", refwidth=3.6) + ax = ax[0] # pycirclize expects a PolarAxes, not a SubplotGrid wrapper circos = ax.circos_bed(bed_path, plot=False) for sector in circos.sectors: diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index 647ff6408..6cafa1373 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -1160,9 +1160,8 @@ def test_circos_bed_smoke(tmp_path): fig, axs = uplt.subplots(proj="polar") ax = axs[0] - circos = ax.circos_bed(bed_path, plot=False) + circos = ax.circos_bed(bed_path, plot=True) assert len(circos.sectors) == 2 - circos.plotfig(ax=ax) uplt.close(fig) @@ -1177,9 +1176,8 @@ def test_circos_builder_smoke(): fig, axs = uplt.subplots(proj="polar") ax = axs[0] - circos = ax.circos({"A": 10, "B": 12}, plot=False) + circos = ax.circos({"A": 10, "B": 12}, plot=True) assert len(circos.sectors) == 2 - circos.plotfig(ax=ax) uplt.close(fig) From 02de6445946f168714fba77f490c506ff9244f00 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 24 Jan 2026 23:44:04 +1000 Subject: [PATCH 20/26] ci: disable workflow caches for verification --- .github/workflows/build-ultraplot.yml | 6 +++--- .github/workflows/main.yml | 2 +- .github/workflows/test-map.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index c682a7a8f..5f7da01bd 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -46,7 +46,7 @@ jobs: --verbose python=${{ inputs.python-version }} matplotlib=${{ inputs.matplotlib-version }} - cache-environment: true + cache-environment: false cache-downloads: false - name: Build Ultraplot @@ -88,14 +88,14 @@ jobs: --verbose python=${{ inputs.python-version }} matplotlib=${{ inputs.matplotlib-version }} - cache-environment: true + cache-environment: false cache-downloads: false # Cache Baseline Figures (Restore step) - name: Cache Baseline Figures id: cache-baseline uses: actions/cache@v4 - if: ${{ env.IS_PR && env.DISABLE_BASELINE_CACHE != 'true' }} + if: ${{ false }} with: path: ./ultraplot/tests/baseline # The directory to cache # Key is based on OS, Python/Matplotlib versions, and the base commit SHA diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bcb70e58e..514ef736a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -214,7 +214,7 @@ jobs: --verbose python=3.11 matplotlib=3.9 - cache-environment: true + cache-environment: false cache-downloads: false - name: Build Ultraplot diff --git a/.github/workflows/test-map.yml b/.github/workflows/test-map.yml index f5c23e1e5..3735f25e0 100644 --- a/.github/workflows/test-map.yml +++ b/.github/workflows/test-map.yml @@ -26,7 +26,7 @@ jobs: --verbose python=3.11 matplotlib=3.9 - cache-environment: true + cache-environment: false cache-downloads: false - name: Build Ultraplot From 7c4ec72c1c4b79f04f5628b0aec77efea2a960ac Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 25 Jan 2026 06:40:05 +1000 Subject: [PATCH 21/26] ci: restore workflow caches --- .github/workflows/build-ultraplot.yml | 7 +++---- .github/workflows/main.yml | 2 +- .github/workflows/test-map.yml | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index 5f7da01bd..9899017df 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -20,7 +20,6 @@ on: env: LC_ALL: en_US.UTF-8 LANG: en_US.UTF-8 - DISABLE_BASELINE_CACHE: "true" jobs: build-ultraplot: @@ -46,7 +45,7 @@ jobs: --verbose python=${{ inputs.python-version }} matplotlib=${{ inputs.matplotlib-version }} - cache-environment: false + cache-environment: true cache-downloads: false - name: Build Ultraplot @@ -88,14 +87,14 @@ jobs: --verbose python=${{ inputs.python-version }} matplotlib=${{ inputs.matplotlib-version }} - cache-environment: false + cache-environment: true cache-downloads: false # Cache Baseline Figures (Restore step) - name: Cache Baseline Figures id: cache-baseline uses: actions/cache@v4 - if: ${{ false }} + if: ${{ env.IS_PR }} with: path: ./ultraplot/tests/baseline # The directory to cache # Key is based on OS, Python/Matplotlib versions, and the base commit SHA diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 514ef736a..bcb70e58e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -214,7 +214,7 @@ jobs: --verbose python=3.11 matplotlib=3.9 - cache-environment: false + cache-environment: true cache-downloads: false - name: Build Ultraplot diff --git a/.github/workflows/test-map.yml b/.github/workflows/test-map.yml index 3735f25e0..f5c23e1e5 100644 --- a/.github/workflows/test-map.yml +++ b/.github/workflows/test-map.yml @@ -26,7 +26,7 @@ jobs: --verbose python=3.11 matplotlib=3.9 - cache-environment: false + cache-environment: true cache-downloads: false - name: Build Ultraplot From fa5df3ffbd801b8384b89248a02e6458911cd7f8 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 25 Jan 2026 06:44:45 +1000 Subject: [PATCH 22/26] restore build to main --- .github/workflows/build-ultraplot.yml | 58 ++++++--------------------- 1 file changed, 12 insertions(+), 46 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index 9899017df..9ebab8d0e 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -8,14 +8,6 @@ on: matplotlib-version: required: true type: string - test-mode: - required: false - type: string - default: full - test-nodeids: - required: false - type: string - default: "" env: LC_ALL: en_US.UTF-8 @@ -29,9 +21,6 @@ jobs: defaults: run: shell: bash -el {0} - env: - TEST_MODE: ${{ inputs.test-mode }} - TEST_NODEIDS: ${{ inputs.test-nodeids }} steps: - uses: actions/checkout@v6 with: @@ -54,11 +43,7 @@ jobs: - name: Test Ultraplot run: | - if [ "${TEST_MODE}" = "selected" ] && [ -n "${TEST_NODEIDS}" ]; then - pytest -n auto --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml ${TEST_NODEIDS} - else - pytest -n auto --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml ultraplot - fi + pytest -n auto --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml ultraplot - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 @@ -71,8 +56,6 @@ jobs: runs-on: ubuntu-latest env: IS_PR: ${{ github.event_name == 'pull_request' }} - TEST_MODE: ${{ inputs.test-mode }} - TEST_NODEIDS: ${{ inputs.test-nodeids }} defaults: run: shell: bash -el {0} @@ -119,17 +102,10 @@ jobs: # Generate the baseline images and hash library python -c "import ultraplot as plt; plt.config.Configurator()._save_yaml('ultraplot.yml')" - if [ "${TEST_MODE}" = "selected" ] && [ -n "${TEST_NODEIDS}" ]; then - pytest -W ignore \ - --mpl-generate-path=./ultraplot/tests/baseline/ \ - --mpl-default-style="./ultraplot.yml" \ - ${TEST_NODEIDS} - else - pytest -W ignore \ - --mpl-generate-path=./ultraplot/tests/baseline/ \ - --mpl-default-style="./ultraplot.yml" \ - ultraplot/tests - fi + pytest -x -W ignore \ + --mpl-generate-path=./ultraplot/tests/baseline/ \ + --mpl-default-style="./ultraplot.yml"\ + ultraplot/tests # Return to the PR branch for the rest of the job if [ -n "${{ github.event.pull_request.base.sha }}" ]; then @@ -144,23 +120,13 @@ jobs: mkdir -p results python -c "import ultraplot as plt; plt.config.Configurator()._save_yaml('ultraplot.yml')" - if [ "${TEST_MODE}" = "selected" ] && [ -n "${TEST_NODEIDS}" ]; then - pytest -W ignore \ - --mpl \ - --mpl-baseline-path=./ultraplot/tests/baseline \ - --mpl-results-path=./results/ \ - --mpl-generate-summary=html \ - --mpl-default-style="./ultraplot.yml" \ - ${TEST_NODEIDS} - else - pytest -W ignore \ - --mpl \ - --mpl-baseline-path=./ultraplot/tests/baseline \ - --mpl-results-path=./results/ \ - --mpl-generate-summary=html \ - --mpl-default-style="./ultraplot.yml" \ - ultraplot/tests - fi + pytest -x -W ignore \ + --mpl \ + --mpl-baseline-path=./ultraplot/tests/baseline \ + --mpl-results-path=./results/ \ + --mpl-generate-summary=html \ + --mpl-default-style="./ultraplot.yml" \ + ultraplot/tests # Return the html output of the comparison even if failed - name: Upload comparison failures From 0f1d3457553f669b5eb5ddc570687b63da948bd1 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 25 Jan 2026 06:45:23 +1000 Subject: [PATCH 23/26] restore build to main --- .github/workflows/main.yml | 101 +------------------------------------ 1 file changed, 2 insertions(+), 99 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bcb70e58e..2cc8b1b68 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,60 +19,6 @@ jobs: python: - 'ultraplot/**' - select-tests: - runs-on: ubuntu-latest - needs: - - run-if-changes - if: always() && needs.run-if-changes.outputs.run == 'true' - outputs: - mode: ${{ steps.select.outputs.mode }} - tests: ${{ steps.select.outputs.tests }} - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Prepare workspace - run: mkdir -p .ci - - - name: Restore test map cache - id: restore-map - uses: actions/cache/restore@v4 - with: - path: .ci/test-map.json - key: test-map-${{ github.event.pull_request.base.sha }} - restore-keys: | - test-map- - - - name: Select impacted tests - id: select - run: | - if [ "${{ github.event_name }}" != "pull_request" ]; then - echo "mode=full" >> $GITHUB_OUTPUT - echo "tests=" >> $GITHUB_OUTPUT - exit 0 - fi - - git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} > .ci/changed.txt - - python tools/ci/select_tests.py \ - --map .ci/test-map.json \ - --changed-files .ci/changed.txt \ - --output .ci/selection.json \ - --always-full 'pyproject.toml' \ - --always-full 'environment.yml' \ - --always-full 'ultraplot/__init__.py' \ - --ignore 'docs/**' \ - --ignore 'README.rst' - - python - <<'PY' > .ci/selection.out - import json - data = json.load(open(".ci/selection.json", "r", encoding="utf-8")) - print(f"mode={data['mode']}") - print("tests=" + " ".join(data.get("tests", []))) - PY - cat .ci/selection.out >> $GITHUB_OUTPUT - get-versions: runs-on: ubuntu-latest needs: @@ -175,8 +121,7 @@ jobs: needs: - get-versions - run-if-changes - - select-tests - if: always() && needs.run-if-changes.outputs.run == 'true' && needs.get-versions.result == 'success' && needs.select-tests.result == 'success' + if: always() && needs.run-if-changes.outputs.run == 'true' && needs.get-versions.result == 'success' strategy: matrix: python-version: ${{ fromJson(needs.get-versions.outputs.python-versions) }} @@ -189,52 +134,10 @@ jobs: with: python-version: ${{ matrix.python-version }} matplotlib-version: ${{ matrix.matplotlib-version }} - test-mode: ${{ needs.select-tests.outputs.mode }} - test-nodeids: ${{ needs.select-tests.outputs.tests }} - - unit-tests: - name: Unit Tests (coverage focus) - needs: - - run-if-changes - if: always() && needs.run-if-changes.outputs.run == 'true' - runs-on: ubuntu-latest - defaults: - run: - shell: bash -el {0} - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - uses: mamba-org/setup-micromamba@v2.0.7 - with: - environment-file: ./environment.yml - init-shell: bash - create-args: >- - --verbose - python=3.11 - matplotlib=3.9 - cache-environment: true - cache-downloads: false - - - name: Build Ultraplot - run: | - pip install --no-build-isolation --no-deps . - - - name: Run unit tests - run: | - pytest -n auto --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml -m "not mpl_image_compare" ultraplot/tests - - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - slug: Ultraplot/ultraplot build-success: needs: - build - - unit-tests - run-if-changes if: always() runs-on: ubuntu-latest @@ -243,7 +146,7 @@ jobs: if [[ '${{ needs.run-if-changes.outputs.run }}' == 'false' ]]; then echo "No changes detected, tests skipped." else - if [[ '${{ needs.build.result }}' == 'success' && '${{ needs.unit-tests.result }}' == 'success' ]]; then + if [[ '${{ needs.build.result }}' == 'success' ]]; then echo "All tests passed successfully!" else echo "Tests failed!" From 1652c5113644bc0e5cf6508bfc67b9ad383a6b05 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 25 Jan 2026 06:48:17 +1000 Subject: [PATCH 24/26] ci: restore workflows from main --- .github/workflows/build-ultraplot.yml | 58 ++++++++++++++++++++------ .github/workflows/main.yml | 59 ++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index 9ebab8d0e..9899017df 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -8,6 +8,14 @@ on: matplotlib-version: required: true type: string + test-mode: + required: false + type: string + default: full + test-nodeids: + required: false + type: string + default: "" env: LC_ALL: en_US.UTF-8 @@ -21,6 +29,9 @@ jobs: defaults: run: shell: bash -el {0} + env: + TEST_MODE: ${{ inputs.test-mode }} + TEST_NODEIDS: ${{ inputs.test-nodeids }} steps: - uses: actions/checkout@v6 with: @@ -43,7 +54,11 @@ jobs: - name: Test Ultraplot run: | - pytest -n auto --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml ultraplot + if [ "${TEST_MODE}" = "selected" ] && [ -n "${TEST_NODEIDS}" ]; then + pytest -n auto --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml ${TEST_NODEIDS} + else + pytest -n auto --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml ultraplot + fi - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 @@ -56,6 +71,8 @@ jobs: runs-on: ubuntu-latest env: IS_PR: ${{ github.event_name == 'pull_request' }} + TEST_MODE: ${{ inputs.test-mode }} + TEST_NODEIDS: ${{ inputs.test-nodeids }} defaults: run: shell: bash -el {0} @@ -102,10 +119,17 @@ jobs: # Generate the baseline images and hash library python -c "import ultraplot as plt; plt.config.Configurator()._save_yaml('ultraplot.yml')" - pytest -x -W ignore \ - --mpl-generate-path=./ultraplot/tests/baseline/ \ - --mpl-default-style="./ultraplot.yml"\ - ultraplot/tests + if [ "${TEST_MODE}" = "selected" ] && [ -n "${TEST_NODEIDS}" ]; then + pytest -W ignore \ + --mpl-generate-path=./ultraplot/tests/baseline/ \ + --mpl-default-style="./ultraplot.yml" \ + ${TEST_NODEIDS} + else + pytest -W ignore \ + --mpl-generate-path=./ultraplot/tests/baseline/ \ + --mpl-default-style="./ultraplot.yml" \ + ultraplot/tests + fi # Return to the PR branch for the rest of the job if [ -n "${{ github.event.pull_request.base.sha }}" ]; then @@ -120,13 +144,23 @@ jobs: mkdir -p results python -c "import ultraplot as plt; plt.config.Configurator()._save_yaml('ultraplot.yml')" - pytest -x -W ignore \ - --mpl \ - --mpl-baseline-path=./ultraplot/tests/baseline \ - --mpl-results-path=./results/ \ - --mpl-generate-summary=html \ - --mpl-default-style="./ultraplot.yml" \ - ultraplot/tests + if [ "${TEST_MODE}" = "selected" ] && [ -n "${TEST_NODEIDS}" ]; then + pytest -W ignore \ + --mpl \ + --mpl-baseline-path=./ultraplot/tests/baseline \ + --mpl-results-path=./results/ \ + --mpl-generate-summary=html \ + --mpl-default-style="./ultraplot.yml" \ + ${TEST_NODEIDS} + else + pytest -W ignore \ + --mpl \ + --mpl-baseline-path=./ultraplot/tests/baseline \ + --mpl-results-path=./results/ \ + --mpl-generate-summary=html \ + --mpl-default-style="./ultraplot.yml" \ + ultraplot/tests + fi # Return the html output of the comparison even if failed - name: Upload comparison failures diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2cc8b1b68..c035214ed 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,6 +19,60 @@ jobs: python: - 'ultraplot/**' + select-tests: + runs-on: ubuntu-latest + needs: + - run-if-changes + if: always() && needs.run-if-changes.outputs.run == 'true' + outputs: + mode: ${{ steps.select.outputs.mode }} + tests: ${{ steps.select.outputs.tests }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Prepare workspace + run: mkdir -p .ci + + - name: Restore test map cache + id: restore-map + uses: actions/cache/restore@v4 + with: + path: .ci/test-map.json + key: test-map-${{ github.event.pull_request.base.sha }} + restore-keys: | + test-map- + + - name: Select impacted tests + id: select + run: | + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo "mode=full" >> $GITHUB_OUTPUT + echo "tests=" >> $GITHUB_OUTPUT + exit 0 + fi + + git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} > .ci/changed.txt + + python tools/ci/select_tests.py \ + --map .ci/test-map.json \ + --changed-files .ci/changed.txt \ + --output .ci/selection.json \ + --always-full 'pyproject.toml' \ + --always-full 'environment.yml' \ + --always-full 'ultraplot/__init__.py' \ + --ignore 'docs/**' \ + --ignore 'README.rst' + + python - <<'PY' > .ci/selection.out + import json + data = json.load(open(".ci/selection.json", "r", encoding="utf-8")) + print(f"mode={data['mode']}") + print("tests=" + " ".join(data.get("tests", []))) + PY + cat .ci/selection.out >> $GITHUB_OUTPUT + get-versions: runs-on: ubuntu-latest needs: @@ -121,7 +175,8 @@ jobs: needs: - get-versions - run-if-changes - if: always() && needs.run-if-changes.outputs.run == 'true' && needs.get-versions.result == 'success' + - select-tests + if: always() && needs.run-if-changes.outputs.run == 'true' && needs.get-versions.result == 'success' && needs.select-tests.result == 'success' strategy: matrix: python-version: ${{ fromJson(needs.get-versions.outputs.python-versions) }} @@ -134,6 +189,8 @@ jobs: with: python-version: ${{ matrix.python-version }} matplotlib-version: ${{ matrix.matplotlib-version }} + test-mode: ${{ needs.select-tests.outputs.mode }} + test-nodeids: ${{ needs.select-tests.outputs.tests }} build-success: needs: From 44d356eba854862456c00d2f7643ba04687ebb9d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 25 Jan 2026 06:52:07 +1000 Subject: [PATCH 25/26] docs: document optional all extra --- README.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.rst b/README.rst index a92fc3dbf..cd1e2aa9b 100644 --- a/README.rst +++ b/README.rst @@ -104,6 +104,13 @@ UltraPlot is published on `PyPi `__ and pip install ultraplot conda install -c conda-forge ultraplot +The default install is minimal. To enable optional features (for example, +pyCirclize-based plots), install the ``all`` extra: + +.. code-block:: bash + + pip install "ultraplot[all]" + Likewise, an existing installation of UltraPlot can be upgraded to the latest version with: From 121469dd6b64a4d0196e0fae3ef9829dfb66b98a Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 25 Jan 2026 06:53:44 +1000 Subject: [PATCH 26/26] packaging: include pycirclize by default and add minimal requirements --- README.rst | 7 ++++--- pyproject.toml | 5 +---- requirements-minimal.txt | 3 +++ 3 files changed, 8 insertions(+), 7 deletions(-) create mode 100644 requirements-minimal.txt diff --git a/README.rst b/README.rst index cd1e2aa9b..d9526253e 100644 --- a/README.rst +++ b/README.rst @@ -104,12 +104,13 @@ UltraPlot is published on `PyPi `__ and pip install ultraplot conda install -c conda-forge ultraplot -The default install is minimal. To enable optional features (for example, -pyCirclize-based plots), install the ``all`` extra: +The default install includes optional features (for example, pyCirclize-based plots). +For a minimal install, use ``--no-deps`` and install the core requirements: .. code-block:: bash - pip install "ultraplot[all]" + pip install ultraplot --no-deps + pip install -r requirements-minimal.txt Likewise, an existing installation of UltraPlot can be upgraded to the latest version with: diff --git a/pyproject.toml b/pyproject.toml index c5157c4bb..730ba3209 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,14 +34,11 @@ classifiers = [ dependencies= [ "numpy>=1.26.0", "matplotlib>=3.9,<3.11", + "pycirclize>=1.10.1", "typing-extensions; python_version < '3.12'", ] dynamic = ["version"] -[project.optional-dependencies] -circos = ["pycirclize>=1.10.1"] -all = ["pycirclize>=1.10.1"] - [project.urls] "Documentation" = "https://ultraplot.readthedocs.io" "Issue Tracker" = "https://github.com/ultraplot/ultraplot/issues" diff --git a/requirements-minimal.txt b/requirements-minimal.txt new file mode 100644 index 000000000..c4fa01680 --- /dev/null +++ b/requirements-minimal.txt @@ -0,0 +1,3 @@ +numpy>=1.26.0 +matplotlib>=3.9,<3.11 +typing-extensions; python_version < "3.12"