├── .github
└── workflows
│ └── test.yml
├── .gitignore
├── LICENSE
├── README.md
├── README.rst
├── constable
└── __init__.py
├── setup.py
└── tests
└── __init__.py
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Run tests
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | pull_request:
9 | branches:
10 | - main
11 |
12 | workflow_dispatch:
13 |
14 | jobs:
15 | test:
16 |
17 | runs-on: ubuntu-latest
18 | strategy:
19 | fail-fast: false
20 | matrix:
21 | python-version: [3.8, 3.9, 3.11]
22 |
23 | steps:
24 | - uses: actions/checkout@v2
25 | - name: Set up Python ${{ matrix.python-version }}
26 | uses: actions/setup-python@v2
27 | with:
28 | python-version: ${{ matrix.python-version }}
29 |
30 | - name: Run tests
31 | run: |
32 | python -m unittest -v
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # pdm
105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106 | #pdm.lock
107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108 | # in version control.
109 | # https://pdm.fming.dev/#use-with-ide
110 | .pdm.toml
111 |
112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113 | __pypackages__/
114 |
115 | # Celery stuff
116 | celerybeat-schedule
117 | celerybeat.pid
118 |
119 | # SageMath parsed files
120 | *.sage.py
121 |
122 | # Environments
123 | .env
124 | .venv
125 | env/
126 | venv/
127 | ENV/
128 | env.bak/
129 | venv.bak/
130 |
131 | # Spyder project settings
132 | .spyderproject
133 | .spyproject
134 |
135 | # Rope project settings
136 | .ropeproject
137 |
138 | # mkdocs documentation
139 | /site
140 |
141 | # mypy
142 | .mypy_cache/
143 | .dmypy.json
144 | dmypy.json
145 |
146 | # Pyre type checker
147 | .pyre/
148 |
149 | # pytype static type analyzer
150 | .pytype/
151 |
152 | # Cython debug symbols
153 | cython_debug/
154 |
155 | # PyCharm
156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158 | # and can be added to the global gitignore or merged into this file. For a more nuclear
159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160 | #.idea/
161 |
162 | trial.py
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Saurabh Pujari
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | > :memo: This is an experimental project that tweaks the AST. Use at your own risk in mission-critical environments, or with unknown agents, as compiling and executing code during runtime can cause unwanted side effects. For all use cases that matter, use `pdb` instead.
8 |
9 |
10 | If you find yourself aimlessly adding :sparkles: `print` :sparkles: statements while debugging your code, this is for you. :handshake:
11 |
12 | Constable inserts print statements directly into the AST at runtime to print variable assignments and other details.
13 |
14 | It turns this 🔽 ....
15 | ```python
16 | @constable.trace('a', 'b')
17 | def do_something(a, b):
18 | a = a + b
19 | ```
20 | .... into this 🔽 during runtime
21 | ```python
22 | # During runtime, print statements will be added for every assignment on 'a' & 'b'.
23 | # Resulting in something like -
24 | def do_something(a, b):
25 | a = a + b
26 | print(f"wowww i wonder who put this print here! a = {a}")
27 | ```
28 |
29 | See the [examples](#example) below
30 |
31 | ```sh
32 | $ pip install constable
33 | ```
34 |
35 | ### How does it work?
36 |
37 | The `constable.trace` decorator uses Python's Abstract Syntax Tree (AST) in much the same way we add `print`(s) to debug states. During runtime, it prepares and inserts `print` statements into the function's AST after every assignment operation (`ast.Assign`, `ast.AugAssign` and `ast.AnnAssign`), and then executes the modified code in a separate namespace with `exec`.
38 |
39 | #### Print variable assignments and execution info.
40 |
41 |
42 |
43 | Monitor the state of specified variables at each assignment operation with a step-by-step view of variable changes!
44 |
45 | ```python
46 | import constable
47 |
48 | @constable.trace('a', 'b')
49 | def example(a, b):
50 | a = a + b
51 | c = a
52 | a = "Experimenting with the AST"
53 | b = c + b
54 | a = c + b
55 | return a
56 |
57 | example(5, 6)
58 | ```
59 |
60 | Output -
61 |
62 | ```
63 | constable: example: line 5
64 | a = a + b
65 | a = 11
66 | type(a) =
67 |
68 | constable: example: line 7
69 | a = "Experimenting with the AST"
70 | a = Experimenting with the AST
71 | type(a) =
72 |
73 | constable: example: line 8
74 | b = c + b
75 | b = 17
76 | type(b) =
77 |
78 | constable: example: line 9
79 | a = c + b
80 | a = 28
81 | type(a) =
82 |
83 | constable: example: line 3 to 10
84 | args: (5, 6)
85 | kwargs: {}
86 | returned: 28
87 | execution time: 0.00018480 seconds
88 | ```
89 |
90 | You can also use it on its own to track function execution info.
91 |
92 | ```python
93 | import constable
94 |
95 | @constable.trace()
96 | def add(a, b):
97 | return a + b
98 |
99 | add(5, 6)
100 | ```
101 |
102 | Output -
103 |
104 | ```
105 | constable: add: line 3 to 5
106 | args: (5, 6)
107 | kwargs: {}
108 | returned: 11
109 | execution time: 0.00004312 seconds
110 | ```
111 |
112 |
113 | #### @trace
114 | The `trace` function is the decorator to add `print` statements into the AST.
115 |
116 | ```python
117 |
118 | def trace(
119 | *variables,
120 | exec_info=True,
121 | verbose=True,
122 | use_spaces=True,
123 | max_len=None,
124 | ):
125 | """
126 | An experimental decorator for tracing function execution using AST.
127 |
128 | Args:
129 | variables (list): List of variable names to trace.
130 | exec_info (bool, optional): Whether to print execution info.
131 | verbose (bool, optional): Whether to print detailed trace info.
132 | use_spaces (bool, optional): Whether to add empty lines for readability.
133 | max_len (int, optional): Max length of printed values. Truncates if exceeded.
134 |
135 | Returns:
136 | function: Decorator for function tracing.
137 | """
138 |
139 | ```
140 |
141 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | constable
2 | --------------
3 |
4 | constable allows you to monitor the state of specified variables at each assignment operation, providing a step-by-step view of variable changes!
5 |
6 | View the `Github repository `__ and the `official docs `__.
7 |
8 |
9 | How does it work?
10 | ~~~~~~~~~~~~~~~~~~
11 |
12 | The `constable.trace` decorator uses Python's Abstract Syntax Tree (AST) in much the same way we add `print`(s) to debug states. During runtime, it prepares and inserts `print` statements into the function's AST after every assignment operation (`ast.Assign`, `ast.AugAssign` and `ast.AnnAssign`), and then executes the modified code in a separate namespace with `exec`.
13 |
14 |
15 | .. code:: sh
16 |
17 | $ pip install constable
18 |
19 | Tested for python 3.8 and above.
20 |
21 |
22 | Usage :
23 | ~~~~~~~~~~~~~
24 |
25 |
26 | Tracking variables in a function
27 |
28 | .. code:: python
29 |
30 | import constable
31 |
32 | @constable.trace('a', 'b')
33 | def example(a, b):
34 | a = a + b
35 | c = a
36 | a = "Experimenting with the AST"
37 | b = c + b
38 | a = c + b
39 | return a
40 |
41 | example(5, 6)
42 |
43 |
44 | Output :
45 |
46 | ::
47 |
48 | constable: example: line 5
49 | a = a + b
50 | a = 11
51 | type(a) =
52 |
53 | constable: example: line 7
54 | a = "Experimenting with the AST"
55 | a = Experimenting with the AST
56 | type(a) =
57 |
58 | constable: example: line 8
59 | b = c + b
60 | b = 17
61 | type(b) =
62 |
63 | constable: example: line 9
64 | a = c + b
65 | a = 28
66 | type(a) =
67 |
68 | constable: example: line 3 to 10
69 | args: (5, 6)
70 | kwargs: {}
71 | returned: 28
72 | execution time: 0.00018480 seconds
73 |
74 |
75 | Monitor functions
76 |
77 | .. code:: python
78 |
79 | import constable
80 |
81 | @constable.trace()
82 | def add(a, b):
83 | return a + b
84 |
85 | add(5, 6)
86 |
87 | Output :
88 |
89 | ::
90 |
91 | constable: add: line 3 to 5
92 | args: (5, 6)
93 | kwargs: {}
94 | returned: 11
95 | execution time: 0.00004312 seconds
96 |
--------------------------------------------------------------------------------
/constable/__init__.py:
--------------------------------------------------------------------------------
1 | import ast
2 | import functools
3 | import inspect
4 | import textwrap
5 | import time as t
6 |
7 |
8 | __all__ = ['trace']
9 |
10 |
11 | green = lambda x: f"\033[92m{x}\033[0m"
12 | blue = lambda x: f"\033[94m{x}\033[0m"
13 |
14 |
15 | def trunc(s: str, max_len: int, dot=False):
16 | """
17 | Keep this outside scope of the decorator because it's used in the AST.
18 | """
19 | if not s or max_len is None:
20 | return s
21 |
22 | s = str(s)
23 | if len(s) > max_len:
24 | if dot:
25 | s = (s[:max_len] + f"...'")
26 | else:
27 | s = (s[:max_len] + f"...[+{len(s)-max_len} chars]").replace('"', '\\"')
28 | return s
29 |
30 |
31 | class FunctionWrapper:
32 | """
33 | Wraps a function along with its arguments and provides utility methods.
34 | """
35 | def __init__(self, func, args=None, kwargs=None):
36 | self.func = func
37 | self.args = args if args is not None else ()
38 | self.kwargs = kwargs if kwargs is not None else {}
39 | self.start_line_num = func.__code__.co_firstlineno
40 | self.source_code_lines = inspect.getsource(func).splitlines()
41 | self.func_def_offset = 0
42 | for i in range(len(self.source_code_lines)):
43 | line = self.source_code_lines[i].strip()
44 | if line.startswith('def '):
45 | # Python uses 1-based indexing for line nums, so add 1 to the index
46 | self.func_def_offset = i + 1
47 | break
48 | self.end_line_num = self.start_line_num + len(self.source_code_lines) - 1
49 |
50 | def debug_prefix(self, line_num=None, to_line_num=None):
51 | signature = f"{self.func.__name__}"
52 | if line_num:
53 | signature += f": line {line_num}"
54 | if to_line_num:
55 | signature += f" to {to_line_num}"
56 | return f"{blue('constable:')} {signature}"
57 |
58 |
59 | class AstProcessor:
60 | """
61 | Processes the AST of a function and inserts print statements for debugging.
62 | """
63 | def __init__(
64 | self,
65 | fn_wrapper: FunctionWrapper,
66 | verbose=True,
67 | use_spaces=True,
68 | max_len=None
69 | ):
70 | self.fn_wrapper = fn_wrapper
71 | self.max_len = max_len
72 | self.use_spaces = use_spaces
73 | self.verbose = verbose
74 | self.module = None
75 |
76 | def get_ast_module(self):
77 | if self.module is not None:
78 | return self.module
79 | source_code = textwrap.dedent(
80 | '\n'.join(inspect.getsource(self.fn_wrapper.func).splitlines()[1:])
81 | )
82 | self.module = ast.parse(source_code)
83 | return self.module
84 |
85 | def get_source_code_and_line_number(self, node_line_num) -> tuple:
86 | # source_code_lines will contain decorators, func def etc.
87 | # func_def_offset is the line num of the function definition in the source code
88 | # node_line_num is the line num of the statement in the function
89 | # we need to find the line num of the statement inside source code
90 | line_index = self.fn_wrapper.func_def_offset + node_line_num - 2
91 | line = self.fn_wrapper.source_code_lines[line_index].strip()
92 | start_line_num = self.fn_wrapper.start_line_num + node_line_num
93 | return line, start_line_num
94 |
95 | def get_statements_to_insert(self, target, node):
96 | line, line_num = self.get_source_code_and_line_number(node.lineno)
97 | debug_prefix = self.fn_wrapper.debug_prefix(line_num=line_num)
98 | if self.verbose:
99 | return [
100 | f'print("{debug_prefix}")',
101 | f'print(" ", {trunc(repr(line), 80, True)})',
102 | f'print(" {target.id} =", green(trunc(str({target.id}), {self.max_len})))',
103 | f'print(" type({target.id}) =", green(str(type({target.id}))))',
104 | ]
105 | else:
106 | return [
107 | f'print("{debug_prefix} - {green(target.id)}", green("="), green(trunc(str({target.id}), {self.max_len})))',
108 | ]
109 |
110 | def get_nodes_to_insert(self, target, node):
111 | empty_print_node = ast.parse(f'print("")').body[0]
112 | nodes_to_insert = [empty_print_node] if self.use_spaces else []
113 | statements = self.get_statements_to_insert(target, node)
114 | for stmnt in statements:
115 | node_to_insert = ast.parse(stmnt).body[0]
116 | nodes_to_insert.append(node_to_insert)
117 | return nodes_to_insert
118 |
119 | def insert_nodes(self, nodes_to_insert, node):
120 | i = 1
121 | module = self.get_ast_module()
122 | for node_to_insert in nodes_to_insert:
123 | node_to_insert.lineno = node.lineno + i
124 | node_to_insert.end_lineno = node.lineno + i
125 | node_to_insert.col_offset = 0
126 | module.body[0].body.insert(
127 | module.body[0].body.index(node) + i,
128 | node_to_insert
129 | )
130 | i += 1
131 |
132 | def insert_print_statements(self, variables):
133 | module = self.get_ast_module()
134 | for node in module.body[0].body:
135 | # skip any statement apart from assignment
136 | if not isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign)):
137 | continue
138 | targets = []
139 | # a = 5
140 | if isinstance(node, ast.Assign):
141 | targets = node.targets
142 | # a: int = 5 or a += 5
143 | elif isinstance(node, (ast.AnnAssign, ast.AugAssign)):
144 | targets = [node.target]
145 |
146 | for target in targets:
147 | if isinstance(target, ast.Name) and target.id in variables:
148 | nodes_to_insert = self.get_nodes_to_insert(target, node)
149 | self.insert_nodes(nodes_to_insert, node)
150 |
151 |
152 | class Executor:
153 | """
154 | Executes and times function after inserting print statements
155 | """
156 | def __init__(self, processor: AstProcessor, variables: list, exec_info=True):
157 | self.processor = processor
158 | self.exec_info = exec_info
159 | self.variables = variables
160 | self.fn_wrapper = processor.fn_wrapper
161 | self.max_len = processor.max_len
162 |
163 | def print_execution_info(self, result, runtime):
164 | debug_prefix = self.fn_wrapper.debug_prefix(
165 | self.fn_wrapper.start_line_num,
166 | self.fn_wrapper.end_line_num
167 | )
168 | print(f"\n{debug_prefix}")
169 | print(f" args: {green(self.fn_wrapper.args)}")
170 | print(f" kwargs: {green(self.fn_wrapper.kwargs)}")
171 | print(f" returned: {green(trunc(result, self.max_len))}")
172 | print(f" execution time: {green(f'{runtime:.8f} seconds')}\n")
173 |
174 | def execute(self):
175 | self.processor.insert_print_statements(self.variables)
176 | module = self.processor.get_ast_module()
177 | start = t.perf_counter()
178 | # compile and execute
179 | code = compile(module, filename='', mode='exec')
180 | global_vars = {**globals(), **locals(), **self.fn_wrapper.func.__globals__}
181 | namespace = {
182 | self.fn_wrapper.func.__name__: self.fn_wrapper.func,
183 | **global_vars
184 | }
185 | exec(code, namespace)
186 | fn = namespace[self.fn_wrapper.func.__name__]
187 | result = fn(*self.fn_wrapper.args, **self.fn_wrapper.kwargs)
188 | runtime = t.perf_counter() - start
189 | if self.exec_info:
190 | self.print_execution_info(result, runtime)
191 | return result
192 |
193 |
194 | def trace(
195 | *variables,
196 | exec_info=True,
197 | verbose=True,
198 | use_spaces=True,
199 | max_len=None,
200 | ):
201 | """
202 | An experimental decorator for tracing function execution using AST.
203 |
204 | Args:
205 | variables (list): List of variable names to trace.
206 | exec_info (bool, optional): Whether to print execution info.
207 | verbose (bool, optional): Whether to print detailed trace info.
208 | use_spaces (bool, optional): Whether to add empty lines for readability.
209 | max_len (int, optional): Max length of printed values. Truncates if exceeded.
210 |
211 | Returns:
212 | function: Decorator for function tracing.
213 | """
214 |
215 | if max_len and not isinstance(max_len, int):
216 | raise ValueError("max_len must be an integer")
217 |
218 | def decorator(func):
219 |
220 | @functools.wraps(func)
221 | def wrapper(*args, **kwargs):
222 | fn_wrapper = FunctionWrapper(func, args, kwargs)
223 | processor = AstProcessor(fn_wrapper, verbose, use_spaces, max_len)
224 | executor = Executor(processor, variables, exec_info)
225 | ret = executor.execute()
226 | return ret
227 |
228 | return wrapper
229 | return decorator
230 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import pathlib
2 | from distutils.core import setup
3 |
4 | long_description = pathlib.Path("README.rst").read_text()
5 |
6 | setup(
7 | name="constable",
8 | packages=["constable"],
9 | version="0.3.1",
10 | license="MIT",
11 | description="One decorator for lazy debugging. Inserts print statements directly into your AST.",
12 | long_description=long_description,
13 | long_description_content_type= 'text/x-rst',
14 | author="Saurabh Pujari",
15 | author_email="saurabhpuj99@gmail.com",
16 | url="https://github.com/saurabh0719/constable",
17 | keywords=[
18 | "debugger",
19 | "decorator",
20 | "tracker",
21 | "state tracker",
22 | "variable state",
23 | "timer",
24 | ],
25 | project_urls={
26 | "Documentation": "https://github.com/saurabh0719/constable#README",
27 | "Source": "https://github.com/saurabh0719/constable",
28 | },
29 | install_requires=[],
30 | classifiers=[
31 | "Development Status :: 4 - Beta",
32 | "Intended Audience :: Developers",
33 | "License :: OSI Approved :: MIT License",
34 | "Programming Language :: Python",
35 | ],
36 | )
37 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from io import StringIO
3 | from contextlib import redirect_stdout
4 |
5 | import constable
6 |
7 | class TestDebugDecorator(unittest.TestCase):
8 | def test_decorator_does_not_change_behavior(self):
9 | @constable.trace('a', 'b', 'c', verbose=False)
10 | def complex_function(a, b, c):
11 | d = a + b
12 | e = b * c
13 | f = a - c
14 | return d, e, f
15 |
16 | with redirect_stdout(StringIO()):
17 | self.assertEqual(complex_function(1, 2, 3), (3, 6, -2))
18 |
19 | def test_decorator_output_count(self):
20 | @constable.trace('a', 'b', verbose=True)
21 | def add(a, b):
22 | a = a + 1
23 | b: int = b + 1
24 | a += 1
25 | return a + b
26 |
27 | f = StringIO()
28 | with redirect_stdout(f):
29 | add(1, 2)
30 | output = f.getvalue()
31 | num_lines = len(output.split('\n')) - 1
32 | self.assertEqual(num_lines, 22)
33 |
--------------------------------------------------------------------------------