diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 07dd881..d6fbca6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15.0-alpha.1"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/tests_and_coverage.yml b/.github/workflows/tests_and_coverage.yml index 46737ad..e6346d7 100644 --- a/.github/workflows/tests_and_coverage.yml +++ b/.github/workflows/tests_and_coverage.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: os: [macos-latest, ubuntu-latest, windows-latest] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15.0-alpha.1"] steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 03d532a..b794981 100644 --- a/README.md +++ b/README.md @@ -17,26 +17,35 @@ ![logo](https://raw.githubusercontent.com/mutating/getsources/develop/docs/assets/logo_1.svg) +This library lets you retrieve a function's source code at runtime. It can serve as a foundation for tools that work with [ASTs](https://en.wikipedia.org/wiki/Abstract_syntax_tree). It is a thin wrapper around [`inspect.getsource`](https://docs.python.org/3/library/inspect.html#inspect.getsource) and [`dill.source.getsource`](https://dill.readthedocs.io/en/latest/dill.html#dill.source.getsource). -This library is needed to obtain the source code of functions at runtime. It can be used, for example, as a basis for libraries that work with [AST](https://en.wikipedia.org/wiki/Abstract_syntax_tree) on the fly. In fact, it is a thin layer built around [`inspect.getsource`](https://docs.python.org/3/library/inspect.html#inspect.getsource) and [`dill.source.getsource`](https://dill.readthedocs.io/en/latest/dill.html#dill.source.getsource). + +## Table of contents + +- [**Installation**](#installation) +- [**Get raw source**](#get-raw-source) +- [**Get cleaned source**](#get-cleaned-source) +- [**Generate source hashes**](#generate-source-hashes) ## Installation -You can install [`getsources`](https://pypi.python.org/pypi/getsources) using pip: +You can install [`getsources`](https://pypi.python.org/pypi/getsources) with pip: ```bash pip install getsources ``` +You can also use [`instld`](https://github.com/pomponchik/instld) to quickly try this package and others without installing them. -You can also quickly try out this and other packages without having to install using [instld](https://github.com/pomponchik/instld). +## Get raw source -## Usage +The standard library provides the [`getsource`](https://docs.python.org/3/library/inspect.html#inspect.getsource) function that returns the source code of functions and other objects. However, this does not work with functions defined in the [`REPL`](https://docs.python.org/3/tutorial/interpreter.html#interactive-mode). -The basic function of the library is `getsource`, which works similarly to the function of the same name from the standard library: +This library provides a function with the same name and nearly the same interface, but without this limitation: ```python +# You can run this code snippet in the REPL. from getsources import getsource def function(): @@ -47,12 +56,37 @@ print(getsource(function)) #> ... ``` -Unlike its counterpart from the standard library, this thing can also work: +This allows AST-based tools to work reliably in both scripts and the `REPL`. All other functions in the library are built on top of it. + +> ⚠️ Please note that this library is intended solely for retrieving the source code of functions of any kind, including generators, async functions, regular functions, class methods, lambdas, and so on. It is not intended for classes, modules, or other objects. Other use cases may work, but they are not covered by the test suite. + -- With lambda functions -- With functions defined inside REPL +## Get cleaned source -We also often need to trim excess indentation from a function object to make it easier to further process the resulting code. To do this, use the `getclearsource` function: +The [`getsource`](#get-raw-source) function returns a function's source code in raw form. This means that the code snippet captures some unnecessary surrounding code. + +Here is an example where the standard `getsource` output includes extra leading whitespace: + +```python +if True: + def function(): + ... + +print(getsource(function)) +#> def function(): +#> ... +``` + +> ↑ Notice the extra leading spaces. + +For lambda functions, it may also return the entire surrounding expression: + +```python +print(getsource(lambda x: x)) +#> print(getsource(lambda x: x)) +``` + +To address these issues, the library provides a function called `getclearsource`, which returns the function's source with unnecessary context removed: ```python from getsources import getclearsource @@ -66,6 +100,86 @@ print(getclearsource(SomeClass.method)) #> @staticmethod #> def method(): #> ... +print(getclearsource(lambda x: x)) +#> lambda x: x +``` + +To extract only the substring containing a lambda function, the library uses AST parsing behind the scenes. Unfortunately, this [does not allow](https://stackoverflow.com/a/55386046/14522393) it to distinguish between multiple lambda functions defined in a single line, so in this case you will get an exception: + +```python +lambdas = [lambda: None, lambda x: x] + +getclearsource(lambdas[0]) +#> ... +#> getsources.errors.UncertaintyWithLambdasError: Several lambda functions are defined in a single line of code, can't determine which one. +``` + +If you absolutely must obtain at least some source code for these lambdas, use [`getsource`](#get-raw-source): + +```python +try: + getclearsource(function) +except UncertaintyWithLambdasError: + getsource(function) +``` + +However, in general, the `getclearsource` function is recommended for retrieving the source code of functions when working with the AST. + + +## Generate source hashes + +In some cases, you may not care about a function's exact source, but you still need to distinguish between different implementations. In this case, the `getsourcehash` function is useful. It returns a short hash string derived from the function's source code: + +```python +from getsources import getsourcehash + +def function(): + ... + +print(getsourcehash(function)) +#> 7SWJGZ ``` -As you can see, the resulting source code text has no extra indentation, but in all other respects this function is completely identical to the usual `getsource`. +> ⓘ A hash string uses only characters from the [`Crockford Base32`](https://en.wikipedia.org/wiki/Base32) alphabet, which consists solely of uppercase English letters and digits; ambiguous characters are excluded, which makes the hash easier to read. + +> ⓘ The `getsourcehash` function is built on top of [`getclearsource`](#get-cleaned-source) and ignores "extra" characters in the source code. + +By default, the hash string length is 6 characters, but you can choose a length from 4 to 8 characters: + +```python +print(getsourcehash(function, size=4)) +#> WJGZ +print(getsourcehash(function, size=8)) +#> XG7SWJGZ +``` + +By default, the full source code of a function is used, including its name and arguments. If you only want to compare function bodies, pass `only_body=True`: + +```python +def function_1(): + ... + +def function_2(a=5): + ... + +print(getsourcehash(function_1, only_body=True)) +#> V587A6 +print(getsourcehash(function_2, only_body=True)) +#> V587A6 +``` + +By default, docstrings are considered part of the function body. If you want to skip them as well, pass `skip_docstring=True`: + +```python +def function_1(): + """some text""" + ... + +def function_2(a=5): + ... + +print(getsourcehash(function_1, only_body=True, skip_docstring=True)) +#> V587A6 +print(getsourcehash(function_2, only_body=True, skip_docstring=True)) +#> V587A6 +``` diff --git a/getsources/__init__.py b/getsources/__init__.py index ca36d06..92866d7 100644 --- a/getsources/__init__.py +++ b/getsources/__init__.py @@ -1,2 +1,4 @@ from getsources.base import getsource as getsource from getsources.clear import getclearsource as getclearsource +from getsources.errors import UncertaintyWithLambdasError as UncertaintyWithLambdasError +from getsources.hash import getsourcehash as getsourcehash diff --git a/getsources/clear.py b/getsources/clear.py index 4911f82..aa055f4 100644 --- a/getsources/clear.py +++ b/getsources/clear.py @@ -1,11 +1,33 @@ +from ast import Lambda, get_source_segment, parse, walk from typing import Any, Callable from getsources import getsource +from getsources.errors import UncertaintyWithLambdasError +from getsources.helpers.is_lambda import is_lambda def getclearsource(function: Callable[..., Any]) -> str: source_code = getsource(function) + if is_lambda(function): + stripped_source_code = source_code.strip() + tree = parse(stripped_source_code) + + first = True + lambda_node = None + for node in walk(tree): + if isinstance(node, Lambda): + if not first: + raise UncertaintyWithLambdasError('Several lambda functions are defined in a single line of code, can\'t determine which one.') + lambda_node = node + first = False + + segment_source = get_source_segment(stripped_source_code, lambda_node) # type: ignore[arg-type] + if segment_source is None: + raise UncertaintyWithLambdasError('It seems that the AST for the lambda function has been modified; can\'t extract the source code.') + return segment_source + + splitted_source_code = source_code.split('\n') indent = 0 diff --git a/getsources/errors.py b/getsources/errors.py new file mode 100644 index 0000000..1776b7b --- /dev/null +++ b/getsources/errors.py @@ -0,0 +1,2 @@ +class UncertaintyWithLambdasError(Exception): + ... diff --git a/getsources/hash.py b/getsources/hash.py new file mode 100644 index 0000000..5571fab --- /dev/null +++ b/getsources/hash.py @@ -0,0 +1,53 @@ +import hashlib +from ast import Constant, Expr, Lambda, get_source_segment, parse, walk +from typing import Any, Callable + +from getsources import getclearsource +from getsources.helpers.is_lambda import is_lambda + +ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' + + +def get_body_text(function: Callable[..., Any], source: str, skip_docstring: bool) -> str: + tree = parse(source) + + if is_lambda(function): + body_nodes = [] + + for node in walk(tree): + if isinstance(node, Lambda): + body_nodes.append(node.body) + + else: + function_node = tree.body[0] + body_nodes = function_node.body # type: ignore[attr-defined] + first = body_nodes[0] + + if skip_docstring and body_nodes and (isinstance(first, Expr) and isinstance(first.value, Constant) and isinstance(first.value.value, str)): + body_nodes = body_nodes[1:] + + return '\n'.join([get_source_segment(source, statement) for statement in body_nodes]) # type: ignore[misc] + + +def getsourcehash(function: Callable[..., Any], size: int = 6, only_body: bool = False, skip_docstring: bool = False) -> str: + if not 4 <= size <= 8: + raise ValueError('The hash string size must be in range 4..8.') + if skip_docstring and not only_body: + raise ValueError('You can omit the docstring only if the `only_body=True` option is set.') + + source_code = getclearsource(function) + if not only_body: + interesting_part = source_code + else: + interesting_part = get_body_text(function, source_code, skip_docstring=skip_docstring) + + digest = hashlib.sha256(interesting_part.encode('utf-8')).digest() + number = int.from_bytes(digest, 'big') + base = len(ALPHABET) + + chars = [] + for _ in range(size): + number, rem = divmod(number, base) + chars.append(ALPHABET[rem]) + + return ''.join(reversed(chars)) diff --git a/getsources/helpers/__init__.py b/getsources/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/getsources/helpers/is_lambda.py b/getsources/helpers/is_lambda.py new file mode 100644 index 0000000..4f05a74 --- /dev/null +++ b/getsources/helpers/is_lambda.py @@ -0,0 +1,6 @@ +from types import FunctionType +from typing import Any, Callable + + +def is_lambda(function: Callable[..., Any]) -> bool: + return isinstance(function, FunctionType) and function.__name__ == "" diff --git a/pyproject.toml b/pyproject.toml index 286ed89..3f4b563 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "getsources" -version = "0.0.2" +version = "0.0.3" authors = [ { name="Evgeniy Blinov", email="zheni-b@yandex.ru" }, ] @@ -28,6 +28,7 @@ classifiers = [ 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3.14', + 'Programming Language :: Python :: 3.15', 'Programming Language :: Python :: Free Threading', 'Programming Language :: Python :: Free Threading :: 3 - Stable', 'License :: OSI Approved :: MIT License', diff --git a/requirements_dev.txt b/requirements_dev.txt index 0da7190..648f1c2 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -6,6 +6,6 @@ mypy==1.14.1 ruff==0.14.6 pytest-mypy-testing==0.1.3 full_match==0.0.3 -transfunctions==0.0.9 +transfunctions==0.0.10 pexpect==4.9.0; sys_platform == "linux" or sys_platform == "darwin" setuptools==75.3.4 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..09bf3c3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,14 @@ +import pytest +from transfunctions import transfunction + + +@pytest.fixture(params=['async', 'sync', 'generator']) +def transformed(request): + def transformator_function(function): + if request.param == 'sync': + return function + if request.param == 'async': + return transfunction(function, check_decorators=False).get_async_function() + if request.param == 'generator': + return transfunction(function, check_decorators=False).get_generator_function() + return transformator_function diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/helpers/test_is_lambda.py b/tests/helpers/test_is_lambda.py new file mode 100644 index 0000000..63e0df4 --- /dev/null +++ b/tests/helpers/test_is_lambda.py @@ -0,0 +1,15 @@ +from getsources.helpers.is_lambda import is_lambda + + +def test_lambdas_are_lambdas(): + assert is_lambda(lambda x: x) + assert is_lambda(lambda x: None) # noqa: ARG005 + assert is_lambda(lambda: None) + + +def test_are_not_lambdas(transformed): + @transformed + def function(): + ... + + assert not is_lambda(function) diff --git a/tests/test_base.py b/tests/test_base.py index cc48b3b..64d0bd9 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -19,6 +19,17 @@ def function_2(a, b): assert getsource(function_2).splitlines() == [' def function_2(a, b):', ' pass'] +def test_async_functions(): + async def function_1(): + pass + + async def function_2(a, b): + pass + + assert getsource(function_1).splitlines() == [' async def function_1():', ' pass'] + assert getsource(function_2).splitlines() == [' async def function_2(a, b):', ' pass'] + + def test_lambda(): function = lambda x: x @@ -38,6 +49,19 @@ def method(self, a, b): assert getsource(B().method).splitlines() == [' def method(self, a, b):', ' pass'] +def test_async_methods(): + class A: + async def method(self): + pass + + class B: + async def method(self, a, b): + pass + + assert getsource(A().method).splitlines() == [' async def method(self):', ' pass'] + assert getsource(B().method).splitlines() == [' async def method(self, a, b):', ' pass'] + + def test_usual_classmethods(): class A: @classmethod @@ -55,6 +79,23 @@ def method(cls, a, b): assert getsource(B.method).splitlines() == [' @classmethod', ' def method(cls, a, b):', ' pass'] +def test_async_classmethods(): + class A: + @classmethod + async def method(cls): + pass + + class B: + @classmethod + async def method(cls, a, b): + pass + + assert getsource(A().method).splitlines() == [' @classmethod', ' async def method(cls):', ' pass'] + assert getsource(B().method).splitlines() == [' @classmethod', ' async def method(cls, a, b):', ' pass'] + assert getsource(A.method).splitlines() == [' @classmethod', ' async def method(cls):', ' pass'] + assert getsource(B.method).splitlines() == [' @classmethod', ' async def method(cls, a, b):', ' pass'] + + def test_usual_staticmethods(): class A: @staticmethod @@ -72,6 +113,23 @@ def method(a, b): assert getsource(B.method).splitlines() == [' @staticmethod', ' def method(a, b):', ' pass'] +def test_async_staticmethods(): + class A: + @staticmethod + async def method(): + pass + + class B: + @staticmethod + async def method(a, b): + pass + + assert getsource(A().method).splitlines() == [' @staticmethod', ' async def method():', ' pass'] + assert getsource(B().method).splitlines() == [' @staticmethod', ' async def method(a, b):', ' pass'] + assert getsource(A.method).splitlines() == [' @staticmethod', ' async def method():', ' pass'] + assert getsource(B.method).splitlines() == [' @staticmethod', ' async def method(a, b):', ' pass'] + + @pytest.mark.skipif(platform == "win32", reason='I wait this: https://github.com/raczben/wexpect/issues/55') def test_usual_functions_in_REPL(): # noqa: N802 from pexpect import spawn # type: ignore[import-untyped] # noqa: PLC0415 @@ -105,6 +163,39 @@ def test_usual_functions_in_REPL(): # noqa: N802 assert any('def function(): ...' in x for x in after) +@pytest.mark.skipif(platform == "win32", reason='I wait this: https://github.com/raczben/wexpect/issues/55') +def test_async_functions_in_REPL(): # noqa: N802 + from pexpect import spawn # type: ignore[import-untyped] # noqa: PLC0415 + + env = environ.copy() + env["PYTHON_COLORS"] = "0" + child = spawn('python3', ["-i"], encoding="utf-8", env=env, timeout=5) + + buffer = StringIO() + child.logfile = buffer + + child.expect(">>> ") + child.sendline('from getsources import getsource') + child.expect(">>> ") + child.sendline('async def function(): ...') + child.sendline('') + child.expect(">>> ") + + before = buffer.getvalue() + + child.sendline("print(getsource(function), end='')") + child.expect(">>> ") + + after = buffer.getvalue() + after = re.compile(r'(?:\x1B[@-_]|\x9B)[0-?]*[ -/]*[@-~]').sub('', after.lstrip(before)) + after = ''.join(ch for ch in after if ch >= ' ' or ch in '\n\r\t') + after = after.splitlines() + + child.sendline("exit()") + + assert any('async def function(): ...' in x for x in after) + + @pytest.mark.skipif(platform == "win32", reason='I wait this: https://github.com/raczben/wexpect/issues/55') def test_lambda_in_REPL(): # noqa: N802 from pexpect import spawn # type: ignore[import-untyped] # noqa: PLC0415 @@ -134,3 +225,10 @@ def test_lambda_in_REPL(): # noqa: N802 child.sendline("exit()") assert any('function = lambda x: x' in x for x in after) + + +def test_get_lambda_where_are_two_lambdas(): + lambdas = [lambda: None, lambda x: x] + + assert getsource(lambdas[0]) == getsource(lambdas[1]) + assert getsource(lambdas[0]) == ' lambdas = [lambda: None, lambda x: x]\n' diff --git a/tests/test_clear.py b/tests/test_clear.py index 95474b0..9ed124d 100644 --- a/tests/test_clear.py +++ b/tests/test_clear.py @@ -1,11 +1,14 @@ +import ast import re from io import StringIO from os import environ from sys import platform import pytest +from full_match import match from getsources import getclearsource +from getsources.errors import UncertaintyWithLambdasError def global_function_1(): @@ -49,8 +52,8 @@ def function_2(a, b): def test_lambda(): function = lambda x: x - assert getclearsource(function) == 'function = lambda x: x' - assert getclearsource(global_function_3) == 'global_function_3 = lambda x: x' + assert getclearsource(function) == 'lambda x: x' + assert getclearsource(global_function_3) == 'lambda x: x' def test_usual_methods(): @@ -171,4 +174,23 @@ def test_lambda_in_REPL(): # noqa: N802 child.sendline("exit()") - assert any('function = lambda x: x' in x for x in after) + assert any('lambda x: x' in x for x in after) + + +def test_get_lambda_where_are_two_lambdas(): + lambdas = [lambda: None, lambda x: x] + + with pytest.raises(UncertaintyWithLambdasError, match=match('Several lambda functions are defined in a single line of code, can\'t determine which one.')): + getclearsource(lambdas[0]) + + +def test_try_to_get_changed_lambda(): + function = lambda x, y: x # noqa: ARG005 + + tree = ast.parse(getclearsource(function), mode='eval') + tree.body.body = ast.Name(id="y", ctx=ast.Load(), lineno=1, col_offset=1) + code = compile(tree, filename="", mode="eval") + new_function = eval(code) + + with pytest.raises(UncertaintyWithLambdasError, match=match('It seems that the AST for the lambda function has been modified; can\'t extract the source code.')): + getclearsource(new_function) diff --git a/tests/test_hash.py b/tests/test_hash.py new file mode 100644 index 0000000..b4c67dd --- /dev/null +++ b/tests/test_hash.py @@ -0,0 +1,208 @@ +import ast + +import pytest +from full_match import match + +from getsources import getclearsource, getsourcehash +from getsources.errors import UncertaintyWithLambdasError + + +def test_hash_lenth(transformed): + @transformed + def function(): + ... + + assert len(getsourcehash(function)) == 6 + assert len(getsourcehash(function, size=4)) == 4 + assert len(getsourcehash(function, size=5)) == 5 + assert len(getsourcehash(function, size=6)) == 6 + assert len(getsourcehash(function, size=7)) == 7 + assert len(getsourcehash(function, size=8)) == 8 + + with pytest.raises(ValueError, match=match('The hash string size must be in range 4..8.')): + getsourcehash(function, size=3) + + with pytest.raises(ValueError, match=match('The hash string size must be in range 4..8.')): + getsourcehash(function, size=9) + + with pytest.raises(ValueError, match=match('The hash string size must be in range 4..8.')): + getsourcehash(function, size=0) + + with pytest.raises(ValueError, match=match('The hash string size must be in range 4..8.')): + getsourcehash(function, size=-5) + + +def test_equality(transformed): + @transformed + def function(): + ... + + first_hash = getsourcehash(function) + + @transformed + def function(): + ... + + second_hash = getsourcehash(function) + + assert first_hash == second_hash + + +def test_not_equality(transformed): + @transformed + def function(): + ... + + first_hash = getsourcehash(function) + + @transformed + def function(): + pass + + second_hash = getsourcehash(function) + + assert first_hash != second_hash + + +def test_contain_ilou(transformed): + @transformed + def function1(): + ... + + @transformed + def function2(): + ... + + @transformed + def function3(): + return 1 + 2 + 3 + + @transformed + def function4(): + return 1 + 2 + 3 + 4 + + for function in (function1, function2, function3, function4): + for size in range(4, 9): + for letter in ('I', 'L', 'O', 'U'): + assert letter not in getsourcehash(function, size=size) + + +def test_only_body_off(transformed): + @transformed + def function1(): + return 1234 + + @transformed + def function2(a=5): # noqa: ARG001 + return 1234 + + @transformed + def function3(): + return 12345 + + assert getsourcehash(function1) != getsourcehash(function2) + assert getsourcehash(function3) != getsourcehash(function2) + + +def test_only_body_on(transformed): + @transformed + def function1(): + return 1234 + + @transformed + def function2(a=5): # noqa: ARG001 + return 1234 + + @transformed + def function3(): + return 12345 + + assert getsourcehash(function1, only_body=True) == getsourcehash(function2, only_body=True) + assert getsourcehash(function3, only_body=True) != getsourcehash(function2, only_body=True) + + +def test_only_body_on_and_skip_docstring_off(transformed): + @transformed + def function1(): + return 1234 + + @transformed + def function2(a=5): # noqa: ARG001 + return 1234 + + @transformed + def function3(): + """kek""" + return 1234 + + @transformed + def function4(a=5): # noqa: ARG001 + """kek""" + return 1234 + + assert getsourcehash(function1, only_body=True) == getsourcehash(function2, only_body=True) + assert getsourcehash(function3, only_body=True) == getsourcehash(function4, only_body=True) + + assert getsourcehash(function1, only_body=True) != getsourcehash(function3, only_body=True) + + +def test_only_body_on_and_skip_docstring_on(transformed): + @transformed + def function1(): + return 1234 + + @transformed + def function2(a=5): # noqa: ARG001 + return 1234 + + @transformed + def function3(): + """kek""" + return 1234 + + @transformed + def function4(a=5): # noqa: ARG001 + """kek""" + return 1234 + + assert getsourcehash(function1, only_body=True, skip_docstring=True) == getsourcehash(function2, only_body=True, skip_docstring=True) + assert getsourcehash(function3, only_body=True, skip_docstring=True) == getsourcehash(function4, only_body=True, skip_docstring=True) + + assert getsourcehash(function1, only_body=True, skip_docstring=True) == getsourcehash(function3, only_body=True, skip_docstring=True) + + +def test_try_to_skip_doctstring_if_only_body_option_isnt_sen(transformed): + @transformed + def function(): + ... + + with pytest.raises(ValueError, match=match('You can omit the docstring only if the `only_body=True` option is set.')): + getsourcehash(function, skip_docstring=True) + + with pytest.raises(ValueError, match=match('You can omit the docstring only if the `only_body=True` option is set.')): + getsourcehash(function, only_body=False, skip_docstring=True) + + +def test_hash_simple_lambda(): + lambda_hash = getsourcehash(lambda x: x) + assert lambda_hash == 'HVND1V' + + +def test_hash_lambda_only_body(): + first_hash = getsourcehash(lambda x: x, only_body=True) + second_hash = getsourcehash(lambda x, y: x, only_body=True) # noqa: ARG005 + + assert first_hash == '91MJ41' + assert first_hash == second_hash + + +def test_try_to_get_hash_of_changed_lambda(): + function = lambda x, y: x # noqa: ARG005 + + tree = ast.parse(getclearsource(function), mode='eval') + tree.body.body = ast.Name(id="y", ctx=ast.Load(), lineno=1, col_offset=1) + code = compile(tree, filename="", mode="eval") + new_function = eval(code) + + with pytest.raises(UncertaintyWithLambdasError, match=match('It seems that the AST for the lambda function has been modified; can\'t extract the source code.')): + getsourcehash(new_function)