From 427e5b1ebdb6fe9f0440f79d8a742c15bebc8c02 Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Tue, 14 Apr 2026 18:33:11 +1000 Subject: [PATCH 1/9] Hotfix/publish zenodo fix (#686) Could still be broken. Attempts to fix the citation sync without causing a dirty state that prevents uploading to pypi --- tools/release/publish_zenodo.py | 55 +++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/tools/release/publish_zenodo.py b/tools/release/publish_zenodo.py index db57689ce..5b55af38e 100644 --- a/tools/release/publish_zenodo.py +++ b/tools/release/publish_zenodo.py @@ -288,19 +288,70 @@ def validate_inputs(dist_dir: Path, access_token: str | None) -> None: raise SystemExit(f"Distribution directory {dist_dir} does not contain files.") +def find_existing_draft(api_url: str, token: str, record_id: int) -> dict | None: + record = api_request("GET", f"{api_url}/records/{record_id}", token=token) + conceptrecid = str(record.get("conceptrecid") or record.get("id")) + + page = 1 + while True: + payload = api_request( + "GET", + f"{api_url}/deposit/depositions?page={page}&size=100", + token=token, + ) + + if not payload: + break + + for dep in payload: + if str(dep.get("conceptrecid")) != conceptrecid: + continue + if dep.get("submitted"): + continue + + print(f"Found existing draft deposition {dep['id']}") + return dep + + if len(payload) < 100: + break + + page += 1 + + return None + + +def get_or_create_draft(api_url: str, token: str, record_id: int) -> dict: + try: + return create_new_version(api_url, token, record_id) + except RuntimeError as exc: + message = str(exc) + if "files.enabled" not in message: + raise + + draft = find_existing_draft(api_url, token, record_id) + if draft is None: + raise + print(f"Reusing existing Zenodo draft {draft['id']}.") + return draft + + def main() -> int: args = parse_args() validate_inputs(args.dist_dir, args.access_token) - citation = load_citation(args.citation) + + citation = load_citation(args.citation_file) pyproject = load_pyproject(args.pyproject) metadata = build_metadata(citation, pyproject) + conceptrecid = resolve_concept_recid(args.api_url, citation["doi"]) record_id = latest_record_id(args.api_url, conceptrecid) - draft = create_new_version(args.api_url, args.access_token, record_id) + + draft = get_or_create_draft(args.api_url, args.access_token, record_id) clear_draft_files(draft, args.access_token) upload_dist_files(draft, args.access_token, args.dist_dir) draft = update_metadata(draft, args.access_token, metadata) published = publish_draft(draft, args.access_token) + doi = published.get("doi") or published.get("metadata", {}).get("doi") print( f"Published Zenodo release record {published['id']} for " From fe38293d8eb98a6da8c476650123d703fcfe68a3 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 14 Apr 2026 19:10:53 +1000 Subject: [PATCH 2/9] fix zenodo tests --- .github/workflows/publish-pypi.yml | 3 +-- ultraplot/tests/test_release_metadata.py | 8 ++++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 25b8069d6..e5f7144c0 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -3,8 +3,7 @@ name: Publish to PyPI on: pull_request: push: - tags: - - "v*" + tags: ["v*"] concurrency: group: publish-pypi-${{ github.sha }} diff --git a/ultraplot/tests/test_release_metadata.py b/ultraplot/tests/test_release_metadata.py index e865d12a1..c6c570242 100644 --- a/ultraplot/tests/test_release_metadata.py +++ b/ultraplot/tests/test_release_metadata.py @@ -138,8 +138,12 @@ def test_publish_workflow_creates_github_release_and_pushes_to_zenodo(): """ text = PUBLISH_WORKFLOW.read_text(encoding="utf-8") assert 'tags: ["v*"]' in text - assert text.count("tools/release/sync_citation.py --tag") >= 2 + assert "tools/release/sync_citation.py" in text + assert "--tag" in text + assert "--output" in text assert "softprops/action-gh-release@v2" in text assert "publish-zenodo:" in text assert "ZENODO_ACCESS_TOKEN" in text - assert "tools/release/publish_zenodo.py --dist-dir dist" in text + assert "tools/release/publish_zenodo.py" in text + assert "--dist-dir dist" in text + assert '--citation-file "${RUNNER_TEMP}/CITATION.cff"' in text From 2f929f7855a35f0697ff9c5529c74c83997651a1 Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Wed, 15 Apr 2026 15:28:30 +1000 Subject: [PATCH 3/9] Refactor init figure (#687) Refactors init of the figure class to reduce cognitive load and improve readability by splitting the logic into helpers. --- ultraplot/figure.py | 186 ++++++++++++++++++++++++++------------------ 1 file changed, 111 insertions(+), 75 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 2bac74b22..1c0e47c8f 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -657,18 +657,66 @@ def __init__( ultraplot.ui.subplots matplotlib.figure.Figure """ - # Add figure sizing settings - # NOTE: We cannot catpure user-input 'figsize' here because it gets - # automatically filled by the figure manager. See ui.figure(). - # NOTE: The figure size is adjusted according to these arguments by the - # canvas preprocessor. Although in special case where both 'figwidth' and - # 'figheight' were passes we update 'figsize' to limit side effects. - refnum = _not_none(refnum=refnum, ref=ref, default=1) # never None + # Resolve aliases + refnum = _not_none(refnum=refnum, ref=ref, default=1) refaspect = _not_none(refaspect=refaspect, aspect=aspect) refwidth = _not_none(refwidth=refwidth, axwidth=axwidth) refheight = _not_none(refheight=refheight, axheight=axheight) figwidth = _not_none(figwidth=figwidth, width=width) figheight = _not_none(figheight=figheight, height=height) + + # Initialize sections + figwidth, figheight = self._init_figure_size( + refnum, refaspect, refwidth, refheight, figwidth, figheight, journal + ) + self._init_gridspec_params( + left=left, + right=right, + top=top, + bottom=bottom, + wspace=wspace, + hspace=hspace, + space=space, + wequal=wequal, + hequal=hequal, + equal=equal, + wgroup=wgroup, + hgroup=hgroup, + group=group, + wpad=wpad, + hpad=hpad, + pad=pad, + outerpad=outerpad, + innerpad=innerpad, + panelpad=panelpad, + ) + self._init_tight_layout(tight, kwargs) + self._init_sharing( + sharex=sharex, + sharey=sharey, + share=share, + spanx=spanx, + spany=spany, + span=span, + alignx=alignx, + aligny=aligny, + align=align, + ) + self._init_figure_state(figwidth, figheight, kwargs) + + def _init_figure_size( + self, refnum, refaspect, refwidth, refheight, figwidth, figheight, journal + ): + """ + Resolve figure sizing from reference dimensions, journal presets, + and explicit figure dimensions. Sets sizing attributes on self and + returns the resolved (figwidth, figheight). + """ + # NOTE: We cannot capture user-input 'figsize' here because it gets + # automatically filled by the figure manager. See ui.figure(). + # NOTE: The figure size is adjusted according to these arguments by the + # canvas preprocessor. Although in special case where both 'figwidth' and + # 'figheight' were passed we update 'figsize' to limit side effects. messages = [] if journal is not None: jwidth, jheight = _get_journal_size(journal) @@ -689,7 +737,7 @@ def __init__( and figheight is None and refwidth is None and refheight is None - ): # noqa: E501 + ): refwidth = rc["subplots.refwidth"] # always inches if np.iterable(refaspect): refaspect = refaspect[0] / refaspect[1] @@ -706,7 +754,7 @@ def __init__( self._figwidth = figwidth = units(figwidth, "in") self._figheight = figheight = units(figheight, "in") - # Add special consideration for interactive backends + # Handle interactive backends backend = _not_none(rc.backend, "") backend = backend.lower() interactive = "nbagg" in backend or "ipympl" in backend @@ -727,35 +775,13 @@ def __init__( "(default) backend. This warning message is shown the first time " "you create a figure without explicitly specifying the size." ) + return figwidth, figheight - # Add space settings - # NOTE: This is analogous to 'subplotpars' but we don't worry about - # user mutability. Think it's perfectly fine to ask users to simply - # pass these to uplt.figure() or uplt.subplots(). Also overriding - # 'subplots_adjust' would be confusing since we switch to absolute - # units and that function is heavily used outside of ultraplot. - params = { - "left": left, - "right": right, - "top": top, - "bottom": bottom, - "wspace": wspace, - "hspace": hspace, - "space": space, - "wequal": wequal, - "hequal": hequal, - "equal": equal, - "wgroup": wgroup, - "hgroup": hgroup, - "group": group, - "wpad": wpad, - "hpad": hpad, - "pad": pad, - "outerpad": outerpad, - "innerpad": innerpad, - "panelpad": panelpad, - } - self._gridspec_params = params # used to initialize the gridspec + def _init_gridspec_params(self, **params): + """ + Validate and store gridspec spacing parameters. + """ + self._gridspec_params = params for key, value in tuple(params.items()): if not isinstance(value, str) and np.iterable(value) and len(value) > 1: raise ValueError( @@ -764,7 +790,10 @@ def __init__( "GridSpec() or pass space parameters to subplots()." ) - # Add tight layout setting and ignore native settings + def _init_tight_layout(self, tight, kwargs): + """ + Configure tight layout, suppressing native matplotlib layout engines. + """ pars = kwargs.pop("subplotpars", None) if pars is not None: warnings._warn_ultraplot( @@ -785,50 +814,58 @@ def __init__( if rc_matplotlib.get("figure.constrained_layout.use", False): warnings._warn_ultraplot( "Setting rc['figure.constrained_layout.use'] to False. " - + self._tight_message # noqa: E501 + + self._tight_message ) try: - rc_matplotlib["figure.autolayout"] = False # this is rcParams + rc_matplotlib["figure.autolayout"] = False except KeyError: pass try: - rc_matplotlib["figure.constrained_layout.use"] = False # this is rcParams + rc_matplotlib["figure.constrained_layout.use"] = False except KeyError: pass self._tight_active = _not_none(tight, rc["subplots.tight"]) - # Translate share settings + @staticmethod + def _normalize_share(value): + """ + Normalize a share setting to an integer level and auto flag. + """ translate = {"labels": 1, "labs": 1, "limits": 2, "lims": 2, "all": 4} + auto = isinstance(value, str) and value.lower() == "auto" + if auto: + return 3, True + value = 3 if value is True else translate.get(value, value) + if value not in range(5): + raise ValueError( + f"Invalid sharing value {value!r}. " + Figure._share_message + ) + return int(value), False + + def _init_sharing( + self, *, sharex, sharey, share, spanx, spany, span, alignx, aligny, align + ): + """ + Resolve share, span, and align settings. + """ sharex = _not_none(sharex, share, rc["subplots.share"]) sharey = _not_none(sharey, share, rc["subplots.share"]) - - def _normalize_share(value): - auto = isinstance(value, str) and value.lower() == "auto" - if auto: - return 3, True - value = 3 if value is True else translate.get(value, value) - if value not in range(5): - raise ValueError( - f"Invalid sharing value {value!r}. " + self._share_message - ) - return int(value), False - - sharex, sharex_auto = _normalize_share(sharex) - sharey, sharey_auto = _normalize_share(sharey) + sharex, sharex_auto = self._normalize_share(sharex) + sharey, sharey_auto = self._normalize_share(sharey) self._sharex = int(sharex) self._sharey = int(sharey) self._sharex_auto = bool(sharex_auto) self._sharey_auto = bool(sharey_auto) self._share_incompat_warned = False - # Translate span and align settings + # Span and align settings spanx = _not_none( spanx, span, False if not sharex else None, rc["subplots.span"] - ) # noqa: E501 + ) spany = _not_none( spany, span, False if not sharey else None, rc["subplots.span"] - ) # noqa: E501 - if spanx and (alignx or align): # only warn when explicitly requested + ) + if spanx and (alignx or align): warnings._warn_ultraplot('"alignx" has no effect when spanx=True.') if spany and (aligny or align): warnings._warn_ultraplot('"aligny" has no effect when spany=True.') @@ -839,12 +876,15 @@ def _normalize_share(value): self._alignx = bool(alignx) self._aligny = bool(aligny) - # Initialize the figure - # NOTE: Super labels are stored inside {axes: text} dictionaries + def _init_figure_state(self, figwidth, figheight, kwargs): + """ + Initialize internal state, call matplotlib's Figure.__init__, + set up super labels, and apply initial formatting. + """ self._gridspec = None self._panel_dict = {"left": [], "right": [], "bottom": [], "top": []} - self._subplot_dict = {} # subplots indexed by number - self._subplot_counter = 0 # avoid add_subplot() returning an existing subplot + self._subplot_dict = {} + self._subplot_counter = 0 self._is_adjusting = False self._is_authorized = False self._layout_initialized = False @@ -859,30 +899,26 @@ def _normalize_share(value): with self._context_authorized(): super().__init__(**kwargs) - # Super labels. We don't rely on private matplotlib _suptitle attribute and - # _align_axis_labels supports arbitrary spanning labels for subplot groups. - # NOTE: Don't use 'anchor' rotation mode otherwise switching to horizontal - # left and right super labels causes overlap. Current method is fine. + # Super labels self._suptitle = self.text(0.5, 0.95, "", ha="center", va="bottom") - self._supxlabel_dict = {} # an axes: label mapping - self._supylabel_dict = {} # an axes: label mapping + self._supxlabel_dict = {} + self._supylabel_dict = {} self._suplabel_dict = {"left": {}, "right": {}, "bottom": {}, "top": {}} - self._share_label_groups = {"x": {}, "y": {}} # explicit label-sharing groups + self._share_label_groups = {"x": {}, "y": {}} self._subset_title_dict = {} self._suptitle_pad = rc["suptitle.pad"] - d = self._suplabel_props = {} # store the super label props + d = self._suplabel_props = {} d["left"] = {"va": "center", "ha": "right"} d["right"] = {"va": "center", "ha": "left"} d["bottom"] = {"va": "top", "ha": "center"} d["top"] = {"va": "bottom", "ha": "center"} - d = self._suplabel_pad = {} # store the super label padding + d = self._suplabel_pad = {} d["left"] = rc["leftlabel.pad"] d["right"] = rc["rightlabel.pad"] d["bottom"] = rc["bottomlabel.pad"] d["top"] = rc["toplabel.pad"] - # Format figure - # NOTE: This ignores user-input rc_mode. + # Apply initial formatting (ignores user-input rc_mode) self.format(rc_kw=rc_kw, rc_mode=1, skip_axes=True, **kw_format) @override From d1cd3d4f75ae7b081ef037094c16d9a6b64abef2 Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Thu, 16 Apr 2026 09:10:36 +1000 Subject: [PATCH 4/9] Feature/span cbar slot based (#688) Marked this PR as ready for a refactor down the line to make figure more behave as an interface rather than wearing too many hats(functions). --- ultraplot/axes/base.py | 69 +++++++-- ultraplot/axes/geo.py | 206 +++++++++++++++++--------- ultraplot/tests/test_colorbar.py | 244 +++++++++++++++++++++++++++++++ 3 files changed, 431 insertions(+), 88 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index ffa8644a2..29a962e14 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -2417,6 +2417,8 @@ def _reposition_subplot(self): """ Reposition the subplot axes. """ + # NOTE: The panel span override logic here will move to a layout + # composer in a future refactor. # WARNING: In later versions self.numRows, self.numCols, and self.figbox # are @property definitions that never go stale but in mpl < 3.4 they are # attributes that must be updated explicitly with update_params(). @@ -2460,9 +2462,9 @@ def _reposition_subplot(self): ) # Check if the panel has a span override (spans more columns/rows - # than its parent). When it does, use the SubplotSpec position for - # the "along" dimension so the span is respected. Otherwise use - # parent_bbox which correctly tracks aspect-ratio adjustments. + # than its parent). When it does, compute the visual extent from + # actual axes positions so the panel aligns with aspect-adjusted + # axes rather than raw grid slots. Otherwise use parent_bbox. parent_ss = self._panel_parent.get_subplotspec().get_topmost_subplotspec() p_row1, p_row2, p_col1, p_col2 = parent_ss._get_rows_columns( ncols=gs.ncols_total @@ -2470,9 +2472,30 @@ def _reposition_subplot(self): if side in ("right", "left"): has_span_override = (row1 < p_row1) or (row2 > p_row2) - along_bbox = ( - ss.get_position(self.figure) if has_span_override else parent_bbox - ) + if has_span_override: + # Compute visual extent from all axes in the span range + vmin, vmax = float("inf"), float("-inf") + for other in self.figure.axes: + if getattr(other, "_panel_side", None): + continue + oss = getattr(other, "get_subplotspec", lambda: None)() + if oss is None: + continue + oss = oss.get_topmost_subplotspec() + if oss.get_gridspec() is not gs: + continue + o_r1, o_r2, _, _ = oss._get_rows_columns(ncols=gs.ncols_total) + if o_r1 >= row1 and o_r2 <= row2: + opos = other.get_position() + vmin = min(vmin, opos.y0) + vmax = max(vmax, opos.y1) + if vmin < vmax: + along_y0, along_h = vmin, vmax - vmin + else: + slot = ss.get_position(self.figure) + along_y0, along_h = slot.y0, slot.height + else: + along_y0, along_h = parent_bbox.y0, parent_bbox.height boundary = None width = sum(gs._wratios_total[col1 : col2 + 1]) / figwidth if a_col2 < col1: @@ -2492,14 +2515,32 @@ def _reposition_subplot(self): x0 = anchor_bbox.x1 + pad else: x0 = anchor_bbox.x0 - pad - width - bbox = mtransforms.Bbox.from_bounds( - x0, along_bbox.y0, width, along_bbox.height - ) + bbox = mtransforms.Bbox.from_bounds(x0, along_y0, width, along_h) else: has_span_override = (col1 < p_col1) or (col2 > p_col2) - along_bbox = ( - ss.get_position(self.figure) if has_span_override else parent_bbox - ) + if has_span_override: + vmin, vmax = float("inf"), float("-inf") + for other in self.figure.axes: + if getattr(other, "_panel_side", None): + continue + oss = getattr(other, "get_subplotspec", lambda: None)() + if oss is None: + continue + oss = oss.get_topmost_subplotspec() + if oss.get_gridspec() is not gs: + continue + _, _, o_c1, o_c2 = oss._get_rows_columns(ncols=gs.ncols_total) + if o_c1 >= col1 and o_c2 <= col2: + opos = other.get_position() + vmin = min(vmin, opos.x0) + vmax = max(vmax, opos.x1) + if vmin < vmax: + along_x0, along_w = vmin, vmax - vmin + else: + slot = ss.get_position(self.figure) + along_x0, along_w = slot.x0, slot.width + else: + along_x0, along_w = parent_bbox.x0, parent_bbox.width boundary = None height = sum(gs._hratios_total[row1 : row2 + 1]) / figheight if a_row2 < row1: @@ -2518,9 +2559,7 @@ def _reposition_subplot(self): y0 = anchor_bbox.y1 + pad else: y0 = anchor_bbox.y0 - pad - height - bbox = mtransforms.Bbox.from_bounds( - along_bbox.x0, y0, along_bbox.width, height - ) + bbox = mtransforms.Bbox.from_bounds(along_x0, y0, along_w, height) setter(bbox) def _update_abc(self, **kwargs): diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 868f1b008..d364e1591 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -1352,12 +1352,116 @@ def _apply_aspect_and_adjust_panels(self, *, tol: float = 1e-9) -> None: self.apply_aspect() self._adjust_panel_positions(tol=tol) + def _compute_span_extent(self, side, panel, gs, p_r1, p_r2, p_c1, p_c2): + """ + If the panel spans beyond the parent's SubplotSpec, compute the visual + extent (min, max) along the span axis from all non-panel axes in range. + Returns None if not a span override or no valid extent found. + """ + # NOTE: This will move to a layout composer in a future refactor. + ss = getattr(panel, "get_subplotspec", lambda: None)() + if ss is None or p_c1 is None: + return None + + panel_ss = ss.get_topmost_subplotspec() + s_r1, s_r2, s_c1, s_c2 = panel_ss._get_rows_columns(ncols=gs.ncols_total) + + if side in ("bottom", "top"): + has_span_override = s_c1 < p_c1 or s_c2 > p_c2 + elif side in ("left", "right"): + has_span_override = s_r1 < p_r1 or s_r2 > p_r2 + else: + return None + + if not has_span_override: + return None + + vmin, vmax = float("inf"), float("-inf") + for other_ax in self.figure.axes: + if getattr(other_ax, "_panel_side", None): + continue + oss = getattr(other_ax, "get_subplotspec", lambda: None)() + if oss is None: + continue + oss = oss.get_topmost_subplotspec() + if oss.get_gridspec() is not gs: + continue + o_r1, o_r2, o_c1, o_c2 = oss._get_rows_columns(ncols=gs.ncols_total) + opos = other_ax.get_position() + if side in ("left", "right"): + if o_r1 >= s_r1 and o_r2 <= s_r2: + vmin = min(vmin, opos.y0) + vmax = max(vmax, opos.y1) + else: + if o_c1 >= s_c1 and o_c2 <= s_c2: + vmin = min(vmin, opos.x0) + vmax = max(vmax, opos.x1) + + return (vmin, vmax) if vmin < vmax else None + + @staticmethod + def _compute_adjusted_panel_pos( + side, panel_pos, span_extent, original_pos, main_pos, sx, sy, tol + ): + """ + Compute the new [x0, y0, width, height] for a panel on the given side, + accounting for aspect-adjusted main axes and optional span extent. + Returns the new position list, or None for unknown sides. + """ + # NOTE: This will move to a layout composer in a future refactor. + ox0, oy0 = original_pos.x0, original_pos.y0 + ox1, oy1 = original_pos.x1, original_pos.y1 + mx0, my0 = main_pos.x0, main_pos.y0 + px0, py0 = panel_pos.x0, panel_pos.y0 + px1, py1 = panel_pos.x1, panel_pos.y1 + + if side in ("left", "right"): + # Compute vertical extent + if span_extent is not None: + along_y0 = span_extent[0] + along_h = span_extent[1] - span_extent[0] + elif py0 <= oy0 + tol and py1 >= oy1 - tol: + along_y0, along_h = my0, main_pos.height + else: + along_y0 = my0 + (panel_pos.y0 - oy0) * sy + along_h = panel_pos.height * sy + + if side == "left": + gap = original_pos.x0 - (panel_pos.x0 + panel_pos.width) + new_x0 = main_pos.x0 - panel_pos.width - gap + else: + gap = panel_pos.x0 - (original_pos.x0 + original_pos.width) + new_x0 = main_pos.x0 + main_pos.width + gap + return [new_x0, along_y0, panel_pos.width, along_h] + + elif side in ("top", "bottom"): + # Compute horizontal extent + if span_extent is not None: + along_x0 = span_extent[0] + along_w = span_extent[1] - span_extent[0] + elif px0 <= ox0 + tol and px1 >= ox1 - tol: + along_x0, along_w = mx0, main_pos.width + else: + along_x0 = mx0 + (panel_pos.x0 - ox0) * sx + along_w = panel_pos.width * sx + + if side == "top": + gap = panel_pos.y0 - (original_pos.y0 + original_pos.height) + new_y0 = main_pos.y0 + main_pos.height + gap + else: + gap = original_pos.y0 - (panel_pos.y0 + panel_pos.height) + new_y0 = main_pos.y0 - panel_pos.height - gap + return [along_x0, new_y0, along_w, panel_pos.height] + + return None + def _adjust_panel_positions(self, *, tol: float = 1e-9) -> None: """ Adjust panel positions to align with the aspect-constrained main axes. After apply_aspect() shrinks the main axes, panels should flank the actual map boundaries rather than the full gridspec allocation. """ + # NOTE: This will move to a layout composer in a future refactor. if not getattr(self, "_panel_dict", None): return # no panels to adjust @@ -1366,11 +1470,8 @@ def _adjust_panel_positions(self, *, tol: float = 1e-9) -> None: # Subplot-spec position before apply_aspect(). This is the true "gridspec slot" # and remains well-defined even if we temporarily modify axes positions. - try: - ss = self.get_subplotspec() - original_pos = ss.get_position(self.figure) if ss is not None else None - except Exception: - original_pos = None + ss = getattr(self, "get_subplotspec", lambda: None)() + original_pos = ss.get_position(self.figure) if ss is not None else None if original_pos is None: original_pos = getattr( self, "_originalPosition", None @@ -1390,87 +1491,46 @@ def _adjust_panel_positions(self, *, tol: float = 1e-9) -> None: # panel, so span overrides across subplot rows/cols are preserved). sx = main_pos.width / original_pos.width if original_pos.width else 1.0 sy = main_pos.height / original_pos.height if original_pos.height else 1.0 - ox0, oy0 = original_pos.x0, original_pos.y0 - ox1, oy1 = ( - original_pos.x0 + original_pos.width, - original_pos.y0 + original_pos.height, - ) - mx0, my0 = main_pos.x0, main_pos.y0 + + # Detect span overrides by comparing SubplotSpec extents of parent vs panels + parent_ss = getattr(self, "get_subplotspec", lambda: None)() + if parent_ss is not None: + parent_ss = parent_ss.get_topmost_subplotspec() + gs = parent_ss.get_gridspec() + p_r1, p_r2, p_c1, p_c2 = parent_ss._get_rows_columns(ncols=gs.ncols_total) + else: + gs = None + p_r1 = p_r2 = p_c1 = p_c2 = None for side, panels in self._panel_dict.items(): for panel in panels: # Use the panel subplot-spec box as the baseline (not its current # original position) to avoid accumulated adjustments. - try: - ss = panel.get_subplotspec() - panel_pos = ( - ss.get_position(panel.figure) if ss is not None else None - ) - except Exception: - panel_pos = None + ss = getattr(panel, "get_subplotspec", lambda: None)() + panel_pos = ss.get_position(panel.figure) if ss is not None else None if panel_pos is None: panel_pos = panel.get_position(original=True) - px0, py0 = panel_pos.x0, panel_pos.y0 - px1, py1 = ( - panel_pos.x0 + panel_pos.width, - panel_pos.y0 + panel_pos.height, - ) - - # Use _set_position when available to avoid layoutbox side effects - # from public set_position() on newer matplotlib versions. - setter = getattr(panel, "_set_position", panel.set_position) - if side == "left": - # Calculate original gap between panel and main axes - gap = original_pos.x0 - (panel_pos.x0 + panel_pos.width) - # Position panel to the left of the adjusted main axes - new_x0 = main_pos.x0 - panel_pos.width - gap - if py0 <= oy0 + tol and py1 >= oy1 - tol: - new_y0, new_h = my0, main_pos.height - else: - new_y0 = my0 + (panel_pos.y0 - oy0) * sy - new_h = panel_pos.height * sy - new_pos = [new_x0, new_y0, panel_pos.width, new_h] - elif side == "right": - # Calculate original gap - gap = panel_pos.x0 - (original_pos.x0 + original_pos.width) - # Position panel to the right of the adjusted main axes - new_x0 = main_pos.x0 + main_pos.width + gap - if py0 <= oy0 + tol and py1 >= oy1 - tol: - new_y0, new_h = my0, main_pos.height - else: - new_y0 = my0 + (panel_pos.y0 - oy0) * sy - new_h = panel_pos.height * sy - new_pos = [new_x0, new_y0, panel_pos.width, new_h] - elif side == "top": - # Calculate original gap - gap = panel_pos.y0 - (original_pos.y0 + original_pos.height) - # Position panel above the adjusted main axes - new_y0 = main_pos.y0 + main_pos.height + gap - if px0 <= ox0 + tol and px1 >= ox1 - tol: - new_x0, new_w = mx0, main_pos.width - else: - new_x0 = mx0 + (panel_pos.x0 - ox0) * sx - new_w = panel_pos.width * sx - new_pos = [new_x0, new_y0, new_w, panel_pos.height] - elif side == "bottom": - # Calculate original gap - gap = original_pos.y0 - (panel_pos.y0 + panel_pos.height) - # Position panel below the adjusted main axes - new_y0 = main_pos.y0 - panel_pos.height - gap - if px0 <= ox0 + tol and px1 >= ox1 - tol: - new_x0, new_w = mx0, main_pos.width - else: - new_x0 = mx0 + (panel_pos.x0 - ox0) * sx - new_w = panel_pos.width * sx - new_pos = [new_x0, new_y0, new_w, panel_pos.height] - else: - # Unknown side, skip adjustment + span_extent = self._compute_span_extent( + side, panel, gs, p_r1, p_r2, p_c1, p_c2 + ) + new_pos = self._compute_adjusted_panel_pos( + side, + panel_pos, + span_extent, + original_pos, + main_pos, + sx, + sy, + tol, + ) + if new_pos is None: continue # Panels typically have aspect='auto', which causes matplotlib to # reset their *active* position to their *original* position inside # apply_aspect()/get_position(). Update both so the change persists. + setter = getattr(panel, "_set_position", panel.set_position) try: setter(new_pos, which="both") except TypeError: # older matplotlib diff --git a/ultraplot/tests/test_colorbar.py b/ultraplot/tests/test_colorbar.py index 683388910..af5866dd8 100644 --- a/ultraplot/tests/test_colorbar.py +++ b/ultraplot/tests/test_colorbar.py @@ -899,6 +899,250 @@ def test_colorbar_row_without_span(): assert cb is not None +def test_colorbar_span_bottom_non_rectilinear_geo_axes(rng): + """Spanning bottom colorbar should stay under row 1 and honor cols.""" + fig, axs = uplt.subplots(nrows=2, ncols=2, proj=["npstere", "npstere", None, None]) + data = rng.random((20, 20)) + cm = axs[0, 0].imshow(data) + + cb = fig.colorbar(cm, ax=axs[0, :], span=(1, 2), loc="bottom") + assert cb is not None + + fig.canvas.draw() + panel_pos = cb.ax.get_position() + row0_col0_pos = axs[0, 0].get_position() + row0_col1_pos = axs[0, 1].get_position() + row1_col0_pos = axs[1, 0].get_position() + + tol = 0.05 + assert abs(panel_pos.x0 - row0_col0_pos.x0) < tol + assert abs(panel_pos.x1 - row0_col1_pos.x1) < tol + assert panel_pos.width > row0_col0_pos.width * 1.5 + assert abs(panel_pos.y1 - row0_col0_pos.y0) < 0.08 + assert panel_pos.y0 > row1_col0_pos.y1 + + +def test_colorbar_span_bottom_mixed_projections(rng): + """Spanning bottom colorbar across mixed projections (npstere + cyl).""" + import cartopy.crs as ccrs + + fig, axs = uplt.subplots(nrows=2, ncols=2, proj=["npstere", "cyl", None, None]) + data = rng.random((100, 100)) + lon = np.linspace(-180, 180, 100) + lat = np.linspace(30, 90, 100) + Lon, Lat = np.meshgrid(lon, lat) + + cm = axs[0, 0].pcolormesh(Lon, Lat, data, transform=ccrs.PlateCarree()) + cb = fig.colorbar(cm, loc="b", ax=axs[0, :], span=(1, 2)) + assert cb is not None + + fig.canvas.draw() + panel_pos = cb.ax.get_position() + row0_col0_pos = axs[0, 0].get_position() + row0_col1_pos = axs[0, 1].get_position() + row1_col0_pos = axs[1, 0].get_position() + + tol = 0.05 + assert abs(panel_pos.x0 - row0_col0_pos.x0) < tol + assert abs(panel_pos.x1 - row0_col1_pos.x1) < tol + assert panel_pos.width > row0_col0_pos.width * 1.5 + assert panel_pos.y1 < row0_col0_pos.y0 + 0.08 + assert panel_pos.y0 > row1_col0_pos.y1 + + +def test_colorbar_span_mixed_geo_and_cartesian_right(rng): + """Right colorbar on mixed npstere+Cartesian grid aligns with axes extent.""" + import cartopy.crs as ccrs + + fig, axs = uplt.subplots(nrows=2, ncols=2, proj=["npstere", None, "cyl", "cyl"]) + data = rng.random((100, 100)) + lon = np.linspace(-180, 180, 100) + lat = np.linspace(30, 90, 100) + Lon, Lat = np.meshgrid(lon, lat) + + cm = axs[0, 0].pcolormesh(Lon, Lat, data, transform=ccrs.PlateCarree()) + fig.colorbar(cm, loc="b", ax=axs[0, :], span=(1, 2)) + cb_right = fig.colorbar(cm, loc="r", ax=axs[0], ref=axs[:, 1]) + + fig.canvas.draw() + + right_pos = cb_right.ax.get_position() + top_ax = axs[0, 1].get_position() + bot_ax = axs[1, 1].get_position() + tol = 0.05 + # Right colorbar should align with actual axes, not grid slots + assert abs(right_pos.y1 - top_ax.y1) < tol + assert abs(right_pos.y0 - bot_ax.y0) < tol + assert right_pos.x0 >= top_ax.x1 - tol + + +def test_colorbar_span_mixed_projections_bottom_and_right(rng): + """Bottom + right colorbars on mixed npstere/cyl grid align with axes.""" + import cartopy.crs as ccrs + + fig, axs = uplt.subplots( + nrows=2, ncols=2, proj=["npstere", "npstere", "cyl", "cyl"] + ) + data = rng.random((100, 100)) + lon = np.linspace(-180, 180, 100) + lat = np.linspace(30, 90, 100) + Lon, Lat = np.meshgrid(lon, lat) + + cm = axs[0, 0].pcolormesh(Lon, Lat, data, transform=ccrs.PlateCarree()) + cb_bot = fig.colorbar(cm, loc="b", ax=axs[0, :], span=(1, 2)) + cb_right = fig.colorbar(cm, loc="r", ax=axs[0], ref=axs[:, 1]) + + fig.canvas.draw() + + # Bottom colorbar should span both columns + bot_pos = cb_bot.ax.get_position() + assert bot_pos.width > axs[0, 0].get_position().width * 1.5 + + # Right colorbar should align with the visual extent of axs[:,1] + right_pos = cb_right.ax.get_position() + top_ax = axs[0, 1].get_position() + bot_ax = axs[1, 1].get_position() + tol = 0.05 + assert abs(right_pos.y1 - top_ax.y1) < tol + assert abs(right_pos.y0 - bot_ax.y0) < tol + assert right_pos.x0 >= top_ax.x1 - tol + + +def test_colorbar_span_right_non_rectilinear_geo_axes(rng): + """Right colorbar with row span on npstere should preserve span height.""" + fig, axs = uplt.subplots(nrows=2, ncols=2, proj="npstere") + cm = axs[0, 0].imshow(rng.random((20, 20))) + + cb = fig.colorbar(cm, loc="r", ax=axs[:, 1], rows=(1, 2)) + assert cb is not None + + fig.canvas.draw() + panel_pos = cb.ax.get_position() + row0_pos = axs[0, 1].get_position() + row1_pos = axs[1, 1].get_position() + + # Panel must span both rows vertically + assert panel_pos.y1 >= row0_pos.y1 - 0.05 + assert panel_pos.y0 <= row1_pos.y0 + 0.05 + assert panel_pos.height > row0_pos.height * 1.5 + # Panel must be to the right of column 1 + assert panel_pos.x0 >= row0_pos.x1 - 0.05 + + +def test_colorbar_span_left_non_rectilinear_geo_axes(rng): + """Left colorbar with row span on npstere should preserve span height.""" + fig, axs = uplt.subplots(nrows=2, ncols=2, proj="npstere") + cm = axs[0, 0].imshow(rng.random((20, 20))) + + cb = fig.colorbar(cm, loc="l", ax=axs[:, 0], rows=(1, 2)) + assert cb is not None + + fig.canvas.draw() + panel_pos = cb.ax.get_position() + row0_pos = axs[0, 0].get_position() + row1_pos = axs[1, 0].get_position() + + # Panel must span both rows vertically + assert panel_pos.y1 >= row0_pos.y1 - 0.05 + assert panel_pos.y0 <= row1_pos.y0 + 0.05 + assert panel_pos.height > row0_pos.height * 1.5 + # Panel must be to the left of column 0 + assert panel_pos.x1 <= row0_pos.x0 + 0.05 + + +def test_colorbar_span_top_non_rectilinear_geo_axes(rng): + """Top colorbar with col span on npstere should preserve span width.""" + fig, axs = uplt.subplots(nrows=2, ncols=2, proj="npstere") + cm = axs[0, 0].imshow(rng.random((20, 20))) + + cb = fig.colorbar(cm, loc="t", ax=axs[0, :], span=(1, 2)) + assert cb is not None + + fig.canvas.draw() + panel_pos = cb.ax.get_position() + col0_pos = axs[0, 0].get_position() + col1_pos = axs[0, 1].get_position() + + # Panel must span both columns + assert abs(panel_pos.x0 - col0_pos.x0) < 0.05 + assert abs(panel_pos.x1 - col1_pos.x1) < 0.05 + assert panel_pos.width > col0_pos.width * 1.5 + # Panel must be above row 0 + assert panel_pos.y0 >= col0_pos.y1 - 0.05 + + +def test_colorbar_no_span_override_geo_axes_bottom(rng): + """Bottom colorbar without span override clips to parent on npstere.""" + fig, axs = uplt.subplots(nrows=1, ncols=2, proj="npstere") + cm = axs[0].imshow(rng.random((20, 20))) + + # Single-axis colorbar, no span override + cb = fig.colorbar(cm, loc="b", ax=axs[0]) + assert cb is not None + + fig.canvas.draw() + panel_pos = cb.ax.get_position() + parent_pos = axs[0].get_position() + + # Panel should be below the parent and not wider than parent + assert panel_pos.y1 < parent_pos.y0 + 0.05 + assert panel_pos.x0 >= parent_pos.x0 - 0.05 + assert panel_pos.x1 <= parent_pos.x1 + 0.05 + + +def test_colorbar_no_span_override_geo_axes_right(rng): + """Right colorbar without span override clips to parent on npstere.""" + fig, axs = uplt.subplots(nrows=2, ncols=1, proj="npstere") + cm = axs[0].imshow(rng.random((20, 20))) + + # Single-axis colorbar, no span override + cb = fig.colorbar(cm, loc="r", ax=axs[0]) + assert cb is not None + + fig.canvas.draw() + panel_pos = cb.ax.get_position() + parent_pos = axs[0].get_position() + + # Panel should be to the right and not taller than parent + assert panel_pos.x0 >= parent_pos.x1 - 0.05 + assert panel_pos.y0 >= parent_pos.y0 - 0.05 + assert panel_pos.y1 <= parent_pos.y1 + 0.05 + + +def test_colorbar_no_span_override_geo_axes_left(rng): + """Left colorbar without span override clips to parent on npstere.""" + fig, axs = uplt.subplots(nrows=2, ncols=1, proj="npstere") + cm = axs[0].imshow(rng.random((20, 20))) + + cb = fig.colorbar(cm, loc="l", ax=axs[0]) + assert cb is not None + + fig.canvas.draw() + panel_pos = cb.ax.get_position() + parent_pos = axs[0].get_position() + + assert panel_pos.x1 <= parent_pos.x0 + 0.05 + assert panel_pos.y0 >= parent_pos.y0 - 0.05 + assert panel_pos.y1 <= parent_pos.y1 + 0.05 + + +def test_colorbar_no_span_override_geo_axes_top(rng): + """Top colorbar without span override clips to parent on npstere.""" + fig, axs = uplt.subplots(nrows=1, ncols=2, proj="npstere") + cm = axs[0].imshow(rng.random((20, 20))) + + cb = fig.colorbar(cm, loc="t", ax=axs[0]) + assert cb is not None + + fig.canvas.draw() + panel_pos = cb.ax.get_position() + parent_pos = axs[0].get_position() + + assert panel_pos.y0 >= parent_pos.y1 - 0.05 + assert panel_pos.x0 >= parent_pos.x0 - 0.05 + assert panel_pos.x1 <= parent_pos.x1 + 0.05 + + def test_colorbar_column_without_span(): """Test that colorbar on column without span spans entire column.""" fig, axs = uplt.subplots(nrows=3, ncols=2) From 1567aa35dc5669b67babe76a929452e1c6d85fdb Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Thu, 16 Apr 2026 16:25:02 +1000 Subject: [PATCH 5/9] Fix patheffects affecting recall of titleborder (#691) --- ultraplot/internals/labels.py | 4 +--- ultraplot/tests/test_axes.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/ultraplot/internals/labels.py b/ultraplot/internals/labels.py index c7af81452..9cb49d2ec 100644 --- a/ultraplot/internals/labels.py +++ b/ultraplot/internals/labels.py @@ -81,10 +81,8 @@ def _update_label(text, props=None, **kwargs): text.set_path_effects( [mpatheffects.Stroke(**kw), mpatheffects.Normal()], ) - # ISSUE: interfers with adding path effects when we border is False but we do apply path effects - elif border is False and not text.get_path_effects(): + elif border is False: text.set_path_effects(None) - # print(props.get("path_effects", [])) # Update bounding box # NOTE: We use '_title_pad' and '_title_above' for both titles and a-b-c diff --git a/ultraplot/tests/test_axes.py b/ultraplot/tests/test_axes.py index e19e81e80..2c432b515 100644 --- a/ultraplot/tests/test_axes.py +++ b/ultraplot/tests/test_axes.py @@ -277,6 +277,37 @@ def test_text_borderstyle_overrides_rc(): assert _get_text_stroke_joinstyle(txt) == "bevel" +def test_titleborder_false_clears_path_effects(): + """Ensure titleborder=False removes stroke effects on inner titles.""" + fig, axs = uplt.subplots() + ax = axs[0] + # Default rc has title.border=True, so inner title gets Stroke + ax.format(title="Test", titleloc="upper left") + t = ax._title_dict["upper left"] + assert t.get_path_effects(), "expected default border path effects" + # Now disable — path effects should be cleared + ax.format(titleborder=False) + assert not t.get_path_effects(), "titleborder=False did not clear path effects" + + +def test_titleborder_false_at_creation(): + """Ensure titleborder=False works when passed during subplot creation.""" + fig, ax = uplt.subplot(title="Test", titleloc="upper left", titleborder=False) + t = ax._title_dict["upper left"] + assert not t.get_path_effects() + + +def test_abcborder_false_clears_path_effects(): + """Ensure abcborder=False removes stroke effects on inner abc labels.""" + fig, axs = uplt.subplots() + ax = axs[0] + ax.format(abc="A", abcloc="upper left", abcborder=True) + t = ax._title_dict["abc"] + assert t.get_path_effects(), "expected border path effects after abcborder=True" + ax.format(abcborder=False) + assert not t.get_path_effects(), "abcborder=False did not clear path effects" + + def test_dualx_log_transform_is_finite(): """ Ensure dualx transforms remain finite on log axes. From 78a7f94f46ba9700e1aa768c47e15c71bc5e1c3c Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Fri, 17 Apr 2026 09:28:15 +1000 Subject: [PATCH 6/9] Fix issue where hidden panels promote to level 3 (#696) --- ultraplot/figure.py | 12 ++++++++-- ultraplot/tests/test_subplots.py | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 1c0e47c8f..334c61a44 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -1487,10 +1487,18 @@ def _effective_share_level(self, axi, axis: str, sides: tuple[str, str]) -> int: if getattr(axi, "_panel_side", None) and getattr(axi, f"_share{axis}", None): return 3 - # Adjacent panels on any relevant side + # Adjacent panels on any relevant side. Ignore hidden filled panels + # (e.g. those created for outer legends/colorbars via ``loc='r'``); + # they do not participate in axis sharing and must not promote the + # parent axes' tick-label sharing level (see issue #694). panel_dict = getattr(axi, "_panel_dict", {}) for side in sides: - side_panels = panel_dict.get(side) or [] + side_panels = [ + p + for p in (panel_dict.get(side) or []) + if not getattr(p, "_panel_hidden", False) + and getattr(p, "_panel_share", False) + ] if side_panels and getattr(side_panels[0], f"_share{axis}", False): return 3 diff --git a/ultraplot/tests/test_subplots.py b/ultraplot/tests/test_subplots.py index ee61616c4..0f75cb125 100644 --- a/ultraplot/tests/test_subplots.py +++ b/ultraplot/tests/test_subplots.py @@ -948,3 +948,41 @@ def test_grid_geo_and_cartesian(): assert axs[2] in outer_axes["right"] assert axs[3] in outer_axes["right"] return fig + + +@pytest.mark.parametrize("share_level", ["labs", "lims"]) +def test_outer_legend_keeps_ticklabels_with_label_sharing(share_level: str) -> None: + """ + Regression test for issue #694. With ``sharey`` set to a "label" or + "limits" level (i.e. < 3), tick labels must remain on every visible + subplot even after an outer legend (``loc='r'``) is added next to one + of them. Previously the legend's hidden filled panel promoted the + parent's effective share level to 3 and hid its left tick labels. + """ + fig, axs = uplt.subplots(ncols=3, sharey=share_level) + axs[-1].set_visible(False) + for ax in axs[:-1]: + ax.plot([0, 1], [0, 1], label="line") + axs[-2].legend(loc="r") + fig.canvas.draw() + + for ax in axs[:-1]: + assert ax.yaxis.get_tick_params()["labelleft"] is True + + +def test_outer_legend_preserves_share_true_ticklabel_hiding() -> None: + """ + Counterpart to ``test_outer_legend_keeps_ticklabels_with_label_sharing``: + when ``sharey=True`` (level 3) the inner subplots' tick labels must + still be hidden even after an outer legend is attached. Guards against + over-correcting the issue #694 fix. + """ + fig, axs = uplt.subplots(ncols=3, sharey=True) + for ax in axs: + ax.plot([0, 1], [0, 1], label="line") + axs[1].legend(loc="r") + fig.canvas.draw() + + assert axs[0].yaxis.get_tick_params()["labelleft"] is True + assert axs[1].yaxis.get_tick_params()["labelleft"] is False + assert axs[2].yaxis.get_tick_params()["labelleft"] is False From c748326176a83e9b3b1850e5ee50a8beee9b43e4 Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Fri, 17 Apr 2026 10:12:49 +1000 Subject: [PATCH 7/9] Fix/whats new page generation (#697) Replace m2r2 to a pure html renderer + wire in unittest to prevent this from happening again. --- docs/_scripts/fetch_releases.py | 191 +++++++++++--------- docs/_static/custom.css | 38 ++++ environment.yml | 1 + pyproject.toml | 2 +- ultraplot/tests/test_docs_fetch_releases.py | 172 ++++++++++++++++++ 5 files changed, 315 insertions(+), 89 deletions(-) create mode 100644 ultraplot/tests/test_docs_fetch_releases.py diff --git a/docs/_scripts/fetch_releases.py b/docs/_scripts/fetch_releases.py index cb8e78505..d71cff3a6 100644 --- a/docs/_scripts/fetch_releases.py +++ b/docs/_scripts/fetch_releases.py @@ -1,135 +1,150 @@ """ -Dynamically build what's new page based on github releases +Dynamically build the "What's new?" page from the GitHub releases feed. + +The release notes on GitHub are written in Markdown and frequently mix raw +HTML (``
`` blocks wrapping fenced code samples). The +previous implementation converted the body to RST via ``m2r2``, which left +the inner Markdown code fences inside ``.. raw:: html`` directives — Sphinx +then rendered them as literal text. This module instead converts each +release body to HTML (so fences become ``
`` elements) and emits
+a single ``.. raw:: html`` block per release wrapped in a styling hook
+``div.uplt-whats-new-release-body``.
 """
 
+from __future__ import annotations
+
 import re
 from pathlib import Path
+from typing import Iterable
 
+import markdown
 import requests
-from m2r2 import convert
 
 GITHUB_REPO = "ultraplot/ultraplot"
 OUTPUT_RST = Path("whats_new.rst")
+GITHUB_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases"
 
+# Markdown extensions: fenced code (for ```python blocks), tables, attribute
+# lists for class hooks, and md_in_html so block-level HTML such as
+# ``
`` correctly contains parsed Markdown children. +_MD_EXTENSIONS = ("fenced_code", "tables", "attr_list", "md_in_html") + +# Strip the trailing "by @user in PR_URL" attribution that GitHub auto-adds +# to release notes. Keep the PR link in parentheses so credit/traceability +# remains while removing the contributor handles from rendered output. +# GitHub author handles can include ``[bot]`` suffixes (``@dependabot[bot]``, +# ``@pre-commit-ci[bot]``); ``\w`` alone misses the brackets. +_PR_ATTRIBUTION = re.compile( + r" by @[\w.\-]+(?:\[bot\])? in (https://github\.com/[^\s]+)" +) + +# Match an ATX heading line, tolerating up-to-3 leading spaces. Authors +# occasionally indent whole sections by two spaces in the GitHub release +# editor (e.g. v2.0.1's "### Layout, Rendering, and Geo Improvements"), +# which python-markdown then parses as a paragraph rather than a heading. +# We capture the ``#`` run so we can both strip the indent and downgrade +# one level — the page already provides the H1 ("What's new?") and each +# release contributes a per-release RST H2, so body headings start at H2. +_ATX_HEADING = re.compile(r"^[ ]{0,3}(#{1,5})(?=\s)", flags=re.MULTILINE) + + +def _strip_pr_attribution(text: str) -> str: + return _PR_ATTRIBUTION.sub(r" (\1)", text) -GITHUB_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases" +def _downgrade_headings(text: str) -> str: + """Demote every Markdown ATX heading by one level (``#`` → ``##``, etc.).""" + return _ATX_HEADING.sub(lambda m: "#" + m.group(1), text) -def format_release_body(text): - """Formats GitHub release notes for better RST readability.""" - # Convert Markdown to RST using m2r2 - formatted_text = convert(text) - formatted_text = _downgrade_headings(formatted_text) - formatted_text = formatted_text.replace("→", "->") - formatted_text = re.sub(r"^\\s*`\\s*$", "", formatted_text, flags=re.MULTILINE) +def _normalize_unicode(text: str) -> str: + return text.replace("→", "->") - # Convert PR references (remove "by @user in ..." but keep the link) - formatted_text = re.sub( - r" by @\w+ in (https://github.com/[^\s]+)", r" (\1)", formatted_text - ) - return formatted_text.strip() +def _indent_html(html: str, indent: str = " ") -> str: + """Indent every line of ``html`` by ``indent`` for inclusion under ``.. raw:: html``.""" + return "\n".join(indent + line if line else line for line in html.splitlines()) -def _downgrade_headings(text): +def format_release_body(text: str) -> str: """ - Downgrade all heading levels by one to avoid H1/H2 collisions in the TOC. + Convert a GitHub release body (Markdown + embedded HTML) into an RST + ``.. raw:: html`` block wrapped in ``div.uplt-whats-new-release-body``. + + Parameters + ---------- + text : str + Raw Markdown release body as returned by the GitHub releases API. + + Returns + ------- + str + Indented RST snippet ready to be appended to ``whats_new.rst``. """ - adornment_map = { - "=": "-", - "-": "~", - "~": "^", - "^": '"', - '"': "'", - "'": "`", - } - lines = text.splitlines() - for idx in range(len(lines) - 1): - title = lines[idx] - underline = lines[idx + 1] - if not title.strip(): - continue - if not underline: - continue - char = underline[0] - if char not in adornment_map: - continue - if underline.strip(char): - continue - lines[idx + 1] = adornment_map[char] * len(underline) - return "\n".join(lines) - - -def fetch_all_releases(): - """Fetches all GitHub releases across multiple pages.""" - releases = [] - page = 1 + cleaned = _downgrade_headings( + _normalize_unicode(_strip_pr_attribution(text or "")) + ).strip() + html_body = markdown.markdown(cleaned, extensions=list(_MD_EXTENSIONS)) + wrapped = f'
\n{html_body}\n
' + return ".. raw:: html\n\n" + _indent_html(wrapped) + "\n" + +def _format_release_title(release: dict) -> str: + """ + Build the per-release section title in ``": "`` form, + de-duplicating the tag if it is already a prefix of the release name. + """ + tag = release["tag_name"].lower() + title = (release.get("name") or "").strip() + if title.lower().startswith(tag): + title = title[len(tag) :].lstrip(" :-—–") + return f"{tag}: {title}" if title else tag + + +def fetch_all_releases() -> list[dict]: + """Fetch every GitHub release across paginated responses.""" + releases: list[dict] = [] + page = 1 while True: response = requests.get(GITHUB_API_URL, params={"per_page": 30, "page": page}) if response.status_code != 200: print(f"Error fetching releases: {response.status_code}") break - page_data = response.json() - # If the page is empty, stop fetching if not page_data: break - releases.extend(page_data) page += 1 - return releases -def fetch_releases(): - """Fetches the latest releases from GitHub and formats them as RST.""" - releases = fetch_all_releases() - if not releases: - print(f"Error fetching releases!") - return "" - +def _render_releases(releases: Iterable[dict]) -> str: + """Render an iterable of release dicts to the full ``whats_new.rst`` body.""" header = "What's new?" - rst_content = f".. _whats_new:\n\n{header}\n{'=' * len(header)}\n\n" # H1 - + out = f".. _whats_new:\n\n{header}\n{'=' * len(header)}\n\n" for release in releases: - # ensure title is formatted as {tag}: {title} - tag = release["tag_name"].lower() - title = release["name"] - if title.startswith(tag): - title = title[len(tag) :] - while title: - if not title[0].isalpha(): - title = title[1:] - title = title.strip() - else: - title = title.strip() - break - - if title: - title = f"{tag}: {title}" - else: - title = tag - + title = _format_release_title(release) date = release["published_at"][:10] - body = format_release_body(release["body"] or "") - - # Version header (H2) - rst_content += f"{title} ({date})\n{'-' * (len(title) + len(date) + 3)}\n\n" + heading = f"{title} ({date})" + out += f"{heading}\n{'-' * len(heading)}\n\n" + out += format_release_body(release.get("body") or "") + "\n" + return out - # Process body content - rst_content += f"{body}\n\n" - return rst_content +def fetch_releases() -> str: + """Fetch the latest releases from GitHub and format them as RST.""" + releases = fetch_all_releases() + if not releases: + print("Error fetching releases!") + return "" + return _render_releases(releases) -def write_rst(): - """Writes fetched releases to an RST file.""" +def write_rst() -> None: + """Write fetched releases to ``whats_new.rst``.""" content = fetch_releases() if content: - with open(OUTPUT_RST, "w", encoding="utf-8") as f: - f.write(content) + OUTPUT_RST.write_text(content, encoding="utf-8") print(f"Updated {OUTPUT_RST}") else: print("No updates to write.") diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 8cbe3d9e8..4a5390e73 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -1212,3 +1212,41 @@ body.wy-body-for-nav border-radius: 0.2rem; background: var(--uplt-color-sidebar-bg); } + +/* What's-new release bodies generated by docs/_scripts/fetch_releases.py. + Each release is emitted inside a
+ so we have a single styling hook for spacing, code-block padding, and the +
snippet affordance. */ +.uplt-whats-new-release-body { + margin-top: 0.5rem; + margin-bottom: 1.5rem; +} + +.uplt-whats-new-release-body h2, +.uplt-whats-new-release-body h3, +.uplt-whats-new-release-body h4 { + margin-top: 1.25rem; + margin-bottom: 0.5rem; +} + +.uplt-whats-new-release-body details { + margin: 0.5rem 0 1rem; + padding: 0.25rem 0.75rem; + border: 1px solid var(--uplt-color-sidebar-bg, #e5e7eb); + border-radius: 0.35rem; + background: var(--uplt-color-sidebar-bg, #f9fafb); +} + +.uplt-whats-new-release-body details > summary { + cursor: pointer; + font-weight: 600; +} + +.uplt-whats-new-release-body pre { + overflow-x: auto; +} + +.uplt-whats-new-release-body img { + max-width: 100%; + height: auto; +} diff --git a/environment.yml b/environment.yml index 0207b556c..a0b16dab9 100644 --- a/environment.yml +++ b/environment.yml @@ -20,5 +20,6 @@ dependencies: - networkx - pyarrow - cftime + - markdown - pip: - pycirclize diff --git a/pyproject.toml b/pyproject.toml index 1f85fd3b6..cfb72d87e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ docs = [ "jupyter", "jupytext", "lxml-html-clean", - "m2r2", + "markdown", "mpltern", "nbsphinx", "sphinx", diff --git a/ultraplot/tests/test_docs_fetch_releases.py b/ultraplot/tests/test_docs_fetch_releases.py new file mode 100644 index 000000000..be4e8b923 --- /dev/null +++ b/ultraplot/tests/test_docs_fetch_releases.py @@ -0,0 +1,172 @@ +from html import unescape +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "docs" / "_scripts" / "fetch_releases.py" + + +def _load_module(): + spec = spec_from_file_location("uplt_fetch_releases", SCRIPT) + module = module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_format_release_body_renders_raw_html(): + module = _load_module() + body = """ +# UltraPlot v9.9.9 + +Highlights +---------- + +* Fix a regression by @cvanelteren in https://github.com/Ultraplot/UltraPlot/pull/123 +* Replace arrows → with ASCII + +```python +print("ok") +``` +""" + rendered = module.format_release_body(body) + + assert rendered.startswith(".. raw:: html") + assert '
' in rendered + assert "

UltraPlot v9.9.9

" in rendered + assert "by @cvanelteren in" not in rendered + assert "->" in unescape(rendered) + assert '' in rendered + + +def test_fetch_releases_formats_titles(monkeypatch): + module = _load_module() + monkeypatch.setattr( + module, + "fetch_all_releases", + lambda: [ + { + "tag_name": "v1.2.3", + "name": "v1.2.3: Release title", + "published_at": "2026-04-02T10:00:00Z", + "body": "Hello world", + } + ], + ) + + rendered = module.fetch_releases() + + assert ".. _whats_new:" in rendered + assert "v1.2.3: Release title (2026-04-02)" in rendered + assert ".. raw:: html" in rendered + + +def test_format_release_body_preserves_code_inside_details(): + """Regression test for the original issue — fenced code blocks nested inside + ``
`` must turn into proper ``
`` HTML, not literal
+    backticks. The previous m2r2 pipeline left them unrendered."""
+    module = _load_module()
+    body = (
+        "# v9.9.9\n\n"
+        "
snippet\n\n" + "```python\n" + "import ultraplot as uplt\n" + "fig, ax = uplt.subplots()\n" + "```\n\n" + "
\n" + ) + + rendered = module.format_release_body(body) + + assert "
snippet" in rendered + assert '
' in rendered
+    assert "import ultraplot as uplt" in rendered
+    # No literal Markdown fences should leak through into the output
+    assert "```python" not in rendered
+
+
+def test_format_release_body_indents_for_raw_html_directive():
+    """Every line of the wrapper must be indented by three spaces so the block
+    is parsed as the body of the ``.. raw:: html`` directive."""
+    module = _load_module()
+    rendered = module.format_release_body("# Heading\n\nBody")
+
+    lines = rendered.splitlines()
+    assert lines[0] == ".. raw:: html"
+    assert lines[1] == ""
+    # All subsequent non-empty lines must start with the 3-space indent
+    for line in lines[2:]:
+        if line:
+            assert line.startswith("   "), line
+
+
+def test_fetch_releases_handles_empty_body(monkeypatch):
+    """A release with an empty body must not crash and must still emit the
+    section heading."""
+    module = _load_module()
+    monkeypatch.setattr(
+        module,
+        "fetch_all_releases",
+        lambda: [
+            {
+                "tag_name": "v0.0.1",
+                "name": "v0.0.1",
+                "published_at": "2026-01-01T00:00:00Z",
+                "body": None,
+            }
+        ],
+    )
+
+    rendered = module.fetch_releases()
+
+    assert "v0.0.1 (2026-01-01)" in rendered
+    assert ".. raw:: html" in rendered
+
+
+def test_fetch_releases_returns_empty_string_when_api_returns_nothing(monkeypatch):
+    module = _load_module()
+    monkeypatch.setattr(module, "fetch_all_releases", lambda: [])
+    assert module.fetch_releases() == ""
+
+
+def test_format_release_body_recognises_indented_atx_headings():
+    """Some GitHub release bodies (e.g. v2.0.1) indent whole sections by two
+    spaces in the source Markdown. python-markdown won't parse ``  ### Foo``
+    as an ATX heading, so without normalisation those headings render as
+    paragraphs (literal ``###`` text). The script must strip up to three
+    leading spaces from heading lines before parsing."""
+    module = _load_module()
+    body = (
+        "  ### Layout, Rendering, and Geo Improvements\n\n"
+        "  - Bullet one\n"
+        "  - Bullet two\n"
+    )
+
+    rendered = module.format_release_body(body)
+
+    assert "

Layout, Rendering, and Geo Improvements

" in rendered + assert "### Layout" not in rendered + assert "

### " not in rendered + + +def test_format_release_body_strips_bot_attribution(): + """``@dependabot[bot]`` and ``@pre-commit-ci[bot]`` style handles must be + stripped along with regular ``@user`` ones; only the PR URL should + remain.""" + module = _load_module() + body = ( + "* Bump deps by @dependabot[bot] in " + "https://github.com/Ultraplot/UltraPlot/pull/671\n" + "* Autoupdate by @pre-commit-ci[bot] in " + "https://github.com/Ultraplot/UltraPlot/pull/674\n" + "* Real fix by @cvanelteren in " + "https://github.com/Ultraplot/UltraPlot/pull/696\n" + ) + + rendered = module.format_release_body(body) + + assert "@dependabot" not in rendered + assert "@pre-commit-ci" not in rendered + assert "@cvanelteren" not in rendered + assert "https://github.com/Ultraplot/UltraPlot/pull/671" in rendered + assert "https://github.com/Ultraplot/UltraPlot/pull/696" in rendered From b061e4581f5542ff828a1de84883dcd8c6512b59 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 17 Apr 2026 10:26:08 +1000 Subject: [PATCH 8/9] Add requests to conda env so the docs fetch_releases tests can import The new ultraplot/tests/test_docs_fetch_releases.py loads docs/_scripts/fetch_releases.py via importlib, which imports `requests` at module level. The conda env used in CI didn't include it, causing all eight tests to fail with ModuleNotFoundError. --- environment.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/environment.yml b/environment.yml index a0b16dab9..5ce6193e7 100644 --- a/environment.yml +++ b/environment.yml @@ -21,5 +21,6 @@ dependencies: - pyarrow - cftime - markdown + - requests - pip: - pycirclize From 8bf6ccb5a815f9d9f45ee96b2d2187d050789859 Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Mon, 20 Apr 2026 17:17:02 +1000 Subject: [PATCH 9/9] Fix/norm inputs (#693) * Implement fix for 689 --- ultraplot/axes/plot.py | 27 ++++++++++++-- ultraplot/tests/test_colorbar.py | 64 ++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index ae387927f..39e55a896 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -4120,9 +4120,30 @@ def _parse_cmap( # Parse keyword args cmap_kw = cmap_kw or {} norm_kw = norm_kw or {} - # If norm is given we use it to set vmin and vmax - if (vmin is not None or vmax is not None) and norm is not None: - raise ValueError("If 'norm' is given, 'vmin' and 'vmax' must not be set.") + # Tuple/list specs like ``('linear', 0, 1)`` pack positional args for + # ``constructor.Norm``. Build the Normalize now so downstream code can + # treat it uniformly with pre-constructed Normalize instances instead + # of risking a positional/kwarg collision when vmin/vmax are forwarded. + if ( + np.iterable(norm) + and not isinstance(norm, str) + and not isinstance(norm, mcolors.Normalize) + and len(norm) > 1 + ): + norm = constructor.Norm(norm, **norm_kw) + norm_kw = {} + # A ``Normalize`` instance already carries vmin/vmax, so combining it + # with explicit vmin/vmax is ambiguous. String / single-element list or + # tuple specs are just names for ``constructor.Norm`` and accept + # vmin/vmax as kwargs. + if (vmin is not None or vmax is not None) and isinstance( + norm, mcolors.Normalize + ): + raise ValueError( + "If 'norm' is a Normalize instance, 'vmin' and 'vmax' must not be " + "set. Pass them through the Normalize constructor, or specify " + "'norm' as a string / list / tuple to let vmin and vmax apply." + ) if isinstance(norm, mcolors.Normalize): vmin = norm.vmin vmax = norm.vmax diff --git a/ultraplot/tests/test_colorbar.py b/ultraplot/tests/test_colorbar.py index af5866dd8..2830ac1df 100644 --- a/ultraplot/tests/test_colorbar.py +++ b/ultraplot/tests/test_colorbar.py @@ -1226,3 +1226,67 @@ def test_colorbar_span_position_matches_target_rows(): ), f"Panel y0={panel_pos.y0:.3f} != row1 y0={row1_pos.y0:.3f}" # Sanity: panel must be taller than a single row assert panel_pos.height > row0_pos.height * 1.5 + + +@pytest.mark.parametrize( + "norm", + ["linear", ["linear"], ("linear",)], +) +def test_colorbar_norm_str_with_limits(norm): + """ + Should allow to pass vmin or vmax when we are passing a norm specification + as a string, list, or tuple (per the ``constructor.Norm`` contract). + """ + data = np.random.rand(10, 10) + fig, ax = uplt.subplots() + cm = ax.pcolormesh(data, vmin=0.1, norm=norm, vmax=1) + assert cm.norm.vmin == pytest.approx(0.1) + assert cm.norm.vmax == pytest.approx(1) + + +@pytest.mark.parametrize( + "norm", [("linear", 0.1, 1), ["linear", 0.1, 1], ("linear", 0.1, 1, False)] +) +def test_colorbar_norm_tuple_positional_limits(norm): + """ + Tuple / list form ``(name, vmin, vmax)`` should construct the normalizer + with the positional arguments and not collide with implicit vmin/vmax + kwargs when the user does not separately specify them. + """ + data = np.random.rand(10, 10) + fig, ax = uplt.subplots() + cm = ax.pcolormesh(data, norm=norm) + assert cm.norm.vmin == pytest.approx(0.1) + assert cm.norm.vmax == pytest.approx(1) + + +@pytest.mark.parametrize("norm", [uplt.DiscreteNorm, uplt.colors.mcolors.Normalize]) +def test_normalize_types(norm): + data = np.random.rand(10, 10) + target = norm + + if norm is uplt.DiscreteNorm: + norm = uplt.DiscreteNorm(levels=[0, 1]) + discrete = True + elif norm is uplt.colors.mcolors.Normalize: + norm = uplt.colors.mcolors.Normalize(vmin=0, vmax=1) + discrete = False + else: + raise ValueError("Norm not understood.") + fig, ax = uplt.subplots() + cm = ax.pcolormesh(data, norm=norm, discrete=discrete) + assert isinstance(cm.norm, target) + + +def test_colorbar_norm_with_limits(): + """ " + Should allow to pass vmin or vmax when we are passing a str formatter + """ + data = np.random.rand(10, 10) + fig = None + with pytest.raises(ValueError): + fig, ax = uplt.subplots() + ax.pcolormesh( + data, vmin=0, norm=uplt.colors.mcolors.Normalize(vmin=0, vmax=1), vmax=1 + ) + return fig