├── .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 | --------------------------------------------------------------------------------