From 69e78da168c8178388c12f5b28459bf3dfc2f68d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 13:49:22 +0300 Subject: [PATCH 01/33] Bump version to 0.0.3 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 286ed89..6b2aa16 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" }, ] From 5e6e5f0a945d20b0e9d72404835a4fcc43908a73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 13:50:04 +0300 Subject: [PATCH 02/33] Add Python 3.15.0-alpha.1 to CI matrix --- .github/workflows/lint.yml | 2 +- .github/workflows/tests_and_coverage.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 From 608d05afde1c22866e6d0aa34567af5834285e92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 14:46:51 +0300 Subject: [PATCH 03/33] Update transfunctions to version 0.0.10 --- requirements_dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 96859fa46d3a4f8b2ab1e572106b9e1c518af4b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 14:56:26 +0300 Subject: [PATCH 04/33] Add tests for async functions, methods, and classmethods with getsource --- tests/test_base.py | 91 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/tests/test_base.py b/tests/test_base.py index cc48b3b..c2bc331 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 From 25a268888f339daac675ab616f4490c34dbdeb2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 15:02:22 +0300 Subject: [PATCH 05/33] Add source hash functionality to getsources --- getsources/__init__.py | 1 + getsources/hash.py | 44 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 getsources/hash.py diff --git a/getsources/__init__.py b/getsources/__init__.py index ca36d06..70fdcff 100644 --- a/getsources/__init__.py +++ b/getsources/__init__.py @@ -1,2 +1,3 @@ from getsources.base import getsource as getsource from getsources.clear import getclearsource as getclearsource +from getsources.hash import getsourcehash as getsourcehash diff --git a/getsources/hash.py b/getsources/hash.py new file mode 100644 index 0000000..b318e0f --- /dev/null +++ b/getsources/hash.py @@ -0,0 +1,44 @@ +import hashlib +from typing import Any, Callable +from ast import parse, Expr, Constant, get_source_segment + +from getsources import getclearsource + +ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' + + +def get_body_text(source: str, skip_docstring: bool) -> str: + tree = parse(source) + + function_node = tree.body[0] + body_nodes = function_node.body + 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]) + + +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(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)) From c1f928433c262ed91568c4fe4d8ea1c89c34fa55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 15:02:47 +0300 Subject: [PATCH 06/33] Add tests for source hash functionality with fixtures for async/sync/generator functions --- tests/conftest.py | 14 ++++ tests/test_hash.py | 180 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/test_hash.py 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/test_hash.py b/tests/test_hash.py new file mode 100644 index 0000000..278d8ab --- /dev/null +++ b/tests/test_hash.py @@ -0,0 +1,180 @@ +import pytest +from full_match import match + +from getsources import getsourcehash + + +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): + 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): + 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): + return 1234 + + @transformed + def function3(): + """kek""" + return 1234 + + @transformed + def function4(a=5): + """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): + return 1234 + + @transformed + def function3(): + """kek""" + return 1234 + + @transformed + def function4(a=5): + """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) From 9e5733da4af76f42421985917c5c6c9648741993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 15:22:53 +0300 Subject: [PATCH 07/33] Add info about getsourcehash() function with customizable options --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/README.md b/README.md index 03d532a..2507831 100644 --- a/README.md +++ b/README.md @@ -69,3 +69,57 @@ print(getclearsource(SomeClass.method)) ``` 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`. + +In some cases, you may not care what exactly is inside a function, but you need to distinguish between functions with different contents. In this case, the `getsourcehash` function is useful, as it returns a short string representation of the function’s source code hash: + +```python +from getsources import getsourcehash + +def function(): + ... + +print(getsourcehash(function)) +#> 7SWJGZ +``` + +> ⓘ A hash string contains only characters from the [`Crockford Base32`](https://en.wikipedia.org/wiki/Base32) alphabet, which consists solely of uppercase English letters and digits; letters that resemble digits are excluded from the list, making the hash easy to read. + +By default, the hash string length is 6 characters, but you can set your own values ranging from 4 to 8 characters: + +```python +print(getsourcehash(function, size=4)) +#> WJGZ +print(getsourcehash(function, size=8)) +#> XG7SWJGZ +``` + +By default, the full text representation of a function is used, including its name and arguments. However, in some cases, we need to compare only the contents of the functions while ignoring these details; in such cases, we need to pass the argument `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 +``` From 5f42cfd7ba36b78054a8ca9b73d0756f21aa4df5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 15:24:34 +0300 Subject: [PATCH 08/33] Enhance README with lambda function usage notes --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2507831..a39b5b3 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ print(getsource(function)) Unlike its counterpart from the standard library, this thing can also work: -- With lambda functions +- With lambda functions (however, keep in mind that the entire text of the line where they are defined is returned, and if there are multiple lambda functions there, the library won't let you distinguish between them) - With functions defined inside REPL 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: From 2523bc2b3c0b38fc09fc776bb068336a81fc0ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 15:48:39 +0300 Subject: [PATCH 09/33] Add support for hashing lambda functions --- getsources/hash.py | 29 +++++++++++++++++++++-------- tests/test_hash.py | 10 ++++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/getsources/hash.py b/getsources/hash.py index b318e0f..c66bbe6 100644 --- a/getsources/hash.py +++ b/getsources/hash.py @@ -1,21 +1,34 @@ import hashlib from typing import Any, Callable -from ast import parse, Expr, Constant, get_source_segment +from ast import parse, Expr, Constant, Lambda, get_source_segment, walk +from types import FunctionType from getsources import getclearsource ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' -def get_body_text(source: str, skip_docstring: bool) -> str: +def is_lambda(function: Callable[..., Any]): + return isinstance(function, FunctionType) and function.__name__ == "" + + +def get_body_text(function: Callable[..., Any], source: str, skip_docstring: bool) -> str: tree = parse(source) - function_node = tree.body[0] - body_nodes = function_node.body - first = body_nodes[0] + 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 + 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:] + 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]) @@ -30,7 +43,7 @@ def getsourcehash(function: Callable[..., Any], size: int = 6, only_body: bool = if not only_body: interesting_part = source_code else: - interesting_part = get_body_text(source_code, skip_docstring=skip_docstring) + 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') diff --git a/tests/test_hash.py b/tests/test_hash.py index 278d8ab..16f81e4 100644 --- a/tests/test_hash.py +++ b/tests/test_hash.py @@ -178,3 +178,13 @@ def function(): 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 == '14FXP9' + + +def test_hash_lambda_only_body(): + lambda_hash = getsourcehash(lambda x: x, only_body=True) + assert lambda_hash == '91MJ41' From 69eb3b3417a46facbe6c84f90c130c8e6d0f08c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 15:48:58 +0300 Subject: [PATCH 10/33] Add test for hash equality with different lambda args --- tests/test_hash.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_hash.py b/tests/test_hash.py index 16f81e4..9c06af0 100644 --- a/tests/test_hash.py +++ b/tests/test_hash.py @@ -186,5 +186,8 @@ def test_hash_simple_lambda(): def test_hash_lambda_only_body(): - lambda_hash = getsourcehash(lambda x: x, only_body=True) - assert lambda_hash == '91MJ41' + first_hash = getsourcehash(lambda x: x, only_body=True) + second_hash = getsourcehash(lambda x, y: x, only_body=True) + + assert first_hash == '91MJ41' + assert first_hash == second_hash From 485dfe068110d242499c4e5613015eae3803351d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 15:55:02 +0300 Subject: [PATCH 11/33] Refactor is_lambda into helper module with tests --- getsources/hash.py | 6 +----- getsources/helpers/__init__.py | 0 getsources/helpers/is_lambda.py | 6 ++++++ tests/helpers/__init__.py | 0 tests/helpers/test_is_lambda.py | 15 +++++++++++++++ 5 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 getsources/helpers/__init__.py create mode 100644 getsources/helpers/is_lambda.py create mode 100644 tests/helpers/__init__.py create mode 100644 tests/helpers/test_is_lambda.py diff --git a/getsources/hash.py b/getsources/hash.py index c66bbe6..2f4773b 100644 --- a/getsources/hash.py +++ b/getsources/hash.py @@ -1,17 +1,13 @@ import hashlib from typing import Any, Callable from ast import parse, Expr, Constant, Lambda, get_source_segment, walk -from types import FunctionType from getsources import getclearsource +from getsources.helpers.is_lambda import is_lambda ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' -def is_lambda(function: Callable[..., Any]): - return isinstance(function, FunctionType) and function.__name__ == "" - - def get_body_text(function: Callable[..., Any], source: str, skip_docstring: bool) -> str: tree = parse(source) 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..5c712dc --- /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]): + return isinstance(function, FunctionType) and function.__name__ == "" 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..f3b631d --- /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) + assert is_lambda(lambda: None) + + +def test_are_not_lambdas(transformed): + @transformed + def function(): + ... + + assert not is_lambda(function) From 60739325d103ea00256129683460b8c51ccade2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 16:02:14 +0300 Subject: [PATCH 12/33] Add Python 3.15 to classifiers --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 6b2aa16..3f4b563 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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', From 48ecd4166f8678d3eab9093725dee3f6f8ff27d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 16:14:53 +0300 Subject: [PATCH 13/33] Support lambda extraction by AST parsing --- getsources/clear.py | 22 ++++++++++++++++++++++ getsources/errors.py | 2 ++ getsources/hash.py | 2 +- tests/test_clear.py | 6 +++--- 4 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 getsources/errors.py diff --git a/getsources/clear.py b/getsources/clear.py index 4911f82..a77c473 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 pick the one.') + lambda_node = node + first = False + + segment_source = get_source_segment(stripped_source_code, lambda_node) + 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 index 2f4773b..5ee6348 100644 --- a/getsources/hash.py +++ b/getsources/hash.py @@ -1,6 +1,6 @@ import hashlib +from ast import Constant, Expr, Lambda, get_source_segment, parse, walk from typing import Any, Callable -from ast import parse, Expr, Constant, Lambda, get_source_segment, walk from getsources import getclearsource from getsources.helpers.is_lambda import is_lambda diff --git a/tests/test_clear.py b/tests/test_clear.py index 95474b0..5092cf4 100644 --- a/tests/test_clear.py +++ b/tests/test_clear.py @@ -49,8 +49,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 +171,4 @@ 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) From e1b8017d268286f1b0c6e659f54d111bd0923428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 16:15:32 +0300 Subject: [PATCH 14/33] Add "# noqa: ARG005" to test cases for unused arguments --- tests/helpers/test_is_lambda.py | 2 +- tests/test_hash.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/helpers/test_is_lambda.py b/tests/helpers/test_is_lambda.py index f3b631d..63e0df4 100644 --- a/tests/helpers/test_is_lambda.py +++ b/tests/helpers/test_is_lambda.py @@ -3,7 +3,7 @@ def test_lambdas_are_lambdas(): assert is_lambda(lambda x: x) - assert is_lambda(lambda x: None) + assert is_lambda(lambda x: None) # noqa: ARG005 assert is_lambda(lambda: None) diff --git a/tests/test_hash.py b/tests/test_hash.py index 9c06af0..5588fab 100644 --- a/tests/test_hash.py +++ b/tests/test_hash.py @@ -90,7 +90,7 @@ def function1(): return 1234 @transformed - def function2(a=5): + def function2(a=5): # noqa: ARG001 return 1234 @transformed @@ -107,7 +107,7 @@ def function1(): return 1234 @transformed - def function2(a=5): + def function2(a=5): # noqa: ARG001 return 1234 @transformed @@ -124,7 +124,7 @@ def function1(): return 1234 @transformed - def function2(a=5): + def function2(a=5): # noqa: ARG001 return 1234 @transformed @@ -133,7 +133,7 @@ def function3(): return 1234 @transformed - def function4(a=5): + def function4(a=5): # noqa: ARG001 """kek""" return 1234 @@ -149,7 +149,7 @@ def function1(): return 1234 @transformed - def function2(a=5): + def function2(a=5): # noqa: ARG001 return 1234 @transformed @@ -158,7 +158,7 @@ def function3(): return 1234 @transformed - def function4(a=5): + def function4(a=5): # noqa: ARG001 """kek""" return 1234 @@ -182,12 +182,12 @@ def function(): def test_hash_simple_lambda(): lambda_hash = getsourcehash(lambda x: x) - assert lambda_hash == '14FXP9' + 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) + second_hash = getsourcehash(lambda x, y: x, only_body=True) # noqa: ARG005 assert first_hash == '91MJ41' assert first_hash == second_hash From acd56ab59cf54bd2c0735c3d775976e71c6b7ce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 16:17:29 +0300 Subject: [PATCH 15/33] Mypy's issues --- getsources/hash.py | 2 +- getsources/helpers/is_lambda.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/getsources/hash.py b/getsources/hash.py index 5ee6348..df43d88 100644 --- a/getsources/hash.py +++ b/getsources/hash.py @@ -20,7 +20,7 @@ def get_body_text(function: Callable[..., Any], source: str, skip_docstring: boo else: function_node = tree.body[0] - body_nodes = function_node.body + 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)): diff --git a/getsources/helpers/is_lambda.py b/getsources/helpers/is_lambda.py index 5c712dc..4f05a74 100644 --- a/getsources/helpers/is_lambda.py +++ b/getsources/helpers/is_lambda.py @@ -2,5 +2,5 @@ from typing import Any, Callable -def is_lambda(function: Callable[..., Any]): +def is_lambda(function: Callable[..., Any]) -> bool: return isinstance(function, FunctionType) and function.__name__ == "" From 7a4498012ae549f32f214bfd307410488b021efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 16:18:25 +0300 Subject: [PATCH 16/33] Mypy's issue --- getsources/clear.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/getsources/clear.py b/getsources/clear.py index a77c473..00faa78 100644 --- a/getsources/clear.py +++ b/getsources/clear.py @@ -22,7 +22,7 @@ def getclearsource(function: Callable[..., Any]) -> str: lambda_node = node first = False - segment_source = get_source_segment(stripped_source_code, lambda_node) + 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 From 62a36f6e673c8d70080aa81405fb12e619f1bb7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 16:24:34 +0300 Subject: [PATCH 17/33] Add UncertaintyWithLambdasError import to __init__.py --- getsources/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/getsources/__init__.py b/getsources/__init__.py index 70fdcff..2d0b0ad 100644 --- a/getsources/__init__.py +++ b/getsources/__init__.py @@ -1,3 +1,4 @@ from getsources.base import getsource as getsource from getsources.clear import getclearsource as getclearsource from getsources.hash import getsourcehash as getsourcehash +from getsources.errors import UncertaintyWithLambdasError as UncertaintyWithLambdasError From 4aafa78f23884c726dcc46281eb3bedddfb6685b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 16:25:06 +0300 Subject: [PATCH 18/33] Add test for handling multiple lambdas in getclearsource --- tests/test_base.py | 7 +++++++ tests/test_clear.py | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/tests/test_base.py b/tests/test_base.py index c2bc331..64d0bd9 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -225,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 5092cf4..a89c75d 100644 --- a/tests/test_clear.py +++ b/tests/test_clear.py @@ -4,8 +4,10 @@ from sys import platform import pytest +from full_match import match from getsources import getclearsource +from getsources.errors import UncertaintyWithLambdasError def global_function_1(): @@ -172,3 +174,10 @@ def test_lambda_in_REPL(): # noqa: N802 child.sendline("exit()") 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 pick the one.')): + getclearsource(lambdas[0]) From 9abc132fd996aa569af8958600526a2997f2b7b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 16:25:34 +0300 Subject: [PATCH 19/33] Restore getsourcehash import from getsources.hash --- getsources/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/getsources/__init__.py b/getsources/__init__.py index 2d0b0ad..92866d7 100644 --- a/getsources/__init__.py +++ b/getsources/__init__.py @@ -1,4 +1,4 @@ from getsources.base import getsource as getsource from getsources.clear import getclearsource as getclearsource -from getsources.hash import getsourcehash as getsourcehash from getsources.errors import UncertaintyWithLambdasError as UncertaintyWithLambdasError +from getsources.hash import getsourcehash as getsourcehash From 0d5e2e9ceb732315ccaee635128763eb4e60fc40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 16:38:22 +0300 Subject: [PATCH 20/33] Add test for detecting modified lambda AST --- tests/test_clear.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_clear.py b/tests/test_clear.py index a89c75d..1035afc 100644 --- a/tests/test_clear.py +++ b/tests/test_clear.py @@ -1,3 +1,4 @@ +import ast import re from io import StringIO from os import environ @@ -181,3 +182,15 @@ def test_get_lambda_where_are_two_lambdas(): with pytest.raises(UncertaintyWithLambdasError, match=match('Several lambda functions are defined in a single line of code, can\'t pick the 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) From 5192fcf7d48bfce7df8ecda43db12b796d2c353c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 17:06:28 +0300 Subject: [PATCH 21/33] Add test for handling modified lambda ASTs --- tests/test_hash.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/test_hash.py b/tests/test_hash.py index 5588fab..b4c67dd 100644 --- a/tests/test_hash.py +++ b/tests/test_hash.py @@ -1,7 +1,10 @@ +import ast + import pytest from full_match import match -from getsources import getsourcehash +from getsources import getclearsource, getsourcehash +from getsources.errors import UncertaintyWithLambdasError def test_hash_lenth(transformed): @@ -191,3 +194,15 @@ def test_hash_lambda_only_body(): 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) From f488827232c2bf900bab8f51ed09f8e380147ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 17:06:43 +0300 Subject: [PATCH 22/33] Skip mypy's issue --- getsources/hash.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/getsources/hash.py b/getsources/hash.py index df43d88..5571fab 100644 --- a/getsources/hash.py +++ b/getsources/hash.py @@ -26,7 +26,7 @@ def get_body_text(function: Callable[..., Any], source: str, skip_docstring: boo 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]) + 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: From 63f71158a14d8a00702d8adf0dd6ea36764d1cfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 17:20:04 +0300 Subject: [PATCH 23/33] Add Table of Contents and split Usage into dedicated sections --- README.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a39b5b3..8e799cf 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,13 @@ 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 dirty sources**](#get-dirty-sources) +- [**Get clear sources**](#get-clear-sources) +- [**Get hashes**](#get-hashes) + ## Installation You can install [`getsources`](https://pypi.python.org/pypi/getsources) using pip: @@ -29,10 +36,10 @@ You can install [`getsources`](https://pypi.python.org/pypi/getsources) using pi pip install getsources ``` -You can also quickly try out this and other packages without having to install using [instld](https://github.com/pomponchik/instld). +You can also quickly try this package and others without installing them via [instld](https://github.com/pomponchik/instld). -## Usage +## Get dirty sources The basic function of the library is `getsource`, which works similarly to the function of the same name from the standard library: @@ -50,7 +57,10 @@ print(getsource(function)) Unlike its counterpart from the standard library, this thing can also work: - With lambda functions (however, keep in mind that the entire text of the line where they are defined is returned, and if there are multiple lambda functions there, the library won't let you distinguish between them) -- With functions defined inside REPL +- With functions defined inside `REPL` + + +## Get clear sources 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: @@ -68,7 +78,10 @@ print(getclearsource(SomeClass.method)) #> ... ``` -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`. +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`](#get-dirty-sources). + + +## Get hashes In some cases, you may not care what exactly is inside a function, but you need to distinguish between functions with different contents. In this case, the `getsourcehash` function is useful, as it returns a short string representation of the function’s source code hash: From 1e04c1594b147a8a6e6468e66c71e232bd9bc341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 17:23:20 +0300 Subject: [PATCH 24/33] Add an empty line --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8e799cf..8f6184a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ This library is needed to obtain the source code of functions at runtime. It can - [**Get clear sources**](#get-clear-sources) - [**Get hashes**](#get-hashes) + ## Installation You can install [`getsources`](https://pypi.python.org/pypi/getsources) using pip: From 05c4a2252d6ef8c4b533580968cd0d9124e0847f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 17:23:37 +0300 Subject: [PATCH 25/33] Update README to clarify lambda function support --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f6184a..6235de2 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ print(getsource(function)) Unlike its counterpart from the standard library, this thing can also work: -- With lambda functions (however, keep in mind that the entire text of the line where they are defined is returned, and if there are multiple lambda functions there, the library won't let you distinguish between them) +- With lambda functions - With functions defined inside `REPL` From ae250fcd0a61e6b40ff2bd40e4b2c6f6c31b3b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 17:46:07 +0300 Subject: [PATCH 26/33] Update README to clarify getsource behavior and add getclearsource examples --- README.md | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6235de2..78876b5 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,12 @@ You can also quickly try this package and others without installing them via [in ## Get dirty sources -The basic function of the library is `getsource`, which works similarly to the function of the same name from the standard library: +The standard library includes 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). + +This library defines a function of the same name that does the same thing but does not have this drawback: ```python +# You can run this code snippet in the REPL. from getsources import getsource def function(): @@ -55,15 +58,35 @@ print(getsource(function)) #> ... ``` -Unlike its counterpart from the standard library, this thing can also work: - -- With lambda functions -- With functions defined inside `REPL` +This way, you can ensure that your functions that work with ASTs can be executed in any way. All other functions in this library are built on top of this one. ## Get clear sources -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-dirty-sources) function returns the source code of functions in a "raw" format. This means that the code snippet captures some unnecessary surrounding code. + +Here's an example where the standard `getsources` function gets rid extra whitespace characters: + +```python +if True: + def function(): + ... + +print(getsource(function)) +#> def function(): +#> ... +``` + +See? There are extra spaces at the beginning. + +Lambda functions also capture the entire surrounding string: + +```python +print(getsource(lambda x: x)) +#> print(getsource(lambda x: x)) +``` + +To address these issues, there is a special function called `getclearsource`, which returns the original function's code but stripped of any unnecessary elements: ```python from getsources import getclearsource @@ -77,9 +100,11 @@ print(getclearsource(SomeClass.method)) #> @staticmethod #> def method(): #> ... +print(getclearsource(lambda x: x)) +#> lambda x: x ``` -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`](#get-dirty-sources). +When working with AST, this function is the recommended and safe way to retrieve the source code of functions. ## Get hashes @@ -98,6 +123,8 @@ print(getsourcehash(function)) > ⓘ A hash string contains only characters from the [`Crockford Base32`](https://en.wikipedia.org/wiki/Base32) alphabet, which consists solely of uppercase English letters and digits; letters that resemble digits are excluded from the list, making the hash easy to read. +> ⓘ The `getsourcehash` function operates on top of [`getclearsource`](#get-clear-sources) and ignores "extra" characters in the source code. + By default, the hash string length is 6 characters, but you can set your own values ranging from 4 to 8 characters: ```python From f889c01a6211ead9dcc100102ab131cef9923176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 18:19:23 +0300 Subject: [PATCH 27/33] Update README to reflect new function names and clarify usage --- README.md | 48 +++++++++++++++++++++++------------------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 78876b5..8c1fe8c 100644 --- a/README.md +++ b/README.md @@ -17,34 +17,32 @@ ![logo](https://raw.githubusercontent.com/mutating/getsources/develop/docs/assets/logo_1.svg) - -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). +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). ## Table of contents - [**Installation**](#installation) -- [**Get dirty sources**](#get-dirty-sources) -- [**Get clear sources**](#get-clear-sources) -- [**Get hashes**](#get-hashes) +- [**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 quickly try this package and others without installing them via [instld](https://github.com/pomponchik/instld). +You can also use [`instld`](https://github.com/pomponchik/instld) to quickly try this package and others without installing them. -## Get dirty sources +## Get raw source -The standard library includes 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 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). -This library defines a function of the same name that does the same thing but does not have this drawback: +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. @@ -58,14 +56,14 @@ print(getsource(function)) #> ... ``` -This way, you can ensure that your functions that work with ASTs can be executed in any way. All other functions in this library are built on top of this one. +This makes AST-based tools work reliably in both scripts and the REPL. All other functions in the library are built on top of it. -## Get clear sources +## Get cleaned source -The [`getsource`](#get-dirty-sources) function returns the source code of functions in a "raw" format. This means that the code snippet captures some unnecessary surrounding code. +The [`getsource`](#get-raw-source) function a function's source code in raw form. This means that the code snippet captures some unnecessary surrounding code. -Here's an example where the standard `getsources` function gets rid extra whitespace characters: +Here is an example where the standard `getsource` output includes extra leading whitespace: ```python if True: @@ -77,16 +75,16 @@ print(getsource(function)) #> ... ``` -See? There are extra spaces at the beginning. +> ↑ Notice the extra leading spaces. -Lambda functions also capture the entire surrounding string: +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, there is a special function called `getclearsource`, which returns the original function's code but stripped of any unnecessary elements: +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 @@ -104,12 +102,12 @@ print(getclearsource(lambda x: x)) #> lambda x: x ``` -When working with AST, this function is the recommended and safe way to retrieve the source code of functions. +When working with ASTs, this is the recommended way to retrieve a function's source code. -## Get hashes +## Generate source hashes -In some cases, you may not care what exactly is inside a function, but you need to distinguish between functions with different contents. In this case, the `getsourcehash` function is useful, as it returns a short string representation of the function’s source code hash: +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 @@ -121,11 +119,11 @@ print(getsourcehash(function)) #> 7SWJGZ ``` -> ⓘ A hash string contains only characters from the [`Crockford Base32`](https://en.wikipedia.org/wiki/Base32) alphabet, which consists solely of uppercase English letters and digits; letters that resemble digits are excluded from the list, making the hash easy to read. +> ⓘ 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 operates on top of [`getclearsource`](#get-clear-sources) and ignores "extra" characters in the source code. +> ⓘ 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 set your own values ranging from 4 to 8 characters: +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)) @@ -134,7 +132,7 @@ print(getsourcehash(function, size=8)) #> XG7SWJGZ ``` -By default, the full text representation of a function is used, including its name and arguments. However, in some cases, we need to compare only the contents of the functions while ignoring these details; in such cases, we need to pass the argument `only_body=True`: +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(): From 7e86a49d171a9d7828de1e8fb3d4c7e15d64f4d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 18:26:57 +0300 Subject: [PATCH 28/33] Update README to clarify getclearsource behavior with multiple lambdas and provide fallback using getsource --- README.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8c1fe8c..7c26aba 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,26 @@ print(getclearsource(lambda x: x)) #> lambda x: x ``` -When working with ASTs, this is the recommended way to retrieve a function's source code. +To extract only the substring containing a lambda function, the library uses AST parsing behind the scenes. Unfortunately, this does not allow 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 pick the 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 From 12b9cb015ab46206e40b5251c54669472e4d13b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 18:33:05 +0300 Subject: [PATCH 29/33] Fix lambda error message wording for clarity --- README.md | 4 ++-- getsources/clear.py | 2 +- tests/test_clear.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7c26aba..36d0f57 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ This makes AST-based tools work reliably in both scripts and the REPL. All other ## Get cleaned source -The [`getsource`](#get-raw-source) function a function's source code in raw form. This means that the code snippet captures some unnecessary surrounding code. +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: @@ -109,7 +109,7 @@ 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 pick the one. +#> 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): diff --git a/getsources/clear.py b/getsources/clear.py index 00faa78..aa055f4 100644 --- a/getsources/clear.py +++ b/getsources/clear.py @@ -18,7 +18,7 @@ def getclearsource(function: Callable[..., Any]) -> str: 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 pick the one.') + raise UncertaintyWithLambdasError('Several lambda functions are defined in a single line of code, can\'t determine which one.') lambda_node = node first = False diff --git a/tests/test_clear.py b/tests/test_clear.py index 1035afc..9ed124d 100644 --- a/tests/test_clear.py +++ b/tests/test_clear.py @@ -180,7 +180,7 @@ def test_lambda_in_REPL(): # noqa: N802 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 pick the one.')): + 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]) From 50aab5df4e28a7cfdde3fb3025feb923c6bbf210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 18:34:10 +0300 Subject: [PATCH 30/33] Fix markdown formatting of REPL in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 36d0f57..d01c7b6 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ print(getsource(function)) #> ... ``` -This makes AST-based tools work reliably in both scripts and the REPL. All other functions in the library are built on top of it. +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. ## Get cleaned source From 2600ae7c0c8f6c41a9797eb51800b5dde15096c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 18:39:53 +0300 Subject: [PATCH 31/33] Update README to clarify library scope and supported types --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d01c7b6..e62ef6e 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ print(getsource(function)) 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. +However, 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. + ## Get cleaned source From da7e572879c0cc947ef0fdedf31979a51708b958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 18:40:56 +0300 Subject: [PATCH 32/33] Enhance README with warning about intended use cases --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e62ef6e..aae47da 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ print(getsource(function)) 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. -However, 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. +> ⚠️ 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. ## Get cleaned source From 1169592804d19246b8ad0393d592e273d0d98bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=91=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Fri, 13 Mar 2026 18:44:06 +0300 Subject: [PATCH 33/33] Update README to link to explanation of AST limitation --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index aae47da..b794981 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ 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 it to distinguish between multiple lambda functions defined in a single line, so in this case you will get an exception: +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]