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 functools import partial, wraps
25from io import StringIO
26from keyword import iskeyword
27from random import _inst as global_random_instance
28from tokenize import COMMENT, detect_encoding, generate_tokens, untokenize
29from types import ModuleType
30from typing import TYPE_CHECKING, Any, Callable, MutableMapping
31from unittest.mock import _patch as PatchType
32from weakref import WeakKeyDictionary
33
34from hypothesis.errors import HypothesisWarning
35from hypothesis.internal.compat import PYPY, is_typed_named_tuple
36from hypothesis.utils.conventions import not_set
37from hypothesis.vendor.pretty import pretty
38
39if TYPE_CHECKING:
40 from hypothesis.strategies._internal.strategies import T
41
42READTHEDOCS = os.environ.get("READTHEDOCS", None) == "True"
43LAMBDA_SOURCE_CACHE: MutableMapping[Callable, str] = WeakKeyDictionary()
44
45
46def is_mock(obj):
47 """Determine if the given argument is a mock type."""
48
49 # We want to be able to detect these when dealing with various test
50 # args. As they are sneaky and can look like almost anything else,
51 # we'll check this by looking for an attribute with a name that it's really
52 # unlikely to implement accidentally, and that anyone who implements it
53 # deliberately should know what they're doing. This is more robust than
54 # looking for types.
55 return hasattr(obj, "hypothesis_internal_is_this_a_mock_check")
56
57
58def _clean_source(src: str) -> bytes:
59 """Return the source code as bytes, without decorators or comments.
60
61 Because this is part of our database key, we reduce the cache invalidation
62 rate by ignoring decorators, comments, trailing whitespace, and empty lines.
63 We can't just use the (dumped) AST directly because it changes between Python
64 versions (e.g. ast.Constant)
65 """
66 # Get the (one-indexed) line number of the function definition, and drop preceding
67 # lines - i.e. any decorators, so that adding `@example()`s keeps the same key.
68 try:
69 funcdef = ast.parse(src).body[0]
70 if sys.version_info[:2] == (3, 8) and PYPY:
71 # We can't get a line number of the (async) def here, so as a best-effort
72 # approximation we'll use str.split instead and hope for the best.
73 tag = "async def " if isinstance(funcdef, ast.AsyncFunctionDef) else "def "
74 if tag in src:
75 src = tag + src.split(tag, maxsplit=1)[1]
76 else:
77 src = "".join(src.splitlines(keepends=True)[funcdef.lineno - 1 :])
78 except Exception:
79 pass
80 # Remove blank lines and use the tokenize module to strip out comments,
81 # so that those can be changed without changing the database key.
82 try:
83 src = untokenize(
84 t for t in generate_tokens(StringIO(src).readline) if t.type != COMMENT
85 )
86 except Exception:
87 pass
88 # Finally, remove any trailing whitespace and empty lines as a last cleanup.
89 return "\n".join(x.rstrip() for x in src.splitlines() if x.rstrip()).encode()
90
91
92def function_digest(function):
93 """Returns a string that is stable across multiple invocations across
94 multiple processes and is prone to changing significantly in response to
95 minor changes to the function.
96
97 No guarantee of uniqueness though it usually will be. Digest collisions
98 lead to unfortunate but not fatal problems during database replay.
99 """
100 hasher = hashlib.sha384()
101 try:
102 src = inspect.getsource(function)
103 except (OSError, TypeError):
104 # If we can't actually get the source code, try for the name as a fallback.
105 # NOTE: We might want to change this to always adding function.__qualname__,
106 # to differentiate f.x. two classes having the same function implementation
107 # with class-dependent behaviour.
108 try:
109 hasher.update(function.__name__.encode())
110 except AttributeError:
111 pass
112 else:
113 hasher.update(_clean_source(src))
114 try:
115 # This is additional to the source code because it can include the effects
116 # of decorators, or of post-hoc assignment to the .__signature__ attribute.
117 hasher.update(repr(get_signature(function)).encode())
118 except Exception:
119 pass
120 try:
121 # We set this in order to distinguish e.g. @pytest.mark.parametrize cases.
122 hasher.update(function._hypothesis_internal_add_digest)
123 except AttributeError:
124 pass
125 return hasher.digest()
126
127
128def check_signature(sig: inspect.Signature) -> None:
129 # Backport from Python 3.11; see https://github.com/python/cpython/pull/92065
130 for p in sig.parameters.values():
131 if iskeyword(p.name) and p.kind is not p.POSITIONAL_ONLY:
132 raise ValueError(
133 f"Signature {sig!r} contains a parameter named {p.name!r}, "
134 f"but this is a SyntaxError because `{p.name}` is a keyword. "
135 "You, or a library you use, must have manually created an "
136 "invalid signature - this will be an error in Python 3.11+"
137 )
138
139
140def get_signature(
141 target: Any, *, follow_wrapped: bool = True, eval_str: bool = False
142) -> inspect.Signature:
143 # Special case for use of `@unittest.mock.patch` decorator, mimicking the
144 # behaviour of getfullargspec instead of reporting unusable arguments.
145 patches = getattr(target, "patchings", None)
146 if isinstance(patches, list) and all(isinstance(p, PatchType) for p in patches):
147 P = inspect.Parameter
148 return inspect.Signature(
149 [P("args", P.VAR_POSITIONAL), P("keywargs", P.VAR_KEYWORD)]
150 )
151
152 if isinstance(getattr(target, "__signature__", None), inspect.Signature):
153 # This special case covers unusual codegen like Pydantic models
154 sig = target.__signature__
155 check_signature(sig)
156 # And *this* much more complicated block ignores the `self` argument
157 # if that's been (incorrectly) included in the custom signature.
158 if sig.parameters and (inspect.isclass(target) or inspect.ismethod(target)):
159 selfy = next(iter(sig.parameters.values()))
160 if (
161 selfy.name == "self"
162 and selfy.default is inspect.Parameter.empty
163 and selfy.kind.name.startswith("POSITIONAL_")
164 ):
165 return sig.replace(
166 parameters=[v for k, v in sig.parameters.items() if k != "self"]
167 )
168 return sig
169 if sys.version_info[:2] <= (3, 8) and inspect.isclass(target):
170 # Workaround for subclasses of typing.Generic on Python <= 3.8
171 from hypothesis.strategies._internal.types import is_generic_type
172
173 if is_generic_type(target):
174 sig = inspect.signature(target.__init__)
175 check_signature(sig)
176 return sig.replace(
177 parameters=[v for k, v in sig.parameters.items() if k != "self"]
178 )
179 # eval_str is only supported by Python 3.10 and newer
180 if sys.version_info[:2] >= (3, 10):
181 sig = inspect.signature(
182 target, follow_wrapped=follow_wrapped, eval_str=eval_str
183 )
184 else:
185 sig = inspect.signature(
186 target, follow_wrapped=follow_wrapped
187 ) # pragma: no cover
188 check_signature(sig)
189 return sig
190
191
192def arg_is_required(param):
193 return param.default is inspect.Parameter.empty and param.kind in (
194 inspect.Parameter.POSITIONAL_OR_KEYWORD,
195 inspect.Parameter.KEYWORD_ONLY,
196 )
197
198
199def required_args(target, args=(), kwargs=()):
200 """Return a set of names of required args to target that were not supplied
201 in args or kwargs.
202
203 This is used in builds() to determine which arguments to attempt to
204 fill from type hints. target may be any callable (including classes
205 and bound methods). args and kwargs should be as they are passed to
206 builds() - that is, a tuple of values and a dict of names: values.
207 """
208 # We start with a workaround for NamedTuples, which don't have nice inits
209 if inspect.isclass(target) and is_typed_named_tuple(target):
210 provided = set(kwargs) | set(target._fields[: len(args)])
211 return set(target._fields) - provided
212 # Then we try to do the right thing with inspect.signature
213 try:
214 sig = get_signature(target)
215 except (ValueError, TypeError):
216 return set()
217 return {
218 name
219 for name, param in list(sig.parameters.items())[len(args) :]
220 if arg_is_required(param) and name not in kwargs
221 }
222
223
224def convert_keyword_arguments(function, args, kwargs):
225 """Returns a pair of a tuple and a dictionary which would be equivalent
226 passed as positional and keyword args to the function. Unless function has
227 kwonlyargs or **kwargs the dictionary will always be empty.
228 """
229 sig = inspect.signature(function, follow_wrapped=False)
230 bound = sig.bind(*args, **kwargs)
231 return bound.args, bound.kwargs
232
233
234def convert_positional_arguments(function, args, kwargs):
235 """Return a tuple (new_args, new_kwargs) where all possible arguments have
236 been moved to kwargs.
237
238 new_args will only be non-empty if function has pos-only args or *args.
239 """
240 sig = inspect.signature(function, follow_wrapped=False)
241 bound = sig.bind(*args, **kwargs)
242 new_args = []
243 new_kwargs = dict(bound.arguments)
244 for p in sig.parameters.values():
245 if p.name in new_kwargs:
246 if p.kind is p.POSITIONAL_ONLY:
247 new_args.append(new_kwargs.pop(p.name))
248 elif p.kind is p.VAR_POSITIONAL:
249 new_args.extend(new_kwargs.pop(p.name))
250 elif p.kind is p.VAR_KEYWORD:
251 assert set(new_kwargs[p.name]).isdisjoint(set(new_kwargs) - {p.name})
252 new_kwargs.update(new_kwargs.pop(p.name))
253 return tuple(new_args), new_kwargs
254
255
256def ast_arguments_matches_signature(args, sig):
257 assert isinstance(args, ast.arguments)
258 assert isinstance(sig, inspect.Signature)
259 expected = []
260 for node in getattr(args, "posonlyargs", ()): # New in Python 3.8
261 expected.append((node.arg, inspect.Parameter.POSITIONAL_ONLY))
262 for node in args.args:
263 expected.append((node.arg, inspect.Parameter.POSITIONAL_OR_KEYWORD))
264 if args.vararg is not None:
265 expected.append((args.vararg.arg, inspect.Parameter.VAR_POSITIONAL))
266 for node in args.kwonlyargs:
267 expected.append((node.arg, inspect.Parameter.KEYWORD_ONLY))
268 if args.kwarg is not None:
269 expected.append((args.kwarg.arg, inspect.Parameter.VAR_KEYWORD))
270 return expected == [(p.name, p.kind) for p in sig.parameters.values()]
271
272
273def is_first_param_referenced_in_function(f):
274 """Is the given name referenced within f?"""
275 try:
276 tree = ast.parse(textwrap.dedent(inspect.getsource(f)))
277 except Exception:
278 return True # Assume it's OK unless we know otherwise
279 name = next(iter(get_signature(f).parameters))
280 return any(
281 isinstance(node, ast.Name)
282 and node.id == name
283 and isinstance(node.ctx, ast.Load)
284 for node in ast.walk(tree)
285 )
286
287
288def extract_all_lambdas(tree, matching_signature):
289 lambdas = []
290
291 class Visitor(ast.NodeVisitor):
292 def visit_Lambda(self, node):
293 if ast_arguments_matches_signature(node.args, matching_signature):
294 lambdas.append(node)
295
296 Visitor().visit(tree)
297
298 return lambdas
299
300
301LINE_CONTINUATION = re.compile(r"\\\n")
302WHITESPACE = re.compile(r"\s+")
303PROBABLY_A_COMMENT = re.compile("""#[^'"]*$""")
304SPACE_FOLLOWS_OPEN_BRACKET = re.compile(r"\( ")
305SPACE_PRECEDES_CLOSE_BRACKET = re.compile(r" \)")
306
307
308def _extract_lambda_source(f):
309 """Extracts a single lambda expression from the string source. Returns a
310 string indicating an unknown body if it gets confused in any way.
311
312 This is not a good function and I am sorry for it. Forgive me my
313 sins, oh lord
314 """
315 # You might be wondering how a lambda can have a return-type annotation?
316 # The answer is that we add this at runtime, in new_given_signature(),
317 # and we do support strange choices as applying @given() to a lambda.
318 sig = inspect.signature(f)
319 assert sig.return_annotation in (inspect.Parameter.empty, None), sig
320
321 # Using pytest-xdist on Python 3.13, there's an entry in the linecache for
322 # file "<string>", which then returns nonsense to getsource. Discard it.
323 linecache.cache.pop("<string>", None)
324
325 if sig.parameters:
326 if_confused = f"lambda {str(sig)[1:-1]}: <unknown>"
327 else:
328 if_confused = "lambda: <unknown>"
329 try:
330 source = inspect.getsource(f)
331 except OSError:
332 return if_confused
333
334 source = LINE_CONTINUATION.sub(" ", source)
335 source = WHITESPACE.sub(" ", source)
336 source = source.strip()
337 if "lambda" not in source and sys.platform == "emscripten": # pragma: no cover
338 return if_confused # work around Pyodide bug in inspect.getsource()
339 assert "lambda" in source, source
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):
456 if isinstance(f, partial):
457 return pretty(f)
458 if not hasattr(f, "__name__"):
459 return repr(f)
460 name = f.__name__
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):
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(f, args, kwargs, *, reorder=True):
488 # Note: for multi-line pretty-printing, see RepresentationPrinter.repr_call()
489 if reorder:
490 args, kwargs = convert_positional_arguments(f, args, kwargs)
491
492 bits = [nicerepr(x) for x in args]
493
494 for p in get_signature(f).parameters.values():
495 if p.name in kwargs and not p.kind.name.startswith("VAR_"):
496 bits.append(f"{p.name}={nicerepr(kwargs.pop(p.name))}")
497 if kwargs:
498 for a in sorted(kwargs):
499 bits.append(f"{a}={nicerepr(kwargs[a])}")
500
501 rep = nicerepr(f)
502 if rep.startswith("lambda") and ":" in rep:
503 rep = f"({rep})"
504 repr_len = len(rep) + sum(len(b) for b in bits) # approx
505 if repr_len > 30000:
506 warnings.warn(
507 "Generating overly large repr. This is an expensive operation, and with "
508 f"a length of {repr_len//1000} kB is unlikely to be useful. Use -Wignore "
509 "to ignore the warning, or -Werror to get a traceback.",
510 HypothesisWarning,
511 stacklevel=2,
512 )
513 return rep + "(" + ", ".join(bits) + ")"
514
515
516def check_valid_identifier(identifier):
517 if not identifier.isidentifier():
518 raise ValueError(f"{identifier!r} is not a valid python identifier")
519
520
521eval_cache: dict = {}
522
523
524def source_exec_as_module(source):
525 try:
526 return eval_cache[source]
527 except KeyError:
528 pass
529
530 hexdigest = hashlib.sha384(source.encode()).hexdigest()
531 result = ModuleType("hypothesis_temporary_module_" + hexdigest)
532 assert isinstance(source, str)
533 exec(source, result.__dict__)
534 eval_cache[source] = result
535 return result
536
537
538COPY_SIGNATURE_SCRIPT = """
539from hypothesis.utils.conventions import not_set
540
541def accept({funcname}):
542 def {name}{signature}:
543 return {funcname}({invocation})
544 return {name}
545""".lstrip()
546
547
548def get_varargs(sig, kind=inspect.Parameter.VAR_POSITIONAL):
549 for p in sig.parameters.values():
550 if p.kind is kind:
551 return p
552 return None
553
554
555def define_function_signature(name, docstring, signature):
556 """A decorator which sets the name, signature and docstring of the function
557 passed into it."""
558 if name == "<lambda>":
559 name = "_lambda_"
560 check_valid_identifier(name)
561 for a in signature.parameters:
562 check_valid_identifier(a)
563
564 used_names = {*signature.parameters, name}
565
566 newsig = signature.replace(
567 parameters=[
568 p if p.default is signature.empty else p.replace(default=not_set)
569 for p in (
570 p.replace(annotation=signature.empty)
571 for p in signature.parameters.values()
572 )
573 ],
574 return_annotation=signature.empty,
575 )
576
577 pos_args = [
578 p
579 for p in signature.parameters.values()
580 if p.kind.name.startswith("POSITIONAL_")
581 ]
582
583 def accept(f):
584 fsig = inspect.signature(f, follow_wrapped=False)
585 must_pass_as_kwargs = []
586 invocation_parts = []
587 for p in pos_args:
588 if p.name not in fsig.parameters and get_varargs(fsig) is None:
589 must_pass_as_kwargs.append(p.name)
590 else:
591 invocation_parts.append(p.name)
592 if get_varargs(signature) is not None:
593 invocation_parts.append("*" + get_varargs(signature).name)
594 for k in must_pass_as_kwargs:
595 invocation_parts.append(f"{k}={k}")
596 for p in signature.parameters.values():
597 if p.kind is p.KEYWORD_ONLY:
598 invocation_parts.append(f"{p.name}={p.name}")
599 varkw = get_varargs(signature, kind=inspect.Parameter.VAR_KEYWORD)
600 if varkw:
601 invocation_parts.append("**" + varkw.name)
602
603 candidate_names = ["f"] + [f"f_{i}" for i in range(1, len(used_names) + 2)]
604
605 for funcname in candidate_names: # pragma: no branch
606 if funcname not in used_names:
607 break
608
609 source = COPY_SIGNATURE_SCRIPT.format(
610 name=name,
611 funcname=funcname,
612 signature=str(newsig),
613 invocation=", ".join(invocation_parts),
614 )
615 result = source_exec_as_module(source).accept(f)
616 result.__doc__ = docstring
617 result.__defaults__ = tuple(
618 p.default
619 for p in signature.parameters.values()
620 if p.default is not signature.empty and "POSITIONAL" in p.kind.name
621 )
622 kwdefaults = {
623 p.name: p.default
624 for p in signature.parameters.values()
625 if p.default is not signature.empty and p.kind is p.KEYWORD_ONLY
626 }
627 if kwdefaults:
628 result.__kwdefaults__ = kwdefaults
629 annotations = {
630 p.name: p.annotation
631 for p in signature.parameters.values()
632 if p.annotation is not signature.empty
633 }
634 if signature.return_annotation is not signature.empty:
635 annotations["return"] = signature.return_annotation
636 if annotations:
637 result.__annotations__ = annotations
638 return result
639
640 return accept
641
642
643def impersonate(target):
644 """Decorator to update the attributes of a function so that to external
645 introspectors it will appear to be the target function.
646
647 Note that this updates the function in place, it doesn't return a
648 new one.
649 """
650
651 def accept(f):
652 # Lie shamelessly about where this code comes from, to hide the hypothesis
653 # internals from pytest, ipython, and other runtime introspection.
654 f.__code__ = f.__code__.replace(
655 co_filename=target.__code__.co_filename,
656 co_firstlineno=target.__code__.co_firstlineno,
657 )
658 f.__name__ = target.__name__
659 f.__module__ = target.__module__
660 f.__doc__ = target.__doc__
661 f.__globals__["__hypothesistracebackhide__"] = True
662 return f
663
664 return accept
665
666
667def proxies(target: "T") -> Callable[[Callable], "T"]:
668 replace_sig = define_function_signature(
669 target.__name__.replace("<lambda>", "_lambda_"), # type: ignore
670 target.__doc__,
671 get_signature(target, follow_wrapped=False),
672 )
673
674 def accept(proxy):
675 return impersonate(target)(wraps(target)(replace_sig(proxy)))
676
677 return accept
678
679
680def is_identity_function(f):
681 # TODO: pattern-match the AST to handle `def ...` identity functions too
682 return bool(re.fullmatch(r"lambda (\w+): \1", get_pretty_function_description(f)))