Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
69e78da
Bump version to 0.0.3
Mar 13, 2026
5e6e5f0
Add Python 3.15.0-alpha.1 to CI matrix
Mar 13, 2026
608d05a
Update transfunctions to version 0.0.10
Mar 13, 2026
96859fa
Add tests for async functions, methods, and classmethods with getsource
Mar 13, 2026
25a2688
Add source hash functionality to getsources
Mar 13, 2026
c1f9284
Add tests for source hash functionality with fixtures for
Mar 13, 2026
9e5733d
Add info about getsourcehash() function with customizable options
Mar 13, 2026
5f42cfd
Enhance README with lambda function usage notes
Mar 13, 2026
2523bc2
Add support for hashing lambda functions
Mar 13, 2026
69eb3b3
Add test for hash equality with different lambda args
Mar 13, 2026
485dfe0
Refactor is_lambda into helper module with tests
Mar 13, 2026
6073932
Add Python 3.15 to classifiers
Mar 13, 2026
48ecd41
Support lambda extraction by AST parsing
Mar 13, 2026
e1b8017
Add "# noqa: ARG005" to test cases for unused arguments
Mar 13, 2026
acd56ab
Mypy's issues
Mar 13, 2026
7a44980
Mypy's issue
Mar 13, 2026
62a36f6
Add UncertaintyWithLambdasError import to __init__.py
Mar 13, 2026
4aafa78
Add test for handling multiple lambdas in getclearsource
Mar 13, 2026
9abc132
Restore getsourcehash import from getsources.hash
Mar 13, 2026
0d5e2e9
Add test for detecting modified lambda AST
Mar 13, 2026
5192fcf
Add test for handling modified lambda ASTs
Mar 13, 2026
f488827
Skip mypy's issue
Mar 13, 2026
63f7115
Add Table of Contents and split Usage into dedicated sections
Mar 13, 2026
1e04c15
Add an empty line
Mar 13, 2026
05c4a22
Update README to clarify lambda function support
Mar 13, 2026
ae250fc
Update README to clarify getsource behavior and add getclearsource
Mar 13, 2026
f889c01
Update README to reflect new function names and clarify usage
Mar 13, 2026
7e86a49
Update README to clarify getclearsource behavior with multiple lambdas
Mar 13, 2026
12b9cb0
Fix lambda error message wording for clarity
Mar 13, 2026
50aab5d
Fix markdown formatting of REPL in README
Mar 13, 2026
2600ae7
Update README to clarify library scope and supported types
Mar 13, 2026
da7e572
Enhance README with warning about intended use cases
Mar 13, 2026
1169592
Update README to link to explanation of AST limitation
Mar 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests_and_coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
134 changes: 124 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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
Expand All @@ -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
```
2 changes: 2 additions & 0 deletions getsources/__init__.py
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions getsources/clear.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions getsources/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class UncertaintyWithLambdasError(Exception):
...
53 changes: 53 additions & 0 deletions getsources/hash.py
Original file line number Diff line number Diff line change
@@ -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))
Empty file added getsources/helpers/__init__.py
Empty file.
6 changes: 6 additions & 0 deletions getsources/helpers/is_lambda.py
Original file line number Diff line number Diff line change
@@ -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__ == "<lambda>"
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]
Expand All @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion requirements_dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 14 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Empty file added tests/helpers/__init__.py
Empty file.
15 changes: 15 additions & 0 deletions tests/helpers/test_is_lambda.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading