From c82079cdaf305f7e44da812fca411c817001874a Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 23 Mar 2026 16:40:12 +1000 Subject: [PATCH 1/3] Add title_kw to legend and fix titlefontweight/titlefontcolor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legend API now accepts a title_kw dict for passing arbitrary Text properties to the legend title (e.g. title_kw={'style': 'italic'}), consistent with the existing handle_kw parameter for handle styling. This change also fixes a pre-existing bug where titlefontweight and titlefontcolor were accepted by the API and silently stored but never actually applied to the legend title Text object — they now take effect as expected. Co-Authored-By: Claude Sonnet 4.6 --- ultraplot/axes/base.py | 6 ++++++ ultraplot/legend.py | 27 +++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 8ad5753d8..8c44c92be 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -633,6 +633,10 @@ titlefontsize, titlefontweight, titlefontcolor : optional The font size, weight, and color for the legend title. Font size is interpreted by `~ultraplot.utils.units`. The default size is `fontsize`. +title_kw : dict-like, optional + Additional properties passed to the legend title text object, e.g. + ``title_kw={'style': 'italic'}``. This can be used to set any + `~matplotlib.text.Text` property on the legend title. borderpad, borderaxespad, handlelength, handleheight, handletextpad, labelspacing, columnspacing : unit-spec, optional Various matplotlib `~matplotlib.axes.Axes.legend` spacing arguments. %(units.em)s @@ -1231,6 +1235,7 @@ def _add_legend( titlefontsize=None, titlefontweight=None, titlefontcolor=None, + title_kw=None, handle_kw=None, handler_map=None, span: Optional[Union[int, Tuple[int, int]]] = None, @@ -1263,6 +1268,7 @@ def _add_legend( titlefontsize=titlefontsize, titlefontweight=titlefontweight, titlefontcolor=titlefontcolor, + title_kw=title_kw, handle_kw=handle_kw, handler_map=handler_map, span=span, diff --git a/ultraplot/legend.py b/ultraplot/legend.py index c8c5c579d..f11fd61b7 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -1318,6 +1318,7 @@ class _LegendInputs: titlefontsize: float titlefontweight: Any titlefontcolor: Any + title_kw: Any handle_kw: Any handler_map: Any span: Optional[Union[int, Tuple[int, int]]] @@ -1794,6 +1795,7 @@ def _resolve_inputs( titlefontsize=None, titlefontweight=None, titlefontcolor=None, + title_kw=None, handle_kw=None, handler_map=None, span: Optional[Union[int, Tuple[int, int]]] = None, @@ -1844,6 +1846,7 @@ def _resolve_inputs( titlefontsize=titlefontsize, titlefontweight=titlefontweight, titlefontcolor=titlefontcolor, + title_kw=title_kw, handle_kw=handle_kw, handler_map=handler_map, span=span, @@ -1896,6 +1899,9 @@ def _resolve_style_kwargs( lax, fontcolor, fontweight, + titlefontweight, + titlefontcolor, + title_kw, handle_kw, kwargs, ): @@ -1908,10 +1914,16 @@ def _resolve_style_kwargs( kw_text["color"] = fontcolor if fontweight is not None: kw_text["weight"] = fontweight + kw_title = {} + if titlefontweight is not None: + kw_title["weight"] = titlefontweight + if titlefontcolor is not None: + kw_title["color"] = titlefontcolor + kw_title.update(title_kw or {}) kw_handle = _pop_props(kwargs, "line") kw_handle.setdefault("solid_capstyle", "butt") kw_handle.update(handle_kw or {}) - return kw_frame, kw_text, kw_handle, kwargs + return kw_frame, kw_text, kw_title, kw_handle, kwargs def _build_legends( self, @@ -1959,12 +1971,14 @@ def _build_legends( lax.add_artist(obj) return objs - def _apply_handle_styles(self, objs, *, kw_text, kw_handle): + def _apply_handle_styles(self, objs, *, kw_text, kw_title, kw_handle): """ Apply per-handle styling overrides to legend artists. """ for obj in objs: obj.set_clip_on(False) + if kw_title: + obj.get_title().update(kw_title) box = getattr(obj, "_legend_handle_box", None) for child in guides._iter_children(box): if isinstance(child, mtext.Text): @@ -2015,6 +2029,7 @@ def add( titlefontsize=None, titlefontweight=None, titlefontcolor=None, + title_kw=None, handle_kw=None, handler_map=None, span: Optional[Union[int, Tuple[int, int]]] = None, @@ -2050,6 +2065,7 @@ def add( titlefontsize=titlefontsize, titlefontweight=titlefontweight, titlefontcolor=titlefontcolor, + title_kw=title_kw, handle_kw=handle_kw, handler_map=handler_map, span=span, @@ -2062,10 +2078,13 @@ def add( lax, kwargs = self._resolve_axes_layout(inputs) - kw_frame, kw_text, kw_handle, kwargs = self._resolve_style_kwargs( + kw_frame, kw_text, kw_title, kw_handle, kwargs = self._resolve_style_kwargs( lax=lax, fontcolor=inputs.fontcolor, fontweight=inputs.fontweight, + titlefontweight=inputs.titlefontweight, + titlefontcolor=inputs.titlefontcolor, + title_kw=inputs.title_kw, handle_kw=inputs.handle_kw, kwargs=kwargs, ) @@ -2079,5 +2098,5 @@ def add( kwargs=kwargs, ) - self._apply_handle_styles(objs, kw_text=kw_text, kw_handle=kw_handle) + self._apply_handle_styles(objs, kw_text=kw_text, kw_title=kw_title, kw_handle=kw_handle) return self._finalize(objs, loc=inputs.loc, align=inputs.align) From 0074421d135e5c69040e7be2cf887e9aefb9f3ab Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 23 Mar 2026 16:40:50 +1000 Subject: [PATCH 2/3] Implement title_kw --- pyproject.toml | 3 + ultraplot/__init__.py | 301 +++++++++++++++++++++++++++++++- ultraplot/tests/test_imports.py | 55 ++++++ 3 files changed, 358 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1f85fd3b6..f169e6597 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,9 @@ dynamic = ["version"] packages = {find = {exclude=["docs*", "baseline*", "logo*"]}} include-package-data = true +[tool.setuptools.package-data] +ultraplot = ["py.typed"] + [tool.setuptools_scm] write_to = "ultraplot/_version.py" write_to_template = "__version__ = '{version}'\n" diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index 01fd0bed6..49fe1fb51 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -7,12 +7,311 @@ import sys from pathlib import Path -from typing import Optional +from typing import TYPE_CHECKING, Any, Optional from ._lazy import LazyLoader, install_module_proxy name = "ultraplot" +if TYPE_CHECKING: + import matplotlib.pyplot as pyplot + + try: + import cartopy as cartopy + from cartopy.crs import ( + AlbersEqualArea, + AzimuthalEquidistant, + EckertI, + EckertII, + EckertIII, + EckertIV, + EckertV, + EckertVI, + EqualEarth, + EquidistantConic, + EuroPP, + Geostationary, + Gnomonic, + InterruptedGoodeHomolosine, + LambertAzimuthalEqualArea, + LambertConformal, + LambertCylindrical, + Mercator, + Miller, + Mollweide, + NearsidePerspective, + NorthPolarStereo, + OSGB, + OSNI, + Orthographic, + PlateCarree, + Robinson, + RotatedPole, + Sinusoidal, + SouthPolarStereo, + Stereographic, + TransverseMercator, + UTM, + ) + except ModuleNotFoundError: + cartopy: Any + AlbersEqualArea: Any + AzimuthalEquidistant: Any + EckertI: Any + EckertII: Any + EckertIII: Any + EckertIV: Any + EckertV: Any + EckertVI: Any + EqualEarth: Any + EquidistantConic: Any + EuroPP: Any + Geostationary: Any + Gnomonic: Any + InterruptedGoodeHomolosine: Any + LambertAzimuthalEqualArea: Any + LambertConformal: Any + LambertCylindrical: Any + Mercator: Any + Miller: Any + Mollweide: Any + NearsidePerspective: Any + NorthPolarStereo: Any + OSGB: Any + OSNI: Any + Orthographic: Any + PlateCarree: Any + Robinson: Any + RotatedPole: Any + Sinusoidal: Any + SouthPolarStereo: Any + Stereographic: Any + TransverseMercator: Any + UTM: Any + + try: + import mpl_toolkits.basemap as basemap + except ImportError: + basemap: Any + + from matplotlib import rcParams as rc_matplotlib + from matplotlib.colors import ( + LogNorm, + NoNorm, + Normalize, + PowerNorm, + SymLogNorm, + TwoSlopeNorm, + ) + from matplotlib.dates import ( + AutoDateFormatter, + AutoDateLocator, + ConciseDateFormatter, + DateFormatter, + DayLocator, + HourLocator, + MicrosecondLocator, + MinuteLocator, + MonthLocator, + SecondLocator, + WeekdayLocator, + YearLocator, + ) + from matplotlib.projections.polar import ThetaFormatter, ThetaLocator + from matplotlib.scale import AsinhScale, FuncScaleLog + from matplotlib.ticker import ( + AutoLocator, + AutoMinorLocator, + EngFormatter, + FixedLocator, + FormatStrFormatter, + FuncFormatter, + LinearLocator, + LogFormatterMathtext, + LogFormatterSciNotation, + LogLocator, + LogitFormatter, + LogitLocator, + MaxNLocator, + MultipleLocator, + NullFormatter, + NullLocator, + PercentFormatter, + ScalarFormatter, + StrMethodFormatter, + SymmetricalLogLocator, + ) + + from . import ( + axes, + colorbar, + colors, + config, + constructor, + demos, + externals, + gridspec, + internals, + legend, + proj, + scale, + tests, + text, + ticker, + ui, + ultralayout, + utils, + ) + from .axes.base import Axes + from .axes.cartesian import CartesianAxes + from .axes.container import ExternalAxesContainer + from .axes.geo import GeoAxes + from .axes.plot import PlotAxes + from .axes.polar import PolarAxes + from .axes.three import ThreeAxes + from .colors import ( + ColorDatabase, + ColormapDatabase, + ContinuousColormap, + DiscreteColormap, + DiscreteNorm, + DivergingNorm, + PerceptualColormap, + SegmentedNorm, + _cmap_database as colormaps, + ) + from .config import ( + Configurator, + config_inline_backend, + rc, + register_cmaps, + register_colors, + register_cycles, + register_fonts, + use_style, + ) + from .constructor import ( + FORMATTERS, + LOCATORS, + NORMS, + PROJS, + SCALES, + Colormap, + Cycle, + Formatter, + Locator, + Norm, + Proj, + Scale, + ) + from .demos import ( + show_channels, + show_cmaps, + show_colors, + show_colorspaces, + show_cycles, + show_fonts, + ) + from .figure import Figure + from .gridspec import GridSpec, SubplotGrid + from .internals import rcsetup, warnings + from .internals.rcsetup import rc_ultraplot + from .internals.warnings import ( + LinearSegmentedColormap, + LinearSegmentedNorm, + ListedColormap, + PerceptuallyUniformColormap, + RcConfigurator, + inline_backend_fmt, + saturate, + shade, + ) + from .legend import GeometryEntry, Legend, LegendEntry + from .proj import ( + Aitoff, + Hammer, + KavrayskiyVII, + NorthPolarAzimuthalEquidistant, + NorthPolarGnomonic, + NorthPolarLambertAzimuthalEqualArea, + SouthPolarAzimuthalEquidistant, + SouthPolarGnomonic, + SouthPolarLambertAzimuthalEqualArea, + WinkelTripel, + ) + from . import proj as crs + from .scale import ( + CutoffScale, + ExpScale, + FuncScale, + InverseScale, + LinearScale, + LogScale, + LogitScale, + MercatorLatitudeScale, + PowerScale, + SineLatitudeScale, + SymmetricalLogScale, + ) + from .text import CurvedText + from .ticker import ( + AutoCFDatetimeFormatter, + AutoCFDatetimeLocator, + AutoFormatter, + CFDatetimeFormatter, + DegreeFormatter, + DegreeLocator, + DiscreteLocator, + FracFormatter, + IndexFormatter, + IndexLocator, + LatitudeFormatter, + LatitudeLocator, + LongitudeFormatter, + LongitudeLocator, + SciFormatter, + SigFigFormatter, + SimpleFormatter, + ) + from .ui import ( + close, + figure, + ioff, + ion, + isinteractive, + show, + subplot, + subplots, + switch_backend, + ) + from .ultralayout import ( + ColorbarLayoutSolver, + UltraLayoutSolver, + compute_ultra_positions, + get_grid_positions_ultra, + is_orthogonal_layout, + ) + from .utils import ( + arange, + check_for_update, + edges, + edges2d, + get_colors, + scale_luminance, + scale_saturation, + set_alpha, + set_hue, + set_luminance, + set_saturation, + shift_hue, + to_hex, + to_rgb, + to_rgba, + to_xyz, + to_xyza, + units, + ) + try: from ._version import __version__ except ImportError: diff --git a/ultraplot/tests/test_imports.py b/ultraplot/tests/test_imports.py index f7ba6e2e0..ef1b467fe 100644 --- a/ultraplot/tests/test_imports.py +++ b/ultraplot/tests/test_imports.py @@ -3,6 +3,8 @@ import os import subprocess import sys +import ast +from pathlib import Path import pytest @@ -146,3 +148,56 @@ def test_docstring_missing_triggers_lazy_import(): docstring._snippet_manager["ticker.not_a_real_key"] with pytest.raises(KeyError): docstring._snippet_manager["does_not_exist.key"] + + +def _collect_type_checking_names(): + root = Path(__file__).resolve().parents[2] + init_path = root / "ultraplot" / "__init__.py" + tree = ast.parse(init_path.read_text(encoding="utf-8"), filename=str(init_path)) + for node in tree.body: + if isinstance(node, ast.If) and isinstance(node.test, ast.Name): + if node.test.id != "TYPE_CHECKING": + continue + names = set() + for child in ast.walk(node): + if isinstance(child, ast.Import): + for alias in child.names: + names.add(alias.asname or alias.name.split(".")[0]) + elif isinstance(child, ast.ImportFrom): + for alias in child.names: + names.add(alias.asname or alias.name) + elif isinstance(child, ast.AnnAssign) and isinstance( + child.target, ast.Name + ): + names.add(child.target.id) + return names + raise AssertionError("Missing TYPE_CHECKING export block in ultraplot.__init__") + + +def test_type_checking_block_exposes_core_lazy_api(): + names = _collect_type_checking_names() + expected = { + "Figure", + "colormaps", + "figure", + "pyplot", + "rc", + "show_colors", + "subplot", + "subplots", + } + assert expected.issubset(names) + + +def test_package_marks_itself_typed(): + import ultraplot as uplt + + typed_marker = Path(uplt.__file__).resolve().with_name("py.typed") + assert typed_marker.is_file() + + +def test_pyproject_includes_typed_marker(): + root = Path(__file__).resolve().parents[2] + text = (root / "pyproject.toml").read_text(encoding="utf-8") + assert '[tool.setuptools.package-data]' in text + assert 'ultraplot = ["py.typed"]' in text From 4969a4fcf8392f893ff4fc6cc8488c2f620caa9a Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 23 Mar 2026 16:53:38 +1000 Subject: [PATCH 3/3] style: apply black formatting Co-Authored-By: Claude Sonnet 4.6 --- ultraplot/legend.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index f11fd61b7..db71338f3 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -2098,5 +2098,7 @@ def add( kwargs=kwargs, ) - self._apply_handle_styles(objs, kw_text=kw_text, kw_title=kw_title, kw_handle=kw_handle) + self._apply_handle_styles( + objs, kw_text=kw_text, kw_title=kw_title, kw_handle=kw_handle + ) return self._finalize(objs, loc=inputs.loc, align=inputs.align)