From df3adae150d8abb951b73653d10728039dad91c5 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 8 Sep 2026 13:48:30 +1000 Subject: [PATCH 1/6] Bump mpl to 3.11 --- noxfile.py | 4 +++ pyproject.toml | 7 +++-- requirements-minimal.txt | 2 +- ultraplot/_interaction.py | 5 ++- ultraplot/_layout.py | 13 ++++++-- ultraplot/axes/base.py | 6 +++- ultraplot/axes/container.py | 51 ++++++++++++++++++++++++++++++- ultraplot/axes/geo.py | 34 +++++++++++++++++++++ ultraplot/config.py | 9 ++++-- ultraplot/figure.py | 6 ++++ ultraplot/tests/test_animation.py | 10 ++++-- ultraplot/tests/test_gridspec.py | 5 ++- ultraplot/tests/test_textalign.py | 5 +-- ultraplot/ticker.py | 7 ++++- 14 files changed, 146 insertions(+), 18 deletions(-) diff --git a/noxfile.py b/noxfile.py index 4eadfa8ea..2c3adcd56 100644 --- a/noxfile.py +++ b/noxfile.py @@ -71,6 +71,10 @@ def _mamba_env_name(python_version: str, matplotlib_version: str) -> str: def _ensure_mamba_env( session: nox.Session, python_version: str, matplotlib_version: str ) -> str: + if tuple(map(int, matplotlib_version.split("."))) >= (3, 11) and tuple( + map(int, python_version.split(".")) + ) < (3, 11): + session.skip("Matplotlib 3.11 requires Python 3.11 or newer.") root = _mamba_root() env_name = _mamba_env_name(python_version, matplotlib_version) env_path = root / "envs" / env_name diff --git a/pyproject.toml b/pyproject.toml index 2b260fabb..67f7e09f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ description = "A succinct matplotlib wrapper for making beautiful, publication-q readme = "README.rst" requires-python = ">=3.10,<3.15" license = "MIT" -license-files = ["LICENSE"] +license-files = ["LICENSE.txt"] authors = [ { name = "Casper van Elteren", email = "caspervanelteren@gmail.com" }, { name = "Luke Davis", email = "lukelbd@gmail.com" }, @@ -25,7 +25,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "matplotlib>=3.9,<3.11", + "matplotlib>=3.9,<3.12", "numpy>=1.26.0", "typing-extensions; python_version < '3.12'", ] @@ -49,6 +49,7 @@ docs = [ "lxml-html-clean", "markdown", "mpltern", + "pycirclize>=1.10.1", # Floors below are the versions that work with Sphinx 9, which removed # sphinx.ext.autosummary.get_documenter. Without them pip is free to pair a # current Sphinx with an extension that cannot import it, and the build @@ -100,4 +101,4 @@ version_file_template = "__version__ = '{version}'\n" [tool.ultraplot.core_versions] python = ["3.10", "3.11", "3.12", "3.13", "3.14"] -matplotlib = ["3.9", "3.10"] +matplotlib = ["3.9", "3.10", "3.11"] diff --git a/requirements-minimal.txt b/requirements-minimal.txt index c4fa01680..d2fbfd369 100644 --- a/requirements-minimal.txt +++ b/requirements-minimal.txt @@ -1,3 +1,3 @@ numpy>=1.26.0 -matplotlib>=3.9,<3.11 +matplotlib>=3.9,<3.12 typing-extensions; python_version < "3.12" diff --git a/ultraplot/_interaction.py b/ultraplot/_interaction.py index 764ac35a0..0f7a67ce3 100644 --- a/ultraplot/_interaction.py +++ b/ultraplot/_interaction.py @@ -203,7 +203,10 @@ class _SurfaceProxyRecipe: def _surface_geometry_signature(surface): - vector = getattr(surface, "_vec", None) + # Matplotlib 3.11 stores polygons in _faces instead of the flattened _vec. + vector = getattr(surface, "_faces", None) + if vector is None: + vector = getattr(surface, "_vec", None) return (id(vector), getattr(vector, "shape", None)) diff --git a/ultraplot/_layout.py b/ultraplot/_layout.py index 7cdb2a55e..217ffefbc 100644 --- a/ultraplot/_layout.py +++ b/ultraplot/_layout.py @@ -38,6 +38,13 @@ def _interval_key(values): return tuple(np.asarray(values).reshape(-1).tolist()) +def _formatter_locs(formatter): + """Read cached locations without the deprecated 3.11 public alias.""" + if hasattr(formatter, "_locs"): + return formatter._locs + return getattr(formatter, "locs", ()) + + @dataclass(frozen=True) class _AxisTickState: """State that can affect ``Axis._update_ticks`` within one canvas draw.""" @@ -186,7 +193,7 @@ def _get_state(self, axis): @staticmethod def _copy_formatter_locs(formatter): - locs = getattr(formatter, "locs", ()) + locs = _formatter_locs(formatter) try: return np.array(locs, copy=True) except Exception: @@ -334,8 +341,8 @@ def _get_state(self, axes, include_subset_titles=True): id(axis.minor.formatter), id(converter), id(units), - _interval_key(getattr(axis.major.formatter, "locs", ())), - _interval_key(getattr(axis.minor.formatter, "locs", ())), + _interval_key(_formatter_locs(axis.major.formatter)), + _interval_key(_formatter_locs(axis.minor.formatter)), ) ) return _AxesExtentState( diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 6ff1a2c7c..e6f174c9f 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -2976,7 +2976,7 @@ def _update_title_position(self, renderer): # Adjust the above-axes positions with builtin algorithm # WARNING: Make sure the name of this private function doesn't change - super()._update_title_position(renderer) + self._update_native_title_position(renderer) # Sync the title position with the a-b-c label position aobj = self._title_dict["abc"] @@ -3412,6 +3412,10 @@ def draw(self, renderer=None, *args, **kwargs): # Re-draw synchronously so the current render pass sees reflowed bounds. super().draw(renderer, *args, **kwargs) + def _update_native_title_position(self, renderer): + """Apply the native axes title positioning algorithm.""" + super()._update_title_position(renderer) + def get_tightbbox(self, renderer, *args, **kwargs): # Perform extra post-processing steps # NOTE: This should be updated alongside draw(). We also cache the resulting diff --git a/ultraplot/axes/container.py b/ultraplot/axes/container.py index 4a806e041..844b7a6e0 100644 --- a/ultraplot/axes/container.py +++ b/ultraplot/axes/container.py @@ -7,12 +7,15 @@ figure system while maintaining their native functionality. """ +from types import MethodType + import matplotlib.axes as maxes import matplotlib.transforms as mtransforms +import numpy as np from matplotlib import cbook, container from ..config import rc -from ..internals import _pop_rc, warnings +from ..internals import _pop_rc, _version_mpl, warnings from . import shared from .cartesian import CartesianAxes @@ -21,6 +24,39 @@ _ABOVE_AXES_TITLE_LOCS = {"left", "center", "right"} +def _ternary_ticklabel_points(axis, renderer): + """Adapt mpltern's tick-label bounds to Matplotlib 3.11 text layouts. + + Bound only to the container's axes; do not patch mpltern globally. + """ + axes = axis.axes + points = [] + for sibling in (axes.taxis, axes.laxis, axes.raxis): + for tick in sibling._update_ticks(): + for label in (tick.label1, tick.label2): + if not label.get_visible(): + continue + position = label.get_transform().transform(label.get_position()) + if not label.get_text(): + points.append(position) + continue + # This is the same text-aligned box used by Text's bbox patch. + _, _, (corner, (width, height)) = label._get_layout(renderer) + transform = ( + mtransforms.Affine2D() + .rotate_deg(label.get_rotation()) + .translate(*(position + corner)) + ) + points.extend( + transform.transform( + [(0, 0), (width, 0), (width, height), (0, height)] + ) + ) + vertices = axes._get_hexagonal_vertices() + points.extend(axes._ternary2display_transform.transform(vertices)) + return np.asarray(points) + + class ExternalAxesContainer(CartesianAxes): """ Container axes that wraps an external axes instance. @@ -278,6 +314,19 @@ def _create_external_axes(self): **external_kwargs, ) + if ( + _version_mpl >= "3.11" + and self._external_axes_class.__module__.startswith("mpltern") + ): + for axis in ( + self._external_axes.taxis, + self._external_axes.laxis, + self._external_axes.raxis, + ): + axis._get_points_surrounding_hexagon = MethodType( + _ternary_ticklabel_points, axis + ) + # Note: Most axes classes automatically register themselves with the figure # during __init__. We need to REMOVE them from fig.axes so that ultraplot # doesn't try to call ultraplot-specific methods on them. diff --git a/ultraplot/axes/geo.py b/ultraplot/axes/geo.py index 10feee232..f0df1f213 100644 --- a/ultraplot/axes/geo.py +++ b/ultraplot/axes/geo.py @@ -42,6 +42,7 @@ _pop_props, _pop_rc, _version_cartopy, + _version_mpl, docstring, ic, # noqa: F401 labels, @@ -4026,6 +4027,39 @@ def draw(self, renderer: Any = None, *args: Any, **kwargs: Any) -> None: super().draw(renderer, *args, **kwargs) self._adjust_panel_positions(tol=self._PANEL_TOL) + def _update_native_title_position(self, renderer: Any) -> None: + """Place titles above visible grid labels, ignoring empty bboxes.""" + if _version_mpl < "3.11": + return super()._update_native_title_position(renderer) + maxes.Axes._update_title_position(self, renderer) + if self._autotitlepos is not None and not self._autotitlepos: + return + top = -np.inf + gridliners = ( + [a for a in self.artists if isinstance(a, cgridliner.Gridliner)] + if _version_cartopy >= "0.23" + else self._gridliners + ) + for gl in gridliners: + if not (gl.top_labels or gl.geo_labels): + continue + gl._draw_gridliner(renderer=renderer) + for label in gl.top_label_artists + gl.geo_label_artists: + # Matplotlib 3.11 returns Bbox.null() for invisible text. + # Its ymax is infinite, so it must not move the titles. + if not label.get_visible(): + continue + bbox = label.get_tightbbox(renderer) + if bbox is not None and np.isfinite(bbox.extents).all(): + top = max(top, bbox.ymax) + if not np.isfinite(top): + return + y = self.transAxes.inverted().transform((0, top))[1] + if y > 1: + for title in (self.title, self._left_title, self._right_title): + x, current_y = title.get_position() + title.set_position((x, max(current_y, y))) + def get_tightbbox(self, renderer: Any, *args: Any, **kwargs: Any) -> Any: # Perform extra post-processing steps # For now this just draws the gridliners diff --git a/ultraplot/config.py b/ultraplot/config.py index af66ed6f9..c75f620de 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -25,7 +25,7 @@ import matplotlib.colors as mcolors import matplotlib.font_manager as mfonts import matplotlib.mathtext # noqa: F401 -import matplotlib.style.core as mstyle +import matplotlib.style as mstyle import numpy as np from matplotlib import RcParams @@ -59,6 +59,11 @@ ] # Constants +if hasattr(mstyle, "_STYLE_BLACKLIST"): # Matplotlib >= 3.11 + _STYLE_BLACKLIST = mstyle._STYLE_BLACKLIST +else: + from matplotlib.style.core import STYLE_BLACKLIST as _STYLE_BLACKLIST + COLORS_KEEP = ("red", "green", "blue", "cyan", "yellow", "magenta", "white", "black") _ULTRAPLOT_STYLES = { @@ -293,7 +298,7 @@ def _filter_style_dict(rcdict, warn=True): # you import ultraplot in jupyter notebooks. So apply retroactively. rcdict_filtered = {} for key in rcdict: - if key in mstyle.STYLE_BLACKLIST: + if key in _STYLE_BLACKLIST: if warn: warnings._warn_ultraplot( f"Dictionary includes a parameter, {key!r}, that is not related " diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 1a30c6762..fec4fabea 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -3126,6 +3126,8 @@ def _update_subset_title( align = self._normalize_title_alignment(loc) if group is None: + # Use axes-title defaults rather than Figure.text's font.size. + color = rc["title.color"] artist = self.text( 0.5, 0.0, @@ -3134,6 +3136,10 @@ def _update_subset_title( ha=align, va="baseline", zorder=3.5, + fontsize=rc["title.size"], + fontweight=rc["title.weight"], + fontfamily=rc["font.family"], + color=rc["text.color"] if color == "auto" else color, ) group = {"axes": axes, "artist": artist, "pad": None, "y": None} self._subset_title_dict[key] = group diff --git a/ultraplot/tests/test_animation.py b/ultraplot/tests/test_animation.py index 92e6596d1..09191f6d4 100644 --- a/ultraplot/tests/test_animation.py +++ b/ultraplot/tests/test_animation.py @@ -8,7 +8,12 @@ from matplotlib import ticker as mticker from matplotlib import transforms as mtransforms from matplotlib.animation import FuncAnimation -from matplotlib.backend_bases import FigureCanvasBase, MouseEvent, TimerBase +from matplotlib.backend_bases import ( + FigureCanvasBase, + MouseEvent, + NavigationToolbar2, + TimerBase, +) from PIL import Image import ultraplot as uplt @@ -1156,7 +1161,8 @@ def test_navigation_preview_tracks_mouse_press_and_release(projection): values = np.linspace(0, 10, 5_000) if projection is None: line = ax.plot(values, np.sin(values))[0] - ax.set_navigate_mode("PAN") + toolbar = NavigationToolbar2(fig.canvas) + toolbar.pan() original_size = len(line.get_xdata()) else: line = ax.plot(np.cos(values), np.sin(values), values)[0] diff --git a/ultraplot/tests/test_gridspec.py b/ultraplot/tests/test_gridspec.py index ec8b1e9fc..1c7c7a03d 100644 --- a/ultraplot/tests/test_gridspec.py +++ b/ultraplot/tests/test_gridspec.py @@ -223,7 +223,9 @@ def test_subplotgrid_format_title_accepts_standard_title_locations(loc, ha): def test_subplotgrid_format_title_matches_axes_title_top_gap(): fig, axs = uplt.subplots(ncols=3) - axs[0].format(title="Single") + # Compare identical glyphs: baseline-aligned titles can have different + # bounding-box descents with Matplotlib 3.11's font metrics. + axs[0].format(title="Shared") subset = axs[1:] subset.format(title="Shared") fig.canvas.draw() @@ -231,6 +233,7 @@ def test_subplotgrid_format_title_matches_axes_title_top_gap(): renderer = fig._get_renderer() single = axs[0]._title_dict["center"] shared = next(iter(fig._subset_title_dict.values()))["artist"] + assert single.get_fontsize() == shared.get_fontsize() single_top = fig.transFigure.transform((0, axs[0].get_position().y1))[1] shared_top = fig.transFigure.transform((0, axs[1].get_position().y1))[1] single_gap = single.get_window_extent(renderer).y0 - single_top diff --git a/ultraplot/tests/test_textalign.py b/ultraplot/tests/test_textalign.py index 132ca9118..40a231aee 100644 --- a/ultraplot/tests/test_textalign.py +++ b/ultraplot/tests/test_textalign.py @@ -443,11 +443,12 @@ def test_connector_points_at_the_annotated_point(crowded): fontsize=7, avoid_overlap=True, ) - ax.auto_align_text(arrows=True, min_arrow_dist=0) + # Disable endpoint shortening when testing the exact data anchor. + ax.auto_align_text(arrows=dict(shrinkB=0), min_arrow_dist=0) fig.canvas.draw() assert ax._align_arrows targets = {tuple(np.round(p.get_path().vertices[-1], 6)) for p in ax._align_arrows} - assert targets & {tuple(np.round(xy, 6)) for xy in zip(x, y)} + assert targets <= {tuple(np.round(xy, 6)) for xy in zip(x, y)} def test_min_arrow_dist_suppresses_short_connectors(crowded): diff --git a/ultraplot/ticker.py b/ultraplot/ticker.py index 7e98c0491..a64830786 100644 --- a/ultraplot/ticker.py +++ b/ultraplot/ticker.py @@ -482,7 +482,12 @@ def _fix_small_number(self, x, string, precision_offset=2): # Format with precision below floating point error x -= getattr(self, "offset", 0) # guard against API change - x /= 10 ** getattr(self, "orderOfMagnitude", 0) # guard against API change + order = ( + self._orderOfMagnitude + if hasattr(self, "_orderOfMagnitude") + else getattr(self, "orderOfMagnitude", 0) + ) + x /= 10**order precision_true = max(0, self._decimal_place(x)) precision_max = max(0, np.finfo(type(x)).precision - precision_offset) precision = min(precision_true, precision_max) From a4d8ae014e8da95e7cb74ff4fb1515c99768ef7e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 8 Sep 2026 13:53:40 +1000 Subject: [PATCH 2/6] final touchesg --- ultraplot/axes/container.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ultraplot/axes/container.py b/ultraplot/axes/container.py index 844b7a6e0..22cd87496 100644 --- a/ultraplot/axes/container.py +++ b/ultraplot/axes/container.py @@ -316,7 +316,17 @@ def _create_external_axes(self): if ( _version_mpl >= "3.11" - and self._external_axes_class.__module__.startswith("mpltern") + and any( + base.__module__.startswith("mpltern.") + for base in self._external_axes_class.__mro__ + ) + and all( + hasattr( + getattr(self._external_axes, name, None), + "_get_points_surrounding_hexagon", + ) + for name in ("taxis", "laxis", "raxis") + ) ): for axis in ( self._external_axes.taxis, From 322643efa238b603443e37ee80909647fd06aac0 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 8 Sep 2026 14:32:17 +1000 Subject: [PATCH 3/6] Fix Matplotlib 3.11 test environments and coverage --- .github/workflows/build-ultraplot.yml | 24 +++++++++++- .github/workflows/main.yml | 36 ++++++++++++++---- environment.yml | 2 + noxfile.py | 31 +++++++++------ tools/ci/version_support.py | 26 +++++++++++++ ultraplot/tests/conftest.py | 6 +++ ultraplot/tests/test_core_versions.py | 55 +++++++++++++++++++++++++++ ultraplot/tests/test_geographic.py | 28 +++++++++----- 8 files changed, 178 insertions(+), 30 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index 96564db46..db4e181d4 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -53,9 +53,15 @@ jobs: with: fetch-depth: 0 + - name: Prepare compatible test environment + run: >- + python3 tools/ci/version_support.py + --matplotlib-version '${{ inputs.matplotlib-version }}' + --environment-output '${{ runner.temp }}/ultraplot-environment.yml' + - uses: mamba-org/setup-micromamba@v3.2.1 with: - environment-file: ./environment.yml + environment-file: ${{ runner.temp }}/ultraplot-environment.yml init-shell: bash condarc-file: ./.github/micromamba-condarc.yml post-cleanup: none @@ -69,6 +75,14 @@ jobs: - name: Build Ultraplot run: | pip install --no-build-isolation --no-deps . + python - <<'PY' + import sys + import matplotlib, mpltern, pycirclize + assert sys.version_info[:2] == tuple(map(int, '${{ inputs.python-version }}'.split('.'))) + assert matplotlib.__version__.startswith('${{ inputs.matplotlib-version }}.') + print('Python executable:', sys.executable) + print('Matplotlib:', matplotlib.__version__) + PY compare-baseline: name: Compare baseline Python ${{ inputs.python-version }} with MPL ${{ inputs.matplotlib-version }} @@ -100,9 +114,15 @@ jobs: - uses: actions/checkout@v7 + - name: Prepare compatible test environment + run: >- + python3 tools/ci/version_support.py + --matplotlib-version '${{ inputs.matplotlib-version }}' + --environment-output '${{ runner.temp }}/ultraplot-environment.yml' + - uses: mamba-org/setup-micromamba@v3.2.1 with: - environment-file: ./environment.yml + environment-file: ${{ runner.temp }}/ultraplot-environment.yml init-shell: bash condarc-file: ./.github/micromamba-condarc.yml post-cleanup: none diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1a2fcadb2..173ed9fd8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -139,11 +139,16 @@ jobs: python tools/ci/version_support.py --format github-output >> $GITHUB_OUTPUT coverage: - name: Coverage + name: Coverage Python ${{ matrix.python-version }} / MPL ${{ matrix.matplotlib-version }} runs-on: ubuntu-latest needs: - run-if-changes - if: always() && needs.run-if-changes.outputs.run == 'true' && github.event_name == 'pull_request' + - get-versions + if: always() && needs.run-if-changes.outputs.run == 'true' && needs.get-versions.result == 'success' && github.event_name == 'pull_request' + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.get-versions.outputs.test-matrix) }} defaults: run: shell: bash -el {0} @@ -157,16 +162,22 @@ jobs: with: fetch-depth: 0 + - name: Prepare compatible coverage environment + run: >- + python3 tools/ci/version_support.py + --matplotlib-version '${{ matrix.matplotlib-version }}' + --environment-output '${{ runner.temp }}/ultraplot-environment.yml' + - uses: mamba-org/setup-micromamba@v3.2.1 with: - environment-file: ./environment.yml + environment-file: ${{ runner.temp }}/ultraplot-environment.yml init-shell: bash condarc-file: ./.github/micromamba-condarc.yml post-cleanup: none create-args: >- --verbose - python=3.10 - matplotlib=3.9 + python=${{ matrix.python-version }} + matplotlib=${{ matrix.matplotlib-version }} cache-environment: true cache-downloads: false @@ -176,6 +187,16 @@ jobs: - name: Run full coverage suite run: | + python - <<'PY' + import sys + from importlib.metadata import version + import matplotlib, mpltern, pycirclize + assert sys.version_info[:2] == tuple(map(int, '${{ matrix.python-version }}'.split('.'))) + assert matplotlib.__version__.startswith('${{ matrix.matplotlib-version }}.') + print('Python executable:', sys.executable) + for package in ('matplotlib', 'mpltern', 'pycirclize', 'cartopy'): + print(f'{package}: {version(package)}') + PY pytest -q --tb=short --disable-warnings -n auto -p pytest_cov \ --cov=ultraplot --cov-branch --cov-context=test \ --cov-report=xml:coverage.xml --cov-report= \ @@ -186,7 +207,7 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage.xml - name: codecov-pr-python3.10-mpl3.9 + name: codecov-pr-python${{ matrix.python-version }}-mpl${{ matrix.matplotlib-version }} build: needs: @@ -212,6 +233,7 @@ jobs: build-success: needs: - build + - coverage - run-if-changes if: always() runs-on: ubuntu-latest @@ -220,7 +242,7 @@ jobs: if [[ '${{ needs.run-if-changes.outputs.run }}' == 'false' ]]; then echo "No changes detected, tests skipped." else - if [[ '${{ needs.build.result }}' == 'success' ]]; then + if [[ '${{ needs.build.result }}' == 'success' && ( '${{ needs.coverage.result }}' == 'success' || '${{ needs.coverage.result }}' == 'skipped' ) ]]; then echo "All tests passed successfully!" else echo "Tests failed!" diff --git a/environment.yml b/environment.yml index 11aa5d230..1377b4f28 100644 --- a/environment.yml +++ b/environment.yml @@ -6,6 +6,7 @@ dependencies: - numpy - matplotlib>=3.9 - ffmpeg # animation encoder for ultraplot.animation + # CI/Nox omit Basemap for Matplotlib >=3.11; retain legacy/docs coverage here. - basemap >=1.4.1 - cartopy - xarray @@ -24,4 +25,5 @@ dependencies: - markdown - requests - pip: + - mpltern - pycirclize diff --git a/noxfile.py b/noxfile.py index 2c3adcd56..95f39c3f8 100644 --- a/noxfile.py +++ b/noxfile.py @@ -83,19 +83,26 @@ def _ensure_mamba_env( exe = _mamba_exe(session) env = os.environ.copy() env["MAMBA_ROOT_PREFIX"] = str(root) - session.run( - exe, - "create", - "-y", - "-n", - env_name, - "-f", - str(PROJECT_ROOT / "environment.yml"), - f"python={python_version}", - f"matplotlib={matplotlib_version}", - external=True, - env=env, + environment = _load_version_support().environment_for_matplotlib( + (PROJECT_ROOT / "environment.yml").read_text(encoding="utf-8"), + matplotlib_version, ) + with tempfile.TemporaryDirectory() as tmpdir: + environment_path = Path(tmpdir) / "environment.yml" + environment_path.write_text(environment, encoding="utf-8") + session.run( + exe, + "create", + "-y", + "-n", + env_name, + "-f", + str(environment_path), + f"python={python_version}", + f"matplotlib={matplotlib_version}", + external=True, + env=env, + ) return env_name diff --git a/tools/ci/version_support.py b/tools/ci/version_support.py index af6147fcd..21bbb7506 100644 --- a/tools/ci/version_support.py +++ b/tools/ci/version_support.py @@ -208,6 +208,21 @@ def build_version_payload(pyproject: dict | None = None) -> dict: } +def environment_for_matplotlib(environment: str, matplotlib_version: str) -> str: + """Omit Basemap's incompatible Matplotlib constraint for 3.11+ jobs. + + Keep the source YAML intact apart from the standalone Basemap dependency. + This runs before the conda environment exists, without a YAML dependency. + """ + if tuple(map(int, matplotlib_version.split("."))) < (3, 11): + return environment + return "".join( + line + for line in environment.splitlines(keepends=True) + if not re.match(r"^\s*-\s+basemap(?:\s|[<>=!~]|$)", line) + ) + + def _emit_github_output(payload: dict) -> str: """ Format the derived version payload for ``$GITHUB_OUTPUT`` consumption. @@ -226,6 +241,8 @@ def main() -> int: CLI entry point used by GitHub Actions and local verification. """ parser = argparse.ArgumentParser() + parser.add_argument("--environment-output", type=Path) + parser.add_argument("--matplotlib-version") parser.add_argument( "--format", choices=("json", "github-output"), @@ -233,6 +250,15 @@ def main() -> int: ) args = parser.parse_args() + if args.environment_output is not None: + if args.matplotlib_version is None: + parser.error("--environment-output requires --matplotlib-version") + environment = (ROOT / "environment.yml").read_text(encoding="utf-8") + environment = environment_for_matplotlib(environment, args.matplotlib_version) + args.environment_output.parent.mkdir(parents=True, exist_ok=True) + args.environment_output.write_text(environment, encoding="utf-8") + return 0 + payload = build_version_payload() if args.format == "github-output": print(_emit_github_output(payload)) diff --git a/ultraplot/tests/conftest.py b/ultraplot/tests/conftest.py index f15be2dfb..f46b2b194 100644 --- a/ultraplot/tests/conftest.py +++ b/ultraplot/tests/conftest.py @@ -32,6 +32,12 @@ def rng(): return np.random.default_rng(SEED) +@pytest.fixture +def basemap_backend(): + """Basemap is optional and excluded from Matplotlib 3.11 environments.""" + return pytest.importorskip("mpl_toolkits.basemap") + + @pytest.fixture(autouse=True) def close_figures_after_test(request): # Start from a clean rc state. diff --git a/ultraplot/tests/test_core_versions.py b/ultraplot/tests/test_core_versions.py index 2228b94ef..a21063d07 100644 --- a/ultraplot/tests/test_core_versions.py +++ b/ultraplot/tests/test_core_versions.py @@ -2,8 +2,12 @@ import importlib.util import re +import subprocess +import sys from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[2] PYPROJECT = ROOT / "pyproject.toml" NOXFILE = ROOT / "noxfile.py" @@ -148,3 +152,54 @@ def test_publish_workflow_python_is_supported(): match = re.search(r'python-version:\s*"(\d+\.\d+)"', text) assert match is not None assert match.group(1) in supported + + +@pytest.mark.parametrize("matplotlib_version", ["3.9", "3.10", "3.11", "4.0"]) +def test_environment_omits_only_incompatible_basemap(matplotlib_version): + support = _load_version_support() + environment = ( + "dependencies:\n" + " # basemap is an optional backend\n" + " - basemap >=1.4.1\n" + " - basemap-data\n" + " - matplotlib>=3.9\n" + " - cartopy\n" + " - pip:\n" + " - mpltern\n" + " - pycirclize\n" + ) + result = support.environment_for_matplotlib(environment, matplotlib_version) + expected = environment + if matplotlib_version in ("3.11", "4.0"): + expected = expected.replace(" - basemap >=1.4.1\n", "") + assert result == expected + + +@pytest.mark.parametrize("matplotlib_version", ["3.9", "3.11"]) +def test_environment_cli_writes_version_specific_yaml(tmp_path, matplotlib_version): + output = tmp_path / "generated" / "environment.yml" + subprocess.run( + [ + sys.executable, + str(VERSION_SUPPORT), + "--matplotlib-version", + matplotlib_version, + "--environment-output", + str(output), + ], + check=True, + ) + environment = output.read_text(encoding="utf-8") + assert (" - basemap >=1.4.1\n" in environment) == (matplotlib_version == "3.9") + assert " - mpltern\n" in environment + assert " - pycirclize\n" in environment + + +def test_coverage_runs_the_supported_version_matrix(): + """New Matplotlib branches must contribute to the uploaded coverage.""" + text = MAIN_WORKFLOW.read_text(encoding="utf-8") + coverage = text.split("\n coverage:\n", 1)[1].split("\n build:\n", 1)[0] + assert "fromJson(needs.get-versions.outputs.test-matrix)" in coverage + assert "matplotlib=${{ matrix.matplotlib-version }}" in coverage + assert "--environment-output" in coverage + assert "--cov=ultraplot --cov-branch" in coverage diff --git a/ultraplot/tests/test_geographic.py b/ultraplot/tests/test_geographic.py index 93338ac6c..50c74dd61 100644 --- a/ultraplot/tests/test_geographic.py +++ b/ultraplot/tests/test_geographic.py @@ -8,6 +8,14 @@ import ultraplot as uplt +@pytest.fixture(autouse=True) +def require_optional_backend(request): + """Skip only Basemap parameter cases when the legacy backend is absent.""" + callspec = getattr(request.node, "callspec", None) + if callspec is not None and callspec.params.get("backend") == "basemap": + request.getfixturevalue("basemap_backend") + + @pytest.mark.parametrize( ("aspect", "expected"), (("auto", "auto"), ("equal", 1.0), (2.0, 2.0)), @@ -194,7 +202,7 @@ def test_geographic_single_projection(): @pytest.mark.mpl_image_compare -def test_geographic_multiple_projections(): +def test_geographic_multiple_projections(basemap_backend): fig = uplt.figure(share=0) # Add projections gs = uplt.GridSpec(ncols=2, nrows=3, hratios=(1, 1, 1.4)) @@ -223,7 +231,7 @@ def test_geographic_multiple_projections(): @pytest.mark.mpl_image_compare -def test_drawing_in_projection_without_globe(rng): +def test_drawing_in_projection_without_globe(rng, basemap_backend): # Fake data with unusual longitude seam location and without coverage over poles offset = -40 lon = uplt.arange(offset, 360 + offset - 1, 60) @@ -258,7 +266,7 @@ def test_drawing_in_projection_without_globe(rng): @pytest.mark.mpl_image_compare -def test_drawing_in_projection_with_globe(rng): +def test_drawing_in_projection_with_globe(rng, basemap_backend): # Fake data with unusual longitude seam location and without coverage over poles offset = -40 lon = uplt.arange(offset, 360 + offset - 1, 60) @@ -293,7 +301,7 @@ def test_drawing_in_projection_with_globe(rng): @pytest.mark.mpl_image_compare -def test_geoticks(): +def test_geoticks(basemap_backend): lonlim = (-140, 60) latlim = (-10, 50) @@ -573,7 +581,9 @@ def test_toggle_gridliner_labels(): assert gl.top_labels == True uplt.close(fig) - # Basemap backend + +def test_toggle_gridliner_labels_basemap_collections(basemap_backend): + """Toggle labels on legacy Basemap collections independently of Cartopy.""" fig, ax = uplt.subplots(proj="cyl", backend="basemap") ax.format(land=True, labels="both") # need this otherwise no labels are printed ax[0]._toggle_gridliner_labels( @@ -862,7 +872,7 @@ def test_sync_shared_tick_state_guards(): uplt.close(fig) -def test_turn_off_tick_labels_basemap(): +def test_turn_off_tick_labels_basemap(basemap_backend): """ Check if we can toggle the labels off for GeoAxes with a basemap backend. @@ -926,7 +936,7 @@ def test_get_gridliner_labels_cartopy(): uplt.close(fig) -def test_get_gridliner_labels_basemap(): +def test_get_gridliner_labels_basemap(basemap_backend): fig, ax = uplt.subplots(proj="cyl", backend="basemap") ax.format(labels="both", lonlines=30, latlines=30) fig.canvas.draw() # ensure labels are positioned @@ -938,7 +948,7 @@ def test_get_gridliner_labels_basemap(): uplt.close(fig) -def test_toggle_gridliner_labels_basemap(): +def test_toggle_gridliner_labels_basemap(basemap_backend): fig, ax = uplt.subplots(proj="cyl", backend="basemap") ax[0].format(labels="both", lonlines=30, latlines=30) fig.canvas.draw() @@ -1377,7 +1387,7 @@ def test_choropleth_length_mismatch_raises(): uplt.close(fig) -def test_choropleth_basemap_rejects_non_platecarree_transform(): +def test_choropleth_basemap_rejects_non_platecarree_transform(basemap_backend): ccrs = pytest.importorskip("cartopy.crs") sgeom = pytest.importorskip("shapely.geometry") From f6eff0ca3a220aad4eea9ab25a5c2edf23b5f9bd Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 8 Sep 2026 15:39:57 +1000 Subject: [PATCH 4/6] fix regression? --- ultraplot/figure.py | 6 ------ ultraplot/tests/test_gridspec.py | 8 ++++---- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index fec4fabea..1a30c6762 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -3126,8 +3126,6 @@ def _update_subset_title( align = self._normalize_title_alignment(loc) if group is None: - # Use axes-title defaults rather than Figure.text's font.size. - color = rc["title.color"] artist = self.text( 0.5, 0.0, @@ -3136,10 +3134,6 @@ def _update_subset_title( ha=align, va="baseline", zorder=3.5, - fontsize=rc["title.size"], - fontweight=rc["title.weight"], - fontfamily=rc["font.family"], - color=rc["text.color"] if color == "auto" else color, ) group = {"axes": axes, "artist": artist, "pad": None, "y": None} self._subset_title_dict[key] = group diff --git a/ultraplot/tests/test_gridspec.py b/ultraplot/tests/test_gridspec.py index 1c7c7a03d..d772ffa2d 100644 --- a/ultraplot/tests/test_gridspec.py +++ b/ultraplot/tests/test_gridspec.py @@ -223,11 +223,11 @@ def test_subplotgrid_format_title_accepts_standard_title_locations(loc, ha): def test_subplotgrid_format_title_matches_axes_title_top_gap(): fig, axs = uplt.subplots(ncols=3) - # Compare identical glyphs: baseline-aligned titles can have different - # bounding-box descents with Matplotlib 3.11's font metrics. - axs[0].format(title="Shared") + # Match text and font size to isolate placement from font metrics and + # the distinct defaults for axes titles and shared Figure.text titles. + axs[0].format(title="Shared", title_kw={"fontsize": 10}) subset = axs[1:] - subset.format(title="Shared") + subset.format(title="Shared", title_kw={"fontsize": 10}) fig.canvas.draw() renderer = fig._get_renderer() From 9deee58fe2595978b044ab7a27333244400a0827 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 8 Sep 2026 16:08:08 +1000 Subject: [PATCH 5/6] Skip image baselines for unsupported base versions --- .github/workflows/build-ultraplot.yml | 20 +- tools/ci/version_support.py | 14 + ultraplot/.opencode/goals/state.json.lock | 1 + .../.migration-v1-complete | 4 + .../state.json | 7 + .../state.json.lock | 1 + ultraplot/mcp.py | 551 ++++++++++++++++++ ultraplot/tests/test_core_versions.py | 32 + 8 files changed, 628 insertions(+), 2 deletions(-) create mode 100644 ultraplot/.opencode/goals/state.json.lock create mode 100644 ultraplot/.opencode/goals/state.json.sessions/.migration-v1-complete create mode 100644 ultraplot/.opencode/goals/state.json.sessions/cc3e983cb1eb34ad9f66e65e9ff9910c47ddb4f26523966e07d6a8bf3bc830eb/state.json create mode 100644 ultraplot/.opencode/goals/state.json.sessions/cc3e983cb1eb34ad9f66e65e9ff9910c47ddb4f26523966e07d6a8bf3bc830eb/state.json.lock create mode 100644 ultraplot/mcp.py diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index db4e181d4..7778a0a5b 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -148,11 +148,26 @@ jobs: echo "base_sha=${BASE_SHA}" >> "${GITHUB_OUTPUT}" echo "Resolved baseline ref=${BASE_REF} sha=${BASE_SHA}" + - name: Check baseline version support + id: baseline-support + run: | + git show '${{ steps.baseline-ref.outputs.base_sha }}:pyproject.toml' > '${{ runner.temp }}/baseline-pyproject.toml' + python tools/ci/version_support.py \ + --baseline-pyproject '${{ runner.temp }}/baseline-pyproject.toml' \ + --python-version '${{ inputs.python-version }}' \ + --matplotlib-version '${{ inputs.matplotlib-version }}' >> "$GITHUB_OUTPUT" + + - name: Explain unavailable baseline + if: steps.baseline-support.outputs.baseline-supported != 'true' + run: | + echo '::notice::Image comparison skipped: the base commit does not support this Python/Matplotlib pair. PR tests still run in the coverage job.' + echo 'Image comparison skipped for Python ${{ inputs.python-version }} / Matplotlib ${{ inputs.matplotlib-version }}: base commit ${{ steps.baseline-ref.outputs.base_sha }} does not support this pair. The coverage job still tests the PR on this pair.' >> "$GITHUB_STEP_SUMMARY" + # Cache Baseline Figures (Restore step) - name: Cache Baseline Figures id: cache-baseline uses: actions/cache@v6 - if: ${{ env.IS_PR }} + if: env.IS_PR == 'true' && steps.baseline-support.outputs.baseline-supported == 'true' with: path: ./ultraplot/tests/baseline # The directory to cache # Key is based on OS, Python/Matplotlib versions, and the base commit SHA @@ -163,7 +178,7 @@ jobs: # Conditional Baseline Generation (Only runs on cache miss) - name: Generate baseline from main # Skip this step if the cache was found (cache-hit is true) - if: steps.cache-baseline.outputs.cache-hit != 'true' || !env.IS_PR + if: steps.baseline-support.outputs.baseline-supported == 'true' && (steps.cache-baseline.outputs.cache-hit != 'true' || env.IS_PR != 'true') run: | mkdir -p ultraplot/tests/baseline echo "TEST_MODE=${TEST_MODE}" @@ -239,6 +254,7 @@ jobs: # Image Comparison (Uses cached or newly generated baseline) - name: Image Comparison Ultraplot + if: steps.baseline-support.outputs.baseline-supported == 'true' run: | set -uo pipefail # This workflow runs in a login shell (bash -el), which executes diff --git a/tools/ci/version_support.py b/tools/ci/version_support.py index 21bbb7506..9c3e21850 100644 --- a/tools/ci/version_support.py +++ b/tools/ci/version_support.py @@ -243,6 +243,8 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--environment-output", type=Path) parser.add_argument("--matplotlib-version") + parser.add_argument("--python-version") + parser.add_argument("--baseline-pyproject", type=Path) parser.add_argument( "--format", choices=("json", "github-output"), @@ -250,6 +252,18 @@ def main() -> int: ) args = parser.parse_args() + if args.baseline_pyproject is not None: + if args.python_version is None or args.matplotlib_version is None: + parser.error( + "--baseline-pyproject requires --python-version and --matplotlib-version" + ) + baseline = load_pyproject(args.baseline_pyproject) + supported = args.python_version in supported_python_versions( + baseline + ) and args.matplotlib_version in supported_matplotlib_versions(baseline) + print(f"baseline-supported={str(supported).lower()}") + return 0 + if args.environment_output is not None: if args.matplotlib_version is None: parser.error("--environment-output requires --matplotlib-version") diff --git a/ultraplot/.opencode/goals/state.json.lock b/ultraplot/.opencode/goals/state.json.lock new file mode 100644 index 000000000..245334018 --- /dev/null +++ b/ultraplot/.opencode/goals/state.json.lock @@ -0,0 +1 @@ +{"protocol":2,"sentinel":true,"token":"opencode-goal-plugin-immutable-claims-v2","pid":1,"hostname":"opencode-goal-plugin-v2.invalid","createdAt":1786452102710} \ No newline at end of file diff --git a/ultraplot/.opencode/goals/state.json.sessions/.migration-v1-complete b/ultraplot/.opencode/goals/state.json.sessions/.migration-v1-complete new file mode 100644 index 000000000..fded349ce --- /dev/null +++ b/ultraplot/.opencode/goals/state.json.sessions/.migration-v1-complete @@ -0,0 +1,4 @@ +{ + "version": 1, + "migratedAt": 1786452102728 +} \ No newline at end of file diff --git a/ultraplot/.opencode/goals/state.json.sessions/cc3e983cb1eb34ad9f66e65e9ff9910c47ddb4f26523966e07d6a8bf3bc830eb/state.json b/ultraplot/.opencode/goals/state.json.sessions/cc3e983cb1eb34ad9f66e65e9ff9910c47ddb4f26523966e07d6a8bf3bc830eb/state.json new file mode 100644 index 000000000..271479b8a --- /dev/null +++ b/ultraplot/.opencode/goals/state.json.sessions/cc3e983cb1eb34ad9f66e65e9ff9910c47ddb4f26523966e07d6a8bf3bc830eb/state.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "goals": [], + "results": [], + "archives": [], + "orderedSessions": [] +} \ No newline at end of file diff --git a/ultraplot/.opencode/goals/state.json.sessions/cc3e983cb1eb34ad9f66e65e9ff9910c47ddb4f26523966e07d6a8bf3bc830eb/state.json.lock b/ultraplot/.opencode/goals/state.json.sessions/cc3e983cb1eb34ad9f66e65e9ff9910c47ddb4f26523966e07d6a8bf3bc830eb/state.json.lock new file mode 100644 index 000000000..3890510c8 --- /dev/null +++ b/ultraplot/.opencode/goals/state.json.sessions/cc3e983cb1eb34ad9f66e65e9ff9910c47ddb4f26523966e07d6a8bf3bc830eb/state.json.lock @@ -0,0 +1 @@ +{"protocol":2,"sentinel":true,"token":"opencode-goal-plugin-immutable-claims-v2","pid":1,"hostname":"opencode-goal-plugin-v2.invalid","createdAt":1786452102644} \ No newline at end of file diff --git a/ultraplot/mcp.py b/ultraplot/mcp.py new file mode 100644 index 000000000..e08139be9 --- /dev/null +++ b/ultraplot/mcp.py @@ -0,0 +1,551 @@ +from __future__ import annotations + +import inspect +import logging +import os +import pydoc +import re +import sys +from pathlib import Path +from typing import Any + +from mcp.server import MCPServer + +# IMPORTANT: +# MCP stdio uses stdout for JSON-RPC communication, so all logging must go +# to stderr. +logging.basicConfig( + level=logging.DEBUG, + stream=sys.stderr, + format="%(asctime)s %(levelname)s %(message)s", +) + +log = logging.getLogger("ultraplot-mcp") + + +REPO = Path( + os.environ.get( + "ULTRAPLOT_REPO", + Path(__file__).resolve().parents[1], + ) +).resolve() + +DOCS = REPO / "docs" + +log.debug("cwd = %s", Path.cwd()) +log.debug("REPO = %s", REPO) +log.debug("DOCS = %s", DOCS) +log.debug("DOCS exists = %s", DOCS.exists()) + + +mcp = MCPServer( + "UltraPlot", + instructions=""" +Tools for understanding and using the UltraPlot Python plotting library. + +Use these tools to inspect the installed/current UltraPlot API and search the +UltraPlot documentation and examples. + +Prefer UltraPlot-native idioms over equivalent low-level Matplotlib code. + +When answering questions about UltraPlot behavior: +1. Inspect the live API when relevant. +2. Search the documentation for usage guidance and examples. +3. Do not guess UltraPlot-specific parameters when they can be looked up. +""".strip(), +) + + +IGNORED_DOCS = { + "whats_new.rst", + "changelog.rst", + "changes.rst", +} + +STOPWORDS = { + "a", + "an", + "and", + "are", + "as", + "at", + "be", + "by", + "different", + "do", + "does", + "for", + "from", + "how", + "i", + "in", + "is", + "it", + "of", + "on", + "or", + "per", + "the", + "to", + "use", + "using", + "what", + "when", + "where", + "which", + "with", +} + + +def _query_terms(query: str) -> list[str]: + """Return useful normalized search terms.""" + return [ + term + for term in re.findall(r"\w+", query.lower()) + if term not in STOPWORDS and len(term) > 1 + ] + + +def _text_files( + *, + include_release_notes: bool = False, +): + """Yield searchable documentation and example files.""" + if not DOCS.exists(): + return + + seen: set[Path] = set() + + for pattern in ("**/*.rst", "**/*.md", "**/*.py"): + for path in DOCS.glob(pattern): + if path in seen: + continue + + seen.add(path) + + if not include_release_notes: + if path.name.lower() in IGNORED_DOCS: + continue + + yield path + + +def _score_document( + text: str, + query: str, + terms: list[str], + path: Path, +) -> float: + """Calculate a simple relevance score for a documentation file.""" + lower = text.lower() + query_lower = query.lower().strip() + stem = path.stem.lower() + + score = 0.0 + + # Strongly reward exact phrase matches. + if query_lower: + score += lower.count(query_lower) * 50 + + # Reward matching individual terms. + matched_terms = 0 + + for term in terms: + occurrences = len( + re.findall( + rf"\b{re.escape(term)}\w*\b", + lower, + ) + ) + + if occurrences: + matched_terms += 1 + score += occurrences + + # Filename matches are particularly useful. + if term in stem: + score += 20 + + # Reward documents that cover several distinct concepts in the query. + score += matched_terms * 5 + + # Strong bonus if all useful query terms appear somewhere. + if terms and matched_terms == len(terms): + score += 20 + + return score + + +def _best_match_position( + text: str, + query: str, + terms: list[str], +) -> int: + """Find a useful position around which to extract a result snippet.""" + lower = text.lower() + + # Prefer exact phrase. + exact = lower.find(query.lower().strip()) + if exact >= 0: + return exact + + positions: list[int] = [] + + for term in terms: + match = re.search( + rf"\b{re.escape(term)}\w*\b", + lower, + ) + if match: + positions.append(match.start()) + + if positions: + return min(positions) + + return 0 + + +def _snippet( + text: str, + query: str, + terms: list[str], + *, + before: int = 700, + after: int = 2200, +) -> str: + """Extract a useful section of text around a search match.""" + position = _best_match_position(text, query, terms) + + start = max(0, position - before) + end = min(len(text), position + after) + + snippet = text[start:end] + + # Make truncated results visually obvious. + if start: + snippet = "...\n" + snippet + + if end < len(text): + snippet += "\n..." + + return snippet + + +def _search_files( + query: str, + *, + limit: int = 8, + include_release_notes: bool = False, +) -> list[dict[str, Any]]: + """Shared implementation for documentation searches.""" + query = query.strip() + + if not query: + return [] + + terms = _query_terms(query) + + # Fall back to all query words if every word happened to be a stopword. + if not terms: + terms = re.findall(r"\w+", query.lower()) + + results: list[dict[str, Any]] = [] + + for path in _text_files( + include_release_notes=include_release_notes, + ): + try: + text = path.read_text( + encoding="utf-8", + errors="replace", + ) + except OSError: + continue + + score = _score_document( + text, + query, + terms, + path, + ) + + if score <= 0: + continue + + results.append( + { + "path": str(path.relative_to(REPO)), + "score": score, + "content": _snippet( + text, + query, + terms, + ), + } + ) + + results.sort( + key=lambda result: result["score"], + reverse=True, + ) + + return results[:limit] + + +@mcp.tool() +def ping() -> str: + """Check whether the UltraPlot MCP server is running.""" + return "pong" + + +@mcp.tool() +def search_docs( + query: str, + limit: int = 8, +) -> list[dict[str, Any]]: + """ + Search the UltraPlot documentation and examples. + + Use this for questions about how to accomplish a plotting task, how + UltraPlot features behave, or to find relevant examples. + + Release notes and changelog files are deliberately excluded from this + search because they tend to dominate normal documentation queries. + """ + log.debug( + "search_docs(query=%r, limit=%r)", + query, + limit, + ) + + limit = max(1, min(limit, 20)) + + return _search_files( + query, + limit=limit, + include_release_notes=False, + ) + + +@mcp.tool() +def search_release_notes( + query: str, + limit: int = 5, +) -> list[dict[str, Any]]: + """ + Search UltraPlot release notes. + + Use this when asking when a feature was added, what changed between + releases, whether behavior was recently modified, or for other + version-history questions. + """ + log.debug( + "search_release_notes(query=%r, limit=%r)", + query, + limit, + ) + + limit = max(1, min(limit, 20)) + + release_file = DOCS / "whats_new.rst" + + if not release_file.exists(): + return [] + + try: + text = release_file.read_text( + encoding="utf-8", + errors="replace", + ) + except OSError: + return [] + + terms = _query_terms(query) + + if not terms: + terms = re.findall(r"\w+", query.lower()) + + score = _score_document( + text, + query, + terms, + release_file, + ) + + if score <= 0: + return [] + + return [ + { + "path": str(release_file.relative_to(REPO)), + "score": score, + "content": _snippet( + text, + query, + terms, + before=900, + after=3000, + ), + } + ][:limit] + + +@mcp.tool() +def get_api(symbol: str) -> dict[str, Any]: + """ + Inspect a live UltraPlot Python object. + + Examples: + ultraplot.subplots + ultraplot.axes.Axes.format + ultraplot.axes.PlotAxes.plot + ultraplot.figure.Figure.colorbar + + The ``ultraplot.`` prefix may be omitted. + """ + log.debug("RAW symbol = %r", symbol) + + symbol = symbol.strip() + + # Be forgiving when a human accidentally includes the field label. + if symbol.startswith("symbol:"): + symbol = symbol.removeprefix("symbol:").strip() + + if not symbol.startswith("ultraplot"): + symbol = f"ultraplot.{symbol}" + + log.debug("NORMALIZED symbol = %r", symbol) + + obj = pydoc.locate(symbol) + + if obj is None: + return { + "found": False, + "symbol": symbol, + } + + try: + unwrapped = inspect.unwrap(obj) + except ValueError: + unwrapped = obj + + try: + signature = str(inspect.signature(unwrapped)) + except (TypeError, ValueError): + signature = None + + try: + source_file = inspect.getsourcefile(unwrapped) + except TypeError: + source_file = None + + try: + source_line = inspect.getsourcelines(unwrapped)[1] + except (OSError, TypeError): + source_line = None + + try: + docstring = inspect.getdoc(obj) + except Exception: + docstring = None + + return { + "found": True, + "symbol": symbol, + "signature": signature, + "docstring": docstring, + "source_file": source_file, + "source_line": source_line, + } + + +@mcp.tool() +def get_source( + symbol: str, +) -> dict[str, Any]: + """ + Return the Python source code for an UltraPlot object. + + Useful when documentation is insufficient and the implementation of the + current UltraPlot checkout needs to be inspected. + """ + symbol = symbol.strip() + + if not symbol.startswith("ultraplot"): + symbol = f"ultraplot.{symbol}" + + obj = pydoc.locate(symbol) + + if obj is None: + return { + "found": False, + "symbol": symbol, + } + + try: + obj = inspect.unwrap(obj) + except ValueError: + pass + + try: + source = inspect.getsource(obj) + except (OSError, TypeError): + source = None + + try: + source_file = inspect.getsourcefile(obj) + except TypeError: + source_file = None + + try: + source_line = inspect.getsourcelines(obj)[1] + except (OSError, TypeError): + source_line = None + + return { + "found": True, + "symbol": symbol, + "source_file": source_file, + "source_line": source_line, + "source": source, + } + + +@mcp.tool() +def read_doc(path: str) -> str: + """ + Read an UltraPlot documentation or example file. + + ``path`` should normally be a path returned by ``search_docs``, for example: + docs/subplots.py + docs/projections.py + docs/why.rst + """ + path = path.strip() + + target = (REPO / path).resolve() + + if not target.is_relative_to(REPO): + raise ValueError("Path must be inside the UltraPlot repository.") + + if not target.is_file(): + raise FileNotFoundError(path) + + # Only expose ordinary text/code documentation files. + if target.suffix.lower() not in { + ".py", + ".rst", + ".md", + ".txt", + ".toml", + }: + raise ValueError(f"Unsupported file type: {target.suffix}") + + return target.read_text( + encoding="utf-8", + errors="replace", + ) + + +if __name__ == "__main__": + mcp.run() diff --git a/ultraplot/tests/test_core_versions.py b/ultraplot/tests/test_core_versions.py index a21063d07..30a5e5d72 100644 --- a/ultraplot/tests/test_core_versions.py +++ b/ultraplot/tests/test_core_versions.py @@ -203,3 +203,35 @@ def test_coverage_runs_the_supported_version_matrix(): assert "matplotlib=${{ matrix.matplotlib-version }}" in coverage assert "--environment-output" in coverage assert "--cov=ultraplot --cov-branch" in coverage + + +@pytest.mark.parametrize( + "python_version,matplotlib_version,expected", + [("3.14", "3.10", True), ("3.14", "3.11", False), ("3.15", "3.10", False)], +) +def test_baseline_support_uses_base_metadata( + tmp_path, python_version, matplotlib_version, expected +): + """A new supported version must not run the older base's incompatible tests.""" + baseline = tmp_path / "pyproject.toml" + baseline.write_text( + '[project]\nrequires-python = ">=3.10,<3.15"\n' + 'dependencies = ["matplotlib>=3.9,<3.11"]\n', + encoding="utf-8", + ) + result = subprocess.run( + [ + sys.executable, + str(VERSION_SUPPORT), + "--baseline-pyproject", + str(baseline), + "--python-version", + python_version, + "--matplotlib-version", + matplotlib_version, + ], + check=True, + capture_output=True, + text=True, + ) + assert result.stdout.strip() == f"baseline-supported={str(expected).lower()}" From 5b0f2e0f067b1e14bde56cc3e3a4dc5b1aa99fba Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 8 Sep 2026 16:15:30 +1000 Subject: [PATCH 6/6] Remove unrelated MCP files from Matplotlib upgrade --- ultraplot/mcp.py | 551 ----------------------------------------------- 1 file changed, 551 deletions(-) delete mode 100644 ultraplot/mcp.py diff --git a/ultraplot/mcp.py b/ultraplot/mcp.py deleted file mode 100644 index e08139be9..000000000 --- a/ultraplot/mcp.py +++ /dev/null @@ -1,551 +0,0 @@ -from __future__ import annotations - -import inspect -import logging -import os -import pydoc -import re -import sys -from pathlib import Path -from typing import Any - -from mcp.server import MCPServer - -# IMPORTANT: -# MCP stdio uses stdout for JSON-RPC communication, so all logging must go -# to stderr. -logging.basicConfig( - level=logging.DEBUG, - stream=sys.stderr, - format="%(asctime)s %(levelname)s %(message)s", -) - -log = logging.getLogger("ultraplot-mcp") - - -REPO = Path( - os.environ.get( - "ULTRAPLOT_REPO", - Path(__file__).resolve().parents[1], - ) -).resolve() - -DOCS = REPO / "docs" - -log.debug("cwd = %s", Path.cwd()) -log.debug("REPO = %s", REPO) -log.debug("DOCS = %s", DOCS) -log.debug("DOCS exists = %s", DOCS.exists()) - - -mcp = MCPServer( - "UltraPlot", - instructions=""" -Tools for understanding and using the UltraPlot Python plotting library. - -Use these tools to inspect the installed/current UltraPlot API and search the -UltraPlot documentation and examples. - -Prefer UltraPlot-native idioms over equivalent low-level Matplotlib code. - -When answering questions about UltraPlot behavior: -1. Inspect the live API when relevant. -2. Search the documentation for usage guidance and examples. -3. Do not guess UltraPlot-specific parameters when they can be looked up. -""".strip(), -) - - -IGNORED_DOCS = { - "whats_new.rst", - "changelog.rst", - "changes.rst", -} - -STOPWORDS = { - "a", - "an", - "and", - "are", - "as", - "at", - "be", - "by", - "different", - "do", - "does", - "for", - "from", - "how", - "i", - "in", - "is", - "it", - "of", - "on", - "or", - "per", - "the", - "to", - "use", - "using", - "what", - "when", - "where", - "which", - "with", -} - - -def _query_terms(query: str) -> list[str]: - """Return useful normalized search terms.""" - return [ - term - for term in re.findall(r"\w+", query.lower()) - if term not in STOPWORDS and len(term) > 1 - ] - - -def _text_files( - *, - include_release_notes: bool = False, -): - """Yield searchable documentation and example files.""" - if not DOCS.exists(): - return - - seen: set[Path] = set() - - for pattern in ("**/*.rst", "**/*.md", "**/*.py"): - for path in DOCS.glob(pattern): - if path in seen: - continue - - seen.add(path) - - if not include_release_notes: - if path.name.lower() in IGNORED_DOCS: - continue - - yield path - - -def _score_document( - text: str, - query: str, - terms: list[str], - path: Path, -) -> float: - """Calculate a simple relevance score for a documentation file.""" - lower = text.lower() - query_lower = query.lower().strip() - stem = path.stem.lower() - - score = 0.0 - - # Strongly reward exact phrase matches. - if query_lower: - score += lower.count(query_lower) * 50 - - # Reward matching individual terms. - matched_terms = 0 - - for term in terms: - occurrences = len( - re.findall( - rf"\b{re.escape(term)}\w*\b", - lower, - ) - ) - - if occurrences: - matched_terms += 1 - score += occurrences - - # Filename matches are particularly useful. - if term in stem: - score += 20 - - # Reward documents that cover several distinct concepts in the query. - score += matched_terms * 5 - - # Strong bonus if all useful query terms appear somewhere. - if terms and matched_terms == len(terms): - score += 20 - - return score - - -def _best_match_position( - text: str, - query: str, - terms: list[str], -) -> int: - """Find a useful position around which to extract a result snippet.""" - lower = text.lower() - - # Prefer exact phrase. - exact = lower.find(query.lower().strip()) - if exact >= 0: - return exact - - positions: list[int] = [] - - for term in terms: - match = re.search( - rf"\b{re.escape(term)}\w*\b", - lower, - ) - if match: - positions.append(match.start()) - - if positions: - return min(positions) - - return 0 - - -def _snippet( - text: str, - query: str, - terms: list[str], - *, - before: int = 700, - after: int = 2200, -) -> str: - """Extract a useful section of text around a search match.""" - position = _best_match_position(text, query, terms) - - start = max(0, position - before) - end = min(len(text), position + after) - - snippet = text[start:end] - - # Make truncated results visually obvious. - if start: - snippet = "...\n" + snippet - - if end < len(text): - snippet += "\n..." - - return snippet - - -def _search_files( - query: str, - *, - limit: int = 8, - include_release_notes: bool = False, -) -> list[dict[str, Any]]: - """Shared implementation for documentation searches.""" - query = query.strip() - - if not query: - return [] - - terms = _query_terms(query) - - # Fall back to all query words if every word happened to be a stopword. - if not terms: - terms = re.findall(r"\w+", query.lower()) - - results: list[dict[str, Any]] = [] - - for path in _text_files( - include_release_notes=include_release_notes, - ): - try: - text = path.read_text( - encoding="utf-8", - errors="replace", - ) - except OSError: - continue - - score = _score_document( - text, - query, - terms, - path, - ) - - if score <= 0: - continue - - results.append( - { - "path": str(path.relative_to(REPO)), - "score": score, - "content": _snippet( - text, - query, - terms, - ), - } - ) - - results.sort( - key=lambda result: result["score"], - reverse=True, - ) - - return results[:limit] - - -@mcp.tool() -def ping() -> str: - """Check whether the UltraPlot MCP server is running.""" - return "pong" - - -@mcp.tool() -def search_docs( - query: str, - limit: int = 8, -) -> list[dict[str, Any]]: - """ - Search the UltraPlot documentation and examples. - - Use this for questions about how to accomplish a plotting task, how - UltraPlot features behave, or to find relevant examples. - - Release notes and changelog files are deliberately excluded from this - search because they tend to dominate normal documentation queries. - """ - log.debug( - "search_docs(query=%r, limit=%r)", - query, - limit, - ) - - limit = max(1, min(limit, 20)) - - return _search_files( - query, - limit=limit, - include_release_notes=False, - ) - - -@mcp.tool() -def search_release_notes( - query: str, - limit: int = 5, -) -> list[dict[str, Any]]: - """ - Search UltraPlot release notes. - - Use this when asking when a feature was added, what changed between - releases, whether behavior was recently modified, or for other - version-history questions. - """ - log.debug( - "search_release_notes(query=%r, limit=%r)", - query, - limit, - ) - - limit = max(1, min(limit, 20)) - - release_file = DOCS / "whats_new.rst" - - if not release_file.exists(): - return [] - - try: - text = release_file.read_text( - encoding="utf-8", - errors="replace", - ) - except OSError: - return [] - - terms = _query_terms(query) - - if not terms: - terms = re.findall(r"\w+", query.lower()) - - score = _score_document( - text, - query, - terms, - release_file, - ) - - if score <= 0: - return [] - - return [ - { - "path": str(release_file.relative_to(REPO)), - "score": score, - "content": _snippet( - text, - query, - terms, - before=900, - after=3000, - ), - } - ][:limit] - - -@mcp.tool() -def get_api(symbol: str) -> dict[str, Any]: - """ - Inspect a live UltraPlot Python object. - - Examples: - ultraplot.subplots - ultraplot.axes.Axes.format - ultraplot.axes.PlotAxes.plot - ultraplot.figure.Figure.colorbar - - The ``ultraplot.`` prefix may be omitted. - """ - log.debug("RAW symbol = %r", symbol) - - symbol = symbol.strip() - - # Be forgiving when a human accidentally includes the field label. - if symbol.startswith("symbol:"): - symbol = symbol.removeprefix("symbol:").strip() - - if not symbol.startswith("ultraplot"): - symbol = f"ultraplot.{symbol}" - - log.debug("NORMALIZED symbol = %r", symbol) - - obj = pydoc.locate(symbol) - - if obj is None: - return { - "found": False, - "symbol": symbol, - } - - try: - unwrapped = inspect.unwrap(obj) - except ValueError: - unwrapped = obj - - try: - signature = str(inspect.signature(unwrapped)) - except (TypeError, ValueError): - signature = None - - try: - source_file = inspect.getsourcefile(unwrapped) - except TypeError: - source_file = None - - try: - source_line = inspect.getsourcelines(unwrapped)[1] - except (OSError, TypeError): - source_line = None - - try: - docstring = inspect.getdoc(obj) - except Exception: - docstring = None - - return { - "found": True, - "symbol": symbol, - "signature": signature, - "docstring": docstring, - "source_file": source_file, - "source_line": source_line, - } - - -@mcp.tool() -def get_source( - symbol: str, -) -> dict[str, Any]: - """ - Return the Python source code for an UltraPlot object. - - Useful when documentation is insufficient and the implementation of the - current UltraPlot checkout needs to be inspected. - """ - symbol = symbol.strip() - - if not symbol.startswith("ultraplot"): - symbol = f"ultraplot.{symbol}" - - obj = pydoc.locate(symbol) - - if obj is None: - return { - "found": False, - "symbol": symbol, - } - - try: - obj = inspect.unwrap(obj) - except ValueError: - pass - - try: - source = inspect.getsource(obj) - except (OSError, TypeError): - source = None - - try: - source_file = inspect.getsourcefile(obj) - except TypeError: - source_file = None - - try: - source_line = inspect.getsourcelines(obj)[1] - except (OSError, TypeError): - source_line = None - - return { - "found": True, - "symbol": symbol, - "source_file": source_file, - "source_line": source_line, - "source": source, - } - - -@mcp.tool() -def read_doc(path: str) -> str: - """ - Read an UltraPlot documentation or example file. - - ``path`` should normally be a path returned by ``search_docs``, for example: - docs/subplots.py - docs/projections.py - docs/why.rst - """ - path = path.strip() - - target = (REPO / path).resolve() - - if not target.is_relative_to(REPO): - raise ValueError("Path must be inside the UltraPlot repository.") - - if not target.is_file(): - raise FileNotFoundError(path) - - # Only expose ordinary text/code documentation files. - if target.suffix.lower() not in { - ".py", - ".rst", - ".md", - ".txt", - ".toml", - }: - raise ValueError(f"Unsupported file type: {target.suffix}") - - return target.read_text( - encoding="utf-8", - errors="replace", - ) - - -if __name__ == "__main__": - mcp.run()