├── .github └── workflows │ └── build.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MAINTAINERS.md ├── MANIFEST.in ├── README.md ├── bowler ├── README.md ├── __init__.py ├── __main__.py ├── helpers.py ├── imr.py ├── main.py ├── py.typed ├── query.py ├── tests │ ├── __init__.py │ ├── __main__.py │ ├── helpers.py │ ├── lib.py │ ├── query.py │ ├── smoke-selftest.py │ ├── smoke-target.py │ ├── smoke.py │ ├── tool.py │ └── type_inference.py ├── tool.py ├── type_inference.py └── types.py ├── docs ├── api-commands.md ├── api-filters.md ├── api-modifiers.md ├── api-query.md ├── api-selectors.md ├── basics-intro.md ├── basics-refactoring.md ├── basics-setup.md ├── basics-usage.md ├── dev-intro.md └── dev-roadmap.md ├── makefile ├── requirements-dev.txt ├── requirements.txt ├── scripts └── check_copyright.sh ├── setup.cfg ├── setup.py └── website ├── blog └── 2018-08-24-launch.md ├── core └── Footer.js ├── package.json ├── pages └── en │ ├── help.js │ ├── index.js │ └── users.js ├── sidebars.json ├── siteConfig.js └── static ├── css └── custom.css └── img ├── bowler.png ├── docusaurus.svg ├── favicon.png ├── favicon ├── Oddy.png ├── Oddy.pxm └── favicon.ico ├── logo ├── Bowler_Blue.png ├── Bowler_Blue.svg ├── Bowler_BlueAlt.png ├── Bowler_BlueAlt.svg ├── Bowler_Dark.png ├── Bowler_Dark.svg ├── Bowler_FullColor.png ├── Bowler_FullColor.svg ├── Bowler_FullColor_DarkText.png ├── Bowler_FullColor_DarkText.svg ├── Bowler_FullColor_LightAlt.png ├── Bowler_FullColor_LightAlt.svg ├── Bowler_FullColor_LightAlt_LightText.png ├── Bowler_FullColor_LightAlt_LightText.svg ├── Bowler_Light.png ├── Bowler_Light.svg ├── Bowler_SingleColor.png ├── Bowler_SingleColor.svg ├── Bowler_TextOnly_Dark.png ├── Bowler_TextOnly_Dark.svg ├── Bowler_TextOnly_Light.png └── Bowler_TextOnly_Light.svg └── oss_logo.png /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - tmp-* 7 | tags: 8 | - v* 9 | pull_request: 10 | 11 | jobs: 12 | bowler: 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: [3.6, 3.7, 3.8, 3.9] 18 | os: [macOS-latest, ubuntu-latest, windows-latest] 19 | 20 | steps: 21 | - uses: actions/checkout@v1 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v1 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install 27 | run: | 28 | python -m pip install --upgrade pip 29 | make setup 30 | pip install -U . 31 | - name: Test 32 | run: make test 33 | - name: Lint 34 | run: make lint 35 | - name: Coverage 36 | run: codecov --token ${{ secrets.CODECOV_TOKEN }} --branch ${{ github.ref }} 37 | continue-on-error: true 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # setuptools_scm 2 | bowler/version.py 3 | 4 | # Docusaurus 5 | .DS_Store 6 | 7 | node_modules 8 | 9 | lib/core/metadata.js 10 | lib/core/MetadataBlog.js 11 | 12 | website/translated_docs 13 | website/build/ 14 | website/yarn.lock 15 | website/node_modules 16 | website/i18n/* 17 | 18 | # Byte-compiled / optimized / DLL files 19 | __pycache__/ 20 | *.py[cod] 21 | *$py.class 22 | 23 | # C extensions 24 | *.so 25 | 26 | # Distribution / packaging 27 | .Python 28 | env/ 29 | build/ 30 | develop-eggs/ 31 | dist/ 32 | downloads/ 33 | eggs/ 34 | .eggs/ 35 | lib/ 36 | lib64/ 37 | parts/ 38 | sdist/ 39 | var/ 40 | wheels/ 41 | *.egg-info/ 42 | .installed.cfg 43 | *.egg 44 | 45 | # PyInstaller 46 | # Usually these files are written by a python script from a template 47 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 48 | *.manifest 49 | *.spec 50 | 51 | # Installer logs 52 | pip-log.txt 53 | pip-delete-this-directory.txt 54 | 55 | # Unit test / coverage reports 56 | htmlcov/ 57 | .tox/ 58 | .coverage 59 | .coverage.* 60 | .cache 61 | nosetests.xml 62 | coverage.xml 63 | *.cover 64 | .hypothesis/ 65 | 66 | # Translations 67 | *.mo 68 | *.pot 69 | 70 | # Django stuff: 71 | *.log 72 | local_settings.py 73 | 74 | # Flask stuff: 75 | instance/ 76 | .webassets-cache 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | target/ 86 | 87 | # Jupyter Notebook 88 | .ipynb_checkpoints 89 | 90 | # pyenv 91 | .python-version 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # dotenv 100 | .env 101 | 102 | # virtualenv 103 | .venv 104 | venv/ 105 | ENV/ 106 | 107 | # Spyder project settings 108 | .spyderproject 109 | .spyproject 110 | 111 | # Rope project settings 112 | .ropeproject 113 | 114 | # mkdocs documentation 115 | /site 116 | 117 | # mypy 118 | .mypy_cache/ 119 | 120 | # IDEA based IDEs 121 | .idea 122 | 123 | # VSCode 124 | .vscode/ 125 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.9.0 2 | 3 | * Added `bowler test` command for testing codemod scripts (#82) 4 | * Added `python_version` option to load files with Python 2 print statement (#123) 5 | * Implemented `Query.encapsulate()` to generate `@property` wrappers (#113) 6 | * Improvements to `Query.add_argument()` and positional arguments (#104) 7 | * No longer depends on shelling-out to `patch` command for applying diffs (#120) 8 | * Fix `Query.write()` to be non-interactive and silent (#88) 9 | * Fix unexpected error code after successful queries (#89) 10 | * Marked package as typed for PEP 561 support (#124) 11 | * Improved documentation (#100) 12 | 13 | ```bash 14 | $ git shortlog -sn v0.8.0... 15 | 13 John Reese 16 | 12 Tim Hatch 17 | 5 Anentropic 18 | 5 David Arnold 19 | 3 Marion 20 | 2 Mohit Solanki 21 | 2 ajberchek 22 | 1 Kamil Breguła 23 | ``` 24 | 25 | ## v0.8.0 26 | 27 | * Bug fix: reverify using fissix instead of ast to preserve the ability to modify code 28 | that is incompatible with the host version of python (#78) 29 | * Log full traceback of errors during `bowler run` (#86) 30 | * Adds a maintainers guide (#83) 31 | * Changes `filename_matcher` to receive a full path, in case it needs to read 32 | the file separately (#81) 33 | * Adds `query_func` to BowlerTestCase making it easier to test the provided 34 | helpers, and improve exception capture in tests. 35 | 36 | ```bash 37 | $ git shortlog -sn v0.7.1... 38 | 6 Tim Hatch 39 | 5 John Reese 40 | 1 Andy Freeland 41 | 1 Mariatta 42 | 1 Mariatta Wijaya 43 | ``` 44 | 45 | ## v0.7.1 46 | 47 | * Bug fix: skip writing files if they fail to parse after transform (#76) 48 | * Improved debug logging for parse failures (#75) 49 | 50 | ```bash 51 | $ git shortlog -sn v0.7.0... 52 | 4 John Reese 53 | 1 Tim Hatch 54 | ``` 55 | 56 | ## v0.7.0 57 | 58 | * Validate transformed AST before generating diff or writing changes (#26) 59 | * Improve function argument modifiers to not require filters (#29) 60 | * Better support for renaming dotted module names (#61, #72) 61 | * Materialize file list before deciding how many processes to create (#70) 62 | * Multiple documentation and site fixes (#50, #54, #55, #60) 63 | * Debug mode now runs in a single process (#53) 64 | * More test cases for helpers (#57) 65 | * Build wheel distributions during release (#67, #71) 66 | * Start tracking code coverage and upload results to coveralls.io (#71) 67 | * Mark Bowler as requiring Python >= 3.6 (#71) 68 | 69 | ```bash 70 | $ git shortlog -sn v0.6.0... 71 | 26 John Reese 72 | 5 Bojan Mihelac 73 | 5 Leopold Talirz 74 | 2 Tim Hatch 75 | 1 Guru 76 | 1 Philip Jameson 77 | 1 peng weikang 78 | ``` 79 | 80 | ## v0.6.0 81 | 82 | * Fix matching for functions with more than one decorators (#10) 83 | * Fix matching function/method calls preceded by the `await` keyword (#6) 84 | * Fix silent failures when processing files without trailing newlines (#20) 85 | * Better patching behavior for large files with many hunks (#21) 86 | * Support passing `pathlib.Path` values to `Query()` (#23) 87 | * Fix interactive mode when IPython not available (#31) 88 | * Better error logging and debugging (#38, #39) 89 | * Support refactoring arbitrary file extensions (#37) 90 | * Better testing framework and more unit tests (#43) 91 | * New helpers for numeric type inference (#42) 92 | * Support returning leaf/node elements from modifiers (#14, #44) 93 | * Fix lint/type checking on Python 3.7+ (#45) 94 | * Fixes and improvements to documentation (#13, #30, #32) 95 | * Consistent shebang/copyright headers in source files (#24, #25, #33) 96 | 97 | ```bash 98 | $ git shortlog -sn v0.5.1... 99 | 22 John Reese 100 | 18 Tim Hatch 101 | 8 Lisa Roach 102 | 3 Syrus Akbary 103 | 1 Sadie Bartholomew 104 | 1 Bruno Oliveira 105 | 1 Łukasz Langa 106 | 1 Christian Delahousse 107 | 1 Loren Carvalho 108 | 1 Qingzhuo Aw Young 109 | ``` 110 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | Facebook has adopted a Code of Conduct that we expect project participants to adhere to. 4 | Please read the [full text](https://code.facebook.com/pages/876921332402685/open-source-code-of-conduct) 5 | so that you can understand what actions will and will not be tolerated. 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Bowler 2 | 3 | We want to make contributing to this project as easy and transparent as 4 | possible. 5 | 6 | ## Getting Started 7 | 8 | When developing Bowler, follow these steps to setup your environment, 9 | format your code, and run linters and unit tests: 10 | 11 | 1. Fork [Bowler][] on Github. 12 | 13 | 1. Clone the git repo: 14 | ```bash 15 | $ git clone https://github.com/$USERNAME/bowler 16 | $ cd bowler 17 | ``` 18 | 19 | 1. Setup the virtual environment with dependencies and tools: 20 | ```bash 21 | $ make dev 22 | $ source .venv/bin/activate 23 | ``` 24 | 25 | 1. Format your code using [*Black*](https://github.com/ambv/black) and 26 | [isort](https://pypi.org/project/isort/): 27 | ```bash 28 | $ make format 29 | ``` 30 | 31 | 1. Run linter, type checks, and unit tests: 32 | ```bash 33 | $ make lint test 34 | ``` 35 | 36 | ## Pull Requests 37 | 38 | We actively welcome your pull requests. 39 | 40 | 1. If you've added code that should be tested, add unit tests. 41 | 1. If you've changed APIs, update the documentation. 42 | 1. Ensure the test suite passes. 43 | 1. Make sure your code lints. 44 | 1. If you haven't already, complete the Contributor License Agreement ("CLA"). 45 | 46 | ## Contributor License Agreement ("CLA") 47 | 48 | In order to accept your pull request, we need you to submit a CLA. You only need 49 | to do this once to work on any of Facebook's open source projects. 50 | 51 | Complete your CLA here: 52 | 53 | ## Issues 54 | 55 | We use GitHub issues to track public bugs. Please ensure your description is 56 | clear and has sufficient instructions to be able to reproduce the issue. 57 | 58 | Facebook has a [bounty program](https://www.facebook.com/whitehat/) for the safe 59 | disclosure of security bugs. In those cases, please go through the process 60 | outlined on that page and do not file a public issue. 61 | 62 | ## License 63 | 64 | By contributing to Bowler, you agree that your contributions will be licensed 65 | under the `LICENSE` file in the root directory of this source tree. 66 | 67 | 68 | [Bowler]: https://github.com/facebookincubator/bowler 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Facebook, Inc. and its affiliates. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | # Maintaining Bowler 2 | 3 | This documents the processes for maintaining Bowler. 4 | 5 | ## Reviewing Pull Requests 6 | 7 | When developers submit pull requests, the following points should be considered 8 | when deciding to accept, request changes, or reject them: 9 | 10 | For new features: 11 | 12 | * Is the feature appropriate for Bowler? 13 | * Do we want to take responsibility for maintaining and fixing this feature? 14 | * Is this implemented in a way that matches existing Bowler patterns and use cases? 15 | * Is this a complete implementation, or is there a clear path to completion? 16 | * Is the feature documented appropriately? 17 | 18 | For all code changes: 19 | 20 | * Does CI (test, lint, formatting) pass on all supported platforms? 21 | * Does this include appropriate test case coverage? 22 | * Is documentation updated as necessary? 23 | 24 | When a PR has been accepted: 25 | 26 | * Update PR title if necessary to clarify purpose. 27 | * Prefer using merge commits from Github to record PR name and number. 28 | * For automated PR's (like pyup.io), prefer using rebase from Github UI. 29 | 30 | ## Releasing New Versions 31 | 32 | 1. Decide on the next version number, based on what has been added to `main` 33 | since the previous release: 34 | 35 | * Major breaking changes should increment the first number and reset the other 36 | two, eg `0.10.0 -> 1.0.0` 37 | * New features should increment the second number and reset the third, 38 | eg `0.10.0 -> 0.11.0` 39 | * Bug fixes should only increment the third number, eg `0.10.0 -> 0.10.1`. 40 | 41 | 2. Update `bowler/__init__.py` with the new version number. 42 | 43 | 3. Update `CHANGELOG.md` with the new version, following the same pattern as 44 | previous versions. Entries should reference both the PR number and any 45 | associated issue numbers related to the feature or change described. 46 | 47 | Contributers to this release should be acknowledged by including the output 48 | of `git shortlog -sn ...`. 49 | 50 | 4. Commit the updated version number and changelog with a message following 51 | the pattern "(Feature | bugfix) release v". 52 | 53 | 5. Push this commit to upstream master branch and wait for CI to run/pass. 54 | 55 | 6. Tag this commit with the version number (including the preceding "v") 56 | using `git tag -s v`, and paste the contents of the changelog 57 | for this version as the tag's message. Be sure to make a signed tag (`-s`) 58 | using a GPG key attached to your Github profile. 59 | 60 | 7. Push this tag to upstream using `git push --tags` and wait for CI to pass. 61 | 62 | 8. Publish this release to PyPI using `make release` to build and upload 63 | the source distribution and wheels. 64 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE CODE_OF_CONDUCT.md CONTRIBUTING.md requirements.txt docs/*.md 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Bowler 2 | 3 | **Safe code refactoring for modern Python projects.** 4 | 5 | [![build status](https://github.com/facebookincubator/Bowler/workflows/Build/badge.svg)](https://github.com/facebookincubator/Bowler/actions) 6 | [![code coverage](https://img.shields.io/codecov/c/github/facebookincubator/Bowler)](https://codecov.io/gh/facebookincubator/Bowler) 7 | [![version](https://img.shields.io/pypi/v/bowler.svg)](https://pypi.org/project/bowler) 8 | [![changelog](https://img.shields.io/badge/change-log-blue.svg)](https://github.com/facebookincubator/bowler/blob/main/CHANGELOG.md) 9 | [![license](https://img.shields.io/pypi/l/bowler.svg)](https://github.com/facebookincubator/bowler/blob/main/LICENSE) 10 | [![code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) 11 | 12 | 13 | Current Development Status 14 | -------------------------- 15 | 16 | Bowler 0.x is based on fissix (a fork of lib2to3) which was never intended to be a 17 | stable api. We've pretty much reached the limit of being able to add new language 18 | features, so while we can support 3.8's walrus, that's basically the last it's going to 19 | get. 20 | 21 | See discussion for [`f(**not x)`](https://bugs.python.org/issue36541) for one example -- 22 | a proper fix for things like this would mean invalidating all current patterns since 23 | there's no way to match against a specific revision of the grammar. 24 | 25 | If you need to do codemods today, we recommend looking at 26 | [LibCST codemods](https://libcst.readthedocs.io/en/stable/codemods_tutorial.html) which 27 | are a bit more verbose, but work well on modern python grammars. We have contributed 28 | [support for Python 3.0-3.3 grammars](https://github.com/Instagram/LibCST/pull/261) 29 | and have a draft PR going even further [back to 2.3](https://github.com/Instagram/LibCST/pull/275). 30 | 31 | The longer term plan for Bowler is to build Bowler 2.x on top of libcst's parser, while 32 | still supporting a very simple fluent api. That's unlikely to materialize in a final 33 | release during 2021. 34 | 35 | 36 | Overview 37 | -------- 38 | 39 | Bowler is a refactoring tool for manipulating Python at the syntax tree level. It enables 40 | safe, large scale code modifications while guaranteeing that the resulting code compiles 41 | and runs. It provides both a simple command line interface and a fluent API in Python for 42 | generating complex code modifications in code. 43 | 44 | Bowler uses a "fluent" `Query` API to build refactoring scripts through a series 45 | of selectors, filters, and modifiers. Many simple modifications are already possible 46 | using the existing API, but you can also provide custom selectors, filters, and 47 | modifiers as needed to build more complex or custom refactorings. See the 48 | [Query Reference](https://pybowler.io/docs/api-query) for more details. 49 | 50 | Using the query API to rename a single function, and generate an interactive diff from 51 | the results, would look something like this: 52 | 53 | ```python 54 | query = ( 55 | Query() 56 | .select_function("old_name") 57 | .rename("new_name") 58 | .diff(interactive=True) 59 | ) 60 | ``` 61 | 62 | For more details or documentation, check out https://pybowler.io 63 | 64 | 65 | Installing Bowler 66 | ----------------- 67 | 68 | Bowler supports modifications to code from any version of Python 2 or 3, but it 69 | requires Python 3.6 or higher to run. Bowler can be easily installed using most common 70 | Python packaging tools. We recommend installing the latest stable release from 71 | [PyPI][] with `pip`: 72 | 73 | ```bash 74 | pip install bowler 75 | ``` 76 | 77 | You can also install a development version from source by checking out the Git repo: 78 | 79 | ```bash 80 | git clone https://github.com/facebookincubator/bowler 81 | cd bowler 82 | python setup.py install 83 | ``` 84 | 85 | 86 | License 87 | ------- 88 | 89 | Bowler is MIT licensed, as found in the `LICENSE` file. 90 | 91 | 92 | [PyPI]: https://pypi.org/p/bowler 93 | -------------------------------------------------------------------------------- /bowler/README.md: -------------------------------------------------------------------------------- 1 | Bowler 2 | ====== 3 | 4 | 5 | Overview 6 | -------- 7 | 8 | Bowler is a refactoring tool for manipulating Python at the syntax tree level. It 9 | enables safe, large scale code modification while guaranteeing that the resulting code 10 | compiles and runs. It provides both a simple command line interface and a fluent API in 11 | Python for generating complex code modifications in code. 12 | 13 | query = ( 14 | Query([]) 15 | # rename class Foo to Bar 16 | .select_class("Foo") 17 | .rename("Bar") 18 | # change method buzz(x) to buzzard(x: int) 19 | .select_method("buzz") 20 | .rename("buzzard") 21 | .modify_argument("x", type_annotation="int") 22 | ) 23 | 24 | query.diff() # generate unified diff on stdout 25 | query.write() # write changes directly to files 26 | 27 | Bowler uses the concrete syntax tree (CST) as generated by the [lib2to3][] module. 28 | 29 | 30 | CLI Reference 31 | ------------- 32 | 33 | Using Bowler at the command line follows the pattern below: 34 | 35 | $ bowler [--debug] [--help] [ ...] 36 | 37 | Bowler supports the following commands: 38 | 39 | do [ ...] 40 | Compile and run the given query, or open an IPython shell if none given. 41 | Common API elements will already be available in the global namespace. 42 | 43 | dump [ ...] 44 | Dump the CST from the given paths to stdout. 45 | 46 | rename_function [-i | --interactive] [ ...] 47 | Rename a function and its calls. 48 | 49 | 50 | Query Reference 51 | --------------- 52 | 53 | Queries use a fluent API to build a series of transforms over a given set of paths. 54 | Each transform consists of a selector, any number of filters, and one or more 55 | modifiers. Queries will only be compiled and executed once an appropriate action 56 | is triggered – like `diff()` or `write()`. 57 | 58 | Constructing queries should follow this basic pattern: 59 | 60 | 1. Create the query object, and specify all paths that should be considered 61 | 2. Specify a selector to define broad search criteria 62 | 3. Optionally specify one or more filters to refine the scope of modification 63 | 4. Specify one or more modifiers 64 | 5. Repeat from step 2 to include more transforms in the query 65 | 6. Execute the query with a terminal action, such as `diff()` or `write()`. 66 | 67 | Queries are started by creating a `Query` instance, and passing a list of paths that 68 | should be considered for modification: 69 | 70 | query = Query([path, ...]) 71 | 72 | All methods on a `Query` object will return the same `Query` object back, enabling 73 | "fluent" usage of the API – chaining one method call after the other: 74 | 75 | query = Query(...).selector(...)[.filter(...)].modifier(...) 76 | 77 | 78 | ### Selectors 79 | 80 | Selectors are query methods that generate a search pattern for the custom [lib2to3][] 81 | syntax. There are a number of prewritten selectors, but Bowler supports arbitrary 82 | selectors as well. 83 | 84 | Bowler supports the following methods for choosing selectors: 85 | 86 | .select_root() 87 | Selects the root of the syntax tree for each file. 88 | 89 | .select_module(name) 90 | Selects all module imports and references with the given name. 91 | 92 | .select_class(name) 93 | Selects all class definitions for – or subclasses of – the given name, as well 94 | as any calls or references to that name. 95 | 96 | .select_subclass(name) 97 | Selects all class definitions that subclass the given name, as well as any calls 98 | or references to that name. 99 | 100 | .select_attribute(name) 101 | Selects all class or object attributes, including assignments and references. 102 | 103 | .select_method(name) 104 | Selects all class method definitions with the given name, as well as any method 105 | calls or references with that name. 106 | 107 | .select_function(name) 108 | Selects all bare function definitions with the given name, as well as any calls 109 | or references with that name. 110 | 111 | .select_var(name) 112 | Select all references to that name, regardless of context. 113 | 114 | .select_pattern(pattern) 115 | Select nodes based on the arbitrary [lib2to3][] pattern given. 116 | 117 | 118 | ### Filters 119 | 120 | Filters are functions that limit the scope of modifiers. They are functions with the 121 | signature of `filter(node, capture, filename) -> bool`, and return `True` if the current 122 | node should be eligible for modification, or `False` to skip the node. 123 | 124 | - `node` refers to the base CST node matched by the active selector 125 | - `capture` is a dictionary, mapping named captures to their associated CST leaf or node 126 | - `filename` is the current filename being modified 127 | 128 | Bowler supports the following methods for adding filters: 129 | 130 | .is_call() 131 | Filters all nodes that aren't function or method calls. 132 | 133 | .is_def() 134 | Filters all nodes that aren't function or method definitions. 135 | 136 | .in_class(name, [include_subclasses = True]) 137 | Filters all nodes that aren't part of the either given class definition or 138 | a subclass of the given class. 139 | 140 | .is_filename([include = ], [exclude = ]) 141 | Filters all nodes belonging to files that don't match the given include/exclude 142 | regular expressions. 143 | 144 | .add_filter(function | str) 145 | Use an arbitrary function to filter nodes. If given a string, compile that 146 | and `eval()` it at each node to determine if the filter passed. 147 | 148 | 149 | ### Modifiers 150 | 151 | Modifiers take each matched node – that passed all active filters – and optionally 152 | applies some number of modifications to the CST of that node. They are functions with 153 | the signature of `filter(node, capture, filename)`, with no expected return value. 154 | 155 | - `node` refers to the base CST node matched by the active selector 156 | - `capture` is a dictionary, mapping named captures to their associated CST leaf or node 157 | - `filename` is the current filename being modified 158 | 159 | Bowler supports the following methods for adding modifiers: 160 | 161 | .rename(new_name) 162 | Rename all `*_name` captures to the given new name. 163 | 164 | .encapsulate([internal_name]) 165 | Encapsulate a class attribute into an `@property` decorated getter and setter. 166 | Requires the `select_attribute()` selector. 167 | 168 | .add_argument(name, value, [positional], [after], [type_annotation]) 169 | Add a new argument to a method or function, with a default value and optional 170 | position or type annotation. Also updates all callers with the new argument. 171 | 172 | .modify_argument(name, [new_name], [type_annotation], [default_value]) 173 | Modify an existing argument to a method or function, optionally renaming it, 174 | adding/changing the type annotation, or adding/changing the default value. 175 | Also updates all callers with new names. 176 | 177 | .remove_argument(name) 178 | Remove an existing argument from a method or function, as well as from callers. 179 | 180 | .add_modifier(function | str) 181 | Add an arbitrary modifier function. If given a string, compile that and 182 | `exec()` it at each matched node to perform modifications. 183 | 184 | 185 | ### Actions 186 | 187 | After building one or more transforms, those transforms are applied through the use of 188 | terminal actions. These include generating diffs, writing modifications to disk, and 189 | dumping matched nodes to stdout. 190 | 191 | Bowler supports the following terminal actions: 192 | 193 | .diff([interactive=False]) 194 | Generate a unified diff and echo it to stdout. Colors will be used when stdout 195 | is a terminal. Interactive mode will prompt the user after each hunk, with 196 | actions similar to those found in `git add -p`. 197 | 198 | .idiff() 199 | Shortcut for `.diff(interactive=True)`. 200 | 201 | .write() 202 | Write all changes back to disk, overwriting existing files. 203 | 204 | .dump() 205 | For each node matched, for each transform, print the CST representation of the 206 | node along with all captured nodes. 207 | 208 | .execute([write=False], [interactive=False]) 209 | Longer form of `.diff()` or `.write()`. 210 | 211 | 212 | [lib2to3]: https://docs.python.org/3.6/library/2to3.html#module-lib2to3 213 | -------------------------------------------------------------------------------- /bowler/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | """Safe code refactoring for modern Python projects.""" 9 | 10 | __author__ = "John Reese, Facebook" 11 | try: 12 | from .version import version as __version__ 13 | except ImportError: 14 | __version__ = "dev" 15 | 16 | from .imr import FunctionArgument, FunctionSpec 17 | from .query import Query 18 | from .tool import BowlerTool 19 | from .types import ( 20 | ARG_ELEMS, 21 | ARG_END, 22 | ARG_LISTS, 23 | DROP, 24 | LN, 25 | STARS, 26 | START, 27 | SYMBOL, 28 | TOKEN, 29 | BowlerException, 30 | Callback, 31 | Capture, 32 | Filename, 33 | Filter, 34 | Fixers, 35 | Hunk, 36 | IMRError, 37 | Processor, 38 | Stringish, 39 | ) 40 | -------------------------------------------------------------------------------- /bowler/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | import sys 9 | 10 | from .main import main 11 | 12 | if __name__ == "__main__": 13 | sys.argv[0] = "bowler" 14 | main() 15 | -------------------------------------------------------------------------------- /bowler/helpers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | import logging 9 | from typing import List, Optional, Sequence, Union 10 | 11 | import click 12 | from fissix.pgen2.token import tok_name 13 | from fissix.pytree import Leaf, Node, type_repr 14 | 15 | from .types import LN, SYMBOL, TOKEN, Capture, Filename, FilenameMatcher 16 | 17 | log = logging.getLogger(__name__) 18 | 19 | INDENT_STR = ". " 20 | 21 | 22 | def print_selector_pattern( 23 | node: LN, 24 | results: Capture = None, 25 | filename: Filename = None, 26 | first: bool = True, 27 | ): 28 | key = "" 29 | if results: 30 | for k, v in results.items(): 31 | if node == v: 32 | key = k + "=" 33 | elif isinstance(v, list) and node in v: # v is a list? 34 | key = k + "=" 35 | 36 | if isinstance(node, Leaf): 37 | click.echo(f"{key}{repr(node.value)} ", nl=False) 38 | else: 39 | click.echo(f"{key}{type_repr(node.type)} ", nl=False) 40 | if node.children: 41 | click.echo("< ", nl=False) 42 | for child in node.children: 43 | print_selector_pattern(child, results, filename, first=False) 44 | click.echo("> ", nl=False) 45 | 46 | if first: 47 | click.echo() 48 | 49 | 50 | def print_tree( 51 | node: LN, 52 | results: Capture = None, 53 | filename: Filename = None, 54 | indent: int = 0, 55 | recurse: int = -1, 56 | ): 57 | filename = filename or Filename("") 58 | tab = INDENT_STR * indent 59 | if filename and indent == 0: 60 | click.secho(filename, fg="red", bold=True) 61 | 62 | if isinstance(node, Leaf): 63 | click.echo( 64 | click.style(tab, fg="black", bold=True) 65 | + click.style( 66 | f"[{tok_name[node.type]}] {repr(node.prefix)} {repr(node.value)}", 67 | fg="yellow", 68 | ) 69 | ) 70 | else: 71 | click.echo( 72 | click.style(tab, fg="black", bold=True) 73 | + click.style(f"[{type_repr(node.type)}] {repr(node.prefix)}", fg="blue") 74 | ) 75 | 76 | if node.children: 77 | if recurse: 78 | for child in node.children: 79 | # N.b. do not pass results here since we print them once 80 | # at the end. 81 | print_tree(child, indent=indent + 1, recurse=recurse - 1) 82 | else: 83 | click.echo(INDENT_STR * (indent + 1) + "...") 84 | 85 | if results is None: 86 | return 87 | 88 | for key in results: 89 | if key == "node": 90 | continue 91 | 92 | value = results[key] 93 | if isinstance(value, (Leaf, Node)): 94 | click.secho(f"results[{repr(key)}] =", fg="red") 95 | print_tree(value, indent=1, recurse=1) 96 | else: 97 | # TODO: Improve display of multi-match here, see 98 | # test_print_tree_captures test. 99 | click.secho(f"results[{repr(key)}] = {value}", fg="red") 100 | 101 | 102 | def dotted_parts(name: str) -> List[str]: 103 | pre, dot, post = name.partition(".") 104 | if post: 105 | post_parts = dotted_parts(post) 106 | else: 107 | post_parts = [] 108 | result = [] 109 | if pre: 110 | result.append(pre) 111 | if pre and dot: 112 | result.append(dot) 113 | if post_parts: 114 | result.extend(post_parts) 115 | return result 116 | 117 | 118 | def quoted_parts(name: str) -> List[str]: 119 | return [f"'{part}'" for part in dotted_parts(name)] 120 | 121 | 122 | def power_parts(name: str) -> List[str]: 123 | parts = quoted_parts(name) 124 | index = 0 125 | while index < len(parts): 126 | if parts[index] == "'.'": 127 | parts.insert(index, "trailer<") 128 | parts.insert(index + 3, ">") 129 | index += 1 130 | index += 1 131 | return parts 132 | 133 | 134 | def is_method(node: LN) -> bool: 135 | return ( 136 | node.type == SYMBOL.funcdef 137 | and node.parent is not None 138 | and node.parent.type == SYMBOL.suite 139 | and node.parent.parent is not None 140 | and node.parent.parent.type == SYMBOL.classdef 141 | ) 142 | 143 | 144 | def is_call_to(node: LN, func_name: str) -> bool: 145 | """Returns whether the node represents a call to the named function.""" 146 | return ( 147 | node.type == SYMBOL.power 148 | and node.children[0].type == TOKEN.NAME 149 | and node.children[0].value == func_name 150 | ) 151 | 152 | 153 | def find_first(node: LN, target: int, recursive: bool = False) -> Optional[LN]: 154 | queue: List[LN] = [node] 155 | queue.extend(node.children) 156 | while queue: 157 | child = queue.pop(0) 158 | if child.type == target: 159 | return child 160 | if recursive: 161 | queue = child.children + queue 162 | return None 163 | 164 | 165 | def find_previous(node: LN, target: int, recursive: bool = False) -> Optional[LN]: 166 | while node.prev_sibling is not None: 167 | node = node.prev_sibling 168 | result = find_last(node, target, recursive) 169 | if result: 170 | return result 171 | return None 172 | 173 | 174 | def find_next(node: LN, target: int, recursive: bool = False) -> Optional[LN]: 175 | while node.next_sibling is not None: 176 | node = node.next_sibling 177 | result = find_first(node, target, recursive) 178 | if result: 179 | return result 180 | return None 181 | 182 | 183 | def find_last(node: LN, target: int, recursive: bool = False) -> Optional[LN]: 184 | queue: List[LN] = [] 185 | queue.extend(reversed(node.children)) 186 | while queue: 187 | child = queue.pop(0) 188 | if recursive: 189 | result = find_last(child, target, recursive) 190 | if result: 191 | return result 192 | if child.type == target: 193 | return child 194 | return None 195 | 196 | 197 | def get_class(node: LN) -> LN: 198 | while node.parent is not None: 199 | if node.type == SYMBOL.classdef: 200 | return node 201 | node = node.parent 202 | raise ValueError(f"classdef node not found") 203 | 204 | 205 | class Once: 206 | """Simple object that evaluates to True once, and then always False.""" 207 | 208 | def __init__(self) -> None: 209 | self.done = False 210 | 211 | def __bool__(self) -> bool: 212 | if self.done: 213 | return False 214 | else: 215 | self.done = True 216 | return True 217 | 218 | 219 | def filename_endswith(ext: Union[Sequence, str]) -> FilenameMatcher: 220 | if isinstance(ext, str): 221 | ext = [ext] 222 | 223 | def inner(filename: Filename) -> bool: 224 | return any(filename.endswith(e) for e in ext) 225 | 226 | return inner 227 | -------------------------------------------------------------------------------- /bowler/imr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | import logging 9 | from typing import Any, List, Optional 10 | 11 | from attr import dataclass 12 | from fissix.fixer_util import LParen, Name 13 | 14 | from .helpers import find_last 15 | from .types import ( 16 | ARG_END, 17 | ARG_LISTS, 18 | LN, 19 | STARS, 20 | SYMBOL, 21 | TOKEN, 22 | Capture, 23 | IMRError, 24 | Leaf, 25 | Node, 26 | ) 27 | 28 | log = logging.getLogger(__name__) 29 | 30 | 31 | @dataclass 32 | class FunctionArgument: 33 | name: str = "" 34 | value: Any = None 35 | annotation: str = "" 36 | star: Optional[Leaf] = None 37 | prefix: Optional[str] = None 38 | 39 | @classmethod 40 | def build(cls, leaf: Leaf, is_def: bool, **kwargs: Any) -> "FunctionArgument": 41 | while leaf is not None and leaf.type not in ARG_END: 42 | if leaf.type in (SYMBOL.star_expr, SYMBOL.argument): 43 | return cls.build(leaf.children[0], is_def, prefix=leaf.prefix) 44 | 45 | elif leaf.type in STARS: 46 | kwargs["star"] = leaf.clone() 47 | 48 | elif leaf.type == SYMBOL.tname: 49 | kwargs["name"] = leaf.children[0].value 50 | kwargs["annotation"] = leaf.children[-1].value 51 | 52 | elif leaf.type == TOKEN.EQUAL: 53 | pass 54 | 55 | elif leaf.type == TOKEN.NAME: 56 | if (is_def and "name" not in kwargs) or ( 57 | leaf.next_sibling and leaf.next_sibling.type == TOKEN.EQUAL 58 | ): 59 | kwargs["name"] = leaf.value 60 | else: 61 | kwargs["value"] = leaf.clone() 62 | 63 | else: 64 | # assume everything else is a complex value 65 | kwargs["value"] = leaf.clone() 66 | 67 | kwargs.setdefault("prefix", leaf.prefix) 68 | leaf = leaf.next_sibling 69 | 70 | return FunctionArgument(**kwargs) 71 | 72 | @classmethod 73 | def build_list( 74 | cls, arguments: List[Leaf], is_def: bool 75 | ) -> List["FunctionArgument"]: 76 | result: List[FunctionArgument] = [] 77 | 78 | # empty function 79 | if not arguments: 80 | return result 81 | 82 | # only care about what's on the inside 83 | if arguments[0].type in ARG_LISTS: 84 | leaf = arguments[0].children[0] 85 | else: 86 | leaf = arguments[0] 87 | 88 | while leaf is not None: 89 | arg = cls.build(leaf, is_def) 90 | log.debug(f"{leaf} -> {arg}") 91 | result.append(arg) 92 | 93 | # consume leafs for this argument 94 | while leaf is not None and leaf.type not in ARG_END: 95 | log.debug(f"consuming {leaf}") 96 | leaf = leaf.next_sibling 97 | 98 | # assume we stopped on a comma or parenthesis 99 | if leaf: 100 | log.debug(f"separator {leaf}") 101 | leaf = leaf.next_sibling 102 | 103 | return result 104 | 105 | def explode(self, is_def: bool, prefix: str = "") -> List[LN]: 106 | result: List[LN] = [] 107 | prefix = self.prefix if self.prefix else prefix 108 | if is_def: 109 | if self.star: 110 | self.star.prefix = prefix 111 | result.append(self.star) 112 | prefix = "" 113 | 114 | if self.annotation: 115 | result.append( 116 | Node( 117 | SYMBOL.tname, 118 | [ 119 | Name(self.name, prefix=prefix), 120 | Leaf(TOKEN.COLON, ":", prefix=""), 121 | Name(self.annotation, prefix=" "), 122 | ], 123 | prefix=prefix, 124 | ) 125 | ) 126 | else: 127 | result.append(Name(self.name, prefix=prefix)) 128 | 129 | if self.value: 130 | space = " " if self.annotation else "" 131 | result.append(Leaf(TOKEN.EQUAL, "=", prefix=space)) 132 | result.append(self.value) 133 | 134 | else: 135 | if self.star: 136 | if self.star.type == TOKEN.STAR: 137 | node = Node(SYMBOL.star_expr, [self.star], prefix=prefix) 138 | elif self.star.type == TOKEN.DOUBLESTAR: 139 | node = Node(SYMBOL.argument, [self.star], prefix=prefix) 140 | 141 | if self.value: 142 | self.value.prefix = "" 143 | node.append_child(self.value) 144 | 145 | result.append(node) 146 | return result 147 | 148 | if self.name: 149 | self.value.prefix = "" 150 | result.append( 151 | Node( 152 | SYMBOL.argument, 153 | [ 154 | Name(self.name, prefix=prefix), 155 | Leaf(TOKEN.EQUAL, value="=", prefix=""), 156 | self.value, 157 | ], 158 | prefix=prefix, 159 | ) 160 | ) 161 | else: 162 | self.value.prefix = prefix 163 | result.append(self.value) 164 | 165 | return result 166 | 167 | @classmethod 168 | def explode_list( 169 | cls, arguments: List["FunctionArgument"], is_def: bool 170 | ) -> Optional[LN]: 171 | nodes: List[LN] = [] 172 | prefix = "" 173 | index = 0 174 | for argument in arguments: 175 | if index: 176 | nodes.append(Leaf(TOKEN.COMMA, ",", prefix="")) 177 | prefix = " " 178 | 179 | result = argument.explode(is_def, prefix=prefix) 180 | log.debug(f"{argument} -> {result}") 181 | nodes.extend(result) 182 | index += 1 183 | 184 | if not nodes: 185 | return None 186 | 187 | if len(nodes) == 1: 188 | return nodes[0] 189 | 190 | elif is_def: 191 | return Node(SYMBOL.typedargslist, nodes, prefix=nodes[0].prefix) 192 | 193 | else: 194 | return Node(SYMBOL.arglist, nodes, prefix=nodes[0].prefix) 195 | 196 | 197 | @dataclass 198 | class FunctionSpec: 199 | name: str 200 | arguments: List[FunctionArgument] 201 | is_def: bool 202 | capture: Capture 203 | node: Node 204 | 205 | @classmethod 206 | def build(cls, node: Node, capture: Capture) -> "FunctionSpec": 207 | try: 208 | name = capture["function_name"] 209 | is_def = "function_def" in capture 210 | args = capture["function_arguments"] 211 | except KeyError as e: 212 | raise IMRError("function spec invalid") from e 213 | 214 | arguments = FunctionArgument.build_list(args, is_def) 215 | 216 | return FunctionSpec(name.value, arguments, is_def, capture, node) 217 | 218 | def explode(self) -> LN: 219 | arguments = FunctionArgument.explode_list(self.arguments, self.is_def) 220 | 221 | rparen = find_last(self.capture["function_parameters"], TOKEN.RPAR) 222 | rprefix = rparen.prefix if rparen else "" 223 | 224 | if self.is_def: 225 | parameters = Node( 226 | SYMBOL.parameters, 227 | [LParen(), Leaf(TOKEN.RPAR, ")", prefix=rprefix)], 228 | prefix="", 229 | ) 230 | else: 231 | parameters = Node( 232 | SYMBOL.trailer, 233 | [LParen(), Leaf(TOKEN.RPAR, ")", prefix=rprefix)], 234 | prefix="", 235 | ) 236 | 237 | if arguments: 238 | parameters.insert_child(1, arguments) 239 | 240 | self.capture["function_parameters"].replace(parameters) 241 | 242 | return self.node 243 | -------------------------------------------------------------------------------- /bowler/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | import importlib 9 | import importlib.util 10 | import logging 11 | import os.path 12 | import sys 13 | import unittest 14 | from importlib.abc import Loader 15 | from pathlib import Path 16 | from typing import List, cast 17 | 18 | import click 19 | 20 | from .query import Query 21 | from .tool import BowlerTool 22 | from .types import START, SYMBOL, TOKEN 23 | 24 | 25 | @click.group(invoke_without_command=True) 26 | @click.option("--debug/--quiet", default=False, help="Logging output level") 27 | @click.option("--version", "-V", is_flag=True, help="Print version string and exit") 28 | @click.pass_context 29 | def main(ctx: click.Context, debug: bool, version: bool) -> None: 30 | """Safe Python code modification and refactoring.""" 31 | if version: 32 | from bowler import __version__ 33 | 34 | click.echo(f"bowler {__version__}") 35 | return 36 | 37 | if debug: 38 | BowlerTool.NUM_PROCESSES = 1 39 | BowlerTool.IN_PROCESS = True 40 | 41 | root = logging.getLogger() 42 | if not root.hasHandlers(): 43 | logging.addLevelName(logging.DEBUG, "DBG") 44 | logging.addLevelName(logging.INFO, "INF") 45 | logging.addLevelName(logging.WARNING, "WRN") 46 | logging.addLevelName(logging.ERROR, "ERR") 47 | level = logging.DEBUG if debug else logging.WARNING 48 | fmt = logging.Formatter("{levelname}:{filename}:{lineno} {message}", style="{") 49 | han = logging.StreamHandler(stream=sys.stderr) 50 | han.setFormatter(fmt) 51 | han.setLevel(level) 52 | root.setLevel(level) 53 | root.addHandler(han) 54 | 55 | if ctx.invoked_subcommand is None: 56 | return do(None, None) 57 | 58 | 59 | @main.command() 60 | @click.option("--selector-pattern", is_flag=True) 61 | @click.argument("paths", type=click.Path(exists=True), nargs=-1, required=False) 62 | def dump(selector_pattern: bool, paths: List[str]) -> None: 63 | """Dump the CST representation of each file in .""" 64 | return Query(paths).select_root().dump(selector_pattern).retcode 65 | 66 | 67 | @main.command() 68 | @click.option("-i", "--interactive", is_flag=True) 69 | @click.argument("query", required=False) 70 | @click.argument("paths", type=click.Path(exists=True), nargs=-1, required=False) 71 | def do(interactive: bool, query: str, paths: List[str]) -> None: 72 | """Execute a query or enter interactive mode.""" 73 | if not query or query == "-": 74 | 75 | namespace = {"Query": Query, "START": START, "SYMBOL": SYMBOL, "TOKEN": TOKEN} 76 | 77 | try: 78 | import IPython 79 | 80 | IPython.start_ipython(argv=[], user_ns=namespace) 81 | 82 | except ImportError: 83 | import code as _code 84 | 85 | _code.interact(local=namespace) 86 | 87 | finally: 88 | return 89 | 90 | code = compile(query, "", "eval") 91 | result = eval(code) # noqa eval() - developer tool, hopefully they're not dumb 92 | 93 | if isinstance(result, Query): 94 | if result.retcode: 95 | exc = click.ClickException("query failed") 96 | exc.exit_code = result.retcode 97 | raise exc 98 | result.diff(interactive=interactive) 99 | elif result: 100 | click.echo(repr(result)) 101 | 102 | 103 | @main.command() 104 | @click.argument("codemod", required=True, type=str) 105 | @click.argument("argv", required=False, type=str, nargs=-1) 106 | def run(codemod: str, argv: List[str]) -> None: 107 | """ 108 | Execute a file-based code modification. 109 | 110 | Takes either a path to a python script, or an importable module name, and attempts 111 | to import and run a "main()" function from that script/module if found. 112 | Extra arguments to this command will be supplied to the script/module. 113 | Use `--` to forcibly pass through all following options or arguments. 114 | """ 115 | 116 | try: 117 | original_argv = sys.argv[1:] 118 | sys.argv[1:] = argv 119 | 120 | path = Path(codemod) 121 | if path.exists(): 122 | if path.is_dir(): 123 | raise click.ClickException("running directories not supported") 124 | 125 | spec = importlib.util.spec_from_file_location( # type: ignore 126 | path.name, path 127 | ) 128 | module = importlib.util.module_from_spec(spec) 129 | spec.loader.exec_module(module) # type: ignore 130 | 131 | else: 132 | module = importlib.import_module(codemod) 133 | 134 | main = getattr(module, "main", None) 135 | if main is not None: 136 | main() 137 | 138 | except ImportError as e: 139 | raise click.ClickException(f"failed to import codemod: {e}") from e 140 | 141 | finally: 142 | sys.argv[1:] = original_argv 143 | 144 | 145 | @main.command() 146 | @click.argument("codemod", required=True, type=str) 147 | def test(codemod: str) -> None: 148 | """ 149 | Run the tests in the codemod file 150 | """ 151 | 152 | # TODO: Unify the import code between 'run' and 'test' 153 | module_name_from_codemod = os.path.basename(codemod).replace(".py", "") 154 | spec = importlib.util.spec_from_file_location(module_name_from_codemod, codemod) 155 | foo = importlib.util.module_from_spec(spec) 156 | cast(Loader, spec.loader).exec_module(foo) 157 | suite = unittest.TestLoader().loadTestsFromModule(foo) 158 | 159 | result = unittest.TextTestRunner().run(suite) 160 | sys.exit(not result.wasSuccessful()) 161 | 162 | 163 | if __name__ == "__main__": 164 | main() 165 | -------------------------------------------------------------------------------- /bowler/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/facebookincubator/Bowler/92c9eeb7eebab8a1b65a989d0cf3b4947773ea2b/bowler/py.typed -------------------------------------------------------------------------------- /bowler/tests/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | from .helpers import ( 9 | DottedPartsTest, 10 | FilenameEndswithTest, 11 | PowerPartsTest, 12 | PrintSelectorPatternTest, 13 | PrintTreeTest, 14 | ) 15 | from .lib import BowlerTestCaseTest 16 | from .query import QueryTest 17 | from .smoke import SmokeTest 18 | from .tool import ToolTest 19 | from .type_inference import ExpressionTest, OpMinTypeTest 20 | -------------------------------------------------------------------------------- /bowler/tests/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | import unittest 9 | 10 | if __name__ == "__main__": # pragma: no cover 11 | unittest.main(module="bowler.tests", verbosity=2) 12 | -------------------------------------------------------------------------------- /bowler/tests/helpers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | import unittest 9 | 10 | from fissix.pytree import Leaf, Node 11 | 12 | from ..helpers import ( 13 | dotted_parts, 14 | filename_endswith, 15 | power_parts, 16 | print_selector_pattern, 17 | print_tree, 18 | ) 19 | from .lib import BowlerTestCase 20 | 21 | 22 | class PrintTreeTest(BowlerTestCase): 23 | def test_print_tree_node(self): 24 | node = self.parse_line("x + 1") 25 | expected = """\ 26 | [arith_expr] '' 27 | . [NAME] '' 'x' 28 | . [PLUS] ' ' '+' 29 | . [NUMBER] ' ' '1' 30 | """ 31 | print_tree(node) 32 | self.assertMultiLineEqual(expected, self.buffer.getvalue()) 33 | 34 | def test_print_tree_captures(self): 35 | node = self.parse_line("x + 1 + 2") 36 | expected = """\ 37 | [arith_expr] '' 38 | . [NAME] '' 'x' 39 | . [PLUS] ' ' '+' 40 | . [NUMBER] ' ' '1' 41 | . [PLUS] ' ' '+' 42 | . [NUMBER] ' ' '2' 43 | results['op'] = 44 | . [PLUS] ' ' '+' 45 | results['rhs'] = [Leaf(2, '1'), Leaf(14, '+'), Leaf(2, '2')] 46 | """ 47 | print_tree(node, {"op": node.children[1], "rhs": node.children[2:]}) 48 | self.assertMultiLineEqual(expected, self.buffer.getvalue()) 49 | 50 | def test_print_tree_recurse_limit(self): 51 | node = self.parse_line("(((x+1)+2)+3)") 52 | expected = """\ 53 | [atom] '' 54 | . [LPAR] '' '(' 55 | . [arith_expr] '' 56 | . . [atom] '' 57 | . . . [LPAR] '' '(' 58 | . . . [arith_expr] '' 59 | . . . . ... 60 | . . . [RPAR] '' ')' 61 | . . [PLUS] '' '+' 62 | . . [NUMBER] '' '3' 63 | . [RPAR] '' ')' 64 | """ 65 | print_tree(node, recurse=3) 66 | self.assertMultiLineEqual(expected, self.buffer.getvalue()) 67 | 68 | 69 | class PrintSelectorPatternTest(BowlerTestCase): 70 | def test_print_selector_pattern(self): 71 | node = self.parse_line("x + 1") 72 | expected = """\ 73 | arith_expr < 'x' '+' '1' > \n""" 74 | print_selector_pattern(node) 75 | self.assertMultiLineEqual(expected, self.buffer.getvalue()) 76 | 77 | def test_print_selector_pattern_capture(self): 78 | node = self.parse_line("x + 1") 79 | expected = """\ 80 | arith_expr < 'x' op='+' '1' > \n""" 81 | print_selector_pattern(node, {"op": node.children[1]}) 82 | self.assertMultiLineEqual(expected, self.buffer.getvalue()) 83 | 84 | def test_print_selector_pattern_capture_list(self): 85 | node = self.parse_line("x + 1") 86 | # This is not ideal, but hard to infer a good pattern 87 | expected = """\ 88 | arith_expr < 'x' rest='+' rest='1' > \n""" 89 | print_selector_pattern(node, {"rest": node.children[1:]}) 90 | self.assertMultiLineEqual(expected, self.buffer.getvalue()) 91 | 92 | 93 | class PowerPartsTest(unittest.TestCase): 94 | def test_power_parts_include_trailer(self): 95 | self.assertEqual(["'Model'"], power_parts("Model")) 96 | self.assertEqual( 97 | ["'models'", "trailer<", "'.'", "'Model'", ">"], power_parts("models.Model") 98 | ) 99 | 100 | 101 | class DottedPartsTest(unittest.TestCase): 102 | def test_dotted_parts(self): 103 | self.assertEqual(["Model"], dotted_parts("Model")) 104 | self.assertEqual(["models", ".", "Model"], dotted_parts("models.Model")) 105 | self.assertEqual( 106 | ["models", ".", "utils", ".", "Model"], dotted_parts("models.utils.Model") 107 | ) 108 | 109 | 110 | class FilenameEndswithTest(unittest.TestCase): 111 | def test_single_string(self): 112 | py = filename_endswith(".py") 113 | self.assertTrue(py("foo.py")) 114 | self.assertTrue(py("foo/foo.py")) 115 | self.assertFalse(py("foo.txt")) 116 | self.assertFalse(py("foo/foo.txt")) 117 | 118 | def test_sequence(self): 119 | py = filename_endswith([".py", ".pyi"]) 120 | self.assertTrue(py("foo.py")) 121 | self.assertTrue(py("foo/foo.py")) 122 | self.assertTrue(py("foo.pyi")) 123 | self.assertTrue(py("foo/foo.pyi")) 124 | self.assertFalse(py("foo.txt")) 125 | self.assertFalse(py("foo/foo.txt")) 126 | -------------------------------------------------------------------------------- /bowler/tests/lib.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | import functools 9 | import multiprocessing 10 | import sys 11 | import unittest 12 | from io import StringIO 13 | 14 | import click 15 | import volatile 16 | from fissix import pygram, pytree 17 | from fissix.pgen2.driver import Driver 18 | 19 | from bowler import Query 20 | from bowler.types import LN, SYMBOL, TOKEN 21 | 22 | 23 | class BowlerTestCase(unittest.TestCase): 24 | """Subclass of TestCase that captures stdout and makes it easier to run Bowler.""" 25 | 26 | def setUp(self): 27 | self.buffer = StringIO() 28 | # Replace the write method instead of stdout so that already-existing 29 | # loggers end up writing here. 30 | sys.stdout._saved_write = sys.stdout.write 31 | sys.stdout.write = self.buffer.write 32 | sys.stdout._saved_isatty = sys.stdout.isatty 33 | sys.stdout.isatty = lambda: False 34 | 35 | def tearDown(self): 36 | if hasattr(sys.stdout, "_saved_write"): 37 | sys.stdout.write = sys.stdout._saved_write 38 | del sys.stdout._saved_write 39 | if hasattr(sys.stdout, "_saved_isatty"): 40 | sys.stdout.isatty = sys.stdout._saved_isatty 41 | del sys.stdout._saved_isatty 42 | 43 | def _formatMessage(self, msg1, msg2): 44 | stdout_text = self.buffer.getvalue() 45 | msg = msg1 or msg2 46 | if stdout_text: 47 | msg += "\n" 48 | msg += "-" * 20 + "< captured stdout >" + "-" * 20 + "\n" 49 | msg += stdout_text + "\n" 50 | msg += "-" * 20 + "< end stdout >" + "-" * 20 + "\n" 51 | return msg 52 | 53 | def run_bowler_modifier( 54 | self, 55 | input_text, 56 | selector=None, 57 | modifier=None, 58 | selector_func=None, 59 | modifier_func=None, 60 | in_process=True, 61 | query_func=None, 62 | ): 63 | """Returns the modified text.""" 64 | 65 | if not (selector or selector_func or query_func): 66 | raise ValueError("Pass selector") 67 | if not (modifier or modifier_func or query_func): 68 | raise ValueError("Pass modifier") 69 | 70 | exception_queue = multiprocessing.Queue() 71 | 72 | def store_exceptions_on(func): 73 | @functools.wraps(func) 74 | def inner(node, capture, filename): 75 | # When in_process=False, this runs in another process. See notes below. 76 | try: 77 | return func(node, capture, filename) 78 | except Exception as e: 79 | exception_queue.put(e) 80 | 81 | return inner 82 | 83 | def default_query_func(files): 84 | if selector_func: 85 | q = selector_func(files) 86 | else: 87 | q = Query(files).select(selector) 88 | 89 | if modifier_func: 90 | q = modifier_func(q) 91 | else: 92 | q = q.modify(modifier) 93 | 94 | return q 95 | 96 | if query_func is None: 97 | query_func = default_query_func 98 | 99 | with volatile.file(mode="w", suffix=".py") as f: 100 | # TODO: I'm almost certain this will not work on Windows, since 101 | # NamedTemporaryFile has it already open for writing. Consider 102 | # using mktemp directly? 103 | f.write(input_text + "\n") 104 | f.close() 105 | 106 | query = query_func([f.name]) 107 | assert query is not None, "Remember to return the Query" 108 | assert query.retcode is None, "Return before calling .execute" 109 | assert len(query.transforms) == 1, "TODO: Support multiple" 110 | 111 | for i in range(len(query.current.callbacks)): 112 | query.current.callbacks[i] = store_exceptions_on( 113 | query.current.callbacks[i] 114 | ) 115 | 116 | # We require the in_process parameter in order to record coverage properly, 117 | # but it also helps in bubbling exceptions and letting tests read state set 118 | # by modifiers. 119 | query.execute( 120 | interactive=False, write=True, silent=False, in_process=in_process 121 | ) 122 | 123 | # In the case of in_process=False (mirroring normal use of the tool) we use 124 | # the queue to ship back exceptions from local_process, which can actually 125 | # fail the test. Normally exceptions in modifiers are not printed 126 | # at all unless you pass --debug, and even then you don't get the 127 | # traceback. 128 | # See https://github.com/facebookincubator/Bowler/issues/63 129 | if not exception_queue.empty(): 130 | raise AssertionError from exception_queue.get() 131 | 132 | with open(f.name, "r") as fr: 133 | return fr.read().rstrip() 134 | 135 | def run_bowler_modifiers( 136 | self, cases, selector=None, modifier=None, query_func=None 137 | ): 138 | for input, expected in cases: 139 | with self.subTest(input): 140 | output = self.run_bowler_modifier( 141 | input, selector, modifier, query_func=query_func 142 | ) 143 | self.assertMultiLineEqual(expected, output) 144 | 145 | def parse_line(self, source: str) -> LN: 146 | grammar = pygram.python_grammar_no_print_statement 147 | driver = Driver(grammar, convert=pytree.convert) 148 | # Skip file_input, simple_stmt 149 | return driver.parse_string(source + "\n").children[0].children[0] 150 | 151 | 152 | class BowlerTestCaseTest(BowlerTestCase): 153 | def test_stdout_capture(self): 154 | print("hi") 155 | print("there") 156 | self.assertIn("hi\n", self.buffer.getvalue()) 157 | 158 | def test_stdout_click_no_colors(self): 159 | # This tests that we patched isatty correctly. 160 | click.echo(click.style("hi", fg="red", bold=True)) 161 | self.assertEqual("hi\n", self.buffer.getvalue()) 162 | 163 | def test_run_bowler_modifier(self): 164 | input = "x=a*b" 165 | 166 | selector = "term< any op='*' any >" 167 | 168 | def modifier(node, capture, filename): 169 | capture["op"].value = "/" 170 | capture["op"].changed() 171 | 172 | output = self.run_bowler_modifier(input, selector, modifier) 173 | self.assertEqual("x=a/b", output) 174 | 175 | def test_run_bowler_modifier_parse_error(self): 176 | input = " if x:\n bad" 177 | selector = "any" 178 | output = self.run_bowler_modifier(input, selector, lambda *args: None) 179 | self.assertFalse("None" in output) 180 | 181 | def test_run_bowler_modifier_query_func(self): 182 | input = "x=a*b" 183 | 184 | selector = "term< any op='*' any >" 185 | 186 | def modifier(node, capture, filename): 187 | capture["op"].value = "/" 188 | capture["op"].changed() 189 | 190 | def query_func(arg): 191 | return Query(arg).select(selector).modify(modifier) 192 | 193 | output = self.run_bowler_modifier(input, query_func=query_func) 194 | self.assertEqual("x=a/b", output) 195 | 196 | def test_run_bowler_modifier_modifier_func(self): 197 | input = "x=a*b" 198 | 199 | selector = "term< any op='*' any >" 200 | 201 | def selector_func(arg): 202 | return Query(arg).select(selector) 203 | 204 | def modifier(node, capture, filename): 205 | capture["op"].value = "/" 206 | capture["op"].changed() 207 | 208 | def modifier_func(q): 209 | return q.modify(modifier) 210 | 211 | output = self.run_bowler_modifier( 212 | input, selector_func=selector_func, modifier_func=modifier_func 213 | ) 214 | self.assertEqual("x=a/b", output) 215 | 216 | def test_run_bowler_modifier_ferries_exception(self): 217 | input = "x=a*b" 218 | 219 | selector = "term< any op='*' any >" 220 | 221 | def modifier(not_enough_args): 222 | pass 223 | 224 | # Should work in both modes 225 | self.assertRaises( 226 | AssertionError, 227 | lambda: self.run_bowler_modifier( 228 | input, selector, modifier, in_process=False 229 | ), 230 | ) 231 | 232 | self.assertRaises( 233 | AssertionError, 234 | lambda: self.run_bowler_modifier( 235 | input, selector, modifier, in_process=True 236 | ), 237 | ) 238 | 239 | def test_parse_line_leaf(self): 240 | input = "2.5" 241 | tree = self.parse_line(input) 242 | self.assertEqual(TOKEN.NUMBER, tree.type) 243 | self.assertEqual("2.5", tree.value) 244 | 245 | def test_parse_line_node(self): 246 | input = "x = (y+1)" 247 | tree = self.parse_line(input) 248 | self.assertEqual(SYMBOL.expr_stmt, tree.type) 249 | 250 | self.assertEqual(TOKEN.NAME, tree.children[0].type) 251 | self.assertEqual(TOKEN.EQUAL, tree.children[1].type) 252 | self.assertEqual(SYMBOL.atom, tree.children[2].type) 253 | 254 | self.assertEqual("x", tree.children[0].value) 255 | -------------------------------------------------------------------------------- /bowler/tests/query.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | from unittest import mock 9 | 10 | from ..query import SELECTORS, Query 11 | from ..types import TOKEN, Leaf 12 | from .lib import BowlerTestCase 13 | 14 | 15 | class QueryTest(BowlerTestCase): 16 | fake_paths = ["foo/bar", "baz.py"] 17 | 18 | def test_basic(self): 19 | query = Query(self.fake_paths) 20 | self.assertEqual(len(query.transforms), 0) 21 | self.assertEqual(query.paths, self.fake_paths) 22 | with self.assertRaises(ValueError): 23 | transform = query.current 24 | self.assertEqual(transform, None) 25 | 26 | query.select_root().is_filename("frob.py") 27 | self.assertEqual(len(query.transforms), 1) 28 | self.assertEqual(query.current.selector, "root") 29 | self.assertEqual(len(query.current.kwargs), 0) 30 | self.assertEqual(len(query.current.filters), 1) 31 | self.assertEqual(len(query.current.filters), 1) 32 | 33 | fixers = query.compile() 34 | self.assertEqual(len(fixers), 1) 35 | self.assertEqual(fixers[0].PATTERN, SELECTORS["root"].strip()) 36 | 37 | def test_rename_func(self): 38 | def query_func(arg): 39 | return Query(arg).select_function("f").rename("foo") 40 | 41 | self.run_bowler_modifiers( 42 | [ 43 | ("def f(x): pass", "def foo(x): pass"), 44 | ("def g(x): pass", "def g(x): pass"), 45 | ("f()", "foo()"), 46 | ("g()", "g()"), 47 | ], 48 | query_func=query_func, 49 | ) 50 | 51 | def test_parse_print_func_py3(self): 52 | # Py 3 mode is the default 53 | def select_print_func(arg): 54 | return Query(arg).select_var("bar").rename("baz") 55 | 56 | template = """{} = 1; {}""" 57 | self.run_bowler_modifiers( 58 | [ 59 | ( 60 | # ParseError prevents rename succeeding 61 | template.format("bar", 'print "hello world"'), 62 | template.format("bar", 'print "hello world"'), 63 | ), 64 | ( 65 | template.format("bar", 'print("hello world")'), 66 | template.format("baz", 'print("hello world")'), 67 | ), 68 | ( 69 | template.format("bar", 'print("hello world", end="")'), 70 | template.format("baz", 'print("hello world", end="")'), 71 | ), 72 | ], 73 | query_func=select_print_func, 74 | ) 75 | 76 | def test_parse_print_func_py2(self): 77 | def select_print_func(arg): 78 | return Query(arg, python_version=2).select_var("bar").rename("baz") 79 | 80 | template = """{} = 1; {}""" 81 | self.run_bowler_modifiers( 82 | [ 83 | ( 84 | template.format("bar", 'print "hello world"'), 85 | template.format("baz", 'print "hello world"'), 86 | ), 87 | ( 88 | # not a print function call, just parenthesised statement 89 | template.format("bar", 'print("hello world")'), 90 | template.format("baz", 'print("hello world")'), 91 | ), 92 | ( 93 | # ParseError prevents rename succeeding 94 | template.format("bar", 'print("hello world", end="")'), 95 | template.format("bar", 'print("hello world", end="")'), 96 | ), 97 | ], 98 | query_func=select_print_func, 99 | ) 100 | 101 | def test_parse_print_func_py2_future_print(self): 102 | def select_print_func(arg): 103 | return Query(arg, python_version=2).select_var("bar").rename("baz") 104 | 105 | template = """\ 106 | from __future__ import print_function 107 | {} = 1; {}""" 108 | self.run_bowler_modifiers( 109 | [ 110 | ( 111 | # ParseError prevents rename succeeding 112 | template.format("bar", 'print "hello world"'), 113 | template.format("bar", 'print "hello world"'), 114 | ), 115 | ( 116 | template.format("bar", 'print("hello world")'), 117 | template.format("baz", 'print("hello world")'), 118 | ), 119 | ( 120 | template.format("bar", 'print("hello world", end="")'), 121 | template.format("baz", 'print("hello world", end="")'), 122 | ), 123 | ], 124 | query_func=select_print_func, 125 | ) 126 | 127 | def test_rename_class(self): 128 | self.run_bowler_modifiers( 129 | [("class Bar(Foo):\n pass", "class FooBar(Foo):\n pass")], 130 | query_func=lambda x: Query(x).select_class("Bar").rename("FooBar"), 131 | ) 132 | 133 | def test_rename_module(self): 134 | self.run_bowler_modifiers( 135 | [("from a.b.c.d import E", "from a.f.d import E")], 136 | query_func=lambda x: Query(x).select_module("a.b.c").rename("a.f"), 137 | ) 138 | 139 | def test_rename_module_callsites(self): 140 | input = """\ 141 | a.b.c.d.f() 142 | w.bar()""" 143 | 144 | def selector(pattern): 145 | def _selector(arg): 146 | return Query(arg).select_module(pattern) 147 | 148 | return _selector 149 | 150 | def modifier(pattern): 151 | def _modifier(q): 152 | return q.rename(pattern) 153 | 154 | return _modifier 155 | 156 | # Same length 157 | output = self.run_bowler_modifier( 158 | input, selector_func=selector("a.b.c"), modifier_func=modifier("x.y.z") 159 | ) 160 | expected = """\ 161 | x.y.z.d.f() 162 | w.bar()""" 163 | self.assertMultiLineEqual(expected, output) 164 | 165 | # Shorter replacement 166 | output = self.run_bowler_modifier( 167 | input, selector_func=selector("a.b.c"), modifier_func=modifier("x.y") 168 | ) 169 | expected = """\ 170 | x.y.d.f() 171 | w.bar()""" 172 | self.assertMultiLineEqual(expected, output) 173 | 174 | # Longer replacement 175 | output = self.run_bowler_modifier( 176 | input, selector_func=selector("a.b.c"), modifier_func=modifier("w.x.y.z") 177 | ) 178 | expected = """\ 179 | w.x.y.z.d.f() 180 | w.bar()""" 181 | self.assertMultiLineEqual(expected, output) 182 | 183 | # Single character replacements replacement 184 | output = self.run_bowler_modifier( 185 | input, selector_func=selector("w"), modifier_func=modifier("x.y.z") 186 | ) 187 | expected = """\ 188 | a.b.c.d.f() 189 | x.y.z.bar()""" 190 | self.assertMultiLineEqual(expected, output) 191 | 192 | def test_rename_subclass(self): 193 | def query_func(x): 194 | return Query(x).select_subclass("Foo").rename("somepackage.Foo") 195 | 196 | self.run_bowler_modifiers( 197 | [("class Bar(Foo):\n pass", "class Bar(somepackage.Foo):\n pass")], 198 | query_func=query_func, 199 | ) 200 | 201 | def test_filter_in_class(self): 202 | def query_func_bar(x): 203 | return Query(x).select_function("f").in_class("Bar", False).rename("g") 204 | 205 | def query_func_foo(x): 206 | return Query(x).select_function("f").in_class("Foo", False).rename("g") 207 | 208 | def query_func_foo_subclasses(x): 209 | return Query(x).select_function("f").in_class("Foo", True).rename("g") 210 | 211 | # Does not have subclasses enabled 212 | self.run_bowler_modifiers( 213 | [ 214 | ( 215 | "class Bar(Foo):\n def f(self): pass", 216 | "class Bar(Foo):\n def f(self): pass", 217 | ) 218 | ], 219 | query_func=query_func_foo, 220 | ) 221 | # Does 222 | self.run_bowler_modifiers( 223 | [ 224 | ( 225 | "class Bar(Foo):\n def f(self2): pass", 226 | "class Bar(Foo):\n def g(self2): pass", 227 | ), 228 | ( 229 | "class Bar(Baz):\n def f(self2): pass", 230 | "class Bar(Baz):\n def f(self2): pass", 231 | ), 232 | ], 233 | query_func=query_func_foo_subclasses, 234 | ) 235 | # Operates directly on class 236 | self.run_bowler_modifiers( 237 | [ 238 | ( 239 | "class Bar(Foo):\n def f(self3): pass", 240 | "class Bar(Foo):\n def g(self3): pass", 241 | ) 242 | ], 243 | [("class Bar:\n def f(self3): pass", "class Bar:\n def g(self3): pass")], 244 | query_func=query_func_bar, 245 | ) 246 | # Works on functions too, not just methods 247 | self.run_bowler_modifiers( 248 | [("def f(): pass", "def f(): pass")], query_func=query_func_bar 249 | ) 250 | 251 | def test_encapsulate(self): 252 | input = """\ 253 | class Bar: 254 | f = '42' 255 | """ 256 | 257 | def query_bar_f(x): 258 | return Query(x).select_attribute("f").in_class("Bar").encapsulate("_f") 259 | 260 | expected = """\ 261 | class Bar: 262 | _f = '42' 263 | @property 264 | def f(self): 265 | return self._f 266 | 267 | @f.setter 268 | def f(self, value): 269 | self._f = value""" 270 | output = self.run_bowler_modifier( 271 | input, query_func=query_bar_f, in_process=True 272 | ) 273 | self.assertMultiLineEqual(expected, output) 274 | 275 | def test_add_keyword_argument(self): 276 | def query_func(x): 277 | return Query(x).select_function("f").add_argument("y", "5") 278 | 279 | self.run_bowler_modifiers( 280 | [ 281 | ("def f(x): pass", "def f(x, y=5): pass"), 282 | ("def f(x, **a): pass", "def f(x, y=5, **a): pass"), 283 | ("def g(x): pass", "def g(x): pass"), 284 | # ("f()", "???"), 285 | ("g()", "g()"), 286 | ], 287 | query_func=query_func, 288 | ) 289 | 290 | def test_add_positional_agument(self): 291 | def f(x, y, z): 292 | pass 293 | 294 | def query_func(x): 295 | return Query(x).select_function(f).add_argument("y", "5", True, "x") 296 | 297 | self.run_bowler_modifiers( 298 | [ 299 | ("def f(x): pass", "def f(x, y): pass"), 300 | ("def g(x): pass", "def g(x): pass"), 301 | ("f(3)", "f(3, 5)"), 302 | ], 303 | query_func=query_func, 304 | ) 305 | 306 | def test_modifier_return_value(self): 307 | input = "a+b" 308 | 309 | def modifier(node, capture, filename): 310 | new_op = Leaf(TOKEN.MINUS, "-") 311 | return new_op 312 | 313 | output = self.run_bowler_modifier(input, "'+'", modifier) 314 | self.assertEqual("a-b", output) 315 | 316 | def test_modifier_return_value_multiple(self): 317 | input = "a+b" 318 | 319 | def noop_modifier(node, capture, filename): 320 | print("Noop modifier") 321 | pass 322 | 323 | def modifier(node, capture, filename): 324 | print("Modifier") 325 | new_op = Leaf(TOKEN.MINUS, "-") 326 | return new_op 327 | 328 | def add_ok_modifier(q): 329 | return q.modify(noop_modifier).modify(modifier) 330 | 331 | output = self.run_bowler_modifier(input, "'+'", modifier_func=add_ok_modifier) 332 | self.assertEqual("a-b", output) 333 | 334 | def add_bad_modifier(q): 335 | return q.modify(modifier).modify(noop_modifier) 336 | 337 | with mock.patch("bowler.tool.log.error") as error: 338 | output = self.run_bowler_modifier( 339 | input, "'+'", modifier_func=add_bad_modifier 340 | ) 341 | self.assertEqual("a+b", output) # unmodified 342 | self.assertTrue(error.call_args) 343 | self.assertIn( 344 | "Only the last fixer/callback may return", error.call_args[0][0] 345 | ) 346 | -------------------------------------------------------------------------------- /bowler/tests/smoke-selftest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | 9 | from bowler.tests.lib import BowlerTestCase 10 | 11 | 12 | class Tests(BowlerTestCase): 13 | def test_pass(self): 14 | pass 15 | 16 | def test_fail(self): 17 | assert False 18 | -------------------------------------------------------------------------------- /bowler/tests/smoke-target.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | 9 | # todo: i18n 10 | print("Hello world!") 11 | 12 | 13 | def foo(): 14 | pass 15 | -------------------------------------------------------------------------------- /bowler/tests/smoke.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | import io 9 | import logging 10 | import subprocess 11 | import sys 12 | from pathlib import Path 13 | from unittest import TestCase 14 | from unittest.mock import Mock 15 | 16 | from fissix.fixer_util import Call, Name 17 | 18 | from ..query import Query 19 | from ..types import TOKEN, BadTransform 20 | 21 | STDERR = io.StringIO() 22 | 23 | 24 | class SmokeTest(TestCase): 25 | @classmethod 26 | def setUpClass(cls): 27 | logging.basicConfig(stream=STDERR) 28 | 29 | def test_tr(self): 30 | target = Path(__file__).parent / "smoke-target.py" 31 | 32 | def takes_string_literal(node, capture, fn): 33 | args = capture.get("args") 34 | return args and args[0].type == TOKEN.STRING 35 | 36 | def wrap_string(node, capture, fn): 37 | args = capture["args"] 38 | if args and args[0].type == TOKEN.STRING: 39 | literal = args[0] 40 | literal.replace(Call(Name("tr"), [literal.clone()])) 41 | 42 | files_processed = [] 43 | hunks_processed = 0 44 | 45 | def verify_hunk(filename, hunk): 46 | nonlocal hunks_processed 47 | files_processed.append(filename) 48 | hunks_processed += 1 49 | 50 | self.assertIn("""-print("Hello world!")""", hunk) 51 | self.assertIn("""+print(tr("Hello world!"))""", hunk) 52 | self.assertIn("""-def foo():""", hunk) 53 | self.assertIn("""+def foo(bar="something"):""", hunk) 54 | 55 | ( 56 | Query(target) 57 | .select( 58 | """ 59 | power< "print" trailer< "(" args=any* ")" > > 60 | """ 61 | ) 62 | .filter(takes_string_literal) 63 | .modify(wrap_string) 64 | .select_function("foo") 65 | .add_argument("bar", '"something"') 66 | .process(verify_hunk) 67 | .silent() 68 | ) 69 | 70 | self.assertEqual(hunks_processed, 1) 71 | self.assertEqual(len(files_processed), 1) 72 | self.assertIn("smoke-target.py", files_processed[0]) 73 | 74 | def test_check_ast(self): 75 | target = Path(__file__).parent / "smoke-target.py" 76 | mock_processor = Mock() 77 | 78 | query = ( 79 | Query(str(target)) 80 | .select_function("foo") 81 | .rename("foo/") 82 | .process(mock_processor) 83 | .silent() 84 | ) 85 | self.assertTrue(any(isinstance(e, BadTransform) for e in query.exceptions)) 86 | mock_processor.assert_not_called() 87 | 88 | def test_click_test(self): 89 | proc = subprocess.run( 90 | [sys.executable, "-m", "bowler", "test", "bowler/tests/smoke-selftest.py"], 91 | stdout=subprocess.PIPE, 92 | stderr=subprocess.PIPE, 93 | encoding="utf-8", 94 | ) 95 | self.assertIn("Ran 2 tests", proc.stderr) 96 | self.assertEqual(1, proc.returncode) 97 | -------------------------------------------------------------------------------- /bowler/tests/tool.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | import os 9 | from pathlib import Path 10 | from unittest import TestCase, mock 11 | 12 | from ..query import Query 13 | from ..tool import BadTransform, BowlerTool, log 14 | from ..types import BowlerQuit 15 | 16 | target = Path(__file__).parent / "smoke-target.py" 17 | hunks = [ 18 | [ 19 | f"--- {target}", 20 | f"+++ {target}", 21 | "@@ -7,8 +7,8 @@", 22 | " ", 23 | " ", 24 | " # todo: i18n", 25 | '-print("Hello world!")', 26 | '+print(tr("Hello world!"))', 27 | " ", 28 | " ", 29 | "-def foo():", 30 | '+def foo(bar="something"):', 31 | " pass", 32 | ], 33 | [ 34 | f"--- {target}", 35 | f"+++ {target}", 36 | "@@ -10,11 +10,11 @@", 37 | " ", 38 | " ", 39 | " # todo: i18n", 40 | '-print("Hello world!")', 41 | '+print(tr("Hello world!"))', 42 | " ", 43 | " ", 44 | "-def foo():", 45 | '+def foo(bar="something"):', 46 | " pass", 47 | ], 48 | ] 49 | 50 | 51 | class ToolTest(TestCase): 52 | def setUp(self): 53 | echo_patcher = mock.patch("bowler.tool.click.echo") 54 | secho_patcher = mock.patch("bowler.tool.click.secho") 55 | self.addCleanup(echo_patcher.stop) 56 | self.addCleanup(secho_patcher.stop) 57 | self.mock_echo = echo_patcher.start() 58 | self.mock_secho = secho_patcher.start() 59 | 60 | @mock.patch("bowler.tool.apply_single_file") 61 | def test_process_hunks_patch_called_correctly(self, mock_patch): 62 | tool = BowlerTool(Query().compile(), write=True, interactive=False, silent=True) 63 | mock_patch.side_effect = lambda f, _: f 64 | 65 | tool.process_hunks(target, hunks) 66 | string_hunks = "" 67 | for hunk in hunks: 68 | string_hunks += "\n".join(hunk[2:]) + "\n" 69 | string_hunks = f"--- {target}\n+++ {target}\n" + string_hunks 70 | with open(target) as f: 71 | input = f.read() 72 | 73 | mock_patch.assert_called_with(input, string_hunks) 74 | 75 | @mock.patch.object(log, "exception") 76 | def test_process_hunks_invalid_hunks(self, mock_log): 77 | tool = BowlerTool(Query().compile(), write=True, interactive=False, silent=True) 78 | 79 | tool.process_hunks(target, hunks) 80 | mock_log.assert_called_with( 81 | f"failed to apply patch hunk: context error 4: start before range_start" 82 | ) 83 | 84 | @mock.patch.object(log, "exception") 85 | def test_process_hunks_no_hunks(self, mock_log): 86 | tool = BowlerTool(Query().compile(), write=True, interactive=False) 87 | empty_hunks = [[]] 88 | tool.process_hunks(target, empty_hunks) 89 | patch_stderr = "Lines without hunk header at '\\n'" 90 | mock_log.assert_called_with(f"failed to apply patch hunk: {patch_stderr}") 91 | 92 | @mock.patch("bowler.tool.prompt_user") 93 | @mock.patch("bowler.tool.apply_single_file") 94 | def test_process_hunks_after_skip_rest(self, mock_patch, mock_prompt): 95 | # Test that we apply the hunks that have been 'yessed' and nothing more 96 | tool = BowlerTool(Query().compile(), silent=False) 97 | mock_patch.side_effect = lambda f, _: f 98 | mock_prompt.side_effect = ["y", "d"] 99 | tool.process_hunks(target, hunks) 100 | mock_patch.assert_called_once_with(mock.ANY, "\n".join(hunks[0]) + "\n") 101 | 102 | @mock.patch("bowler.tool.prompt_user") 103 | @mock.patch("bowler.tool.apply_single_file") 104 | def test_process_hunks_after_quit(self, mock_patch, mock_prompt): 105 | # Test that we apply the hunks that have been 'yessed' and nothing more 106 | tool = BowlerTool(Query().compile(), silent=False) 107 | mock_patch.side_effect = lambda f, _: f 108 | mock_prompt.side_effect = ["y", "q"] 109 | with self.assertRaises(BowlerQuit): 110 | tool.process_hunks(target, hunks) 111 | mock_patch.assert_called_once_with(mock.ANY, "\n".join(hunks[0]) + "\n") 112 | 113 | @mock.patch("bowler.tool.prompt_user") 114 | @mock.patch("bowler.tool.apply_single_file") 115 | def test_process_hunks_after_auto_yes(self, mock_patch, mock_prompt): 116 | tool = BowlerTool(Query().compile(), silent=False) 117 | mock_patch.side_effect = lambda f, _: f 118 | mock_prompt.side_effect = ["a"] 119 | tool.process_hunks(target, hunks) 120 | joined_hunks = "".join(["\n".join(hunk[2:]) + "\n" for hunk in hunks]) 121 | encoded_hunks = f"--- {target}\n+++ {target}\n{joined_hunks}" 122 | mock_patch.assert_called_once_with(mock.ANY, encoded_hunks) 123 | 124 | @mock.patch("bowler.tool.prompt_user") 125 | @mock.patch("bowler.tool.apply_single_file") 126 | def test_process_hunks_after_no_then_yes(self, mock_patch, mock_prompt): 127 | tool = BowlerTool(Query().compile(), silent=False) 128 | mock_patch.side_effect = lambda f, _: f 129 | mock_prompt.side_effect = ["n", "y"] 130 | tool.process_hunks(target, hunks) 131 | mock_patch.assert_called_once_with(mock.ANY, "\n".join(hunks[1]) + "\n") 132 | 133 | @mock.patch("bowler.tool.prompt_user") 134 | @mock.patch("bowler.tool.apply_single_file") 135 | def test_process_hunks_after_only_no(self, mock_patch, mock_prompt): 136 | tool = BowlerTool(Query().compile(), silent=False) 137 | mock_patch.side_effect = lambda f, _: f 138 | mock_prompt.side_effect = ["n", "n"] 139 | tool.process_hunks(target, hunks) 140 | mock_patch.assert_not_called() 141 | 142 | def test_validate_print(self): 143 | tool = BowlerTool(Query().compile(), silent=False) 144 | # Passes 145 | tool.processed_file( 146 | new_text="print('str')", filename="foo.py", old_text="print('str')" 147 | ) 148 | # Fails 149 | with self.assertRaises(BadTransform): 150 | tool.processed_file( 151 | new_text="print 'str'", filename="foo.py", old_text="print('str')" 152 | ) 153 | 154 | def test_validate_completely_invalid(self): 155 | tool = BowlerTool(Query().compile(), silent=False) 156 | with self.assertRaises(BadTransform): 157 | tool.processed_file(new_text="x=1///2", filename="foo.py", old_text="x=1/2") 158 | -------------------------------------------------------------------------------- /bowler/tests/type_inference.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | import sys 9 | import unittest 10 | from io import StringIO 11 | 12 | from fissix import pygram, pytree 13 | from fissix.pgen2.driver import Driver 14 | 15 | from ..type_inference import OP_MIN_TYPE, InferredType, numeric_expr_type 16 | from ..types import LN, SYMBOL, TOKEN 17 | from .lib import BowlerTestCase 18 | 19 | BINARY_OPERATORS = ["+", "-", "*", "**", "<<", ">>", "|", "&", "^", "%", "<"] 20 | 21 | UNARY_OPERATORS = ["~", "-", "+"] 22 | # TODO 'is', 'not' 23 | 24 | SAMPLE_EXPRESSIONS = [ 25 | # and/or 26 | ("True", InferredType.BOOL), 27 | ("True or False", InferredType.BOOL), 28 | ("True or 1", InferredType.INT), 29 | ("1 or 1.0", InferredType.FLOAT), 30 | # Calls 31 | ("bool(x)", InferredType.BOOL), 32 | ("int(x)", InferredType.INT), 33 | ("len(x)", InferredType.INT), 34 | ("float(x)", InferredType.FLOAT), 35 | # Basic 36 | ("func()", InferredType.INT_OR_FLOAT), 37 | ("x+1", InferredType.INT_OR_FLOAT), 38 | ("x*1", InferredType.INT_OR_FLOAT), 39 | ("1+x", InferredType.INT_OR_FLOAT), 40 | ("1*x", InferredType.INT_OR_FLOAT), 41 | # Single 42 | ("1+1", InferredType.INT), 43 | ("1.0+1.0", InferredType.FLOAT), 44 | # Mixed 45 | ("1+1.0", InferredType.FLOAT), 46 | ("1+x/2", InferredType.FLOAT), # py3 47 | ("1.0+x/2", InferredType.FLOAT), 48 | # Division 49 | ("1/1", InferredType.FLOAT), 50 | ("1/2.0", InferredType.FLOAT), 51 | ("1.0/2", InferredType.FLOAT), 52 | ("1/x", InferredType.FLOAT), 53 | ("1.0/x", InferredType.FLOAT), 54 | ("True/True", InferredType.FLOAT), 55 | ("1j/2", InferredType.COMPLEX), 56 | # Floor Division 57 | ("1//2", InferredType.INT), 58 | ("1.0//2.0", InferredType.INT), 59 | ] 60 | 61 | SAMPLE_PY2_EXPRESSIONS = [ 62 | # Py2 Division 63 | ("1/1", InferredType.INT), 64 | ("1/2.0", InferredType.FLOAT), 65 | ("1.0/2", InferredType.FLOAT), 66 | ("1/x", InferredType.INT_OR_FLOAT), 67 | ("1.0/x", InferredType.FLOAT), 68 | ("True/True", InferredType.INT), 69 | ("1j/2", InferredType.COMPLEX), 70 | ] 71 | 72 | 73 | def _produce_test(lcals, gen_func, args): 74 | t = gen_func(*args) 75 | t.__name__ += str(args) 76 | lcals[t.__name__] = t 77 | 78 | 79 | def tree(input: str) -> LN: 80 | print(f"Input is {repr(input)}") 81 | driver = Driver(pygram.python_grammar_no_print_statement, convert=pytree.convert) 82 | return driver.parse_string(input) 83 | 84 | 85 | def map_type(o): 86 | if isinstance(o, complex): 87 | return InferredType.COMPLEX 88 | elif isinstance(o, float): 89 | return InferredType.FLOAT 90 | elif isinstance(o, bool): 91 | return InferredType.BOOL 92 | elif isinstance(o, int): 93 | return InferredType.INT 94 | 95 | 96 | class OpMinTypeTest(BowlerTestCase): 97 | """ 98 | Verifies that the generated OP_MIN_TYPE matches that of the current Python 99 | interpreter, and that `numeric_expr_type` agrees. 100 | """ 101 | 102 | def _run_test(self, expression_str, expected_type): 103 | t = tree(expression_str) 104 | expr = t.children[0].children[0] 105 | # t_op = expr.children[1].type 106 | # expr_type = pytree.type_repr(expr.type) 107 | # key_type = TOKEN.tok_name[t_op] 108 | 109 | inferred_result = numeric_expr_type(expr) 110 | 111 | self.assertEqual(expected_type, inferred_result, repr(expr)) 112 | 113 | def gen_test_binop(op): 114 | def test_binop(self): 115 | for lhs in ("True", "1", "1.0", "2j"): 116 | for rhs in ("True", "1", "1.0", "2j"): 117 | snippet = f"{lhs} {op} {rhs}\n" 118 | try: 119 | real_result = eval(snippet, {}, {}) 120 | except TypeError: 121 | continue 122 | self._run_test(snippet, map_type(real_result)) 123 | 124 | return test_binop 125 | 126 | def gen_test_uniop(op): 127 | def test_uniop(self): 128 | for rhs in ("True", "1", "1.0", "2j"): 129 | snippet = f"{op} {rhs}\n" 130 | try: 131 | real_result = eval(snippet, {}, {}) 132 | except TypeError: 133 | continue 134 | self._run_test(snippet, map_type(real_result)) 135 | 136 | return test_uniop 137 | 138 | def gen_test_min_type(op): 139 | def test_min_type(self): 140 | snippet = f"True {op} True\n" 141 | real_result = eval(snippet, {}, {}) 142 | 143 | t = tree(snippet) 144 | expr = t.children[0].children[0] 145 | t_op = expr.children[1].type 146 | expr_type = pytree.type_repr(expr.type) 147 | key_type = TOKEN.tok_name[t_op] 148 | 149 | self.assertEqual(map_type(real_result), OP_MIN_TYPE.get(t_op), key_type) 150 | 151 | return test_min_type 152 | 153 | def gen_test_min_type_unary(op): 154 | def test_min_type_unary(self): 155 | snippet = f"{op} True\n" 156 | real_result = eval(snippet, {}, {}) 157 | 158 | t = tree(snippet) 159 | 160 | expr = t.children[0].children[0] 161 | t_op = expr.children[0].type 162 | expr_type = pytree.type_repr(expr.type) 163 | key_type = TOKEN.tok_name[t_op] 164 | 165 | self.assertEqual(map_type(real_result), OP_MIN_TYPE.get(t_op), expr_type) 166 | 167 | return test_min_type_unary 168 | 169 | # This produces real methods that can have normal decorators on them, with 170 | # a proper pass/fail count (unlike subTest). 171 | for i, op in enumerate(BINARY_OPERATORS): 172 | _produce_test(locals(), gen_test_binop, (op,)) 173 | _produce_test(locals(), gen_test_min_type, (op,)) 174 | 175 | for i, op in enumerate(UNARY_OPERATORS): 176 | _produce_test(locals(), gen_test_uniop, (op,)) 177 | _produce_test(locals(), gen_test_min_type_unary, (op,)) 178 | 179 | 180 | class ExpressionTest(BowlerTestCase): 181 | def gen_test_expression(expression_str, expected_type, use_py2_division=False): 182 | def test_expression(self): 183 | expr = tree(expression_str).children[0].children[0] 184 | inferred_type = numeric_expr_type( 185 | expr, use_py2_division, type_for_unknown=InferredType.INT_OR_FLOAT 186 | ) 187 | self.assertEqual(expected_type, inferred_type) 188 | 189 | return test_expression 190 | 191 | for expr, expected in SAMPLE_EXPRESSIONS: 192 | _produce_test(locals(), gen_test_expression, (expr + "\n", expected)) 193 | for expr, expected in SAMPLE_PY2_EXPRESSIONS: 194 | _produce_test(locals(), gen_test_expression, (expr + "\n", expected, True)) 195 | -------------------------------------------------------------------------------- /bowler/tool.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | import difflib 9 | import logging 10 | import multiprocessing 11 | import os 12 | import sys 13 | import time 14 | from queue import Empty 15 | from typing import Any, Iterator, List, Optional, Sequence, Tuple 16 | 17 | import click 18 | from fissix import pygram 19 | from fissix.pgen2.parse import ParseError 20 | from fissix.refactor import RefactoringTool, _detect_future_features 21 | from moreorless.patch import PatchException, apply_single_file 22 | 23 | from .helpers import filename_endswith 24 | from .types import ( 25 | BadTransform, 26 | BowlerException, 27 | BowlerQuit, 28 | Filename, 29 | FilenameMatcher, 30 | Fixers, 31 | Hunk, 32 | Processor, 33 | RetryFile, 34 | ) 35 | 36 | PROMPT_HELP = { 37 | "y": "apply this hunk", 38 | "n": "skip this hunk", 39 | "a": "apply this hunk and all remaining hunks for this file", 40 | "d": "skip this hunk and all remaining hunks for this file", 41 | "q": "quit; do not apply this hunk or any remaining hunks", 42 | "?": "show help", 43 | } 44 | 45 | log = logging.getLogger(__name__) 46 | 47 | 48 | def diff_texts(a: str, b: str, filename: str) -> Iterator[str]: 49 | lines_a = a.splitlines() 50 | lines_b = b.splitlines() 51 | return difflib.unified_diff(lines_a, lines_b, filename, filename, lineterm="") 52 | 53 | 54 | def prompt_user(question: str, options: str, default: str = "") -> str: 55 | options = options.lower() 56 | default = default.lower() 57 | assert len(default) < 2 and default in options 58 | 59 | if "?" not in options: 60 | options += "?" 61 | 62 | prompt_options = ",".join(o.upper() if o == default else o for o in options) 63 | prompt = f"{question} [{prompt_options}]? " 64 | result = "" 65 | 66 | while True: 67 | result = input(prompt).strip().lower() 68 | if result == "?": 69 | for option in PROMPT_HELP: 70 | click.secho(f"{option} - {PROMPT_HELP[option]}", fg="red", bold=True) 71 | 72 | elif len(result) == 1 and result in options: 73 | return result 74 | 75 | elif result: 76 | click.echo(f'invalid response "{result}"') 77 | 78 | elif default: 79 | return default 80 | 81 | 82 | class BowlerTool(RefactoringTool): 83 | NUM_PROCESSES = os.cpu_count() or 1 84 | IN_PROCESS = False # set when run DEBUG mode from command line 85 | 86 | def __init__( 87 | self, 88 | fixers: Fixers, 89 | *args, 90 | interactive: bool = True, 91 | write: bool = False, 92 | silent: bool = False, 93 | in_process: Optional[bool] = None, 94 | hunk_processor: Processor = None, 95 | filename_matcher: Optional[FilenameMatcher] = None, 96 | **kwargs, 97 | ) -> None: 98 | options = kwargs.pop("options", {}) 99 | super().__init__(fixers, *args, options=options, **kwargs) 100 | self.queue_count = 0 101 | self.queue = multiprocessing.JoinableQueue() # type: ignore 102 | self.results = multiprocessing.Queue() # type: ignore 103 | self.semaphore = multiprocessing.Semaphore(self.NUM_PROCESSES) 104 | self.interactive = interactive 105 | self.write = write 106 | self.silent = silent 107 | if in_process is None: 108 | in_process = self.IN_PROCESS 109 | # pick the most restrictive of flags; we can pickle fixers when 110 | # using spawn. 111 | if sys.platform == "win32" or sys.version_info > (3, 7): 112 | in_process = True 113 | self.in_process = in_process 114 | self.exceptions: List[BowlerException] = [] 115 | if hunk_processor is not None: 116 | self.hunk_processor = hunk_processor 117 | else: 118 | self.hunk_processor = lambda f, h: True 119 | self.filename_matcher = filename_matcher or filename_endswith(".py") 120 | 121 | def log_error(self, msg: str, *args: Any, **kwds: Any) -> None: 122 | self.logger.error(msg, *args, **kwds) 123 | 124 | def get_fixers(self) -> Tuple[Fixers, Fixers]: 125 | fixers = [f(self.options, self.fixer_log) for f in self.fixers] 126 | pre: Fixers = [f for f in fixers if f.order == "pre"] 127 | post: Fixers = [f for f in fixers if f.order == "post"] 128 | return pre, post 129 | 130 | def processed_file( 131 | self, new_text: str, filename: str, old_text: str = "", *args, **kwargs 132 | ) -> List[Hunk]: 133 | self.files.append(filename) 134 | hunks: List[Hunk] = [] 135 | if old_text != new_text: 136 | a, b, *lines = list(diff_texts(old_text, new_text, filename)) 137 | 138 | hunk: Hunk = [] 139 | for line in lines: 140 | if line.startswith("@@"): 141 | if hunk: 142 | hunks.append([a, b, *hunk]) 143 | hunk = [] 144 | hunk.append(line) 145 | 146 | if hunk: 147 | hunks.append([a, b, *hunk]) 148 | 149 | original_grammar = self.driver.grammar 150 | if "print_function" in _detect_future_features(new_text): 151 | self.driver.grammar = pygram.python_grammar_no_print_statement 152 | try: 153 | new_tree = self.driver.parse_string(new_text) 154 | if new_tree is None: 155 | raise AssertionError("Re-parsed CST is None") 156 | except Exception as e: 157 | raise BadTransform( 158 | f"Transforms generated invalid CST for {filename}", 159 | filename=filename, 160 | hunks=hunks, 161 | ) from e 162 | finally: 163 | self.driver.grammar = original_grammar 164 | 165 | return hunks 166 | 167 | def refactor_file(self, filename: str, *a, **k) -> List[Hunk]: 168 | try: 169 | hunks: List[Hunk] = [] 170 | input, encoding = self._read_python_source(filename) 171 | if input is None: 172 | # Reading the file failed. 173 | return hunks 174 | except (OSError, UnicodeDecodeError) as e: 175 | log.error(f"Skipping {filename}: failed to read because {e}") 176 | return hunks 177 | 178 | try: 179 | if not input.endswith("\n"): 180 | input += "\n" 181 | tree = self.refactor_string(input, filename) 182 | if tree: 183 | hunks = self.processed_file(str(tree), filename, input) 184 | except ParseError as e: 185 | log.exception("Skipping {filename}: failed to parse ({e})") 186 | 187 | return hunks 188 | 189 | def refactor_dir(self, dir_name: str, *a, **k) -> None: 190 | """Descends down a directory and refactor every Python file found. 191 | 192 | Python files are those for which `self.filename_matcher(filename)` 193 | returns true, to allow for custom extensions. 194 | 195 | Files and subdirectories starting with '.' are skipped. 196 | """ 197 | for dirpath, dirnames, filenames in os.walk(dir_name): 198 | self.log_debug("Descending into %s", dirpath) 199 | dirnames.sort() 200 | filenames.sort() 201 | for name in filenames: 202 | fullname = os.path.join(dirpath, name) 203 | if not name.startswith(".") and self.filename_matcher( 204 | Filename(fullname) 205 | ): 206 | self.queue_work(Filename(fullname)) 207 | # Modify dirnames in-place to remove subdirs with leading dots 208 | dirnames[:] = [dn for dn in dirnames if not dn.startswith(".")] 209 | 210 | def refactor_queue(self) -> None: 211 | self.semaphore.acquire() 212 | while True: 213 | filename = self.queue.get() 214 | 215 | if filename is None: 216 | break 217 | 218 | try: 219 | hunks = self.refactor_file(filename) 220 | self.results.put((filename, hunks, None)) 221 | 222 | except RetryFile: 223 | self.log_debug(f"Retrying {filename} later...") 224 | self.queue.put(filename) 225 | except BowlerException as e: 226 | log.exception(f"Bowler exception during transform of {filename}: {e}") 227 | self.results.put((filename, e.hunks, e)) 228 | except Exception as e: 229 | log.exception(f"Skipping {filename}: failed to transform because {e}") 230 | self.results.put((filename, [], e)) 231 | 232 | finally: 233 | self.queue.task_done() 234 | self.semaphore.release() 235 | 236 | def queue_work(self, filename: Filename) -> None: 237 | self.queue.put(filename) 238 | self.queue_count += 1 239 | 240 | def refactor(self, items: Sequence[str], *a, **k) -> None: 241 | """Refactor a list of files and directories.""" 242 | 243 | for dir_or_file in sorted(items): 244 | if os.path.isdir(dir_or_file): 245 | self.refactor_dir(dir_or_file) 246 | else: 247 | self.queue_work(Filename(dir_or_file)) 248 | 249 | children: List[multiprocessing.Process] = [] 250 | if self.in_process: 251 | self.queue.put(None) 252 | self.refactor_queue() 253 | else: 254 | child_count = max(1, min(self.NUM_PROCESSES, self.queue_count)) 255 | self.log_debug(f"starting {child_count} processes") 256 | for i in range(child_count): 257 | child = multiprocessing.Process(target=self.refactor_queue) 258 | child.start() 259 | children.append(child) 260 | self.queue.put(None) 261 | 262 | results_count = 0 263 | 264 | while True: 265 | try: 266 | filename, hunks, exc = self.results.get_nowait() 267 | results_count += 1 268 | 269 | if exc: 270 | self.log_error(f"{type(exc).__name__}: {exc}") 271 | if exc.__cause__: 272 | self.log_error( 273 | f" {type(exc.__cause__).__name__}: {exc.__cause__}" 274 | ) 275 | if isinstance(exc, BowlerException) and exc.hunks: 276 | diff = "\n".join("\n".join(hunk) for hunk in exc.hunks) 277 | self.log_error(f"Generated transform:\n{diff}") 278 | self.exceptions.append(exc) 279 | else: 280 | self.log_debug(f"results: got {len(hunks)} hunks for {filename}") 281 | self.process_hunks(filename, hunks) 282 | 283 | except Empty: 284 | if self.queue.empty() and results_count == self.queue_count: 285 | break 286 | 287 | elif not self.in_process and not any( 288 | child.is_alive() for child in children 289 | ): 290 | self.log_debug(f"child processes stopped without consuming work") 291 | break 292 | 293 | else: 294 | time.sleep(0.05) 295 | 296 | except BowlerQuit: 297 | for child in children: 298 | child.terminate() 299 | break 300 | 301 | self.log_debug(f"all children stopped and all diff hunks processed") 302 | 303 | def process_hunks(self, filename: Filename, hunks: List[Hunk]) -> None: 304 | auto_yes = False 305 | result = "" 306 | accepted_hunks = "" 307 | for hunk in hunks: 308 | if self.hunk_processor(filename, hunk) is False: 309 | continue 310 | 311 | if not self.silent: 312 | for line in hunk: 313 | if line.startswith("---"): 314 | click.secho(line, fg="red", bold=True) 315 | elif line.startswith("+++"): 316 | click.secho(line, fg="green", bold=True) 317 | elif line.startswith("-"): 318 | click.secho(line, fg="red") 319 | elif line.startswith("+"): 320 | click.secho(line, fg="green") 321 | else: 322 | click.echo(line) 323 | 324 | if self.interactive: 325 | if auto_yes: 326 | click.echo(f"Applying remaining hunks to {filename}") 327 | result = "y" 328 | else: 329 | result = prompt_user("Apply this hunk", "ynqad", "n") 330 | 331 | self.log_debug(f"result = {result}") 332 | 333 | if result == "q": 334 | self.apply_hunks(accepted_hunks, filename) 335 | raise BowlerQuit() 336 | elif result == "d": 337 | self.apply_hunks(accepted_hunks, filename) 338 | return # skip all remaining hunks 339 | elif result == "n": 340 | continue 341 | elif result == "a": 342 | auto_yes = True 343 | result = "y" 344 | elif result != "y": 345 | raise ValueError("unknown response") 346 | 347 | if result == "y" or self.write: 348 | accepted_hunks += "\n".join(hunk[2:]) + "\n" 349 | 350 | self.apply_hunks(accepted_hunks, filename) 351 | 352 | def apply_hunks(self, accepted_hunks, filename): 353 | if accepted_hunks: 354 | with open(filename) as f: 355 | data = f.read() 356 | 357 | try: 358 | accepted_hunks = f"--- {filename}\n+++ {filename}\n{accepted_hunks}" 359 | new_data = apply_single_file(data, accepted_hunks) 360 | except PatchException as err: 361 | log.exception(f"failed to apply patch hunk: {err}") 362 | return 363 | 364 | with open(filename, "w") as f: 365 | f.write(new_data) 366 | 367 | def run(self, paths: Sequence[str]) -> int: 368 | if not self.errors: 369 | self.refactor(paths) 370 | self.summarize() 371 | 372 | return int(bool(self.errors or self.exceptions)) 373 | -------------------------------------------------------------------------------- /bowler/type_inference.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | """bowler.type_inference 9 | 10 | Given an expression, find its result type. 11 | 12 | For sufficiently obvious expressions, we can find this using only local 13 | knowledge (numeric literals, and functions/names which have a standard 14 | meaning). Some obvious examples: 15 | 16 | `1.0` -> InferredType.FLOAT 17 | `2L` -> InferredType.INT 18 | `1/2` -> Depends on use_py2_division 19 | 20 | Even in cases where we don't know the full inputs, we can make some reasonable 21 | assumptions. For example when passing `use_py2_division=False, 22 | type_for_unknown=InferredType.INT_OR_FLOAT`: 23 | 24 | `x+1.0` -> InferredType.FLOAT 25 | `x/y` -> InferredType.FLOAT 26 | `len(x) + 1` -> InferredType.INT 27 | `float(z)` -> InferredType.FLOAT 28 | 29 | This is intended to be useful for either refactoring or flagging for humans 30 | syntax like `range(float(...))` or `"%d" % (float(...),)`. 31 | """ 32 | 33 | import enum 34 | from typing import Dict, Sequence, Union 35 | 36 | from fissix import pygram, pytree 37 | from fissix.pgen2 import token 38 | from fissix.pgen2.driver import Driver 39 | 40 | from .helpers import is_call_to 41 | from .types import LN, SYMBOL, TOKEN 42 | 43 | __all__ = ["InferredType", "numeric_expr_type"] 44 | 45 | 46 | class InferredType(enum.IntEnum): 47 | # The order of these is important, such that an expression using py3 48 | # semantics most operators take max(OP_MIN_TYPE[op], max(children)) as the 49 | # result. 50 | UNSET = 0 51 | BOOL = 1 52 | INT = 2 53 | # This represents UNKNOWN but assumed to be restricted to normal numeric 54 | # values. It can still be promoted to COMPLEX or FLOAT, but if is the 55 | # final result should be treated as INT (or better). 56 | INT_OR_FLOAT = 3 57 | FLOAT = 4 58 | COMPLEX = 5 59 | UNKNOWN = 6 60 | 61 | 62 | # Note: SLASH and DOUBLESLASH are specialcased. 63 | OP_MIN_TYPE: Dict = { 64 | TOKEN.PLUS: InferredType.INT, 65 | TOKEN.MINUS: InferredType.INT, 66 | TOKEN.STAR: InferredType.INT, 67 | TOKEN.PERCENT: InferredType.INT, 68 | TOKEN.SLASH: InferredType.INT, 69 | TOKEN.DOUBLESLASH: InferredType.INT, 70 | TOKEN.TILDE: InferredType.INT, # bitwise not 71 | TOKEN.DOUBLESTAR: InferredType.INT, 72 | TOKEN.LEFTSHIFT: InferredType.INT, 73 | TOKEN.RIGHTSHIFT: InferredType.INT, 74 | TOKEN.VBAR: InferredType.BOOL, 75 | TOKEN.CIRCUMFLEX: InferredType.BOOL, 76 | TOKEN.AMPER: InferredType.BOOL, 77 | TOKEN.LESS: InferredType.BOOL, 78 | } 79 | 80 | 81 | def numeric_expr_type( 82 | node: LN, 83 | use_py2_division=False, 84 | type_for_unknown: InferredType = InferredType.UNKNOWN, 85 | ) -> "InferredType": 86 | """Infer the type of an expression from its literals. 87 | 88 | We broaden the definition of "literal" a bit to also include calls to 89 | certain functions like int() and float() where the return type does not 90 | change based on the arguments. 91 | 92 | Args: 93 | node: A Node or leaf. 94 | use_py2_division: Whether to use magical python 2 style division. 95 | type_for_unknown: An InferredType to customize how you wan unknown 96 | handled. Use `INT_OR_FLOAT` if you trust your input to only work 97 | on numbers, but `UNKNOWN` if you want objects to be an option. 98 | 99 | Returns: InferredType 100 | """ 101 | if node.type == TOKEN.NUMBER: 102 | # It's important that we do not use eval here; some literals like `2L` 103 | # may be invalid in the current interpreter. 104 | if "j" in node.value: 105 | return InferredType.COMPLEX 106 | elif "." in node.value or "e" in node.value: 107 | return InferredType.FLOAT 108 | return InferredType.INT 109 | elif node.type == TOKEN.NAME and node.value in ("True", "False"): 110 | return InferredType.BOOL 111 | # TODO let the caller provide other known return types, or even a 112 | # collection of locals and their types. 113 | elif is_call_to(node, "bool"): 114 | return InferredType.BOOL 115 | elif is_call_to(node, "int") or is_call_to(node, "len"): 116 | return InferredType.INT 117 | elif is_call_to(node, "float"): 118 | return InferredType.FLOAT 119 | 120 | elif node.type in (SYMBOL.comparison, SYMBOL.not_test): 121 | return InferredType.BOOL 122 | elif node.type == SYMBOL.factor: 123 | # unary ~ + -, always [op, number] 124 | return max( 125 | OP_MIN_TYPE[node.children[0].type], 126 | numeric_expr_type(node.children[1], use_py2_division, type_for_unknown), 127 | ) 128 | elif node.type == SYMBOL.shift_expr: 129 | # << only valid on int 130 | return InferredType.INT 131 | 132 | elif node.type == SYMBOL.power: 133 | # a**b, but also f(...) 134 | if node.children[1].type != TOKEN.DOUBLESTAR: 135 | # probably f(...) 136 | return type_for_unknown 137 | 138 | return max( 139 | max(OP_MIN_TYPE[c.type] for c in node.children[1::2]), 140 | max( 141 | numeric_expr_type(c, use_py2_division, type_for_unknown) 142 | for c in node.children[::2] 143 | ), 144 | ) 145 | elif node.type in ( 146 | SYMBOL.arith_expr, 147 | SYMBOL.xor_expr, 148 | SYMBOL.and_expr, 149 | SYMBOL.expr, 150 | ): 151 | return max( 152 | max(OP_MIN_TYPE[c.type] for c in node.children[1::2]), 153 | max( 154 | numeric_expr_type(c, use_py2_division, type_for_unknown) 155 | for c in node.children[::2] 156 | ), 157 | ) 158 | elif node.type == SYMBOL.term: 159 | # */% 160 | # This is where things get interesting, as we handle use_py2_division. 161 | t = InferredType.UNSET 162 | last_op = None 163 | for i in range(len(node.children)): 164 | if i % 2 == 0: 165 | new = numeric_expr_type( 166 | node.children[i], use_py2_division, type_for_unknown 167 | ) 168 | if last_op == TOKEN.DOUBLESLASH: 169 | t = InferredType.INT 170 | elif last_op == TOKEN.SLASH: 171 | if use_py2_division: 172 | if t == InferredType.INT and new == InferredType.INT: 173 | t = InferredType.INT 174 | else: 175 | t = max(t, max(OP_MIN_TYPE[last_op], new)) 176 | else: 177 | t = max(t, InferredType.FLOAT) 178 | else: 179 | if last_op: 180 | t = max(t, OP_MIN_TYPE[last_op]) 181 | t = max(t, new) 182 | else: 183 | last_op = node.children[i].type 184 | return t 185 | elif node.type in (SYMBOL.or_test, SYMBOL.and_test): 186 | return max( 187 | numeric_expr_type(c, use_py2_division, type_for_unknown) 188 | for c in node.children[::2] 189 | ) 190 | 191 | return type_for_unknown 192 | -------------------------------------------------------------------------------- /bowler/types.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | from typing import Any, Callable, Dict, List, NewType, Optional, Type, Union 9 | 10 | from attr import Factory, dataclass 11 | from fissix.fixer_base import BaseFix 12 | from fissix.pgen2 import token 13 | from fissix.pygram import python_symbols 14 | from fissix.pytree import Leaf, Node 15 | 16 | 17 | class Passthrough: 18 | def __init__(self, target: Any) -> None: 19 | self._target = target 20 | 21 | def __getattr__(self, name: str) -> Any: 22 | return getattr(self._target, name) 23 | 24 | 25 | TOKEN = Passthrough(token) 26 | SYMBOL = Passthrough(python_symbols) 27 | 28 | SENTINEL = object() 29 | START = object() 30 | DROP = object() 31 | 32 | STARS = {TOKEN.STAR, TOKEN.DOUBLESTAR} 33 | ARG_END = {TOKEN.RPAR, TOKEN.COMMA} 34 | ARG_LISTS = {SYMBOL.typedargslist, SYMBOL.arglist} # function def, function call 35 | ARG_ELEMS = { 36 | TOKEN.NAME, # single argument 37 | SYMBOL.tname, # type annotated 38 | SYMBOL.argument, # keyword argument 39 | SYMBOL.star_expr, # vararg 40 | } | STARS 41 | 42 | LN = Union[Leaf, Node] 43 | Stringish = Union[str, object] 44 | Filename = NewType("Filename", str) 45 | FilenameMatcher = Callable[[Filename], bool] 46 | Capture = Dict[str, Any] 47 | Callback = Callable[[Node, Capture, Filename], Optional[LN]] 48 | Filter = Callable[[Node, Capture, Filename], bool] 49 | Fixers = List[Type[BaseFix]] 50 | Hunk = List[str] 51 | Processor = Callable[[Filename, Hunk], bool] 52 | 53 | 54 | @dataclass 55 | class Transform: 56 | selector: str = "" 57 | kwargs: Dict[str, Any] = Factory(dict) 58 | filters: List[Filter] = Factory(list) 59 | callbacks: List[Callback] = Factory(list) 60 | fixer: Optional[Type[BaseFix]] = None 61 | 62 | 63 | class BowlerException(Exception): 64 | def __init__( 65 | self, message: str = "", *, filename: str = "", hunks: List[Hunk] = None 66 | ) -> None: 67 | super().__init__(message) 68 | self.filename = filename 69 | self.hunks = hunks 70 | 71 | 72 | class BowlerQuit(BowlerException): 73 | pass 74 | 75 | 76 | class IMRError(BowlerException): 77 | pass 78 | 79 | 80 | class RetryFile(BowlerException): 81 | pass 82 | 83 | 84 | class BadTransform(BowlerException): 85 | pass 86 | -------------------------------------------------------------------------------- /docs/api-commands.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: api-commands 3 | title: CLI Commands 4 | --- 5 | 6 | Bowler provides a command line tool to simplify the process of testing and 7 | executing code refactoring scripts. 8 | 9 | The primary CLI tool is called `bowler`, and has a few commands to choose from. 10 | You can run Bowler with the following pattern: 11 | 12 | ```bash 13 | bowler [--help] [--debug | --quiet] [ ...] [] 14 | ``` 15 | 16 | ## Options 17 | 18 | `--help` 19 | 20 | Displays all options and commands, or if given after a command, shows all options and 21 | arguments for that command. 22 | 23 | `--debug | --quiet` 24 | 25 | Control the level of logging output from Bowler. By default, Bowler only outputs 26 | warnings and errors. With `--debug`, Bowler will also output debug and info level 27 | messages. With `--quiet`, Bowler will only output error messages. 28 | 29 | ## Commands 30 | 31 | 32 | 33 | ## Command Reference 34 | 35 | ### `do` 36 | 37 | Compile and run the given Python query, or open an IPython shell if none given. 38 | Common Bowler API elements will already be available in the global namespace. 39 | 40 | ```bash 41 | bowler do [] 42 | ``` 43 | 44 | ### `dump` 45 | 46 | Load and dump the concrete syntax tree from the given paths to stdout. 47 | 48 | ```bash 49 | bowler dump [ ...] 50 | ``` 51 | 52 | ### `run` 53 | 54 | Execute a file-based code modification. 55 | 56 | Takes either a path to a python script, or an importable module name, and 57 | attempts to import and run a "main()" function from that script/module if 58 | found. Extra arguments to this command will be supplied to the 59 | script/module. Use `--` to forcibly pass through all following options or 60 | arguments. 61 | 62 | ```bash 63 | bowler run ( | ) [-- ] 64 | ``` 65 | -------------------------------------------------------------------------------- /docs/api-filters.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: api-filters 3 | title: Filters 4 | --- 5 | 6 | Filters in Bowler are intermediate functions that optionally restrict the set of 7 | syntax tree elements originally matched by [selectors](api-selectors) before being 8 | modified. Matched elements must "pass" all filters to get modified – elements that fail 9 | a filter will be dropped immediately, and will not be tested by subsequent filters. 10 | 11 | All filter functions must match this signature: 12 | 13 | ```python 14 | def some_filter(node: LN, capture: Capture, filename: Filename) -> bool: 15 | ... 16 | ``` 17 | 18 | Argument | Description 19 | ---|--- 20 | node | The matched syntax tree element. 21 | capture | Sub-elements captured by the selector pattern. 22 | filename | The file being considered for modification. 23 | return value | `True` if the element should be modified, `False` otherwise. 24 | 25 | 26 | ## Filter Reference 27 | 28 | All filters are accessed as methods on the [`Query`](api-query) class: 29 | 30 | 31 | 32 | > Note: Bowler's API is still in a "provisional" phase. The core concepts will likely 33 | > stay the same, but individual method signatures may change as the tool matures or 34 | > gains new functionality. 35 | 36 | ### `.filter()` 37 | 38 | Add a custom filter function to the query. 39 | 40 | ```python 41 | query.filter(callback: Callback) 42 | ``` 43 | 44 | Argument | Description 45 | ---|--- 46 | callback | The custom filter function. 47 | 48 | ### `.is_filename()` 49 | 50 | Restrict modifications to files matching the supplied regular expression[s]. 51 | Supports both inclusive and exclusive matches. If both are given, then both must match. 52 | 53 | ```python 54 | query.is_filename(include: str = None, exclude: str = None) 55 | ``` 56 | 57 | Argument | Description 58 | ---|--- 59 | include | Only modify files matching the given pattern. 60 | exclude | Only modify files that don't match the given pattern. 61 | 62 | ### `.is_call()` 63 | 64 | Only modify if the matched element is a class, function, or method call. Requires 65 | selector that captures `class_call` or `function_call` similar to 66 | [`.select_function()`](api-selectors#select-function). 67 | 68 | ```python 69 | query.is_call() 70 | ``` 71 | 72 | ### `.is_def()` 73 | 74 | Only modify if the matched element is a class, function, or method definition. Requires 75 | selector that captures `class_def` or `function_def` similar to 76 | [`.select_function()`](api-selectors#select-function). 77 | 78 | ```python 79 | query.is_def() 80 | ``` 81 | 82 | ### `.in_class()` 83 | 84 | Only modify matched elements belonging to the given class, or to a subclass. 85 | Implies use of `.is_def()`. 86 | 87 | ```python 88 | query.in_class(class_name: str, include_subclasses: bool = True) 89 | ``` 90 | 91 | Argument | Description 92 | ---|--- 93 | class_name | Name of class or ancestor class to match. 94 | include_subclasses | When `False`, skips elements on subclasses of `class_name`. 95 | -------------------------------------------------------------------------------- /docs/api-modifiers.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: api-modifiers 3 | title: Modifiers 4 | --- 5 | 6 | Modifiers in Bowler are functions that modify, add, remove, or replace syntax tree 7 | elements originally matched by [selectors](api-selectors) after elements have passed 8 | all [filters](api-filters). Modifications may occur anywhere in the syntax tree, 9 | either above or below the matched element, and may include multiple modifications. 10 | 11 | All modifier functions must match this signature: 12 | 13 | ```python 14 | def modifier(node: LN, capture: Capture, filename: Filename) -> Optional[LN]: 15 | ... 16 | ``` 17 | 18 | Argument | Description 19 | ---|--- 20 | node | The matched syntax tree element. 21 | capture | Sub-elements captured by the selector pattern. 22 | filename | The file being modified. 23 | return value | Leaf or nodes returned will automatically replace the matched element. 24 | 25 | ## Modifier Reference 26 | 27 | All modifiers are accessed as methods on the [`Query`](api-query) class: 28 | 29 | 30 | 31 | > Note: Bowler's API is still in a "provisional" phase. The core concepts will likely 32 | > stay the same, but individual method signatures may change as the tool matures or 33 | > gains new functionality. 34 | 35 | ### `.modify()` 36 | 37 | Add a custom modifier to the query. 38 | 39 | ```python 40 | query.modify(callback: Callback) 41 | ``` 42 | 43 | Argument | Description 44 | ---|--- 45 | callback | The custom modifier function. 46 | 47 | ### `.encapsulate()` 48 | 49 | Encapsulate a class attribute using `@property`. 50 | 51 | ```python 52 | query.encapsulate(internal_name: str = "") 53 | ``` 54 | 55 | Argument | Description 56 | ---|--- 57 | internal_name | The "internal" name for the underlying attribute. Defaults to `_{old_name}`. 58 | 59 | ### `.rename()` 60 | 61 | Rename a matched element to a new name. Requires using a default selector. 62 | 63 | ```python 64 | query.rename(new_name: str) 65 | ``` 66 | 67 | Argument | Description 68 | ---|--- 69 | new_name | New name for element. 70 | 71 | ### `.add_argument()` 72 | 73 | Add an argument to a function or method, as well as callers. For positional arguments, 74 | the default value will be used to update all callers; for keyword arguments, it will 75 | be used in the function definition. 76 | 77 | Requires use of [`.select_function`](api-selectors#select-function) or 78 | [`.select_method`](api-selectors#select-method). 79 | 80 | ```python 81 | query.add_argument( 82 | name: str, 83 | value: str, 84 | positional: bool = False, 85 | after: Stringish = SENTINEL, 86 | type_annotation: Stringish = SENTINEL, 87 | ) 88 | ``` 89 | 90 | Argument | Description 91 | ---|--- 92 | name | The new argument name. 93 | value | Default value for the argument. 94 | positional | If `True`, will be treated as a positional argument, otherwise as a keyword. 95 | after | If `positional = True`, will be placed after this argument. Defaults to the end. Use `START` to place at the beginning. 96 | type_annotation | Optional type annotation for the new argument. 97 | 98 | ### `.modify_argument()` 99 | 100 | Modify the specified argument to a function or method, as well as callers. 101 | 102 | Requires use of [`.select_function`](api-selectors#select-function) or 103 | [`.select_method`](api-selectors#select-method). 104 | 105 | ```python 106 | query.modify_argument( 107 | name: str, 108 | new_name: Stringish = SENTINEL, 109 | type_annotation: Stringish = SENTINEL, 110 | default_value: Stringish = SENTINEL, 111 | ) 112 | ``` 113 | 114 | Argument | Description 115 | ---|--- 116 | name | The argument to modify. 117 | new_name | Optional new name for the argument. 118 | type_annotation | Optional type annotation to set. Use `DROP` to remove a type annotation. 119 | default_value | Optional default value to set. 120 | 121 | ### `.remove_argument()` 122 | 123 | Remove the specified argument from a function or method, as well as all callers. 124 | 125 | Requires use of [`.select_function`](api-selectors#select-function) or 126 | [`.select_method`](api-selectors#select-method). 127 | 128 | ```python 129 | query.remove_argument(name: str) 130 | ``` 131 | 132 | Argument | Description 133 | ---|--- 134 | name | The argument to remove. 135 | -------------------------------------------------------------------------------- /docs/api-query.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: api-query 3 | title: Queries 4 | --- 5 | 6 | Bowler provides a "fluent" `Query` API for building and executing refactoring scripts. 7 | Each method returns the `Query` object as the result, allowing you to easily chain 8 | multiple method calls one after the other, without needing to assign the query object 9 | to a variable or reference that variable for every method call. 10 | 11 | Example usage of the fluent API: 12 | 13 | ```python 14 | ( 15 | Query() 16 | .select_function("foo") 17 | .rename("bar") 18 | .diff() 19 | ) 20 | ``` 21 | 22 | In the example, the query object was never assigned to a variable, but you could write, 23 | build, and execute an equivalent query without the fluent API: 24 | 25 | ```python 26 | query = Query() 27 | query.select_function("foo") 28 | query.rename("bar") 29 | query.diff() 30 | ``` 31 | 32 | Both styles are supported by Bowler, but all examples will prefer the fluent style for 33 | clarity and brevity. 34 | 35 | ## Query Reference 36 | 37 | 38 | 39 | > Note: Bowler's API is still in a "provisional" phase. The core concepts will likely 40 | > stay the same, but individual method signatures may change as the tool matures or 41 | > gains new functionality. 42 | 43 | ### `Query()` 44 | 45 | Create a new query object to process the given set of files or directories. 46 | 47 | ```python 48 | Query( 49 | *paths: Union[str, List[str]], 50 | python_version: int, 51 | filename_matcher: FilenameMatcher, 52 | ) 53 | ``` 54 | 55 | * `*paths` - Accepts either individual file or directory paths (relative to the current 56 | working directory), or lists of paths, for any positional argument given. 57 | Defaults to the current working directory if no arguments given. 58 | 59 | * `*filename_matcher*` - A callback which returns whether a given filename is 60 | eligible for refactoring. Defaults to only matching files that end with 61 | `.py`. 62 | 63 | * `python_version` - The 'major' python version of the files to be refactored, i.e. `2` 64 | or `3`. This allows the parser to handle `print` statement vs function correctly. This 65 | includes detecting use of `from __future__ import print_function` when 66 | `python_version=2`. Default is `3`. 67 | 68 | 69 | ### `.select()` 70 | 71 | Start a new transform using a custom selector pattern. 72 | Subsequent calls to `.filter()` or `.modify()` will be attached to this transform. 73 | For a full list of predefined selectors, as well as details on defining custom 74 | selectors, see the [Selectors reference](/docs/api-selectors). 75 | 76 | ```python 77 | query.select(pattern: str, **kwargs) 78 | ``` 79 | 80 | * `pattern` - A **lib2to3** fixer pattern to select nodes in the syntax tree matching 81 | the specified elements, and capture sub-elements using the appropriate pattern syntax. 82 | May contain bracketed `{keyword}` tokens to be formatted by `**kwargs`. 83 | 84 | * `**kwargs` - Optional values to format the pattern with. Bowler will perform the 85 | equivalent of `pattern.format(**kwargs)`. 86 | 87 | ### `.fixer()` 88 | 89 | Start a new transform using an existing fixer class for **lib2to3**. 90 | Subsequent calls to `.filter()` or `.modify()` will be attached to this transform. 91 | Filters will execute before the fixer is executed, while subsequent modifiers will 92 | execute after the fixer. 93 | 94 | ```python 95 | query.fixer(fx: Type[BaseFix]) 96 | ``` 97 | 98 | * `fx` - A class implementing the `BaseFix` protocol, with a `PATTERN` attribute 99 | defining the selector and a `.transform()` method to modify matched elements. 100 | 101 | ### `.filter()` 102 | 103 | Filter matched elements using a custom function. 104 | For a full list of predefined filters, as well as details on defining custom 105 | filters, see the [Filters reference](/docs/api-filters). 106 | 107 | ```python 108 | query.filter(callback: Callback) 109 | ``` 110 | 111 | * `callback` - The custom function to call for every matched syntax tree element. 112 | Filter functions must accept three arguments: the matched element, the dictionary of 113 | captured elements, and the filename currently being processed. A truthy return value 114 | will pass the matched element to the next filter or modifier function, while a falsey 115 | return value will short-circuit remaining filters and prevent the matched element 116 | from being modified. 117 | 118 | ### `.modify()` 119 | 120 | Modify matched elements using a custom function. 121 | For a full list of predefined modifiers, as well as details on defining custom 122 | modifiers, see the [Modifiers reference](/docs/api-modifiers). 123 | 124 | ```python 125 | query.modify(callback: Callback) 126 | ``` 127 | 128 | * `callback` - The custom function to call for every matched syntax tree element that 129 | passed all filter functions. Modifier functions must accept three arguments: the 130 | matched element, the dictionary of captured elements, and the filename currently 131 | being processed. Returned values will automatically replace the matched element 132 | in the syntax tree. 133 | 134 | ### `.process()` 135 | 136 | Post-process all modifications with a custom function. 137 | 138 | ```python 139 | query.process(callback: Processor) 140 | ``` 141 | 142 | * `callback` - The custom function to call for every "hunk" of modifications made to 143 | files by the query. Processors must accept two arguments: the current filename, and 144 | the "hunk" being applied. Each hunk is represented as a list of strings, with each 145 | string representing a single line of the [unified diff][] for that modification. 146 | Return values of `False` will prevent the hunk from being applied, and any other 147 | return value will allow the hunk to be applied automatically or interactively by 148 | the user. 149 | 150 | ### `.execute()` 151 | 152 | Execute the current query, and generate a diff or write changes to disk. 153 | 154 | ```python 155 | .execute( 156 | interactive: bool = True, 157 | write: bool = False, 158 | silent: bool = False, 159 | ) 160 | ``` 161 | 162 | * `interactive` - Whether the generated diff should interactively prompt the user to 163 | apply each hunk to each file. When `True`, the `write` parameter is ignored. 164 | * `write` - Whether diff hunks should be written to disk. When `False`, diff hunks 165 | will only be echoed to stdout; when `True`, they will be echoed and files will be 166 | modified in place. 167 | * `silent` - When `True`, diff hunks will not be echoed to stdout, and the `interactive` 168 | parameter is ignored. 169 | 170 | ### `.diff()` 171 | 172 | Alias for `.execute(interactive=False, write=False)` 173 | 174 | ### `.idiff()` 175 | 176 | Alias for `.execute(interactive=True, write=False)` 177 | 178 | ### `.write()` 179 | 180 | Alias for `.execute(interactive=False, write=True, silent=True)` 181 | 182 | ### `.dump()` 183 | 184 | Attaches a debugging function to all transforms to dump a human-readable representation 185 | of matched and captured elements to stdout, and then executes the query without 186 | writing results to disk. 187 | 188 | 189 | [unified diff]: https://en.wikipedia.org/wiki/Diff#Unified_format 190 | -------------------------------------------------------------------------------- /docs/api-selectors.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: api-selectors 3 | title: Selectors 4 | --- 5 | 6 | Selectors are defined using the **lib2to3** pattern syntax to search the syntax tree 7 | for elements matching that pattern, and capturing specific nested nodes when matches 8 | are found. Patterns can be nested, with alternate branches, allowing arbitrarily 9 | complex matching. 10 | 11 | ## Pattern Syntax 12 | 13 | Selector patterns follow a very simple syntax, as defined in the **lib2to3** 14 | [pattern grammar][]. Matching elements of the [Python grammar][] is done by listing 15 | the grammar element, optionally followed by angle brackets containing nested match 16 | expressions. The `any` keyword can be used to match grammar elements, regardless of 17 | their type, while `*` denotes elements that repeat zero or more times. 18 | 19 | Make sure to include _necessary_ string literal tokens when using nested expressions, 20 | and `any*` to match remaining _grammar_ elements. 21 | 22 | ```python 23 | # any* does not capture the '=' string literal 24 | expr_stmt< 25 | attr_name='{name}' attr_value=any* 26 | > 27 | # but declare '(' and ')' string literals to differentiate from '[' and ']' 28 | trailer< '(' function_arguments=any* ')' > 29 | ``` 30 | 31 | Example pattern to match class definitions that contain a function definition (the 32 | "suite" denotes the body of the class definition): 33 | 34 | ```python 35 | PATTERN = """ 36 | classdef< 37 | any* 38 | suite< 39 | any* funcdef any* 40 | > 41 | > 42 | """ 43 | ``` 44 | 45 | The root element of the expression is what will be passed to filters and modifiers by 46 | default when matched. Capturing nested elements is done by preceding elements in the 47 | expressions with the desired name and an equals sign – these elements will then show 48 | up in the `Capture` argument to filters and modifiers. Square brackets can denote 49 | optional (zero or one) elements. 50 | 51 | Building on the example above, we can capture the defined classname, any ancestors, and 52 | the function name: 53 | 54 | ```python 55 | PATTERN = """ 56 | classdef< 57 | "class" name=NAME ["(" [ancestors=arglist] ")"] ":" 58 | suite< 59 | any* 60 | funcdef< "def" func_name=NAME any* > 61 | any* 62 | > 63 | > 64 | """ 65 | ``` 66 | 67 | To capture multiple possible arrangements of grammar elements, the `|` can be used with 68 | parentheses to combine expressions. You can reuse capture names for each expression, 69 | and you can further nest alternative expressions. 70 | 71 | To find both function definitions and function calls in a single selector: 72 | 73 | ```python 74 | PATTERN = """ 75 | ( 76 | funcdef< "def" name=NAME "(" [args=typedargslist] ")" any* > 77 | | 78 | power< name=NAME trailer< "(" [args=arglist] ")" > any* > 79 | ) 80 | """ 81 | ``` 82 | 83 | ## Selector Reference 84 | 85 | All selectors are accessed as methods on the [`Query`](api-query) class: 86 | 87 | 88 | 89 | > Note: Bowler's API is still in a "provisional" phase. The core concepts will likely 90 | > stay the same, but individual method signatures may change as the tool matures or 91 | > gains new functionality. 92 | 93 | ### `.select()` 94 | 95 | Supply a custom selector pattern as the given string, and auto-format it with any 96 | supplied keyword arguments. Captures are defined by the supplied pattern. 97 | 98 | ```python 99 | query.select(pattern: str, **kwargs: str) 100 | ``` 101 | 102 | ### `.select_root()` 103 | 104 | Select only the root node of the syntax tree, the `file_input` node. No captures. 105 | 106 | ```python 107 | query.select_root() 108 | ``` 109 | 110 | ### `.select_module()` 111 | 112 | Select specified modules when imported and referenced. 113 | 114 | ```python 115 | query.select_module(name: str) 116 | ``` 117 | 118 | Capture | Description | Example | Match 119 | ---|---|---|--- 120 | module_access | Attributes accessed | `foo.bar()` | `bar` 121 | module_imports | Attributes imported | `from foo import bar` | `[bar]` 122 | module_name | Name of module | `import foo` | `foo` 123 | module_nickname | Nickname at import | `import foo as bar` | `bar` 124 | 125 | ### `.select_class()` 126 | 127 | Select specified classes when imported, defined, subclassed, or instantiated. 128 | 129 | ```python 130 | query.select_class(name: str) 131 | ``` 132 | 133 | Capture | Description | Example | Match 134 | ---|---|---|--- 135 | class_arguments | Arguments of instantiation | `Foo(bar)` | `bar` 136 | class_call | Class being instantiated | `Foo()` | `Foo` 137 | class_def | Root of the class definition | `class Foo:` | `class` 138 | class_import | Root of the import | `from foo import Bar` | `from` 139 | class_name | Name of class | `class Bar(Foo):` | `Bar` 140 | class_subclass | Root of the subclass definition | `class Bar(Foo):` | `class` 141 | 142 | ### `.select_subclass()` 143 | 144 | Select subclasses of specified classes when defined. 145 | 146 | ```python 147 | query.select_subclass(name: str) 148 | ``` 149 | 150 | Capture | Description | Example | Match 151 | ---|---|---|--- 152 | class_ancestor | Name of ancestor class | `class Bar(Foo):` | `Foo` 153 | class_def | Root of the class definition | `class Bar(Foo):` | `class` 154 | class_name | Name of class | `class Bar(Foo):` | `Bar` 155 | 156 | ### `.select_attribute()` 157 | 158 | Select specified class attributes when defined, assigned, or accessed. 159 | 160 | ```python 161 | query.select_attribute(name: str) 162 | ``` 163 | 164 | Capture | Description | Example | Match 165 | ---|---|---|--- 166 | attr_class | Root of class definition | n/a | n/a 167 | attr_access | Attribute access | `value = self.foo` | `value` 168 | attr_assignment | Attribute assignment | `self.foo = 42` | `self.foo` 169 | attr_name | Name of attribute | `self.foo = 42` | `foo` 170 | attr_value | Value of attribute | `self.foo = 42` | `42` 171 | 172 | ### `.select_method()` 173 | 174 | Select specified class methods when defined or called. 175 | 176 | ```python 177 | query.select_method(name: str) 178 | ``` 179 | 180 | Capture | Description | Example | Match 181 | ---|---|---|--- 182 | decorators | Method decorators | `@classmethod` | `@classmethod` 183 | function_arguments | Method arguments | `def foo(self):` | `[self]` 184 | function_call | Method invocation | `foo.bar()` | `foo` 185 | function_def | Method definition | `def foo(self):` | `def` 186 | function_name | Method name | `def foo(self):` | `foo` 187 | 188 | ### `.select_function()` 189 | 190 | Select specified functions when imported, defined, or called. 191 | 192 | ```python 193 | query.select_function(name: str) 194 | ``` 195 | 196 | Capture | Description | Example | Match 197 | ---|---|---|--- 198 | decorators | Function decorators | `@wraps` | `@wraps` 199 | function_arguments | Function arguments | `def foo(a, b):` | `[a, b]` 200 | function_call | Function invocation | `foo()` | `foo` 201 | function_def | Function definition | `def foo(self):` | `def` 202 | function_import | Function import | `from foo import bar` | `from` 203 | function_name | Function name | `def foo(self):` | `foo` 204 | 205 | 206 | ### `.select_var()` 207 | 208 | Select arbitrary names when assigned or referenced. 209 | 210 | ```python 211 | query.select_var(name: str) 212 | ``` 213 | 214 | Capture | Description | Example | Match 215 | ---|---|---|--- 216 | var_assignment | Variable assignment | `foo = 42` | `foo = 42` 217 | var_name | Variable name | `foo = 42` | `foo` 218 | var_value | Assignment value | `foo = 42` | `42` 219 | 220 | 221 | [pattern grammar]: https://github.com/python/cpython/blob/main/Lib/lib2to3/PatternGrammar.txt 222 | [python grammar]: https://github.com/python/cpython/blob/main/Lib/lib2to3/Grammar.txt 223 | -------------------------------------------------------------------------------- /docs/basics-intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: basics-intro 3 | title: Introduction 4 | --- 5 | 6 | ## What is Bowler? 7 | 8 | Bowler is a refactoring tool for manipulating Python at the syntax tree level. It 9 | enables safe, large scale code modifications while guaranteeing that the resulting code 10 | compiles and runs. It provides both a simple command line interface and a fluent API in 11 | Python for generating complex code modifications in code. 12 | 13 | Bowler is built on **lib2to3** from the standard Python library, and requires only a few 14 | third party dependencies, like [click][], for common components. 15 | 16 | 17 | ## Why lib2to3 18 | 19 | **lib2to3** provides a concrete syntax tree (CST) implementation that recognizes and 20 | supports the grammar of all Python versions back to 2.6. By nature of being a CST, 21 | **lib2to3** enables modifications to the syntax tree while maintaining all formatting and 22 | comments, preventing modifications from destroying valuable information. 23 | 24 | By building on **lib2to3**, Bowler is capable of reading and modifying source files 25 | written for both Python 2 and 3. That said, Bowler requires Python 3.6 or newer to run, 26 | as it uses a large number of modern features of the language to enable more readable 27 | and maintainable code. 28 | 29 | > Technical detail: Bowler actually uses **[fissix][]**, a backport of lib2to3 that 30 | > includes a few minor improvements and features that have not yet been upstreamed 31 | > to CPython. This allows Bowler to parse grammars and utilize features from newer 32 | > versions of Python than might otherwise be supported by the local runtime. 33 | 34 | 35 | [click]: http://click.pocoo.org/ 36 | [fissix]: https://github.com/jreese/fissix 37 | -------------------------------------------------------------------------------- /docs/basics-refactoring.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: basics-refactoring 3 | title: Refactoring 4 | --- 5 | 6 | Bowler builds on top of the Concrete Syntax Tree (CST) provided by **lib2to3** in the 7 | standard library. This enables direct modifications to the tree, without losing 8 | formatting, comments, or whitespace, and without needing to directly support every 9 | new version of Python as they get released. 10 | 11 | ## Fixers 12 | 13 | Fixers are a core concept of refactoring in **lib2to3**, and they contain both 14 | a grammar-based search "pattern" for finding nodes in the CST as well as methods for 15 | transforming nodes that match the pattern. They are relatively self-contained, and 16 | form the core component of the `2to3` tool used for migrating older Python structures 17 | to modern Python 3 analogues. 18 | 19 | Bowler wraps much of its framework around generating a set of fixers, and then using 20 | a modified version of the **lib2to3** refactoring framework to apply these fixers 21 | to the target Python source files. This allows Bowler to reuse and benefit from most 22 | of the features in **lib2to3** while still allowing it to provide custom behaviors 23 | and functionality not found in the standard library. 24 | 25 | ## Queries 26 | 27 | Bowler operates by building "queries" against the CST. Queries consist of one or more 28 | "transforms", which specify what elements of the syntax tree to modify, as well as how 29 | to modify those elements. Each transform represents a search against the syntax tree, 30 | along with a programmatic modification to any matched tree elements. These transforms 31 | generate a fixer for **lib2to3**, and Bowler will execute multiple transforms 32 | simultaneously on each file processed. By combining multiple transforms together, 33 | Bowler enables a wide range of refactoring, from simple renames to complicated upgrades 34 | from old APIs to their modern replacements. 35 | 36 | The primary mechanism for building queries and transforms in Bowler is by using a 37 | "fluent" API: 38 | 39 | > A fluent interface is a method for designing object oriented APIs based extensively 40 | > on method chaining with the goal of making the readability of the source code close 41 | > to that of ordinary written prose, essentially creating a domain-specific language 42 | > within the interface. -- [Fluent Interface][fi] 43 | 44 | This allows you to chain method calls to the `Query` class one after the other, without 45 | needing to assign a query object to a variable or reference that variable for every 46 | method call. In practice, that looks something like this: 47 | 48 | ```python 49 | ( 50 | Query() 51 | .select(...) 52 | .modify(...) 53 | .execute() 54 | ) 55 | ``` 56 | 57 | ## Transforms 58 | 59 | Transforms are implicitly created by the `Query` API while defining the desired query. 60 | Each transform begins with a "selector" (the syntax tree pattern to search for), or an 61 | existing fixer class that already contains a selector pattern. Then, any number of 62 | "filters" are specified, followed by one or more "modifiers". Each time a selector or 63 | fixer is added to the query, a new transform is generated, and subsequent filters or 64 | modifiers are added to the transform. 65 | 66 | Repeating this pattern of selector, filters, and modifiers will generate further 67 | transforms, each of which will be executed as elements in the syntax tree match the 68 | selector patterns for a given transform. 69 | 70 | ### Selectors 71 | 72 | These represents **lib2to3** search patterns to select syntax tree nodes matching the 73 | given pattern, while capturing relevant child nodes or leaves when available. Bowler 74 | includes a set of common selectors that attempt to find all definitions or references 75 | of a specific type, and uses a consistent naming scheme for all captured elements. 76 | 77 | An example selector to find all uses of the `print` function might look like: 78 | 79 | ```python 80 | pattern = """ 81 | power< "print" 82 | trailer< "(" print_args=any* ")" > 83 | > 84 | """ 85 | 86 | ( 87 | Query() 88 | .select(pattern) 89 | ... 90 | ) 91 | ``` 92 | 93 | This example finds all function calls named `print`, followed immediately by the 94 | parenthetical trailer, and captures everything contained in those parentheses using the 95 | name `print_args`. 96 | 97 | ### Filters 98 | 99 | Once elements are found matching the active selector, filters can further limit which 100 | elements are passed to the modifiers by inspecting the original matches and returning 101 | `True` if it still matches, or `False` if the element should be excluded. Elements 102 | will only be considered as matches if they successfully pass all filter functions. 103 | If an element fails one filter function, it will not be passed or considered for any 104 | other filter functions. 105 | 106 | An example filter, to pair with the example selector above, that only matches calls 107 | to the print function with string literal arguments: 108 | 109 | ```python 110 | def print_string(node: LN, capture: Capture, filename: Filename) -> bool: 111 | args = capture.get("print_args") 112 | 113 | # args will be a list because we captured `any*` 114 | if args: 115 | return len(args) == 1 and args[0].type == TOKEN.STRING: 116 | 117 | return False 118 | 119 | ( 120 | Query() 121 | ... 122 | .filter(print_string) 123 | ... 124 | ) 125 | ``` 126 | 127 | This filter function will only return `True` if the matched print call contained 128 | exactly one argument, and that argument's type was a string literal. 129 | 130 | ### Modifiers 131 | 132 | After matched elements have been filtered, all remaining matches will be passed to all 133 | modifier functions in sequence. The modifier function may then transform the syntax 134 | tree at any point from the root to the leaves, without being restricted to the branch 135 | corresponding to the matched element. Transformations can include modifying existing 136 | nodes or leaves, removing or replacing elements, or inserting elements into the tree. 137 | 138 | An example modifier, which builds the above selector and filter examples, transforms 139 | the string literal arguments of the print function to wrap them in a hypothetical 140 | translation function (`tr`): 141 | 142 | ```python 143 | def translate_string(node: LN, capture: Capture, filename: Filename) -> None: 144 | args = capture.get("print_args") 145 | 146 | # make sure the arguments haven't already been modified 147 | if args and args[0].type == TOKEN.STRING: 148 | args[0].replace( 149 | Call( 150 | Name("tr"), 151 | args=[args[0].clone()], 152 | ) 153 | ) 154 | 155 | ( 156 | Query() 157 | ... 158 | .modify(translate_string) 159 | ... 160 | ) 161 | ``` 162 | 163 | The modifier function replaces the existing string literal element with the nested 164 | nodes to represent a call to the hypothetical translation function (`tr`), with a clone 165 | of the existing string literal as the only argument. 166 | 167 | > Note: take care to ensure that matched and captured elements still meet the 168 | > expectations of your modifier function. They may have already been transformed 169 | > (or entirely removed or replaced) by previous modifiers. 170 | 171 | ### Processors 172 | 173 | Bowler recognizes that there are cases where it's useful to post-process final changes 174 | to the codebase, and provides a mechanism for attaching "processors" to the query. 175 | These are functions that will be executed for each individual "hunk", or change, to 176 | a file – the same "hunks" that you would see in a unified diff format – and optionally 177 | decide whether that hunk should be applied or skipped. This allows for behavior such 178 | as extra logging, selective application of hunks based on conditions, record keeping 179 | for future queries, and much more. 180 | 181 | An example processor that keeps track of every file touched by the query: 182 | 183 | ```python 184 | MODIFIED: Set[Filename] = set() 185 | 186 | def modified_files(filename: Filename, hunk: Hunk) -> bool: 187 | MODIFIED.add(filename) 188 | return True 189 | 190 | ( 191 | Query() 192 | ... 193 | .process(modified_files) 194 | ... 195 | ) 196 | ``` 197 | 198 | ## Execution 199 | 200 | After building a query with some combination of selectors, filters, modifiers, and/or 201 | processors, there are a few different execution commands that Bowler takes to determine 202 | how it will apply the query to the codebase. The default behavior when calling 203 | `.execute()` on the query is to generate an interactive diff – similar to that provided 204 | by `git add -p` – and show each hunk in series, asking the user to apply or skip that 205 | hunk. This is the "safest" option, as the user can verify that each change is intended 206 | and meets the expectations of the refactoring. There are also options to just generate 207 | a diff without applying hunks, or to apply all hunks without asking, and there are 208 | shortcut methods for each of these options as well. 209 | 210 | ```python 211 | ( 212 | Query() 213 | ... 214 | .execute( 215 | interactive = True, # ask about each hunk 216 | write = False, # automatically apply each hunk 217 | silent = False, # don't ask or print hunks at all 218 | ) 219 | ) 220 | ``` 221 | 222 | 223 | [fi]: https://en.wikipedia.org/wiki/Fluent_interface 224 | -------------------------------------------------------------------------------- /docs/basics-setup.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: basics-setup 3 | title: Setup 4 | --- 5 | 6 | ## Installing Bowler 7 | 8 | Bowler supports modifications to code from any version of Python 3, as well as 9 | Python 2.6+ with use of print functions from `__future__`, but it requires 10 | Python 3.6 or higher to run. Bowler can be easily installed using most common 11 | Python packaging tools. We recommend installing the latest stable release from 12 | [PyPI][] with `pip`: 13 | 14 | ```bash 15 | pip install bowler 16 | ``` 17 | 18 | You can also install a development version from source by checking out the Git repo: 19 | 20 | ```bash 21 | git clone https://github.com/facebookincubator/Bowler 22 | cd Bowler 23 | python setup.py install 24 | ``` 25 | 26 | 27 | ## Configuration 28 | 29 | Bowler tries to minimize the need for configuration. It does not currently support 30 | any configuration files, and has no command line parameters other than to control 31 | logging output. All configuration needed for Bowler can be done via the API while 32 | executing any refactoring or code modification scripts. 33 | 34 | 35 | [PyPI]: https://pypi.org/p/bowler 36 | -------------------------------------------------------------------------------- /docs/basics-usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: basics-usage 3 | title: Usage 4 | --- 5 | 6 | Bowler provides two primary mechanisms for refactoring. It provides a 7 | [fluent API](fixers) for standalone applications to import and use, and it also 8 | provides a simple command line tool for executing one or more refactoring scripts 9 | on a given set of directories or files. 10 | 11 | 12 | ## Command Line 13 | 14 | Bowler has multiple commands, which allow you to debug or execute code refactoring 15 | scripts in the most convenient format for you. See the 16 | [Command Reference](api-commands.md) for more details. 17 | 18 | ```bash 19 | bowler [ ...] 20 | ``` 21 | 22 | ## Fluent API 23 | 24 | Bowler uses a "fluent" `Query` API to build refactoring scripts through a series 25 | of selectors, filters, and modifiers. Many simple modifications are already possible 26 | using the existing API, but you can also provide custom selectors, filters, and 27 | modifiers as needed to build more complex or custom refactorings. See the 28 | [Query Reference](api-query.md) for more details. 29 | 30 | Using the query API to rename a single function, and generate an interactive diff from 31 | the results, would look something like this: 32 | 33 | ```python 34 | query = ( 35 | Query() 36 | .select_function("old_name") 37 | .rename("new_name") 38 | .diff(interactive=True) 39 | ) 40 | ``` 41 | 42 | ## Refactoring Scripts 43 | 44 | Refactoring scripts combine the mechanisms above to provide self-contained, reusable 45 | components for refactoring large code bases. They consist of one or more queries or 46 | fixers in a single Python file, ready to be imported and executed by Bowler. This 47 | allows the user to run these predefined or parameterized refactors on future code, 48 | enabling long term benefits from the initial effort. 49 | 50 | For example, if we have a simple refactoring script named `rename_func.py`: 51 | 52 | ```python 53 | from bowler import Query 54 | 55 | 56 | old_name, new_name = sys.argv[1:] 57 | ( 58 | Query(".") 59 | .select_function(old_name) 60 | .rename(new_name) 61 | .idiff() 62 | ) 63 | ``` 64 | 65 | That script can then be executed directly as a normal python application, or with the 66 | following Bowler command, to interactively rename and function called `foo` to `bar`, 67 | including all references: 68 | 69 | ```bash 70 | bowler run rename_func.py -- foo bar 71 | ``` 72 | -------------------------------------------------------------------------------- /docs/dev-intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: dev-intro 3 | title: Developing Bowler 4 | --- 5 | 6 | We want to make contributing to this project as easy and transparent as 7 | possible. 8 | 9 | ## Getting Started 10 | 11 | When developing Bowler, follow these steps to setup your environment, 12 | format your code, and run linters and unit tests: 13 | 14 | 1. Fork [Bowler][] on Github. 15 | 16 | 1. Clone the git repo: 17 | ```bash 18 | $ git clone https://github.com/$USERNAME/bowler 19 | $ cd bowler 20 | ``` 21 | 22 | 1. Setup the virtual environment with dependencies and tools: 23 | ```bash 24 | $ make dev 25 | $ source .venv/bin/activate 26 | ``` 27 | 28 | 1. Format your code using [*Black*](https://github.com/ambv/black) and 29 | [isort](https://pypi.org/project/isort/): 30 | ```bash 31 | $ make format 32 | ``` 33 | 34 | 1. Run linter, type checks, and unit tests: 35 | ```bash 36 | $ make lint test 37 | ``` 38 | 39 | ## Pull Requests 40 | 41 | We actively welcome your pull requests. 42 | 43 | 1. If you've added code that should be tested, add unit tests. 44 | 1. If you've changed APIs, update the documentation. 45 | 1. Ensure the test suite passes. 46 | 1. Make sure your code lints. 47 | 1. If you haven't already, complete the Contributor License Agreement ("CLA"). 48 | 49 | ## Contributor License Agreement ("CLA") 50 | 51 | In order to accept your pull request, we need you to submit a CLA. You only need 52 | to do this once to work on any of Facebook's open source projects. 53 | 54 | Complete your CLA here: 55 | 56 | ## Issues 57 | 58 | We use GitHub issues to track public bugs. Please ensure your description is 59 | clear and has sufficient instructions to be able to reproduce the issue. 60 | 61 | Facebook has a [bounty program](https://www.facebook.com/whitehat/) for the safe 62 | disclosure of security bugs. In those cases, please go through the process 63 | outlined on that page and do not file a public issue. 64 | 65 | ## License 66 | 67 | By contributing to Bowler, you agree that your contributions will be licensed 68 | under the `LICENSE` file in the root directory of this source tree. 69 | 70 | 71 | [Bowler]: https://github.com/facebookincubator/bowler 72 | -------------------------------------------------------------------------------- /docs/dev-roadmap.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: dev-roadmap 3 | title: Roadmap to the Future 4 | --- 5 | 6 | Bowler is still a very young project. There are many ways in which it could improve and 7 | expand to cover more use cases. Below is a non-exhaustive list of planned features 8 | that are just waiting on someone to have the spare cycles to implement. 9 | 10 | * Linting features, such as looking for and reporting lints, complete with recommended 11 | fixes from the CST. Supporting diff output in formats expected by common tools, like 12 | Phabricator, would make integration easier. 13 | 14 | * Integration with common editors or IDE's like Nuclide, such as one-click renaming 15 | or encapsulation of attributes into properties. 16 | 17 | * Comprehensive intermediate representations (IMR) for easier modification of syntax 18 | tree elements. 19 | 20 | * More comprehensive set of default selectors, filters, and modifiers. 21 | 22 | * Reduce boilerplate for executing queries or fixers. 23 | 24 | * Better documentation for those unfamiliar with code refactoring. 25 | 26 | If you're interested in working on these features, please read the [contributors][] 27 | guide and open an issue on the Github repo to discuss it in more detail. 28 | 29 | [contributors]: dev-intro 30 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | venv: 2 | python -m venv .venv 3 | @echo 'run `source .venv/bin/activate` to use virtualenv' 4 | 5 | setup: 6 | python -m pip install -Ur requirements.txt 7 | python -m pip install -Ur requirements-dev.txt 8 | 9 | dev: venv 10 | source .venv/bin/activate && make setup 11 | source .venv/bin/activate && python setup.py develop 12 | @echo 'run `source .venv/bin/activate` to develop bowler' 13 | 14 | release: lint test clean 15 | python setup.py sdist bdist_wheel 16 | python -m twine upload dist/* 17 | 18 | format: 19 | isort bowler setup.py 20 | black bowler setup.py 21 | 22 | lint: 23 | /bin/bash scripts/check_copyright.sh 24 | isort --check bowler setup.py 25 | black --check bowler setup.py 26 | mypy -p bowler 27 | 28 | test: 29 | python -m coverage run -m bowler.tests 30 | python -m coverage report 31 | 32 | clean: 33 | rm -rf build dist README MANIFEST *.egg-info 34 | 35 | distclean: clean 36 | rm -rf .venv 37 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black==20.8b1 2 | codecov==2.1.9 3 | coverage==5.3 4 | isort==5.5.2 5 | mypy==0.782 6 | python-coveralls==2.9.3 7 | twine==3.2.0 8 | wheel==0.38.1 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs 2 | click 3 | fissix 4 | moreorless>=0.2.0 5 | volatile 6 | -------------------------------------------------------------------------------- /scripts/check_copyright.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | die() { echo "$1"; exit 1; } 9 | 10 | while read filename; do 11 | grep -q "Copyright (c) Facebook" "$filename" || 12 | die "Missing copyright in $filename"; 13 | grep -q "#!/usr/bin/env python3" "$filename" || 14 | die "Missing #! in $filename"; 15 | done < <( git ls-tree -r --name-only HEAD | grep ".py$" ) 16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | branch = True 3 | source = bowler 4 | 5 | [coverage:report] 6 | fail_under = 65 7 | precision = 1 8 | show_missing = True 9 | skip_covered = True 10 | 11 | [isort] 12 | combine_as_imports = True 13 | force_grid_wrap = False 14 | include_trailing_comma = True 15 | line_length = 88 16 | multi_line_output = 3 17 | use_parentheses = True 18 | 19 | [mypy] 20 | ignore_missing_imports = True 21 | 22 | [metadata] 23 | name = bowler 24 | description = Safe code refactoring for modern Python projects 25 | long_description = file: README.md 26 | long_description_content_type = text/markdown 27 | author = John Reese, Facebook 28 | author_email = jreese@fb.com 29 | url = https://github.com/facebookincubator/bowler 30 | classifiers = 31 | Development Status :: 1 - Planning 32 | Intended Audience :: Developers 33 | License :: OSI Approved :: MIT License 34 | Programming Language :: Python 35 | Programming Language :: Python :: 3 36 | Programming Language :: Python :: 3.6 37 | Programming Language :: Python :: 3.7 38 | Programming Language :: Python :: 3.8 39 | license = MIT 40 | 41 | [options] 42 | packages = 43 | bowler 44 | bowler.tests 45 | test_suite = bowler.tests 46 | python_requires = >=3.6 47 | setup_requires = 48 | setuptools_scm 49 | setuptools>=38.6.0 50 | 51 | [options.entry_points] 52 | console_scripts = 53 | bowler = bowler.main:main 54 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) Facebook, Inc. and its affiliates. 4 | # 5 | # This source code is licensed under the MIT license found in the 6 | # LICENSE file in the root directory of this source tree. 7 | 8 | from setuptools import find_packages, setup 9 | 10 | with open("requirements.txt") as f: 11 | requires = f.read().strip().splitlines() 12 | 13 | setup( 14 | install_requires=requires, 15 | use_scm_version={"write_to": "bowler/version.py"}, 16 | ) 17 | -------------------------------------------------------------------------------- /website/blog/2018-08-24-launch.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introducing Bowler 3 | author: John Reese 4 | authorURL: https://jreese.sh 5 | authorFBID: 24410668 6 | --- 7 | 8 | We are excited to announce Bowler, the safe refactoring tool for modern Python! 9 | We created Bowler as a tool for building powerful and reusable code mods for all of the 10 | Python services and utilities that we've created at Facebook. This tool has already 11 | proved useful in upgrading old code to new APIs, fixing broken attributes in build 12 | rules, and in making these changes trivial to repeat in production. 13 | 14 | Bowler is built using syntax trees from the Python standard library, enabling 15 | compatibility with all past and future versions of Python. Bowler's fluent query API 16 | allows you to build refactoring scripts from composable and reusable components that 17 | can continue providing value into the future, rather than being thrown away or wasted. 18 | 19 | As of today, we have made Bowler available in "early access" on [Github][], while we 20 | continue to iterate on both the feature set and the API. It's already quite useful 21 | for common refactoring in a large code base, and we look forward to your feedback and 22 | suggestions for how to improve Bowler in the future. 23 | 24 | ## Getting Started 25 | 26 | Take a look at the documentation on [how Bowler works](/docs/basics-intro), or dive 27 | into the [Query API](/docs/api-query). If you're interested in making Bowler even 28 | better, head over to the [contributors guide](/docs/dev-intro), or let us know on 29 | [Twitter][]. 30 | 31 | [Github]: https://github.com/facebookincubator/bowler 32 | [Twitter]: https://twitter.com/fbOpenSource 33 | -------------------------------------------------------------------------------- /website/core/Footer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const React = require('react'); 9 | 10 | class Footer extends React.Component { 11 | docUrl(doc, language) { 12 | const baseUrl = this.props.config.baseUrl; 13 | return baseUrl + 'docs/' + (language ? language + '/' : '') + doc; 14 | } 15 | 16 | imgUrl(img) { 17 | const baseUrl = this.props.config.baseUrl; 18 | return baseUrl + 'img/' + img; 19 | } 20 | 21 | pageUrl(doc, language) { 22 | const baseUrl = this.props.config.baseUrl; 23 | return baseUrl + (language ? language + '/' : '') + doc; 24 | } 25 | 26 | render() { 27 | const currentYear = new Date().getFullYear(); 28 | let language = this.props.language || ''; 29 | return ( 30 | 77 | ); 78 | } 79 | } 80 | 81 | module.exports = Footer; 82 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "examples": "docusaurus-examples", 4 | "start": "docusaurus-start", 5 | "build": "docusaurus-build", 6 | "publish-gh-pages": "docusaurus-publish", 7 | "write-translations": "docusaurus-write-translations", 8 | "version": "docusaurus-version", 9 | "rename-version": "docusaurus-rename-version" 10 | }, 11 | "devDependencies": { 12 | "docusaurus": "^1.14.6" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /website/pages/en/help.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const React = require('react'); 9 | 10 | const CompLibrary = require('../../core/CompLibrary.js'); 11 | const Container = CompLibrary.Container; 12 | const GridBlock = CompLibrary.GridBlock; 13 | 14 | const siteConfig = require(process.cwd() + '/siteConfig.js'); 15 | 16 | function docUrl(doc, language) { 17 | return siteConfig.baseUrl + 'docs/' + (language ? language + '/' : '') + doc; 18 | } 19 | 20 | class Help extends React.Component { 21 | render() { 22 | let language = this.props.language || ''; 23 | const supportLinks = [ 24 | { 25 | content: `Learn more using the [documentation on this site.](${docUrl( 26 | 'doc1.html', 27 | language 28 | )})`, 29 | title: 'Browse Docs', 30 | }, 31 | { 32 | content: 'Ask questions about the documentation and project', 33 | title: 'Join the community', 34 | }, 35 | { 36 | content: "Find out what's new with this project", 37 | title: 'Stay up to date', 38 | }, 39 | ]; 40 | 41 | return ( 42 |
43 | 44 |
45 |
46 |

