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 "
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: