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/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 " 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/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 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) 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