├── .github ├── ISSUE_TEMPLATE │ └── bug_report.yml └── workflows │ └── test.yaml ├── .gitignore ├── .readthedocs.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── logo_raw_bordered.png └── logo_raw_pip_bordered-60px.png ├── developer_resources ├── make_release.sh ├── sample_attribute_node.py ├── sample_for_node.py ├── sample_import_node.py └── simple_indent_nodes.py ├── docs ├── contributing │ ├── index.md │ └── roadmap.md ├── index.md ├── maintaining │ └── index.md ├── quick_start │ └── index.md ├── requirements.txt └── usage │ └── index.md ├── mkdocs.yml ├── pyproject.toml ├── requirements.txt ├── src └── py_bugger │ ├── __init__.py │ ├── buggers.py │ ├── cli │ ├── __init__.py │ ├── cli.py │ ├── cli_messages.py │ ├── cli_utils.py │ └── config.py │ ├── py_bugger.py │ └── utils │ ├── bug_utils.py │ ├── cst_utils.py │ └── file_utils.py └── tests ├── e2e_tests ├── conftest.py ├── reference_files │ └── help.txt ├── test_basic_behavior.py ├── test_cli_flags.py ├── test_git_status_checks.py ├── test_non_bugmaking_behavior.py └── test_project_setup.py ├── sample_code └── sample_scripts │ ├── all_indentation_blocks.py │ ├── blank_file.py │ ├── dog.py │ ├── dog_bark.py │ ├── dog_bark_no_trailing_newline.py │ ├── dog_bark_two_trailing_newlines.py │ ├── else_block.py │ ├── hello.txt │ ├── identical_attributes.py │ ├── many_dogs.py │ ├── name_picker.py │ ├── simple_indent.py │ ├── simple_indent_tab.py │ ├── system_info_script.py │ ├── ten_imports.py │ └── zero_imports.py └── unit_tests ├── test_bug_utils.py └── test_file_utils.py /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Open a new bug report 3 | title: "[BUG]: " 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thank you for taking the time to report a bug. 10 | 11 | - type: textarea 12 | id: bug-description 13 | attributes: 14 | label: Description 15 | description: Briefly describe the bug. Please be as specific as possible. 16 | - type: textarea 17 | id: environment 18 | attributes: 19 | label: Environment 20 | description: Please describe the environment in which the program was run. What operating system are you using? What version of Python are you using? How did you install python-bugger? 21 | - type: textarea 22 | id: Execution 23 | attributes: 24 | label: Execution 25 | description: How did you run the program? Please give the exact command you used, such as `py-bugger -e ModuleNotFoundError`. 26 | - type: textarea 27 | id: target-project 28 | attributes: 29 | label: Target project 30 | description: What project did you run py-bugger against? If it's a public project, please provide a link to the project. 31 | - type: textarea 32 | id: results 33 | attributes: 34 | label: Results 35 | description: What were the results of running py-bugger against the target project? Did it fail to introduce the requested exception type? Did py-bugger crash? (If so, please provide the full traceback.) 36 | - type: textarea 37 | id: insights 38 | attributes: 39 | label: Insights 40 | description: Do you have any insights into what might have caused this bug? Is there anything else you can share that might be helpful in identifying the root cause, and developing a fix? 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: py-bugger CI tests 2 | 3 | on: 4 | pull_request: 5 | 6 | push: 7 | branches: 8 | - main 9 | 10 | workflow_dispatch: 11 | 12 | jobs: 13 | test: 14 | name: Run tests on ${{ matrix.os }} with Python ${{ matrix.python-version}} 15 | runs-on: ${{ matrix.os }} 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | os: [ubuntu-latest, macos-latest, windows-latest] 21 | python-version: ["3.12"] #["3.9", "3.10", "3.11", "3.12", "3.13"] 22 | 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | 32 | # --- macOS and Linux tests --- 33 | 34 | - name: Run macOS and Linux tests 35 | 36 | if: startsWith(matrix.os, 'macos-') || startsWith(matrix.os, 'ubuntu-') 37 | run: | 38 | # Install uv 39 | curl -LsSf https://astral.sh/uv/install.sh | sh 40 | 41 | # Build and activate virtual environment 42 | uv venv .venv 43 | source .venv/bin/activate 44 | 45 | # Install dependencies 46 | uv pip install -r requirements.txt 47 | uv pip install -e ../py-bugger 48 | 49 | # Configure Git 50 | git config --global user.email "ci_tester@example.com" 51 | git config --global user.name "Ci Tester" 52 | git config --global init.defaultBranch main 53 | 54 | # Run all tests 55 | source .venv/bin/activate 56 | pytest -x 57 | 58 | - name: Run Windows tests 59 | 60 | if: startsWith(matrix.os, 'windows-') 61 | run: | 62 | # Install uv 63 | powershell -c "irm https://astral.sh/uv/install.ps1 | iex" 64 | $env:Path = "C:\Users\runneradmin\.local\bin;$env:Path" 65 | 66 | # Build and activate virtual environment 67 | uv venv .venv 68 | .venv\Scripts\activate 69 | 70 | # Install dependencies 71 | uv pip install -r requirements.txt 72 | uv pip install -e ..\py-bugger 73 | 74 | # Configure Git 75 | git config --global user.email "ci_tester@example.com" 76 | git config --global user.name "Ci Tester" 77 | git config --global init.defaultBranch main 78 | 79 | # Run all tests 80 | pytest -x 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | 3 | __pycache__/ 4 | *.pyc 5 | 6 | .DS_Store 7 | 8 | build/ 9 | src/python_bugger.egg-info/ 10 | 11 | dist/ 12 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | # Require Python 3.12, to match overall project requirements. 4 | build: 5 | os: ubuntu-22.04 6 | tools: 7 | python: "3.12" 8 | 9 | # Python requirements. 10 | python: 11 | install: 12 | - requirements: docs/requirements.txt 13 | 14 | mkdocs: 15 | configuration: mkdocs.yml -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog: python-bugger 2 | === 3 | 4 | 0.4 - Git status checks 5 | --- 6 | 7 | Looks for a clean Git status before introducing bugs. 8 | 9 | ### 0.4.0 10 | 11 | #### External changes 12 | 13 | - Checks Git status before introducing bugs. 14 | - Allows overriding Git checks with `--ignore-git-status`. 15 | 16 | #### Internal changes 17 | 18 | - Moving `py_bugger` import closer to where it's needed speeds up tests. 19 | 20 | 21 | 0.3 - Multiple exception types targeted 22 | --- 23 | 24 | Can request more than one type of exception to be induced. 25 | 26 | ### 0.3.6 27 | 28 | #### External changes 29 | 30 | - Includes validation for --target-dir and --target-file args. 31 | 32 | ### 0.3.5 33 | 34 | #### External changes 35 | 36 | - Removes else, elif, case, except and finally from targets for IndentationError for now. 37 | 38 | #### Internal changes 39 | 40 | - Added a release script. 41 | - Partial implementation of a test for handling tabs correctly. 42 | 43 | ### 0.3.4 44 | 45 | #### External changes 46 | 47 | - Fixes a bug where the last trailing newline was not written back to the file after introducing bugs that cause an `IndentationError`. 48 | 49 | #### Internal changes 50 | 51 | - Adds thorough tests for handling trailing newlines when modifying files. 52 | 53 | ### 0.3.3 54 | 55 | #### External changes 56 | 57 | - Added a `--verbose` (`-v`) flag. Only shows where bugs were added when this flag is present. 58 | 59 | #### Internal changes 60 | 61 | - The `pb_config` object is imported directly into *buggers.py*, and does not need to be passed to each bugger function. 62 | 63 | ### 0.3.2 64 | 65 | #### External changes 66 | 67 | - Does not modify files in directories named `Tests/`. 68 | - Moved docs to Read the Docs. 69 | 70 | #### Internal changes 71 | 72 | - Set up CI. 73 | - Move CLI code to a cli/ dir. 74 | - Move buggers.py out of utils/. 75 | - Make a cli_utils.py module. 76 | - Use a `config` object for CLI options. 77 | - Simpler parsing of CLI options. 78 | - Simpler approach to getting `py_files` in `main()`. 79 | - Issue template for bug reports. 80 | - Move helper functions from buggers.py to appropriate utility modules. 81 | 82 | ### 0.3.1 83 | 84 | #### External changes 85 | 86 | - Wider variety of bugs generated to induce requested exception type. 87 | - Greater variety of typos. 88 | - Greater variety in placement of bugs. 89 | - Supports `-e IndentationError`. 90 | 91 | #### Internal changes 92 | 93 | - The `developer_resources/` dir contains sample nodes. 94 | - Uses a generic `NodeCollector` class. 95 | - Utility functions for generating bugs, ie `utils/bug_utils.make_typo()`. 96 | - End to end tests are less specific, so more resilient to changes in bugmaking algos, while still ensuring the requested exception type is induced. 97 | - Helper function to get all nodes in a file, to support development work. 98 | - Use `random.sample()` (no replacement) rather than `random.choices()` (uses replacement) when selecting which nodes to modify. 99 | 100 | ### 0.3.0 101 | 102 | #### External changes 103 | 104 | - Support for `--exception-type AttributeError`. 105 | 106 | 107 | 0.2 - Much wider range of bugs possible 108 | --- 109 | 110 | Still only results in a `ModuleNotFoundError`, but creates a much wider range of bugs to induce that error. Also, much better overall structure for continued development. 111 | 112 | ### 0.2.1 113 | 114 | #### External changes 115 | 116 | - Filters out .py files from dirs named `test_code/`. 117 | 118 | ### 0.2.0 119 | 120 | #### External changes 121 | 122 | - Require `click`. 123 | - Includes a `--num-bugs` arg. 124 | - Modifies specified number of import nodes. 125 | - Randomly selects which relevant node to modify. 126 | - Reports level of success. 127 | - Supports `--target-file` arg. 128 | - Better messaging when not including `--exception-type`. 129 | 130 | #### Internal changes 131 | 132 | - CLI is built on `click`, rather than `argparse`. 133 | - Uses a random seed when `PY_BUGGER_RANDOM_SEED` env var is set, for testing. 134 | - Utils dir, with initial `file_utils.py` module. 135 | - Finds all .py files we can consider changing. 136 | - If using Git, returns all tracked .py files not related to testing. 137 | - If not using Git, returns all .py files not in venv, dist, build, or tests. 138 | - Catches `TypeError` if unable to make desired change; we can focus on these kinds of changes as the project evolves. 139 | 140 | 141 | 0.1 - Proof of concept (one exception type implemented) 142 | --- 143 | 144 | This series of releases will serve as a proof of concept for the project. If it continues to be interesting and useful to people, particularly people teaching Python, I'll continue to develop it. 145 | 146 | I'm aiming for a stable API, but that is not guaranteed until the 1.0 release. If you have feedback about usage, please open a [discussion](https://github.com/ehmatthes/py-bugger/discussions/new/choose) or an [issue](https://github.com/ehmatthes/py-bugger/issues/new/choose). 147 | 148 | ### 0.1.0 149 | 150 | Initial release. Very limited implementation of: 151 | 152 | ```sh 153 | $ py-bugger --exception-type ModuleNotFoundError 154 | ``` 155 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Eric Matthes 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![py-bugger logo](https://raw.githubusercontent.com/ehmatthes/py-bugger/main/assets/logo_raw_bordered.png) 3 | 4 | py-bugger 5 | === 6 | 7 | When people learn debugging, they typically have to learn it by focusing on whatever bugs come up in their code. They don't get to work on specific kinds of errors, and they don't get the chance to progress from simple to more complex bugs. This is quite different from how we teach and learn just about any other skill. 8 | 9 | `py-bugger` lets you intentionally introduce specific kinds and numbers of bugs to a working project. You can introduce bugs to a project with a single file, or a much larger project. This is much different from the typical process of waiting for your next bug to show up, or introducing a bug yourself. `py-bugger` gives people a structured way to learn debugging, just as we approach all other areas of programming. 10 | 11 | Full documentation is at [https://py-bugger.readthedocs.io/](https://py-bugger.readthedocs.io/en/latest/). 12 | 13 | Installation 14 | --- 15 | 16 | ```sh 17 | $ pip install python-bugger 18 | ``` 19 | 20 | Note: The package name is python-bugger, because py-bugger was unavailable on PyPI. 21 | 22 | ## Basic usage 23 | 24 | If you don't specify a target directory or file, `py-bugger` will look at all *.py* files in the current directory before deciding where to insert a bug. If the directory is a Git repository, it will follow the rules in *.gitignore*. It will also avoid introducing bugs into test directories and virtual environments that follow familiar naming patterns. 25 | 26 | `py-bugger` creates bugs that induce specific exceptions. Here's how to create a bug that generates a `ModuleNotFoundError`: 27 | 28 | ```sh 29 | $ py-bugger -e ModuleNotFoundError 30 | Introducing a ModuleNotFoundError... 31 | Modified file. 32 | ``` 33 | 34 | When you run the project again, it should fail with a `ModuleNotFoundError`. 35 | 36 | For more details, see the [Quick Start](https://py-bugger.readthedocs.io/en/latest/quick_start/) and [Usage](https://py-bugger.readthedocs.io/en/latest/usage/) pages in the official [docs](https://py-bugger.readthedocs.io/en/latest/). 37 | 38 | 39 | Contributing 40 | --- 41 | 42 | If you're interested in this project, please feel free to get in touch. If you have general feedback or just want to see the project progress, please share your thoughts in the [Initial feedback](https://github.com/ehmatthes/py-bugger/discussions/7) discussion. Also, feel free to [open a new issue](https://github.com/ehmatthes/py-bugger/issues/new). The [contributing](https://py-bugger.readthedocs.io/en/latest/contributing/) section in the official docs has more information about how to contribute. 43 | -------------------------------------------------------------------------------- /assets/logo_raw_bordered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ehmatthes/py-bugger/fc0ff412e30fc8c189c73c6de38276eb5c949c3f/assets/logo_raw_bordered.png -------------------------------------------------------------------------------- /assets/logo_raw_pip_bordered-60px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ehmatthes/py-bugger/fc0ff412e30fc8c189c73c6de38276eb5c949c3f/assets/logo_raw_pip_bordered-60px.png -------------------------------------------------------------------------------- /developer_resources/make_release.sh: -------------------------------------------------------------------------------- 1 | # Release script for py-bugger. 2 | # 3 | # To make a new release: 4 | # - Update changelog 5 | # - Bump version 6 | # - Push to main 7 | # - Tag release: git tag vA.B.C, git push origin vA.B.C 8 | # - Run this script from the project root: 9 | # $ ./developer_resources/make_release.sh 10 | 11 | echo "\nMaking a new release of py-bugger..." 12 | 13 | echo " Working directory:" 14 | pwd 15 | 16 | # Remove previous build, and build new version. 17 | rm -rf dist/ 18 | python -m build 19 | 20 | # Push to PyPI. 21 | python -m twine upload dist/* 22 | 23 | # Open PyPI page in browser to verify push was successful. 24 | open "https://pypi.org/project/python-bugger/" -------------------------------------------------------------------------------- /developer_resources/sample_attribute_node.py: -------------------------------------------------------------------------------- 1 | Attribute( 2 | value=Name( 3 | value="random", 4 | lpar=[], 5 | rpar=[], 6 | ), 7 | attr=Name( 8 | value="choice", 9 | lpar=[], 10 | rpar=[], 11 | ), 12 | dot=Dot( 13 | whitespace_before=SimpleWhitespace( 14 | value="", 15 | ), 16 | whitespace_after=SimpleWhitespace( 17 | value="", 18 | ), 19 | ), 20 | lpar=[], 21 | rpar=[], 22 | ) 23 | -------------------------------------------------------------------------------- /developer_resources/sample_for_node.py: -------------------------------------------------------------------------------- 1 | For( 2 | target=Name( 3 | value="num", 4 | lpar=[], 5 | rpar=[], 6 | ), 7 | iter=List( 8 | elements=[ 9 | Element( 10 | value=Integer( 11 | value="1", 12 | lpar=[], 13 | rpar=[], 14 | ), 15 | comma=Comma( 16 | whitespace_before=SimpleWhitespace( 17 | value="", 18 | ), 19 | whitespace_after=SimpleWhitespace( 20 | value=" ", 21 | ), 22 | ), 23 | ), 24 | Element( 25 | value=Integer( 26 | value="2", 27 | lpar=[], 28 | rpar=[], 29 | ), 30 | comma=Comma( 31 | whitespace_before=SimpleWhitespace( 32 | value="", 33 | ), 34 | whitespace_after=SimpleWhitespace( 35 | value=" ", 36 | ), 37 | ), 38 | ), 39 | Element( 40 | value=Integer( 41 | value="3", 42 | lpar=[], 43 | rpar=[], 44 | ), 45 | comma=MaybeSentinel.DEFAULT, 46 | ), 47 | ], 48 | lbracket=LeftSquareBracket( 49 | whitespace_after=SimpleWhitespace( 50 | value="", 51 | ), 52 | ), 53 | rbracket=RightSquareBracket( 54 | whitespace_before=SimpleWhitespace( 55 | value="", 56 | ), 57 | ), 58 | lpar=[], 59 | rpar=[], 60 | ), 61 | body=IndentedBlock( 62 | body=[ 63 | SimpleStatementLine( 64 | body=[ 65 | Expr( 66 | value=Call( 67 | func=Name( 68 | value="print", 69 | lpar=[], 70 | rpar=[], 71 | ), 72 | args=[ 73 | Arg( 74 | value=Name( 75 | value="num", 76 | lpar=[], 77 | rpar=[], 78 | ), 79 | keyword=None, 80 | equal=MaybeSentinel.DEFAULT, 81 | comma=MaybeSentinel.DEFAULT, 82 | star="", 83 | whitespace_after_star=SimpleWhitespace( 84 | value="", 85 | ), 86 | whitespace_after_arg=SimpleWhitespace( 87 | value="", 88 | ), 89 | ), 90 | ], 91 | lpar=[], 92 | rpar=[], 93 | whitespace_after_func=SimpleWhitespace( 94 | value="", 95 | ), 96 | whitespace_before_args=SimpleWhitespace( 97 | value="", 98 | ), 99 | ), 100 | semicolon=MaybeSentinel.DEFAULT, 101 | ), 102 | ], 103 | leading_lines=[], 104 | trailing_whitespace=TrailingWhitespace( 105 | whitespace=SimpleWhitespace( 106 | value="", 107 | ), 108 | comment=None, 109 | newline=Newline( 110 | value=None, 111 | ), 112 | ), 113 | ), 114 | ], 115 | header=TrailingWhitespace( 116 | whitespace=SimpleWhitespace( 117 | value="", 118 | ), 119 | comment=None, 120 | newline=Newline( 121 | value=None, 122 | ), 123 | ), 124 | indent=None, 125 | footer=[], 126 | ), 127 | orelse=None, 128 | asynchronous=None, 129 | leading_lines=[], 130 | whitespace_after_for=SimpleWhitespace( 131 | value=" ", 132 | ), 133 | whitespace_before_in=SimpleWhitespace( 134 | value=" ", 135 | ), 136 | whitespace_after_in=SimpleWhitespace( 137 | value=" ", 138 | ), 139 | whitespace_before_colon=SimpleWhitespace( 140 | value="", 141 | ), 142 | ) 143 | -------------------------------------------------------------------------------- /developer_resources/sample_import_node.py: -------------------------------------------------------------------------------- 1 | Import( 2 | names=[ 3 | ImportAlias( 4 | name=Name( 5 | value="os", 6 | lpar=[], 7 | rpar=[], 8 | ), 9 | asname=None, 10 | comma=MaybeSentinel.DEFAULT, 11 | ), 12 | ], 13 | semicolon=MaybeSentinel.DEFAULT, 14 | whitespace_after_import=SimpleWhitespace( 15 | value=" ", 16 | ), 17 | ) 18 | -------------------------------------------------------------------------------- /developer_resources/simple_indent_nodes.py: -------------------------------------------------------------------------------- 1 | [ 2 | Module( 3 | body=[ 4 | For( 5 | target=Name( 6 | value="num", 7 | lpar=[], 8 | rpar=[], 9 | ), 10 | iter=List( 11 | elements=[ 12 | Element( 13 | value=Integer( 14 | value="1", 15 | lpar=[], 16 | rpar=[], 17 | ), 18 | comma=Comma( 19 | whitespace_before=SimpleWhitespace( 20 | value="", 21 | ), 22 | whitespace_after=SimpleWhitespace( 23 | value=" ", 24 | ), 25 | ), 26 | ), 27 | Element( 28 | value=Integer( 29 | value="2", 30 | lpar=[], 31 | rpar=[], 32 | ), 33 | comma=Comma( 34 | whitespace_before=SimpleWhitespace( 35 | value="", 36 | ), 37 | whitespace_after=SimpleWhitespace( 38 | value=" ", 39 | ), 40 | ), 41 | ), 42 | Element( 43 | value=Integer( 44 | value="3", 45 | lpar=[], 46 | rpar=[], 47 | ), 48 | comma=MaybeSentinel.DEFAULT, 49 | ), 50 | ], 51 | lbracket=LeftSquareBracket( 52 | whitespace_after=SimpleWhitespace( 53 | value="", 54 | ), 55 | ), 56 | rbracket=RightSquareBracket( 57 | whitespace_before=SimpleWhitespace( 58 | value="", 59 | ), 60 | ), 61 | lpar=[], 62 | rpar=[], 63 | ), 64 | body=IndentedBlock( 65 | body=[ 66 | SimpleStatementLine( 67 | body=[ 68 | Expr( 69 | value=Call( 70 | func=Name( 71 | value="print", 72 | lpar=[], 73 | rpar=[], 74 | ), 75 | args=[ 76 | Arg( 77 | value=Name( 78 | value="num", 79 | lpar=[], 80 | rpar=[], 81 | ), 82 | keyword=None, 83 | equal=MaybeSentinel.DEFAULT, 84 | comma=MaybeSentinel.DEFAULT, 85 | star="", 86 | whitespace_after_star=SimpleWhitespace( 87 | value="", 88 | ), 89 | whitespace_after_arg=SimpleWhitespace( 90 | value="", 91 | ), 92 | ), 93 | ], 94 | lpar=[], 95 | rpar=[], 96 | whitespace_after_func=SimpleWhitespace( 97 | value="", 98 | ), 99 | whitespace_before_args=SimpleWhitespace( 100 | value="", 101 | ), 102 | ), 103 | semicolon=MaybeSentinel.DEFAULT, 104 | ), 105 | ], 106 | leading_lines=[], 107 | trailing_whitespace=TrailingWhitespace( 108 | whitespace=SimpleWhitespace( 109 | value="", 110 | ), 111 | comment=None, 112 | newline=Newline( 113 | value=None, 114 | ), 115 | ), 116 | ), 117 | ], 118 | header=TrailingWhitespace( 119 | whitespace=SimpleWhitespace( 120 | value="", 121 | ), 122 | comment=None, 123 | newline=Newline( 124 | value=None, 125 | ), 126 | ), 127 | indent=None, 128 | footer=[], 129 | ), 130 | orelse=None, 131 | asynchronous=None, 132 | leading_lines=[], 133 | whitespace_after_for=SimpleWhitespace( 134 | value=" ", 135 | ), 136 | whitespace_before_in=SimpleWhitespace( 137 | value=" ", 138 | ), 139 | whitespace_after_in=SimpleWhitespace( 140 | value=" ", 141 | ), 142 | whitespace_before_colon=SimpleWhitespace( 143 | value="", 144 | ), 145 | ), 146 | ], 147 | header=[], 148 | footer=[], 149 | encoding="utf-8", 150 | default_indent=" ", 151 | default_newline="\n", 152 | has_trailing_newline=True, 153 | ), 154 | For( 155 | target=Name( 156 | value="num", 157 | lpar=[], 158 | rpar=[], 159 | ), 160 | iter=List( 161 | elements=[ 162 | Element( 163 | value=Integer( 164 | value="1", 165 | lpar=[], 166 | rpar=[], 167 | ), 168 | comma=Comma( 169 | whitespace_before=SimpleWhitespace( 170 | value="", 171 | ), 172 | whitespace_after=SimpleWhitespace( 173 | value=" ", 174 | ), 175 | ), 176 | ), 177 | Element( 178 | value=Integer( 179 | value="2", 180 | lpar=[], 181 | rpar=[], 182 | ), 183 | comma=Comma( 184 | whitespace_before=SimpleWhitespace( 185 | value="", 186 | ), 187 | whitespace_after=SimpleWhitespace( 188 | value=" ", 189 | ), 190 | ), 191 | ), 192 | Element( 193 | value=Integer( 194 | value="3", 195 | lpar=[], 196 | rpar=[], 197 | ), 198 | comma=MaybeSentinel.DEFAULT, 199 | ), 200 | ], 201 | lbracket=LeftSquareBracket( 202 | whitespace_after=SimpleWhitespace( 203 | value="", 204 | ), 205 | ), 206 | rbracket=RightSquareBracket( 207 | whitespace_before=SimpleWhitespace( 208 | value="", 209 | ), 210 | ), 211 | lpar=[], 212 | rpar=[], 213 | ), 214 | body=IndentedBlock( 215 | body=[ 216 | SimpleStatementLine( 217 | body=[ 218 | Expr( 219 | value=Call( 220 | func=Name( 221 | value="print", 222 | lpar=[], 223 | rpar=[], 224 | ), 225 | args=[ 226 | Arg( 227 | value=Name( 228 | value="num", 229 | lpar=[], 230 | rpar=[], 231 | ), 232 | keyword=None, 233 | equal=MaybeSentinel.DEFAULT, 234 | comma=MaybeSentinel.DEFAULT, 235 | star="", 236 | whitespace_after_star=SimpleWhitespace( 237 | value="", 238 | ), 239 | whitespace_after_arg=SimpleWhitespace( 240 | value="", 241 | ), 242 | ), 243 | ], 244 | lpar=[], 245 | rpar=[], 246 | whitespace_after_func=SimpleWhitespace( 247 | value="", 248 | ), 249 | whitespace_before_args=SimpleWhitespace( 250 | value="", 251 | ), 252 | ), 253 | semicolon=MaybeSentinel.DEFAULT, 254 | ), 255 | ], 256 | leading_lines=[], 257 | trailing_whitespace=TrailingWhitespace( 258 | whitespace=SimpleWhitespace( 259 | value="", 260 | ), 261 | comment=None, 262 | newline=Newline( 263 | value=None, 264 | ), 265 | ), 266 | ), 267 | ], 268 | header=TrailingWhitespace( 269 | whitespace=SimpleWhitespace( 270 | value="", 271 | ), 272 | comment=None, 273 | newline=Newline( 274 | value=None, 275 | ), 276 | ), 277 | indent=None, 278 | footer=[], 279 | ), 280 | orelse=None, 281 | asynchronous=None, 282 | leading_lines=[], 283 | whitespace_after_for=SimpleWhitespace( 284 | value=" ", 285 | ), 286 | whitespace_before_in=SimpleWhitespace( 287 | value=" ", 288 | ), 289 | whitespace_after_in=SimpleWhitespace( 290 | value=" ", 291 | ), 292 | whitespace_before_colon=SimpleWhitespace( 293 | value="", 294 | ), 295 | ), 296 | SimpleWhitespace( 297 | value=" ", 298 | ), 299 | Name( 300 | value="num", 301 | lpar=[], 302 | rpar=[], 303 | ), 304 | SimpleWhitespace( 305 | value=" ", 306 | ), 307 | SimpleWhitespace( 308 | value=" ", 309 | ), 310 | List( 311 | elements=[ 312 | Element( 313 | value=Integer( 314 | value="1", 315 | lpar=[], 316 | rpar=[], 317 | ), 318 | comma=Comma( 319 | whitespace_before=SimpleWhitespace( 320 | value="", 321 | ), 322 | whitespace_after=SimpleWhitespace( 323 | value=" ", 324 | ), 325 | ), 326 | ), 327 | Element( 328 | value=Integer( 329 | value="2", 330 | lpar=[], 331 | rpar=[], 332 | ), 333 | comma=Comma( 334 | whitespace_before=SimpleWhitespace( 335 | value="", 336 | ), 337 | whitespace_after=SimpleWhitespace( 338 | value=" ", 339 | ), 340 | ), 341 | ), 342 | Element( 343 | value=Integer( 344 | value="3", 345 | lpar=[], 346 | rpar=[], 347 | ), 348 | comma=MaybeSentinel.DEFAULT, 349 | ), 350 | ], 351 | lbracket=LeftSquareBracket( 352 | whitespace_after=SimpleWhitespace( 353 | value="", 354 | ), 355 | ), 356 | rbracket=RightSquareBracket( 357 | whitespace_before=SimpleWhitespace( 358 | value="", 359 | ), 360 | ), 361 | lpar=[], 362 | rpar=[], 363 | ), 364 | LeftSquareBracket( 365 | whitespace_after=SimpleWhitespace( 366 | value="", 367 | ), 368 | ), 369 | SimpleWhitespace( 370 | value="", 371 | ), 372 | Element( 373 | value=Integer( 374 | value="1", 375 | lpar=[], 376 | rpar=[], 377 | ), 378 | comma=Comma( 379 | whitespace_before=SimpleWhitespace( 380 | value="", 381 | ), 382 | whitespace_after=SimpleWhitespace( 383 | value=" ", 384 | ), 385 | ), 386 | ), 387 | Integer( 388 | value="1", 389 | lpar=[], 390 | rpar=[], 391 | ), 392 | Comma( 393 | whitespace_before=SimpleWhitespace( 394 | value="", 395 | ), 396 | whitespace_after=SimpleWhitespace( 397 | value=" ", 398 | ), 399 | ), 400 | SimpleWhitespace( 401 | value="", 402 | ), 403 | SimpleWhitespace( 404 | value=" ", 405 | ), 406 | Element( 407 | value=Integer( 408 | value="2", 409 | lpar=[], 410 | rpar=[], 411 | ), 412 | comma=Comma( 413 | whitespace_before=SimpleWhitespace( 414 | value="", 415 | ), 416 | whitespace_after=SimpleWhitespace( 417 | value=" ", 418 | ), 419 | ), 420 | ), 421 | Integer( 422 | value="2", 423 | lpar=[], 424 | rpar=[], 425 | ), 426 | Comma( 427 | whitespace_before=SimpleWhitespace( 428 | value="", 429 | ), 430 | whitespace_after=SimpleWhitespace( 431 | value=" ", 432 | ), 433 | ), 434 | SimpleWhitespace( 435 | value="", 436 | ), 437 | SimpleWhitespace( 438 | value=" ", 439 | ), 440 | Element( 441 | value=Integer( 442 | value="3", 443 | lpar=[], 444 | rpar=[], 445 | ), 446 | comma=MaybeSentinel.DEFAULT, 447 | ), 448 | Integer( 449 | value="3", 450 | lpar=[], 451 | rpar=[], 452 | ), 453 | RightSquareBracket( 454 | whitespace_before=SimpleWhitespace( 455 | value="", 456 | ), 457 | ), 458 | SimpleWhitespace( 459 | value="", 460 | ), 461 | SimpleWhitespace( 462 | value="", 463 | ), 464 | IndentedBlock( 465 | body=[ 466 | SimpleStatementLine( 467 | body=[ 468 | Expr( 469 | value=Call( 470 | func=Name( 471 | value="print", 472 | lpar=[], 473 | rpar=[], 474 | ), 475 | args=[ 476 | Arg( 477 | value=Name( 478 | value="num", 479 | lpar=[], 480 | rpar=[], 481 | ), 482 | keyword=None, 483 | equal=MaybeSentinel.DEFAULT, 484 | comma=MaybeSentinel.DEFAULT, 485 | star="", 486 | whitespace_after_star=SimpleWhitespace( 487 | value="", 488 | ), 489 | whitespace_after_arg=SimpleWhitespace( 490 | value="", 491 | ), 492 | ), 493 | ], 494 | lpar=[], 495 | rpar=[], 496 | whitespace_after_func=SimpleWhitespace( 497 | value="", 498 | ), 499 | whitespace_before_args=SimpleWhitespace( 500 | value="", 501 | ), 502 | ), 503 | semicolon=MaybeSentinel.DEFAULT, 504 | ), 505 | ], 506 | leading_lines=[], 507 | trailing_whitespace=TrailingWhitespace( 508 | whitespace=SimpleWhitespace( 509 | value="", 510 | ), 511 | comment=None, 512 | newline=Newline( 513 | value=None, 514 | ), 515 | ), 516 | ), 517 | ], 518 | header=TrailingWhitespace( 519 | whitespace=SimpleWhitespace( 520 | value="", 521 | ), 522 | comment=None, 523 | newline=Newline( 524 | value=None, 525 | ), 526 | ), 527 | indent=None, 528 | footer=[], 529 | ), 530 | TrailingWhitespace( 531 | whitespace=SimpleWhitespace( 532 | value="", 533 | ), 534 | comment=None, 535 | newline=Newline( 536 | value=None, 537 | ), 538 | ), 539 | SimpleWhitespace( 540 | value="", 541 | ), 542 | Newline( 543 | value=None, 544 | ), 545 | SimpleStatementLine( 546 | body=[ 547 | Expr( 548 | value=Call( 549 | func=Name( 550 | value="print", 551 | lpar=[], 552 | rpar=[], 553 | ), 554 | args=[ 555 | Arg( 556 | value=Name( 557 | value="num", 558 | lpar=[], 559 | rpar=[], 560 | ), 561 | keyword=None, 562 | equal=MaybeSentinel.DEFAULT, 563 | comma=MaybeSentinel.DEFAULT, 564 | star="", 565 | whitespace_after_star=SimpleWhitespace( 566 | value="", 567 | ), 568 | whitespace_after_arg=SimpleWhitespace( 569 | value="", 570 | ), 571 | ), 572 | ], 573 | lpar=[], 574 | rpar=[], 575 | whitespace_after_func=SimpleWhitespace( 576 | value="", 577 | ), 578 | whitespace_before_args=SimpleWhitespace( 579 | value="", 580 | ), 581 | ), 582 | semicolon=MaybeSentinel.DEFAULT, 583 | ), 584 | ], 585 | leading_lines=[], 586 | trailing_whitespace=TrailingWhitespace( 587 | whitespace=SimpleWhitespace( 588 | value="", 589 | ), 590 | comment=None, 591 | newline=Newline( 592 | value=None, 593 | ), 594 | ), 595 | ), 596 | Expr( 597 | value=Call( 598 | func=Name( 599 | value="print", 600 | lpar=[], 601 | rpar=[], 602 | ), 603 | args=[ 604 | Arg( 605 | value=Name( 606 | value="num", 607 | lpar=[], 608 | rpar=[], 609 | ), 610 | keyword=None, 611 | equal=MaybeSentinel.DEFAULT, 612 | comma=MaybeSentinel.DEFAULT, 613 | star="", 614 | whitespace_after_star=SimpleWhitespace( 615 | value="", 616 | ), 617 | whitespace_after_arg=SimpleWhitespace( 618 | value="", 619 | ), 620 | ), 621 | ], 622 | lpar=[], 623 | rpar=[], 624 | whitespace_after_func=SimpleWhitespace( 625 | value="", 626 | ), 627 | whitespace_before_args=SimpleWhitespace( 628 | value="", 629 | ), 630 | ), 631 | semicolon=MaybeSentinel.DEFAULT, 632 | ), 633 | Call( 634 | func=Name( 635 | value="print", 636 | lpar=[], 637 | rpar=[], 638 | ), 639 | args=[ 640 | Arg( 641 | value=Name( 642 | value="num", 643 | lpar=[], 644 | rpar=[], 645 | ), 646 | keyword=None, 647 | equal=MaybeSentinel.DEFAULT, 648 | comma=MaybeSentinel.DEFAULT, 649 | star="", 650 | whitespace_after_star=SimpleWhitespace( 651 | value="", 652 | ), 653 | whitespace_after_arg=SimpleWhitespace( 654 | value="", 655 | ), 656 | ), 657 | ], 658 | lpar=[], 659 | rpar=[], 660 | whitespace_after_func=SimpleWhitespace( 661 | value="", 662 | ), 663 | whitespace_before_args=SimpleWhitespace( 664 | value="", 665 | ), 666 | ), 667 | Name( 668 | value="print", 669 | lpar=[], 670 | rpar=[], 671 | ), 672 | SimpleWhitespace( 673 | value="", 674 | ), 675 | SimpleWhitespace( 676 | value="", 677 | ), 678 | Arg( 679 | value=Name( 680 | value="num", 681 | lpar=[], 682 | rpar=[], 683 | ), 684 | keyword=None, 685 | equal=MaybeSentinel.DEFAULT, 686 | comma=MaybeSentinel.DEFAULT, 687 | star="", 688 | whitespace_after_star=SimpleWhitespace( 689 | value="", 690 | ), 691 | whitespace_after_arg=SimpleWhitespace( 692 | value="", 693 | ), 694 | ), 695 | SimpleWhitespace( 696 | value="", 697 | ), 698 | Name( 699 | value="num", 700 | lpar=[], 701 | rpar=[], 702 | ), 703 | SimpleWhitespace( 704 | value="", 705 | ), 706 | TrailingWhitespace( 707 | whitespace=SimpleWhitespace( 708 | value="", 709 | ), 710 | comment=None, 711 | newline=Newline( 712 | value=None, 713 | ), 714 | ), 715 | SimpleWhitespace( 716 | value="", 717 | ), 718 | Newline( 719 | value=None, 720 | ), 721 | ] 722 | -------------------------------------------------------------------------------- /docs/contributing/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Contributing 3 | hide: 4 | - footer 5 | --- 6 | 7 | # Contributing 8 | 9 | This project is still in an early phase of development, so it's a great time to jump in if you're interested. Please open or comment in an [issue](https://github.com/ehmatthes/py-bugger/issues) or a [discussion](https://github.com/ehmatthes/py-bugger/discussions) before starting any work you'd like to see merged, so we're all on the same page. 10 | 11 | All ideas are welcome at this point. If you're looking for specific tasks to help with, see the [Good first tasks](https://github.com/ehmatthes/py-bugger/issues/36) issue. 12 | 13 | ## Setting up a development environment 14 | 15 | Clone the project, and run the tests: 16 | 17 | ```sh 18 | $ git clone https://github.com/ehmatthes/py-bugger.git 19 | Cloning into 'py-bugger'... 20 | ... 21 | 22 | $ cd py-bugger 23 | py-bugger$ uv venv .venv 24 | py-bugger$ source .venv/bin/activate 25 | (.venv) py-bugger$ uv pip install -e ".[dev]" 26 | ... 27 | 28 | (.venv) py-bugger$ pytest 29 | ========== test session starts ========== 30 | tests/e2e_tests/test_basic_behavior.py ................. 31 | tests/unit_tests/test_bug_utils.py ..... 32 | tests/unit_tests/test_file_utils.py ... 33 | ========== 25 passed in 3.29s ========== 34 | ``` 35 | 36 | ## Development work 37 | 38 | There are two good approaches to development work. The first focuses on running `py-bugger` against a single .py file; the second focuses on running against a larger project with multiple .py files, nested in a more complex file structure. 39 | 40 | ### Running `py-bugger` against a single .py file 41 | 42 | Make a directory somewhere on your system, outside the `py-bugger` directory. Add a single .py file, and make an initial Git commit. Install `py-bugger` in editable mode, with a command like this: `uv pip install -e /path/to/py-bugger/`. 43 | 44 | The single file should be a minimal file that lets you introduce the kind of bug you're trying to create. For example if you want to focus on `IndentationError`, make a file of just a few lines, with an indented block. Now you can run `py-bugger`, see that it generates the expected error type, and run `git checkout .` to restore the .py file. 45 | 46 | Here's an example, using *simple_indent.py* from the *tests/sample_code/sample_scripts/* [directory](https://github.com/ehmatthes/py-bugger/tree/main/tests/sample_code/sample_scripts): 47 | 48 | ```sh 49 | $ mkdir pb-simple-test && cd pb-simple-test 50 | pb-simple-test$ cp ~/projects/py-bugger/tests/sample_code/sample_scripts/simple_indent.py simple_indent.py 51 | pb-simple-test$ ls 52 | simple_indent.py 53 | pb-simple-test$ nano .gitignore 54 | pb-simple-test$ git init 55 | Initialized empty Git repository in pb-simple-test/.git/ 56 | pb-simple-test$ git add . 57 | pb-simple-test$ git commit -am "Initial state." 58 | pb-simple-test$ uv venv .venv 59 | pb-simple-test$ source .venv/bin/activate 60 | (.venv) pb-simple-test$ uv pip install -e ~/projects/py-bugger/ 61 | (.venv) pb-simple-test$ python simple_indent.py 62 | 1 63 | 2 64 | 3 65 | 66 | (.venv) pb-simple-test$ py-bugger -e IndentationError 67 | Added bug. 68 | All requested bugs inserted. 69 | 70 | (.venv) pb-simple-test$ python simple_indent.py 71 | File "/Users/eric/test_codepb-simple-test/simple_indent.py", line 1 72 | for num in [1, 2, 3]: 73 | IndentationError: unexpected indent 74 | 75 | (.venv) pb-simple-test$ git checkout . 76 | (.venv) pb-simple-test$ python simple_indent.py 77 | 1 78 | 2 79 | 3 80 | ``` 81 | 82 | ### Running `py-bugger` against a larger project 83 | 84 | Once you have `py-bugger` working against a single .py file, you'll want to run it against a larger project as well. I've been using Pillow in development work, because it's a mature project with lots of nested .py files, and it has a solid test suite that runs in less than a minute. Whatever project you choose, make sure it has a well-developed test suite. Install `py-bugger` in editable mode, run it against the project, and then make sure the tests fail in the expected way due to the bug that was introduced. 85 | 86 | Here's how to run py-bugger against Pillow, and verify that it worked as expected: 87 | 88 | ```sh 89 | $ git clone https://github.com/python-pillow/Pillow.git pb-pillow 90 | $ cd pb-pillow 91 | pb-pillow$ uv venv .venv 92 | pb-pillow$ source .venv/bin/activate 93 | (.venv) /pb-pillow$ uv pip install -e ".[tests]" 94 | (.venv) /pb-pillow$ pytest 95 | ... 96 | ========== 4692 passed, 259 skipped, 3 xfailed in 46.65s ========== 97 | 98 | (.venv) /pb-pillow$ uv pip install -e ~/projects/py-bugger 99 | (.venv) /pb-pillow$ py-bugger -e AttributeError 100 | Added bug. 101 | All requested bugs inserted. 102 | (.venv) /pb-pillow$ pytest -qx 103 | ... 104 | E AttributeError: module 'PIL.TiffTags' has no attribute 'LONmG8'. Did you mean: 'LONG8'? 105 | ========== short test summary info ========== 106 | ERROR Tests/test_file_libtiff.py - AttributeError: module 'PIL.TiffTags' has no attribute 'LONmG8'. Did you mean: 'LONG8'? 107 | !!!!!!!!!! stopping after 1 failures !!!!!!!!!! 108 | 1 error in 0.33s 109 | ``` 110 | 111 | !!! note 112 | 113 | When you install the project you want to test against, make sure you install it in editable mode. I've made the mistake of installing Pillow without the `-e` flag, and the tests keep passing no matter how many bugs I add. 114 | 115 | !!! note 116 | 117 | Passing the `--verbose` (`-v`) flag will show you which files bugs were added to. This is not good for end users, who typically don't want to be told which files were modified. But it can be really helpful in development and testing work. 118 | 119 | ## Overall logic 120 | 121 | It's helpful to get a quick sense of how the project works. 122 | 123 | ### `src/py_bugger/cli/cli.py` 124 | 125 | The main public interface is defined in `cli.py`. The `cli()` function updates the `pb_config` object based on the current CLI args. These args are then validated, and the `main()` function is called. 126 | 127 | ### `src/py_bugger/py_bugger.py` 128 | 129 | The `main()` function in `py_bugger.py` collects the `py_files` that we can consider modifying. It then calls out to "bugger" functions that inspect the target code, identifying all the ways we could modify it to introduce the requested kind of bug. The actual bug that's introduced is chosen randomly on each run. After introducing bugs, a `success_msg` is generated showing whether the requested bugs were inserted. 130 | 131 | ### Notes 132 | 133 | - This is the ideal take. Currently, we're not identifying all possible ways any given bug could be introduced. Each bug that's supported is implemented in a way that we should see a significant variety of bugs generated in a project of moderate complexity. 134 | - The initial internal structure has not been fully refactored yet, because there's some behavior yet to refine. To be specific, questions about supporting multiple types of bugs in one call, and supporting logical errors will impact internal structure. 135 | 136 | ## Parsing code 137 | 138 | To introduce bugs, `py-bugger` needs to inspect all the code in the target .py file, or the appropriate set of .py files in a project. For most bugs, `py-bugger` uses a *Concrete Syntax Tree* (CST) to do this. When you convert Python code to an *Abstract Syntax Tree* (AST), it loses all comments and non-significant whitespace. We can't really use an AST, because we need to preserve the original comments and whitespace. A CST is like an AST, with comments and nons-significant whitespace included. 139 | 140 | Consider trying to induce an `AttributeError`. We want to find all attributes in a set of .py files. The CST is perfect for that. But if we want to find all indented lines, it can be simpler (and much faster) to just parse all the lines in all the files, and look for any leading whitespace. 141 | 142 | As the project evolves, most work will probably be done using the CST. It may be worthwhile to offer a `--quick` or `--fast` argument, which prefers non-CST parsing even if it means a smaller variety of possible bugs. 143 | 144 | ## Updating documentation 145 | 146 | Start a local documentation server: 147 | 148 | ```sh 149 | (.venv)$ mkdocs serve 150 | INFO - Building documentation... 151 | ... 152 | INFO - [16:24:31] Serving on http://127.0.0.1:8000/ 153 | ``` 154 | 155 | With the documentation server running, you can open a browser to the address shown and view a local copy of the docs. When you modify the files in `docs/`, you should see those changes immediately in your browser session. Sidebar navigation is configured in `mkdocs.yml`. 156 | 157 | ## Testing 158 | 159 | `py-bugger` currently has a small set of unit and end-to-end tests. The project is still evolving, and there's likely some significant refactoring that will happen before it fully stabilizes internally. We're aiming for test coverage that preserves current functionality, but isn't overly fragile to refactoring. Currently, the focus is on e2e tests for all significant external behavior, and unit tests for critical and stable utilities. 160 | 161 | ### Unit tests 162 | 163 | Unit tests currently require no setup. 164 | 165 | ### End-to-end tests 166 | 167 | End-to-end tests run `py-bugger` commands just as end users would, against a variety of scripts and small projects. This requires a bit of setup that's helpful to understand. 168 | 169 | Randomness plays an important role in creating all bugs, so a random seed is set in `tests/e2e_tests/conftest.py`. This is done in `set_random_seed_env()`, which sets an environment variable with session scope. 170 | 171 | The `e2e_config()` fixture returns a session-scoped config object containing paths used in most e2e tests. These include reference files, sample scripts, and the path to the Python interpreter for the current virtual environment. Note that this test config object is *not* the same as the `pb_config` object that's used in the main project. 172 | 173 | Most e2e test functions copy sample code to a temp directory, and then make a `py-bugger` call using either `--target-dir` or `--target-file` aimed at that directory. Usually, they run the target file as well. We then make various assertions about the bugs that were introduced, and the results of running the file or project after running `py-bugger`. -------------------------------------------------------------------------------- /docs/contributing/roadmap.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Roadmap 3 | hide: 4 | - footer 5 | --- 6 | 7 | # Roadmap 8 | 9 | Here's a brief roadmap of where I'm planning to take this project: 10 | 11 | - **(implemented)** Check for a clean Git state before introducing any bugs. 12 | - Make a new commit after introducing bugs. 13 | - Expand the variety of exception types that can be introduced. 14 | - Expand the variety of possible causes for inducing specific exceptions. 15 | - Generate logical (non-crashing) errors as well as specific exception types. 16 | - Expand usage to allow an arbitrary number and kind of bugs. 17 | - **(implemented)** Support an arbitrary number of one kind of bug. 18 | - Support multiple kinds of bugs in one call. 19 | - Develop a list of good projects to practice against. ie, clone from GitHub, run its tests, run `py-bugger`, and practice debugging. 20 | 21 | If you have any feedback or suggestions, please jump into the [issues](https://github.com/ehmatthes/py-bugger/issues) or [discussions](https://github.com/ehmatthes/py-bugger/discussions). -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | hide: 4 | - footer 5 | --- 6 | 7 | # py-bugger 8 | 9 | People typically have to learn debugging by focusing on whatever bug happens to come up in their development work. People don't usually get the chance to progress from simple to more complex bugs. 10 | 11 | `py-bugger` lets you intentionally introduce specific kinds of bugs to a working project. You can introduce bugs to a single *.py* file, or a fully-developed project. This is much different from the typical process of waiting for your next bug to show up, or introducing a bug yourself. 12 | 13 | `py-bugger` gives people a structured way to learn debugging, just as we approach most other areas of programming. 14 | 15 | --- 16 | 17 | [Quick Start](quick_start/index.md) 18 | 19 | [Usage](usage/index.md) 20 | 21 | [Contributing](contributing/index.md) 22 | 23 | [Roadmap](contributing/roadmap.md) -------------------------------------------------------------------------------- /docs/maintaining/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Maintaining 3 | hide: 4 | - footer 5 | --- 6 | 7 | # Maintaining 8 | 9 | Notes for maintainers. 10 | 11 | ## Making a new release 12 | --- 13 | 14 | - Make sure you're on the main branch, and you've pulled all recently merged changes: `git pull origin main` 15 | - Bump the version number in `pyproject.toml` 16 | - Make an entry in `CHANGELOG.md` 17 | - Commit this change: `git commit -am "Bumped version number, and updated changelog."` 18 | - Push this change directly to main: `git push origin main` 19 | - Delete everything in `dist/`: `rm -rf dist/` 20 | - Run `python -m build`, which recreates `dist/` 21 | - Tag the new release: 22 | - `$ git tag vA.B.C` 23 | - `$ git push origin vA.B.C` 24 | 25 | - Push to PyPI: 26 | ``` 27 | (venv)$ python -m twine upload dist/* 28 | ``` 29 | 30 | - View on PyPI: 31 | [https://pypi.org/project/python-bugger/](https://pypi.org/project/python-bugger/) 32 | 33 | ## Deleting branches 34 | 35 | Delete the remote and local development branches: 36 | 37 | ``` 38 | $ git push origin -d feature_branch 39 | $ git branch -d feature_branch 40 | ``` 41 | 42 | ## Deleting tags 43 | 44 | ``` 45 | $ git tag -d vA.B.C 46 | ``` 47 | 48 | - See [Git docs](https://git-scm.com/book/en/v2/Git-Basics-Tagging) for more about tagging. 49 | - See also [GH docs about releases](https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository). -------------------------------------------------------------------------------- /docs/quick_start/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Quick Start 3 | hide: 4 | - footer 5 | --- 6 | 7 | # Quick Start 8 | 9 | ## Installation 10 | 11 | ```sh 12 | $ pip install python-bugger 13 | ``` 14 | 15 | !!! note 16 | 17 | The package name is `python-bugger`, because `py-bugger` was unavailable on PyPI. 18 | 19 | ## Introducing a bug into a project 20 | 21 | If you don't specify a target directory or file, `py-bugger` will look at all *.py* files in the current directory before deciding where to insert a bug. If the directory is a Git repository, it will follow the rules in *.gitignore*. It will also avoid introducing bugs into test directories and virtual environments that follow familiar naming patterns. 22 | 23 | `py-bugger` creates bugs that induce specific exceptions. Here's how to create a bug that generates a `ModuleNotFoundError`: 24 | 25 | ```sh 26 | $ py-bugger -e ModuleNotFoundError 27 | Introducing a ModuleNotFoundError... 28 | Modified file. 29 | ``` 30 | 31 | When you run the project again, it should fail with a `ModuleNotFoundError`. 32 | 33 | 34 | ## Introducing a bug into a specific directory 35 | 36 | You can target any directory: 37 | 38 | ```sh 39 | $ py-bugger -e ModuleNotFoundError --target-dir /Users/eric/test_code/Pillow/ 40 | Introducing a ModuleNotFoundError... 41 | Modified file. 42 | ``` 43 | 44 | ## Introducing a bug into a specific *.py* file 45 | 46 | And you can target a specific file: 47 | 48 | ```sh 49 | $ py-bugger -e ModuleNotFoundError --target-file name_picker.py 50 | Introducing a ModuleNotFoundError... 51 | Modified file. 52 | ``` 53 | 54 | ## Supported exception types 55 | 56 | Currently, you can generate bugs that result in three exception types: 57 | 58 | - `ModuleNotFoundError` 59 | - `AttributeError` 60 | - `IndentationError` 61 | 62 | ## Caveat 63 | 64 | It's recommended to run `py-bugger` against a repository with a clean Git status. That way, if you get stuck resolving the bug that's introduced, you can either run `git diff` to see the actual bug, or restore the project to its original state. 65 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.6.1 2 | mkdocs-get-deps==0.2.0 3 | mkdocs-material==9.6.12 4 | mkdocs-material-extensions==1.3.1 5 | -------------------------------------------------------------------------------- /docs/usage/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Usage 3 | hide: 4 | - footer 5 | --- 6 | 7 | # Usage 8 | 9 | This page covers the full usage options for `py-bugger`. If you haven't already read the [Quick Start](../quick_start/index.md) page, it's best to start there. 10 | 11 | Here's the output of `py-bugger --help`, which summarizes all usage options: 12 | 13 | ```sh 14 | Usage: py-bugger [OPTIONS] 15 | 16 | Practice debugging, by intentionally introducing bugs into an existing 17 | codebase. 18 | 19 | Options: 20 | -e, --exception-type TEXT What kind of exception to induce: 21 | ModuleNotFoundError, AttributeError, or 22 | IndentationError 23 | --target-dir TEXT What code directory to target. (Be careful when 24 | using this arg!) 25 | --target-file TEXT Target a single .py file. 26 | -n, --num-bugs INTEGER How many bugs to introduce. 27 | --ignore-git-status Don't check Git status before inserting bugs. 28 | -v, --verbose Enable verbose output. 29 | --help Show this message and exit. 30 | ``` 31 | 32 | ## Introducing multiple bugs 33 | 34 | Currently, you can create multiple bugs that target any of the supported exception types. For example, this command will try to introduce three bugs that each induce an `IndentationError`: 35 | 36 | ```sh 37 | $ py-bugger -e IndentationError --n 3 38 | ``` 39 | 40 | ## Introducing multiple bugs of different types 41 | 42 | Currently, it's not possible to specify more than one exception type in a single `py-bugger` call. You may have luck running `py-bugger` multiple times, with different exception types: 43 | 44 | ```sh 45 | $ py-bugger -e IndentationError -n 2 46 | $ py-bugger -e ModuleNotFoundError 47 | ``` 48 | 49 | ## A note about speed 50 | 51 | Some bugs are easier to create than others. For example you can induce an `IndentationError` without closely examining the code. Other bugs take more work; to induce an `AttributeError`, you need to examine the code much more closely. Depending on the size of the codebase you're working with, you might see some very quick runs and some very slow runs. This is expected behavior. 52 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: py-bugger 2 | 3 | repo_url: https://github.com/ehmatthes/py-bugger/ 4 | 5 | markdown_extensions: 6 | - attr_list 7 | - tables 8 | - pymdownx.highlight: 9 | anchor_linenums: true 10 | - pymdownx.inlinehilite 11 | - pymdownx.snippets 12 | - pymdownx.superfences 13 | - pymdownx.tabbed: 14 | alternate_style: true 15 | - pymdownx.tasklist: 16 | custom_checkbox: false 17 | - def_list 18 | - admonition 19 | - pymdownx.details 20 | - footnotes 21 | 22 | nav: 23 | - "Introduction": "index.md" 24 | - "Quick Start": "quick_start/index.md" 25 | - "Usage": "usage/index.md" 26 | - "Contributing": 27 | - "contributing/index.md" 28 | - "Roadmap": "contributing/roadmap.md" 29 | - "Maintaining": "maintaining/index.md" 30 | 31 | theme: 32 | name: material 33 | features: 34 | - navigation.indexes -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "python-bugger" 3 | description = "Practice debugging, by intentionally introducing bugs into an existing codebase." 4 | readme = "README.md" 5 | version = "0.4.0" 6 | requires-python = ">=3.9" 7 | 8 | dependencies = ["libcst", "click"] 9 | 10 | [project.optional-dependencies] 11 | dev = [ 12 | "black>=24.1.0", 13 | "build>=1.2.1", 14 | "pytest>=8.3.0", 15 | "twine>=5.1.1", 16 | "mkdocs>=1.6.0", 17 | "mkdocs-material>=9.5.0", 18 | ] 19 | 20 | [build-system] 21 | requires = ["setuptools"] 22 | build-backend = "setuptools.build_meta" 23 | 24 | [tool.setuptools.packages.find] 25 | where = ["src"] 26 | 27 | [project.scripts] 28 | py-bugger = "py_bugger.cli.cli:cli" 29 | 30 | [project.urls] 31 | "Documentation" = "https://py-bugger.readthedocs.io/en/latest/" 32 | "GitHub" = "https://github.com/ehmatthes/py-bugger" 33 | "Changelog" = "https://github.com/ehmatthes/py-bugger/blob/main/CHANGELOG.md" 34 | 35 | [tool.black] 36 | # Sample code for tests sometimes require nonstandard formatting. 37 | extend-exclude = "tests/sample_code" 38 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | babel==2.17.0 2 | backrefs==5.8 3 | black==25.1.0 4 | build==1.2.2.post1 5 | certifi==2025.1.31 6 | charset-normalizer==3.4.1 7 | click==8.1.8 8 | colorama==0.4.6 9 | docutils==0.21.2 10 | ghp-import==2.1.0 11 | id==1.5.0 12 | idna==3.10 13 | iniconfig==2.0.0 14 | jaraco-classes==3.4.0 15 | jaraco-context==6.0.1 16 | jaraco-functools==4.1.0 17 | jinja2==3.1.6 18 | keyring==25.6.0 19 | libcst==1.7.0 20 | markdown==3.8 21 | markdown-it-py==3.0.0 22 | markupsafe==3.0.2 23 | mdurl==0.1.2 24 | mergedeep==1.3.4 25 | mkdocs==1.6.1 26 | mkdocs-get-deps==0.2.0 27 | mkdocs-material==9.6.12 28 | mkdocs-material-extensions==1.3.1 29 | more-itertools==10.6.0 30 | mypy-extensions==1.0.0 31 | nh3==0.2.21 32 | packaging==24.2 33 | paginate==0.5.7 34 | pathspec==0.12.1 35 | platformdirs==4.3.6 36 | pluggy==1.5.0 37 | pygments==2.19.1 38 | pymdown-extensions==10.14.3 39 | pyproject-hooks==1.2.0 40 | pytest==8.3.5 41 | python-dateutil==2.9.0.post0 42 | pyyaml==6.0.2 43 | pyyaml-env-tag==0.1 44 | readme-renderer==44.0 45 | requests==2.32.3 46 | requests-toolbelt==1.0.0 47 | rfc3986==2.0.0 48 | rich==13.9.4 49 | six==1.17.0 50 | twine==6.1.0 51 | urllib3==2.3.0 52 | watchdog==6.0.0 53 | -------------------------------------------------------------------------------- /src/py_bugger/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ehmatthes/py-bugger/fc0ff412e30fc8c189c73c6de38276eb5c949c3f/src/py_bugger/__init__.py -------------------------------------------------------------------------------- /src/py_bugger/buggers.py: -------------------------------------------------------------------------------- 1 | """Utilities for introducing specific kinds of bugs.""" 2 | 3 | import libcst as cst 4 | import random 5 | 6 | from py_bugger.utils import cst_utils 7 | from py_bugger.utils import file_utils 8 | from py_bugger.utils import bug_utils 9 | 10 | from py_bugger.cli.config import pb_config 11 | 12 | 13 | ### --- *_bugger functions --- 14 | 15 | 16 | def module_not_found_bugger(py_files): 17 | """Induce a ModuleNotFoundError. 18 | 19 | Returns: 20 | Int: Number of bugs made. 21 | """ 22 | # Find all relevant nodes. 23 | paths_nodes = cst_utils.get_paths_nodes(py_files, node_type=cst.Import) 24 | 25 | # Select the set of nodes to modify. If num_bugs is greater than the number 26 | # of nodes, just change each node. 27 | num_changes = min(len(paths_nodes), pb_config.num_bugs) 28 | paths_nodes_modify = random.sample(paths_nodes, k=num_changes) 29 | 30 | # Modify each relevant path. 31 | bugs_added = 0 32 | for path, node in paths_nodes_modify: 33 | source = path.read_text() 34 | tree = cst.parse_module(source) 35 | 36 | # Modify user's code. 37 | try: 38 | modified_tree = tree.visit(cst_utils.ImportModifier(node)) 39 | except TypeError: 40 | # DEV: Figure out which nodes are ending up here, and update 41 | # modifier code to handle these nodes. 42 | # For diagnostics, can run against Pillow with -n set to a 43 | # really high number. 44 | ... 45 | else: 46 | path.write_text(modified_tree.code) 47 | _report_bug_added(path) 48 | bugs_added += 1 49 | 50 | return bugs_added 51 | 52 | 53 | def attribute_error_bugger(py_files): 54 | """Induce an AttributeError. 55 | 56 | Returns: 57 | Int: Number of bugs made. 58 | """ 59 | # Find all relevant nodes. 60 | paths_nodes = cst_utils.get_paths_nodes(py_files, node_type=cst.Attribute) 61 | 62 | # Select the set of nodes to modify. If num_bugs is greater than the number 63 | # of nodes, just change each node. 64 | num_changes = min(len(paths_nodes), pb_config.num_bugs) 65 | paths_nodes_modify = random.sample(paths_nodes, k=num_changes) 66 | 67 | # Modify each relevant path. 68 | bugs_added = 0 69 | for path, node in paths_nodes_modify: 70 | source = path.read_text() 71 | tree = cst.parse_module(source) 72 | 73 | # Pick node to modify if more than one match in the file. 74 | node_count = cst_utils.count_nodes(tree, node) 75 | if node_count > 1: 76 | node_index = random.randrange(0, node_count - 1) 77 | else: 78 | node_index = 0 79 | 80 | # Modify user's code. 81 | try: 82 | modified_tree = tree.visit(cst_utils.AttributeModifier(node, node_index)) 83 | except TypeError: 84 | # DEV: Figure out which nodes are ending up here, and update 85 | # modifier code to handle these nodes. 86 | # For diagnostics, can run against Pillow with -n set to a 87 | # really high number. 88 | ... 89 | else: 90 | path.write_text(modified_tree.code) 91 | _report_bug_added(path) 92 | bugs_added += 1 93 | 94 | return bugs_added 95 | 96 | 97 | def indentation_error_bugger(py_files): 98 | """Induce an IndentationError. 99 | 100 | This simply parses raw source files. Conditions are pretty concrete, and LibCST 101 | doesn't make it easy to create invalid syntax. 102 | 103 | Returns: 104 | Int: Number of bugs made. 105 | """ 106 | # Find relevant files and lines. 107 | targets = [ 108 | "for", 109 | "while", 110 | "def", 111 | "class", 112 | "if", 113 | "with", 114 | "match", 115 | "try", 116 | ] 117 | paths_lines = file_utils.get_paths_lines(py_files, targets=targets) 118 | 119 | # Select the set of lines to modify. If num_bugs is greater than the number 120 | # of lines, just change each line. 121 | num_changes = min(len(paths_lines), pb_config.num_bugs) 122 | paths_lines_modify = random.sample(paths_lines, k=num_changes) 123 | 124 | # Modify each relevant path. 125 | bugs_added = 0 126 | for path, target_line in paths_lines_modify: 127 | if bug_utils.add_indentation(path, target_line): 128 | _report_bug_added(path) 129 | bugs_added += 1 130 | 131 | return bugs_added 132 | 133 | 134 | # --- Helper functions --- 135 | # DEV: This is a good place for helper functions, before they are refined enough 136 | # to move to utils/. 137 | 138 | 139 | def _report_bug_added(path_modified): 140 | """Report that a bug was added.""" 141 | if pb_config.verbose: 142 | print(f"Added bug to: {path_modified.as_posix()}") 143 | else: 144 | print(f"Added bug.") 145 | -------------------------------------------------------------------------------- /src/py_bugger/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ehmatthes/py-bugger/fc0ff412e30fc8c189c73c6de38276eb5c949c3f/src/py_bugger/cli/__init__.py -------------------------------------------------------------------------------- /src/py_bugger/cli/cli.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from py_bugger.cli import cli_utils 4 | from py_bugger.cli.config import pb_config 5 | 6 | 7 | @click.command() 8 | @click.option( 9 | "--exception-type", 10 | "-e", 11 | type=str, 12 | help="What kind of exception to induce: ModuleNotFoundError, AttributeError, or IndentationError", 13 | ) 14 | @click.option( 15 | "--target-dir", 16 | type=str, 17 | help="What code directory to target. (Be careful when using this arg!)", 18 | ) 19 | @click.option( 20 | "--target-file", 21 | type=str, 22 | help="Target a single .py file.", 23 | ) 24 | @click.option( 25 | "--num-bugs", 26 | "-n", 27 | type=int, 28 | default=1, 29 | help="How many bugs to introduce.", 30 | ) 31 | @click.option( 32 | "--ignore-git-status", 33 | is_flag=True, 34 | help="Don't check Git status before inserting bugs.", 35 | ) 36 | @click.option( 37 | "--verbose", 38 | "-v", 39 | is_flag=True, 40 | help="Enable verbose output.", 41 | ) 42 | def cli(**kwargs): 43 | """Practice debugging, by intentionally introducing bugs into an existing codebase.""" 44 | # Update pb_config using options passed through CLI call. 45 | pb_config.__dict__.update(kwargs) 46 | cli_utils.validate_config() 47 | 48 | # Importing py_bugger here cuts test time significantly, as these resources are not 49 | # loaded for many calls. (6.7s -> 5.4s, for 20% speedup, 6/10/25.) 50 | from py_bugger import py_bugger 51 | py_bugger.main() 52 | -------------------------------------------------------------------------------- /src/py_bugger/cli/cli_messages.py: -------------------------------------------------------------------------------- 1 | """Messages for use in CLI output.""" 2 | 3 | # --- Static messages --- 4 | 5 | msg_exception_type_required = ( 6 | "You must be explicit about what kinds of errors you want to induce in the project." 7 | ) 8 | 9 | msg_target_file_dir = ( 10 | "Target file overrides target dir. Please only pass one of these args." 11 | ) 12 | 13 | msg_git_not_available = "Git does not seem to be available. It's highly recommended that you run py-bugger against a file or project with a clean Git status. You can ignore this check with the --ignore-git-status argument." 14 | 15 | msg_unclean_git_status = "You have uncommitted changes in your project. It's highly recommended that you run py-bugger against a file or project with a clean Git status. You can ignore this check with the --ignore-git-status argument." 16 | 17 | 18 | # --- Dynamic messages --- 19 | 20 | 21 | def success_msg(num_added, num_requested): 22 | """Generate a success message at end of run.""" 23 | 24 | # Show a final success/fail message. 25 | if num_added == num_requested: 26 | return "All requested bugs inserted." 27 | elif num_added == 0: 28 | return "Unable to introduce any of the requested bugs." 29 | else: 30 | msg = f"Inserted {num_added} bugs." 31 | msg += "\nUnable to introduce additional bugs of the requested type." 32 | return msg 33 | 34 | 35 | # Messagess for invalid --target-dir calls. 36 | 37 | 38 | def msg_file_not_dir(target_file): 39 | """Specified --target-dir, but passed a file.""" 40 | msg = f"You specified --target-dir, but {target_file.name} is a file. Did you mean to use --target-file?" 41 | return msg 42 | 43 | 44 | def msg_nonexistent_dir(target_dir): 45 | """Passed a nonexistent dir to --target-dir.""" 46 | msg = f"The directory {target_dir.name} does not exist. Did you make a typo?" 47 | return msg 48 | 49 | 50 | def msg_not_dir(target_dir): 51 | """Passed something that exists to --target-dir, but it's not a dir.""" 52 | msg = f"{target_dir.name} does not seem to be a directory." 53 | return msg 54 | 55 | 56 | # Messages for invalid --target-file calls. 57 | 58 | 59 | def msg_dir_not_file(target_dir): 60 | """Specified --target-file, but passed a dir.""" 61 | msg = f"You specified --target-file, but {target_dir.name} is a directory. Did you mean to use --target-dir, or did you intend to pass a specific file from that directory?" 62 | return msg 63 | 64 | 65 | def msg_nonexistent_file(target_file): 66 | """Passed a nonexistent file to --target-file.""" 67 | msg = f"The file {target_file.name} does not exist. Did you make a typo?" 68 | return msg 69 | 70 | 71 | def msg_not_file(target_file): 72 | """Passed something that exists to --target-file, but it's not a file.""" 73 | msg = f"{target_file.name} does not seem to be a file." 74 | return msg 75 | 76 | 77 | def msg_file_not_py(target_file): 78 | """Passed a non-.py file to --target-file.""" 79 | msg = f"{target_file.name} does not appear to be a Python file." 80 | return msg 81 | 82 | 83 | # Messages for Git status-related issues. 84 | def msg_git_not_used(pb_config): 85 | """Git is not being used to manage target file or directory.""" 86 | if pb_config.target_file: 87 | target = "file" 88 | else: 89 | target = "directory" 90 | 91 | msg = f"The {target} you're running py-bugger against does not seem to be under version control. It's highly recommended that you run py-bugger against a file or project with a clean Git status. You can ignore this check with the --ignore-git-status argument." 92 | return msg 93 | -------------------------------------------------------------------------------- /src/py_bugger/cli/cli_utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for the CLI. 2 | 3 | If this grows into groups of utilities, move to a cli/utils/ dir, with more specific 4 | filenames. 5 | """ 6 | 7 | import os 8 | import sys 9 | from pathlib import Path 10 | import subprocess 11 | import shlex 12 | import shutil 13 | 14 | import click 15 | 16 | from py_bugger.cli import cli_messages 17 | from py_bugger.cli.config import pb_config 18 | 19 | 20 | def validate_config(): 21 | """Make sure the CLI options are valid.""" 22 | if not pb_config.exception_type: 23 | click.echo(cli_messages.msg_exception_type_required) 24 | sys.exit() 25 | 26 | if pb_config.target_dir and pb_config.target_file: 27 | click.echo(cli_messages.msg_target_file_dir) 28 | sys.exit() 29 | 30 | if pb_config.target_dir: 31 | _validate_target_dir() 32 | 33 | if pb_config.target_file: 34 | _validate_target_file() 35 | 36 | # Update all options before running Git status checks. Info like target_dir 37 | # is used for those checks. 38 | _update_options() 39 | 40 | _validate_git_status() 41 | 42 | 43 | # --- Helper functions ___ 44 | 45 | 46 | def _update_options(): 47 | """Make sure options are ready to use.""" 48 | # Set an appropriate target directory. 49 | if pb_config.target_dir: 50 | pb_config.target_dir = Path(pb_config.target_dir) 51 | else: 52 | pb_config.target_dir = Path(os.getcwd()) 53 | 54 | # Make sure target_file is a Path. 55 | if pb_config.target_file: 56 | pb_config.target_file = Path(pb_config.target_file) 57 | 58 | 59 | def _validate_target_dir(): 60 | """Make sure a valid directory was passed. 61 | 62 | Check for common mistakes, then verify it is a dir. 63 | """ 64 | path_target_dir = Path(pb_config.target_dir) 65 | if path_target_dir.is_file(): 66 | msg = cli_messages.msg_file_not_dir(path_target_dir) 67 | click.echo(msg) 68 | sys.exit() 69 | elif not path_target_dir.exists(): 70 | msg = cli_messages.msg_nonexistent_dir(path_target_dir) 71 | click.echo(msg) 72 | sys.exit() 73 | elif not path_target_dir.is_dir(): 74 | msg = cli_messages.msg_not_dir(path_target_dir) 75 | click.echo(msg) 76 | sys.exit() 77 | 78 | 79 | def _validate_target_file(): 80 | """Make sure an appropriate file was passed. 81 | 82 | Check for common mistakes, then verify it is a file. 83 | """ 84 | path_target_file = Path(pb_config.target_file) 85 | if path_target_file.is_dir(): 86 | msg = cli_messages.msg_dir_not_file(path_target_file) 87 | click.echo(msg) 88 | sys.exit() 89 | elif not path_target_file.exists(): 90 | msg = cli_messages.msg_nonexistent_file(path_target_file) 91 | click.echo(msg) 92 | sys.exit() 93 | elif not path_target_file.is_file(): 94 | msg = cli_messages.msg_not_file(path_target_file) 95 | click.echo(msg) 96 | sys.exit() 97 | elif path_target_file.suffix != ".py": 98 | msg = cli_messages.msg_file_not_py(path_target_file) 99 | click.echo(msg) 100 | sys.exit() 101 | 102 | 103 | def _validate_git_status(): 104 | """Look for a clean Git status before introducing bugs.""" 105 | if pb_config.ignore_git_status: 106 | return 107 | 108 | _check_git_available() 109 | _check_git_status() 110 | 111 | 112 | def _check_git_available(): 113 | """Quit with appropriate message if Git not available.""" 114 | if not shutil.which("git"): 115 | click.echo(cli_messages.msg_git_not_available) 116 | sys.exit() 117 | 118 | 119 | def _check_git_status(): 120 | """Make sure we're starting with a clean git status.""" 121 | if pb_config.target_file: 122 | git_dir = pb_config.target_file.parent 123 | else: 124 | git_dir = pb_config.target_dir 125 | 126 | cmd = "git status --porcelain" 127 | cmd_parts = shlex.split(cmd) 128 | output = subprocess.run(cmd_parts, cwd=git_dir, capture_output=True, text=True) 129 | 130 | if "fatal: not a git repository" in output.stderr: 131 | msg = cli_messages.msg_git_not_used(pb_config) 132 | click.echo(msg) 133 | sys.exit() 134 | 135 | # `git status --porcelain` has no output when the status is clean. 136 | if output.stdout or output.stderr: 137 | msg = cli_messages.msg_unclean_git_status 138 | click.echo(msg) 139 | sys.exit() 140 | -------------------------------------------------------------------------------- /src/py_bugger/cli/config.py: -------------------------------------------------------------------------------- 1 | """Config object to collect CLI options.""" 2 | 3 | from dataclasses import dataclass 4 | from pathlib import Path 5 | 6 | 7 | @dataclass 8 | class PBConfig: 9 | exception_type: str = "" 10 | target_dir: Path = "" 11 | target_file: Path = "" 12 | num_bugs: int = 1 13 | ignore_git_status: bool = False 14 | verbose: bool = True 15 | 16 | 17 | pb_config = PBConfig() 18 | -------------------------------------------------------------------------------- /src/py_bugger/py_bugger.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | 4 | from py_bugger import buggers 5 | from py_bugger.utils import file_utils 6 | 7 | from py_bugger.cli.config import pb_config 8 | from py_bugger.cli import cli_messages 9 | 10 | 11 | # Set a random seed when testing. 12 | if seed := os.environ.get("PY_BUGGER_RANDOM_SEED"): 13 | random.seed(int(seed)) 14 | 15 | 16 | def main(): 17 | # Get a list of .py files we can consider modifying. 18 | py_files = file_utils.get_py_files(pb_config.target_dir, pb_config.target_file) 19 | 20 | # Track how many bugs have been added. 21 | bugs_added = 0 22 | 23 | # Currently, handles just one exception type per call. 24 | # When multiple are supported, implement more complex logic for choosing which ones 25 | # to introduce, and tracking bugs. Also consider a more appropriate dispatch approach 26 | # as the project evolves. 27 | if pb_config.exception_type == "ModuleNotFoundError": 28 | new_bugs_made = buggers.module_not_found_bugger(py_files) 29 | bugs_added += new_bugs_made 30 | elif pb_config.exception_type == "AttributeError": 31 | new_bugs_made = buggers.attribute_error_bugger(py_files) 32 | bugs_added += new_bugs_made 33 | elif pb_config.exception_type == "IndentationError": 34 | new_bugs_made = buggers.indentation_error_bugger(py_files) 35 | bugs_added += new_bugs_made 36 | 37 | # Show a final success/fail message. 38 | msg = cli_messages.success_msg(bugs_added, pb_config.num_bugs) 39 | print(msg) 40 | -------------------------------------------------------------------------------- /src/py_bugger/utils/bug_utils.py: -------------------------------------------------------------------------------- 1 | """Resources for modifying code in ways that make it break.""" 2 | 3 | import random 4 | import builtins 5 | 6 | from py_bugger.utils import file_utils 7 | 8 | 9 | def make_typo(name): 10 | """Add a typo to the name of an identifier. 11 | 12 | Randomly decides which kind of change to make. 13 | """ 14 | typo_fns = [remove_char, insert_char, modify_char] 15 | 16 | while True: 17 | typo_fn = random.choice(typo_fns) 18 | new_name = typo_fn(name) 19 | 20 | # Reject names that match builtins. 21 | if new_name in dir(builtins): 22 | continue 23 | 24 | return new_name 25 | 26 | 27 | def remove_char(name): 28 | """Remove a character from the name.""" 29 | chars = list(name) 30 | index_remove = random.randint(0, len(chars) - 1) 31 | del chars[index_remove] 32 | 33 | return "".join(chars) 34 | 35 | 36 | def insert_char(name): 37 | """Insert a character into the name.""" 38 | chars = list(name) 39 | new_char = random.choice("abcdefghijklmnopqrstuvwxyz") 40 | index = random.randint(0, len(chars)) 41 | chars.insert(index, new_char) 42 | 43 | return "".join(chars) 44 | 45 | 46 | def modify_char(name): 47 | """Modify a character in a name.""" 48 | chars = list(name) 49 | index = random.randint(0, len(chars) - 1) 50 | 51 | # Make sure new_char does not match current char. 52 | while True: 53 | new_char = random.choice("abcdefghijklmnopqrstuvwxyz") 54 | if new_char != chars[index]: 55 | break 56 | chars[index] = new_char 57 | 58 | return "".join(chars) 59 | 60 | 61 | def add_indentation(path, target_line): 62 | """Add one level of indentation (four spaces) to line.""" 63 | indentation_added = False 64 | 65 | lines = path.read_text().splitlines(keepends=True) 66 | 67 | modified_lines = [] 68 | for line in lines: 69 | # `line` contains leading whitespace and trailing newline. 70 | # `target_line` just contains code, so use `in` rather than `==`. 71 | if target_line in line: 72 | line = f" {line}" 73 | modified_lines.append(line) 74 | indentation_added = True 75 | else: 76 | modified_lines.append(line) 77 | 78 | modified_source = "".join(modified_lines) 79 | path.write_text(modified_source) 80 | 81 | return indentation_added 82 | -------------------------------------------------------------------------------- /src/py_bugger/utils/cst_utils.py: -------------------------------------------------------------------------------- 1 | """Utilities for working with the CST.""" 2 | 3 | import libcst as cst 4 | 5 | from py_bugger.utils import bug_utils 6 | 7 | 8 | class NodeCollector(cst.CSTVisitor): 9 | """Collect all nodes of a specific kind.""" 10 | 11 | def __init__(self, node_type): 12 | self.node_type = node_type 13 | self.collected_nodes = [] 14 | 15 | def on_visit(self, node): 16 | """Visit each node, collecting nodes that match the node type.""" 17 | if isinstance(node, self.node_type): 18 | self.collected_nodes.append(node) 19 | return True 20 | 21 | 22 | class NodeCounter(cst.CSTVisitor): 23 | """Count all nodes matching the target node.""" 24 | 25 | def __init__(self, target_node): 26 | self.target_node = target_node 27 | self.node_count = 0 28 | 29 | def on_visit(self, node): 30 | """Increment node_count if node matches..""" 31 | if node.deep_equals(self.target_node): 32 | self.node_count += 1 33 | return True 34 | 35 | 36 | class ImportModifier(cst.CSTTransformer): 37 | """Modify imports in the user's project. 38 | 39 | Note: Each import should be unique, so there shouldn't be any need to track 40 | whether a bug was introduced. node_to_break should only match one node in the 41 | tree. 42 | """ 43 | 44 | def __init__(self, node_to_break): 45 | self.node_to_break = node_to_break 46 | 47 | def leave_Import(self, original_node, updated_node): 48 | """Modify a direct `import ` statement.""" 49 | names = updated_node.names 50 | 51 | if original_node.deep_equals(self.node_to_break): 52 | original_name = names[0].name.value 53 | 54 | # Add a typo to the name of the module being imported. 55 | new_name = bug_utils.make_typo(original_name) 56 | 57 | # Modify the node name. 58 | new_names = [cst.ImportAlias(name=cst.Name(new_name))] 59 | 60 | return updated_node.with_changes(names=new_names) 61 | 62 | return updated_node 63 | 64 | 65 | class AttributeModifier(cst.CSTTransformer): 66 | """Modify attributes in the user's project.""" 67 | 68 | def __init__(self, node_to_break, node_index): 69 | self.node_to_break = node_to_break 70 | 71 | # There may be identical nodes in the tree. node_index determines which to modify. 72 | self.node_index = node_index 73 | self.identical_nodes_visited = 0 74 | 75 | # Each use of this class should only generate one bug. But multiple nodes 76 | # can match node_to_break, so make sure we only modify one node. 77 | self.bug_generated = False 78 | 79 | def leave_Attribute(self, original_node, updated_node): 80 | """Modify an attribute name, to generate AttributeError.""" 81 | attr = updated_node.attr 82 | 83 | if original_node.deep_equals(self.node_to_break) and not self.bug_generated: 84 | # If there are identical nodes and this isn't the right one, bump count 85 | # and return unmodified node. 86 | if self.identical_nodes_visited != self.node_index: 87 | self.identical_nodes_visited += 1 88 | return updated_node 89 | 90 | original_identifier = attr.value 91 | 92 | # Add a typo to the attribute name. 93 | new_identifier = bug_utils.make_typo(original_identifier) 94 | 95 | # Modify the node name. 96 | new_attr = cst.Name(new_identifier) 97 | 98 | self.bug_generated = True 99 | 100 | return updated_node.with_changes(attr=new_attr) 101 | 102 | return updated_node 103 | 104 | 105 | def get_paths_nodes(py_files, node_type): 106 | """Get all nodes of given type.""" 107 | paths_nodes = [] 108 | for path in py_files: 109 | source = path.read_text() 110 | tree = cst.parse_module(source) 111 | 112 | node_collector = NodeCollector(node_type=node_type) 113 | tree.visit(node_collector) 114 | 115 | for node in node_collector.collected_nodes: 116 | paths_nodes.append((path, node)) 117 | 118 | return paths_nodes 119 | 120 | 121 | def get_all_nodes(path): 122 | """Get all nodes in a file. 123 | 124 | This is primarily for development work, where we want to see all the nodes 125 | in a short representative file. 126 | 127 | Example usage, from a #_bugger() function: 128 | nodes = _get_all_nodes(py_files[0]) 129 | """ 130 | source = path.read_text() 131 | tree = cst.parse_module(source) 132 | 133 | node_collector = NodeCollector(node_type=cst.CSTNode) 134 | tree.visit(node_collector) 135 | 136 | return node_collector.collected_nodes 137 | 138 | 139 | def count_nodes(tree, node): 140 | """Count the number of nodes in path that match node. 141 | 142 | Useful when a file has multiple identical nodes, and we want to choose one. 143 | """ 144 | # Count all relevant nodes. 145 | node_counter = NodeCounter(node) 146 | tree.visit(node_counter) 147 | 148 | return node_counter.node_count 149 | -------------------------------------------------------------------------------- /src/py_bugger/utils/file_utils.py: -------------------------------------------------------------------------------- 1 | """Utilities for working with the target project's files and directories.""" 2 | 3 | import subprocess 4 | import shlex 5 | from pathlib import Path 6 | import sys 7 | 8 | 9 | # --- Public functions --- 10 | 11 | 12 | def get_py_files(target_dir, target_file): 13 | """Get all the .py files we can consider modifying when introducing bugs.""" 14 | # Check if user requested a single target file. 15 | if target_file: 16 | return [target_file] 17 | 18 | # Use .gitignore if possible. 19 | path_git = target_dir / ".git" 20 | if path_git.exists(): 21 | return _get_py_files_git(target_dir) 22 | else: 23 | return _get_py_files_non_git(target_dir) 24 | 25 | 26 | def get_paths_lines(py_files, targets): 27 | """Get all lines from all files matching targets.""" 28 | paths_lines = [] 29 | for path in py_files: 30 | lines = path.read_text().splitlines() 31 | for line in lines: 32 | stripped_line = line.strip() 33 | if any([stripped_line.startswith(target) for target in targets]): 34 | paths_lines.append((path, line)) 35 | 36 | return paths_lines 37 | 38 | 39 | # --- Helper functions --- 40 | 41 | 42 | def _get_py_files_git(target_dir): 43 | """Get all relevant .py files from a directory manage.py by Git.""" 44 | cmd = 'git ls-files "*.py"' 45 | cmd_parts = shlex.split(cmd) 46 | output = subprocess.run(cmd_parts, capture_output=True) 47 | py_files = output.stdout.decode().strip().splitlines() 48 | 49 | # Convert to path objects. Filter out any test-related files. 50 | py_files = [Path(f) for f in py_files] 51 | py_files = [pf for pf in py_files if "tests/" not in pf.as_posix()] 52 | py_files = [pf for pf in py_files if "Tests/" not in pf.as_posix()] 53 | py_files = [pf for pf in py_files if "test_code/" not in pf.as_posix()] 54 | py_files = [pf for pf in py_files if pf.name != "conftest.py"] 55 | py_files = [pf for pf in py_files if not pf.name.startswith("test_")] 56 | 57 | return py_files 58 | 59 | 60 | def _get_py_files_non_git(target_dir): 61 | """Get all relevant .py files from a directory not managed by Git.""" 62 | py_files = target_dir.rglob("*.py") 63 | 64 | exclude_dirs = [ 65 | ".venv/", 66 | "venv/", 67 | "tests/", 68 | "Tests/", 69 | "test_code/", 70 | "build/", 71 | "dist/", 72 | ] 73 | py_files = [ 74 | pf 75 | for pf in py_files 76 | if not any(ex_dir in pf.as_posix() for ex_dir in exclude_dirs) 77 | ] 78 | py_files = [pf for pf in py_files if pf.name != "conftest.py"] 79 | py_files = [pf for pf in py_files if not pf.name.startswith("test_")] 80 | 81 | return py_files 82 | -------------------------------------------------------------------------------- /tests/e2e_tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import sys 3 | import os 4 | import platform 5 | 6 | import pytest 7 | 8 | 9 | # --- Fixtures --- 10 | 11 | 12 | @pytest.fixture(autouse=True, scope="session") 13 | def set_random_seed_env(): 14 | """Make random selections repeatable.""" 15 | # To verify a random action, set autouse to False and run one test. 16 | os.environ["PY_BUGGER_RANDOM_SEED"] = "10" 17 | 18 | 19 | @pytest.fixture(scope="session") 20 | def e2e_config(): 21 | """Resources useful to most tests.""" 22 | 23 | class Config: 24 | # Paths 25 | path_root = Path(__file__).parents[2] 26 | 27 | path_tests = path_root / "tests" 28 | 29 | path_reference_files = path_tests / "e2e_tests" / "reference_files" 30 | 31 | path_sample_code = path_tests / "sample_code" 32 | path_sample_scripts = path_sample_code / "sample_scripts" 33 | 34 | path_name_picker = path_sample_scripts / "name_picker.py" 35 | path_system_info = path_sample_scripts / "system_info_script.py" 36 | path_ten_imports = path_sample_scripts / "ten_imports.py" 37 | path_zero_imports = path_sample_scripts / "zero_imports.py" 38 | path_dog = path_sample_scripts / "dog.py" 39 | path_dog_bark = path_sample_scripts / "dog_bark.py" 40 | path_many_dogs = path_sample_scripts / "many_dogs.py" 41 | path_identical_attributes = path_sample_scripts / "identical_attributes.py" 42 | path_simple_indent = path_sample_scripts / "simple_indent.py" 43 | path_all_indentation_blocks = path_sample_scripts / "all_indentation_blocks.py" 44 | 45 | # Python executable 46 | if sys.platform == "win32": 47 | python_cmd = path_root / ".venv" / "Scripts" / "python" 48 | else: 49 | python_cmd = path_root / ".venv" / "bin" / "python" 50 | 51 | return Config() 52 | 53 | 54 | @pytest.fixture(scope="session") 55 | def on_windows(): 56 | """Some tests need to run differently on Windows.""" 57 | return platform.system() == "Windows" 58 | -------------------------------------------------------------------------------- /tests/e2e_tests/reference_files/help.txt: -------------------------------------------------------------------------------- 1 | Usage: py-bugger [OPTIONS] 2 | 3 | Practice debugging, by intentionally introducing bugs into an existing 4 | codebase. 5 | 6 | Options: 7 | -e, --exception-type TEXT What kind of exception to induce: 8 | ModuleNotFoundError, AttributeError, or 9 | IndentationError 10 | --target-dir TEXT What code directory to target. (Be careful when 11 | using this arg!) 12 | --target-file TEXT Target a single .py file. 13 | -n, --num-bugs INTEGER How many bugs to introduce. 14 | --ignore-git-status Don't check Git status before inserting bugs. 15 | -v, --verbose Enable verbose output. 16 | --help Show this message and exit. 17 | -------------------------------------------------------------------------------- /tests/e2e_tests/test_basic_behavior.py: -------------------------------------------------------------------------------- 1 | """Test basic behavior. 2 | 3 | - Copy sample code to a temp dir. 4 | - Run py-bugger against that code. 5 | - Verify correct exception is raised. 6 | """ 7 | 8 | import shutil 9 | import shlex 10 | import subprocess 11 | import filecmp 12 | import os 13 | import sys 14 | 15 | import pytest 16 | 17 | 18 | # --- Test functions --- 19 | 20 | 21 | def test_no_exception_type(tmp_path_factory, e2e_config): 22 | """Test output for not passing --exception-type.""" 23 | 24 | # Copy sample code to tmp dir. 25 | tmp_path = tmp_path_factory.mktemp("sample_code") 26 | print(f"\nCopying code to: {tmp_path.as_posix()}") 27 | 28 | path_dst = tmp_path / e2e_config.path_name_picker.name 29 | shutil.copyfile(e2e_config.path_name_picker, path_dst) 30 | 31 | # Make bare py-bugger call. 32 | cmd = f"py-bugger" 33 | cmd_parts = shlex.split(cmd) 34 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 35 | 36 | # Verify output. 37 | assert ( 38 | "You must be explicit about what kinds of errors you want to induce in the project." 39 | in stdout 40 | ) 41 | 42 | # Check that .py file is unchanged. 43 | assert filecmp.cmp(e2e_config.path_name_picker, path_dst) 44 | 45 | 46 | def test_help(e2e_config): 47 | """Test output of `py-bugger --help`.""" 48 | # Set an explicit column width, so output is consistent across systems. 49 | env = os.environ.copy() 50 | env["COLUMNS"] = "80" 51 | 52 | cmd = "py-bugger --help" 53 | cmd_parts = shlex.split(cmd) 54 | stdout = subprocess.run(cmd_parts, capture_output=True, env=env).stdout.decode() 55 | 56 | path_help_output = e2e_config.path_reference_files / "help.txt" 57 | assert stdout.replace("\r\n", "\n") == path_help_output.read_text().replace( 58 | "\r\n", "\n" 59 | ) 60 | 61 | 62 | def test_modulenotfounderror(tmp_path_factory, e2e_config): 63 | """py-bugger --exception-type ModuleNotFoundError""" 64 | 65 | # Copy sample code to tmp dir. 66 | tmp_path = tmp_path_factory.mktemp("sample_code") 67 | print(f"\nCopying code to: {tmp_path.as_posix()}") 68 | 69 | path_dst = tmp_path / e2e_config.path_name_picker.name 70 | shutil.copyfile(e2e_config.path_name_picker, path_dst) 71 | 72 | # Run py-bugger against directory. 73 | cmd = f"py-bugger --exception-type ModuleNotFoundError --target-dir {tmp_path.as_posix()} --ignore-git-status" 74 | cmd_parts = shlex.split(cmd) 75 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 76 | 77 | assert "All requested bugs inserted." in stdout 78 | 79 | # Run file, should raise ModuleNotFoundError. 80 | cmd = f"{e2e_config.python_cmd.as_posix()} {path_dst.as_posix()}" 81 | cmd_parts = shlex.split(cmd) 82 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 83 | assert "Traceback (most recent call last)" in stderr 84 | assert 'name_picker.py", line 1, in ' in stderr 85 | assert "ModuleNotFoundError: No module named" in stderr 86 | 87 | 88 | def test_default_one_error(tmp_path_factory, e2e_config): 89 | """py-bugger --exception-type ModuleNotFoundError 90 | 91 | Test that only one import statement is modified. 92 | """ 93 | 94 | # Copy sample code to tmp dir. 95 | tmp_path = tmp_path_factory.mktemp("sample_code") 96 | print(f"\nCopying code to: {tmp_path.as_posix()}") 97 | 98 | path_dst = tmp_path / e2e_config.path_system_info.name 99 | shutil.copyfile(e2e_config.path_system_info, path_dst) 100 | 101 | # Run py-bugger against directory. 102 | cmd = f"py-bugger --exception-type ModuleNotFoundError --target-dir {tmp_path.as_posix()} --ignore-git-status" 103 | cmd_parts = shlex.split(cmd) 104 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 105 | 106 | assert "All requested bugs inserted." in stdout 107 | 108 | # Run file, should raise ModuleNotFoundError. 109 | cmd = f"{e2e_config.python_cmd.as_posix()} {path_dst.as_posix()}" 110 | cmd_parts = shlex.split(cmd) 111 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 112 | assert "Traceback (most recent call last)" in stderr 113 | assert 'system_info_script.py", line ' in stderr 114 | assert "ModuleNotFoundError: No module named " in stderr 115 | 116 | # Read modified file; should have changed only one import statement. 117 | modified_source = path_dst.read_text() 118 | assert "import sys" in modified_source or "import os" in modified_source 119 | 120 | 121 | def test_two_bugs(tmp_path_factory, e2e_config): 122 | """py-bugger --exception-type ModuleNotFoundError --num-bugs 2 123 | 124 | Test that both import statements are modified. 125 | """ 126 | # Copy sample code to tmp dir. 127 | tmp_path = tmp_path_factory.mktemp("sample_code") 128 | print(f"\nCopying code to: {tmp_path.as_posix()}") 129 | 130 | path_dst = tmp_path / e2e_config.path_system_info.name 131 | shutil.copyfile(e2e_config.path_system_info, path_dst) 132 | 133 | # Run py-bugger against directory. 134 | cmd = f"py-bugger --exception-type ModuleNotFoundError --num-bugs 2 --target-dir {tmp_path.as_posix()} --ignore-git-status" 135 | cmd_parts = shlex.split(cmd) 136 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 137 | 138 | assert "All requested bugs inserted." in stdout 139 | 140 | # Run file, should raise ModuleNotFoundError. 141 | cmd = f"{e2e_config.python_cmd.as_posix()} {path_dst.as_posix()}" 142 | cmd_parts = shlex.split(cmd) 143 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 144 | assert "Traceback (most recent call last)" in stderr 145 | assert 'system_info_script.py", line 3, in ' in stderr 146 | assert "ModuleNotFoundError: No module named " in stderr 147 | 148 | # Read modified file; should have changed both import statements. 149 | modified_source = path_dst.read_text() 150 | assert "import sys" not in modified_source 151 | assert "import os" not in modified_source 152 | 153 | 154 | def test_random_import_affected(tmp_path_factory, e2e_config): 155 | """py-bugger --exception-type ModuleNotFoundError 156 | 157 | Test that a random import statement is modified. 158 | """ 159 | # Copy sample code to tmp dir. 160 | tmp_path = tmp_path_factory.mktemp("sample_code") 161 | print(f"\nCopying code to: {tmp_path.as_posix()}") 162 | 163 | path_dst = tmp_path / e2e_config.path_ten_imports.name 164 | shutil.copyfile(e2e_config.path_ten_imports, path_dst) 165 | 166 | # Run py-bugger against directory. 167 | cmd = f"py-bugger --exception-type ModuleNotFoundError --target-dir {tmp_path.as_posix()} --ignore-git-status" 168 | print(cmd) 169 | cmd_parts = shlex.split(cmd) 170 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 171 | 172 | assert "All requested bugs inserted." in stdout 173 | 174 | # Run file, should raise ModuleNotFoundError. 175 | cmd = f"{e2e_config.python_cmd.as_posix()} {path_dst.as_posix()}" 176 | cmd_parts = shlex.split(cmd) 177 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 178 | assert "Traceback (most recent call last)" in stderr 179 | assert 'ten_imports.py", line ' in stderr 180 | assert "ModuleNotFoundError: No module named " in stderr 181 | 182 | # Read modified file; should have changed one import statement. 183 | modified_source = path_dst.read_text() 184 | pkgs = [ 185 | "os", 186 | "sys", 187 | "re", 188 | "random", 189 | "difflib", 190 | "calendar", 191 | "zoneinfo", 192 | "array", 193 | "pprint", 194 | "enum", 195 | ] 196 | assert sum([p in modified_source for p in pkgs]) == 9 197 | 198 | 199 | def test_random_py_file_affected(tmp_path_factory, e2e_config): 200 | """py-bugger --exception-type ModuleNotFoundError 201 | 202 | Test that a random .py file is modified. 203 | """ 204 | # Copy two sample scripts to tmp dir. 205 | tmp_path = tmp_path_factory.mktemp("sample_code") 206 | print(f"\nCopying code to: {tmp_path.as_posix()}") 207 | 208 | path_dst_ten_imports = tmp_path / e2e_config.path_ten_imports.name 209 | shutil.copyfile(e2e_config.path_ten_imports, path_dst_ten_imports) 210 | 211 | path_dst_system_info = tmp_path / e2e_config.path_system_info.name 212 | shutil.copyfile(e2e_config.path_system_info, path_dst_system_info) 213 | 214 | # Run py-bugger against directory. 215 | cmd = f"py-bugger --exception-type ModuleNotFoundError --target-dir {tmp_path.as_posix()} --ignore-git-status" 216 | print(cmd) 217 | cmd_parts = shlex.split(cmd) 218 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 219 | 220 | assert "All requested bugs inserted." in stdout 221 | 222 | # Run file, should raise ModuleNotFoundError. 223 | cmd = f"{e2e_config.python_cmd.as_posix()} {path_dst_ten_imports.as_posix()}" 224 | cmd_parts = shlex.split(cmd) 225 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 226 | assert "Traceback (most recent call last)" in stderr 227 | assert 'ten_imports.py", line ' in stderr 228 | assert "ModuleNotFoundError: No module named " in stderr 229 | 230 | # Other file should not be changed. 231 | assert filecmp.cmp(e2e_config.path_system_info, path_dst_system_info) 232 | 233 | 234 | def test_unable_insert_all_bugs(tmp_path_factory, e2e_config): 235 | """Test for appropriate message when unable to generate all requested bugs.""" 236 | # Copy sample code to tmp dir. 237 | tmp_path = tmp_path_factory.mktemp("sample_code") 238 | print(f"\nCopying code to: {tmp_path.as_posix()}") 239 | 240 | path_dst = tmp_path / e2e_config.path_system_info.name 241 | shutil.copyfile(e2e_config.path_system_info, path_dst) 242 | 243 | # Run py-bugger against directory. 244 | cmd = f"py-bugger --exception-type ModuleNotFoundError -n 3 --target-dir {tmp_path.as_posix()} --ignore-git-status" 245 | cmd_parts = shlex.split(cmd) 246 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 247 | 248 | assert "Inserted 2 bugs." in stdout 249 | assert "Unable to introduce additional bugs of the requested type." in stdout 250 | 251 | 252 | def test_no_bugs(tmp_path_factory, e2e_config): 253 | """Test for appropriate message when unable to introduce any requested bugs.""" 254 | tmp_path = tmp_path_factory.mktemp("sample_code") 255 | print(f"\nCopying code to: {tmp_path.as_posix()}") 256 | 257 | path_dst = tmp_path / e2e_config.path_zero_imports.name 258 | shutil.copyfile(e2e_config.path_zero_imports, path_dst) 259 | 260 | # Run py-bugger against directory. 261 | cmd = f"py-bugger --exception-type ModuleNotFoundError --target-dir {tmp_path.as_posix()} --ignore-git-status" 262 | cmd_parts = shlex.split(cmd) 263 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 264 | 265 | assert "Unable to introduce any of the requested bugs." in stdout 266 | 267 | 268 | def test_target_dir_and_file(tmp_path_factory, e2e_config): 269 | """Test an invalid call including --target-dir and --target-file.""" 270 | tmp_path = tmp_path_factory.mktemp("sample_code") 271 | print(f"\nCopying code to: {tmp_path.as_posix()}") 272 | 273 | path_dst = tmp_path / e2e_config.path_zero_imports.name 274 | shutil.copyfile(e2e_config.path_zero_imports, path_dst) 275 | 276 | # Run py-bugger against directory. 277 | cmd = f"py-bugger --exception-type ModuleNotFoundError --target-dir {tmp_path.as_posix()} --target-file {path_dst.as_posix()}" 278 | cmd_parts = shlex.split(cmd) 279 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 280 | 281 | assert ( 282 | "Target file overrides target dir. Please only pass one of these args." 283 | in stdout 284 | ) 285 | 286 | 287 | def test_target_file(tmp_path_factory, e2e_config): 288 | """Test for passing --target-file.""" 289 | # Copy two sample scripts to tmp dir. 290 | tmp_path = tmp_path_factory.mktemp("sample_code") 291 | print(f"\nCopying code to: {tmp_path.as_posix()}") 292 | 293 | path_dst_ten_imports = tmp_path / e2e_config.path_ten_imports.name 294 | shutil.copyfile(e2e_config.path_ten_imports, path_dst_ten_imports) 295 | 296 | path_dst_system_info = tmp_path / e2e_config.path_system_info.name 297 | shutil.copyfile(e2e_config.path_system_info, path_dst_system_info) 298 | 299 | # Run py-bugger against directory. 300 | cmd = f"py-bugger --exception-type ModuleNotFoundError --target-file {path_dst_system_info.as_posix()} --ignore-git-status" 301 | print(cmd) 302 | cmd_parts = shlex.split(cmd) 303 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 304 | 305 | assert "All requested bugs inserted." in stdout 306 | 307 | # Run file, should raise ModuleNotFoundError. 308 | cmd = f"{e2e_config.python_cmd.as_posix()} {path_dst_system_info.as_posix()}" 309 | cmd_parts = shlex.split(cmd) 310 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 311 | assert "Traceback (most recent call last)" in stderr 312 | assert 'system_info_script.py", line ' in stderr 313 | assert "ModuleNotFoundError: No module named " in stderr 314 | 315 | # Other file should not be changed. 316 | assert filecmp.cmp(e2e_config.path_ten_imports, path_dst_ten_imports) 317 | 318 | 319 | def test_attribute_error(tmp_path_factory, e2e_config): 320 | """py-bugger --exception-type AttributeError""" 321 | 322 | # Copy sample code to tmp dir. 323 | tmp_path = tmp_path_factory.mktemp("sample_code") 324 | print(f"\nCopying code to: {tmp_path.as_posix()}") 325 | 326 | path_dst = tmp_path / e2e_config.path_name_picker.name 327 | shutil.copyfile(e2e_config.path_name_picker, path_dst) 328 | 329 | # Run py-bugger against directory. 330 | cmd = f"py-bugger --exception-type AttributeError --target-dir {tmp_path.as_posix()} --ignore-git-status" 331 | print("cmd:", cmd) 332 | cmd_parts = shlex.split(cmd) 333 | 334 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 335 | 336 | assert "All requested bugs inserted." in stdout 337 | 338 | # Run file, should raise AttributeError. 339 | cmd = f"{e2e_config.python_cmd.as_posix()} {path_dst.as_posix()}" 340 | cmd_parts = shlex.split(cmd) 341 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 342 | assert "Traceback (most recent call last)" in stderr 343 | assert 'name_picker.py", line ' in stderr 344 | assert "AttributeError: " in stderr 345 | assert "Did you mean: " in stderr 346 | 347 | 348 | def test_one_node_changed(tmp_path_factory, e2e_config): 349 | """Test that only one node in a file is modified for identical nodes.""" 350 | # Copy sample code to tmp dir. 351 | tmp_path = tmp_path_factory.mktemp("sample_code") 352 | print(f"\nCopying code to: {tmp_path.as_posix()}") 353 | 354 | path_dst = tmp_path / e2e_config.path_dog.name 355 | shutil.copyfile(e2e_config.path_dog, path_dst) 356 | 357 | # Run py-bugger against directory. 358 | cmd = f"py-bugger --exception-type AttributeError --target-dir {tmp_path.as_posix()} --ignore-git-status" 359 | cmd_parts = shlex.split(cmd) 360 | 361 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 362 | 363 | assert "All requested bugs inserted." in stdout 364 | 365 | # Run file, should raise AttributeError. 366 | cmd = f"{e2e_config.python_cmd.as_posix()} {path_dst.as_posix()}" 367 | cmd_parts = shlex.split(cmd) 368 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 369 | assert "Traceback (most recent call last)" in stderr 370 | assert 'dog.py", line 10, in ' in stderr 371 | assert "AttributeError: 'Dog' object has no attribute " in stderr 372 | assert "Did you mean: " in stderr 373 | 374 | # Make sure only one attribute was affected. 375 | modified_source = path_dst.read_text() 376 | assert "self.name" in modified_source 377 | assert "self.nam" in modified_source 378 | 379 | 380 | def test_random_node_changed(tmp_path_factory, e2e_config): 381 | """Test that a random node in a file is modified if it has numerous identical nodes.""" 382 | # Copy sample code to tmp dir. 383 | tmp_path = tmp_path_factory.mktemp("sample_code") 384 | print(f"\nCopying code to: {tmp_path.as_posix()}") 385 | 386 | path_dst = tmp_path / e2e_config.path_identical_attributes.name 387 | shutil.copyfile(e2e_config.path_identical_attributes, path_dst) 388 | 389 | # Run py-bugger against directory. 390 | cmd = f"py-bugger --exception-type AttributeError --target-dir {tmp_path.as_posix()} --ignore-git-status" 391 | cmd_parts = shlex.split(cmd) 392 | 393 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 394 | 395 | assert "All requested bugs inserted." in stdout 396 | 397 | # Run file, should raise AttributeError. 398 | cmd = f"{e2e_config.python_cmd.as_posix()} {path_dst.as_posix()}" 399 | cmd_parts = shlex.split(cmd) 400 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 401 | assert "Traceback (most recent call last)" in stderr 402 | assert 'identical_attributes.py", line ' in stderr 403 | assert "AttributeError: module 'random' has no attribute " in stderr 404 | assert "Did you mean: " in stderr 405 | 406 | # Make sure only one attribute was affected. 407 | modified_source = path_dst.read_text() 408 | assert modified_source.count("random.choice(") == 19 409 | 410 | 411 | def test_indentation_error_simple(tmp_path_factory, e2e_config): 412 | """py-bugger --exception-type IndentationError 413 | 414 | Run against a file with a single indented block. 415 | """ 416 | 417 | # Copy sample code to tmp dir. 418 | tmp_path = tmp_path_factory.mktemp("sample_code") 419 | print(f"\nCopying code to: {tmp_path.as_posix()}") 420 | 421 | path_dst = tmp_path / e2e_config.path_simple_indent.name 422 | shutil.copyfile(e2e_config.path_simple_indent, path_dst) 423 | 424 | # Run py-bugger against directory. 425 | cmd = f"py-bugger --exception-type IndentationError --target-dir {tmp_path.as_posix()} --ignore-git-status" 426 | print("cmd:", cmd) 427 | cmd_parts = shlex.split(cmd) 428 | 429 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 430 | 431 | assert "All requested bugs inserted." in stdout 432 | 433 | # Run file, should raise IndentationError. 434 | cmd = f"{e2e_config.python_cmd.as_posix()} {path_dst.as_posix()}" 435 | cmd_parts = shlex.split(cmd) 436 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 437 | assert "IndentationError: unexpected indent" in stderr 438 | assert 'simple_indent.py", line 1' in stderr 439 | 440 | 441 | # This test passes, but it mixes tabs and spaces. It would fail if the 442 | # for loop was inside a function. Make a test file with the for loop 443 | # in the function, induce an error that indents the for line, not the 444 | # def line, and assert not TabError. 445 | @pytest.mark.skip() 446 | def test_indentation_error_simple_tab(tmp_path_factory, e2e_config): 447 | """py-bugger --exception-type IndentationError 448 | 449 | Run against a file with a single indented block, using a tab delimiter. 450 | """ 451 | # Copy sample code to tmp dir. 452 | tmp_path = tmp_path_factory.mktemp("sample_code") 453 | print(f"\nCopying code to: {tmp_path.as_posix()}") 454 | 455 | path_src = e2e_config.path_sample_scripts / "simple_indent_tab.py" 456 | path_dst = tmp_path / path_src.name 457 | shutil.copyfile(path_src, path_dst) 458 | 459 | # Run py-bugger against directory. 460 | cmd = f"py-bugger --exception-type IndentationError --target-dir {tmp_path.as_posix()} --ignore-git-status" 461 | print("cmd:", cmd) 462 | cmd_parts = shlex.split(cmd) 463 | 464 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 465 | 466 | assert "All requested bugs inserted." in stdout 467 | 468 | # Run file, should raise IndentationError. 469 | cmd = f"{e2e_config.python_cmd.as_posix()} {path_dst.as_posix()}" 470 | cmd_parts = shlex.split(cmd) 471 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 472 | assert "IndentationError: unexpected indent" in stderr 473 | assert 'simple_indent_tab.py", line 1' in stderr 474 | 475 | 476 | def test_indentation_error_complex(tmp_path_factory, e2e_config): 477 | """py-bugger --exception-type IndentationError 478 | 479 | Run against a file with multiple indented blocks of different kinds. 480 | """ 481 | # Copy sample code to tmp dir. 482 | tmp_path = tmp_path_factory.mktemp("sample_code") 483 | print(f"\nCopying code to: {tmp_path.as_posix()}") 484 | 485 | path_dst = tmp_path / e2e_config.path_many_dogs.name 486 | shutil.copyfile(e2e_config.path_many_dogs, path_dst) 487 | 488 | # Run py-bugger against directory. 489 | cmd = f"py-bugger --exception-type IndentationError --target-dir {tmp_path.as_posix()} --ignore-git-status" 490 | print("cmd:", cmd) 491 | cmd_parts = shlex.split(cmd) 492 | 493 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 494 | 495 | assert "All requested bugs inserted." in stdout 496 | 497 | # Run file, should raise IndentationError. 498 | cmd = f"{e2e_config.python_cmd.as_posix()} {path_dst.as_posix()}" 499 | cmd_parts = shlex.split(cmd) 500 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 501 | assert "IndentationError: unexpected indent" in stderr 502 | assert 'many_dogs.py", line 1' in stderr 503 | 504 | 505 | def test_all_indentation_blocks(tmp_path_factory, e2e_config): 506 | """Test that all kinds of indented blocks can be modified. 507 | 508 | Note: There are a couple blocks that aren't currently in all_indentation_blocks.py 509 | match, case, finally 510 | """ 511 | # Copy sample code to tmp dir. 512 | tmp_path = tmp_path_factory.mktemp("sample_code") 513 | print(f"\nCopying code to: {tmp_path.as_posix()}") 514 | 515 | path_dst = tmp_path / e2e_config.path_all_indentation_blocks.name 516 | shutil.copyfile(e2e_config.path_all_indentation_blocks, path_dst) 517 | 518 | # Run py-bugger against directory. 519 | cmd = f"py-bugger --exception-type IndentationError --num-bugs 8 --target-dir {tmp_path.as_posix()} --ignore-git-status" 520 | print("cmd:", cmd) 521 | cmd_parts = shlex.split(cmd) 522 | 523 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 524 | 525 | assert "All requested bugs inserted." in stdout 526 | 527 | # Run file, should raise IndentationError. 528 | cmd = f"{e2e_config.python_cmd.as_posix()} {path_dst.as_posix()}" 529 | cmd_parts = shlex.split(cmd) 530 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 531 | assert "IndentationError: unexpected indent" in stderr 532 | assert 'all_indentation_blocks.py", line 1' in stderr 533 | 534 | 535 | def test_indentation_else_block(tmp_path_factory, e2e_config): 536 | """Test that an indendented else block does not result in a SyntaxError. 537 | 538 | If the else block is moved to its own indentation level, -> IndentationError. 539 | If it matches the indentation level of the parent's block, ie the if's block, 540 | it will result in a Syntax Error: 541 | 542 | if True: 543 | print("Hi.") 544 | else: 545 | print("Bye.") 546 | """ 547 | # Copy sample code to tmp dir. 548 | tmp_path = tmp_path_factory.mktemp("sample_code") 549 | print(f"\nCopying code to: {tmp_path.as_posix()}") 550 | 551 | path_src = e2e_config.path_sample_scripts / "else_block.py" 552 | path_dst = tmp_path / path_src.name 553 | shutil.copyfile(path_src, path_dst) 554 | 555 | # Run py-bugger against directory. 556 | cmd = f"py-bugger --exception-type IndentationError --target-dir {tmp_path.as_posix()} --ignore-git-status" 557 | print("cmd:", cmd) 558 | cmd_parts = shlex.split(cmd) 559 | 560 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 561 | 562 | assert "All requested bugs inserted." in stdout 563 | 564 | # Run file, should raise IndentationError. 565 | cmd = f"{e2e_config.python_cmd.as_posix()} {path_dst.as_posix()}" 566 | cmd_parts = shlex.split(cmd) 567 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 568 | assert "SyntaxError: invalid syntax" not in stderr 569 | -------------------------------------------------------------------------------- /tests/e2e_tests/test_cli_flags.py: -------------------------------------------------------------------------------- 1 | """Test behavior of specific CLI flags. 2 | 3 | Some flags that focus on bugs are covered in test_basic_behavior.py, 4 | and other test modules. This module is for more generic flags such as -v. 5 | """ 6 | 7 | import shutil 8 | import shlex 9 | import subprocess 10 | import filecmp 11 | import os 12 | import sys 13 | 14 | 15 | def test_verbose_flag_true(tmp_path_factory, e2e_config): 16 | """py-bugger --exception-type ModuleNotFoundError --verbose""" 17 | 18 | # Copy sample code to tmp dir. 19 | tmp_path = tmp_path_factory.mktemp("sample_code") 20 | print(f"\nCopying code to: {tmp_path.as_posix()}") 21 | 22 | path_dst = tmp_path / e2e_config.path_name_picker.name 23 | shutil.copyfile(e2e_config.path_name_picker, path_dst) 24 | 25 | # Run py-bugger against directory. 26 | cmd = f"py-bugger --exception-type ModuleNotFoundError --target-dir {tmp_path.as_posix()} --verbose --ignore-git-status" 27 | cmd_parts = shlex.split(cmd) 28 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 29 | 30 | assert "All requested bugs inserted." in stdout 31 | assert "name_picker.py" in stdout 32 | 33 | 34 | def test_verbose_flag_false(tmp_path_factory, e2e_config): 35 | """py-bugger --exception-type ModuleNotFoundError""" 36 | 37 | # Copy sample code to tmp dir. 38 | tmp_path = tmp_path_factory.mktemp("sample_code") 39 | print(f"\nCopying code to: {tmp_path.as_posix()}") 40 | 41 | path_dst = tmp_path / e2e_config.path_name_picker.name 42 | shutil.copyfile(e2e_config.path_name_picker, path_dst) 43 | 44 | # Run py-bugger against directory. 45 | cmd = f"py-bugger --exception-type ModuleNotFoundError --target-dir {tmp_path.as_posix()} --ignore-git-status" 46 | cmd_parts = shlex.split(cmd) 47 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 48 | 49 | assert "All requested bugs inserted." in stdout 50 | assert "Added bug." in stdout 51 | assert "name_picker.py" not in stdout 52 | -------------------------------------------------------------------------------- /tests/e2e_tests/test_git_status_checks.py: -------------------------------------------------------------------------------- 1 | """Tests for all the checks related to Git status. 2 | 3 | This is handled in cli_utils.py. 4 | """ 5 | 6 | import shutil 7 | import shlex 8 | import subprocess 9 | import filecmp 10 | import os 11 | import sys 12 | import platform 13 | from pathlib import Path 14 | 15 | import pytest 16 | 17 | from py_bugger.cli import cli_messages 18 | from py_bugger.cli.config import PBConfig 19 | 20 | 21 | def test_git_not_available(tmp_path_factory, e2e_config, on_windows): 22 | """Check appropriate message shown when Git not available.""" 23 | # Copy sample code to tmp dir. 24 | tmp_path = tmp_path_factory.mktemp("sample_code") 25 | print(f"\nCopying code to: {tmp_path.as_posix()}") 26 | 27 | path_src = e2e_config.path_sample_scripts / "dog.py" 28 | path_dst = tmp_path / path_src.name 29 | shutil.copyfile(path_src, path_dst) 30 | 31 | # Run py-bugger against file. We're emptying PATH in order to make sure Git is not 32 | # available for this run, so we need the direct path to the py-bugger command. 33 | py_bugger_exe = Path(sys.executable).parent / "py-bugger" 34 | cmd = f"{py_bugger_exe} --exception-type IndentationError --target-file {path_dst.as_posix()}" 35 | print("\ncmd:", cmd) 36 | cmd_parts = shlex.split(cmd) 37 | 38 | env = os.environ.copy() 39 | env["PATH"] = "" 40 | stdout = subprocess.run( 41 | cmd_parts, capture_output=True, env=env, text=True, shell=on_windows 42 | ).stdout 43 | return 44 | msg_expected = cli_messages.msg_git_not_available 45 | assert msg_expected in stdout 46 | 47 | 48 | def test_git_not_used(tmp_path_factory, e2e_config): 49 | """Check appropriate message shown when Git not being used.""" 50 | # Copy sample code to tmp dir. 51 | tmp_path = tmp_path_factory.mktemp("sample_code") 52 | print(f"\nCopying code to: {tmp_path.as_posix()}") 53 | 54 | path_src = e2e_config.path_sample_scripts / "dog.py" 55 | path_dst = tmp_path / path_src.name 56 | shutil.copyfile(path_src, path_dst) 57 | 58 | # Run py-bugger against file. This is one of the few e2e tests where --ignore-git-status 59 | # is not passed, because we want to verify appropriate behavior without a clean Git status. 60 | cmd = ( 61 | f"py-bugger --exception-type AttributeError --target-file {path_dst.as_posix()}" 62 | ) 63 | print("cmd:", cmd) 64 | cmd_parts = shlex.split(cmd) 65 | 66 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 67 | 68 | pb_config = PBConfig() 69 | pb_config.target_file = path_dst 70 | msg_expected = cli_messages.msg_git_not_used(pb_config) 71 | assert msg_expected in stdout 72 | 73 | 74 | def test_unclean_git_status(tmp_path_factory, e2e_config): 75 | """Check appropriate message shown when Git status is not clean.""" 76 | # Copy sample code to tmp dir. 77 | tmp_path = tmp_path_factory.mktemp("sample_code") 78 | print(f"\nCopying code to: {tmp_path.as_posix()}") 79 | 80 | path_src = e2e_config.path_sample_scripts / "dog.py" 81 | path_dst = tmp_path / path_src.name 82 | shutil.copyfile(path_src, path_dst) 83 | 84 | # Run git init, but don't make a commit. This is enough to create an unclean status. 85 | cmd = "git init" 86 | cmd_parts = shlex.split(cmd) 87 | subprocess.run(cmd_parts, cwd=tmp_path) 88 | 89 | # Run py-bugger against file. This is one of the few e2e tests where --ignore-git-status 90 | # is not passed, because we want to verify appropriate behavior without a clean Git status. 91 | cmd = ( 92 | f"py-bugger --exception-type AttributeError --target-file {path_dst.as_posix()}" 93 | ) 94 | print("cmd:", cmd) 95 | cmd_parts = shlex.split(cmd) 96 | 97 | stdout = subprocess.run(cmd_parts, capture_output=True, text=True).stdout 98 | 99 | msg_expected = cli_messages.msg_unclean_git_status 100 | assert msg_expected in stdout 101 | 102 | 103 | def test_clean_git_status(tmp_path_factory, e2e_config): 104 | """Run py-bugger against a tiny repo with a clean status, without passing 105 | --ignore-git-status. 106 | """ 107 | # Copy sample code to tmp dir. 108 | tmp_path = tmp_path_factory.mktemp("sample_code") 109 | print(f"\nCopying code to: {tmp_path.as_posix()}") 110 | 111 | path_src = e2e_config.path_sample_scripts / "dog.py" 112 | path_dst = tmp_path / path_src.name 113 | shutil.copyfile(path_src, path_dst) 114 | 115 | # Make an initial commit with a clean status. 116 | cmd = "git init" 117 | cmd_parts = shlex.split(cmd) 118 | subprocess.run(cmd_parts, cwd=tmp_path) 119 | 120 | cmd = "git add ." 121 | cmd_parts = shlex.split(cmd) 122 | subprocess.run(cmd_parts, cwd=tmp_path) 123 | 124 | cmd = 'git commit -m "Initial state."' 125 | cmd_parts = shlex.split(cmd) 126 | subprocess.run(cmd_parts, cwd=tmp_path) 127 | 128 | # Run py-bugger against file. This is one of the few e2e tests where --ignore-git-status 129 | # is not passed, because we want to verify appropriate behavior with a clean Git status. 130 | cmd = ( 131 | f"py-bugger --exception-type AttributeError --target-file {path_dst.as_posix()}" 132 | ) 133 | print("cmd:", cmd) 134 | cmd_parts = shlex.split(cmd) 135 | 136 | stdout = subprocess.run(cmd_parts, capture_output=True, text=True).stdout 137 | 138 | assert "All requested bugs inserted." in stdout 139 | 140 | # Run file, should raise AttributeError. 141 | cmd = f"{e2e_config.python_cmd.as_posix()} {path_dst.as_posix()}" 142 | cmd_parts = shlex.split(cmd) 143 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 144 | assert "Traceback (most recent call last)" in stderr 145 | assert 'dog.py", line ' in stderr 146 | assert "AttributeError: " in stderr 147 | assert "Did you mean: " in stderr 148 | 149 | 150 | def test_ignore_git_status(tmp_path_factory, e2e_config): 151 | """Test that py-bugger runs when --ignore-git-status is passed. 152 | 153 | This is the test for Git not being used, with a different assertion. 154 | """ 155 | # Copy sample code to tmp dir. 156 | tmp_path = tmp_path_factory.mktemp("sample_code") 157 | print(f"\nCopying code to: {tmp_path.as_posix()}") 158 | 159 | path_src = e2e_config.path_sample_scripts / "dog.py" 160 | path_dst = tmp_path / path_src.name 161 | shutil.copyfile(path_src, path_dst) 162 | 163 | # Run py-bugger against file, passing --ignore-git-status. 164 | cmd = f"py-bugger --exception-type AttributeError --target-file {path_dst.as_posix()} --ignore-git-status" 165 | print("cmd:", cmd) 166 | cmd_parts = shlex.split(cmd) 167 | 168 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 169 | 170 | assert "All requested bugs inserted." in stdout 171 | 172 | # Run file, should raise AttributeError. 173 | cmd = f"{e2e_config.python_cmd.as_posix()} {path_dst.as_posix()}" 174 | cmd_parts = shlex.split(cmd) 175 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 176 | assert "Traceback (most recent call last)" in stderr 177 | assert 'dog.py", line ' in stderr 178 | assert "AttributeError: " in stderr 179 | assert "Did you mean: " in stderr 180 | -------------------------------------------------------------------------------- /tests/e2e_tests/test_non_bugmaking_behavior.py: -------------------------------------------------------------------------------- 1 | """Tests for behavior not specifically related to making bugs. 2 | 3 | - How trailing newlines are handled. 4 | - How incorret target types are handled. 5 | """ 6 | 7 | import shutil 8 | import shlex 9 | import subprocess 10 | import filecmp 11 | import os 12 | import sys 13 | import platform 14 | from pathlib import Path 15 | 16 | import pytest 17 | 18 | from py_bugger.cli import cli_messages 19 | 20 | 21 | # --- Tests for handling of line endings. --- 22 | 23 | 24 | @pytest.mark.parametrize( 25 | "exception_type", ["IndentationError", "AttributeError", "ModuleNotFoundError"] 26 | ) 27 | def test_preserve_file_ending_trailing_newline( 28 | tmp_path_factory, e2e_config, exception_type 29 | ): 30 | """Test that trailing newlines are preserved when present.""" 31 | 32 | # Copy sample code to tmp dir. 33 | tmp_path = tmp_path_factory.mktemp("sample_code") 34 | print(f"\nCopying code to: {tmp_path.as_posix()}") 35 | 36 | path_dst = tmp_path / e2e_config.path_dog_bark.name 37 | shutil.copyfile(e2e_config.path_dog_bark, path_dst) 38 | 39 | # Run py-bugger against file. 40 | cmd = f"py-bugger --exception-type {exception_type} --target-file {path_dst.as_posix()} --ignore-git-status" 41 | print("cmd:", cmd) 42 | cmd_parts = shlex.split(cmd) 43 | 44 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 45 | 46 | assert "All requested bugs inserted." in stdout 47 | 48 | # Check that last line has a trailing newline. 49 | lines = path_dst.read_text().splitlines(keepends=True) 50 | if exception_type == "AttributeError": 51 | # Random seed causes a bug in the last line, but we're just checking the line ending. 52 | assert lines[-1] == "dog.sayhi()\n" 53 | else: 54 | assert lines[-1] == "dog.say_hi()\n" 55 | 56 | 57 | @pytest.mark.parametrize( 58 | "exception_type", ["IndentationError", "AttributeError", "ModuleNotFoundError"] 59 | ) 60 | def test_preserve_file_ending_no_trailing_newline( 61 | tmp_path_factory, e2e_config, exception_type 62 | ): 63 | """Test that trailing newlines are not introduced when not originally present.""" 64 | 65 | # Copy sample code to tmp dir. 66 | tmp_path = tmp_path_factory.mktemp("sample_code") 67 | print(f"\nCopying code to: {tmp_path.as_posix()}") 68 | 69 | path_src = e2e_config.path_sample_scripts / "dog_bark_no_trailing_newline.py" 70 | path_dst = tmp_path / path_src.name 71 | shutil.copyfile(path_src, path_dst) 72 | 73 | # Run py-bugger against file. 74 | cmd = f"py-bugger --exception-type {exception_type} --target-file {path_dst.as_posix()} --ignore-git-status" 75 | print("cmd:", cmd) 76 | cmd_parts = shlex.split(cmd) 77 | 78 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 79 | 80 | assert "All requested bugs inserted." in stdout 81 | 82 | # Check that last line is not blank. 83 | lines = path_dst.read_text().splitlines(keepends=True) 84 | if exception_type == "AttributeError": 85 | # Random seed causes a bug in the last line, but we're just checking the line ending. 86 | assert lines[-1] == "dog.sayhi()" 87 | else: 88 | assert lines[-1] == "dog.say_hi()" 89 | 90 | 91 | @pytest.mark.parametrize( 92 | "exception_type", ["IndentationError", "AttributeError", "ModuleNotFoundError"] 93 | ) 94 | def test_preserve_file_ending_two_trailing_newline( 95 | tmp_path_factory, e2e_config, exception_type 96 | ): 97 | """Test that two trailing newlines are preserved when present.""" 98 | 99 | # Copy sample code to tmp dir. 100 | tmp_path = tmp_path_factory.mktemp("sample_code") 101 | print(f"\nCopying code to: {tmp_path.as_posix()}") 102 | 103 | path_src = e2e_config.path_sample_scripts / "dog_bark_two_trailing_newlines.py" 104 | path_dst = tmp_path / path_src.name 105 | shutil.copyfile(path_src, path_dst) 106 | 107 | # Run py-bugger against file. 108 | cmd = f"py-bugger --exception-type {exception_type} --target-file {path_dst.as_posix()} --ignore-git-status" 109 | print("cmd:", cmd) 110 | cmd_parts = shlex.split(cmd) 111 | 112 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 113 | 114 | assert "All requested bugs inserted." in stdout 115 | 116 | # Check that last line is not blank. 117 | lines = path_dst.read_text().splitlines(keepends=True) 118 | assert lines[-1] == "\n" 119 | 120 | 121 | ### --- Test for handling of blank files --- 122 | 123 | 124 | @pytest.mark.parametrize( 125 | "exception_type", ["IndentationError", "AttributeError", "ModuleNotFoundError"] 126 | ) 127 | def test_blank_file_behavior(tmp_path_factory, e2e_config, exception_type): 128 | """Make sure py-bugger handles a blank file correctly.""" 129 | # Copy sample code to tmp dir. 130 | tmp_path = tmp_path_factory.mktemp("sample_code") 131 | print(f"\nCopying code to: {tmp_path.as_posix()}") 132 | 133 | path_src = e2e_config.path_sample_scripts / "blank_file.py" 134 | path_dst = tmp_path / path_src.name 135 | shutil.copyfile(path_src, path_dst) 136 | 137 | # Run py-bugger against file. 138 | cmd = f"py-bugger --exception-type {exception_type} --target-file {path_dst.as_posix()} --ignore-git-status" 139 | print("cmd:", cmd) 140 | cmd_parts = shlex.split(cmd) 141 | 142 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 143 | 144 | assert "Unable to introduce any of the requested bugs." in stdout 145 | 146 | # Check that file is still blank. 147 | contents = path_dst.read_text() 148 | assert contents == "" 149 | 150 | 151 | ### --- Tests for invalid --target-dir calls --- 152 | 153 | 154 | def test_file_passed_to_targetdir(tmp_path_factory, e2e_config): 155 | """Make sure passing a file to --target-dir fails appropriately.""" 156 | # Copy sample code to tmp dir. 157 | tmp_path = tmp_path_factory.mktemp("sample_code") 158 | print(f"\nCopying code to: {tmp_path.as_posix()}") 159 | 160 | path_src = e2e_config.path_sample_scripts / "dog.py" 161 | path_dst = tmp_path / path_src.name 162 | shutil.copyfile(path_src, path_dst) 163 | 164 | # Run py-bugger against file. 165 | cmd = ( 166 | f"py-bugger --exception-type AttributeError --target-dir {path_dst.as_posix()}" 167 | ) 168 | print("cmd:", cmd) 169 | cmd_parts = shlex.split(cmd) 170 | 171 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 172 | 173 | msg_expected = cli_messages.msg_file_not_dir(path_dst) 174 | assert msg_expected in stdout 175 | 176 | 177 | def test_nonexistent_dir_passed_to_targetdir(): 178 | """Make sure passing a nonexistent dir to --target-dir fails appropriately.""" 179 | 180 | # Make a dir path that doesn't exist. If this assertion fails, something weird happened. 181 | path_dst = Path("nonsense_name") 182 | assert not path_dst.exists() 183 | 184 | # Run py-bugger against nonexistent dir. 185 | cmd = ( 186 | f"py-bugger --exception-type AttributeError --target-dir {path_dst.as_posix()}" 187 | ) 188 | print("cmd:", cmd) 189 | cmd_parts = shlex.split(cmd) 190 | 191 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 192 | 193 | msg_expected = cli_messages.msg_nonexistent_dir(path_dst) 194 | assert msg_expected in stdout 195 | 196 | 197 | @pytest.mark.skipif( 198 | platform.system() == "Windows", reason="Can't use /dev/null on Windows." 199 | ) 200 | def test_targetdir_exists_not_dir(): 201 | """Passed something that exists, but is not a file or dir..""" 202 | 203 | # /dev/null is neither a file or a dir, but exists. 204 | path_dst = Path("/dev/null") 205 | assert path_dst.exists() 206 | assert not path_dst.is_file() 207 | assert not path_dst.is_dir() 208 | 209 | # Run py-bugger. 210 | cmd = ( 211 | f"py-bugger --exception-type AttributeError --target-dir {path_dst.as_posix()}" 212 | ) 213 | print("cmd:", cmd) 214 | cmd_parts = shlex.split(cmd) 215 | 216 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 217 | 218 | msg_expected = cli_messages.msg_not_dir(path_dst) 219 | assert msg_expected in stdout 220 | 221 | 222 | ### --- Tests for invalid --target-file calls --- 223 | 224 | 225 | def test_dir_passed_to_targetfile(tmp_path_factory): 226 | """Make sure passing a dir to --target-file fails appropriately.""" 227 | path_dst = tmp_path_factory.mktemp("sample_code") 228 | 229 | # Run py-bugger. 230 | cmd = ( 231 | f"py-bugger --exception-type AttributeError --target-file {path_dst.as_posix()}" 232 | ) 233 | print("cmd:", cmd) 234 | cmd_parts = shlex.split(cmd) 235 | 236 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 237 | 238 | msg_expected = cli_messages.msg_dir_not_file(path_dst) 239 | assert msg_expected in stdout 240 | 241 | 242 | def test_nonexistent_file_passed_to_targetfile(): 243 | """Make sure passing a nonexistent file to --target-file fails appropriately.""" 244 | 245 | # Make a file path that doesn't exist. If this assertion fails, something weird happened. 246 | path_dst = Path("nonsense_python_file.py") 247 | assert not path_dst.exists() 248 | 249 | # Run py-bugger. 250 | cmd = ( 251 | f"py-bugger --exception-type AttributeError --target-file {path_dst.as_posix()}" 252 | ) 253 | print("cmd:", cmd) 254 | cmd_parts = shlex.split(cmd) 255 | 256 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 257 | 258 | msg_expected = cli_messages.msg_nonexistent_file(path_dst) 259 | assert msg_expected in stdout 260 | 261 | 262 | @pytest.mark.skipif( 263 | platform.system() == "Windows", reason="Can't use /dev/null on Windows." 264 | ) 265 | def test_targetfile_exists_not_file(): 266 | """Passed something that exists, but is not a file or dir..""" 267 | 268 | # /dev/null is neither a file or a dir, but exists. 269 | path_dst = Path("/dev/null") 270 | assert path_dst.exists() 271 | assert not path_dst.is_file() 272 | assert not path_dst.is_dir() 273 | 274 | # Run py-bugger. 275 | cmd = ( 276 | f"py-bugger --exception-type AttributeError --target-file {path_dst.as_posix()}" 277 | ) 278 | print("cmd:", cmd) 279 | cmd_parts = shlex.split(cmd) 280 | 281 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 282 | 283 | msg_expected = cli_messages.msg_not_file(path_dst) 284 | assert msg_expected in stdout 285 | 286 | 287 | def test_targetfile_py_file(tmp_path_factory, e2e_config): 288 | """Test for appropriate message when passed a non-.py file.""" 289 | # Copy sample code to tmp dir. 290 | tmp_path = tmp_path_factory.mktemp("sample_code") 291 | print(f"\nCopying code to: {tmp_path.as_posix()}") 292 | 293 | path_src = e2e_config.path_sample_scripts / "hello.txt" 294 | path_dst = tmp_path / path_src.name 295 | shutil.copyfile(path_src, path_dst) 296 | 297 | # Run py-bugger against file. 298 | cmd = ( 299 | f"py-bugger --exception-type AttributeError --target-file {path_dst.as_posix()}" 300 | ) 301 | print("cmd:", cmd) 302 | cmd_parts = shlex.split(cmd) 303 | 304 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 305 | 306 | msg_expected = cli_messages.msg_file_not_py(path_dst) 307 | assert msg_expected in stdout 308 | -------------------------------------------------------------------------------- /tests/e2e_tests/test_project_setup.py: -------------------------------------------------------------------------------- 1 | """Test aspects of project setup that might interfere with CI and the release process.""" 2 | 3 | 4 | def test_editable_requirement(e2e_config): 5 | """Make sure there's no editable entry for py-bugger in requirements.txt. 6 | 7 | This entry gets inserted when running pip freeze, from an editable install. 8 | """ 9 | path_req_txt = e2e_config.path_root / "requirements.txt" 10 | 11 | contents = path_req_txt.read_text() 12 | assert "-e file:" not in contents 13 | -------------------------------------------------------------------------------- /tests/sample_code/sample_scripts/all_indentation_blocks.py: -------------------------------------------------------------------------------- 1 | for num in [1, 2, 3]: 2 | print(num) 3 | 4 | while True: 5 | print("one iteration") 6 | break 7 | 8 | 9 | def add_two(x): 10 | return x + 2 11 | 12 | 13 | print(add_two(5)) 14 | 15 | 16 | class Dog: 17 | def __init__(self, name): 18 | self.name = name 19 | 20 | def say_hi(self): 21 | print(f"Hi, I'm {self.name} the dog!") 22 | 23 | 24 | dog = Dog("Willie") 25 | dog.say_hi() 26 | 27 | try: 28 | 5 / 0 29 | except ZeroDivisionError: 30 | print("my bad!") 31 | 32 | import random 33 | 34 | roll = random.randint(1, 6) 35 | if roll > 3: 36 | print("Yes!") 37 | elif roll > 4: 38 | print("Yes yes!") 39 | elif roll == 6: 40 | print("Yes yes yes!") 41 | else: 42 | print("oh no") 43 | -------------------------------------------------------------------------------- /tests/sample_code/sample_scripts/blank_file.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ehmatthes/py-bugger/fc0ff412e30fc8c189c73c6de38276eb5c949c3f/tests/sample_code/sample_scripts/blank_file.py -------------------------------------------------------------------------------- /tests/sample_code/sample_scripts/dog.py: -------------------------------------------------------------------------------- 1 | class Dog: 2 | def __init__(self, name): 3 | self.name = name 4 | 5 | def say_hi(self): 6 | print(f"Hi, I'm {self.name} the dog!") 7 | 8 | 9 | dog = Dog("Willie") 10 | dog.say_hi() 11 | -------------------------------------------------------------------------------- /tests/sample_code/sample_scripts/dog_bark.py: -------------------------------------------------------------------------------- 1 | """A dog that barks. It's a class with an import. 2 | 3 | This supports testing for: 4 | - IndentationError 5 | - AttributeError 6 | - ModuleNotFoundError 7 | """ 8 | 9 | import random 10 | 11 | 12 | class Dog: 13 | def __init__(self, name): 14 | self.name = name 15 | 16 | def say_hi(self): 17 | print(f"Hi, I'm {self.name} the dog!") 18 | 19 | def bark(self): 20 | barks = ["woof", "ruff", "owooooo"] 21 | bark = random.choice(barks) 22 | print(f"{bark}!") 23 | 24 | 25 | dog = Dog("Willie") 26 | dog.bark() 27 | dog.say_hi() 28 | -------------------------------------------------------------------------------- /tests/sample_code/sample_scripts/dog_bark_no_trailing_newline.py: -------------------------------------------------------------------------------- 1 | """A dog that barks. It's a class with an import. 2 | 3 | This supports testing for: 4 | - IndentationError 5 | - AttributeError 6 | - ModuleNotFoundError 7 | """ 8 | 9 | import random 10 | 11 | 12 | class Dog: 13 | def __init__(self, name): 14 | self.name = name 15 | 16 | def say_hi(self): 17 | print(f"Hi, I'm {self.name} the dog!") 18 | 19 | def bark(self): 20 | barks = ["woof", "ruff", "owooooo"] 21 | bark = random.choice(barks) 22 | print(f"{bark}!") 23 | 24 | 25 | dog = Dog("Willie") 26 | dog.bark() 27 | dog.say_hi() -------------------------------------------------------------------------------- /tests/sample_code/sample_scripts/dog_bark_two_trailing_newlines.py: -------------------------------------------------------------------------------- 1 | """A dog that barks. It's a class with an import. 2 | 3 | This supports testing for: 4 | - IndentationError 5 | - AttributeError 6 | - ModuleNotFoundError 7 | """ 8 | 9 | import random 10 | 11 | 12 | class Dog: 13 | def __init__(self, name): 14 | self.name = name 15 | 16 | def say_hi(self): 17 | print(f"Hi, I'm {self.name} the dog!") 18 | 19 | def bark(self): 20 | barks = ["woof", "ruff", "owooooo"] 21 | bark = random.choice(barks) 22 | print(f"{bark}!") 23 | 24 | 25 | dog = Dog("Willie") 26 | dog.bark() 27 | dog.say_hi() 28 | 29 | -------------------------------------------------------------------------------- /tests/sample_code/sample_scripts/else_block.py: -------------------------------------------------------------------------------- 1 | if True: 2 | print("Hi.") 3 | elif False: 4 | # This block just lets the random seed affect the else block. 5 | pass 6 | else: 7 | print("Bye.") 8 | -------------------------------------------------------------------------------- /tests/sample_code/sample_scripts/hello.txt: -------------------------------------------------------------------------------- 1 | Hello, I am not a .py file. 2 | -------------------------------------------------------------------------------- /tests/sample_code/sample_scripts/identical_attributes.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | numbers = [1, 2, 3, 4, 5] 4 | 5 | random.choice(numbers) 6 | random.choice(numbers) 7 | random.choice(numbers) 8 | random.choice(numbers) 9 | random.choice(numbers) 10 | random.choice(numbers) 11 | random.choice(numbers) 12 | random.choice(numbers) 13 | random.choice(numbers) 14 | random.choice(numbers) 15 | random.choice(numbers) 16 | random.choice(numbers) 17 | random.choice(numbers) 18 | random.choice(numbers) 19 | random.choice(numbers) 20 | random.choice(numbers) 21 | random.choice(numbers) 22 | random.choice(numbers) 23 | random.choice(numbers) 24 | random.choice(numbers) 25 | 26 | random.choices(numbers, k=3) 27 | random.choices(numbers, k=3) 28 | random.choices(numbers, k=3) 29 | random.choices(numbers, k=3) 30 | random.choices(numbers, k=3) 31 | random.choices(numbers, k=3) 32 | random.choices(numbers, k=3) 33 | random.choices(numbers, k=3) 34 | -------------------------------------------------------------------------------- /tests/sample_code/sample_scripts/many_dogs.py: -------------------------------------------------------------------------------- 1 | class Dog: 2 | def __init__(self, name): 3 | self.name = name 4 | 5 | def say_hi(self): 6 | print(f"Hi, I'm {self.name} the dog!") 7 | 8 | 9 | for _ in range(10): 10 | dog = Dog("Willie") 11 | dog.say_hi() 12 | -------------------------------------------------------------------------------- /tests/sample_code/sample_scripts/name_picker.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | 4 | names = ["eric", "birdie", "willie"] 5 | 6 | name = random.choice(names) 7 | print(f"The winner: {name.title()}!") 8 | -------------------------------------------------------------------------------- /tests/sample_code/sample_scripts/simple_indent.py: -------------------------------------------------------------------------------- 1 | for num in [1, 2, 3]: 2 | print(num) 3 | -------------------------------------------------------------------------------- /tests/sample_code/sample_scripts/simple_indent_tab.py: -------------------------------------------------------------------------------- 1 | for num in [1, 2, 3]: 2 | print(num) 3 | -------------------------------------------------------------------------------- /tests/sample_code/sample_scripts/system_info_script.py: -------------------------------------------------------------------------------- 1 | """A simple program with two imports, displaying information about system.""" 2 | 3 | import sys 4 | import os 5 | 6 | print(f"Using {sys.version} on {sys.platform}.") 7 | print(f"Using {sys.version} on {os.name}.") 8 | -------------------------------------------------------------------------------- /tests/sample_code/sample_scripts/ten_imports.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import re 4 | import random 5 | import difflib 6 | import calendar 7 | import zoneinfo 8 | import array 9 | import pprint 10 | import enum 11 | -------------------------------------------------------------------------------- /tests/sample_code/sample_scripts/zero_imports.py: -------------------------------------------------------------------------------- 1 | print("Hello, this file has no import statements.") 2 | -------------------------------------------------------------------------------- /tests/unit_tests/test_bug_utils.py: -------------------------------------------------------------------------------- 1 | """Tests for utilities that generate actual bugs.""" 2 | 3 | from py_bugger.utils import bug_utils 4 | 5 | 6 | def test_remove_char(): 7 | """Test utility for removing a random character from a name. 8 | 9 | Take a short name. Call remove_char() 25 times. Should end up with all variations. 10 | """ 11 | name = "event" 12 | new_names = set([bug_utils.remove_char(name) for _ in range(1000)]) 13 | 14 | assert new_names == {"vent", "eent", "evnt", "evet", "even"} 15 | 16 | 17 | def test_insert_char(): 18 | """Test utility for inserting a random character into a name.""" 19 | for _ in range(100): 20 | name = "event" 21 | new_name = bug_utils.insert_char(name) 22 | 23 | assert new_name != name 24 | assert len(new_name) == len(name) + 1 25 | 26 | 27 | def test_modify_char(): 28 | """Test utility for modifying a name.""" 29 | for _ in range(100): 30 | name = "event" 31 | new_name = bug_utils.modify_char(name) 32 | 33 | assert new_name != name 34 | assert len(new_name) == len(name) 35 | 36 | 37 | def test_make_typo(): 38 | """Test utility for generating a typo.""" 39 | for _ in range(100): 40 | name = "event" 41 | new_name = bug_utils.make_typo(name) 42 | 43 | assert new_name != name 44 | 45 | 46 | def test_no_builtin_name(): 47 | """Make sure we don't get a builtin name such as `min`.""" 48 | for _ in range(100): 49 | name = "mine" 50 | new_name = bug_utils.make_typo(name) 51 | 52 | assert new_name != name 53 | assert new_name != "min" 54 | -------------------------------------------------------------------------------- /tests/unit_tests/test_file_utils.py: -------------------------------------------------------------------------------- 1 | """Tests for utils/file_utils.py. 2 | 3 | This module does not use pb_config directly. That makes unit testing easier. To use 4 | pb_config here, update tests to create an appropriate pb_config object. 5 | """ 6 | 7 | from pathlib import Path 8 | 9 | import pytest 10 | 11 | from py_bugger.utils import file_utils 12 | 13 | 14 | def test_get_py_files_git(): 15 | # This takes more setup. Need to actually initialize a Git dir. Could point it 16 | # at this project directory and just test a few files that should show up, and 17 | # some that should be excluded. 18 | root_dir = Path(__file__).parents[2] 19 | py_files = file_utils.get_py_files(root_dir, target_file="") 20 | filenames = [pf.name for pf in py_files] 21 | 22 | assert "__init__.py" in filenames 23 | assert "cli.py" in filenames 24 | assert "cli_messages.py" in filenames 25 | assert "py_bugger.py" in filenames 26 | assert "file_utils.py" in filenames 27 | 28 | assert "test_file_utils.py" not in filenames 29 | assert "test_basic_behavior.py" not in filenames 30 | assert "conftest.py" not in filenames 31 | 32 | 33 | def test_get_py_files_non_git(tmp_path_factory): 34 | """Test function for getting .py files from a dir not managed by Git.""" 35 | # Build a tmp dir with some files that should be gathered, and some that 36 | # should not. 37 | tmp_path = tmp_path_factory.mktemp("sample_non_git_dir") 38 | 39 | path_tests = Path(tmp_path) / "tests" 40 | path_tests.mkdir() 41 | 42 | files = ["hello.py", "goodbye.py", "conftest.py", "tests/test_project.py"] 43 | for file in files: 44 | path = tmp_path / file 45 | path.touch() 46 | 47 | py_files = file_utils.get_py_files(tmp_path, target_file="") 48 | filenames = [pf.name for pf in py_files] 49 | 50 | assert "hello.py" in filenames 51 | assert "goodbye.py" in filenames 52 | 53 | assert "conftest.py" not in filenames 54 | assert "test_project.py" not in filenames 55 | 56 | 57 | def test_get_py_files_target_file(tmp_path_factory): 58 | """Test function for getting .py files when target_file is set.""" 59 | # Build a tmp dir with some files that should be gathered, and some that 60 | # should not. 61 | tmp_path = tmp_path_factory.mktemp("sample_non_git_dir") 62 | 63 | path_tests = Path(tmp_path) / "tests" 64 | path_tests.mkdir() 65 | 66 | files = ["hello.py", "goodbye.py", "conftest.py", "tests/test_project.py"] 67 | for file in files: 68 | path = tmp_path / file 69 | path.touch() 70 | 71 | # Set goodbye.py as the target file. 72 | if file == "goodbye.py": 73 | target_file = path 74 | 75 | py_files = file_utils.get_py_files(tmp_path, target_file) 76 | assert py_files == [target_file] 77 | --------------------------------------------------------------------------------