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/3] 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/3] 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/3] 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