├── src └── py_bugger │ ├── __init__.py │ ├── cli │ ├── __init__.py │ ├── config.py │ ├── cli.py │ ├── cli_messages.py │ └── cli_utils.py │ ├── utils │ ├── modification.py │ ├── bug_utils.py │ ├── file_utils.py │ └── cst_utils.py │ ├── py_bugger.py │ └── buggers.py ├── tests ├── sample_code │ └── sample_scripts │ │ ├── blank_file.py │ │ ├── hello.txt │ │ ├── simple_indent.py │ │ ├── simple_indent_tab.py │ │ ├── zero_imports.py │ │ ├── name_picker.py │ │ ├── else_block.py │ │ ├── ten_imports.py │ │ ├── dog.py │ │ ├── system_info_script.py │ │ ├── many_dogs.py │ │ ├── two_trys.py │ │ ├── five_trys.py │ │ ├── dog_bark.py │ │ ├── dog_bark_no_trailing_newline.py │ │ ├── dog_bark_two_trailing_newlines.py │ │ ├── all_indentation_blocks.py │ │ └── identical_attributes.py ├── e2e_tests │ ├── test_project_setup.py │ ├── reference_files │ │ └── help.txt │ ├── test_cli_flags.py │ ├── test_git_status_checks.py │ ├── test_non_bugmaking_behavior.py │ └── test_basic_behavior.py ├── integration_tests │ ├── conftest.py │ ├── test_cli.py │ ├── test_modifications.py │ └── test_target_lines_arg.py ├── unit_tests │ ├── test_bug_utils.py │ └── test_file_utils.py └── conftest.py ├── assets ├── logo_raw_bordered.png └── logo_raw_pip_bordered-60px.png ├── docs ├── requirements.txt ├── index.md ├── contributing │ ├── roadmap.md │ └── index.md ├── maintaining │ └── index.md ├── quick_start │ └── index.md └── usage │ └── index.md ├── .gitignore ├── .readthedocs.yaml ├── developer_resources ├── sample_import_node.py ├── sample_attribute_node.py ├── make_release.sh ├── sample_for_node.py └── simple_indent_nodes.py ├── mkdocs.yml ├── LICENSE ├── pyproject.toml ├── requirements.txt ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.yml └── workflows │ └── test.yaml ├── README.md └── CHANGELOG.md /src/py_bugger/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/py_bugger/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/sample_code/sample_scripts/blank_file.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/sample_code/sample_scripts/hello.txt: -------------------------------------------------------------------------------- 1 | Hello, I am not a .py file. 2 | -------------------------------------------------------------------------------- /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/zero_imports.py: -------------------------------------------------------------------------------- 1 | print("Hello, this file has no import statements.") 2 | -------------------------------------------------------------------------------- /assets/logo_raw_bordered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ehmatthes/py-bugger/HEAD/assets/logo_raw_bordered.png -------------------------------------------------------------------------------- /assets/logo_raw_pip_bordered-60px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ehmatthes/py-bugger/HEAD/assets/logo_raw_pip_bordered-60px.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/two_trys.py: -------------------------------------------------------------------------------- 1 | """Simple file with two try-except blocks.""" 2 | 3 | try: 4 | 5/0 5 | except ZeroDivisionError: 6 | print("Can't divide by zero!") 7 | 8 | try: 9 | 10/0 10 | except ZeroDivisionError: 11 | print("That doesn't work either!") 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 -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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(test_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 = test_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/five_trys.py: -------------------------------------------------------------------------------- 1 | """Simple file with many identical try-except blocks.""" 2 | 3 | try: 4 | 5/0 5 | except ZeroDivisionError: 6 | print("Can't divide by zero!") 7 | 8 | try: 9 | 5/0 10 | except ZeroDivisionError: 11 | print("Can't divide by zero!") 12 | 13 | try: 14 | 5/0 15 | except ZeroDivisionError: 16 | print("Can't divide by zero!") 17 | 18 | try: 19 | 5/0 20 | except ZeroDivisionError: 21 | print("Can't divide by zero!") 22 | 23 | try: 24 | 5/0 25 | except ZeroDivisionError: 26 | print("Can't divide by zero!") 27 | -------------------------------------------------------------------------------- /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 | SUPPORTED_EXCEPTION_TYPES = [ 8 | "ModuleNotFoundError", 9 | "AttributeError", 10 | "IndentationError", 11 | ] 12 | 13 | 14 | @dataclass 15 | class PBConfig: 16 | exception_type: str = "" 17 | target_dir: Path = "" 18 | target_file: Path = "" 19 | target_lines: str = "" 20 | num_bugs: int = 1 21 | ignore_git_status: bool = False 22 | verbose: bool = True 23 | 24 | 25 | pb_config = PBConfig() 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/" -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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) -------------------------------------------------------------------------------- /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/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 | --target-lines TEXT Target a specific block of lines. A single 14 | integer, or a range. 15 | -n, --num-bugs INTEGER How many bugs to introduce. 16 | --ignore-git-status Don't check Git status before inserting bugs. 17 | -v, --verbose Enable verbose output. 18 | --help Show this message and exit. 19 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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.5.2" 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 | "pytest-xdist>=3.7.0", 16 | "twine>=5.1.1", 17 | "mkdocs>=1.6.0", 18 | "mkdocs-material>=9.5.0", 19 | ] 20 | 21 | [build-system] 22 | requires = ["setuptools"] 23 | build-backend = "setuptools.build_meta" 24 | 25 | [tool.setuptools.packages.find] 26 | where = ["src"] 27 | 28 | [project.scripts] 29 | py-bugger = "py_bugger.cli.cli:cli" 30 | 31 | [project.urls] 32 | "Documentation" = "https://py-bugger.readthedocs.io/en/latest/" 33 | "GitHub" = "https://github.com/ehmatthes/py-bugger" 34 | "Changelog" = "https://github.com/ehmatthes/py-bugger/blob/main/CHANGELOG.md" 35 | 36 | [tool.black] 37 | # Sample code for tests sometimes require nonstandard formatting. 38 | extend-exclude = "tests/sample_code" 39 | -------------------------------------------------------------------------------- /tests/integration_tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Config for integration tests. 2 | 3 | Any test that's more than a unit test, but doesn't require running py-bugger against 4 | actual code should probably be an integration test. 5 | """ 6 | 7 | import pytest 8 | 9 | from py_bugger.utils.modification import modifications 10 | from py_bugger.cli.config import pb_config 11 | 12 | 13 | @pytest.fixture(autouse=True, scope="function") 14 | def reset_state(): 15 | """Reset the shared state objects for each test.""" 16 | 17 | # Reset pb_config. 18 | pb_config.exception_type = "" 19 | pb_config.target_dir = "" 20 | pb_config.target_file = "" 21 | pb_config.target_lines = "" 22 | pb_config.num_bugs = 1 23 | pb_config.ignore_git_status = False 24 | pb_config.verbose = True 25 | 26 | # Reset list of modifications. 27 | modifications.clear() 28 | 29 | # Customize some state. For some tests, you may need to override 30 | # these customizations in specific test functions. 31 | 32 | # For most integration tests, we're targeting a sample file or directory 33 | # that has not been set up as a Git repo. 34 | pb_config.ignore_git_status = True 35 | -------------------------------------------------------------------------------- /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 | execnet==2.1.1 11 | ghp-import==2.1.0 12 | id==1.5.0 13 | idna==3.10 14 | iniconfig==2.0.0 15 | jaraco-classes==3.4.0 16 | jaraco-context==6.0.1 17 | jaraco-functools==4.1.0 18 | jinja2==3.1.6 19 | keyring==25.6.0 20 | libcst==1.7.0 21 | markdown==3.8 22 | markdown-it-py==3.0.0 23 | markupsafe==3.0.2 24 | mdurl==0.1.2 25 | mergedeep==1.3.4 26 | mkdocs==1.6.1 27 | mkdocs-get-deps==0.2.0 28 | mkdocs-material==9.6.12 29 | mkdocs-material-extensions==1.3.1 30 | more-itertools==10.6.0 31 | mypy-extensions==1.0.0 32 | nh3==0.2.21 33 | packaging==24.2 34 | paginate==0.5.7 35 | pathspec==0.12.1 36 | platformdirs==4.3.6 37 | pluggy==1.5.0 38 | pygments==2.19.1 39 | pymdown-extensions==10.14.3 40 | pyproject-hooks==1.2.0 41 | pytest==8.3.5 42 | pytest-xdist==3.7.0 43 | python-dateutil==2.9.0.post0 44 | pyyaml==6.0.2 45 | pyyaml-env-tag==0.1 46 | readme-renderer==44.0 47 | requests==2.32.3 48 | requests-toolbelt==1.0.0 49 | rfc3986==2.0.0 50 | rich==13.9.4 51 | six==1.17.0 52 | twine==6.1.0 53 | urllib3==2.3.0 54 | watchdog==6.0.0 55 | -------------------------------------------------------------------------------- /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 | - **(implemented)** When `-e` arg is omitted, randomly choose from supported bugs. 19 | - Support multiple values for the `-e` arg, so user can have random bugs from a specific subset of exception types. 20 | - Develop a list of good projects to practice against. ie, clone from GitHub, run its tests, run `py-bugger`, and practice debugging. 21 | 22 | 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/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). -------------------------------------------------------------------------------- /src/py_bugger/utils/modification.py: -------------------------------------------------------------------------------- 1 | """Model for modifications made to files to introduce bugs. 2 | 3 | This is used to track which modifications have been made, so we don't make multiple 4 | modifications to the same node or line. 5 | 6 | Not currently using original_node or original_line, but including these means 7 | the list of modifications can be used to stage all changes, and write them all at 8 | once if that becomes a better approach. 9 | 10 | Note: If we move to a deferred writing model, line_num will need to be handled carefully. 11 | May want to stage all changes, then before writing, add a new attribute new_line_num. 12 | Parse list of mods, and for any mod that adds or removes a line, adjust new_line_num 13 | for all subsequent modification instances. 14 | """ 15 | 16 | from dataclasses import dataclass, field 17 | from pathlib import Path 18 | from typing import Type 19 | 20 | import libcst as cst 21 | 22 | 23 | @dataclass 24 | class Modification: 25 | path: Path = "" 26 | 27 | # Only data for a line or node will be set, not both. 28 | # DEV: For line, may want to store line number? 29 | original_node: cst.CSTNode = None 30 | modified_node: cst.CSTNode = None 31 | 32 | original_line: str = "" 33 | modified_line: str = "" 34 | 35 | # Line numbers are not zero-indexed. We count them like a user would. 36 | line_num: int = 0 37 | 38 | exception_induced: type[BaseException] = field(default=None) 39 | 40 | 41 | # Only make one instance of this list. 42 | modifications = [] 43 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | "--target-lines", 26 | type=str, 27 | help="Target a specific block of lines. A single integer, or a range.", 28 | ) 29 | @click.option( 30 | "--num-bugs", 31 | "-n", 32 | type=int, 33 | default=1, 34 | help="How many bugs to introduce.", 35 | ) 36 | @click.option( 37 | "--ignore-git-status", 38 | is_flag=True, 39 | help="Don't check Git status before inserting bugs.", 40 | ) 41 | @click.option( 42 | "--verbose", 43 | "-v", 44 | is_flag=True, 45 | help="Enable verbose output.", 46 | ) 47 | def cli(**kwargs): 48 | """Practice debugging, by intentionally introducing bugs into an existing codebase.""" 49 | # Update pb_config using options passed through CLI call. 50 | pb_config.__dict__.update(kwargs) 51 | cli_utils.validate_config() 52 | 53 | # Importing py_bugger here cuts test time significantly, as these resources are not 54 | # loaded for many calls. (6.7s -> 5.4s, for 20% speedup, 6/10/25.) 55 | from py_bugger import py_bugger 56 | 57 | py_bugger.main() 58 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /tests/integration_tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """Tests that focus on the CLI itself.""" 2 | 3 | import shutil 4 | import shlex 5 | import subprocess 6 | import filecmp 7 | import os 8 | import sys 9 | import platform 10 | from pathlib import Path 11 | 12 | import pytest 13 | 14 | from py_bugger.cli import cli_messages 15 | 16 | 17 | @pytest.mark.parametrize( 18 | "actual_expected", 19 | [ 20 | ("IndentationErrorr", "IndentationError"), 21 | ("AttributeErrorr", "AttributeError"), 22 | ("ModuleNotFoundErrorr", "ModuleNotFoundError"), 23 | ], 24 | ) 25 | def test_exception_type_typo(actual_expected): 26 | """Test appropriate handling of a typo in the exception type.""" 27 | # Run py-bugger against file. 28 | exception_type, correction = actual_expected 29 | cmd = f"py-bugger --exception-type {exception_type} --target-file nonexistent_python_file.py --ignore-git-status" 30 | print("cmd:", cmd) 31 | cmd_parts = shlex.split(cmd) 32 | 33 | stdout = subprocess.run(cmd_parts, capture_output=True, text=True).stdout 34 | 35 | msg_expected = cli_messages.msg_apparent_typo(exception_type, correction) 36 | assert msg_expected in stdout 37 | 38 | 39 | def test_exception_type_unsupported(): 40 | """Test appropriate handling of an unsupported exception type.""" 41 | # Run py-bugger against file. 42 | exception_type = "CompletelyUnsupportedExceptionType" 43 | cmd = f"py-bugger --exception-type {exception_type} --target-file nonexistent_python_file.py --ignore-git-status" 44 | print("cmd:", cmd) 45 | cmd_parts = shlex.split(cmd) 46 | 47 | stdout = subprocess.run(cmd_parts, capture_output=True, text=True).stdout 48 | 49 | msg_expected = cli_messages.msg_unsupported_exception_type(exception_type) 50 | assert msg_expected in stdout 51 | -------------------------------------------------------------------------------- /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.config import SUPPORTED_EXCEPTION_TYPES 9 | from py_bugger.cli import cli_messages 10 | 11 | 12 | def main(): 13 | set_random_seed() 14 | 15 | # Get a list of .py files we can consider modifying. 16 | py_files = file_utils.get_py_files(pb_config.target_dir, pb_config.target_file) 17 | 18 | # Make a list of bugs to introduce. 19 | if pb_config.exception_type: 20 | # User has requested a specific kind of bug. 21 | requested_bugs = [pb_config.exception_type for _ in range(pb_config.num_bugs)] 22 | else: 23 | # No -e arg passed; get a random sequence of bugs to introduce. 24 | # Reorder sequence so all regex parsing happens after CST parsing. CST parsing 25 | # will fail if it's attempted after introducing a bug that affects parsing. 26 | requested_bugs = random.choices(SUPPORTED_EXCEPTION_TYPES, k=pb_config.num_bugs) 27 | requested_bugs = sorted(requested_bugs, key=lambda b: b == "IndentationError") 28 | 29 | # Introduce bugs, one at a time. 30 | for bug in requested_bugs: 31 | if bug == "ModuleNotFoundError": 32 | buggers.module_not_found_bugger(py_files) 33 | elif bug == "AttributeError": 34 | buggers.attribute_error_bugger(py_files) 35 | elif bug == "IndentationError": 36 | buggers.indentation_error_bugger(py_files) 37 | 38 | # Show a final success/fail message. 39 | msg = cli_messages.success_msg() 40 | print(msg) 41 | 42 | # Returning requested_bugs helps with testing. 43 | return requested_bugs 44 | 45 | 46 | # --- Helper functions --- 47 | 48 | 49 | def set_random_seed(): 50 | # Set a random seed when testing. 51 | if seed := os.environ.get("PY_BUGGER_RANDOM_SEED"): 52 | random.seed(int(seed)) 53 | -------------------------------------------------------------------------------- /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, test_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 / test_config.path_name_picker.name 23 | shutil.copyfile(test_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, test_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 / test_config.path_name_picker.name 42 | shutil.copyfile(test_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/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 on_windows(): 21 | """Some tests need to run differently on Windows.""" 22 | return platform.system() == "Windows" 23 | 24 | 25 | @pytest.fixture(scope="session") 26 | def test_config(): 27 | """Resources useful to most tests.""" 28 | 29 | class Config: 30 | # Paths 31 | path_root = Path(__file__).parents[1] 32 | 33 | path_tests = path_root / "tests" 34 | path_reference_files = path_tests / "e2e_tests" / "reference_files" 35 | path_sample_code = path_tests / "sample_code" 36 | path_sample_scripts = path_sample_code / "sample_scripts" 37 | 38 | # Remove these. It's much cleaner to just build these scripts in each 39 | # test function. 40 | path_name_picker = path_sample_scripts / "name_picker.py" 41 | path_system_info = path_sample_scripts / "system_info_script.py" 42 | path_ten_imports = path_sample_scripts / "ten_imports.py" 43 | path_zero_imports = path_sample_scripts / "zero_imports.py" 44 | path_dog = path_sample_scripts / "dog.py" 45 | path_dog_bark = path_sample_scripts / "dog_bark.py" 46 | path_many_dogs = path_sample_scripts / "many_dogs.py" 47 | path_identical_attributes = path_sample_scripts / "identical_attributes.py" 48 | path_simple_indent = path_sample_scripts / "simple_indent.py" 49 | path_all_indentation_blocks = path_sample_scripts / "all_indentation_blocks.py" 50 | 51 | # Python executable 52 | if sys.platform == "win32": 53 | python_cmd = path_root / ".venv" / "Scripts" / "python" 54 | else: 55 | python_cmd = path_root / ".venv" / "bin" / "python" 56 | 57 | return Config() 58 | -------------------------------------------------------------------------------- /.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 -n auto 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 -n auto 81 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 random 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. In the simplest usage, `py-bugger` will choose a random bug to introduce: 24 | 25 | ```sh 26 | $ py-bugger 27 | Added bug. 28 | All requested bugs inserted. 29 | ``` 30 | 31 | If your project is under version control, you can see the bug that was introduced by running `git diff`. 32 | 33 | ## Introducing a bug into a specific directory 34 | 35 | You can target any directory: 36 | 37 | ```sh 38 | $ py-bugger --target-dir /Users/eric/test_code/Pillow/ 39 | Added bug. 40 | All requested bugs inserted. 41 | ``` 42 | 43 | ## Introducing a bug into a specific *.py* file 44 | 45 | And you can target a specific file: 46 | 47 | ```sh 48 | $ py-bugger --target-file name_picker.py 49 | Added bug. 50 | All requested bugs inserted. 51 | ``` 52 | 53 | ## Creating multiple bugs 54 | 55 | You can create as many bugs as you like. `py-bugger` will do its best to introduce all the bugs you ask for: 56 | 57 | ```sh 58 | $ py-bugger -n 3 59 | Added bug. 60 | Added bug. 61 | Added bug. 62 | All requested bugs inserted. 63 | ``` 64 | 65 | ## Creating a specific kind of bug 66 | 67 | Currently, `py-bugger` can create bugs that induce three kinds of exceptions: `ModuleNotFoundError`, `AttributeError`, and `IndentationError`. You can let `py-bugger` choose from these randmly, or you can ask it to create a bug that induces a specific kind of error. 68 | 69 | Here's how to create a bug that generates a `ModuleNotFoundError`: 70 | 71 | ```sh 72 | $ py-bugger -e ModuleNotFoundError 73 | Added bug. 74 | All requested bugs inserted. 75 | ``` 76 | 77 | When you run the project again, it should fail with a `ModuleNotFoundError`. 78 | 79 | ## Caveat 80 | 81 | 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. If you try to run `py-bugger` without a clean Git status you'll get a warning, which can be overridden with the `--ignore-git-status` flag. 82 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --target-lines TEXT Target a specific block of lines. A single 27 | integer, or a range. 28 | -n, --num-bugs INTEGER How many bugs to introduce. 29 | --ignore-git-status Don't check Git status before inserting bugs. 30 | -v, --verbose Enable verbose output. 31 | --help Show this message and exit. 32 | ``` 33 | 34 | ## Targeting specific lines 35 | 36 | You can target a specific line or block of lines in a file: 37 | 38 | ```sh 39 | $ py-bugger --target-file dog.py --target-lines 15 40 | $ py-bugger --target-file dog.py --target-lines 15-20 41 | ``` 42 | 43 | The `--target-lines` argument only works if you're also passing a value for `--target-file`. 44 | 45 | ## Introducing multiple bugs of specific types 46 | 47 | Currently, it's not possible to specify more than one exception type in a single `py-bugger` call. At the moment, you can either let `py-bugger` choose which kind of bug to introduce, or you can request a specific exception to induce. 48 | 49 | If you need to introduce several specific bugs, but not choose randomly from all possible bugs, you may have luck running `py-bugger` multiple times with different exception types: 50 | 51 | ```sh 52 | $ py-bugger -e ModuleNotFoundError 53 | $ py-bugger -e IndentationError -n 2 54 | ``` 55 | 56 | This can fail if a bug introduces a syntax error which prevents `py-bugger` from parsing your codebase. Support for specifying multiple exception types should be added shortly. If this kind of usage is important to you, please consider opening an [issue](https://github.com/ehmatthes/py-bugger/issues) or [discussion](https://github.com/ehmatthes/py-bugger/discussions), and I'll prioritize support for this. 57 | 58 | ## A note about speed 59 | 60 | 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. 61 | -------------------------------------------------------------------------------- /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 | from py_bugger.utils.modification import Modification, modifications 8 | 9 | 10 | def make_typo(name): 11 | """Add a typo to the name of an identifier. 12 | 13 | Randomly decides which kind of change to make. 14 | """ 15 | typo_fns = [remove_char, insert_char, modify_char] 16 | 17 | while True: 18 | typo_fn = random.choice(typo_fns) 19 | new_name = typo_fn(name) 20 | 21 | # Reject names that match builtins. 22 | if new_name in dir(builtins): 23 | continue 24 | 25 | return new_name 26 | 27 | 28 | def remove_char(name): 29 | """Remove a character from the name.""" 30 | chars = list(name) 31 | index_remove = random.randint(0, len(chars) - 1) 32 | del chars[index_remove] 33 | 34 | return "".join(chars) 35 | 36 | 37 | def insert_char(name): 38 | """Insert a character into the name.""" 39 | chars = list(name) 40 | new_char = random.choice("abcdefghijklmnopqrstuvwxyz") 41 | index = random.randint(0, len(chars)) 42 | chars.insert(index, new_char) 43 | 44 | return "".join(chars) 45 | 46 | 47 | def modify_char(name): 48 | """Modify a character in a name.""" 49 | chars = list(name) 50 | index = random.randint(0, len(chars) - 1) 51 | 52 | # Make sure new_char does not match current char. 53 | while True: 54 | new_char = random.choice("abcdefghijklmnopqrstuvwxyz") 55 | if new_char != chars[index]: 56 | break 57 | chars[index] = new_char 58 | 59 | return "".join(chars) 60 | 61 | 62 | def add_indentation(path, target_line): 63 | """Add one level of indentation (four spaces) to line.""" 64 | indentation_added = False 65 | 66 | lines = path.read_text().splitlines(keepends=True) 67 | 68 | modified_lines = [] 69 | for line in lines: 70 | # `line` contains leading whitespace and trailing newline. 71 | # `target_line` just contains code, so use `in` rather than `==`. 72 | if target_line in line: 73 | modified_line = f" {line}" 74 | modified_lines.append(modified_line) 75 | indentation_added = True 76 | 77 | # Record this modification. 78 | modification = Modification( 79 | path, 80 | original_line=line, 81 | modified_line=modified_line, 82 | exception_induced=IndentationError, 83 | ) 84 | modifications.append(modification) 85 | else: 86 | modified_lines.append(line) 87 | 88 | modified_source = "".join(modified_lines) 89 | path.write_text(modified_source) 90 | 91 | return indentation_added 92 | 93 | 94 | def add_indentation_linenum(path, target_line_num): 95 | """Add one level of indentation (four spaces) to line at linenum.""" 96 | indentation_added = False 97 | 98 | lines = path.read_text().splitlines(keepends=True) 99 | 100 | modified_lines = [] 101 | for line_num, line in enumerate(lines, start=1): 102 | if line_num == target_line_num: 103 | modified_line = f" {line}" 104 | modified_lines.append(modified_line) 105 | indentation_added = True 106 | 107 | # Record this modification. 108 | modification = Modification( 109 | path, 110 | original_line=line, 111 | modified_line=modified_line, 112 | exception_induced=IndentationError, 113 | line_num=line_num, 114 | ) 115 | modifications.append(modification) 116 | else: 117 | modified_lines.append(line) 118 | 119 | modified_source = "".join(modified_lines) 120 | path.write_text(modified_source) 121 | 122 | return indentation_added -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/py_bugger/cli/cli_messages.py: -------------------------------------------------------------------------------- 1 | """Messages for use in CLI output.""" 2 | 3 | # --- Static messages --- 4 | 5 | msg_target_file_dir = ( 6 | "Target file overrides target dir. Please only pass one of these args." 7 | ) 8 | 9 | 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." 10 | 11 | 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." 12 | 13 | 14 | # def success_msg(num_added, num_requested): 15 | def success_msg(): 16 | """Generate a success message at end of run.""" 17 | # Importing these here makes for a faster test suite. 18 | from py_bugger.cli.config import pb_config 19 | from py_bugger.utils.modification import modifications 20 | 21 | # Show a final success/fail message. 22 | num_added = len(modifications) 23 | if num_added == pb_config.num_bugs: 24 | return "All requested bugs inserted." 25 | elif num_added == 0: 26 | return "Unable to introduce any of the requested bugs." 27 | else: 28 | msg = f"Inserted {num_added} bugs." 29 | msg += "\nUnable to introduce additional bugs of the requested type." 30 | return msg 31 | 32 | 33 | # Validation for exception type. 34 | def msg_apparent_typo(actual, expected): 35 | """Suggest a typo fix for an exception type.""" 36 | msg = f"You specified {actual} for --exception-type. Did you mean {expected}?" 37 | return msg 38 | 39 | 40 | def msg_unsupported_exception_type(exception_type): 41 | """Specified an unsupported exception type.""" 42 | msg = f"The exception type {exception_type} is not currently supported." 43 | return msg 44 | 45 | 46 | # Messagess for invalid --target-dir calls. 47 | 48 | 49 | def msg_file_not_dir(target_file): 50 | """Specified --target-dir, but passed a file.""" 51 | msg = f"You specified --target-dir, but {target_file.name} is a file. Did you mean to use --target-file?" 52 | return msg 53 | 54 | 55 | def msg_nonexistent_dir(target_dir): 56 | """Passed a nonexistent dir to --target-dir.""" 57 | msg = f"The directory {target_dir.name} does not exist. Did you make a typo?" 58 | return msg 59 | 60 | 61 | def msg_not_dir(target_dir): 62 | """Passed something that exists to --target-dir, but it's not a dir.""" 63 | msg = f"{target_dir.name} does not seem to be a directory." 64 | return msg 65 | 66 | 67 | # Messages for invalid --target-file calls. 68 | 69 | 70 | def msg_dir_not_file(target_dir): 71 | """Specified --target-file, but passed a dir.""" 72 | 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?" 73 | return msg 74 | 75 | 76 | def msg_nonexistent_file(target_file): 77 | """Passed a nonexistent file to --target-file.""" 78 | msg = f"The file {target_file.name} does not exist. Did you make a typo?" 79 | return msg 80 | 81 | 82 | def msg_not_file(target_file): 83 | """Passed something that exists to --target-file, but it's not a file.""" 84 | msg = f"{target_file.name} does not seem to be a file." 85 | return msg 86 | 87 | 88 | def msg_file_not_py(target_file): 89 | """Passed a non-.py file to --target-file.""" 90 | msg = f"{target_file.name} does not appear to be a Python file." 91 | return msg 92 | 93 | 94 | # Messages for --target-lines. 95 | msg_target_lines_no_target_file = "You specified --target-lines, without a --target-file. If you want to use --target-lines, please also specify a target file." 96 | 97 | def msg_invalid_target_line(target_line, target_file, file_length): 98 | """Passed a target line that's not in the target file.""" 99 | msg = f"You asked to target line {target_line}, but {target_file.as_posix()} only has {file_length} lines." 100 | return msg 101 | 102 | def msg_invalid_target_lines(end_line, target_file, file_length): 103 | """Passed a block that's not in the target file.""" 104 | msg = f"You asked to target a block ending at line {end_line}, but {target_file.as_posix()} only has {file_length} lines." 105 | return msg 106 | 107 | # Messages for Git status-related issues. 108 | def msg_git_not_used(pb_config): 109 | """Git is not being used to manage target file or directory.""" 110 | if pb_config.target_file: 111 | target = "file" 112 | else: 113 | target = "directory" 114 | 115 | 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." 116 | return msg 117 | -------------------------------------------------------------------------------- /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 | from py_bugger.utils.modification import modifications 9 | 10 | from py_bugger.cli.config import pb_config 11 | 12 | 13 | # --- Public functions --- 14 | 15 | 16 | def get_py_files(target_dir, target_file): 17 | """Get all the .py files we can consider modifying when introducing bugs.""" 18 | # Check if user requested a single target file. 19 | if target_file: 20 | return [target_file] 21 | 22 | # Use .gitignore if possible. 23 | path_git = target_dir / ".git" 24 | if path_git.exists(): 25 | return _get_py_files_git(target_dir) 26 | else: 27 | return _get_py_files_non_git(target_dir) 28 | 29 | 30 | def get_paths_linenums(py_files, targets): 31 | """Get all line numbers from all files matching targets, if they haven't already 32 | been modified. 33 | """ 34 | paths_linenums = [] 35 | for path in py_files: 36 | # Get lines and line numbers, and remove lines that have already been modified. 37 | lines = path.read_text().splitlines() 38 | linenums_lines = enumerate(lines, start=1) 39 | linenums_lines = _remove_modified_lines(path, linenums_lines) 40 | 41 | # Only keep line numbers for lines that match targets. 42 | # Also, filter for --target-lines if that was passed. 43 | for line_num, line in linenums_lines: 44 | stripped_line = line.strip() 45 | if any([stripped_line.startswith(target) for target in targets]): 46 | if not pb_config.target_lines: 47 | paths_linenums.append((path, line_num)) 48 | elif line_num in pb_config.target_lines: 49 | paths_linenums.append((path, line_num)) 50 | 51 | return paths_linenums 52 | 53 | 54 | def check_unmodified(candidate_path, candidate_node=None, candidate_line=None): 55 | """Check if it's safe to modify a node or line. 56 | 57 | If the node or line has not already been modified, return True. Otherwise, 58 | return False. 59 | """ 60 | # Only look at modifications in the candidate path. 61 | relevant_modifications = [m for m in modifications if m.path == candidate_path] 62 | 63 | if not relevant_modifications: 64 | return True 65 | 66 | modified_nodes = [m.modified_node for m in relevant_modifications] 67 | modified_lines = [m.modified_line for m in relevant_modifications] 68 | 69 | # Need to use deep_equals for node comparisons. 70 | for modified_node in modified_nodes: 71 | if modified_node.deep_equals(candidate_node): 72 | return False 73 | 74 | if candidate_line in modified_lines: 75 | return False 76 | 77 | # The candidate node or line has not been modified during this run of py-bugger. 78 | return True 79 | 80 | 81 | # --- Helper functions --- 82 | 83 | 84 | def _get_py_files_git(target_dir): 85 | """Get all relevant .py files from a directory managed by Git.""" 86 | cmd = f'git -C {target_dir.as_posix()} ls-files "*.py"' 87 | cmd_parts = shlex.split(cmd) 88 | output = subprocess.run(cmd_parts, capture_output=True) 89 | py_files = output.stdout.decode().strip().splitlines() 90 | 91 | # Convert to path objects. Filter out any test-related files. 92 | py_files = [Path(f) for f in py_files] 93 | py_files = [pf for pf in py_files if "tests/" not in pf.as_posix()] 94 | py_files = [pf for pf in py_files if "Tests/" not in pf.as_posix()] 95 | py_files = [pf for pf in py_files if "test_code/" not in pf.as_posix()] 96 | py_files = [pf for pf in py_files if pf.name != "conftest.py"] 97 | py_files = [pf for pf in py_files if not pf.name.startswith("test_")] 98 | 99 | # Build full paths. 100 | py_files = [target_dir / pf for pf in py_files] 101 | 102 | return py_files 103 | 104 | 105 | def _get_py_files_non_git(target_dir): 106 | """Get all relevant .py files from a directory not managed by Git.""" 107 | py_files = target_dir.rglob("*.py") 108 | 109 | exclude_dirs = [ 110 | ".venv/", 111 | "venv/", 112 | "tests/", 113 | "Tests/", 114 | "test_code/", 115 | "build/", 116 | "dist/", 117 | ] 118 | py_files = [ 119 | pf 120 | for pf in py_files 121 | if not any(ex_dir in pf.as_posix() for ex_dir in exclude_dirs) 122 | ] 123 | py_files = [pf for pf in py_files if pf.name != "conftest.py"] 124 | py_files = [pf for pf in py_files if not pf.name.startswith("test_")] 125 | 126 | return py_files 127 | 128 | def _remove_modified_lines(path, linenums_lines): 129 | """Remove lines that have already been modified.""" 130 | for modification in modifications: 131 | if modification.path != path: 132 | continue 133 | if modification.line_num: 134 | linenums_lines = [(line_num, line) for line_num, line in linenums_lines if line_num != modification.line_num] 135 | 136 | return linenums_lines -------------------------------------------------------------------------------- /src/py_bugger/buggers.py: -------------------------------------------------------------------------------- 1 | """Utilities for introducing specific kinds of bugs. 2 | 3 | DEV: Don't rush to refactor bugger functions. for example, it's not yet clear whether this should 4 | be a class. Also, not sure we need separate bugger functions, or one bugger function with some 5 | conditional logic. Implement support for another exception type, and logical errors, and see what 6 | things are looking like. 7 | """ 8 | import random 9 | 10 | import libcst as cst 11 | from libcst.metadata import MetadataWrapper, PositionProvider 12 | 13 | from py_bugger.utils import cst_utils 14 | from py_bugger.utils import file_utils 15 | from py_bugger.utils import bug_utils 16 | 17 | from py_bugger.cli.config import pb_config 18 | 19 | 20 | ### --- *_bugger functions --- 21 | 22 | 23 | def module_not_found_bugger(py_files): 24 | """Induce a ModuleNotFoundError. 25 | 26 | Returns: 27 | Bool: Whether a bug was introduced or not. 28 | """ 29 | # Get a random node that hasn't already been modified. 30 | path, node = _get_random_node(py_files, node_type=cst.Import) 31 | if not path: 32 | return False 33 | 34 | # Parse user's code. 35 | source = path.read_text() 36 | tree = cst.parse_module(source) 37 | wrapper = MetadataWrapper(tree) 38 | metadata = wrapper.resolve(PositionProvider) 39 | 40 | # Modify user's code 41 | try: 42 | modified_tree = wrapper.module.visit(cst_utils.ImportModifier(node, path, metadata)) 43 | except TypeError: 44 | # DEV: Figure out which nodes are ending up here, and update 45 | # modifier code to handle these nodes. 46 | # For diagnostics, can run against Pillow with -n set to a 47 | # really high number. 48 | raise 49 | else: 50 | path.write_text(modified_tree.code) 51 | _report_bug_added(path) 52 | return True 53 | 54 | 55 | def attribute_error_bugger(py_files): 56 | """Induce an AttributeError. 57 | 58 | Returns: 59 | Bool: Whether a bug was introduced or not. 60 | """ 61 | # Get a random node that hasn't already been modified. 62 | path, node = _get_random_node(py_files, node_type=cst.Attribute) 63 | if not path: 64 | return False 65 | 66 | # Parse user's code. 67 | source = path.read_text() 68 | tree = cst.parse_module(source) 69 | wrapper = MetadataWrapper(tree) 70 | metadata = wrapper.resolve(PositionProvider) 71 | 72 | # Pick node to modify if more than one match in the file. 73 | # Note that not all bugger functions need this step. 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 = wrapper.module.visit(cst_utils.AttributeModifier(node, node_index, path, metadata)) 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 | raise 89 | else: 90 | path.write_text(modified_tree.code) 91 | _report_bug_added(path) 92 | return True 93 | 94 | 95 | def indentation_error_bugger(py_files): 96 | """Induce an IndentationError. 97 | 98 | This simply parses raw source files. Conditions are pretty concrete, and LibCST 99 | doesn't make it easy to create invalid syntax. 100 | 101 | Returns: 102 | Bool: Whether a bug was introduced or not. 103 | """ 104 | # Find relevant files and lines. 105 | targets = [ 106 | "for", 107 | "while", 108 | "def", 109 | "class", 110 | "if", 111 | "with", 112 | "match", 113 | "try", 114 | ] 115 | 116 | # We only need line numbers, not actual lines. 117 | paths_linenums = file_utils.get_paths_linenums(py_files, targets=targets) 118 | 119 | # Bail if there are no relevant lines. 120 | if not paths_linenums: 121 | return False 122 | 123 | path, target_linenum = random.choice(paths_linenums) 124 | 125 | if bug_utils.add_indentation_linenum(path, target_linenum): 126 | _report_bug_added(path) 127 | return True 128 | 129 | 130 | # --- Helper functions --- 131 | # DEV: This is a good place for helper functions, before they are refined enough 132 | # to move to utils/. 133 | 134 | 135 | def _report_bug_added(path_modified): 136 | """Report that a bug was added.""" 137 | if pb_config.verbose: 138 | print(f"Added bug to: {path_modified.as_posix()}") 139 | else: 140 | print(f"Added bug.") 141 | 142 | 143 | def _get_random_node(py_files, node_type): 144 | """Randomly select a node to modify. 145 | 146 | Make sure it's a node that hasn't already been modified. 147 | 148 | Returns: 149 | Tuple: (path, node) or (False, False) 150 | """ 151 | # Find all relevant nodes. Bail if there are no relevant nodes. 152 | if not (paths_nodes := cst_utils.get_paths_nodes(py_files, node_type)): 153 | return False, False 154 | 155 | random.shuffle(paths_nodes) 156 | for path, node in paths_nodes: 157 | if file_utils.check_unmodified(path, candidate_node=node): 158 | return path, node 159 | else: 160 | # All nodes have already been modified to introduce a previous bug. 161 | return False, False 162 | -------------------------------------------------------------------------------- /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 | import difflib 14 | 15 | import click 16 | 17 | from py_bugger.cli import cli_messages 18 | from py_bugger.cli.config import pb_config 19 | from py_bugger.cli.config import SUPPORTED_EXCEPTION_TYPES 20 | 21 | 22 | def validate_config(): 23 | """Make sure the CLI options are valid.""" 24 | 25 | if pb_config.target_dir and pb_config.target_file: 26 | click.echo(cli_messages.msg_target_file_dir) 27 | sys.exit() 28 | 29 | _validate_exception_type() 30 | 31 | if pb_config.target_dir: 32 | _validate_target_dir() 33 | 34 | if pb_config.target_file: 35 | _validate_target_file() 36 | 37 | if pb_config.target_lines: 38 | _validate_target_lines() 39 | 40 | # Update all options before running Git status checks. Info like target_dir 41 | # is used for those checks. 42 | _update_options() 43 | 44 | _validate_git_status() 45 | 46 | 47 | # --- Helper functions ___ 48 | 49 | 50 | def _update_options(): 51 | """Make sure options are ready to use.""" 52 | # Set an appropriate target directory. 53 | if pb_config.target_dir: 54 | pb_config.target_dir = Path(pb_config.target_dir) 55 | else: 56 | pb_config.target_dir = Path(os.getcwd()) 57 | 58 | 59 | def _validate_exception_type(): 60 | """Make sure the -e arg provided is supported.""" 61 | if not pb_config.exception_type: 62 | return 63 | 64 | if pb_config.exception_type in SUPPORTED_EXCEPTION_TYPES: 65 | return 66 | 67 | # Check for typos. 68 | matches = difflib.get_close_matches( 69 | pb_config.exception_type, SUPPORTED_EXCEPTION_TYPES, n=1 70 | ) 71 | if matches: 72 | msg = cli_messages.msg_apparent_typo(pb_config.exception_type, matches[0]) 73 | click.echo(msg) 74 | sys.exit() 75 | 76 | # Invalid or unsupported exception type. 77 | msg = cli_messages.msg_unsupported_exception_type(pb_config.exception_type) 78 | click.echo(msg) 79 | sys.exit() 80 | 81 | 82 | def _validate_target_dir(): 83 | """Make sure a valid directory was passed. 84 | 85 | Check for common mistakes, then verify it is a dir. 86 | """ 87 | path_target_dir = Path(pb_config.target_dir) 88 | if path_target_dir.is_file(): 89 | msg = cli_messages.msg_file_not_dir(path_target_dir) 90 | click.echo(msg) 91 | sys.exit() 92 | elif not path_target_dir.exists(): 93 | msg = cli_messages.msg_nonexistent_dir(path_target_dir) 94 | click.echo(msg) 95 | sys.exit() 96 | elif not path_target_dir.is_dir(): 97 | msg = cli_messages.msg_not_dir(path_target_dir) 98 | click.echo(msg) 99 | sys.exit() 100 | 101 | 102 | def _validate_target_file(): 103 | """Make sure an appropriate file was passed. 104 | 105 | Check for common mistakes, then verify it is a file. 106 | """ 107 | path_target_file = Path(pb_config.target_file) 108 | if path_target_file.is_dir(): 109 | msg = cli_messages.msg_dir_not_file(path_target_file) 110 | click.echo(msg) 111 | sys.exit() 112 | elif not path_target_file.exists(): 113 | msg = cli_messages.msg_nonexistent_file(path_target_file) 114 | click.echo(msg) 115 | sys.exit() 116 | elif not path_target_file.is_file(): 117 | msg = cli_messages.msg_not_file(path_target_file) 118 | click.echo(msg) 119 | sys.exit() 120 | elif path_target_file.suffix != ".py": 121 | msg = cli_messages.msg_file_not_py(path_target_file) 122 | click.echo(msg) 123 | sys.exit() 124 | 125 | # It's valid, set it to a Path. 126 | pb_config.target_file = path_target_file 127 | 128 | def _validate_target_lines(): 129 | """Make sure an appropriate block of lines was passed.""" 130 | # You can only pass target lines if you're also passing a target file. 131 | if not pb_config.target_file: 132 | click.echo(cli_messages.msg_target_lines_no_target_file) 133 | sys.exit() 134 | 135 | # Handle a single target line. 136 | if "-" not in pb_config.target_lines: 137 | target_line = int(pb_config.target_lines.strip()) 138 | 139 | # Make sure this line is in the target file. 140 | lines = pb_config.target_file.read_text().splitlines() 141 | if target_line > len(lines): 142 | msg = cli_messages.msg_invalid_target_line(target_line, pb_config.target_file, len(lines)) 143 | click.echo(msg) 144 | sys.exit() 145 | 146 | # Wrap target_line in a list, and return. 147 | pb_config.target_lines = [target_line] 148 | return 149 | 150 | # Handle a block of lines. 151 | start, end = pb_config.target_lines.strip().split("-") 152 | start, end = int(start), int(end) 153 | 154 | # Make sure end line is in the target file. 155 | lines = pb_config.target_file.read_text().splitlines() 156 | if end > len(lines): 157 | msg = cli_messages.msg_invalid_target_lines(end, pb_config.target_file, len(lines)) 158 | click.echo(msg) 159 | sys.exit() 160 | 161 | pb_config.target_lines = list(range(start, end+1)) 162 | 163 | 164 | def _validate_git_status(): 165 | """Look for a clean Git status before introducing bugs.""" 166 | if pb_config.ignore_git_status: 167 | return 168 | 169 | _check_git_available() 170 | _check_git_status() 171 | 172 | 173 | def _check_git_available(): 174 | """Quit with appropriate message if Git not available.""" 175 | if not shutil.which("git"): 176 | click.echo(cli_messages.msg_git_not_available) 177 | sys.exit() 178 | 179 | 180 | def _check_git_status(): 181 | """Make sure we're starting with a clean git status.""" 182 | if pb_config.target_file: 183 | git_dir = pb_config.target_file.parent 184 | else: 185 | git_dir = pb_config.target_dir 186 | 187 | cmd = "git status --porcelain" 188 | cmd_parts = shlex.split(cmd) 189 | output = subprocess.run(cmd_parts, cwd=git_dir, capture_output=True, text=True) 190 | 191 | if "fatal: not a git repository" in output.stderr: 192 | msg = cli_messages.msg_git_not_used(pb_config) 193 | click.echo(msg) 194 | sys.exit() 195 | 196 | # `git status --porcelain` has no output when the status is clean. 197 | if output.stdout or output.stderr: 198 | msg = cli_messages.msg_unclean_git_status 199 | click.echo(msg) 200 | sys.exit() 201 | -------------------------------------------------------------------------------- /tests/integration_tests/test_modifications.py: -------------------------------------------------------------------------------- 1 | """Test the modifications object.""" 2 | 3 | from pathlib import Path 4 | import shutil 5 | import os 6 | 7 | import pytest 8 | 9 | from py_bugger import py_bugger 10 | from py_bugger.cli.config import pb_config 11 | from py_bugger.cli import cli_utils 12 | from py_bugger.utils.modification import modifications 13 | 14 | 15 | def test_modifications_modulenotfounderror(tmp_path_factory, test_config): 16 | """Tests modifications after creating a ModuleNotFoundError.""" 17 | # Copy sample code to tmp dir. 18 | tmp_path = tmp_path_factory.mktemp("sample_code") 19 | print(f"\nCopying code to: {tmp_path.as_posix()}") 20 | 21 | path_src = test_config.path_sample_scripts / "name_picker.py" 22 | path_dst = tmp_path / path_src.name 23 | shutil.copyfile(path_src, path_dst) 24 | 25 | # Make modifications against this directory. 26 | pb_config.exception_type = "ModuleNotFoundError" 27 | pb_config.target_file = path_dst 28 | cli_utils.validate_config() 29 | 30 | py_bugger.main() 31 | 32 | assert len(modifications) == 1 33 | assert modifications[0].exception_induced == ModuleNotFoundError 34 | 35 | 36 | def test_7_random_bugs(tmp_path_factory, test_config): 37 | """Test equivalent of `py-bugger -n 7`. 38 | 39 | Look for modifications that match bugs_requested. 40 | """ 41 | # Copy sample code to tmp dir. 42 | tmp_path = tmp_path_factory.mktemp("sample_code") 43 | print(f"\nCopying code to: {tmp_path.as_posix()}") 44 | 45 | path_src = test_config.path_sample_scripts / "dog_bark.py" 46 | path_dst = tmp_path / path_src.name 47 | shutil.copyfile(path_src, path_dst) 48 | 49 | # Make modifications against this directory. 50 | # With the current random seed, 7 seems to be the max number of bugs before 51 | # it can't add more. 52 | pb_config.target_file = path_dst 53 | pb_config.num_bugs = 7 54 | cli_utils.validate_config() 55 | 56 | requested_bugs = py_bugger.main() 57 | 58 | # Make sure one modification was made for each requested bug. 59 | assert len(modifications) == len(requested_bugs) 60 | 61 | # Make sure the correct kinds of exceptions were induced. 62 | exceptions_induced_str = [m.exception_induced.__name__ for m in modifications] 63 | assert sorted(exceptions_induced_str) == sorted(requested_bugs) 64 | 65 | 66 | def test_8_random_bugs(tmp_path_factory, test_config): 67 | """Test equivalent of `py-bugger -n 8`. 68 | 69 | Look for modifications that match bugs_requested. 70 | Look for message that it can't add more bugs. 71 | """ 72 | # Copy sample code to tmp dir. 73 | tmp_path = tmp_path_factory.mktemp("sample_code") 74 | print(f"\nCopying code to: {tmp_path.as_posix()}") 75 | 76 | path_src = test_config.path_sample_scripts / "dog_bark.py" 77 | path_dst = tmp_path / path_src.name 78 | shutil.copyfile(path_src, path_dst) 79 | 80 | # Make modifications against this directory. 81 | # With the current random seed, 8 seems to be the smallest number of bugs 82 | # where it can't finish adding bugs. 83 | pb_config.target_file = path_dst 84 | pb_config.num_bugs = 8 85 | cli_utils.validate_config() 86 | 87 | requested_bugs = py_bugger.main() 88 | 89 | # Make sure one requested bug was unable to be generated. 90 | assert len(modifications) == len(requested_bugs) - 1 91 | 92 | # Make sure all requested bugs except one are in modifications. 93 | exceptions_induced_str = [m.exception_induced.__name__ for m in modifications] 94 | while exceptions_induced_str: 95 | exception_induced = exceptions_induced_str.pop() 96 | requested_bugs.remove(exception_induced) 97 | 98 | assert len(requested_bugs) == 1 99 | 100 | def test_indentationerror_multiple_trys(tmp_path_factory, test_config): 101 | """Test requesting a single IndentationError against a file with two try blocks. 102 | 103 | This is related to issue 65, where the bare try block that's being targeted matches 104 | every try block in the file. We should see just one modification. 105 | """ 106 | # Copy sample code to tmp dir. 107 | tmp_path = tmp_path_factory.mktemp("sample_code") 108 | print(f"\nCopying code to: {tmp_path.as_posix()}") 109 | 110 | path_src = test_config.path_sample_scripts / "two_trys.py" 111 | path_dst = tmp_path / path_src.name 112 | shutil.copyfile(path_src, path_dst) 113 | 114 | # Make modifications against this file. 115 | pb_config.target_file = path_dst 116 | pb_config.num_bugs = 1 117 | pb_config.exception_type = "IndentationError" 118 | cli_utils.validate_config() 119 | 120 | requested_bugs = py_bugger.main() 121 | 122 | # Check that only one modification was made. 123 | assert len(modifications) == 1 124 | 125 | def test_first_try_not_indented(tmp_path_factory, test_config): 126 | """Make sure a random try block is affected, not always the first one. 127 | 128 | This is related to issue 65, where the bare try block that's being targeted matches 129 | every try block in the file. We should see just one modification, and it should be 130 | a random one. 131 | """ 132 | # Copy sample code to tmp dir. 133 | tmp_path = tmp_path_factory.mktemp("sample_code") 134 | print(f"\nCopying code to: {tmp_path.as_posix()}") 135 | 136 | path_src = test_config.path_sample_scripts / "five_trys.py" 137 | path_dst = tmp_path / path_src.name 138 | shutil.copyfile(path_src, path_dst) 139 | 140 | # Make modifications against this file. 141 | pb_config.target_file = path_dst 142 | pb_config.num_bugs = 1 143 | pb_config.exception_type = "IndentationError" 144 | cli_utils.validate_config() 145 | 146 | requested_bugs = py_bugger.main() 147 | 148 | # Check that only one modification was made. 149 | assert len(modifications) == 1 150 | 151 | # Read modified file. Make sure first try line hasn't changed. Make sure one 152 | # try has been indented. 153 | lines = path_dst.read_text().splitlines() 154 | assert lines[2] == "try:" 155 | assert lines.count(" try:") == 1 156 | 157 | @pytest.mark.parametrize( 158 | "exception_type", ["IndentationError", "AttributeError", "ModuleNotFoundError"] 159 | ) 160 | def test_linenums_recorded(tmp_path_factory, test_config, exception_type): 161 | """Test that a line number is recorded for each exception type.""" 162 | # Copy sample code to tmp dir. 163 | tmp_path = tmp_path_factory.mktemp("sample_code") 164 | print(f"\nCopying code to: {tmp_path.as_posix()}") 165 | 166 | path_src = test_config.path_sample_scripts / "dog_bark.py" 167 | path_dst = tmp_path / path_src.name 168 | shutil.copyfile(path_src, path_dst) 169 | 170 | # Make modifications against this file. 171 | pb_config.target_file = path_dst 172 | pb_config.exception_type = exception_type 173 | cli_utils.validate_config() 174 | 175 | requested_bugs = py_bugger.main() 176 | 177 | assert len(modifications) == 1 178 | assert modifications[0].line_num > 0 179 | -------------------------------------------------------------------------------- /src/py_bugger/utils/cst_utils.py: -------------------------------------------------------------------------------- 1 | """Utilities for working with the CST.""" 2 | 3 | import libcst as cst 4 | from libcst.metadata import MetadataWrapper, PositionProvider 5 | 6 | from py_bugger.utils import bug_utils 7 | from py_bugger.utils.modification import Modification, modifications 8 | 9 | from py_bugger.cli.config import pb_config 10 | 11 | 12 | class NodeCollector(cst.CSTVisitor): 13 | """Collect all nodes of a specific kind.""" 14 | 15 | def __init__(self, node_type): 16 | self.node_type = node_type 17 | self.collected_nodes = [] 18 | 19 | def on_visit(self, node): 20 | """Visit each node, collecting nodes that match the node type.""" 21 | if isinstance(node, self.node_type): 22 | self.collected_nodes.append(node) 23 | return True 24 | 25 | 26 | class NodeCounter(cst.CSTVisitor): 27 | """Count all nodes matching the target node.""" 28 | 29 | def __init__(self, target_node): 30 | self.target_node = target_node 31 | self.node_count = 0 32 | 33 | def on_visit(self, node): 34 | """Increment node_count if node matches..""" 35 | if node.deep_equals(self.target_node): 36 | self.node_count += 1 37 | return True 38 | 39 | 40 | class ImportModifier(cst.CSTTransformer): 41 | """Modify imports in the user's project. 42 | 43 | Note: Each import should be unique, so there shouldn't be any need to track 44 | whether a bug was introduced. node_to_break should only match one node in the 45 | tree. 46 | """ 47 | 48 | def __init__(self, node_to_break, path, metadata): 49 | self.node_to_break = node_to_break 50 | 51 | # Need this to record the modification we're making. 52 | self.path = path 53 | self.metadata = metadata 54 | 55 | def leave_Import(self, original_node, updated_node): 56 | """Modify a direct `import ` statement.""" 57 | names = updated_node.names 58 | 59 | if original_node.deep_equals(self.node_to_break): 60 | original_name = names[0].name.value 61 | 62 | # Add a typo to the name of the module being imported. 63 | new_name = bug_utils.make_typo(original_name) 64 | 65 | # Modify the node name. 66 | new_names = [cst.ImportAlias(name=cst.Name(new_name))] 67 | 68 | # Record this modification. 69 | modified_node = updated_node.with_changes(names=new_names) 70 | 71 | position = self.metadata[original_node] 72 | line_num = position.start.line 73 | 74 | modification = Modification( 75 | path=self.path, 76 | original_node=original_node, 77 | modified_node=modified_node, 78 | line_num=line_num, 79 | exception_induced=ModuleNotFoundError, 80 | ) 81 | modifications.append(modification) 82 | 83 | return updated_node.with_changes(names=new_names) 84 | 85 | return updated_node 86 | 87 | 88 | class AttributeModifier(cst.CSTTransformer): 89 | """Modify attributes in the user's project.""" 90 | 91 | def __init__(self, node_to_break, node_index, path, metadata): 92 | self.node_to_break = node_to_break 93 | 94 | # There may be identical nodes in the tree. node_index determines which to modify. 95 | self.node_index = node_index 96 | self.identical_nodes_visited = 0 97 | 98 | # Each use of this class should only generate one bug. But multiple nodes 99 | # can match node_to_break, so make sure we only modify one node. 100 | self.bug_generated = False 101 | 102 | # Need this to record the modification we're making. 103 | self.path = path 104 | self.metadata = metadata 105 | 106 | def leave_Attribute(self, original_node, updated_node): 107 | """Modify an attribute name, to generate AttributeError.""" 108 | attr = updated_node.attr 109 | 110 | if original_node.deep_equals(self.node_to_break) and not self.bug_generated: 111 | # If there are identical nodes and this isn't the right one, bump count 112 | # and return unmodified node. 113 | if self.identical_nodes_visited != self.node_index: 114 | self.identical_nodes_visited += 1 115 | return updated_node 116 | 117 | original_identifier = attr.value 118 | 119 | # Add a typo to the attribute name. 120 | new_identifier = bug_utils.make_typo(original_identifier) 121 | 122 | # Modify the node name. 123 | new_attr = cst.Name(new_identifier) 124 | 125 | # Record this modification. 126 | modified_node = updated_node.with_changes(attr=new_attr) 127 | 128 | position = self.metadata[original_node] 129 | line_num = position.start.line 130 | 131 | modification = Modification( 132 | path=self.path, 133 | original_node=original_node, 134 | modified_node=modified_node, 135 | line_num=line_num, 136 | exception_induced=AttributeError, 137 | ) 138 | modifications.append(modification) 139 | 140 | self.bug_generated = True 141 | 142 | return updated_node.with_changes(attr=new_attr) 143 | 144 | return updated_node 145 | 146 | 147 | def get_paths_nodes(py_files, node_type): 148 | """Get all nodes of given type.""" 149 | paths_nodes = [] 150 | for path in py_files: 151 | source = path.read_text() 152 | tree = cst.parse_module(source) 153 | 154 | wrapper = MetadataWrapper(tree) 155 | metadata = wrapper.resolve(PositionProvider) 156 | 157 | node_collector = NodeCollector(node_type=node_type) 158 | wrapper.module.visit(node_collector) 159 | 160 | for node in node_collector.collected_nodes: 161 | position = metadata.get(node) 162 | line_num = position.start.line 163 | 164 | if not pb_config.target_lines: 165 | paths_nodes.append((path, node)) 166 | elif line_num in pb_config.target_lines: 167 | paths_nodes.append((path, node)) 168 | 169 | return paths_nodes 170 | 171 | 172 | def get_all_nodes(path): 173 | """Get all nodes in a file. 174 | 175 | This is primarily for development work, where we want to see all the nodes 176 | in a short representative file. 177 | 178 | Example usage, from a #_bugger() function: 179 | nodes = _get_all_nodes(py_files[0]) 180 | """ 181 | source = path.read_text() 182 | tree = cst.parse_module(source) 183 | 184 | node_collector = NodeCollector(node_type=cst.CSTNode) 185 | tree.visit(node_collector) 186 | 187 | return node_collector.collected_nodes 188 | 189 | 190 | def count_nodes(tree, node): 191 | """Count the number of nodes in path that match node. 192 | 193 | Useful when a file has multiple identical nodes, and we want to choose one. 194 | """ 195 | # Count all relevant nodes. 196 | node_counter = NodeCounter(node) 197 | tree.visit(node_counter) 198 | 199 | return node_counter.node_count 200 | -------------------------------------------------------------------------------- /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, test_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 = test_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, test_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 = test_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, test_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 = test_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, test_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 = test_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"{test_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, test_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 = test_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"{test_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 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog: python-bugger 2 | === 3 | 4 | 0.5 - Support multiple exception types in one call 5 | --- 6 | 7 | Previously, any single run of `py-bugger` could only create bugs that induce one kind of exception. In the 0.5 series, a variety of bugs can be introduced in a single call. 8 | 9 | ### 0.5.2 10 | 11 | #### External changes 12 | 13 | - Supports the `--target-lines` argument, which can be a single line or a range of lines. 14 | 15 | #### Internal changes 16 | 17 | - All modifications record a line number. 18 | 19 | ### 0.5.1 20 | 21 | #### External changes 22 | 23 | - Fixes bug where inducing an `IndentationError` would modify all `try` blocks in a file, if that's the kind of line that's chosen to be modified. (Issue [65](https://github.com/ehmatthes/py-bugger/issues/65)) 24 | 25 | #### Internal changes 26 | 27 | - Code that implements `IndentationError` bugs uses line numbers, not lines. 28 | - Added `line_num` attribute to `Modification`. 29 | 30 | ### 0.5.0 31 | 32 | #### External changes 33 | 34 | - When no `-e` arg is passed, generates a random sequence of bugs to induce. Bugs are generated in an order that makes it more likely to successfully create all requested bugs. 35 | - Docs updated to reflect current usage. 36 | 37 | #### Internal changes 38 | 39 | - Main loop works from a list of bugs to introduce, rather than ranging over `num_bugs`. 40 | - Reorders `requested_bugs` so CST-based parsing happens before regex-based parsing. Avoids writing syntax-related bugs before parsing nodes. 41 | - Started integration tests that should take the place of many current e2e tests. Calls `py_bugger.main()` directly, and checks `requested_bugs` and `modfications`. 42 | - `Modification` objects record the exception type that's induced. 43 | 44 | 0.4 - Git status checks 45 | --- 46 | 47 | Looks for a clean Git status before introducing bugs. 48 | 49 | ### 0.4.1 50 | 51 | #### External changes 52 | 53 | - The `--exception-type` argument is now optional. When it's omitted, one of the supported exception types is chosen randomly. 54 | - Passing `--num-args` still works, but each bug induces the same kind of exception. 55 | - If there's a typo in the value for `--exception-type`, a suggestion is made. Users still need to revise their command, it does not prompt to use the suggested value at this point. 56 | - Fixes a bug where running py-bugger from a directory under version control, against a target directory that's also under version control, runs Git commands against the first directory instead of the target. 57 | 58 | #### Internal changes 59 | 60 | - Uses `SUPPORTED_EXCEPTION_TYPES` from `py_bugger.cli.config` anywhere a list of supported exception types is needed. 61 | - All handling of `--num-args` takes place in the main py_bugger.py file. 62 | - Tracks each bug that's introduced, so each node or line can't be modified more than once. There's a new `Modification` model that's used to track each modification that's made to the user's code. This also simplifies tracking the number of bugs, which can always be determined from `len(modifications)`. 63 | - Some light ongoing refactoring work has been done through this point release. 64 | 65 | ### 0.4.0 66 | 67 | #### External changes 68 | 69 | - Checks Git status before introducing bugs. 70 | - Allows overriding Git checks with `--ignore-git-status`. 71 | 72 | #### Internal changes 73 | 74 | - Moving `py_bugger` import closer to where it's needed speeds up tests. 75 | 76 | 77 | 0.3 - Multiple exception types targeted 78 | --- 79 | 80 | Can request more than one type of exception to be induced. 81 | 82 | ### 0.3.6 83 | 84 | #### External changes 85 | 86 | - Includes validation for --target-dir and --target-file args. 87 | 88 | ### 0.3.5 89 | 90 | #### External changes 91 | 92 | - Removes else, elif, case, except and finally from targets for IndentationError for now. 93 | 94 | #### Internal changes 95 | 96 | - Added a release script. 97 | - Partial implementation of a test for handling tabs correctly. 98 | 99 | ### 0.3.4 100 | 101 | #### External changes 102 | 103 | - Fixes a bug where the last trailing newline was not written back to the file after introducing bugs that cause an `IndentationError`. 104 | 105 | #### Internal changes 106 | 107 | - Adds thorough tests for handling trailing newlines when modifying files. 108 | 109 | ### 0.3.3 110 | 111 | #### External changes 112 | 113 | - Added a `--verbose` (`-v`) flag. Only shows where bugs were added when this flag is present. 114 | 115 | #### Internal changes 116 | 117 | - The `pb_config` object is imported directly into *buggers.py*, and does not need to be passed to each bugger function. 118 | 119 | ### 0.3.2 120 | 121 | #### External changes 122 | 123 | - Does not modify files in directories named `Tests/`. 124 | - Moved docs to Read the Docs. 125 | 126 | #### Internal changes 127 | 128 | - Set up CI. 129 | - Move CLI code to a cli/ dir. 130 | - Move buggers.py out of utils/. 131 | - Make a cli_utils.py module. 132 | - Use a `config` object for CLI options. 133 | - Simpler parsing of CLI options. 134 | - Simpler approach to getting `py_files` in `main()`. 135 | - Issue template for bug reports. 136 | - Move helper functions from buggers.py to appropriate utility modules. 137 | 138 | ### 0.3.1 139 | 140 | #### External changes 141 | 142 | - Wider variety of bugs generated to induce requested exception type. 143 | - Greater variety of typos. 144 | - Greater variety in placement of bugs. 145 | - Supports `-e IndentationError`. 146 | 147 | #### Internal changes 148 | 149 | - The `developer_resources/` dir contains sample nodes. 150 | - Uses a generic `NodeCollector` class. 151 | - Utility functions for generating bugs, ie `utils/bug_utils.make_typo()`. 152 | - End to end tests are less specific, so more resilient to changes in bugmaking algos, while still ensuring the requested exception type is induced. 153 | - Helper function to get all nodes in a file, to support development work. 154 | - Use `random.sample()` (no replacement) rather than `random.choices()` (uses replacement) when selecting which nodes to modify. 155 | 156 | ### 0.3.0 157 | 158 | #### External changes 159 | 160 | - Support for `--exception-type AttributeError`. 161 | 162 | 163 | 0.2 - Much wider range of bugs possible 164 | --- 165 | 166 | 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. 167 | 168 | ### 0.2.1 169 | 170 | #### External changes 171 | 172 | - Filters out .py files from dirs named `test_code/`. 173 | 174 | ### 0.2.0 175 | 176 | #### External changes 177 | 178 | - Require `click`. 179 | - Includes a `--num-bugs` arg. 180 | - Modifies specified number of import nodes. 181 | - Randomly selects which relevant node to modify. 182 | - Reports level of success. 183 | - Supports `--target-file` arg. 184 | - Better messaging when not including `--exception-type`. 185 | 186 | #### Internal changes 187 | 188 | - CLI is built on `click`, rather than `argparse`. 189 | - Uses a random seed when `PY_BUGGER_RANDOM_SEED` env var is set, for testing. 190 | - Utils dir, with initial `file_utils.py` module. 191 | - Finds all .py files we can consider changing. 192 | - If using Git, returns all tracked .py files not related to testing. 193 | - If not using Git, returns all .py files not in venv, dist, build, or tests. 194 | - Catches `TypeError` if unable to make desired change; we can focus on these kinds of changes as the project evolves. 195 | 196 | 197 | 0.1 - Proof of concept (one exception type implemented) 198 | --- 199 | 200 | 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. 201 | 202 | 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). 203 | 204 | ### 0.1.0 205 | 206 | Initial release. Very limited implementation of: 207 | 208 | ```sh 209 | $ py-bugger --exception-type ModuleNotFoundError 210 | ``` 211 | -------------------------------------------------------------------------------- /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, test_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 / test_config.path_dog_bark.name 37 | shutil.copyfile(test_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 | 51 | assert lines[-1] == "dog.say_hi()\n" 52 | 53 | 54 | @pytest.mark.parametrize( 55 | "exception_type", ["IndentationError", "AttributeError", "ModuleNotFoundError"] 56 | ) 57 | def test_preserve_file_ending_no_trailing_newline( 58 | tmp_path_factory, test_config, exception_type 59 | ): 60 | """Test that trailing newlines are not introduced when not originally present.""" 61 | 62 | # Copy sample code to tmp dir. 63 | tmp_path = tmp_path_factory.mktemp("sample_code") 64 | print(f"\nCopying code to: {tmp_path.as_posix()}") 65 | 66 | path_src = test_config.path_sample_scripts / "dog_bark_no_trailing_newline.py" 67 | path_dst = tmp_path / path_src.name 68 | shutil.copyfile(path_src, path_dst) 69 | 70 | # Run py-bugger against file. 71 | cmd = f"py-bugger --exception-type {exception_type} --target-file {path_dst.as_posix()} --ignore-git-status" 72 | print("cmd:", cmd) 73 | cmd_parts = shlex.split(cmd) 74 | 75 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 76 | 77 | assert "All requested bugs inserted." in stdout 78 | 79 | # Check that last line is not blank. 80 | lines = path_dst.read_text().splitlines(keepends=True) 81 | 82 | assert lines[-1] == "dog.say_hi()" 83 | 84 | 85 | @pytest.mark.parametrize( 86 | "exception_type", ["IndentationError", "AttributeError", "ModuleNotFoundError"] 87 | ) 88 | def test_preserve_file_ending_two_trailing_newline( 89 | tmp_path_factory, test_config, exception_type 90 | ): 91 | """Test that two trailing newlines are preserved when present.""" 92 | 93 | # Copy sample code to tmp dir. 94 | tmp_path = tmp_path_factory.mktemp("sample_code") 95 | print(f"\nCopying code to: {tmp_path.as_posix()}") 96 | 97 | path_src = test_config.path_sample_scripts / "dog_bark_two_trailing_newlines.py" 98 | path_dst = tmp_path / path_src.name 99 | shutil.copyfile(path_src, path_dst) 100 | 101 | # Run py-bugger against file. 102 | cmd = f"py-bugger --exception-type {exception_type} --target-file {path_dst.as_posix()} --ignore-git-status" 103 | print("cmd:", cmd) 104 | cmd_parts = shlex.split(cmd) 105 | 106 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 107 | 108 | assert "All requested bugs inserted." in stdout 109 | 110 | # Check that last line is not blank. 111 | lines = path_dst.read_text().splitlines(keepends=True) 112 | assert lines[-1] == "\n" 113 | 114 | 115 | ### --- Test for handling of blank files --- 116 | 117 | 118 | @pytest.mark.parametrize( 119 | "exception_type", ["IndentationError", "AttributeError", "ModuleNotFoundError"] 120 | ) 121 | def test_blank_file_behavior(tmp_path_factory, test_config, exception_type): 122 | """Make sure py-bugger handles a blank file correctly.""" 123 | # Copy sample code to tmp dir. 124 | tmp_path = tmp_path_factory.mktemp("sample_code") 125 | print(f"\nCopying code to: {tmp_path.as_posix()}") 126 | 127 | path_src = test_config.path_sample_scripts / "blank_file.py" 128 | path_dst = tmp_path / path_src.name 129 | shutil.copyfile(path_src, path_dst) 130 | 131 | # Run py-bugger against file. 132 | cmd = f"py-bugger --exception-type {exception_type} --target-file {path_dst.as_posix()} --ignore-git-status" 133 | print("cmd:", cmd) 134 | cmd_parts = shlex.split(cmd) 135 | 136 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 137 | 138 | assert "Unable to introduce any of the requested bugs." in stdout 139 | 140 | # Check that file is still blank. 141 | contents = path_dst.read_text() 142 | assert contents == "" 143 | 144 | 145 | ### --- Tests for invalid --target-dir calls --- 146 | 147 | 148 | def test_file_passed_to_targetdir(tmp_path_factory, test_config): 149 | """Make sure passing a file to --target-dir fails appropriately.""" 150 | # Copy sample code to tmp dir. 151 | tmp_path = tmp_path_factory.mktemp("sample_code") 152 | print(f"\nCopying code to: {tmp_path.as_posix()}") 153 | 154 | path_src = test_config.path_sample_scripts / "dog.py" 155 | path_dst = tmp_path / path_src.name 156 | shutil.copyfile(path_src, path_dst) 157 | 158 | # Run py-bugger against file. 159 | cmd = ( 160 | f"py-bugger --exception-type AttributeError --target-dir {path_dst.as_posix()}" 161 | ) 162 | print("cmd:", cmd) 163 | cmd_parts = shlex.split(cmd) 164 | 165 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 166 | 167 | msg_expected = cli_messages.msg_file_not_dir(path_dst) 168 | assert msg_expected in stdout 169 | 170 | 171 | def test_nonexistent_dir_passed_to_targetdir(): 172 | """Make sure passing a nonexistent dir to --target-dir fails appropriately.""" 173 | 174 | # Make a dir path that doesn't exist. If this assertion fails, something weird happened. 175 | path_dst = Path("nonsense_name") 176 | assert not path_dst.exists() 177 | 178 | # Run py-bugger against nonexistent dir. 179 | cmd = ( 180 | f"py-bugger --exception-type AttributeError --target-dir {path_dst.as_posix()}" 181 | ) 182 | print("cmd:", cmd) 183 | cmd_parts = shlex.split(cmd) 184 | 185 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 186 | 187 | msg_expected = cli_messages.msg_nonexistent_dir(path_dst) 188 | assert msg_expected in stdout 189 | 190 | 191 | @pytest.mark.skipif( 192 | platform.system() == "Windows", reason="Can't use /dev/null on Windows." 193 | ) 194 | def test_targetdir_exists_not_dir(): 195 | """Passed something that exists, but is not a file or dir..""" 196 | 197 | # /dev/null is neither a file or a dir, but exists. 198 | path_dst = Path("/dev/null") 199 | assert path_dst.exists() 200 | assert not path_dst.is_file() 201 | assert not path_dst.is_dir() 202 | 203 | # Run py-bugger. 204 | cmd = ( 205 | f"py-bugger --exception-type AttributeError --target-dir {path_dst.as_posix()}" 206 | ) 207 | print("cmd:", cmd) 208 | cmd_parts = shlex.split(cmd) 209 | 210 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 211 | 212 | msg_expected = cli_messages.msg_not_dir(path_dst) 213 | assert msg_expected in stdout 214 | 215 | 216 | ### --- Tests for invalid --target-file calls --- 217 | 218 | 219 | def test_dir_passed_to_targetfile(tmp_path_factory): 220 | """Make sure passing a dir to --target-file fails appropriately.""" 221 | path_dst = tmp_path_factory.mktemp("sample_code") 222 | 223 | # Run py-bugger. 224 | cmd = ( 225 | f"py-bugger --exception-type AttributeError --target-file {path_dst.as_posix()}" 226 | ) 227 | print("cmd:", cmd) 228 | cmd_parts = shlex.split(cmd) 229 | 230 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 231 | 232 | msg_expected = cli_messages.msg_dir_not_file(path_dst) 233 | assert msg_expected in stdout 234 | 235 | 236 | def test_nonexistent_file_passed_to_targetfile(): 237 | """Make sure passing a nonexistent file to --target-file fails appropriately.""" 238 | 239 | # Make a file path that doesn't exist. If this assertion fails, something weird happened. 240 | path_dst = Path("nonsense_python_file.py") 241 | assert not path_dst.exists() 242 | 243 | # Run py-bugger. 244 | cmd = ( 245 | f"py-bugger --exception-type AttributeError --target-file {path_dst.as_posix()}" 246 | ) 247 | print("cmd:", cmd) 248 | cmd_parts = shlex.split(cmd) 249 | 250 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 251 | 252 | msg_expected = cli_messages.msg_nonexistent_file(path_dst) 253 | assert msg_expected in stdout 254 | 255 | 256 | @pytest.mark.skipif( 257 | platform.system() == "Windows", reason="Can't use /dev/null on Windows." 258 | ) 259 | def test_targetfile_exists_not_file(): 260 | """Passed something that exists, but is not a file or dir..""" 261 | 262 | # /dev/null is neither a file or a dir, but exists. 263 | path_dst = Path("/dev/null") 264 | assert path_dst.exists() 265 | assert not path_dst.is_file() 266 | assert not path_dst.is_dir() 267 | 268 | # Run py-bugger. 269 | cmd = ( 270 | f"py-bugger --exception-type AttributeError --target-file {path_dst.as_posix()}" 271 | ) 272 | print("cmd:", cmd) 273 | cmd_parts = shlex.split(cmd) 274 | 275 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 276 | 277 | msg_expected = cli_messages.msg_not_file(path_dst) 278 | assert msg_expected in stdout 279 | 280 | 281 | def test_targetfile_py_file(tmp_path_factory, test_config): 282 | """Test for appropriate message when passed a non-.py file.""" 283 | # Copy sample code to tmp dir. 284 | tmp_path = tmp_path_factory.mktemp("sample_code") 285 | print(f"\nCopying code to: {tmp_path.as_posix()}") 286 | 287 | path_src = test_config.path_sample_scripts / "hello.txt" 288 | path_dst = tmp_path / path_src.name 289 | shutil.copyfile(path_src, path_dst) 290 | 291 | # Run py-bugger against file. 292 | cmd = ( 293 | f"py-bugger --exception-type AttributeError --target-file {path_dst.as_posix()}" 294 | ) 295 | print("cmd:", cmd) 296 | cmd_parts = shlex.split(cmd) 297 | 298 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 299 | 300 | msg_expected = cli_messages.msg_file_not_py(path_dst) 301 | assert msg_expected in stdout 302 | -------------------------------------------------------------------------------- /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 ....s...............s... 31 | tests/e2e_tests/test_cli_flags.py .. 32 | tests/e2e_tests/test_git_status_checks.py ..... 33 | ... 34 | ========== 64 passed, 2 skipped in 6.85s ====================== 35 | ``` 36 | 37 | ## Development work 38 | 39 | 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. 40 | 41 | ### Running `py-bugger` against a single .py file 42 | 43 | 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/`. 44 | 45 | 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. 46 | 47 | 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): 48 | 49 | ```sh 50 | $ mkdir pb-simple-test && cd pb-simple-test 51 | pb-simple-test$ cp ~/projects/py-bugger/tests/sample_code/sample_scripts/simple_indent.py simple_indent.py 52 | pb-simple-test$ ls 53 | simple_indent.py 54 | pb-simple-test$ nano .gitignore 55 | pb-simple-test$ git init 56 | Initialized empty Git repository in pb-simple-test/.git/ 57 | pb-simple-test$ git add . 58 | pb-simple-test$ git commit -am "Initial state." 59 | pb-simple-test$ uv venv .venv 60 | pb-simple-test$ source .venv/bin/activate 61 | (.venv) pb-simple-test$ uv pip install -e ~/projects/py-bugger/ 62 | (.venv) pb-simple-test$ python simple_indent.py 63 | 1 64 | 2 65 | 3 66 | 67 | (.venv) pb-simple-test$ py-bugger -e IndentationError 68 | Added bug. 69 | All requested bugs inserted. 70 | 71 | (.venv) pb-simple-test$ python simple_indent.py 72 | File "/Users/eric/test_codepb-simple-test/simple_indent.py", line 1 73 | for num in [1, 2, 3]: 74 | IndentationError: unexpected indent 75 | 76 | (.venv) pb-simple-test$ git checkout . 77 | (.venv) pb-simple-test$ python simple_indent.py 78 | 1 79 | 2 80 | 3 81 | ``` 82 | 83 | ### Running `py-bugger` against a larger project 84 | 85 | 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. 86 | 87 | Here's how to run py-bugger against Pillow, and verify that it worked as expected: 88 | 89 | ```sh 90 | $ git clone https://github.com/python-pillow/Pillow.git pb-pillow 91 | $ cd pb-pillow 92 | pb-pillow$ uv venv .venv 93 | pb-pillow$ source .venv/bin/activate 94 | (.venv) /pb-pillow$ uv pip install -e ".[tests]" 95 | (.venv) /pb-pillow$ pytest 96 | ... 97 | ========== 4692 passed, 259 skipped, 3 xfailed in 46.65s ========== 98 | 99 | (.venv) /pb-pillow$ uv pip install -e ~/projects/py-bugger 100 | (.venv) /pb-pillow$ py-bugger -e AttributeError 101 | Added bug. 102 | All requested bugs inserted. 103 | (.venv) /pb-pillow$ pytest -qx 104 | ... 105 | E AttributeError: module 'PIL.TiffTags' has no attribute 'LONmG8'. Did you mean: 'LONG8'? 106 | ========== short test summary info ========== 107 | ERROR Tests/test_file_libtiff.py - AttributeError: module 'PIL.TiffTags' has no attribute 'LONmG8'. Did you mean: 'LONG8'? 108 | !!!!!!!!!! stopping after 1 failures !!!!!!!!!! 109 | 1 error in 0.33s 110 | ``` 111 | 112 | !!! note 113 | 114 | 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. 115 | 116 | !!! note 117 | 118 | 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. 119 | 120 | ## Overall logic 121 | 122 | It's helpful to get a quick sense of how the project works. 123 | 124 | ### `src/py_bugger/cli/cli.py` 125 | 126 | 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. 127 | 128 | ### `src/py_bugger/py_bugger.py` 129 | 130 | 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. Each time a bug is introduced, it's added to the list `modifications`, which is created in `src/py_bugger/utils/modification.py`. 131 | 132 | After introducing bugs, a `success_msg` is generated showing whether the requested bugs were inserted. 133 | 134 | ### Notes 135 | 136 | - 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. 137 | - 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. 138 | 139 | ## Parsing code 140 | 141 | 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. 142 | 143 | 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. 144 | 145 | 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. 146 | 147 | ## Updating documentation 148 | 149 | Start a local documentation server: 150 | 151 | ```sh 152 | (.venv)$ mkdocs serve 153 | INFO - Building documentation... 154 | ... 155 | INFO - [16:24:31] Serving on http://127.0.0.1:8000/ 156 | ``` 157 | 158 | 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`. 159 | 160 | ## Testing 161 | 162 | `py-bugger` currently has unit, integration, 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. 163 | 164 | The intial focus was on creating a series of e2e tests that make actual `py-bugger` calls in subprocesses against temp files and directories. This has been really effective for intial development, because it tests the project exactly as end-users experience it. That said, each test takes about 0.15s, which adds up quickly as the test suite grows. 165 | 166 | Integration tests now directly run the `py-bugger` code that modifies the user's codebase. We're still using temp files and directories, but integration tests don't depend on subprocess calls. Most new tests should be written as integration tests. One of the refactoring projects is to figure out which e2e tests really need to be kept, and which can be converted to much faster integration tests. 167 | 168 | Unit tests are only written for critical functions, and functions that are unlikely to change through the refactoring that should happen before a 1.0 release. An overemphaiss on unit tests would slow the project down at this point, with little benefit compared to integration and e2e tests. 169 | 170 | ### Unit tests 171 | 172 | Unit tests currently require no setup. 173 | 174 | ### Integration tests 175 | 176 | Integration tests create a `pb_config` object, and then call `py_bugger.main()`. Assertions are made against the list of modifications that are made to the user's project. An autouse fixture resets the `pb_config` object after each test function. 177 | 178 | ### End-to-end tests 179 | 180 | 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. 181 | 182 | 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. 183 | 184 | 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. 185 | 186 | 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`. 187 | 188 | Long term, as we find a balance between integration tests and e2e tests, the e2e tests should probably focus on verifying that the changes listed in `modifications` are actually written to the user's project. 189 | 190 | ### Running the test suite 191 | 192 | The first time you run the test suite, you should probably use the bare `pytest` call as shown at the top of this page. You'll see all the test files that are being run, and have a sense of what kinds of tests are being run. When you're running tests repeatedly, however, it's much faster to run tests in parallel: 193 | 194 | ```sh 195 | (.venv) py-bugger$ pytest -n auto 196 | ========== test session starts ====================================== 197 | s...s............................................................. 198 | ========== 64 passed, 2 skipped in 1.69s ============================ 199 | ``` 200 | 201 | Keep in mind that parallel testing can introduce all kinds of complexity, so if you see unexpected failures when running tests like this, try running tests without the `-n auto` flag. 202 | -------------------------------------------------------------------------------- /tests/integration_tests/test_target_lines_arg.py: -------------------------------------------------------------------------------- 1 | """Test modifications made when --target-lines is set.""" 2 | 3 | from pathlib import Path 4 | import shutil 5 | import os 6 | 7 | import pytest 8 | 9 | from py_bugger import py_bugger 10 | from py_bugger.cli.config import pb_config 11 | from py_bugger.cli import cli_utils 12 | from py_bugger.utils.modification import modifications 13 | 14 | 15 | def test_invalid_no_target_file(tmp_path_factory, test_config): 16 | """Test exits when no target file provided.""" 17 | # Copy sample code to tmp dir. 18 | tmp_path = tmp_path_factory.mktemp("sample_code") 19 | print(f"\nCopying code to: {tmp_path.as_posix()}") 20 | 21 | path_src = test_config.path_sample_scripts / "dog_bark.py" 22 | path_dst = tmp_path / path_src.name 23 | shutil.copyfile(path_src, path_dst) 24 | 25 | # Make modifications against this file. 26 | pb_config.target_file = "" 27 | pb_config.target_lines = "19-22" 28 | 29 | with pytest.raises(SystemExit) as excinfo: 30 | cli_utils.validate_config() 31 | 32 | assert excinfo.type == SystemExit 33 | 34 | def test_invalid_target_line_too_large(tmp_path_factory, test_config): 35 | """Test exits when target line not in target file.""" 36 | # Copy sample code to tmp dir. 37 | tmp_path = tmp_path_factory.mktemp("sample_code") 38 | print(f"\nCopying code to: {tmp_path.as_posix()}") 39 | 40 | path_src = test_config.path_sample_scripts / "dog_bark.py" 41 | path_dst = tmp_path / path_src.name 42 | shutil.copyfile(path_src, path_dst) 43 | 44 | # Make modifications against this file. 45 | pb_config.target_file = path_dst 46 | pb_config.target_lines = "100" 47 | 48 | with pytest.raises(SystemExit) as excinfo: 49 | cli_utils.validate_config() 50 | 51 | assert excinfo.type == SystemExit 52 | 53 | def test_invalid_target_block_too_large(tmp_path_factory, test_config): 54 | """Test exits when target line not in target file.""" 55 | # Copy sample code to tmp dir. 56 | tmp_path = tmp_path_factory.mktemp("sample_code") 57 | print(f"\nCopying code to: {tmp_path.as_posix()}") 58 | 59 | path_src = test_config.path_sample_scripts / "dog_bark.py" 60 | path_dst = tmp_path / path_src.name 61 | shutil.copyfile(path_src, path_dst) 62 | 63 | # Make modifications against this file. 64 | pb_config.target_file = path_dst 65 | pb_config.target_lines = "25-30" 66 | 67 | with pytest.raises(SystemExit) as excinfo: 68 | cli_utils.validate_config() 69 | 70 | assert excinfo.type == SystemExit 71 | 72 | def test_target_lines_block_indentation_error(tmp_path_factory, test_config): 73 | """Test that the modified line is in the targeted block. 74 | 75 | This test was first written without --target-lines. Then a block of 76 | lines was identifed that didn't contain the bug that was originally made. 77 | We're asserting that the change made is different than what would have been 78 | introduced without this target block. 79 | """ 80 | # Copy sample code to tmp dir. 81 | tmp_path = tmp_path_factory.mktemp("sample_code") 82 | print(f"\nCopying code to: {tmp_path.as_posix()}") 83 | 84 | path_src = test_config.path_sample_scripts / "dog_bark.py" 85 | path_dst = tmp_path / path_src.name 86 | shutil.copyfile(path_src, path_dst) 87 | 88 | # Make modifications against this file. 89 | pb_config.target_file = path_dst 90 | pb_config.exception_type = "IndentationError" 91 | pb_config.target_lines = "19-22" 92 | cli_utils.validate_config() 93 | 94 | # Check that the --target-lines arg was converted correctly. 95 | assert pb_config.target_lines == [19, 20, 21, 22] 96 | 97 | requested_bugs = py_bugger.main() 98 | 99 | # Without including --target-lines, line 12 was modified. Make sure the line that 100 | # was modified with --target-lines is in the target block. 101 | assert len(modifications) == 1 102 | assert modifications[0].line_num in pb_config.target_lines 103 | 104 | def test_target_lines_block_attribute_error(tmp_path_factory, test_config): 105 | """Test that the modified line is in the targeted block. 106 | 107 | This test was first written without --target-lines. Then a block of 108 | lines was identifed that didn't contain the bug that was originally made. 109 | We're asserting that the change made is different than what would have been 110 | introduced without this target block. 111 | """ 112 | # Copy sample code to tmp dir. 113 | tmp_path = tmp_path_factory.mktemp("sample_code") 114 | print(f"\nCopying code to: {tmp_path.as_posix()}") 115 | 116 | path_src = test_config.path_sample_scripts / "dog_bark.py" 117 | path_dst = tmp_path / path_src.name 118 | shutil.copyfile(path_src, path_dst) 119 | 120 | # Make modifications against this file. 121 | pb_config.target_file = path_dst 122 | pb_config.exception_type = "AttributeError" 123 | pb_config.target_lines = "12-22" 124 | cli_utils.validate_config() 125 | 126 | # Check that the --target-lines arg was converted correctly. 127 | assert pb_config.target_lines == [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22] 128 | 129 | requested_bugs = py_bugger.main() 130 | 131 | # Without including --target-lines, line 26 was modified. Make sure the line that 132 | # was modified with --target-lines is in the target block. 133 | assert len(modifications) == 1 134 | assert modifications[0].line_num in pb_config.target_lines 135 | 136 | def test_target_lines_block_modulenotfound_error(tmp_path_factory, test_config): 137 | """Test that the modified line is in the targeted block. 138 | 139 | This test was first written without --target-lines. Then a block of 140 | lines was identifed that didn't contain the bug that was originally made. 141 | We're asserting that the change made is different than what would have been 142 | introduced without this target block. 143 | """ 144 | # Copy sample code to tmp dir. 145 | tmp_path = tmp_path_factory.mktemp("sample_code") 146 | print(f"\nCopying code to: {tmp_path.as_posix()}") 147 | 148 | path_src = test_config.path_sample_scripts / "ten_imports.py" 149 | path_dst = tmp_path / path_src.name 150 | shutil.copyfile(path_src, path_dst) 151 | 152 | # Make modifications against this file. 153 | pb_config.target_file = path_dst 154 | pb_config.exception_type = "ModuleNotFoundError" 155 | pb_config.target_lines = "1-3" 156 | cli_utils.validate_config() 157 | 158 | # Check that the --target-lines arg was converted correctly. 159 | assert pb_config.target_lines == [1, 2, 3] 160 | 161 | requested_bugs = py_bugger.main() 162 | 163 | # Without including --target-lines, line 6 was modified. Make sure the line that 164 | # was modified with --target-lines is in the target block. 165 | assert len(modifications) == 1 166 | assert modifications[0].line_num in pb_config.target_lines 167 | 168 | def test_single_target_line_indentation_error(tmp_path_factory, test_config): 169 | """Test that the modified line is the targeted line. 170 | 171 | This test was first written without --target-lines. Then a line 172 | was identifed that didn't contain the bug that was originally made. 173 | We're asserting that the change made is different than what would have been 174 | introduced without this target line. 175 | """ 176 | # Copy sample code to tmp dir. 177 | tmp_path = tmp_path_factory.mktemp("sample_code") 178 | print(f"\nCopying code to: {tmp_path.as_posix()}") 179 | 180 | path_src = test_config.path_sample_scripts / "dog_bark.py" 181 | path_dst = tmp_path / path_src.name 182 | shutil.copyfile(path_src, path_dst) 183 | 184 | # Make modifications against this file. 185 | pb_config.target_file = path_dst 186 | pb_config.exception_type = "IndentationError" 187 | pb_config.target_lines = "16" 188 | cli_utils.validate_config() 189 | 190 | # Check that the --target-lines arg was converted correctly. 191 | assert pb_config.target_lines == [16] 192 | 193 | requested_bugs = py_bugger.main() 194 | 195 | # Without including --target-lines, line 12 was modified. Make sure the line that 196 | # was modified with --target-lines is the target line. 197 | assert len(modifications) == 1 198 | assert modifications[0].line_num in pb_config.target_lines 199 | 200 | def test_single_target_line_attribute_error(tmp_path_factory, test_config): 201 | """Test that the modified line is the targeted line. 202 | 203 | This test was first written without --target-lines. Then a line 204 | was identifed that didn't contain the bug that was originally made. 205 | We're asserting that the change made is different than what would have been 206 | introduced without this target line. 207 | """ 208 | # Copy sample code to tmp dir. 209 | tmp_path = tmp_path_factory.mktemp("sample_code") 210 | print(f"\nCopying code to: {tmp_path.as_posix()}") 211 | 212 | path_src = test_config.path_sample_scripts / "dog_bark.py" 213 | path_dst = tmp_path / path_src.name 214 | shutil.copyfile(path_src, path_dst) 215 | 216 | # Make modifications against this file. 217 | pb_config.target_file = path_dst 218 | pb_config.exception_type = "AttributeError" 219 | pb_config.target_lines = "14" 220 | cli_utils.validate_config() 221 | 222 | # Check that the --target-lines arg was converted correctly. 223 | assert pb_config.target_lines == [14] 224 | 225 | requested_bugs = py_bugger.main() 226 | 227 | # Without including --target-lines, line 26 was modified. Make sure the line that 228 | # was modified with --target-lines is the target line. 229 | assert len(modifications) == 1 230 | assert modifications[0].line_num in pb_config.target_lines 231 | 232 | def test_target_lines_block_modulenotfound_error(tmp_path_factory, test_config): 233 | """Test that the modified line is the targeted line. 234 | 235 | This test was first written without --target-lines. Then a line 236 | was identifed that didn't contain the bug that was originally made. 237 | We're asserting that the change made is different than what would have been 238 | introduced without this target line. 239 | """ 240 | # Copy sample code to tmp dir. 241 | tmp_path = tmp_path_factory.mktemp("sample_code") 242 | print(f"\nCopying code to: {tmp_path.as_posix()}") 243 | 244 | path_src = test_config.path_sample_scripts / "ten_imports.py" 245 | path_dst = tmp_path / path_src.name 246 | shutil.copyfile(path_src, path_dst) 247 | 248 | # Make modifications against this file. 249 | pb_config.target_file = path_dst 250 | pb_config.exception_type = "ModuleNotFoundError" 251 | pb_config.target_lines = "4" 252 | cli_utils.validate_config() 253 | 254 | # Check that the --target-lines arg was converted correctly. 255 | assert pb_config.target_lines == [4] 256 | 257 | requested_bugs = py_bugger.main() 258 | 259 | # Without including --target-lines, line 6 was modified. Make sure the line that 260 | # was modified with --target-lines is the target line. 261 | assert len(modifications) == 1 262 | assert modifications[0].line_num in pb_config.target_lines 263 | 264 | def test_single_target_line_indentation_error_not_possible(tmp_path_factory, test_config): 265 | """Test that a target line where the error can't be induced results in no modifications. 266 | """ 267 | # Copy sample code to tmp dir. 268 | tmp_path = tmp_path_factory.mktemp("sample_code") 269 | print(f"\nCopying code to: {tmp_path.as_posix()}") 270 | 271 | path_src = test_config.path_sample_scripts / "dog_bark.py" 272 | path_dst = tmp_path / path_src.name 273 | shutil.copyfile(path_src, path_dst) 274 | 275 | # Make modifications against this file. 276 | pb_config.target_file = path_dst 277 | pb_config.exception_type = "IndentationError" 278 | pb_config.target_lines = "9" 279 | cli_utils.validate_config() 280 | 281 | # Check that the --target-lines arg was converted correctly. 282 | assert pb_config.target_lines == [9] 283 | 284 | requested_bugs = py_bugger.main() 285 | 286 | assert not modifications 287 | 288 | def test_single_target_line_attribute_error_not_possible(tmp_path_factory, test_config): 289 | """Test that a target line where the error can't be induced results in no modifications. 290 | """ 291 | # Copy sample code to tmp dir. 292 | tmp_path = tmp_path_factory.mktemp("sample_code") 293 | print(f"\nCopying code to: {tmp_path.as_posix()}") 294 | 295 | path_src = test_config.path_sample_scripts / "dog_bark.py" 296 | path_dst = tmp_path / path_src.name 297 | shutil.copyfile(path_src, path_dst) 298 | 299 | # Make modifications against this file. 300 | pb_config.target_file = path_dst 301 | pb_config.exception_type = "AttributeError" 302 | pb_config.target_lines = "12" 303 | cli_utils.validate_config() 304 | 305 | # Check that the --target-lines arg was converted correctly. 306 | assert pb_config.target_lines == [12] 307 | 308 | requested_bugs = py_bugger.main() 309 | 310 | assert not modifications 311 | 312 | def test_target_lines_block_modulenotfound_error_not_possible(tmp_path_factory, test_config): 313 | """Test that a target line where the error can't be induced results in no modifications. 314 | """ 315 | # Copy sample code to tmp dir. 316 | tmp_path = tmp_path_factory.mktemp("sample_code") 317 | print(f"\nCopying code to: {tmp_path.as_posix()}") 318 | 319 | path_src = test_config.path_sample_scripts / "dog_bark.py" 320 | path_dst = tmp_path / path_src.name 321 | shutil.copyfile(path_src, path_dst) 322 | 323 | # Make modifications against this file. 324 | pb_config.target_file = path_dst 325 | pb_config.exception_type = "ModuleNotFoundError" 326 | pb_config.target_lines = "12" 327 | cli_utils.validate_config() 328 | 329 | # Check that the --target-lines arg was converted correctly. 330 | assert pb_config.target_lines == [12] 331 | 332 | requested_bugs = py_bugger.main() 333 | 334 | assert not modifications 335 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | import platform 15 | 16 | import pytest 17 | 18 | 19 | # --- Test functions --- 20 | 21 | 22 | def test_help(test_config): 23 | """Test output of `py-bugger --help`.""" 24 | # Set an explicit column width, so output is consistent across systems. 25 | env = os.environ.copy() 26 | env["COLUMNS"] = "80" 27 | 28 | cmd = "py-bugger --help" 29 | cmd_parts = shlex.split(cmd) 30 | stdout = subprocess.run(cmd_parts, capture_output=True, env=env).stdout.decode() 31 | 32 | path_help_output = test_config.path_reference_files / "help.txt" 33 | assert stdout.replace("\r\n", "\n") == path_help_output.read_text().replace( 34 | "\r\n", "\n" 35 | ) 36 | 37 | 38 | def test_no_exception_type(tmp_path_factory, test_config): 39 | """Test that passing no -e arg chooses a random exception type to induce.""" 40 | 41 | # Copy sample code to tmp dir. 42 | tmp_path = tmp_path_factory.mktemp("sample_code") 43 | print(f"\nCopying code to: {tmp_path.as_posix()}") 44 | 45 | path_src = test_config.path_sample_scripts / "dog_bark.py" 46 | path_dst = tmp_path / path_src.name 47 | shutil.copyfile(path_src, path_dst) 48 | 49 | # Run py-bugger against directory. 50 | cmd = f"py-bugger --target-dir {tmp_path.as_posix()} --ignore-git-status" 51 | print(f"cmd: {cmd}") 52 | cmd_parts = shlex.split(cmd) 53 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 54 | print(stdout) 55 | 56 | assert "All requested bugs inserted." in stdout 57 | 58 | # Run file, should raise IndentationError. 59 | cmd = f"{test_config.python_cmd.as_posix()} {path_dst.as_posix()}" 60 | cmd_parts = shlex.split(cmd) 61 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 62 | 63 | assert "AttributeError" in stderr 64 | 65 | 66 | @pytest.mark.parametrize("num_bugs", [2, 15]) 67 | def test_no_exception_type_with_narg(tmp_path_factory, test_config, num_bugs): 68 | """Test that passing no -e arg works with --num-bugs.""" 69 | 70 | # Copy sample code to tmp dir. 71 | tmp_path = tmp_path_factory.mktemp("sample_code") 72 | print(f"\nCopying code to: {tmp_path.as_posix()}") 73 | 74 | path_src = test_config.path_sample_scripts / "dog_bark.py" 75 | path_dst = tmp_path / path_src.name 76 | shutil.copyfile(path_src, path_dst) 77 | 78 | # Run py-bugger against directory. 79 | cmd = f"py-bugger --num-bugs {num_bugs} --target-dir {tmp_path.as_posix()} --ignore-git-status" 80 | print(f"cmd: {cmd}") 81 | cmd_parts = shlex.split(cmd) 82 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 83 | print(stdout) 84 | 85 | if num_bugs == 2: 86 | assert "All requested bugs inserted." in stdout 87 | elif num_bugs == 15: 88 | assert "Inserted " in stdout 89 | assert "Unable to introduce additional bugs of the requested type." in stdout 90 | 91 | # Run file, should raise an error. 92 | cmd = f"{test_config.python_cmd.as_posix()} {path_dst.as_posix()}" 93 | cmd_parts = shlex.split(cmd) 94 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 95 | 96 | if num_bugs == 2: 97 | assert "AttributeError" in stderr 98 | else: 99 | assert "IndentationError" in stderr 100 | 101 | 102 | @pytest.mark.skip() 103 | def test_no_exception_type_first_not_possible(tmp_path_factory, test_config): 104 | """Test that passing no -e arg induces an exception, even when the first 105 | exception type randomly selected is not possible. 106 | """ 107 | 108 | # Copy sample code to tmp dir. 109 | tmp_path = tmp_path_factory.mktemp("sample_code") 110 | print(f"\nCopying code to: {tmp_path.as_posix()}") 111 | 112 | # The first exception type chosen it will attempt is IndentationError. 113 | # This sample script has no indented blocks, so py-bugger will have to 114 | # find another exception to induce. 115 | path_src = test_config.path_sample_scripts / "system_info_script.py" 116 | path_dst = tmp_path / path_src.name 117 | shutil.copyfile(path_src, path_dst) 118 | 119 | # Run py-bugger against directory. 120 | cmd = f"py-bugger --target-dir {tmp_path.as_posix()} --ignore-git-status" 121 | cmd_parts = shlex.split(cmd) 122 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 123 | print(stdout) 124 | 125 | assert "All requested bugs inserted." in stdout 126 | 127 | # Run file, should raise IndentationError. 128 | cmd = f"{test_config.python_cmd.as_posix()} {path_dst.as_posix()}" 129 | cmd_parts = shlex.split(cmd) 130 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 131 | assert 'dog_bark.py", line 12' in stderr 132 | assert "IndentationError: unexpected indent" in stderr 133 | 134 | 135 | def test_modulenotfounderror(tmp_path_factory, test_config): 136 | """py-bugger --exception-type ModuleNotFoundError""" 137 | 138 | # Copy sample code to tmp dir. 139 | tmp_path = tmp_path_factory.mktemp("sample_code") 140 | print(f"\nCopying code to: {tmp_path.as_posix()}") 141 | 142 | path_dst = tmp_path / test_config.path_name_picker.name 143 | shutil.copyfile(test_config.path_name_picker, path_dst) 144 | 145 | # Run py-bugger against directory. 146 | cmd = f"py-bugger --exception-type ModuleNotFoundError --target-dir {tmp_path.as_posix()} --ignore-git-status" 147 | print(f"cmd: {cmd}") 148 | cmd_parts = shlex.split(cmd) 149 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 150 | 151 | assert "All requested bugs inserted." in stdout 152 | 153 | # Run file, should raise ModuleNotFoundError. 154 | cmd = f"{test_config.python_cmd.as_posix()} {path_dst.as_posix()}" 155 | cmd_parts = shlex.split(cmd) 156 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 157 | assert "Traceback (most recent call last)" in stderr 158 | assert 'name_picker.py", line 1, in ' in stderr 159 | assert "ModuleNotFoundError: No module named" in stderr 160 | 161 | 162 | def test_default_one_error(tmp_path_factory, test_config): 163 | """py-bugger --exception-type ModuleNotFoundError 164 | 165 | Test that only one import statement is modified. 166 | """ 167 | 168 | # Copy sample code to tmp dir. 169 | tmp_path = tmp_path_factory.mktemp("sample_code") 170 | print(f"\nCopying code to: {tmp_path.as_posix()}") 171 | 172 | path_dst = tmp_path / test_config.path_system_info.name 173 | shutil.copyfile(test_config.path_system_info, path_dst) 174 | 175 | # Run py-bugger against directory. 176 | cmd = f"py-bugger --exception-type ModuleNotFoundError --target-dir {tmp_path.as_posix()} --ignore-git-status" 177 | cmd_parts = shlex.split(cmd) 178 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 179 | 180 | assert "All requested bugs inserted." in stdout 181 | 182 | # Run file, should raise ModuleNotFoundError. 183 | cmd = f"{test_config.python_cmd.as_posix()} {path_dst.as_posix()}" 184 | cmd_parts = shlex.split(cmd) 185 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 186 | assert "Traceback (most recent call last)" in stderr 187 | assert 'system_info_script.py", line ' in stderr 188 | assert "ModuleNotFoundError: No module named " in stderr 189 | 190 | # Read modified file; should have changed only one import statement. 191 | modified_source = path_dst.read_text() 192 | assert "import sys" in modified_source or "import os" in modified_source 193 | 194 | 195 | def test_two_bugs(tmp_path_factory, test_config): 196 | """py-bugger --exception-type ModuleNotFoundError --num-bugs 2 197 | 198 | Test that both import statements are modified. 199 | """ 200 | # Copy sample code to tmp dir. 201 | tmp_path = tmp_path_factory.mktemp("sample_code") 202 | print(f"\nCopying code to: {tmp_path.as_posix()}") 203 | 204 | path_dst = tmp_path / test_config.path_system_info.name 205 | shutil.copyfile(test_config.path_system_info, path_dst) 206 | 207 | # Run py-bugger against directory. 208 | cmd = f"py-bugger --exception-type ModuleNotFoundError --num-bugs 2 --target-dir {tmp_path.as_posix()} --ignore-git-status" 209 | print(f"cmd: {cmd}") 210 | cmd_parts = shlex.split(cmd) 211 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 212 | 213 | assert "All requested bugs inserted." in stdout 214 | 215 | # Run file, should raise ModuleNotFoundError. 216 | cmd = f"{test_config.python_cmd.as_posix()} {path_dst.as_posix()}" 217 | cmd_parts = shlex.split(cmd) 218 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 219 | assert "Traceback (most recent call last)" in stderr 220 | assert 'system_info_script.py", line 3, in ' in stderr 221 | assert "ModuleNotFoundError: No module named " in stderr 222 | 223 | # Read modified file; should have changed both import statements. 224 | # Note that `import os` can become `import osp`, so we can't just do: 225 | # assert "import os" not in modified_source 226 | lines = path_dst.read_text().splitlines() 227 | assert "import sys" not in lines 228 | assert "import os" not in lines 229 | 230 | 231 | def test_random_import_affected(tmp_path_factory, test_config): 232 | """py-bugger --exception-type ModuleNotFoundError 233 | 234 | Test that a random import statement is modified. 235 | """ 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 / test_config.path_ten_imports.name 241 | shutil.copyfile(test_config.path_ten_imports, path_dst) 242 | 243 | # Run py-bugger against directory. 244 | cmd = f"py-bugger --exception-type ModuleNotFoundError --target-dir {tmp_path.as_posix()} --ignore-git-status" 245 | print(cmd) 246 | cmd_parts = shlex.split(cmd) 247 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 248 | 249 | assert "All requested bugs inserted." in stdout 250 | 251 | # Run file, should raise ModuleNotFoundError. 252 | cmd = f"{test_config.python_cmd.as_posix()} {path_dst.as_posix()}" 253 | cmd_parts = shlex.split(cmd) 254 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 255 | assert "Traceback (most recent call last)" in stderr 256 | assert 'ten_imports.py", line ' in stderr 257 | assert "ModuleNotFoundError: No module named " in stderr 258 | 259 | # Read modified file; should have changed one import statement. 260 | modified_source = path_dst.read_text() 261 | pkgs = [ 262 | "os", 263 | "sys", 264 | "re", 265 | "random", 266 | "difflib", 267 | "calendar", 268 | "zoneinfo", 269 | "array", 270 | "pprint", 271 | "enum", 272 | ] 273 | assert sum([p in modified_source for p in pkgs]) == 9 274 | 275 | 276 | def test_random_py_file_affected(tmp_path_factory, test_config): 277 | """py-bugger --exception-type ModuleNotFoundError 278 | 279 | Test that a random .py file is modified. 280 | """ 281 | # Copy two sample scripts to tmp dir. 282 | tmp_path = tmp_path_factory.mktemp("sample_code") 283 | print(f"\nCopying code to: {tmp_path.as_posix()}") 284 | 285 | path_dst_ten_imports = tmp_path / test_config.path_ten_imports.name 286 | shutil.copyfile(test_config.path_ten_imports, path_dst_ten_imports) 287 | 288 | path_dst_system_info = tmp_path / test_config.path_system_info.name 289 | shutil.copyfile(test_config.path_system_info, path_dst_system_info) 290 | 291 | # Run py-bugger against directory. 292 | cmd = f"py-bugger --exception-type ModuleNotFoundError --target-dir {tmp_path.as_posix()} --ignore-git-status" 293 | print(cmd) 294 | cmd_parts = shlex.split(cmd) 295 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 296 | 297 | assert "All requested bugs inserted." in stdout 298 | 299 | # Run file, should raise ModuleNotFoundError. 300 | # When we collect files, the order is different on different OSes. 301 | if platform.system() in ["Windows", "Linux"]: 302 | path_modified = path_dst_ten_imports 303 | path_unmodified = path_dst_system_info 304 | path_unmodified_original = test_config.path_system_info 305 | else: 306 | path_modified = path_dst_system_info 307 | path_unmodified = path_dst_ten_imports 308 | path_unmodified_original = test_config.path_ten_imports 309 | 310 | cmd = f"{test_config.python_cmd.as_posix()} {path_modified.as_posix()}" 311 | cmd_parts = shlex.split(cmd) 312 | stderr = subprocess.run(cmd_parts, capture_output=True, text=True).stderr 313 | 314 | assert "Traceback (most recent call last)" in stderr 315 | assert f'{path_modified.name}", line ' in stderr 316 | assert "ModuleNotFoundError: No module named " in stderr 317 | 318 | # Other file should not be changed. 319 | assert filecmp.cmp(path_unmodified_original, path_unmodified) 320 | 321 | 322 | @pytest.mark.parametrize( 323 | "exception_type", ["IndentationError", "AttributeError", "ModuleNotFoundError"] 324 | ) 325 | def test_unable_insert_all_bugs(tmp_path_factory, test_config, exception_type): 326 | """Test for appropriate message when unable to generate all requested bugs.""" 327 | # Copy sample code to tmp dir. 328 | tmp_path = tmp_path_factory.mktemp("sample_code") 329 | print(f"\nCopying code to: {tmp_path.as_posix()}") 330 | 331 | path_src = test_config.path_sample_scripts / "dog_bark.py" 332 | path_dst = tmp_path / path_src.name 333 | shutil.copyfile(path_src, path_dst) 334 | 335 | # Run py-bugger against directory. 336 | cmd = f"py-bugger --exception-type {exception_type} -n 10 --target-dir {tmp_path.as_posix()} --ignore-git-status" 337 | print(f"cmd: {cmd}") 338 | cmd_parts = shlex.split(cmd) 339 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 340 | 341 | # Check that at least one bug was inserted, but unable to introduce all requested. 342 | assert "Added bug." in stdout 343 | assert "Unable to introduce additional bugs of the requested type." in stdout 344 | 345 | 346 | def test_no_bugs(tmp_path_factory, test_config): 347 | """Test for appropriate message when unable to introduce any requested bugs.""" 348 | tmp_path = tmp_path_factory.mktemp("sample_code") 349 | print(f"\nCopying code to: {tmp_path.as_posix()}") 350 | 351 | path_dst = tmp_path / test_config.path_zero_imports.name 352 | shutil.copyfile(test_config.path_zero_imports, path_dst) 353 | 354 | # Run py-bugger against directory. 355 | cmd = f"py-bugger --exception-type ModuleNotFoundError --target-dir {tmp_path.as_posix()} --ignore-git-status" 356 | cmd_parts = shlex.split(cmd) 357 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 358 | 359 | assert "Unable to introduce any of the requested bugs." in stdout 360 | 361 | 362 | def test_target_dir_and_file(tmp_path_factory, test_config): 363 | """Test an invalid call including --target-dir and --target-file.""" 364 | tmp_path = tmp_path_factory.mktemp("sample_code") 365 | print(f"\nCopying code to: {tmp_path.as_posix()}") 366 | 367 | path_dst = tmp_path / test_config.path_zero_imports.name 368 | shutil.copyfile(test_config.path_zero_imports, path_dst) 369 | 370 | # Run py-bugger against directory. 371 | cmd = f"py-bugger --exception-type ModuleNotFoundError --target-dir {tmp_path.as_posix()} --target-file {path_dst.as_posix()}" 372 | cmd_parts = shlex.split(cmd) 373 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 374 | 375 | assert ( 376 | "Target file overrides target dir. Please only pass one of these args." 377 | in stdout 378 | ) 379 | 380 | 381 | def test_target_file(tmp_path_factory, test_config): 382 | """Test for passing --target-file.""" 383 | # Copy two sample scripts to tmp dir. 384 | tmp_path = tmp_path_factory.mktemp("sample_code") 385 | print(f"\nCopying code to: {tmp_path.as_posix()}") 386 | 387 | path_dst_ten_imports = tmp_path / test_config.path_ten_imports.name 388 | shutil.copyfile(test_config.path_ten_imports, path_dst_ten_imports) 389 | 390 | path_dst_system_info = tmp_path / test_config.path_system_info.name 391 | shutil.copyfile(test_config.path_system_info, path_dst_system_info) 392 | 393 | # Run py-bugger against directory. 394 | cmd = f"py-bugger --exception-type ModuleNotFoundError --target-file {path_dst_system_info.as_posix()} --ignore-git-status" 395 | print(cmd) 396 | cmd_parts = shlex.split(cmd) 397 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 398 | 399 | assert "All requested bugs inserted." in stdout 400 | 401 | # Run file, should raise ModuleNotFoundError. 402 | cmd = f"{test_config.python_cmd.as_posix()} {path_dst_system_info.as_posix()}" 403 | cmd_parts = shlex.split(cmd) 404 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 405 | assert "Traceback (most recent call last)" in stderr 406 | assert 'system_info_script.py", line ' in stderr 407 | assert "ModuleNotFoundError: No module named " in stderr 408 | 409 | # Other file should not be changed. 410 | assert filecmp.cmp(test_config.path_ten_imports, path_dst_ten_imports) 411 | 412 | 413 | def test_attribute_error(tmp_path_factory, test_config): 414 | """py-bugger --exception-type AttributeError""" 415 | 416 | # Copy sample code to tmp dir. 417 | tmp_path = tmp_path_factory.mktemp("sample_code") 418 | print(f"\nCopying code to: {tmp_path.as_posix()}") 419 | 420 | path_dst = tmp_path / test_config.path_name_picker.name 421 | shutil.copyfile(test_config.path_name_picker, path_dst) 422 | 423 | # Run py-bugger against directory. 424 | cmd = f"py-bugger --exception-type AttributeError --target-dir {tmp_path.as_posix()} --ignore-git-status" 425 | print("cmd:", cmd) 426 | cmd_parts = shlex.split(cmd) 427 | 428 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 429 | 430 | assert "All requested bugs inserted." in stdout 431 | 432 | # Run file, should raise AttributeError. 433 | cmd = f"{test_config.python_cmd.as_posix()} {path_dst.as_posix()}" 434 | cmd_parts = shlex.split(cmd) 435 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 436 | assert "Traceback (most recent call last)" in stderr 437 | assert 'name_picker.py", line ' in stderr 438 | assert "AttributeError: " in stderr 439 | assert "Did you mean: " in stderr 440 | 441 | 442 | def test_one_node_changed(tmp_path_factory, test_config): 443 | """Test that only one node in a file is modified for identical nodes.""" 444 | # Copy sample code to tmp dir. 445 | tmp_path = tmp_path_factory.mktemp("sample_code") 446 | print(f"\nCopying code to: {tmp_path.as_posix()}") 447 | 448 | path_dst = tmp_path / test_config.path_dog.name 449 | shutil.copyfile(test_config.path_dog, path_dst) 450 | 451 | # Run py-bugger against directory. 452 | cmd = f"py-bugger --exception-type AttributeError --target-dir {tmp_path.as_posix()} --ignore-git-status" 453 | cmd_parts = shlex.split(cmd) 454 | 455 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 456 | 457 | assert "All requested bugs inserted." in stdout 458 | 459 | # Run file, should raise AttributeError. 460 | cmd = f"{test_config.python_cmd.as_posix()} {path_dst.as_posix()}" 461 | cmd_parts = shlex.split(cmd) 462 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 463 | assert "Traceback (most recent call last)" in stderr 464 | assert 'dog.py", line 10, in ' in stderr 465 | assert "AttributeError: 'Dog' object has no attribute " in stderr 466 | assert "Did you mean: " in stderr 467 | 468 | # Make sure only one attribute was affected. 469 | modified_source = path_dst.read_text() 470 | assert "self.name" in modified_source 471 | assert "self.nam" in modified_source 472 | 473 | 474 | def test_random_node_changed(tmp_path_factory, test_config): 475 | """Test that a random node in a file is modified if it has numerous identical nodes.""" 476 | # Copy sample code to tmp dir. 477 | tmp_path = tmp_path_factory.mktemp("sample_code") 478 | print(f"\nCopying code to: {tmp_path.as_posix()}") 479 | 480 | path_dst = tmp_path / test_config.path_identical_attributes.name 481 | shutil.copyfile(test_config.path_identical_attributes, path_dst) 482 | 483 | # Run py-bugger against directory. 484 | cmd = f"py-bugger --exception-type AttributeError --target-dir {tmp_path.as_posix()} --ignore-git-status" 485 | cmd_parts = shlex.split(cmd) 486 | 487 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 488 | 489 | assert "All requested bugs inserted." in stdout 490 | 491 | # Run file, should raise AttributeError. 492 | cmd = f"{test_config.python_cmd.as_posix()} {path_dst.as_posix()}" 493 | cmd_parts = shlex.split(cmd) 494 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 495 | assert "Traceback (most recent call last)" in stderr 496 | assert 'identical_attributes.py", line ' in stderr 497 | assert "AttributeError: module 'random' has no attribute " in stderr 498 | assert "Did you mean: " in stderr 499 | 500 | # Make sure only one attribute was affected. 501 | modified_source = path_dst.read_text() 502 | assert modified_source.count("random.choice(") == 19 503 | 504 | 505 | def test_indentation_error_simple(tmp_path_factory, test_config): 506 | """py-bugger --exception-type IndentationError 507 | 508 | Run against a file with a single indented block. 509 | """ 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 / test_config.path_simple_indent.name 516 | shutil.copyfile(test_config.path_simple_indent, path_dst) 517 | 518 | # Run py-bugger against directory. 519 | cmd = f"py-bugger --exception-type IndentationError --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"{test_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 'simple_indent.py", line 1' in stderr 533 | 534 | 535 | # This test passes, but it mixes tabs and spaces. It would fail if the 536 | # for loop was inside a function. Make a test file with the for loop 537 | # in the function, induce an error that indents the for line, not the 538 | # def line, and assert not TabError. 539 | @pytest.mark.skip() 540 | def test_indentation_error_simple_tab(tmp_path_factory, test_config): 541 | """py-bugger --exception-type IndentationError 542 | 543 | Run against a file with a single indented block, using a tab delimiter. 544 | """ 545 | # Copy sample code to tmp dir. 546 | tmp_path = tmp_path_factory.mktemp("sample_code") 547 | print(f"\nCopying code to: {tmp_path.as_posix()}") 548 | 549 | path_src = test_config.path_sample_scripts / "simple_indent_tab.py" 550 | path_dst = tmp_path / path_src.name 551 | shutil.copyfile(path_src, path_dst) 552 | 553 | # Run py-bugger against directory. 554 | cmd = f"py-bugger --exception-type IndentationError --target-dir {tmp_path.as_posix()} --ignore-git-status" 555 | print("cmd:", cmd) 556 | cmd_parts = shlex.split(cmd) 557 | 558 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 559 | 560 | assert "All requested bugs inserted." in stdout 561 | 562 | # Run file, should raise IndentationError. 563 | cmd = f"{test_config.python_cmd.as_posix()} {path_dst.as_posix()}" 564 | cmd_parts = shlex.split(cmd) 565 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 566 | assert "IndentationError: unexpected indent" in stderr 567 | assert 'simple_indent_tab.py", line 1' in stderr 568 | 569 | 570 | def test_indentation_error_complex(tmp_path_factory, test_config): 571 | """py-bugger --exception-type IndentationError 572 | 573 | Run against a file with multiple indented blocks of different kinds. 574 | """ 575 | # Copy sample code to tmp dir. 576 | tmp_path = tmp_path_factory.mktemp("sample_code") 577 | print(f"\nCopying code to: {tmp_path.as_posix()}") 578 | 579 | path_dst = tmp_path / test_config.path_many_dogs.name 580 | shutil.copyfile(test_config.path_many_dogs, path_dst) 581 | 582 | # Run py-bugger against directory. 583 | cmd = f"py-bugger --exception-type IndentationError --target-dir {tmp_path.as_posix()} --ignore-git-status" 584 | print("cmd:", cmd) 585 | cmd_parts = shlex.split(cmd) 586 | 587 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 588 | 589 | assert "All requested bugs inserted." in stdout 590 | 591 | # Run file, should raise IndentationError. 592 | cmd = f"{test_config.python_cmd.as_posix()} {path_dst.as_posix()}" 593 | cmd_parts = shlex.split(cmd) 594 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 595 | assert "IndentationError: unexpected indent" in stderr 596 | assert 'many_dogs.py", line 1' in stderr 597 | 598 | 599 | def test_all_indentation_blocks(tmp_path_factory, test_config): 600 | """Test that all kinds of indented blocks can be modified. 601 | 602 | Note: There are a couple blocks that aren't currently in all_indentation_blocks.py 603 | match, case, finally 604 | """ 605 | # Copy sample code to tmp dir. 606 | tmp_path = tmp_path_factory.mktemp("sample_code") 607 | print(f"\nCopying code to: {tmp_path.as_posix()}") 608 | 609 | path_dst = tmp_path / test_config.path_all_indentation_blocks.name 610 | shutil.copyfile(test_config.path_all_indentation_blocks, path_dst) 611 | 612 | # Run py-bugger against directory. 613 | cmd = f"py-bugger --exception-type IndentationError --num-bugs 8 --target-dir {tmp_path.as_posix()} --ignore-git-status" 614 | print("cmd:", cmd) 615 | cmd_parts = shlex.split(cmd) 616 | 617 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 618 | 619 | assert "All requested bugs inserted." in stdout 620 | 621 | # Run file, should raise IndentationError. 622 | cmd = f"{test_config.python_cmd.as_posix()} {path_dst.as_posix()}" 623 | cmd_parts = shlex.split(cmd) 624 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 625 | assert "IndentationError: unexpected indent" in stderr 626 | assert 'all_indentation_blocks.py", line 1' in stderr 627 | 628 | 629 | def test_indentation_else_block(tmp_path_factory, test_config): 630 | """Test that an indendented else block does not result in a SyntaxError. 631 | 632 | If the else block is moved to its own indentation level, -> IndentationError. 633 | If it matches the indentation level of the parent's block, ie the if's block, 634 | it will result in a Syntax Error: 635 | 636 | if True: 637 | print("Hi.") 638 | else: 639 | print("Bye.") 640 | """ 641 | # Copy sample code to tmp dir. 642 | tmp_path = tmp_path_factory.mktemp("sample_code") 643 | print(f"\nCopying code to: {tmp_path.as_posix()}") 644 | 645 | path_src = test_config.path_sample_scripts / "else_block.py" 646 | path_dst = tmp_path / path_src.name 647 | shutil.copyfile(path_src, path_dst) 648 | 649 | # Run py-bugger against directory. 650 | cmd = f"py-bugger --exception-type IndentationError --target-dir {tmp_path.as_posix()} --ignore-git-status" 651 | print("cmd:", cmd) 652 | cmd_parts = shlex.split(cmd) 653 | 654 | stdout = subprocess.run(cmd_parts, capture_output=True).stdout.decode() 655 | 656 | assert "All requested bugs inserted." in stdout 657 | 658 | # Run file, should raise IndentationError. 659 | cmd = f"{test_config.python_cmd.as_posix()} {path_dst.as_posix()}" 660 | cmd_parts = shlex.split(cmd) 661 | stderr = subprocess.run(cmd_parts, capture_output=True).stderr.decode() 662 | assert "SyntaxError: invalid syntax" not in stderr 663 | --------------------------------------------------------------------------------