1# This file is part of Hypothesis, which may be found at
2# https://github.com/HypothesisWorks/hypothesis/
3#
4# Copyright the Hypothesis Authors.
5# Individual contributors are listed in AUTHORS.rst and the git log.
6#
7# This Source Code Form is subject to the terms of the Mozilla Public License,
8# v. 2.0. If a copy of the MPL was not distributed with this file, You can
9# obtain one at https://mozilla.org/MPL/2.0/.
10
11"""This file can approximately be considered the collection of hypothesis going
12to really unreasonable lengths to produce pretty output."""
13
14import ast
15import hashlib
16import inspect
17import linecache
18import os
19import re
20import sys
21import textwrap
22import types
23import warnings
24from collections.abc import MutableMapping, Sequence
25from functools import partial, wraps
26from inspect import Parameter, Signature
27from io import StringIO
28from keyword import iskeyword
29from random import _inst as global_random_instance
30from tokenize import COMMENT, detect_encoding, generate_tokens, untokenize
31from types import ModuleType
32from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union
33from unittest.mock import _patch as PatchType
34from weakref import WeakKeyDictionary
35
36from hypothesis.errors import HypothesisWarning
37from hypothesis.internal.compat import EllipsisType, is_typed_named_tuple
38from hypothesis.utils.conventions import not_set
39from hypothesis.vendor.pretty import pretty
40
41if TYPE_CHECKING:
42 from hypothesis.strategies._internal.strategies import SearchStrategy
43
44T = TypeVar("T")
45
46READTHEDOCS = os.environ.get("READTHEDOCS", None) == "True"
47LAMBDA_SOURCE_CACHE: MutableMapping[Callable, str] = WeakKeyDictionary()
48
49
50def is_mock(obj: object) -> bool:
51 """Determine if the given argument is a mock type."""
52
53 # We want to be able to detect these when dealing with various test
54 # args. As they are sneaky and can look like almost anything else,
55 # we'll check this by looking for an attribute with a name that it's really
56 # unlikely to implement accidentally, and that anyone who implements it
57 # deliberately should know what they're doing. This is more robust than
58 # looking for types.
59 return hasattr(obj, "hypothesis_internal_is_this_a_mock_check")
60
61
62def _clean_source(src: str) -> bytes:
63 """Return the source code as bytes, without decorators or comments.
64
65 Because this is part of our database key, we reduce the cache invalidation
66 rate by ignoring decorators, comments, trailing whitespace, and empty lines.
67 We can't just use the (dumped) AST directly because it changes between Python
68 versions (e.g. ast.Constant)
69 """
70 # Get the (one-indexed) line number of the function definition, and drop preceding
71 # lines - i.e. any decorators, so that adding `@example()`s keeps the same key.
72 try:
73 funcdef = ast.parse(src).body[0]
74 src = "".join(src.splitlines(keepends=True)[funcdef.lineno - 1 :])
75 except Exception:
76 pass
77 # Remove blank lines and use the tokenize module to strip out comments,
78 # so that those can be changed without changing the database key.
79 try:
80 src = untokenize(
81 t for t in generate_tokens(StringIO(src).readline) if t.type != COMMENT
82 )
83 except Exception:
84 pass
85 # Finally, remove any trailing whitespace and empty lines as a last cleanup.
86 return "\n".join(x.rstrip() for x in src.splitlines() if x.rstrip()).encode()
87
88
89def function_digest(function: Any) -> bytes:
90 """Returns a string that is stable across multiple invocations across
91 multiple processes and is prone to changing significantly in response to
92 minor changes to the function.
93
94 No guarantee of uniqueness though it usually will be. Digest collisions
95 lead to unfortunate but not fatal problems during database replay.
96 """
97 hasher = hashlib.sha384()
98 try:
99 src = inspect.getsource(function)
100 except (OSError, TypeError):
101 # If we can't actually get the source code, try for the name as a fallback.
102 # NOTE: We might want to change this to always adding function.__qualname__,
103 # to differentiate f.x. two classes having the same function implementation
104 # with class-dependent behaviour.
105 try:
106 hasher.update(function.__name__.encode())
107 except AttributeError:
108 pass
109 else:
110 hasher.update(_clean_source(src))
111 try:
112 # This is additional to the source code because it can include the effects
113 # of decorators, or of post-hoc assignment to the .__signature__ attribute.
114 hasher.update(repr(get_signature(function)).encode())
115 except Exception:
116 pass
117 try:
118 # We set this in order to distinguish e.g. @pytest.mark.parametrize cases.
119 hasher.update(function._hypothesis_internal_add_digest)
120 except AttributeError:
121 pass
122 return hasher.digest()
123
124
125def check_signature(sig: Signature) -> None:
126 # Backport from Python 3.11; see https://github.com/python/cpython/pull/92065
127 for p in sig.parameters.values():
128 if iskeyword(p.name) and p.kind is not p.POSITIONAL_ONLY:
129 raise ValueError(
130 f"Signature {sig!r} contains a parameter named {p.name!r}, "
131 f"but this is a SyntaxError because `{p.name}` is a keyword. "
132 "You, or a library you use, must have manually created an "
133 "invalid signature - this will be an error in Python 3.11+"
134 )
135
136
137def get_signature(
138 target: Any, *, follow_wrapped: bool = True, eval_str: bool = False
139) -> Signature:
140 # Special case for use of `@unittest.mock.patch` decorator, mimicking the
141 # behaviour of getfullargspec instead of reporting unusable arguments.
142 patches = getattr(target, "patchings", None)
143 if isinstance(patches, list) and all(isinstance(p, PatchType) for p in patches):
144 return Signature(
145 [
146 Parameter("args", Parameter.VAR_POSITIONAL),
147 Parameter("keywargs", Parameter.VAR_KEYWORD),
148 ]
149 )
150
151 if isinstance(getattr(target, "__signature__", None), Signature):
152 # This special case covers unusual codegen like Pydantic models
153 sig = target.__signature__
154 check_signature(sig)
155 # And *this* much more complicated block ignores the `self` argument
156 # if that's been (incorrectly) included in the custom signature.
157 if sig.parameters and (inspect.isclass(target) or inspect.ismethod(target)):
158 selfy = next(iter(sig.parameters.values()))
159 if (
160 selfy.name == "self"
161 and selfy.default is Parameter.empty
162 and selfy.kind.name.startswith("POSITIONAL_")
163 ):
164 return sig.replace(
165 parameters=[v for k, v in sig.parameters.items() if k != "self"]
166 )
167 return sig
168 # eval_str is only supported by Python 3.10 and newer
169 if sys.version_info[:2] >= (3, 10):
170 sig = inspect.signature(
171 target, follow_wrapped=follow_wrapped, eval_str=eval_str
172 )
173 else:
174 sig = inspect.signature(
175 target, follow_wrapped=follow_wrapped
176 ) # pragma: no cover
177 check_signature(sig)
178 return sig
179
180
181def arg_is_required(param: Parameter) -> bool:
182 return param.default is Parameter.empty and param.kind in (
183 Parameter.POSITIONAL_OR_KEYWORD,
184 Parameter.KEYWORD_ONLY,
185 )
186
187
188def required_args(
189 target: Callable[..., Any],
190 args: tuple["SearchStrategy[Any]", ...] = (),
191 kwargs: Optional[dict[str, Union["SearchStrategy[Any]", EllipsisType]]] = None,
192) -> set[str]:
193 """Return a set of names of required args to target that were not supplied
194 in args or kwargs.
195
196 This is used in builds() to determine which arguments to attempt to
197 fill from type hints. target may be any callable (including classes
198 and bound methods). args and kwargs should be as they are passed to
199 builds() - that is, a tuple of values and a dict of names: values.
200 """
201 kwargs = {} if kwargs is None else kwargs
202 # We start with a workaround for NamedTuples, which don't have nice inits
203 if inspect.isclass(target) and is_typed_named_tuple(target):
204 provided = set(kwargs) | set(target._fields[: len(args)])
205 return set(target._fields) - provided
206 # Then we try to do the right thing with inspect.signature
207 try:
208 sig = get_signature(target)
209 except (ValueError, TypeError):
210 return set()
211 return {
212 name
213 for name, param in list(sig.parameters.items())[len(args) :]
214 if arg_is_required(param) and name not in kwargs
215 }
216
217
218def convert_keyword_arguments(
219 function: Any, args: Sequence[object], kwargs: dict[str, object]
220) -> tuple[tuple[object, ...], dict[str, object]]:
221 """Returns a pair of a tuple and a dictionary which would be equivalent
222 passed as positional and keyword args to the function. Unless function has
223 kwonlyargs or **kwargs the dictionary will always be empty.
224 """
225 sig = inspect.signature(function, follow_wrapped=False)
226 bound = sig.bind(*args, **kwargs)
227 return bound.args, bound.kwargs
228
229
230def convert_positional_arguments(
231 function: Any, args: Sequence[object], kwargs: dict[str, object]
232) -> tuple[tuple[object, ...], dict[str, object]]:
233 """Return a tuple (new_args, new_kwargs) where all possible arguments have
234 been moved to kwargs.
235
236 new_args will only be non-empty if function has pos-only args or *args.
237 """
238 sig = inspect.signature(function, follow_wrapped=False)
239 bound = sig.bind(*args, **kwargs)
240 new_args = []
241 new_kwargs = dict(bound.arguments)
242 for p in sig.parameters.values():
243 if p.name in new_kwargs:
244 if p.kind is p.POSITIONAL_ONLY:
245 new_args.append(new_kwargs.pop(p.name))
246 elif p.kind is p.VAR_POSITIONAL:
247 new_args.extend(new_kwargs.pop(p.name))
248 elif p.kind is p.VAR_KEYWORD:
249 assert set(new_kwargs[p.name]).isdisjoint(set(new_kwargs) - {p.name})
250 new_kwargs.update(new_kwargs.pop(p.name))
251 return tuple(new_args), new_kwargs
252
253
254def ast_arguments_matches_signature(args: ast.arguments, sig: Signature) -> bool:
255 expected: list[tuple[str, int]] = []
256 for node in args.posonlyargs:
257 expected.append((node.arg, Parameter.POSITIONAL_ONLY))
258 for node in args.args:
259 expected.append((node.arg, Parameter.POSITIONAL_OR_KEYWORD))
260 if args.vararg is not None:
261 expected.append((args.vararg.arg, Parameter.VAR_POSITIONAL))
262 for node in args.kwonlyargs:
263 expected.append((node.arg, Parameter.KEYWORD_ONLY))
264 if args.kwarg is not None:
265 expected.append((args.kwarg.arg, Parameter.VAR_KEYWORD))
266 return expected == [(p.name, p.kind) for p in sig.parameters.values()]
267
268
269def is_first_param_referenced_in_function(f: Any) -> bool:
270 """Is the given name referenced within f?"""
271 try:
272 tree = ast.parse(textwrap.dedent(inspect.getsource(f)))
273 except Exception:
274 return True # Assume it's OK unless we know otherwise
275 name = next(iter(get_signature(f).parameters))
276 return any(
277 isinstance(node, ast.Name)
278 and node.id == name
279 and isinstance(node.ctx, ast.Load)
280 for node in ast.walk(tree)
281 )
282
283
284def extract_all_lambdas(tree, matching_signature):
285 lambdas = []
286
287 class Visitor(ast.NodeVisitor):
288 def visit_Lambda(self, node):
289 if ast_arguments_matches_signature(node.args, matching_signature):
290 lambdas.append(node)
291
292 Visitor().visit(tree)
293
294 return lambdas
295
296
297LINE_CONTINUATION = re.compile(r"\\\n")
298WHITESPACE = re.compile(r"\s+")
299PROBABLY_A_COMMENT = re.compile("""#[^'"]*$""")
300SPACE_FOLLOWS_OPEN_BRACKET = re.compile(r"\( ")
301SPACE_PRECEDES_CLOSE_BRACKET = re.compile(r" \)")
302
303
304def _extract_lambda_source(f):
305 """Extracts a single lambda expression from the string source. Returns a
306 string indicating an unknown body if it gets confused in any way.
307
308 This is not a good function and I am sorry for it. Forgive me my
309 sins, oh lord
310 """
311 # You might be wondering how a lambda can have a return-type annotation?
312 # The answer is that we add this at runtime, in new_given_signature(),
313 # and we do support strange choices as applying @given() to a lambda.
314 sig = inspect.signature(f)
315 assert sig.return_annotation in (Parameter.empty, None), sig
316
317 # Using pytest-xdist on Python 3.13, there's an entry in the linecache for
318 # file "<string>", which then returns nonsense to getsource. Discard it.
319 linecache.cache.pop("<string>", None)
320
321 if sig.parameters:
322 if_confused = f"lambda {str(sig)[1:-1]}: <unknown>"
323 else:
324 if_confused = "lambda: <unknown>"
325 try:
326 source = inspect.getsource(f)
327 except OSError:
328 return if_confused
329
330 source = LINE_CONTINUATION.sub(" ", source)
331 source = WHITESPACE.sub(" ", source)
332 source = source.strip()
333 if "lambda" not in source: # pragma: no cover
334 # If a user starts a hypothesis process, then edits their code, the lines
335 # in the parsed source code might not match the live __code__ objects.
336 #
337 # (and on sys.platform == "emscripten", this can happen regardless
338 # due to a pyodide bug in inspect.getsource()).
339 return if_confused
340
341 tree = None
342
343 try:
344 tree = ast.parse(source)
345 except SyntaxError:
346 for i in range(len(source) - 1, len("lambda"), -1):
347 prefix = source[:i]
348 if "lambda" not in prefix:
349 break
350 try:
351 tree = ast.parse(prefix)
352 source = prefix
353 break
354 except SyntaxError:
355 continue
356 if tree is None and source.startswith(("@", ".")):
357 # This will always eventually find a valid expression because the
358 # decorator or chained operator must be a valid Python function call,
359 # so will eventually be syntactically valid and break out of the loop.
360 # Thus, this loop can never terminate normally.
361 for i in range(len(source) + 1):
362 p = source[1:i]
363 if "lambda" in p:
364 try:
365 tree = ast.parse(p)
366 source = p
367 break
368 except SyntaxError:
369 pass
370 else:
371 raise NotImplementedError("expected to be unreachable")
372
373 if tree is None:
374 return if_confused
375
376 aligned_lambdas = extract_all_lambdas(tree, matching_signature=sig)
377 if len(aligned_lambdas) != 1:
378 return if_confused
379 lambda_ast = aligned_lambdas[0]
380 assert lambda_ast.lineno == 1
381
382 # If the source code contains Unicode characters, the bytes of the original
383 # file don't line up with the string indexes, and `col_offset` doesn't match
384 # the string we're using. We need to convert the source code into bytes
385 # before slicing.
386 #
387 # Under the hood, the inspect module is using `tokenize.detect_encoding` to
388 # detect the encoding of the original source file. We'll use the same
389 # approach to get the source code as bytes.
390 #
391 # See https://github.com/HypothesisWorks/hypothesis/issues/1700 for an
392 # example of what happens if you don't correct for this.
393 #
394 # Note: if the code doesn't come from a file (but, for example, a doctest),
395 # `getsourcefile` will return `None` and the `open()` call will fail with
396 # an OSError. Or if `f` is a built-in function, in which case we get a
397 # TypeError. In both cases, fall back to splitting the Unicode string.
398 # It's not perfect, but it's the best we can do.
399 try:
400 with open(inspect.getsourcefile(f), "rb") as src_f:
401 encoding, _ = detect_encoding(src_f.readline)
402
403 source_bytes = source.encode(encoding)
404 source_bytes = source_bytes[lambda_ast.col_offset :].strip()
405 source = source_bytes.decode(encoding)
406 except (OSError, TypeError):
407 source = source[lambda_ast.col_offset :].strip()
408
409 # This ValueError can be thrown in Python 3 if:
410 #
411 # - There's a Unicode character in the line before the Lambda, and
412 # - For some reason we can't detect the source encoding of the file
413 #
414 # because slicing on `lambda_ast.col_offset` will account for bytes, but
415 # the slice will be on Unicode characters.
416 #
417 # In practice this seems relatively rare, so we just give up rather than
418 # trying to recover.
419 try:
420 source = source[source.index("lambda") :]
421 except ValueError:
422 return if_confused
423
424 for i in range(len(source), len("lambda"), -1): # pragma: no branch
425 try:
426 parsed = ast.parse(source[:i])
427 assert len(parsed.body) == 1
428 assert parsed.body
429 if isinstance(parsed.body[0].value, ast.Lambda):
430 source = source[:i]
431 break
432 except SyntaxError:
433 pass
434 lines = source.split("\n")
435 lines = [PROBABLY_A_COMMENT.sub("", l) for l in lines]
436 source = "\n".join(lines)
437
438 source = WHITESPACE.sub(" ", source)
439 source = SPACE_FOLLOWS_OPEN_BRACKET.sub("(", source)
440 source = SPACE_PRECEDES_CLOSE_BRACKET.sub(")", source)
441 return source.strip()
442
443
444def extract_lambda_source(f):
445 try:
446 return LAMBDA_SOURCE_CACHE[f]
447 except KeyError:
448 pass
449
450 source = _extract_lambda_source(f)
451 LAMBDA_SOURCE_CACHE[f] = source
452 return source
453
454
455def get_pretty_function_description(f: object) -> str:
456 if isinstance(f, partial):
457 return pretty(f)
458 if not hasattr(f, "__name__"):
459 return repr(f)
460 name = f.__name__ # type: ignore
461 if name == "<lambda>":
462 return extract_lambda_source(f)
463 elif isinstance(f, (types.MethodType, types.BuiltinMethodType)):
464 self = f.__self__
465 # Some objects, like `builtins.abs` are of BuiltinMethodType but have
466 # their module as __self__. This might include c-extensions generally?
467 if not (self is None or inspect.isclass(self) or inspect.ismodule(self)):
468 if self is global_random_instance:
469 return f"random.{name}"
470 return f"{self!r}.{name}"
471 elif isinstance(name, str) and getattr(dict, name, object()) is f:
472 # special case for keys/values views in from_type() / ghostwriter output
473 return f"dict.{name}"
474 return name
475
476
477def nicerepr(v: Any) -> str:
478 if inspect.isfunction(v):
479 return get_pretty_function_description(v)
480 elif isinstance(v, type):
481 return v.__name__
482 else:
483 # With TypeVar T, show List[T] instead of TypeError on List[~T]
484 return re.sub(r"(\[)~([A-Z][a-z]*\])", r"\g<1>\g<2>", pretty(v))
485
486
487def repr_call(
488 f: Any, args: Sequence[object], kwargs: dict[str, object], *, reorder: bool = True
489) -> str:
490 # Note: for multi-line pretty-printing, see RepresentationPrinter.repr_call()
491 if reorder:
492 args, kwargs = convert_positional_arguments(f, args, kwargs)
493
494 bits = [nicerepr(x) for x in args]
495
496 for p in get_signature(f).parameters.values():
497 if p.name in kwargs and not p.kind.name.startswith("VAR_"):
498 bits.append(f"{p.name}={nicerepr(kwargs.pop(p.name))}")
499 if kwargs:
500 for a in sorted(kwargs):
501 bits.append(f"{a}={nicerepr(kwargs[a])}")
502
503 rep = nicerepr(f)
504 if rep.startswith("lambda") and ":" in rep:
505 rep = f"({rep})"
506 repr_len = len(rep) + sum(len(b) for b in bits) # approx
507 if repr_len > 30000:
508 warnings.warn(
509 "Generating overly large repr. This is an expensive operation, and with "
510 f"a length of {repr_len//1000} kB is unlikely to be useful. Use -Wignore "
511 "to ignore the warning, or -Werror to get a traceback.",
512 HypothesisWarning,
513 stacklevel=2,
514 )
515 return rep + "(" + ", ".join(bits) + ")"
516
517
518def check_valid_identifier(identifier: str) -> None:
519 if not identifier.isidentifier():
520 raise ValueError(f"{identifier!r} is not a valid python identifier")
521
522
523eval_cache: dict[str, ModuleType] = {}
524
525
526def source_exec_as_module(source: str) -> ModuleType:
527 try:
528 return eval_cache[source]
529 except KeyError:
530 pass
531
532 hexdigest = hashlib.sha384(source.encode()).hexdigest()
533 result = ModuleType("hypothesis_temporary_module_" + hexdigest)
534 assert isinstance(source, str)
535 exec(source, result.__dict__)
536 eval_cache[source] = result
537 return result
538
539
540COPY_SIGNATURE_SCRIPT = """
541from hypothesis.utils.conventions import not_set
542
543def accept({funcname}):
544 def {name}{signature}:
545 return {funcname}({invocation})
546 return {name}
547""".lstrip()
548
549
550def get_varargs(
551 sig: Signature, kind: int = Parameter.VAR_POSITIONAL
552) -> Optional[Parameter]:
553 for p in sig.parameters.values():
554 if p.kind is kind:
555 return p
556 return None
557
558
559def define_function_signature(name, docstring, signature):
560 """A decorator which sets the name, signature and docstring of the function
561 passed into it."""
562 if name == "<lambda>":
563 name = "_lambda_"
564 check_valid_identifier(name)
565 for a in signature.parameters:
566 check_valid_identifier(a)
567
568 used_names = {*signature.parameters, name}
569
570 newsig = signature.replace(
571 parameters=[
572 p if p.default is signature.empty else p.replace(default=not_set)
573 for p in (
574 p.replace(annotation=signature.empty)
575 for p in signature.parameters.values()
576 )
577 ],
578 return_annotation=signature.empty,
579 )
580
581 pos_args = [
582 p
583 for p in signature.parameters.values()
584 if p.kind.name.startswith("POSITIONAL_")
585 ]
586
587 def accept(f):
588 fsig = inspect.signature(f, follow_wrapped=False)
589 must_pass_as_kwargs = []
590 invocation_parts = []
591 for p in pos_args:
592 if p.name not in fsig.parameters and get_varargs(fsig) is None:
593 must_pass_as_kwargs.append(p.name)
594 else:
595 invocation_parts.append(p.name)
596 if get_varargs(signature) is not None:
597 invocation_parts.append("*" + get_varargs(signature).name)
598 for k in must_pass_as_kwargs:
599 invocation_parts.append(f"{k}={k}")
600 for p in signature.parameters.values():
601 if p.kind is p.KEYWORD_ONLY:
602 invocation_parts.append(f"{p.name}={p.name}")
603 varkw = get_varargs(signature, kind=Parameter.VAR_KEYWORD)
604 if varkw:
605 invocation_parts.append("**" + varkw.name)
606
607 candidate_names = ["f"] + [f"f_{i}" for i in range(1, len(used_names) + 2)]
608
609 for funcname in candidate_names: # pragma: no branch
610 if funcname not in used_names:
611 break
612
613 source = COPY_SIGNATURE_SCRIPT.format(
614 name=name,
615 funcname=funcname,
616 signature=str(newsig),
617 invocation=", ".join(invocation_parts),
618 )
619 result = source_exec_as_module(source).accept(f)
620 result.__doc__ = docstring
621 result.__defaults__ = tuple(
622 p.default
623 for p in signature.parameters.values()
624 if p.default is not signature.empty and "POSITIONAL" in p.kind.name
625 )
626 kwdefaults = {
627 p.name: p.default
628 for p in signature.parameters.values()
629 if p.default is not signature.empty and p.kind is p.KEYWORD_ONLY
630 }
631 if kwdefaults:
632 result.__kwdefaults__ = kwdefaults
633 annotations = {
634 p.name: p.annotation
635 for p in signature.parameters.values()
636 if p.annotation is not signature.empty
637 }
638 if signature.return_annotation is not signature.empty:
639 annotations["return"] = signature.return_annotation
640 if annotations:
641 result.__annotations__ = annotations
642 return result
643
644 return accept
645
646
647def impersonate(target):
648 """Decorator to update the attributes of a function so that to external
649 introspectors it will appear to be the target function.
650
651 Note that this updates the function in place, it doesn't return a
652 new one.
653 """
654
655 def accept(f):
656 # Lie shamelessly about where this code comes from, to hide the hypothesis
657 # internals from pytest, ipython, and other runtime introspection.
658 f.__code__ = f.__code__.replace(
659 co_filename=target.__code__.co_filename,
660 co_firstlineno=target.__code__.co_firstlineno,
661 )
662 f.__name__ = target.__name__
663 f.__module__ = target.__module__
664 f.__doc__ = target.__doc__
665 f.__globals__["__hypothesistracebackhide__"] = True
666 return f
667
668 return accept
669
670
671def proxies(target: T) -> Callable[[Callable], T]:
672 replace_sig = define_function_signature(
673 target.__name__.replace("<lambda>", "_lambda_"), # type: ignore
674 target.__doc__,
675 get_signature(target, follow_wrapped=False),
676 )
677
678 def accept(proxy):
679 return impersonate(target)(wraps(target)(replace_sig(proxy)))
680
681 return accept
682
683
684def is_identity_function(f: object) -> bool:
685 # TODO: pattern-match the AST to handle `def ...` identity functions too
686 return bool(re.fullmatch(r"lambda (\w+): \1", get_pretty_function_description(f)))