Need help?

47 |
48 |

This project is maintained by a dedicated group of people.

49 | 50 |
51 |
52 |
53 | ); 54 | } 55 | } 56 | 57 | module.exports = Help; 58 | -------------------------------------------------------------------------------- /website/pages/en/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const React = require('react'); 9 | 10 | const CompLibrary = require('../../core/CompLibrary.js'); 11 | const MarkdownBlock = CompLibrary.MarkdownBlock; /* Used to read markdown */ 12 | const Container = CompLibrary.Container; 13 | const GridBlock = CompLibrary.GridBlock; 14 | 15 | const siteConfig = require(process.cwd() + '/siteConfig.js'); 16 | 17 | function imgUrl(img) { 18 | return siteConfig.baseUrl + 'img/' + img; 19 | } 20 | 21 | function docUrl(doc, language) { 22 | return siteConfig.baseUrl + 'docs/' + (language ? language + '/' : '') + doc; 23 | } 24 | 25 | function pageUrl(page, language) { 26 | return siteConfig.baseUrl + (language ? language + '/' : '') + page; 27 | } 28 | 29 | function SocialBanner() { 30 | return ( 31 |
32 |
33 | Support Ukraine 🇺🇦{' '} 34 | 35 | Help Provide Humanitarian Aid to Ukraine 36 | 37 | . 38 |
39 |
40 | ); 41 | } 42 | 43 | class Button extends React.Component { 44 | render() { 45 | return ( 46 | 51 | ); 52 | } 53 | } 54 | 55 | Button.defaultProps = { 56 | target: '_self', 57 | }; 58 | 59 | const SplashContainer = props => ( 60 |
61 |
62 |
{props.children}
63 |
64 |
65 | ); 66 | 67 | const Logo = props => ( 68 |
69 | 70 |
71 | ); 72 | 73 | const ProjectTitle = props => ( 74 |

75 | Bowler 76 | {siteConfig.tagline} 77 |

78 | ); 79 | 80 | const PromoSection = props => ( 81 |
82 |
83 |
{props.children}
84 |
85 |
86 | ); 87 | 88 | class HomeSplash extends React.Component { 89 | render() { 90 | let language = this.props.language || ''; 91 | return ( 92 | 93 | 94 |
95 | 96 | 97 | 98 | 99 | 100 |
101 |
102 | ); 103 | } 104 | } 105 | 106 | const Block = props => ( 107 | 111 | 112 | 113 | ); 114 | 115 | class Index extends React.Component { 116 | render() { 117 | let language = this.props.language || ''; 118 | 119 | return ( 120 |
121 | 122 | 123 |
124 | 125 | 145 | 146 | 147 | ', 151 | } 152 | ]} /> 153 | 154 |
155 |
156 | ); 157 | } 158 | } 159 | 160 | module.exports = Index; 161 | -------------------------------------------------------------------------------- /website/pages/en/users.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const React = require('react'); 9 | 10 | const CompLibrary = require('../../core/CompLibrary.js'); 11 | const Container = CompLibrary.Container; 12 | 13 | const siteConfig = require(process.cwd() + '/siteConfig.js'); 14 | 15 | class Users extends React.Component { 16 | render() { 17 | if ((siteConfig.users || []).length === 0) { 18 | return null; 19 | } 20 | const editUrl = siteConfig.repoUrl + '/edit/master/website/siteConfig.js'; 21 | const showcase = siteConfig.users.map((user, i) => { 22 | return ( 23 | 24 | {user.caption} 25 | 26 | ); 27 | }); 28 | 29 | return ( 30 |
31 | 32 |
33 |
34 |

Who's Using This?

35 |

This project is used by many folks

36 |
37 |
{showcase}
38 |

Are you using this project?

39 | 40 | Add your company 41 | 42 |
43 |
44 |
45 | ); 46 | } 47 | } 48 | 49 | module.exports = Users; 50 | -------------------------------------------------------------------------------- /website/sidebars.json: -------------------------------------------------------------------------------- 1 | { 2 | "docs": { 3 | "Basics": [ 4 | "basics-intro", 5 | "basics-refactoring", 6 | "basics-setup", 7 | "basics-usage" 8 | ], 9 | "API Reference": [ 10 | "api-query", 11 | "api-selectors", 12 | "api-filters", 13 | "api-modifiers", 14 | "api-commands" 15 | ], 16 | "Contributing": [ 17 | "dev-intro", 18 | "dev-roadmap" 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /website/siteConfig.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | // See https://docusaurus.io/docs/site-config.html for all the possible 9 | // site configuration options. 10 | 11 | /* List of projects/orgs using your project for the users page */ 12 | const users = [ 13 | { 14 | caption: 'Facebook Engineering', 15 | // You will need to prepend the image path with your baseUrl 16 | // if it is not '/', like: '/test-site/img/docusaurus.svg'. 17 | image: '/img/bowler.png', 18 | infoLink: 'https://code.facebook.com', 19 | pinned: true, 20 | }, 21 | ]; 22 | 23 | const repoUrl = 'https://github.com/facebookincubator/bowler'; 24 | 25 | const siteConfig = { 26 | title: 'Bowler' /* title for your website */, 27 | tagline: 'Safe code refactoring for modern Python', 28 | url: 'https://pybowler.io' /* your website url */, 29 | cname: 'pybowler.io' /* custom gh-pages website domain */, 30 | baseUrl: '/' /* base url for your project */, 31 | // For github.io type URLs, you would set the url and baseUrl like: 32 | // url: 'https://facebook.github.io', 33 | // baseUrl: '/test-site/', 34 | 35 | // Used for publishing and more 36 | projectName: 'bowler', 37 | organizationName: 'facebookincubator', 38 | // For top-level user or org sites, the organization is still the same. 39 | // e.g., for the https://JoelMarcey.github.io site, it would be set like... 40 | // organizationName: 'JoelMarcey' 41 | 42 | // For no header links in the top nav bar -> headerLinks: [], 43 | headerLinks: [ 44 | {doc: 'basics-intro', label: 'Getting Started'}, 45 | {doc: 'api-query', label: 'API Reference'}, 46 | {doc: 'dev-roadmap', label: 'Roadmap'}, 47 | {doc: 'dev-intro', label: 'Contribute'}, 48 | {href: repoUrl, label: 'Github'}, 49 | // {blog: true, label: 'Blog'}, 50 | ], 51 | 52 | // If you have users set above, you add it here: 53 | users, 54 | 55 | /* path to images for header/footer */ 56 | headerIcon: 'img/logo/Bowler_Light.png', 57 | footerIcon: 'img/logo/Bowler_Light.svg', 58 | favicon: 'img/favicon/Oddy.png', 59 | 60 | /* colors for website */ 61 | colors: { 62 | primaryColor: '#383938', 63 | // primaryColor: '#3964a8', 64 | primaryColor: '#20232a', 65 | secondaryColor: '#3956a6', 66 | }, 67 | 68 | /* custom fonts for website */ 69 | fonts: { 70 | myFont: [ 71 | "-apple-system", 72 | "system-ui", 73 | "sans" 74 | ], 75 | myOtherFont: [ 76 | "-apple-system", 77 | "system-ui" 78 | ] 79 | }, 80 | 81 | // This copyright info is used in /core/Footer.js and blog rss/atom feeds. 82 | copyright: 83 | 'Copyright © ' + 84 | new Date().getFullYear() + 85 | ' Facebook Inc.', 86 | 87 | highlight: { 88 | // Highlight.js theme to use for syntax highlighting in code blocks 89 | theme: 'atom-one-dark', 90 | }, 91 | usePrism: ['bash', 'python'], 92 | 93 | // Add custom scripts here that would be placed in