Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/470.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The pyplot `xlim`, `ylim`, `xticks`, and `yticks` helpers now return values that match the requested getter or setter operation. Limit helpers also accept Matplotlib's `xmin`/`xmax` and `ymin`/`ymax` aliases, and `legend()` returns its live handle.
98 changes: 80 additions & 18 deletions python/xy/pyplot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@

rcParams: Any = _native_rc_params

_MINOR_TICK_KEY = "minor"

__all__ = [
"AutoLocator",
"AutoMinorLocator",
Expand Down Expand Up @@ -1815,7 +1817,7 @@ def table(
)


def legend(*args: Any, **kwargs: Any) -> None:
def legend(*args: Any, **kwargs: Any) -> Legend:
"""Show the legend of the current axes.

Call forms: ``legend()`` (labeled artists), ``legend(labels)``, or
Expand Down Expand Up @@ -2777,18 +2779,52 @@ def ylabel(label: str, **kwargs: Any) -> None:
_native_gca().set_ylabel(label, **kwargs)


def xlim(*args: Any) -> None:
"""Set the x limits of the current axes.
def xlim(
left: float | LimitsLike | None = None,
Comment thread
harsh21234i marked this conversation as resolved.
Resolved
right: float | None = None,
*,
xmin: float | None = None,
xmax: float | None = None,
) -> tuple[float, float]:
"""Get or set the x limits of the current axes.

Call as ``xlim(left, right)``, ``xlim((left, right))``, or with
``left=``/``right=``; a descending pair inverts the axis.
"""
_native_gca().set_xlim(*args)


def ylim(*args: Any) -> None:
"""Set the y limits of the current axes (forms as in `xlim`)."""
_native_gca().set_ylim(*args)
``left=``/``right=`` (aliases: ``xmin=``/``xmax=``). With no arguments,
return the current limits without changing the automatic view. A
descending pair inverts the axis.
"""
if xmin is not None:
if left is not None:
raise TypeError("Cannot pass both 'left' and 'xmin'")
left = xmin
if isinstance(left, (tuple, list)) and (right is not None or xmax is not None):
raise TypeError("Cannot combine a limit pair with 'right' or 'xmax'")
if xmax is not None:
if right is not None:
raise TypeError("Cannot pass both 'right' and 'xmax'")
right = xmax
return gca().set_xlim(left, right)
Comment thread
harsh21234i marked this conversation as resolved.
Resolved


def ylim(
bottom: float | LimitsLike | None = None,
top: float | None = None,
*,
ymin: float | None = None,
ymax: float | None = None,
) -> tuple[float, float]:
"""Get or set y limits, with `ymin`/`ymax` aliases for `bottom`/`top`."""
if ymin is not None:
if bottom is not None:
raise TypeError("Cannot pass both 'bottom' and 'ymin'")
bottom = ymin
if isinstance(bottom, (tuple, list)) and (top is not None or ymax is not None):
raise TypeError("Cannot combine a limit pair with 'top' or 'ymax'")
if ymax is not None:
if top is not None:
raise TypeError("Cannot pass both 'top' and 'ymax'")
top = ymax
return gca().set_ylim(bottom, top)


def xscale(scale: str) -> None:
Expand All @@ -2807,12 +2843,26 @@ def xticks(
*,
rotation: float | None = None,
**kwargs: Any,
) -> None:
"""Place the x ticks at the given positions, optionally relabeled.
) -> tuple[np.ndarray, list[Any]]:
"""Get or set x ticks, optionally relabeled.

With no tick arguments, return the current locations and label handles.
``rotation`` (degrees) and supported text keywords style the labels.
"""
_native_gca().set_xticks(ticks, labels, rotation=rotation, **kwargs)
``minor=True`` reads minor ticks; setting minor ticks remains a no-op.
"""
axes = gca()
minor = bool(kwargs.get(_MINOR_TICK_KEY, False))
if (
ticks is None
and labels is None
and rotation is None
and not (set(kwargs) - {_MINOR_TICK_KEY})
):
handles = axes.xaxis.get_minorticklabels() if minor else axes.get_xticklabels()
return axes.get_xticks(minor=minor), handles
axes.set_xticks(ticks, labels, rotation=rotation, **kwargs)
handles = axes.xaxis.get_minorticklabels() if minor else axes.get_xticklabels()
return axes.get_xticks(minor=minor), handles


def yticks(
Expand All @@ -2821,9 +2871,21 @@ def yticks(
*,
rotation: float | None = None,
**kwargs: Any,
) -> None:
"""Place the y ticks at the given positions (see `xticks`)."""
_native_gca().set_yticks(ticks, labels, rotation=rotation, **kwargs)
) -> tuple[np.ndarray, list[Any]]:
"""Get or set y ticks (see `xticks`)."""
axes = gca()
minor = bool(kwargs.get(_MINOR_TICK_KEY, False))
if (
ticks is None
and labels is None
and rotation is None
and not (set(kwargs) - {_MINOR_TICK_KEY})
):
handles = axes.yaxis.get_minorticklabels() if minor else axes.get_yticklabels()
return axes.get_yticks(minor=minor), handles
axes.set_yticks(ticks, labels, rotation=rotation, **kwargs)
handles = axes.yaxis.get_minorticklabels() if minor else axes.get_yticklabels()
return axes.get_yticks(minor=minor), handles


def tight_layout(**kwargs: Any) -> None:
Expand Down
85 changes: 76 additions & 9 deletions python/xy/pyplot/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4236,7 +4236,9 @@ def set(self, **kwargs: Any) -> "Axes":
self._invalidate()
return self

def set_xlim(self, left: float | LimitsLike | None = None, right: float | None = None) -> None:
def set_xlim(
self, left: float | LimitsLike | None = None, right: float | None = None
) -> tuple[float, float]:
"""Set the x view limits.

Call as ``set_xlim(left, right)`` or ``set_xlim((left, right))``;
Expand All @@ -4246,6 +4248,8 @@ def set_xlim(self, left: float | LimitsLike | None = None, right: float | None =
"""
if isinstance(left, (tuple, list)):
left, right = left
if left is None and right is None:
return self.get_xlim()
host = (self._y2_of or self)._shared_ticker_source("x")
spec = host._scale_specs["x"]
current_start, current_end = self.get_xlim()
Expand Down Expand Up @@ -4282,6 +4286,7 @@ def set_xlim(self, left: float | LimitsLike | None = None, right: float | None =
host._explicit_domains.add("x")
host._tick_expanded_domains.discard("x")
host._invalidate_shared_ticker_axis("x")
return self.get_xlim()

def get_xlim(self) -> tuple[float, float]:
"""The current x view limits, in data space and display order."""
Expand All @@ -4299,14 +4304,18 @@ def get_xlim(self) -> tuple[float, float]:
)
return (hi, lo) if host._axis["x"].get("reverse") else (lo, hi)

def set_ylim(self, bottom: float | LimitsLike | None = None, top: float | None = None) -> None:
def set_ylim(
self, bottom: float | LimitsLike | None = None, top: float | None = None
) -> tuple[float, float]:
"""Set the y view limits (forms as in `set_xlim`).

A descending ``(bottom, top)`` pair inverts the axis; on a twin axes
the limits apply to its own right-hand y-axis.
"""
if isinstance(bottom, (tuple, list)):
bottom, top = bottom
if bottom is None and top is None:
return self.get_ylim()
base = self._y2_of or self
key = "y2" if self._y2_of is not None else "y"
host = base._shared_ticker_source(key)
Expand Down Expand Up @@ -4345,6 +4354,7 @@ def set_ylim(self, bottom: float | LimitsLike | None = None, top: float | None =
host._explicit_domains.add(key)
host._tick_expanded_domains.discard(key)
host._invalidate_shared_ticker_axis(key)
return self.get_ylim()

def get_ylim(self) -> tuple[float, float]:
"""The current y view limits, in data space and display order."""
Expand Down Expand Up @@ -6737,7 +6747,7 @@ def set_xticks(
*,
rotation: float | None = None,
**kwargs: Any,
) -> None:
) -> list[_TickLabel]:
"""Place the x ticks at the given positions, optionally relabeled.

``labels`` must match ``ticks`` in length and displaces any user
Expand All @@ -6746,7 +6756,7 @@ def set_xticks(
Positions are in data space, so nonlinear scales transform them.
"""
if kwargs.pop("minor", False):
return
return []
host = (self._y2_of or self)._shared_ticker_source("x")
props = host._axis["x"]
if ticks is not None:
Expand Down Expand Up @@ -6774,6 +6784,7 @@ def set_xticks(
if rotation is not None:
self._axis_props("x")["tick_label_angle"] = float(rotation)
host._invalidate_shared_ticker_axis("x")
return self.get_xticklabels()

def set_yticks(
self,
Expand All @@ -6782,10 +6793,10 @@ def set_yticks(
*,
rotation: float | None = None,
**kwargs: Any,
) -> None:
) -> list[_TickLabel]:
"""Place the y ticks at the given positions (see `set_xticks`)."""
if kwargs.pop("minor", False):
return
return []
base = self._y2_of or self
key = "y2" if self._y2_of is not None else "y"
host = base._shared_ticker_source(key)
Expand Down Expand Up @@ -6813,6 +6824,7 @@ def set_yticks(
if rotation is not None:
self._axis_props("y")["tick_label_angle"] = float(rotation)
host._invalidate_shared_ticker_axis(key)
return self.get_yticklabels()

def _expand_domain_to_ticks(self, axis: str) -> None:
"""Apply Matplotlib's mandatory view expansion for explicit ticks."""
Expand Down Expand Up @@ -6949,11 +6961,66 @@ def _computed_ticks(self, axis: str, minor: bool) -> np.ndarray:
host = base._shared_ticker_source(key)
props = host._axis[key]
if minor:
key = "y2" if axis == "y" and self._y2_of is not None else axis
# Materialize minor positions against a throwaway props mapping.
# `_apply_tickers` normally runs while building a chart, where it
# fills a copied wire payload; getters must return the same
# locations without requiring a render or mutating axis state.
minor_props = dict(props)
figure = base.figure
canvas_width, canvas_height = rc_figsize_px(figure._figsize, figure._dpi)

def chart_size(axes: Axes) -> tuple[int, int]:
if axes._figure_rect is not None:
return (
max(1, round(canvas_width * axes._figure_rect[2])),
max(1, round(canvas_height * axes._figure_rect[3])),
)
if axes._subplot_index is not None and hasattr(figure, "_grid_cell_sizes"):
widths, heights = figure._grid_cell_sizes()
index = axes._subplot_index
return widths[index % figure._ncols], heights[index // figure._ncols]
if axes in figure._axes:
return canvas_width, canvas_height
for parent in figure._axes:
for inset, (_left, _bottom, inset_width, inset_height) in parent._insets:
if inset is axes:
parent_width, parent_height = chart_size(parent)
return (
max(1, round(parent_width * inset_width)),
max(1, round(parent_height * inset_height)),
)
return canvas_width, canvas_height

width, height = chart_size(base)
# Rendering recomputes this hint from the live chart size on every
# build, so a locator's cached hint can be stale after resizing.
count_key = "y" if key == "y2" else key
nbins_hint = base._auto_tick_counts(base._axis["x"], width, height)[count_key]
if (
key in {"x", "y"}
and rcParams["axes.autolimit_mode"] == "round_numbers"
and not (base._aspect_equal and base._aspect_bounds is not None)
and not base._tight
):
# Drawing applies the locator's view_limits to automatic data
# domains before resolving ticks. Mirror that snapshot here so
# minor getters include the same edge ticks without rendering.
x_props = {
name: value for name, value in base._axis["x"].items() if value is not None
}
y_props = {
name: value for name, value in base._axis["y"].items() if value is not None
}
base._inherit_shared_axis_state("x", x_props)
base._inherit_shared_axis_state("y", y_props)
counts = base._auto_tick_counts(x_props, width, height)
base._apply_round_number_domains(x_props, y_props, counts)
minor_props.update(x_props if key == "x" else y_props)
base._apply_tickers(key, minor_props, nbins_hint)
Comment on lines +6998 to +7019

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Minor ticks miss drawn edges

With axes.autolimit_mode="round_numbers" and minorticks_on(), minor-tick getters use the unrounded data limits. Drawing first expands those limits through _apply_round_number_domains, then computes ticks. For data spanning 0.3–1.2, the getter can therefore omit minor ticks drawn near the expanded edges. Code using the returned locations to place marks will miss those ticks.

Resolve the same view limits as drawing before calling _apply_tickers.

return np.asarray(
_scale_values(
props.get("minor_tick_values", []),
(self._y2_of or self)._scale_specs[key],
minor_props.get("minor_tick_values", []),
base._scale_specs[key],
inverse=True,
),
dtype=float,
Expand Down
2 changes: 2 additions & 0 deletions python/xy/pyplot/_mplfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,8 @@ def set_size_inches(self, w: Any, h: Any = None) -> None:
if h is None:
w, h = w[0], w[1] # tuple form
self._figsize = (float(w), float(h))
for ax in self._axes:
ax._chart = None
self._invalidate()

def get_size_inches(self) -> np.ndarray:
Expand Down
8 changes: 4 additions & 4 deletions python/xy/pyplot/_plot_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1274,13 +1274,13 @@ def imshow(self, *args: Any, **kwargs: Any) -> Any: ...

def axhline(self, *args: Any, **kwargs: Any) -> Line2D: ...

def set_xticks(self, *args: Any, **kwargs: Any) -> None: ...
def set_xticks(self, *args: Any, **kwargs: Any) -> list[Any]: ...

def set_yticks(self, *args: Any, **kwargs: Any) -> None: ...
def set_yticks(self, *args: Any, **kwargs: Any) -> list[Any]: ...

def set_xlim(self, *args: Any, **kwargs: Any) -> None: ...
def set_xlim(self, *args: Any, **kwargs: Any) -> tuple[float, float]: ...

def set_ylim(self, *args: Any, **kwargs: Any) -> None: ...
def set_ylim(self, *args: Any, **kwargs: Any) -> tuple[float, float]: ...

def set_xscale(self, scale: str, **kwargs: Any) -> None: ...

Expand Down
29 changes: 28 additions & 1 deletion python/xy/pyplot/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from __future__ import annotations

from collections.abc import Sequence
from typing import Any, Protocol, TypeAlias, runtime_checkable
from typing import Any, Literal, Protocol, TypeAlias, runtime_checkable

from ._axes import Axes as _NativeAxes
from ._mplfig import Figure as _NativeFigure
Expand Down Expand Up @@ -72,6 +72,33 @@ def get_dpi(self) -> float: ...
class CompatAxesProtocol(AxesProtocol, Protocol):
"""Matplotlib-owned Axes surface available specifically in compat mode."""

xaxis: Any
yaxis: Any

def set_xlim(self, left: Any = None, right: Any = None, **kwargs: Any) -> Any: ...

def set_ylim(self, bottom: Any = None, top: Any = None, **kwargs: Any) -> Any: ...

def get_xticks(self, minor: bool = False) -> Any: ...

def get_yticks(self, minor: bool = False) -> Any: ...

def get_xticklabels(
self,
minor: bool = False,
which: Literal["major", "minor", "both"] | None = None,
) -> Any: ...

def get_yticklabels(
self,
minor: bool = False,
which: Literal["major", "minor", "both"] | None = None,
) -> Any: ...

def set_xticks(self, ticks: Any, labels: Any = None, **kwargs: Any) -> Any: ...

def set_yticks(self, ticks: Any, labels: Any = None, **kwargs: Any) -> Any: ...

def get_subplotspec(self) -> Any: ...

def get_legend_handles_labels(self) -> tuple[list[Any], list[str]]: ...
Expand Down
9 changes: 9 additions & 0 deletions spec/matplotlib/compat-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ This changelog records changes to the upstream compatibility target and to the
meaning of xy's compatibility levels. It complements the project changelog,
which covers user-visible releases across the whole package.

## Pyplot limit and tick return values — 2026-10-25 (Matplotlib 3.11 reference)
Comment thread
harsh21234i marked this conversation as resolved.
Resolved

- Reference: pinned Matplotlib revision `bde111fb4e` (`v3.11.0-348-gbde111fb4e`, 2026-07-10).
- Inventory: unchanged 66-method `Axes` Plotting surface (0 additions, 0 removals).
- Compatibility levels: limit and tick location values follow the existing exact-geometry level; returned label and legend handles follow equivalent semantics. No compatibility family changes level.
- `xlim`/`ylim` return the live limits on reads and writes, accept the `xmin`/`xmax` and `ymin`/`ymax` aliases, and reject passing an alias together with its canonical bound.
- `xticks`/`yticks` getters do not change autoscale. Minor-tick reads and setter results return minor locations and labels instead of accidentally returning major ticks. Minor tick locators still compute locations without requiring a render; manual minor tick setters remain compatibility no-ops.
- `legend()` returns the live `Legend` handle so callers can adjust it after creation.

## Dual-mode Matplotlib 3.11 gallery contract — 2026-07-31

- `xy.pyplot` now separates the existing dependency-free, high-performance
Expand Down
Loading