diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index cf5c9b4b739f..9e213330a637 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -50,7 +50,7 @@ import sys import threading import time -from typing import TYPE_CHECKING, cast, overload +from typing import IO, TYPE_CHECKING, cast, overload from cycler import cycler # noqa: F401 import matplotlib @@ -338,8 +338,8 @@ def uninstall_repl_displayhook() -> None: # Ensure this appears in the pyplot docs. @_copy_docstring_and_deprecators(matplotlib.set_loglevel) -def set_loglevel(*args, **kwargs) -> None: - return matplotlib.set_loglevel(*args, **kwargs) +def set_loglevel(level: str) -> None: + return matplotlib.set_loglevel(level) @_copy_docstring_and_deprecators(Artist.findobj) @@ -569,6 +569,14 @@ def draw_if_interactive(*args, **kwargs): return _get_backend_mod().draw_if_interactive(*args, **kwargs) +@overload +def show() -> None: ... + + +@overload +def show(block: bool) -> None: ... + + # This function's signature is rewritten upon backend-load by switch_backend. def show(*args, **kwargs) -> None: """ @@ -1251,11 +1259,11 @@ def draw() -> None: @_copy_docstring_and_deprecators(Figure.savefig) -def savefig(*args, **kwargs) -> None: +def savefig(fname: str | os.PathLike | IO, **kwargs) -> None: fig = gcf() # savefig default implementation has no return, so mypy is unhappy # presumably this is here because subclasses can return? - res = fig.savefig(*args, **kwargs) # type: ignore[func-returns-value] + res = fig.savefig(fname, **kwargs) # type: ignore[func-returns-value] fig.canvas.draw_idle() # Need this if 'transparent=True', to reset colors. return res @@ -1393,6 +1401,22 @@ def cla() -> None: ## More ways of creating Axes ## +@overload +def subplot(nrows: int, ncols: int, index: int, /, **kwargs): ... + + +@overload +def subplot(pos: int | SubplotSpec, /, **kwargs): ... + + +@overload +def subplot(ax: Axes, /): ... + + +@overload +def subplot(**kwargs): ... + + @_docstring.interpd def subplot(*args, **kwargs) -> Axes: """ @@ -2096,80 +2120,6 @@ def box(on: bool | None = None) -> None: ## Axis ## -def xlim(*args, **kwargs) -> tuple[float, float]: - """ - Get or set the x limits of the current Axes. - - Call signatures:: - - left, right = xlim() # return the current xlim - xlim((left, right)) # set the xlim to left, right - xlim(left, right) # set the xlim to left, right - - If you do not specify args, you can pass *left* or *right* as kwargs, - i.e.:: - - xlim(right=3) # adjust the right leaving left unchanged - xlim(left=1) # adjust the left leaving right unchanged - - Setting limits turns autoscaling off for the x-axis. - - Returns - ------- - left, right - A tuple of the new x-axis limits. - - Notes - ----- - Calling this function with no arguments (e.g. ``xlim()``) is the pyplot - equivalent of calling `~.Axes.get_xlim` on the current Axes. - Calling this function with arguments is the pyplot equivalent of calling - `~.Axes.set_xlim` on the current Axes. All arguments are passed though. - """ - ax = gca() - if not args and not kwargs: - return ax.get_xlim() - ret = ax.set_xlim(*args, **kwargs) - return ret - - -def ylim(*args, **kwargs) -> tuple[float, float]: - """ - Get or set the y-limits of the current Axes. - - Call signatures:: - - bottom, top = ylim() # return the current ylim - ylim((bottom, top)) # set the ylim to bottom, top - ylim(bottom, top) # set the ylim to bottom, top - - If you do not specify args, you can alternatively pass *bottom* or - *top* as kwargs, i.e.:: - - ylim(top=3) # adjust the top leaving bottom unchanged - ylim(bottom=1) # adjust the bottom leaving top unchanged - - Setting limits turns autoscaling off for the y-axis. - - Returns - ------- - bottom, top - A tuple of the new y-axis limits. - - Notes - ----- - Calling this function with no arguments (e.g. ``ylim()``) is the pyplot - equivalent of calling `~.Axes.get_ylim` on the current Axes. - Calling this function with arguments is the pyplot equivalent of calling - `~.Axes.set_ylim` on the current Axes. All arguments are passed though. - """ - ax = gca() - if not args and not kwargs: - return ax.get_ylim() - ret = ax.set_ylim(*args, **kwargs) - return ret - - def xticks( ticks: ArrayLike | None = None, labels: Sequence[str] | None = None, @@ -2690,7 +2640,13 @@ def matshow(A: ArrayLike, fignum: None | int = None, **kwargs) -> AxesImage: return im -def polar(*args, **kwargs) -> list[Line2D]: +def polar( + *args: float | ArrayLike | str, + scalex: bool = True, + scaley: bool = True, + data=None, + **kwargs +) -> list[Line2D]: """ Make a polar plot. @@ -2724,7 +2680,13 @@ def polar(*args, **kwargs) -> list[Line2D]: ) else: ax = axes(projection="polar") - return ax.plot(*args, **kwargs) + return ax.plot( + *args, + scalex=scalex, + scaley=scaley, + data=data, + **kwargs + ) # If rcParams['backend_fallback'] is true, and an interactive backend is @@ -4457,6 +4419,72 @@ def yscale(value: str | ScaleBase, **kwargs) -> None: gca().set_yscale(value, **kwargs) +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +@overload +@_copy_docstring_and_deprecators(Axes.get_xlim) +def xlim() -> tuple[float, float]: + ... + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +@overload +@_copy_docstring_and_deprecators(Axes.set_xlim) +def xlim( + left: float | tuple[float, float] | None = None, + right: float | None = None, + *, + emit: bool = True, + auto: bool | None = False, + xmin: float | None = None, + xmax: float | None = None, +) -> tuple[float, float]: + ... + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +@_copy_docstring_and_deprecators(Axes.get_xlim) +def xlim(*args, **kwargs): + ax = gca() + if not args and not kwargs: + return ax.get_xlim() + + ret = ax.set_xlim(*args, **kwargs) + return ret + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +@overload +@_copy_docstring_and_deprecators(Axes.get_ylim) +def ylim() -> tuple[float, float]: + ... + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +@overload +@_copy_docstring_and_deprecators(Axes.set_ylim) +def ylim( + bottom: float | tuple[float, float] | None = None, + top: float | None = None, + *, + emit: bool = True, + auto: bool | None = False, + ymin: float | None = None, + ymax: float | None = None, +) -> tuple[float, float]: + ... + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +@_copy_docstring_and_deprecators(Axes.get_ylim) +def ylim(*args, **kwargs): + ax = gca() + if not args and not kwargs: + return ax.get_ylim() + + ret = ax.set_ylim(*args, **kwargs) + return ret + + # Autogenerated by boilerplate.py. Do not edit as changes will be lost. def autumn() -> None: """ diff --git a/lib/matplotlib/tests/test_pyplot.py b/lib/matplotlib/tests/test_pyplot.py index ab713707bace..5254ed65a845 100644 --- a/lib/matplotlib/tests/test_pyplot.py +++ b/lib/matplotlib/tests/test_pyplot.py @@ -1,4 +1,6 @@ +import ast import difflib +import inspect import numpy as np import sys @@ -449,7 +451,6 @@ def figure_hook_example(figure): def test_figure_hook(): - test_rc = { 'figure.hooks': ['matplotlib.tests.test_pyplot:figure_hook_example'] } @@ -484,3 +485,75 @@ def test_matshow(): # Smoke test that matshow does not ask for a new figsize on the existing figure plt.matshow(arr, fignum=fig.number) + + +def assert_signatures_identical(plt_meth, original_meth, remove_self_param=False): + def get_src(meth): + meth_src = Path(inspect.getfile(meth)) + meth_stub = meth_src.with_suffix(".pyi") + return meth_stub if meth_stub.exists() else meth_src + + def tree_loop(tree, name, class_): + for item in tree.body: + if class_ and isinstance(item, ast.ClassDef) and item.name == class_: + return tree_loop(item, name, None) + + if isinstance(item, ast.FunctionDef) and item.name == name: + return item + + raise ValueError(f"Cannot find {class_}.{name} in ast") + + def get_signature(meth): + qualname = meth.__qualname__ + class_ = None if "." not in qualname else qualname.split(".")[-2] + path = get_src(meth) + tree = ast.parse(path.read_text()) + node = tree_loop(tree, meth.__name__, class_) + + params = dict(inspect.signature(meth).parameters) + args = node.args + allargs = ( + *args.posonlyargs, + *args.args, + args.vararg, + *args.kwonlyargs, + args.kwarg + ) + for param in allargs: + if param is None: + continue + if param.annotation is None: + continue + annotation = ast.unparse(param.annotation) + params[param.arg] = params[param.arg].replace(annotation=annotation) + + if node.returns is not None: + return inspect.Signature( + params.values(), + return_annotation=ast.unparse(node.returns) + ) + else: + return inspect.Signature(params.values()) + + plt_sig = get_signature(plt_meth) + original_sig = get_signature(original_meth) + + assert plt_sig.return_annotation == original_sig.return_annotation + + original_params = original_sig.parameters + if remove_self_param: + if next(iter(original_params)) not in ["self"]: + raise ValueError(f"{original_sig} is not an instance method") + + original_params = original_params.copy() + del original_params["self"] + + assert plt_sig.parameters == original_params + + +def test_setloglevel_signature(): + assert_signatures_identical(plt.set_loglevel, mpl.set_loglevel) + + +def test_polar_signature(): + assert_signatures_identical(plt.polar, plt.Axes.plot, True) diff --git a/tools/boilerplate.py b/tools/boilerplate.py index 11ec15ac1c44..778729abe12d 100644 --- a/tools/boilerplate.py +++ b/tools/boilerplate.py @@ -54,6 +54,25 @@ def {name}{signature}: {return_statement}gca().{called_name}{call} """ +AXES_GETTER_SETTER_TEMPLATE = AUTOGEN_MSG + """ +@overload +@_copy_docstring_and_deprecators(Axes.get_{called_name}) +def {name}() -> {get_return_type}: ... +""" + AUTOGEN_MSG + """ +@overload +@_copy_docstring_and_deprecators(Axes.set_{called_name}) +def {name}{signature}: ... +""" + AUTOGEN_MSG + """ +@_copy_docstring_and_deprecators(Axes.get_{called_name}) +def {name}(*args, **kwargs): + ax = gca() + if not args and not kwargs: + return ax.get_{called_name}() + + ret = ax.set_{called_name}(*args, **kwargs) + return ret +""" + FIGURE_METHOD_TEMPLATE = AUTOGEN_MSG + """ @_copy_docstring_and_deprecators(Figure.{called_name}) def {name}{signature}: @@ -102,6 +121,7 @@ class direct_repr: """ A placeholder class to destringify annotations from ast """ + def __init__(self, value): self._repr = value @@ -109,7 +129,13 @@ def __repr__(self): return self._repr -def generate_function(name, called_fullname, template, **kwargs): +def generate_function( + name, + called_fullname, + template, + gettersetter=False, + **kwargs +): """ Create a wrapper function *pyplot_name* calling *call_name*. @@ -127,6 +153,11 @@ def generate_function(name, called_fullname, template, **kwargs): - signature: The function signature (including parentheses). - called_name: The name of the called function. - call: Parameters passed to *called_name* (including parentheses). + gettersetter : bool + Indicate if the method to be wrapped is correponding to a getter and setter. A new placeholdr is filled : + + - get_return_type: The type returned by the getter + - set_return_type: The type returned by the setter **kwargs Additional parameters are passed to ``template.format()``. @@ -135,15 +166,14 @@ def generate_function(name, called_fullname, template, **kwargs): class_name, called_name = called_fullname.split('.') class_ = {'Axes': Axes, 'Figure': Figure}[class_name] - meth = getattr(class_, called_name) - decorator = _api.deprecation.DECORATORS.get(meth) - # Generate the wrapper with the non-kwonly signature, as it will get - # redecorated with make_keyword_only by _copy_docstring_and_deprecators. - if decorator and decorator.func is _api.make_keyword_only: - meth = meth.__wrapped__ + if not gettersetter: + signature = get_signature(class_, called_name) + else: + getter_signature = get_signature(class_, f"get_{called_name}") + kwargs.setdefault("get_return_type", str(getter_signature.return_annotation)) - annotated_trees = get_ast_mro_trees(class_) - signature = get_matching_signature(meth, annotated_trees) + signature = get_signature(class_, f"set_{called_name}") + kwargs.setdefault('return_type', str(signature.return_annotation)) # Replace self argument. params = list(signature.parameters.values())[1:] @@ -152,30 +182,32 @@ def generate_function(name, called_fullname, template, **kwargs): param.replace(default=value_formatter(param.default)) if param.default is not param.empty else param for param in params])) + # How to call the wrapped function. - call = '(' + ', '.join(( - # Pass "intended-as-positional" parameters positionally to avoid - # forcing third-party subclasses to reproduce the parameter names. - '{0}' - if param.kind in [ - Parameter.POSITIONAL_OR_KEYWORD] - and param.default is Parameter.empty else - # Only pass the data kwarg if it is actually set, to avoid forcing - # third-party subclasses to support it. - '**({{"data": data}} if data is not None else {{}})' - if param.name == "data" else - '{0}={0}' - if param.kind in [ - Parameter.POSITIONAL_OR_KEYWORD, - Parameter.KEYWORD_ONLY] else - '{0}' - if param.kind is Parameter.POSITIONAL_ONLY else - '*{0}' - if param.kind is Parameter.VAR_POSITIONAL else - '**{0}' - if param.kind is Parameter.VAR_KEYWORD else - None).format(param.name) - for param in params) + ')' + + def call_param(param: Parameter): + match param.kind: + # Pass "intended-as-positional" parameters positionally to avoid + # forcing third-party subclasses to reproduce the parameter names. + case Parameter.POSITIONAL_OR_KEYWORD if param.default is Parameter.empty: + return '{0}' + # Only pass the data kwarg if it is actually set, to avoid forcing + # third-party subclasses to support it. + case _ if param.name == "data": + return '**({{"data": data}} if data is not None else {{}})' + case Parameter.POSITIONAL_OR_KEYWORD | Parameter.KEYWORD_ONLY: + return '{0}={0}' + case Parameter.POSITIONAL_ONLY: + return '{0}' + case Parameter.VAR_POSITIONAL: + return '*{0}' + case Parameter.VAR_KEYWORD: + return '**{0}' + return None + + call = '(' + ', '.join( + (call_param(param)).format(param.name) for param in params + ) + ')' return_statement = 'return ' if has_return_value else '' # Bail out in case of name collision. for reserved in ('gca', 'gci', 'gcf', '__ret'): @@ -286,7 +318,12 @@ def boilerplate_gen(): 'xlabel:set_xlabel', 'ylabel:set_ylabel', 'xscale:set_xscale', - 'yscale:set_yscale', + 'yscale:set_yscale' + ) + + _axes_getter_setters = ( + 'xlim', + 'ylim', ) cmappable = { @@ -341,6 +378,14 @@ def boilerplate_gen(): yield generate_function(name, f'Axes.{called_name}', template, sci_command=cmappable.get(name)) + for spec in _axes_getter_setters: + if ':' in spec: + name, called_name = spec.split(':') + else: + name = called_name = spec + yield generate_function(name, f'Axes.{called_name}', + AXES_GETTER_SETTER_TEMPLATE, True) + cmaps = ( 'autumn', 'bone', @@ -405,6 +450,19 @@ def get_ast_mro_trees(cls): return [get_ast_tree(c) for c in cls.__mro__ if c.__module__ != "builtins"] +def get_signature(class_, name): + meth = getattr(class_, name) + + decorator = _api.deprecation.DECORATORS.get(meth) + # Generate the wrapper with the non-kwonly signature, as it will get + # redecorated with make_keyword_only by _copy_docstring_and_deprecators. + if decorator and decorator.func is _api.make_keyword_only: + meth = meth.__wrapped__ + + annotated_trees = get_ast_mro_trees(class_) + return get_matching_signature(meth, annotated_trees) + + def get_matching_signature(method, trees): sig = inspect.signature(method) for tree in trees: @@ -460,10 +518,10 @@ def update_sig_from_node(node, sig): if len(sys.argv) > 1: pyplot_path = Path(sys.argv[1]) else: - mpl_path = (Path(__file__).parent / ".." /"lib"/"matplotlib").resolve() + mpl_path = (Path(__file__).parent / ".." / "lib" / "matplotlib").resolve() pyplot_path = mpl_path / "pyplot.py" for cls in [Axes, Figure]: - if mpl_path not in Path(inspect.getfile(cls)).parents: + if mpl_path not in Path(inspect.getfile(cls)).parents: raise RuntimeError( f"{cls.__name__} import path is not {mpl_path}.\n" "Please make sure your Matplotlib installation "