_decorators.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. from __future__ import annotations
  2. from functools import wraps
  3. import inspect
  4. from textwrap import dedent
  5. from typing import (
  6. Any,
  7. Callable,
  8. Mapping,
  9. cast,
  10. )
  11. import warnings
  12. from pandas._libs.properties import cache_readonly
  13. from pandas._typing import (
  14. F,
  15. T,
  16. )
  17. from pandas.util._exceptions import find_stack_level
  18. def deprecate(
  19. name: str,
  20. alternative: Callable[..., Any],
  21. version: str,
  22. alt_name: str | None = None,
  23. klass: type[Warning] | None = None,
  24. stacklevel: int = 2,
  25. msg: str | None = None,
  26. ) -> Callable[[F], F]:
  27. """
  28. Return a new function that emits a deprecation warning on use.
  29. To use this method for a deprecated function, another function
  30. `alternative` with the same signature must exist. The deprecated
  31. function will emit a deprecation warning, and in the docstring
  32. it will contain the deprecation directive with the provided version
  33. so it can be detected for future removal.
  34. Parameters
  35. ----------
  36. name : str
  37. Name of function to deprecate.
  38. alternative : func
  39. Function to use instead.
  40. version : str
  41. Version of pandas in which the method has been deprecated.
  42. alt_name : str, optional
  43. Name to use in preference of alternative.__name__.
  44. klass : Warning, default FutureWarning
  45. stacklevel : int, default 2
  46. msg : str
  47. The message to display in the warning.
  48. Default is '{name} is deprecated. Use {alt_name} instead.'
  49. """
  50. alt_name = alt_name or alternative.__name__
  51. klass = klass or FutureWarning
  52. warning_msg = msg or f"{name} is deprecated, use {alt_name} instead."
  53. @wraps(alternative)
  54. def wrapper(*args, **kwargs) -> Callable[..., Any]:
  55. warnings.warn(warning_msg, klass, stacklevel=stacklevel)
  56. return alternative(*args, **kwargs)
  57. # adding deprecated directive to the docstring
  58. msg = msg or f"Use `{alt_name}` instead."
  59. doc_error_msg = (
  60. "deprecate needs a correctly formatted docstring in "
  61. "the target function (should have a one liner short "
  62. "summary, and opening quotes should be in their own "
  63. f"line). Found:\n{alternative.__doc__}"
  64. )
  65. # when python is running in optimized mode (i.e. `-OO`), docstrings are
  66. # removed, so we check that a docstring with correct formatting is used
  67. # but we allow empty docstrings
  68. if alternative.__doc__:
  69. if alternative.__doc__.count("\n") < 3:
  70. raise AssertionError(doc_error_msg)
  71. empty1, summary, empty2, doc_string = alternative.__doc__.split("\n", 3)
  72. if empty1 or empty2 and not summary:
  73. raise AssertionError(doc_error_msg)
  74. wrapper.__doc__ = dedent(
  75. f"""
  76. {summary.strip()}
  77. .. deprecated:: {version}
  78. {msg}
  79. {dedent(doc_string)}"""
  80. )
  81. # error: Incompatible return value type (got "Callable[[VarArg(Any), KwArg(Any)],
  82. # Callable[...,Any]]", expected "Callable[[F], F]")
  83. return wrapper # type: ignore[return-value]
  84. def deprecate_kwarg(
  85. old_arg_name: str,
  86. new_arg_name: str | None,
  87. mapping: Mapping[Any, Any] | Callable[[Any], Any] | None = None,
  88. stacklevel: int = 2,
  89. ) -> Callable[[F], F]:
  90. """
  91. Decorator to deprecate a keyword argument of a function.
  92. Parameters
  93. ----------
  94. old_arg_name : str
  95. Name of argument in function to deprecate
  96. new_arg_name : str or None
  97. Name of preferred argument in function. Use None to raise warning that
  98. ``old_arg_name`` keyword is deprecated.
  99. mapping : dict or callable
  100. If mapping is present, use it to translate old arguments to
  101. new arguments. A callable must do its own value checking;
  102. values not found in a dict will be forwarded unchanged.
  103. Examples
  104. --------
  105. The following deprecates 'cols', using 'columns' instead
  106. >>> @deprecate_kwarg(old_arg_name='cols', new_arg_name='columns')
  107. ... def f(columns=''):
  108. ... print(columns)
  109. ...
  110. >>> f(columns='should work ok')
  111. should work ok
  112. >>> f(cols='should raise warning') # doctest: +SKIP
  113. FutureWarning: cols is deprecated, use columns instead
  114. warnings.warn(msg, FutureWarning)
  115. should raise warning
  116. >>> f(cols='should error', columns="can\'t pass do both") # doctest: +SKIP
  117. TypeError: Can only specify 'cols' or 'columns', not both
  118. >>> @deprecate_kwarg('old', 'new', {'yes': True, 'no': False})
  119. ... def f(new=False):
  120. ... print('yes!' if new else 'no!')
  121. ...
  122. >>> f(old='yes') # doctest: +SKIP
  123. FutureWarning: old='yes' is deprecated, use new=True instead
  124. warnings.warn(msg, FutureWarning)
  125. yes!
  126. To raise a warning that a keyword will be removed entirely in the future
  127. >>> @deprecate_kwarg(old_arg_name='cols', new_arg_name=None)
  128. ... def f(cols='', another_param=''):
  129. ... print(cols)
  130. ...
  131. >>> f(cols='should raise warning') # doctest: +SKIP
  132. FutureWarning: the 'cols' keyword is deprecated and will be removed in a
  133. future version please takes steps to stop use of 'cols'
  134. should raise warning
  135. >>> f(another_param='should not raise warning') # doctest: +SKIP
  136. should not raise warning
  137. >>> f(cols='should raise warning', another_param='') # doctest: +SKIP
  138. FutureWarning: the 'cols' keyword is deprecated and will be removed in a
  139. future version please takes steps to stop use of 'cols'
  140. should raise warning
  141. """
  142. if mapping is not None and not hasattr(mapping, "get") and not callable(mapping):
  143. raise TypeError(
  144. "mapping from old to new argument values must be dict or callable!"
  145. )
  146. def _deprecate_kwarg(func: F) -> F:
  147. @wraps(func)
  148. def wrapper(*args, **kwargs) -> Callable[..., Any]:
  149. old_arg_value = kwargs.pop(old_arg_name, None)
  150. if old_arg_value is not None:
  151. if new_arg_name is None:
  152. msg = (
  153. f"the {repr(old_arg_name)} keyword is deprecated and "
  154. "will be removed in a future version. Please take "
  155. f"steps to stop the use of {repr(old_arg_name)}"
  156. )
  157. warnings.warn(msg, FutureWarning, stacklevel=stacklevel)
  158. kwargs[old_arg_name] = old_arg_value
  159. return func(*args, **kwargs)
  160. elif mapping is not None:
  161. if callable(mapping):
  162. new_arg_value = mapping(old_arg_value)
  163. else:
  164. new_arg_value = mapping.get(old_arg_value, old_arg_value)
  165. msg = (
  166. f"the {old_arg_name}={repr(old_arg_value)} keyword is "
  167. "deprecated, use "
  168. f"{new_arg_name}={repr(new_arg_value)} instead."
  169. )
  170. else:
  171. new_arg_value = old_arg_value
  172. msg = (
  173. f"the {repr(old_arg_name)} keyword is deprecated, "
  174. f"use {repr(new_arg_name)} instead."
  175. )
  176. warnings.warn(msg, FutureWarning, stacklevel=stacklevel)
  177. if kwargs.get(new_arg_name) is not None:
  178. msg = (
  179. f"Can only specify {repr(old_arg_name)} "
  180. f"or {repr(new_arg_name)}, not both."
  181. )
  182. raise TypeError(msg)
  183. kwargs[new_arg_name] = new_arg_value
  184. return func(*args, **kwargs)
  185. return cast(F, wrapper)
  186. return _deprecate_kwarg
  187. def _format_argument_list(allow_args: list[str]) -> str:
  188. """
  189. Convert the allow_args argument (either string or integer) of
  190. `deprecate_nonkeyword_arguments` function to a string describing
  191. it to be inserted into warning message.
  192. Parameters
  193. ----------
  194. allowed_args : list, tuple or int
  195. The `allowed_args` argument for `deprecate_nonkeyword_arguments`,
  196. but None value is not allowed.
  197. Returns
  198. -------
  199. str
  200. The substring describing the argument list in best way to be
  201. inserted to the warning message.
  202. Examples
  203. --------
  204. `format_argument_list([])` -> ''
  205. `format_argument_list(['a'])` -> "except for the arguments 'a'"
  206. `format_argument_list(['a', 'b'])` -> "except for the arguments 'a' and 'b'"
  207. `format_argument_list(['a', 'b', 'c'])` ->
  208. "except for the arguments 'a', 'b' and 'c'"
  209. """
  210. if "self" in allow_args:
  211. allow_args.remove("self")
  212. if not allow_args:
  213. return ""
  214. elif len(allow_args) == 1:
  215. return f" except for the argument '{allow_args[0]}'"
  216. else:
  217. last = allow_args[-1]
  218. args = ", ".join(["'" + x + "'" for x in allow_args[:-1]])
  219. return f" except for the arguments {args} and '{last}'"
  220. def future_version_msg(version: str | None) -> str:
  221. """Specify which version of pandas the deprecation will take place in."""
  222. if version is None:
  223. return "In a future version of pandas"
  224. else:
  225. return f"Starting with pandas version {version}"
  226. def deprecate_nonkeyword_arguments(
  227. version: str | None,
  228. allowed_args: list[str] | None = None,
  229. name: str | None = None,
  230. ) -> Callable[[F], F]:
  231. """
  232. Decorator to deprecate a use of non-keyword arguments of a function.
  233. Parameters
  234. ----------
  235. version : str, optional
  236. The version in which positional arguments will become
  237. keyword-only. If None, then the warning message won't
  238. specify any particular version.
  239. allowed_args : list, optional
  240. In case of list, it must be the list of names of some
  241. first arguments of the decorated functions that are
  242. OK to be given as positional arguments. In case of None value,
  243. defaults to list of all arguments not having the
  244. default value.
  245. name : str, optional
  246. The specific name of the function to show in the warning
  247. message. If None, then the Qualified name of the function
  248. is used.
  249. """
  250. def decorate(func):
  251. old_sig = inspect.signature(func)
  252. if allowed_args is not None:
  253. allow_args = allowed_args
  254. else:
  255. allow_args = [
  256. p.name
  257. for p in old_sig.parameters.values()
  258. if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
  259. and p.default is p.empty
  260. ]
  261. new_params = [
  262. p.replace(kind=p.KEYWORD_ONLY)
  263. if (
  264. p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
  265. and p.name not in allow_args
  266. )
  267. else p
  268. for p in old_sig.parameters.values()
  269. ]
  270. new_params.sort(key=lambda p: p.kind)
  271. new_sig = old_sig.replace(parameters=new_params)
  272. num_allow_args = len(allow_args)
  273. msg = (
  274. f"{future_version_msg(version)} all arguments of "
  275. f"{name or func.__qualname__}{{arguments}} will be keyword-only."
  276. )
  277. @wraps(func)
  278. def wrapper(*args, **kwargs):
  279. if len(args) > num_allow_args:
  280. warnings.warn(
  281. msg.format(arguments=_format_argument_list(allow_args)),
  282. FutureWarning,
  283. stacklevel=find_stack_level(),
  284. )
  285. return func(*args, **kwargs)
  286. # error: "Callable[[VarArg(Any), KwArg(Any)], Any]" has no
  287. # attribute "__signature__"
  288. wrapper.__signature__ = new_sig # type: ignore[attr-defined]
  289. return wrapper
  290. return decorate
  291. def doc(*docstrings: None | str | Callable, **params) -> Callable[[F], F]:
  292. """
  293. A decorator take docstring templates, concatenate them and perform string
  294. substitution on it.
  295. This decorator will add a variable "_docstring_components" to the wrapped
  296. callable to keep track the original docstring template for potential usage.
  297. If it should be consider as a template, it will be saved as a string.
  298. Otherwise, it will be saved as callable, and later user __doc__ and dedent
  299. to get docstring.
  300. Parameters
  301. ----------
  302. *docstrings : None, str, or callable
  303. The string / docstring / docstring template to be appended in order
  304. after default docstring under callable.
  305. **params
  306. The string which would be used to format docstring template.
  307. """
  308. def decorator(decorated: F) -> F:
  309. # collecting docstring and docstring templates
  310. docstring_components: list[str | Callable] = []
  311. if decorated.__doc__:
  312. docstring_components.append(dedent(decorated.__doc__))
  313. for docstring in docstrings:
  314. if docstring is None:
  315. continue
  316. if hasattr(docstring, "_docstring_components"):
  317. docstring_components.extend(
  318. docstring._docstring_components # pyright: ignore[reportGeneralTypeIssues] # noqa: E501
  319. )
  320. elif isinstance(docstring, str) or docstring.__doc__:
  321. docstring_components.append(docstring)
  322. params_applied = [
  323. component.format(**params)
  324. if isinstance(component, str) and len(params) > 0
  325. else component
  326. for component in docstring_components
  327. ]
  328. decorated.__doc__ = "".join(
  329. [
  330. component
  331. if isinstance(component, str)
  332. else dedent(component.__doc__ or "")
  333. for component in params_applied
  334. ]
  335. )
  336. # error: "F" has no attribute "_docstring_components"
  337. decorated._docstring_components = ( # type: ignore[attr-defined]
  338. docstring_components
  339. )
  340. return decorated
  341. return decorator
  342. # Substitution and Appender are derived from matplotlib.docstring (1.1.0)
  343. # module https://matplotlib.org/users/license.html
  344. class Substitution:
  345. """
  346. A decorator to take a function's docstring and perform string
  347. substitution on it.
  348. This decorator should be robust even if func.__doc__ is None
  349. (for example, if -OO was passed to the interpreter)
  350. Usage: construct a docstring.Substitution with a sequence or
  351. dictionary suitable for performing substitution; then
  352. decorate a suitable function with the constructed object. e.g.
  353. sub_author_name = Substitution(author='Jason')
  354. @sub_author_name
  355. def some_function(x):
  356. "%(author)s wrote this function"
  357. # note that some_function.__doc__ is now "Jason wrote this function"
  358. One can also use positional arguments.
  359. sub_first_last_names = Substitution('Edgar Allen', 'Poe')
  360. @sub_first_last_names
  361. def some_function(x):
  362. "%s %s wrote the Raven"
  363. """
  364. def __init__(self, *args, **kwargs) -> None:
  365. if args and kwargs:
  366. raise AssertionError("Only positional or keyword args are allowed")
  367. self.params = args or kwargs
  368. def __call__(self, func: F) -> F:
  369. func.__doc__ = func.__doc__ and func.__doc__ % self.params
  370. return func
  371. def update(self, *args, **kwargs) -> None:
  372. """
  373. Update self.params with supplied args.
  374. """
  375. if isinstance(self.params, dict):
  376. self.params.update(*args, **kwargs)
  377. class Appender:
  378. """
  379. A function decorator that will append an addendum to the docstring
  380. of the target function.
  381. This decorator should be robust even if func.__doc__ is None
  382. (for example, if -OO was passed to the interpreter).
  383. Usage: construct a docstring.Appender with a string to be joined to
  384. the original docstring. An optional 'join' parameter may be supplied
  385. which will be used to join the docstring and addendum. e.g.
  386. add_copyright = Appender("Copyright (c) 2009", join='\n')
  387. @add_copyright
  388. def my_dog(has='fleas'):
  389. "This docstring will have a copyright below"
  390. pass
  391. """
  392. addendum: str | None
  393. def __init__(self, addendum: str | None, join: str = "", indents: int = 0) -> None:
  394. if indents > 0:
  395. self.addendum = indent(addendum, indents=indents)
  396. else:
  397. self.addendum = addendum
  398. self.join = join
  399. def __call__(self, func: T) -> T:
  400. func.__doc__ = func.__doc__ if func.__doc__ else ""
  401. self.addendum = self.addendum if self.addendum else ""
  402. docitems = [func.__doc__, self.addendum]
  403. func.__doc__ = dedent(self.join.join(docitems))
  404. return func
  405. def indent(text: str | None, indents: int = 1) -> str:
  406. if not text or not isinstance(text, str):
  407. return ""
  408. jointext = "".join(["\n"] + [" "] * indents)
  409. return jointext.join(text.split("\n"))
  410. __all__ = [
  411. "Appender",
  412. "cache_readonly",
  413. "deprecate",
  414. "deprecate_kwarg",
  415. "deprecate_nonkeyword_arguments",
  416. "doc",
  417. "future_version_msg",
  418. "Substitution",
  419. ]