├── .git-blame-ignore-revs ├── .git_archival.txt ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── _config.yml ├── pyproject.toml ├── removestar ├── __init__.py ├── __main__.py ├── helper.py ├── output.py ├── removestar.py └── version.pyi └── tests ├── __init__.py ├── test_removestar.py └── test_removestar_nb.py /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # activated pre-commit - https://github.com/asmeurer/removestar/pull/36 2 | aa3a9ba0bffb194670484ec6bdb35cc4ce645330 3 | # activated ruff format - https://github.com/asmeurer/removestar/pull/53 4 | 4c441bcc7916864dcfce7e4eecaa1f67b35cc90d 5 | -------------------------------------------------------------------------------- /.git_archival.txt: -------------------------------------------------------------------------------- 1 | node: 9b565f1ad427e31464b2ebc5bf104e68dacb6f42 2 | node-date: 2025-06-01T23:40:49+01:00 3 | describe-name: 1.5.2-9-g9b565f1 4 | ref-names: HEAD -> master 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .git_archival.txt export-subst 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Keep GitHub Actions up to date with GitHub's Dependabot... 2 | # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot 3 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem 4 | version: 2 5 | updates: 6 | - package-ecosystem: github-actions 7 | directory: / 8 | groups: 9 | github-actions: 10 | patterns: 11 | - "*" # Group all Actions updates into a single larger pull request 12 | schedule: 13 | interval: weekly 14 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: 7 | - published 8 | 9 | jobs: 10 | dist: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - uses: hynek/build-and-inspect-python-package@v2 18 | 19 | publish: 20 | needs: [dist] 21 | runs-on: ubuntu-latest 22 | if: github.event_name == 'release' && github.event.action == 'published' 23 | environment: 24 | name: pypi 25 | url: https://pypi.org/p/removestar 26 | permissions: 27 | id-token: write 28 | 29 | steps: 30 | - uses: actions/download-artifact@v4 31 | with: 32 | name: Packages 33 | path: dist 34 | 35 | - name: List distributions to be deployed 36 | run: ls -l dist/ 37 | 38 | - uses: pypa/gh-action-pypi-publish@release/v1 39 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | # Skip intermediate builds: always. 12 | # Cancel intermediate builds: only if it is a pull request build. 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} 15 | 16 | jobs: 17 | pre-commit: 18 | runs-on: ubuntu-latest 19 | name: Check SDist 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-python@v5 23 | with: 24 | python-version: 3.x 25 | - uses: pre-commit/action@v3.0.1 26 | 27 | build: 28 | runs-on: ubuntu-latest 29 | strategy: 30 | matrix: 31 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 32 | steps: 33 | - uses: actions/checkout@v4 34 | - name: Set up Python ${{ matrix.python-version }} 35 | uses: actions/setup-python@v5 36 | with: 37 | python-version: ${{ matrix.python-version }} 38 | allow-prereleases: true 39 | 40 | - name: Install removestar (lite) 41 | run: python -m pip install -e ".[dev]" 42 | 43 | - name: Test removestar (lite) 44 | run: pytest --cov=removestar . -vv 45 | 46 | - name: Install removestar (nb) 47 | run: python -m pip install -e ".[dev,nb]" 48 | 49 | - name: Test removestar (nb) 50 | run: pytest --cov=removestar . -vv 51 | 52 | - name: Upload coverage report 53 | uses: codecov/codecov-action@v3.1.4 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # ruff 107 | .ruff_cache/ 108 | 109 | # hatch-vcs 110 | removestar/_version.py 111 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_commit_msg: "chore: update pre-commit hooks" 3 | autofix_commit_msg: "style: pre-commit fixes" 4 | 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v5.0.0 8 | hooks: 9 | - id: check-added-large-files 10 | - id: check-case-conflict 11 | - id: check-merge-conflict 12 | - id: check-symlinks 13 | - id: check-yaml 14 | - id: debug-statements 15 | - id: end-of-file-fixer 16 | exclude: ^docs 17 | - id: mixed-line-ending 18 | - id: requirements-txt-fixer 19 | - id: trailing-whitespace 20 | 21 | - repo: https://github.com/astral-sh/ruff-pre-commit 22 | rev: "v0.8.0" 23 | hooks: 24 | - id: ruff 25 | args: ["--fix", "--show-fixes"] 26 | - id: ruff-format 27 | 28 | # TODO: add static types 29 | # - repo: https://github.com/pre-commit/mirrors-mypy 30 | # rev: v1.4.1 31 | # hooks: 32 | # - id: mypy 33 | # files: src 34 | # args: [] 35 | # additional_dependencies: 36 | # - numpy 37 | # - packaging 38 | 39 | - repo: https://github.com/codespell-project/codespell 40 | rev: v2.3.0 41 | hooks: 42 | - id: codespell 43 | 44 | - repo: https://github.com/pre-commit/mirrors-prettier 45 | rev: "v4.0.0-alpha.8" 46 | hooks: 47 | - id: prettier 48 | types_or: [yaml, markdown, html, css, scss, javascript, json] 49 | exclude: assets/js/webapp\.js 50 | 51 | - repo: https://github.com/asottile/blacken-docs 52 | rev: 1.19.1 53 | hooks: 54 | - id: blacken-docs 55 | args: ["-E"] 56 | additional_dependencies: [black==23.1.0] 57 | 58 | - repo: https://github.com/pre-commit/pygrep-hooks 59 | rev: v1.10.0 60 | hooks: 61 | - id: python-check-blanket-type-ignore 62 | exclude: ^src/vector/backends/_numba_object.py$ 63 | - id: rst-backticks 64 | - id: rst-directive-colons 65 | - id: rst-inline-touching-normal 66 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: removestar 2 | name: removestar 3 | description: "Automatically replace `import *` imports in Python files with explicit imports" 4 | entry: removestar 5 | language: python 6 | types_or: [python, pyi] 7 | args: [] 8 | require_serial: true 9 | additional_dependencies: ["pyflakes"] 10 | minimum_pre_commit_version: "2.9.2" 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.5.2 (2024-11-25) 2 | 3 | ## Features 4 | 5 | - Python 3.13 support 6 | 7 | ## Maintenance 8 | 9 | - Get rid of deprecated actions 10 | - Add dependabot config 11 | - pre-commit autoupdate 12 | 13 | # 1.5.1 (2024-08-14) 14 | 15 | ## Fixes 16 | 17 | - Make notebook support and dependencies optional 18 | 19 | ## Docs 20 | 21 | - Valid pre-commit tag in README example 22 | 23 | ## Maintenance 24 | 25 | - Pre commit updates 26 | - pre-commit autoupdate 27 | - Fix pytest config to run doctests 28 | 29 | # 1.5.0 (2023-09-18) 30 | 31 | ## Features 32 | 33 | - removestar can now be used to get rid of \* imports in jupyter 34 | notebooks (`.ipynb` files) 35 | 36 | ## Docs 37 | 38 | - Fix typos in `README.md` 39 | 40 | ## Maintenance 41 | 42 | - GitHub Actions: Add Python 3.12 release candidate to the testing 43 | - Ruff: Set upper limits on code complexity 44 | - Pre-commit autoupdate 45 | 46 | # 1.4.0 (2023-09-06) 47 | 48 | ## Features 49 | 50 | - removestar can now be used as a pre-commit hook. 51 | - removestar now outputs colored text. 52 | 53 | ## Bug fixes 54 | 55 | - Turn off verbose output for pre-commit hook. 56 | - Add git archive support for auto versioning. 57 | - Use utf-8 encoding in the command line interface. 58 | 59 | ## Maintenance 60 | 61 | - Use trusted publisher deployment for PyPI uploads. 62 | - Revamp the CI pipeline and create a CD pipeline. 63 | - Enable pre-commit for formatting and linting. 64 | - Migrate the build-backend to `hatch` and use `hatch-vcs` for versioning, 65 | getting rid of `setup.py`, `setup.cfg`, `MANIFEST.in`, `versioneer.py`, 66 | `conftest.py`, `pytest.ini`, and introducing `pyproject.toml`/ 67 | - Move the tests directory out of the removestar directory. 68 | - Ruff: Ignore a new pylint rule. 69 | - Upgrade linter from pyflakes to ruff. 70 | - Upgrade GitHub Actions. 71 | - Add `project_urls` to the metadata. 72 | 73 | # 1.3.1 (2021-09-02) 74 | 75 | ## Bug Fixes 76 | 77 | - Fix the line wrapping logic to always wrap import lines if they are greater 78 | than the max line length (previously it would not account for the last 79 | imported name in the line). 80 | 81 | # 1.3 (2021-08-24) 82 | 83 | ## New Features 84 | 85 | - Lines with star imports can now contain comments. 86 | - Star imports can be whitelisted using `# noqa` comments. 87 | - Replaced Travis CI with GitHub Actions. 88 | 89 | Thanks to [@h4l](https://github.com/h4l) for these improvements. 90 | 91 | # 1.2.4 (2021-08-16) 92 | 93 | ## Bug Fixes 94 | 95 | - Fix an incorrectly done release from 1.2.3. 96 | 97 | # 1.2.3 (2021-08-16) 98 | 99 | ## Bug Fixes 100 | 101 | - Fix unformatted module name placeholder in "Could not find the star imports" 102 | warning (thanks to [@h4l](https://github.com/h4l)). 103 | 104 | # 1.2.2 (2019-08-22) 105 | 106 | ## Bug Fixes 107 | 108 | - Names that are used more than once no longer produce duplicate imports. 109 | - Files are no longer read redundantly. 110 | - Files are no longer written into if the code does not change. 111 | - A blank line is no longer printed for empty diffs. 112 | 113 | # 1.2.1 (2019-08-17) 114 | 115 | ## Bug Fixes 116 | 117 | - Imports that are completely removed are no longer replaced with a blank line. 118 | 119 | # 1.2 (2019-08-16) 120 | 121 | ## New Features 122 | 123 | - removestar now works correctly with recursive star imports. In particular, 124 | `from .submod import *` now works when submod is a submodule whose 125 | `__init__.py` itself uses `import *` (removestar still skips `__init__.py` 126 | files by default). 127 | - `__all__` is now respected. 128 | - The full path to the file is printed for `--verbose` messages. 129 | - Catch all errors when importing external modules dynamically. 130 | - Better error message for same-module absolute imports that don't exist. 131 | 132 | ## Bug Fixes 133 | 134 | - Don't consider `__builtins__` to be imported from external modules (even 135 | though it technically is). 136 | - Make sure pytest-doctestplus is installed when running the tests. 137 | 138 | ## Other 139 | 140 | - Include the LICENSE file in the distribution and the setup.py metadata. 141 | 142 | # 1.1 (2019-08-05) 143 | 144 | ## New Features 145 | 146 | - Add `--verbose` and `--quiet` flags. `--verbose` prints about every name that an 147 | import is added for. `--quiet` hides all warning output. 148 | - Add support for absolute imports. Absolute imports from the same module are 149 | scanned statically, the same as relative imports. Absolute imports from 150 | external modules are imported dynamically to get the list of names. This can 151 | be disabled with the flag `--no-dynamic-importing`. 152 | - Add `--max-line-length` to control the line length at which imports are 153 | wrapped. The default is 100. It can be disabled with `remoevstar 154 | --max-line-length 0`. 155 | - No longer stop traversing a directory when encountering a file with invalid 156 | syntax. 157 | 158 | ## Bug Fixes 159 | 160 | - Fix logic for wrapping long imports 161 | - Fix the filename in some error messages. 162 | 163 | ## Other 164 | 165 | - Add tests. 166 | - Move all TODOs to the GitHub issue tracker. 167 | 168 | # 1.0.1 (2019-07-18) 169 | 170 | ## New Features 171 | 172 | - Automatically skip non-.py files 173 | - Automatically skip `__init__.py` 174 | - Add flag `--no-skip-init` to not skip `__init__.py` 175 | 176 | ## Bug Fixes 177 | 178 | - Fix directory recursion 179 | - Fix multiline import logic 180 | 181 | # 1.0 (2019-07-18) 182 | 183 | Initial release 184 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Aaron Meurer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # removestar 2 | 3 | [![Actions Status][actions-badge]][actions-link] 4 | [![PyPI version][pypi-version]][pypi-link] 5 | [![Anaconda-Server Badge][conda-version]][conda-link] 6 | [![PyPI platforms][pypi-platforms]][pypi-link] 7 | [![Downloads][pypi-downloads]][pypi-link] 8 | [![Conda Downloads][conda-downloads]][conda-link] 9 | [![Ruff][ruff-badge]][ruff-link] 10 | 11 | 16 | 17 | Tool to automatically replace `import *` imports in Python files with explicit imports 18 | 19 | ## Installation 20 | 21 | Install `removestar` globally to use it through CLI using `pypi` - 22 | 23 | ```bash 24 | pip install removestar 25 | pip install "removestar[nb]" # notebook support 26 | ``` 27 | 28 | or `conda` - 29 | 30 | ```bash 31 | conda install -c conda-forge removestar 32 | ``` 33 | 34 | or add `removestar` in `.pre-commit-config.yaml` - 35 | 36 | ```yaml 37 | - repo: https://github.com/asmeurer/removestar 38 | rev: "1.5" 39 | hooks: 40 | - id: removestar 41 | args: [-i] # See docs for all args (-i edits file in-place) 42 | additional_dependencies: # The libraries or packages your code imports 43 | - ... # Add . if running inside a library (to install the library itself in the environment) 44 | - ... # Add nbformat and nbconvert for notebook support 45 | ``` 46 | 47 | ## Usage 48 | 49 | ### pre-commit hook 50 | 51 | Once `removestar` is added in `.pre-commit-config.yaml`, executing the following 52 | will always run it (and other pre-commits) before every commit - 53 | 54 | ```bash 55 | pre-commit install 56 | ``` 57 | 58 | Optionally, the pre-commits (including `removestar`) can be manually triggered for 59 | all the files using - 60 | 61 | ```bash 62 | pre-commit run --all-files 63 | ``` 64 | 65 | ### CLI 66 | 67 | ```bash 68 | 69 | # scripts 70 | 71 | $ removestar file.py # Shows diff but does not edit file.py 72 | 73 | $ removestar -i file.py # Edits file.py in-place 74 | 75 | $ removestar -i module/ # Modifies every Python file in module/ recursively 76 | 77 | # notebooks (make sure nbformat and nbconvert are installed) 78 | 79 | $ removestar file.ipynb # Shows diff but does not edit file.ipynb 80 | 81 | $ removestar -i file.ipynb # Edits file.ipynb in-place 82 | ``` 83 | 84 | ## Why is `import *` so bad? 85 | 86 | Doing `from module import *` is generally frowned upon in Python. It is 87 | considered acceptable when working interactively at a `python` prompt, or in 88 | `__init__.py` files (removestar skips `__init__.py` files by default). 89 | 90 | Some reasons why `import *` is bad: 91 | 92 | - It hides which names are actually imported. 93 | - It is difficult both for human readers and static analyzers such as 94 | pyflakes to tell where a given name comes from when `import *` is used. For 95 | example, pyflakes cannot detect unused names (for instance, from typos) in 96 | the presence of `import *`. 97 | - If there are multiple `import *` statements, it may not be clear which names 98 | come from which module. In some cases, both modules may have a given name, 99 | but only the second import will end up being used. This can break people's 100 | intuition that the order of imports in a Python file generally does not 101 | matter. 102 | - `import *` often imports more names than you would expect. Unless the module 103 | you import defines `__all__` or carefully `del`s unused names at the module 104 | level, `import *` will import every public (doesn't start with an 105 | underscore) name defined in the module file. This can often include things 106 | like standard library imports or loop variables defined at the top-level of 107 | the file. For imports from modules (from `__init__.py`), `from module import 108 | *` will include every submodule defined in that module. Using `__all__` in 109 | modules and `__init__.py` files is also good practice, as these things are 110 | also often confusing even for interactive use where `import *` is 111 | acceptable. 112 | - In Python 3, `import *` is syntactically not allowed inside of a function. 113 | 114 | Here are some official Python references stating not to use `import *` in 115 | files: 116 | 117 | - [The official Python 118 | FAQ](https://docs.python.org/3/faq/programming.html?highlight=faq#what-are-the-best-practices-for-using-import-in-a-module): 119 | 120 | > In general, don’t use `from modulename import *`. Doing so clutters the 121 | > importer’s namespace, and makes it much harder for linters to detect 122 | > undefined names. 123 | 124 | - [PEP 8](https://www.python.org/dev/peps/pep-0008/#imports) (the official 125 | Python style guide): 126 | 127 | > Wildcard imports (`from import *`) should be avoided, as they 128 | > make it unclear which names are present in the namespace, confusing both 129 | > readers and many automated tools. 130 | 131 | Unfortunately, if you come across a file in the wild that uses `import *`, it 132 | can be hard to fix it, because you need to find every name in the file that is 133 | imported from the `*`. Removestar makes this easy by finding which names come 134 | from `*` imports and replacing the import lines in the file automatically. 135 | 136 | One exception where `import *` can be bneficial: 137 | At the early stages of code development, a definite set of required 138 | functions can be difficult to determine. In such a context, wildcard imports could be 139 | beneficial by avoiding constantly curating the set of required functions. For example, 140 | at the development stage usage of `from os.path import *` can save time from curating 141 | required functions. Post-development, the wld card imports could be determined using 142 | `removestar`. Having said that, because of the multiple said reasons, wildcard imports 143 | should be avoided. 144 | 145 | ## Example 146 | 147 | Suppose you have a module `mymod` like 148 | 149 | ```bash 150 | mymod/ 151 | | __init__.py 152 | | a.py 153 | | b.py 154 | ``` 155 | 156 | With 157 | 158 | ```py 159 | # mymod/a.py 160 | from .b import * 161 | 162 | 163 | def func(x): 164 | return x + y 165 | ``` 166 | 167 | ```py 168 | # mymod/b.py 169 | x = 1 170 | y = 2 171 | ``` 172 | 173 | Then `removestar` works like: 174 | 175 | ```bash 176 | $ removestar mymod/ 177 | 178 | --- original/mymod/a.py 179 | +++ fixed/mymod/a.py 180 | @@ -1,5 +1,5 @@ 181 | # mymod/a.py 182 | -from .b import * 183 | +from .b import y 184 | 185 | def func(x): 186 | return x + y 187 | 188 | ``` 189 | 190 | This does not edit `a.py` by default. The `-i` flag causes it to edit `a.py` in-place: 191 | 192 | ```bash 193 | $ removestar -i mymod/ 194 | $ cat mymod/a.py 195 | # mymod/a.py 196 | from .b import y 197 | 198 | def func(x): 199 | return x + y 200 | ``` 201 | 202 | ## Command line options 203 | 204 | 205 | 206 | ```bash 207 | $ removestar --help 208 | usage: removestar [-h] [-i] [--version] [--no-skip-init] 209 | [--no-dynamic-importing] [-v] [-q] 210 | [--max-line-length MAX_LINE_LENGTH] 211 | PATH [PATH ...] 212 | 213 | Tool to automatically replace "import *" imports with explicit imports 214 | 215 | Requires pyflakes. 216 | 217 | Usage: 218 | 219 | $ removestar file.py # Shows diff but does not edit file.py 220 | 221 | $ removestar -i file.py # Edits file.py in-place 222 | 223 | $ removestar -i module/ # Modifies every Python file in module/ recursively 224 | 225 | positional arguments: 226 | PATH Files or directories to fix 227 | 228 | optional arguments: 229 | -h, --help show this help message and exit 230 | -i, --in-place Edit the files in-place. (default: False) 231 | --version Show removestar version number and exit. 232 | --no-skip-init Don't skip __init__.py files (they are skipped by 233 | default) (default: True) 234 | --no-dynamic-importing 235 | Don't dynamically import modules to determine the list 236 | of names. This is required for star imports from 237 | external modules and modules in the standard library. 238 | (default: True) 239 | -v, --verbose Print information about every imported name that is 240 | replaced. (default: False) 241 | -q, --quiet Don't print any warning messages. (default: False) 242 | --max-line-length MAX_LINE_LENGTH 243 | The maximum line length for replaced imports before 244 | they are wrapped. Set to 0 to disable line wrapping. 245 | (default: 100) 246 | ``` 247 | 248 | ## Whitelisting star imports 249 | 250 | `removestar` does not replace star import lines that are marked with 251 | [Flake8 `noqa` comments][noqa-comments] that permit star imports (`F401` or 252 | `F403`). 253 | 254 | [noqa-comments]: https://flake8.pycqa.org/en/3.1.1/user/ignoring-errors.html#in-line-ignoring-errors 255 | 256 | For example, the star imports in this module would be kept: 257 | 258 | ```py 259 | from os import * # noqa: F401 260 | from .b import * # noqa 261 | 262 | 263 | def func(x): 264 | return x + y 265 | ``` 266 | 267 | ## Current limitations 268 | 269 | - Assumes only names in the current file are used by star imports (e.g., it 270 | won't work to replace star imports in `__init__.py`). 271 | 272 | - For files within the same module, removestar determines missing imported names 273 | statically. For external library imports, including imports of standard 274 | library modules, it dynamically imports the module to determine the names. 275 | This can be disabled with the `--no-dynamic-importing` flag. 276 | 277 | ## Contributing 278 | 279 | See the [issue tracker](https://github.com/asmeurer/removestar/issues). Pull 280 | requests are welcome. 281 | 282 | ## Changelog 283 | 284 | See the [CHANGELOG](CHANGELOG.md) file. 285 | 286 | ## License 287 | 288 | [MIT](LICENSE) 289 | 290 | [actions-badge]: https://github.com/asmeurer/removestar/workflows/CI/badge.svg 291 | [actions-link]: https://github.com/asmeurer/removestar/actions 292 | [codecov-badge]: https://codecov.io/gh/asmeurer/removestar/branch/main/graph/badge.svg?token=YBv60ueORQ 293 | [codecov-link]: https://codecov.io/gh/asmeurer/removestar 294 | [conda-downloads]: https://img.shields.io/conda/dn/conda-forge/removestar?color=green 295 | [conda-link]: https://anaconda.org/conda-forge/removestar 296 | [conda-version]: https://anaconda.org/conda-forge/removestar/badges/version.svg 297 | [github-discussions-badge]: https://img.shields.io/static/v1?label=Discussions&message=Ask&color=blue&logo=github 298 | [github-discussions-link]: https://github.com/asmeurer/removestar/discussions 299 | [license-badge]: https://img.shields.io/badge/MIT-blue.svg 300 | [license-link]: https://opensource.org/licenses/MIT 301 | [pypi-downloads]: https://static.pepy.tech/badge/removestar 302 | [pre-commit-badge]: https://results.pre-commit.ci/badge/github/asmeurer/removestar/develop.svg 303 | [pre-commit-link]: https://results.pre-commit.ci/repo/github/asmeurer/removestar 304 | [pypi-link]: https://pypi.org/project/removestar/ 305 | [pypi-platforms]: https://img.shields.io/pypi/pyversions/removestar 306 | [pypi-version]: https://img.shields.io/pypi/v/removestar?color=blue 307 | [ruff-badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json 308 | [ruff-link]: https://github.com/astral-sh/ruff 309 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-merlot 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-vcs"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "removestar" 7 | readme = { file = "README.md", content-type = "text/markdown" } 8 | dynamic = ["version"] 9 | description = "A tool to automatically replace 'import *' imports with explicit imports in files" 10 | license = "MIT" 11 | requires-python = ">=3.8" 12 | authors = [ 13 | { name = "Aaron Meurer", email = "asmeurer@gmail.com" }, 14 | ] 15 | maintainers = [ 16 | { name = "Aaron Meurer", email = "asmeurer@gmail.com" }, 17 | { name = "Saransh Chopra", email = "saransh0701@gmail.com" } 18 | ] 19 | classifiers = [ 20 | "Development Status :: 5 - Production/Stable", 21 | "License :: OSI Approved :: MIT License", 22 | "Operating System :: OS Independent", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3 :: Only", 25 | "Programming Language :: Python :: 3.8", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "Programming Language :: Python :: 3.13", 31 | ] 32 | dependencies = [ 33 | "pyflakes", 34 | ] 35 | 36 | [project.optional-dependencies] 37 | nb = [ 38 | "nbformat", 39 | "nbconvert", 40 | ] 41 | dev = [ 42 | "pytest>=6", 43 | "pytest-cov>=3", 44 | "pytest-doctestplus" 45 | ] 46 | 47 | 48 | [project.scripts] 49 | removestar = "removestar.__main__:main" 50 | 51 | [project.urls] 52 | "Bug Tracker" = "https://github.com/asmeurer/removestar/issues" 53 | Homepage = "https://www.asmeurer.com/removestar/" 54 | "Source Code" = "https://github.com/asmeurer/removestar" 55 | 56 | [tool.hatch] 57 | version.source = "vcs" 58 | build.hooks.vcs.version-file = "removestar/_version.py" 59 | 60 | [tool.hatch.build.targets.sdist] 61 | include = [ 62 | "/removestar", 63 | ] 64 | 65 | [tool.ruff] 66 | line-length = 100 # Default is 88 67 | 68 | [tool.ruff.lint] 69 | extend-select = [ 70 | "E", "F", "W", # flake8 71 | "ASYNC", # flake8-async 72 | "B", # flake8-bugbear 73 | "C4", # flake8-comprehensions 74 | "C9", # mccabe cyclomatic complexity 75 | "I", # isort 76 | "ISC", # flake8-implicit-str-concat 77 | "PERF", # flake8-perf 78 | "PGH", # pygrep-hooks 79 | "PIE", # flake8-pie 80 | "PL", # pylint 81 | "PT", # flake8-pytest-style 82 | "RUF", # Ruff-specific 83 | "SIM", # flake8-simplify 84 | "T20", # flake8-print 85 | "UP", # pyupgrade 86 | "YTT", # flake8-2020 87 | ] 88 | ignore = [ 89 | "B023", # Function definition does not bind loop variable 90 | "T201", # Print statements 91 | "ISC001", # Conflicts with ruff format 92 | ] 93 | unfixable = [ 94 | "F841", # Removes unused variables 95 | "T20", # Removes print statements 96 | ] 97 | 98 | [tool.ruff.lint.mccabe] 99 | max-complexity = 14 # Default is 10 100 | 101 | [tool.ruff.lint.pylint] 102 | max-args = 6 # Default is 5 103 | max-statements = 177 # Default is 50 104 | 105 | [tool.pytest.ini_options] 106 | minversion = "6.0" 107 | xfail_strict = true 108 | addopts = [ 109 | "--doctest-modules" 110 | ] 111 | testpaths = [ 112 | "removestar", 113 | "tests", 114 | ] 115 | log_cli_level = "DEBUG" 116 | filterwarnings = [ 117 | "error", 118 | "ignore::DeprecationWarning", 119 | "ignore::UserWarning", 120 | ] 121 | -------------------------------------------------------------------------------- /removestar/__init__.py: -------------------------------------------------------------------------------- 1 | from ._version import __version__ # noqa: F401 2 | -------------------------------------------------------------------------------- /removestar/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Tool to automatically replace "import *" imports with explicit imports 4 | 5 | Requires pyflakes. 6 | 7 | Usage: 8 | 9 | $ removestar file.py # Shows diff but does not edit file.py 10 | 11 | $ removestar -i file.py # Edits file.py in-place 12 | 13 | $ removestar -i module/ # Modifies every Python file in module/ recursively 14 | 15 | """ 16 | 17 | import argparse 18 | import glob 19 | import importlib.util 20 | import io 21 | import os 22 | import sys 23 | import tempfile 24 | 25 | from . import __version__ 26 | from .helper import get_diff_text 27 | from .output import get_colored_diff, red 28 | from .removestar import fix_code 29 | 30 | 31 | class RawDescriptionHelpArgumentDefaultsHelpFormatter( 32 | argparse.RawDescriptionHelpFormatter, argparse.ArgumentDefaultsHelpFormatter 33 | ): 34 | pass 35 | 36 | 37 | def main(): # noqa: PLR0912, C901 38 | parser = argparse.ArgumentParser( 39 | description=__doc__, 40 | prog="removestar", 41 | formatter_class=RawDescriptionHelpArgumentDefaultsHelpFormatter, 42 | ) 43 | parser.add_argument("paths", nargs="*", help="Files or directories to fix", metavar="PATH") 44 | parser.add_argument("-i", "--in-place", action="store_true", help="Edit the files in-place.") 45 | parser.add_argument( 46 | "--version", 47 | action="version", 48 | version="%(prog)s " + __version__, 49 | help="Show removestar version number and exit.", 50 | ) 51 | parser.add_argument( 52 | "--no-skip-init", 53 | action="store_false", 54 | dest="skip_init", 55 | help="Don't skip __init__.py files (they are skipped by default)", 56 | ) 57 | parser.add_argument( 58 | "--no-dynamic-importing", 59 | action="store_false", 60 | dest="allow_dynamic", 61 | help="""Don't dynamically import modules to determine the list of names. This is required for star imports from external modules and modules in the standard library.""", # noqa: E501 62 | ) 63 | parser.add_argument( 64 | "-v", 65 | "--verbose", 66 | action="store_true", 67 | help="""Print information about every imported name that is replaced.""", 68 | ) 69 | parser.add_argument( 70 | "-q", 71 | "--quiet", 72 | action="store_true", 73 | help="""Don't print any warning messages.""", 74 | ) 75 | parser.add_argument( 76 | "--max-line-length", 77 | type=int, 78 | default=100, 79 | help="""The maximum line length for replaced imports before they are wrapped. Set to 0 to disable line wrapping.""", # noqa: E501 80 | ) 81 | # For testing 82 | parser.add_argument("--_this-file", action="store_true", help=argparse.SUPPRESS) 83 | 84 | args = parser.parse_args() 85 | 86 | if args._this_file: 87 | print(__file__, end="") 88 | return 89 | 90 | if args.max_line_length == 0: 91 | args.max_line_length = float("inf") 92 | 93 | try: 94 | import nbformat 95 | from nbconvert import PythonExporter 96 | 97 | from .removestar import replace_in_nb 98 | except ImportError: 99 | pass 100 | 101 | exit_1 = False 102 | for file in _iter_paths(args.paths): 103 | _, filename = os.path.split(file) 104 | if args.skip_init and filename == "__init__.py": 105 | continue 106 | 107 | if not os.path.isfile(file): 108 | print(red(f"Error: {file}: no such file or directory"), file=sys.stderr) 109 | continue 110 | if file.endswith(".py"): 111 | with open(file, encoding="utf-8") as f: 112 | code = f.read() 113 | 114 | try: 115 | new_code = fix_code( 116 | code, 117 | file=file, 118 | max_line_length=args.max_line_length, 119 | verbose=args.verbose, 120 | quiet=args.quiet, 121 | allow_dynamic=args.allow_dynamic, 122 | ) 123 | except (RuntimeError, NotImplementedError) as e: 124 | if not args.quiet: 125 | print(red(f"Error with {file}: {e}"), file=sys.stderr) 126 | continue 127 | 128 | if new_code != code: 129 | exit_1 = True 130 | if args.in_place: 131 | with open(file, "w", encoding="utf-8") as f: 132 | f.write(new_code) 133 | if not args.quiet: 134 | print( 135 | get_colored_diff( 136 | get_diff_text( 137 | io.StringIO(code).readlines(), 138 | io.StringIO(new_code).readlines(), 139 | file, 140 | ) 141 | ) 142 | ) 143 | else: 144 | print( 145 | get_colored_diff( 146 | get_diff_text( 147 | io.StringIO(code).readlines(), 148 | io.StringIO(new_code).readlines(), 149 | file, 150 | ) 151 | ) 152 | ) 153 | elif ( 154 | file.endswith(".ipynb") 155 | and importlib.util.find_spec("nbconvert") is not None 156 | and importlib.util.find_spec("nbformat") is not None 157 | ): 158 | tmp_file = tempfile.NamedTemporaryFile() # noqa: SIM115 159 | tmp_path = tmp_file.name 160 | 161 | with open(file) as f: 162 | nb = nbformat.reads(f.read(), nbformat.NO_CONVERT) 163 | 164 | ## save as py 165 | exporter = PythonExporter() 166 | code, _ = exporter.from_notebook_node(nb) 167 | tmp_file.write(code.encode("utf-8")) 168 | 169 | try: 170 | new_code = fix_code( 171 | code=code, 172 | file=tmp_path, 173 | max_line_length=args.max_line_length, 174 | verbose=args.verbose, 175 | quiet=args.quiet, 176 | allow_dynamic=args.allow_dynamic, 177 | return_replacements=True, 178 | ) 179 | new_code_not_dict = fix_code( 180 | code=code, 181 | file=tmp_path, 182 | max_line_length=args.max_line_length, 183 | verbose=False, 184 | quiet=True, 185 | allow_dynamic=args.allow_dynamic, 186 | return_replacements=False, 187 | ) 188 | except (RuntimeError, NotImplementedError) as e: 189 | if not args.quiet: 190 | print(red(f"Error with {file}: {e}"), file=sys.stderr) 191 | continue 192 | 193 | tmp_file.close() 194 | 195 | if new_code_not_dict != code: 196 | exit_1 = True 197 | if args.in_place: 198 | with open(file) as f: 199 | nb = nbformat.reads(f.read(), nbformat.NO_CONVERT) 200 | fixed_code = replace_in_nb( 201 | nb, 202 | new_code, 203 | cell_type="code", 204 | ) 205 | 206 | with open(file, "w+") as f: 207 | f.writelines(fixed_code) 208 | 209 | if not args.quiet: 210 | print( 211 | get_colored_diff( 212 | get_diff_text( 213 | io.StringIO(code).readlines(), 214 | io.StringIO(new_code_not_dict).readlines(), 215 | file, 216 | ) 217 | ) 218 | ) 219 | else: 220 | print( 221 | get_colored_diff( 222 | get_diff_text( 223 | io.StringIO(code).readlines(), 224 | io.StringIO(new_code_not_dict).readlines(), 225 | file, 226 | ) 227 | ) 228 | ) 229 | 230 | if exit_1: 231 | sys.exit(1) 232 | 233 | 234 | def _iter_paths(paths): 235 | for path in paths: 236 | if os.path.isdir(path): 237 | for file in glob.iglob(path + "/**", recursive=True): 238 | if not file.endswith(".py") and not file.endswith(".ipynb"): 239 | continue 240 | yield file 241 | else: 242 | yield path 243 | 244 | 245 | if __name__ == "__main__": 246 | main() 247 | -------------------------------------------------------------------------------- /removestar/helper.py: -------------------------------------------------------------------------------- 1 | import difflib 2 | 3 | 4 | def get_diff_text(old, new, filename): 5 | # Taken from https://github.com/myint/autoflake/blob/master/autoflake.py 6 | # Copyright (C) 2012-2018 Steven Myint 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining 9 | # a copy of this software and associated documentation files (the 10 | # "Software"), to deal in the Software without restriction, including 11 | # without limitation the rights to use, copy, modify, merge, publish, 12 | # distribute, sublicense, and/or sell copies of the Software, and to 13 | # permit persons to whom the Software is furnished to do so, subject to 14 | # the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included 17 | # in all copies or substantial portions of the Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 20 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 21 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 22 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 23 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 24 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 25 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 26 | """Return text of unified diff between old and new.""" 27 | newline = "\n" 28 | diff = difflib.unified_diff( 29 | old, new, "original/" + filename, "fixed/" + filename, lineterm=newline 30 | ) 31 | 32 | text = "" 33 | for line in diff: 34 | text += line 35 | 36 | # Work around missing newline (http://bugs.python.org/issue2142). 37 | if not line.endswith(newline): 38 | text += newline + r"\ No newline at end of file" + newline 39 | 40 | return text 41 | -------------------------------------------------------------------------------- /removestar/output.py: -------------------------------------------------------------------------------- 1 | def bold(line): 2 | return "\033[1m" + line + "\033[0m" # bold, reset 3 | 4 | 5 | def red(line): 6 | return "\033[31m" + line + "\033[0m" # red, reset 7 | 8 | 9 | def yellow(line): 10 | return "\033[33m" + line + "\033[0m" # yellow, reset 11 | 12 | 13 | def cyan(line): 14 | return "\033[36m" + line + "\033[0m" # cyan, reset 15 | 16 | 17 | def green(line): 18 | return "\033[32m" + line + "\033[0m" # green, reset 19 | 20 | 21 | def get_colored_diff(contents): 22 | """Inject the ANSI color codes to the diff.""" 23 | # taken from https://github.com/psf/black/blob/main/src/black/output.py 24 | # Copyright (c) 2018 Łukasz Langa 25 | 26 | # Permission is hereby granted, free of charge, to any person obtaining a copy 27 | # of this software and associated documentation files (the "Software"), to deal 28 | # in the Software without restriction, including without limitation the rights 29 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 30 | # copies of the Software, and to permit persons to whom the Software is 31 | # furnished to do so, subject to the following conditions: 32 | 33 | # The above copyright notice and this permission notice shall be included in all 34 | # copies or substantial portions of the Software. 35 | 36 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 37 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 38 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 39 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 40 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 41 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 42 | # SOFTWARE. 43 | lines = contents.split("\n") 44 | for i, line in enumerate(lines): 45 | if line.startswith(("+++", "---")): 46 | line = bold(line) # bold, reset # noqa: PLW2901 47 | elif line.startswith("@@"): 48 | line = cyan(line) # cyan, reset # noqa: PLW2901 49 | elif line.startswith("+"): 50 | line = green(line) # green, reset # noqa: PLW2901 51 | elif line.startswith("-"): 52 | line = red(line) # red, reset # noqa: PLW2901 53 | lines[i] = line 54 | return "\n".join(lines) 55 | -------------------------------------------------------------------------------- /removestar/removestar.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import builtins 3 | import contextlib 4 | import os 5 | import re 6 | import sys 7 | from functools import lru_cache 8 | from pathlib import Path 9 | 10 | from pyflakes.checker import _MAGIC_GLOBALS, Checker, ModuleScope 11 | from pyflakes.messages import ImportStarUsage, ImportStarUsed 12 | 13 | with contextlib.suppress(ImportError): 14 | from nbconvert import NotebookExporter 15 | 16 | from .output import green, yellow 17 | 18 | # quit and exit are not included in old versions of pyflakes 19 | MAGIC_GLOBALS = set(_MAGIC_GLOBALS).union({"quit", "exit"}) 20 | 21 | 22 | def names_to_replace(checker): 23 | names = set() 24 | for message in checker.messages: 25 | if isinstance(message, ImportStarUsage): 26 | name, *modules = message.message_args 27 | names.add(name) 28 | return names 29 | 30 | 31 | def star_imports(checker): 32 | return [ 33 | message.message_args[0] 34 | for message in checker.messages 35 | if isinstance(message, ImportStarUsed) 36 | ] 37 | 38 | 39 | def fix_code( 40 | code, 41 | *, 42 | file, 43 | max_line_length=100, 44 | verbose=False, 45 | quiet=False, 46 | allow_dynamic=True, 47 | **kws_replace_imports, 48 | ): 49 | """ 50 | Return a fixed version of the code `code` from the file `file` 51 | 52 | Raises RuntimeError if it is is not valid Python. 53 | 54 | See the docstring of replace_imports() for the meaning of the keyword 55 | arguments to this function. 56 | 57 | If allow_dynamic=True, then external modules will be dynamically imported. 58 | """ 59 | directory, filename = os.path.split(file) 60 | 61 | try: 62 | tree = ast.parse(code, filename=file) 63 | except SyntaxError as e: 64 | raise RuntimeError(f"SyntaxError: {e}") from e 65 | 66 | checker = Checker(tree) 67 | 68 | stars = star_imports(checker) 69 | names = names_to_replace(checker) 70 | 71 | mod_names = {} 72 | for mod in stars: 73 | mod_names[mod] = get_module_names(mod, directory, allow_dynamic=allow_dynamic) 74 | 75 | repls = {i: [] for i in stars} 76 | for name in names: 77 | mods = [mod for mod in mod_names if name in mod_names[mod]] 78 | if not mods: 79 | if not quiet: 80 | print( 81 | yellow(f"Warning: {file}: could not find import for '{name}'"), 82 | file=sys.stderr, 83 | ) 84 | continue 85 | if len(mods) > 1 and not quiet: 86 | print( 87 | yellow( 88 | f"Warning: {file}: '{name}' comes from multiple modules: {', '.join(map(repr, mods))}. Using '{mods[-1]}'." # noqa: E501 89 | ), 90 | file=sys.stderr, 91 | ) 92 | 93 | repls[mods[-1]].append(name) 94 | 95 | new_code = replace_imports( 96 | code, 97 | repls, 98 | file=file, 99 | verbose=verbose, 100 | quiet=quiet, 101 | max_line_length=max_line_length, 102 | **kws_replace_imports, 103 | ) 104 | 105 | return new_code 106 | 107 | 108 | def replace_imports( # noqa: C901,PLR0913 109 | code, 110 | repls, 111 | *, 112 | max_line_length=100, 113 | file=None, 114 | verbose=False, 115 | quiet=False, 116 | return_replacements=False, 117 | ): 118 | """ 119 | Replace the star imports in code 120 | 121 | repls should be a dictionary mapping module names to a list of names to be 122 | imported. 123 | 124 | max_line_length (default: 100) is the maximum number of characters for a 125 | line. Added imports that are longer than this are wrapped. Set to 126 | float('inf') to disable wrapping. Note that only the names being imported 127 | are line wrapped. If the "from module import" part of the import is longer 128 | than the max_line_length, it is not line wrapped. 129 | 130 | If file is provided it is only used for the verbose messages. 131 | 132 | If verbose=True (default: True), a message is printed for each import that is replaced. 133 | 134 | If quiet=True (default: False), a warning is printed if no replacements 135 | are made. The quiet flag does not affect the messages from verbose=True. 136 | 137 | Example: 138 | 139 | >>> code = ''' 140 | ... from mod import * 141 | ... print(a + b) 142 | ... ''' 143 | >>> repls = {'mod': ['a', 'b']} 144 | >>> print(replace_imports(code, repls, verbose=False)) 145 | from mod import a, b 146 | print(a + b) 147 | >>> code = ''' 148 | ... from .module.submodule import * 149 | ... ''' 150 | >>> repls = {'.module.submodule': ['name1', 'name2', 'name3']} 151 | >>> print(replace_imports(code, repls, max_line_length=40, verbose=False)) 152 | from .module.submodule import (name1, name2, 153 | name3) 154 | 155 | """ 156 | warning_prefix = f"Warning: {file}: " if file else "Warning: " 157 | verbose_prefix = f"{file}: " if file else "" 158 | 159 | if return_replacements: 160 | repls_strings = {} 161 | for mod in repls: 162 | names = sorted(repls[mod]) 163 | 164 | if not names: 165 | new_import = "" 166 | else: 167 | new_import = f"from {mod} import " + ", ".join(names) 168 | if len(new_import) > max_line_length: 169 | lines = [] 170 | line = f"from {mod} import (" 171 | indent = " " * len(line) 172 | for name in names: 173 | if len(line + name + ",") > max_line_length and line[-1] != "(": 174 | lines.append(line.rstrip()) 175 | line = indent 176 | line += name + ", " 177 | lines.append(line[:-2] + ")") # Remove last trailing comma 178 | new_import = "\n".join(lines) 179 | 180 | def star_import_replacement(match, verbose=verbose, quiet=quiet): 181 | original_import, after_import, comment = match.group(0, 1, 2) 182 | if comment and is_noqa_comment_allowing_star_import(comment): 183 | if verbose: 184 | print( 185 | green( 186 | f"{verbose_prefix}Retaining 'from {mod} import *' due to noqa comment" 187 | ), 188 | file=sys.stderr, 189 | ) 190 | return original_import 191 | 192 | if verbose: 193 | print( 194 | green( 195 | f"{verbose_prefix}Replacing 'from {mod} import *' with '{new_import.strip()}'" # noqa: E501 196 | ), 197 | file=sys.stderr, 198 | ) 199 | 200 | if not new_import and comment: 201 | if not quiet: 202 | print( 203 | yellow( 204 | f"{warning_prefix}The removed star import statement for '{mod}' " 205 | f"had an inline comment which may not make sense without the import" 206 | ), 207 | file=sys.stderr, 208 | ) 209 | return f"{comment}\n" 210 | if not (new_import or after_import): 211 | return "" 212 | return f'{new_import}{after_import or ""}\n' 213 | 214 | star_import = re.compile(rf"from +{re.escape(mod)} +import +\*( *(#.*))?\n") 215 | new_code, subs_made = star_import.subn(star_import_replacement, code) 216 | if subs_made == 0 and not quiet: 217 | print( 218 | yellow(f"{warning_prefix}Could not find the star imports for '{mod}'"), 219 | file=sys.stderr, 220 | ) 221 | 222 | if return_replacements: 223 | for match in star_import.finditer(code): 224 | repls_strings[f"from {mod} import *"] = star_import_replacement( 225 | match, 226 | verbose=False, 227 | quiet=True, 228 | ).strip() 229 | break 230 | 231 | code = new_code 232 | 233 | return repls_strings if return_replacements else code 234 | 235 | 236 | # This regex is based on Flake8's noqa regex: 237 | # https://github.com/PyCQA/flake8/blob/9815f4/src/flake8/defaults.py#L27 238 | # Our version is tweaked to prevent malformed comments being interpreted as bare 239 | # "noqa" comments (ignore everything). The original version has strict 240 | # requirements for spaces, while also allowing anything to follow a bare 241 | # "# noqa" comment, which can result in unintuitive behaviour. 242 | # 243 | # The Flake8 version treats these as bare noqa comments, silencing all warnings 244 | # instead of just E2: 245 | # 246 | # "# E2" (colon is missing) 247 | # "# " (two spaces after colon) 248 | # "# noqa:\tE2" (tab instead of space after colon) 249 | INLINE_NOQA_COMMENT_PATTERN = re.compile( 250 | r""" 251 | ^[#][ \t]* noqa 252 | (?::[ \t]* 253 | (?P 254 | (?:[A-Z]+[0-9]+ (?:[, \t]+)?)+ 255 | ) 256 | )? 257 | [ \t]*$ 258 | """, 259 | flags=re.IGNORECASE | re.VERBOSE, 260 | ) 261 | NOQA_STAR_IMPORT_CODES = frozenset(["F401", "F403"]) 262 | 263 | 264 | def is_noqa_comment_allowing_star_import(comment): 265 | """ 266 | Check if a comment string is a Flake8 noqa comment that permits star imports 267 | 268 | The codes F401 and F403 are taken to permit star imports, as is a noqa 269 | comment without codes. 270 | 271 | Example: 272 | 273 | >>> is_noqa_comment_allowing_star_import('# noqa') 274 | True 275 | >>> is_noqa_comment_allowing_star_import('# noqa: FOO12,F403,BAR12') 276 | True 277 | >>> is_noqa_comment_allowing_star_import('# generic comment') 278 | False 279 | """ 280 | match = INLINE_NOQA_COMMENT_PATTERN.match(comment) 281 | return bool( 282 | match 283 | and ( 284 | match.group("codes") is None 285 | or any( 286 | code.upper() in NOQA_STAR_IMPORT_CODES 287 | for code in re.split(r"[, \t]+", match.group("codes")) 288 | ) 289 | ) 290 | ) 291 | 292 | 293 | class ExternalModuleError(Exception): 294 | pass 295 | 296 | 297 | def get_mod_filename(mod, directory): 298 | """ 299 | Get the filename for `mod` relative to a file in `directory`. 300 | """ 301 | # TODO: Use the import machinery to do this. 302 | directory = Path(directory) 303 | 304 | dots = re.compile(r"(\.+)(.*)") 305 | m = dots.match(mod) 306 | if m: 307 | # Relative import 308 | loc = directory.joinpath(*[".."] * (len(m.group(1)) - 1), *m.group(2).split(".")) 309 | filename = Path(str(loc) + ".py") 310 | if not filename.is_file(): 311 | filename = loc / "__init__.py" 312 | if not filename.is_file(): 313 | raise RuntimeError(f"Could not find the file for the module '{mod}'") 314 | else: 315 | top, *rest = mod.split(".") 316 | 317 | # Try to find an absolute import from the same module as the file 318 | head, tail = directory.parent, directory.name 319 | same_module = False 320 | while True: 321 | # If directory is relative assume we 322 | # don't need to go higher than . 323 | if tail == top: 324 | loc = os.path.join(head, tail, *rest) 325 | if os.path.isfile(loc + ".py"): 326 | filename = loc + ".py" 327 | break 328 | elif os.path.isfile(os.path.join(loc, "__init__.py")): 329 | filename = os.path.join(loc, "__init__.py") 330 | break 331 | else: 332 | same_module = True 333 | if head in [Path("."), Path("/")]: 334 | if same_module: 335 | raise RuntimeError(f"Could not find the file for the module '{mod}'") 336 | raise ExternalModuleError 337 | head, tail = head.parent, head.name 338 | 339 | return filename 340 | 341 | 342 | @lru_cache 343 | def get_module_names(mod, directory, *, allow_dynamic=True, _found=()): 344 | """ 345 | Get the names defined in the module 'mod' 346 | 347 | 'directory' should be the directory where the file with the import is. 348 | This is only used for static import determination. 349 | 350 | If allow_dynamic=True, then external module names are found by importing 351 | the module directly. 352 | """ 353 | try: 354 | names = get_names_from_dir(mod, directory, allow_dynamic=allow_dynamic, _found=_found) 355 | except ExternalModuleError as e: 356 | if allow_dynamic: 357 | names = get_names_dynamically(mod) 358 | else: 359 | raise NotImplementedError( 360 | "Static determination of external module imports is not supported." 361 | ) from e 362 | return names 363 | 364 | 365 | def get_names_dynamically(mod): 366 | d = {} 367 | try: 368 | exec(f"from {mod} import *", d) 369 | except ImportError as import_e: 370 | raise RuntimeError(f"Could not import {mod}") from import_e 371 | except Exception as e: 372 | raise RuntimeError(f"Error importing {mod}: {e}") from e 373 | return d.keys() - set(MAGIC_GLOBALS) 374 | 375 | 376 | def get_names_from_dir(mod, directory, *, allow_dynamic=True, _found=()): 377 | filename = Path(get_mod_filename(mod, directory)) 378 | 379 | with open(filename) as f: 380 | code = f.read() 381 | 382 | try: 383 | names = get_names(code, filename) 384 | except SyntaxError as e: 385 | raise RuntimeError(f"Could not parse {filename}: {e}") from e 386 | except RuntimeError as runtime_e: 387 | raise RuntimeError(f"Could not parse the names from {filename}") from runtime_e 388 | 389 | for name in names.copy(): 390 | if name.endswith(".*"): 391 | names.remove(name) 392 | rec_mod = name[:-2] 393 | if rec_mod not in _found: 394 | _found += (rec_mod,) 395 | names = names.union( 396 | get_module_names( 397 | rec_mod, 398 | filename.parent, 399 | allow_dynamic=allow_dynamic, 400 | _found=_found, 401 | ) 402 | ) 403 | return names 404 | 405 | 406 | def get_names(code, filename=""): 407 | # TODO: Make the doctests work 408 | """ 409 | Get a set of defined top-level names from code. 410 | 411 | Example: 412 | 413 | >>> get_names(''' 414 | ... import mod 415 | ... a = 1 416 | ... def func(): 417 | ... b = 2 418 | ... ''') # doctest: +SKIP 419 | {'a', 'func', 'mod'} 420 | 421 | Star imports in code are returned like 422 | 423 | >>> get_names(''' 424 | ... from .mod1 import * 425 | ... from module.mod2 import * 426 | ... ''') # doctest: +SKIP 427 | {'.mod1.*', 'module.mod2.*'} 428 | 429 | __all__ is respected. Constructs supported by pyflakes like __all__ += [...] work. 430 | 431 | >>> get_names(''' 432 | ... a = 1 433 | ... b = 2 434 | ... c = 3 435 | ... __all__ = ['a'] 436 | ... __all__ += ['b'] 437 | ... ''') # doctest: +SKIP 438 | {'a', 'b'} 439 | 440 | Returns a set of names, or raises SyntaxError if the code is not valid 441 | syntax. 442 | """ 443 | tree = ast.parse(code, filename=filename) 444 | 445 | checker = Checker(tree) 446 | for scope in checker.deadScopes: 447 | if isinstance(scope, ModuleScope): 448 | names = scope.keys() - set(dir(builtins)) - set(MAGIC_GLOBALS) 449 | break 450 | else: 451 | raise RuntimeError("Could not parse the names") 452 | 453 | if "__all__" in names: 454 | return set(scope["__all__"].names) 455 | return names 456 | 457 | 458 | ## for jupyter notebooks with .ipynb extension 459 | def replace_in_nb( 460 | nb, 461 | replaces: dict, 462 | cell_type: str = "code", 463 | ): 464 | """ 465 | Replace text in a jupyter notebook. 466 | 467 | Parameters 468 | nb: notebook object obtained from `nbformat.reads`. 469 | replaces (dict): mapping of text to 'replace from' to the one to 'replace with'. 470 | cell_type (str): the type of the cell. 471 | 472 | Returns: 473 | source_nb: Fixed code. 474 | """ 475 | new_nb = nb.copy() 476 | for replace_from, replace_to in replaces.items(): 477 | break_early = str(nb).count(replace_from) == 1 478 | for i, d in enumerate(new_nb["cells"]): 479 | if d["cell_type"] == cell_type and replace_from in d["source"]: 480 | d["source"] = d["source"].replace(replace_from, replace_to) 481 | new_nb["cells"][i] = d 482 | if break_early: 483 | break 484 | 485 | ## save new nb 486 | to_nb = NotebookExporter() 487 | source_nb, _ = to_nb.from_notebook_node(new_nb) 488 | 489 | return source_nb 490 | -------------------------------------------------------------------------------- /removestar/version.pyi: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | version: str 4 | version_tuple: tuple[int, int, int] | tuple[int, int, int, str, str] 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asmeurer/removestar/9b565f1ad427e31464b2ebc5bf104e68dacb6f42/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_removestar.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import os 3 | import subprocess 4 | import sys 5 | from filecmp import dircmp 6 | from pathlib import Path 7 | 8 | import pytest 9 | from pyflakes.checker import Checker 10 | 11 | from removestar.output import get_colored_diff, green, red, yellow 12 | from removestar.removestar import ( 13 | ExternalModuleError, 14 | fix_code, 15 | get_mod_filename, 16 | get_names, 17 | get_names_dynamically, 18 | get_names_from_dir, 19 | is_noqa_comment_allowing_star_import, 20 | names_to_replace, 21 | replace_imports, 22 | star_imports, 23 | ) 24 | 25 | code_mod1 = """\ 26 | a = 1 27 | aa = 2 28 | b = 3 29 | """ 30 | 31 | mod1_names = {"a", "aa", "b"} 32 | 33 | code_mod2 = """\ 34 | b = 1 35 | c = 2 36 | cc = 3 37 | """ 38 | 39 | mod2_names = {"b", "c", "cc"} 40 | 41 | code_mod3 = """\ 42 | name = 0 43 | """ 44 | 45 | mod3_names = {"name"} 46 | 47 | code_mod4 = """\ 48 | from .mod1 import * 49 | from .mod2 import * 50 | from .mod3 import name 51 | 52 | def func(): 53 | return a + b + c + d + d + name 54 | """ 55 | 56 | mod4_names = {"a", "aa", "b", "c", "cc", "name", "func"} 57 | 58 | code_mod4_fixed = """\ 59 | from .mod1 import a 60 | from .mod2 import b, c 61 | from .mod3 import name 62 | 63 | def func(): 64 | return a + b + c + d + d + name 65 | """ 66 | 67 | code_mod5 = """\ 68 | from module.mod1 import * 69 | from module.mod2 import * 70 | from module.mod3 import name 71 | 72 | def func(): 73 | return a + b + c + d + d + name 74 | """ 75 | 76 | mod5_names = {"a", "aa", "b", "c", "cc", "name", "func"} 77 | 78 | code_mod5_fixed = """\ 79 | from module.mod1 import a 80 | from module.mod2 import b, c 81 | from module.mod3 import name 82 | 83 | def func(): 84 | return a + b + c + d + d + name 85 | """ 86 | 87 | code_mod6 = """\ 88 | from os.path import * 89 | isfile(join('a', 'b')) 90 | """ 91 | 92 | code_mod6_fixed = """\ 93 | from os.path import isfile, join 94 | isfile(join('a', 'b')) 95 | """ 96 | 97 | code_mod7 = """\ 98 | from .mod6 import * 99 | """ 100 | 101 | code_mod7_fixed = "" 102 | 103 | mod7_names = {"isfile", "join"} 104 | 105 | code_mod8 = """\ 106 | a = 1 107 | b = 2 108 | c = 3 109 | __all__ = ['a'] 110 | __all__ += ['b'] 111 | """ 112 | 113 | mod8_names = {"a", "b"} 114 | 115 | code_mod9 = """\ 116 | from .mod8 import * 117 | 118 | def func(): 119 | return a + b 120 | """ 121 | 122 | code_mod9_fixed = """\ 123 | from .mod8 import a, b 124 | 125 | def func(): 126 | return a + b 127 | """ 128 | 129 | mod9_names = {"a", "b", "func"} 130 | 131 | code_submod1 = """\ 132 | from ..mod1 import * 133 | from ..mod2 import * 134 | from ..mod3 import name 135 | from .submod3 import * 136 | 137 | def func(): 138 | return a + b + c + d + d + e + name 139 | """ 140 | 141 | submod1_names = {"a", "aa", "b", "c", "cc", "e", "name", "func"} 142 | 143 | code_submod1_fixed = """\ 144 | from ..mod1 import a 145 | from ..mod2 import b, c 146 | from ..mod3 import name 147 | from .submod3 import e 148 | 149 | def func(): 150 | return a + b + c + d + d + e + name 151 | """ 152 | 153 | code_submod2 = """\ 154 | from module.mod1 import * 155 | from module.mod2 import * 156 | from module.mod3 import name 157 | from module.submod.submod3 import * 158 | 159 | def func(): 160 | return a + b + c + d + d + e + name 161 | """ 162 | 163 | submod2_names = {"a", "aa", "b", "c", "cc", "e", "name", "func"} 164 | 165 | code_submod2_fixed = """\ 166 | from module.mod1 import a 167 | from module.mod2 import b, c 168 | from module.mod3 import name 169 | from module.submod.submod3 import e 170 | 171 | def func(): 172 | return a + b + c + d + d + e + name 173 | """ 174 | 175 | code_submod3 = """\ 176 | e = 1 177 | """ 178 | 179 | submod3_names = {"e"} 180 | 181 | code_submod4 = """\ 182 | from . import * 183 | 184 | func() 185 | """ 186 | 187 | submod4_names = {"func"} 188 | 189 | code_submod4_fixed = """\ 190 | from . import func 191 | 192 | func() 193 | """ 194 | 195 | code_submod_init = """\ 196 | from .submod1 import func 197 | """ 198 | 199 | submod_names = {"func"} 200 | # An actual import adds submod1 and submod3 to the submod namespace, since 201 | # they are imported submodule names. The static code does not yet support 202 | # these. If any other imports happen first, like 'import submod.submod2', 203 | # those would be included as well. 204 | submod_dynamic_names = {"submod1", "submod3", "func"} 205 | 206 | code_bad_syntax = """\ 207 | from mod 208 | """ 209 | 210 | code_mod_unfixable = """\ 211 | from .mod1 import *; 212 | from .mod2 import\t* 213 | 214 | def func(): 215 | return a + c 216 | """ 217 | 218 | mod_unfixable_names = {"a", "aa", "b", "c", "cc", "func"} 219 | 220 | code_mod_commented_unused_star = """\ 221 | from .mod1 import * # comment about mod1 222 | from .mod2 import * # noqa 223 | """ 224 | 225 | mod_commented_unused_star_names = {"a", "aa", "b", "c", "cc"} 226 | 227 | code_mod_commented_unused_star_fixed = """\ 228 | # comment about mod1 229 | from .mod2 import * # noqa 230 | """ 231 | 232 | code_mod_commented_star = """\ 233 | from .mod1 import * # noqa 234 | from .mod2 import * # noqa: F401 235 | from .mod3 import * # generic comment 236 | 237 | def func(): 238 | return a + c + name 239 | """ 240 | 241 | mod_commented_star_names = {"a", "aa", "b", "c", "cc", "name", "func"} 242 | 243 | code_mod_commented_star_fixed = """\ 244 | from .mod1 import * # noqa 245 | from .mod2 import * # noqa: F401 246 | from .mod3 import name # generic comment 247 | 248 | def func(): 249 | return a + c + name 250 | """ 251 | 252 | code_submod_recursive_init = """\ 253 | from .submod1 import * 254 | """ 255 | 256 | submod_recursive_names = {"a", "b"} 257 | submod_recursive_dynamic_names = {"submod1", "a", "b"} 258 | 259 | code_submod_recursive_submod1 = """\ 260 | a = 1 261 | b = 2 262 | """ 263 | 264 | submod_recursive_submod1_names = {"a", "b"} 265 | 266 | code_submod_recursive_submod2 = """\ 267 | from . import * 268 | 269 | def func(): 270 | return a + 1 271 | """ 272 | 273 | submod_recursive_submod2_names = {"a", "b", "func"} 274 | submod_recursive_submod2_dynamic_names = {"a", "b", "func", "submod1"} 275 | 276 | code_submod_recursive_submod2_fixed = """\ 277 | from . import a 278 | 279 | def func(): 280 | return a + 1 281 | """ 282 | 283 | 284 | def create_module(module): 285 | os.makedirs(module) 286 | with open(module / "mod1.py", "w") as f: 287 | f.write(code_mod1) 288 | with open(module / "mod2.py", "w") as f: 289 | f.write(code_mod2) 290 | with open(module / "mod3.py", "w") as f: 291 | f.write(code_mod3) 292 | with open(module / "mod4.py", "w") as f: 293 | f.write(code_mod4) 294 | with open(module / "mod5.py", "w") as f: 295 | f.write(code_mod5) 296 | with open(module / "mod6.py", "w") as f: 297 | f.write(code_mod6) 298 | with open(module / "mod7.py", "w") as f: 299 | f.write(code_mod7) 300 | with open(module / "mod8.py", "w") as f: 301 | f.write(code_mod8) 302 | with open(module / "mod9.py", "w") as f: 303 | f.write(code_mod9) 304 | with open(module / "__init__.py", "w") as f: 305 | pass 306 | with open(module / "mod_bad.py", "w") as f: 307 | f.write(code_bad_syntax) 308 | with open(module / "mod_unfixable.py", "w") as f: 309 | f.write(code_mod_unfixable) 310 | with open(module / "mod_commented_unused_star.py", "w") as f: 311 | f.write(code_mod_commented_unused_star) 312 | with open(module / "mod_commented_star.py", "w") as f: 313 | f.write(code_mod_commented_star) 314 | submod = module / "submod" 315 | os.makedirs(submod) 316 | with open(submod / "__init__.py", "w") as f: 317 | f.write(code_submod_init) 318 | with open(submod / "submod1.py", "w") as f: 319 | f.write(code_submod1) 320 | with open(submod / "submod2.py", "w") as f: 321 | f.write(code_submod2) 322 | with open(submod / "submod3.py", "w") as f: 323 | f.write(code_submod3) 324 | with open(submod / "submod4.py", "w") as f: 325 | f.write(code_submod4) 326 | submod_recursive = module / "submod_recursive" 327 | os.makedirs(submod_recursive) 328 | with open(submod_recursive / "__init__.py", "w") as f: 329 | f.write(code_submod_recursive_init) 330 | with open(submod_recursive / "submod1.py", "w") as f: 331 | f.write(code_submod_recursive_submod1) 332 | with open(submod_recursive / "submod2.py", "w") as f: 333 | f.write(code_submod_recursive_submod2) 334 | 335 | 336 | def test_names_to_replace(): 337 | for code in [ 338 | code_mod1, 339 | code_mod2, 340 | code_mod3, 341 | code_mod7, 342 | code_mod8, 343 | code_submod3, 344 | code_submod_init, 345 | code_submod_recursive_init, 346 | code_submod_recursive_submod1, 347 | ]: 348 | names = names_to_replace(Checker(ast.parse(code))) 349 | assert names == set() 350 | 351 | for code in [code_mod4, code_mod5]: 352 | names = names_to_replace(Checker(ast.parse(code))) 353 | assert names == {"a", "b", "c", "d"} 354 | 355 | for code in [code_submod1, code_submod2]: 356 | names = names_to_replace(Checker(ast.parse(code))) 357 | assert names == {"a", "b", "c", "d", "e"} 358 | 359 | names = names_to_replace(Checker(ast.parse(code_submod4))) 360 | assert names == {"func"} 361 | 362 | names = names_to_replace(Checker(ast.parse(code_mod6))) 363 | assert names == {"isfile", "join"} 364 | 365 | names = names_to_replace(Checker(ast.parse(code_submod_recursive_submod2))) 366 | assert names == {"a"} 367 | 368 | names = names_to_replace(Checker(ast.parse(code_mod9))) 369 | assert names == {"a", "b"} 370 | 371 | names = names_to_replace(Checker(ast.parse(code_mod_unfixable))) 372 | assert names == {"a", "c"} 373 | 374 | names = names_to_replace(Checker(ast.parse(code_mod_commented_unused_star))) 375 | assert names == set() 376 | 377 | names = names_to_replace(Checker(ast.parse(code_mod_commented_star))) 378 | assert names == {"a", "c", "name"} 379 | 380 | 381 | def test_star_imports(): 382 | for code in [ 383 | code_mod1, 384 | code_mod2, 385 | code_mod3, 386 | code_mod8, 387 | code_submod3, 388 | code_submod_init, 389 | code_submod_recursive_submod1, 390 | ]: 391 | stars = star_imports(Checker(ast.parse(code))) 392 | assert stars == [] 393 | 394 | stars = star_imports(Checker(ast.parse(code_mod4))) 395 | assert stars == [".mod1", ".mod2"] 396 | 397 | stars = star_imports(Checker(ast.parse(code_mod5))) 398 | assert stars == ["module.mod1", "module.mod2"] 399 | 400 | stars = star_imports(Checker(ast.parse(code_mod6))) 401 | assert stars == ["os.path"] 402 | 403 | stars = star_imports(Checker(ast.parse(code_mod7))) 404 | assert stars == [".mod6"] 405 | 406 | stars = star_imports(Checker(ast.parse(code_mod9))) 407 | assert stars == [".mod8"] 408 | 409 | stars = star_imports(Checker(ast.parse(code_submod1))) 410 | assert stars == ["..mod1", "..mod2", ".submod3"] 411 | 412 | stars = star_imports(Checker(ast.parse(code_submod2))) 413 | assert stars == ["module.mod1", "module.mod2", "module.submod.submod3"] 414 | 415 | for code in [code_submod4, code_submod_recursive_submod2]: 416 | stars = star_imports(Checker(ast.parse(code))) 417 | assert stars == ["."] 418 | 419 | stars = star_imports(Checker(ast.parse(code_submod_recursive_init))) 420 | assert stars == [".submod1"] 421 | 422 | stars = star_imports(Checker(ast.parse(code_mod_unfixable))) 423 | assert stars == [".mod1", ".mod2"] 424 | 425 | stars = star_imports(Checker(ast.parse(code_mod_commented_unused_star))) 426 | assert stars == [".mod1", ".mod2"] 427 | 428 | stars = star_imports(Checker(ast.parse(code_mod_commented_star))) 429 | assert stars == [".mod1", ".mod2", ".mod3"] 430 | 431 | 432 | def test_get_names(): 433 | names = get_names(code_mod1) 434 | assert names == {"a", "aa", "b"} 435 | 436 | names = get_names(code_mod2) 437 | assert names == {"b", "c", "cc"} 438 | 439 | names = get_names(code_mod3) 440 | assert names == {"name"} 441 | 442 | names = get_names(code_mod4) 443 | # TODO: Remove the imported name 'name' 444 | assert names == {".mod1.*", ".mod2.*", "name", "func"} 445 | 446 | names = get_names(code_mod5) 447 | # TODO: Remove the imported name 'name' 448 | assert names == {"module.mod1.*", "module.mod2.*", "name", "func"} 449 | 450 | names = get_names(code_mod6) 451 | assert names == {"os.path.*"} 452 | 453 | names = get_names(code_submod_init) 454 | assert names == {"func"} 455 | 456 | names = get_names(code_submod1) 457 | # TODO: Remove the imported name 'name' 458 | assert names == {"..mod1.*", "..mod2.*", ".submod3.*", "name", "func"} 459 | 460 | names = get_names(code_submod2) 461 | # TODO: Remove the imported name 'name' 462 | assert names == { 463 | "module.mod1.*", 464 | "module.mod2.*", 465 | "module.submod.submod3.*", 466 | "name", 467 | "func", 468 | } 469 | 470 | names = get_names(code_submod3) 471 | assert names == {"e"} 472 | 473 | names = get_names(code_submod4) 474 | assert names == {"..*"} 475 | 476 | pytest.raises(SyntaxError, lambda: get_names(code_bad_syntax)) 477 | 478 | names = get_names(code_mod_unfixable) 479 | assert names == {".mod1.*", ".mod2.*", "func"} 480 | 481 | names = get_names(code_mod_commented_unused_star) 482 | assert names == {".mod1.*", ".mod2.*"} 483 | 484 | names = get_names(code_mod_commented_star) 485 | assert names == {".mod1.*", ".mod2.*", ".mod3.*", "func"} 486 | 487 | names = get_names(code_submod_recursive_init) 488 | assert names == {".submod1.*"} 489 | 490 | names = get_names(code_submod_recursive_submod1) 491 | assert names == {"a", "b"} 492 | 493 | names = get_names(code_submod_recursive_submod2) 494 | assert names == {"..*", "func"} 495 | 496 | 497 | @pytest.mark.parametrize("relative", [True, False]) 498 | def test_get_names_from_dir(tmpdir, relative): 499 | directory = tmpdir / "module" 500 | create_module(directory) 501 | if relative: 502 | chdir = tmpdir 503 | directory = Path("module") 504 | else: 505 | chdir = "." 506 | curdir = os.path.abspath(".") 507 | try: 508 | os.chdir(chdir) 509 | assert get_names_from_dir(".mod1", directory) == mod1_names 510 | assert get_names_from_dir(".mod2", directory) == mod2_names 511 | assert get_names_from_dir(".mod3", directory) == mod3_names 512 | assert get_names_from_dir(".mod4", directory) == mod4_names 513 | assert get_names_from_dir(".mod5", directory) == mod5_names 514 | assert get_names_from_dir(".mod6", directory) == get_names_dynamically("os.path") 515 | pytest.raises( 516 | NotImplementedError, 517 | lambda: get_names_from_dir(".mod6", directory, allow_dynamic=False), 518 | ) 519 | assert get_names_from_dir(".mod7", directory) == get_names_dynamically("os.path") 520 | pytest.raises( 521 | NotImplementedError, 522 | lambda: get_names_from_dir(".mod7", directory, allow_dynamic=False), 523 | ) 524 | assert get_names_from_dir(".mod8", directory) == mod8_names 525 | assert get_names_from_dir(".mod9", directory) == mod9_names 526 | assert get_names_from_dir(".mod_unfixable", directory) == mod_unfixable_names 527 | assert ( 528 | get_names_from_dir(".mod_commented_unused_star", directory) 529 | == mod_commented_unused_star_names 530 | ) 531 | assert get_names_from_dir(".mod_commented_star", directory) == mod_commented_star_names 532 | assert get_names_from_dir(".submod", directory) == submod_names 533 | assert get_names_from_dir(".submod.submod1", directory) == submod1_names 534 | assert get_names_from_dir(".submod.submod2", directory) == submod2_names 535 | assert get_names_from_dir(".submod.submod3", directory) == submod3_names 536 | assert get_names_from_dir(".submod.submod4", directory) == submod4_names 537 | assert get_names_from_dir(".submod_recursive", directory) == submod_recursive_names 538 | assert ( 539 | get_names_from_dir(".submod_recursive.submod1", directory) 540 | == submod_recursive_submod1_names 541 | ) 542 | assert ( 543 | get_names_from_dir(".submod_recursive.submod2", directory) 544 | == submod_recursive_submod2_names 545 | ) 546 | 547 | assert get_names_from_dir("module.mod1", directory) == mod1_names 548 | assert get_names_from_dir("module.mod2", directory) == mod2_names 549 | assert get_names_from_dir("module.mod3", directory) == mod3_names 550 | assert get_names_from_dir("module.mod4", directory) == mod4_names 551 | assert get_names_from_dir("module.mod5", directory) == mod5_names 552 | assert get_names_from_dir("module.mod6", directory) == get_names_dynamically("os.path") 553 | pytest.raises( 554 | NotImplementedError, 555 | lambda: get_names_from_dir("module.mod6", directory, allow_dynamic=False), 556 | ) 557 | assert get_names_from_dir("module.mod7", directory) == get_names_dynamically("os.path") 558 | pytest.raises( 559 | NotImplementedError, 560 | lambda: get_names_from_dir("module.mod7", directory, allow_dynamic=False), 561 | ) 562 | assert get_names_from_dir("module.mod8", directory) == mod8_names 563 | assert get_names_from_dir("module.mod9", directory) == mod9_names 564 | assert get_names_from_dir("module.mod_unfixable", directory) == mod_unfixable_names 565 | assert ( 566 | get_names_from_dir("module.mod_commented_unused_star", directory) 567 | == mod_commented_unused_star_names 568 | ) 569 | assert ( 570 | get_names_from_dir("module.mod_commented_star", directory) == mod_commented_star_names 571 | ) 572 | assert get_names_from_dir("module.submod", directory) == submod_names 573 | assert get_names_from_dir("module.submod.submod1", directory) == submod1_names 574 | assert get_names_from_dir("module.submod.submod2", directory) == submod2_names 575 | assert get_names_from_dir("module.submod.submod3", directory) == submod3_names 576 | assert get_names_from_dir("module.submod.submod4", directory) == submod4_names 577 | assert get_names_from_dir("module.submod_recursive", directory) == submod_recursive_names 578 | assert ( 579 | get_names_from_dir("module.submod_recursive.submod1", directory) 580 | == submod_recursive_submod1_names 581 | ) 582 | assert ( 583 | get_names_from_dir("module.submod_recursive.submod2", directory) 584 | == submod_recursive_submod2_names 585 | ) 586 | 587 | submod = directory / "submod" 588 | assert get_names_from_dir("..submod", submod) == submod_names 589 | assert get_names_from_dir(".", submod) == submod_names 590 | assert get_names_from_dir(".submod1", submod) == submod1_names 591 | assert get_names_from_dir(".submod2", submod) == submod2_names 592 | assert get_names_from_dir(".submod3", submod) == submod3_names 593 | assert get_names_from_dir(".submod4", submod) == submod4_names 594 | assert get_names_from_dir("..mod1", submod) == mod1_names 595 | assert get_names_from_dir("..mod2", submod) == mod2_names 596 | assert get_names_from_dir("..mod3", submod) == mod3_names 597 | assert get_names_from_dir("..mod4", submod) == mod4_names 598 | assert get_names_from_dir("..mod5", submod) == mod5_names 599 | assert get_names_from_dir("..mod6", submod) == get_names_dynamically("os.path") 600 | pytest.raises( 601 | NotImplementedError, 602 | lambda: get_names_from_dir("..mod6", submod, allow_dynamic=False), 603 | ) 604 | assert get_names_from_dir("..mod7", submod) == get_names_dynamically("os.path") 605 | pytest.raises( 606 | NotImplementedError, 607 | lambda: get_names_from_dir("..mod7", submod, allow_dynamic=False), 608 | ) 609 | assert get_names_from_dir("..mod8", submod) == mod8_names 610 | assert get_names_from_dir("..mod9", submod) == mod9_names 611 | assert get_names_from_dir("..mod_unfixable", submod) == mod_unfixable_names 612 | assert ( 613 | get_names_from_dir("..mod_commented_unused_star", submod) 614 | == mod_commented_unused_star_names 615 | ) 616 | assert get_names_from_dir("..mod_commented_star", submod) == mod_commented_star_names 617 | assert get_names_from_dir("..submod_recursive", submod) == submod_recursive_names 618 | assert ( 619 | get_names_from_dir("..submod_recursive.submod1", submod) 620 | == submod_recursive_submod1_names 621 | ) 622 | assert ( 623 | get_names_from_dir("..submod_recursive.submod2", submod) 624 | == submod_recursive_submod2_names 625 | ) 626 | 627 | assert get_names_from_dir("module.mod1", submod) == mod1_names 628 | assert get_names_from_dir("module.mod2", submod) == mod2_names 629 | assert get_names_from_dir("module.mod3", submod) == mod3_names 630 | assert get_names_from_dir("module.mod4", submod) == mod4_names 631 | assert get_names_from_dir("module.mod5", submod) == mod5_names 632 | assert get_names_from_dir("module.mod6", submod) == get_names_dynamically("os.path") 633 | pytest.raises( 634 | NotImplementedError, 635 | lambda: get_names_from_dir("module.mod6", submod, allow_dynamic=False), 636 | ) 637 | assert get_names_from_dir("module.mod7", submod) == get_names_dynamically("os.path") 638 | pytest.raises( 639 | NotImplementedError, 640 | lambda: get_names_from_dir("module.mod7", submod, allow_dynamic=False), 641 | ) 642 | assert get_names_from_dir("module.mod8", submod) == mod8_names 643 | assert get_names_from_dir("module.mod9", submod) == mod9_names 644 | assert get_names_from_dir("module.mod_unfixable", submod) == mod_unfixable_names 645 | assert ( 646 | get_names_from_dir("module.mod_commented_unused_star", submod) 647 | == mod_commented_unused_star_names 648 | ) 649 | assert get_names_from_dir("module.mod_commented_star", submod) == mod_commented_star_names 650 | assert get_names_from_dir("module.submod", submod) == submod_names 651 | assert get_names_from_dir("module.submod.submod1", submod) == submod1_names 652 | assert get_names_from_dir("module.submod.submod2", submod) == submod2_names 653 | assert get_names_from_dir("module.submod.submod3", submod) == submod3_names 654 | assert get_names_from_dir("module.submod.submod4", submod) == submod4_names 655 | assert get_names_from_dir("module.submod_recursive", submod) == submod_recursive_names 656 | assert ( 657 | get_names_from_dir("module.submod_recursive.submod1", submod) 658 | == submod_recursive_submod1_names 659 | ) 660 | assert ( 661 | get_names_from_dir("module.submod_recursive.submod2", submod) 662 | == submod_recursive_submod2_names 663 | ) 664 | 665 | submod_recursive = directory / "submod_recursive" 666 | assert get_names_from_dir("..submod", submod_recursive) == submod_names 667 | assert get_names_from_dir("..submod.submod1", submod_recursive) == submod1_names 668 | assert get_names_from_dir("..submod.submod2", submod_recursive) == submod2_names 669 | assert get_names_from_dir("..submod.submod3", submod_recursive) == submod3_names 670 | assert get_names_from_dir("..submod.submod4", submod_recursive) == submod4_names 671 | assert get_names_from_dir("..mod1", submod_recursive) == mod1_names 672 | assert get_names_from_dir("..mod2", submod_recursive) == mod2_names 673 | assert get_names_from_dir("..mod3", submod_recursive) == mod3_names 674 | assert get_names_from_dir("..mod4", submod_recursive) == mod4_names 675 | assert get_names_from_dir("..mod5", submod_recursive) == mod5_names 676 | assert get_names_from_dir("..mod6", submod_recursive) == get_names_dynamically("os.path") 677 | pytest.raises( 678 | NotImplementedError, 679 | lambda: get_names_from_dir("..mod6", submod_recursive, allow_dynamic=False), 680 | ) 681 | assert get_names_from_dir("..mod7", submod_recursive) == get_names_dynamically("os.path") 682 | pytest.raises( 683 | NotImplementedError, 684 | lambda: get_names_from_dir("..mod7", submod_recursive, allow_dynamic=False), 685 | ) 686 | assert get_names_from_dir("..mod8", submod_recursive) == mod8_names 687 | assert get_names_from_dir("..mod9", submod_recursive) == mod9_names 688 | assert get_names_from_dir("..mod_unfixable", submod_recursive) == mod_unfixable_names 689 | assert ( 690 | get_names_from_dir("..mod_commented_unused_star", submod_recursive) 691 | == mod_commented_unused_star_names 692 | ) 693 | assert ( 694 | get_names_from_dir("..mod_commented_star", submod_recursive) == mod_commented_star_names 695 | ) 696 | assert get_names_from_dir(".", submod_recursive) == submod_recursive_names 697 | assert get_names_from_dir("..submod_recursive", submod_recursive) == submod_recursive_names 698 | assert get_names_from_dir(".submod1", submod_recursive) == submod_recursive_submod1_names 699 | assert get_names_from_dir(".submod2", submod_recursive) == submod_recursive_submod2_names 700 | 701 | assert get_names_from_dir("module.mod1", submod_recursive) == mod1_names 702 | assert get_names_from_dir("module.mod2", submod_recursive) == mod2_names 703 | assert get_names_from_dir("module.mod3", submod_recursive) == mod3_names 704 | assert get_names_from_dir("module.mod4", submod_recursive) == mod4_names 705 | assert get_names_from_dir("module.mod5", submod_recursive) == mod5_names 706 | assert get_names_from_dir("module.mod6", submod_recursive) == get_names_dynamically( 707 | "os.path" 708 | ) 709 | pytest.raises( 710 | NotImplementedError, 711 | lambda: get_names_from_dir("module.mod6", submod, allow_dynamic=False), 712 | ) 713 | assert get_names_from_dir("module.mod7", submod_recursive) == get_names_dynamically( 714 | "os.path" 715 | ) 716 | pytest.raises( 717 | NotImplementedError, 718 | lambda: get_names_from_dir("module.mod7", submod, allow_dynamic=False), 719 | ) 720 | assert get_names_from_dir("module.mod8", submod_recursive) == mod8_names 721 | assert get_names_from_dir("module.mod9", submod_recursive) == mod9_names 722 | assert get_names_from_dir("module.mod_unfixable", submod_recursive) == mod_unfixable_names 723 | assert ( 724 | get_names_from_dir("module.mod_commented_unused_star", submod) 725 | == mod_commented_unused_star_names 726 | ) 727 | assert get_names_from_dir("module.mod_commented_star", submod) == mod_commented_star_names 728 | assert get_names_from_dir("module.submod", submod_recursive) == submod_names 729 | assert get_names_from_dir("module.submod.submod1", submod_recursive) == submod1_names 730 | assert get_names_from_dir("module.submod.submod2", submod_recursive) == submod2_names 731 | assert get_names_from_dir("module.submod.submod3", submod_recursive) == submod3_names 732 | assert get_names_from_dir("module.submod.submod4", submod_recursive) == submod4_names 733 | assert ( 734 | get_names_from_dir("module.submod_recursive", submod_recursive) 735 | == submod_recursive_names 736 | ) 737 | assert ( 738 | get_names_from_dir("module.submod_recursive.submod1", submod_recursive) 739 | == submod_recursive_submod1_names 740 | ) 741 | assert ( 742 | get_names_from_dir("module.submod_recursive.submod2", submod_recursive) 743 | == submod_recursive_submod2_names 744 | ) 745 | 746 | pytest.raises(ExternalModuleError, lambda: get_names_from_dir("os.path", directory)) 747 | pytest.raises(ExternalModuleError, lambda: get_names_from_dir("os.path", submod)) 748 | pytest.raises(RuntimeError, lambda: get_names_from_dir(".mod_bad", directory)) 749 | pytest.raises(RuntimeError, lambda: get_names_from_dir("module.mod_bad", directory)) 750 | pytest.raises(RuntimeError, lambda: get_names_from_dir(".mod_doesnt_exist", directory)) 751 | pytest.raises( 752 | RuntimeError, 753 | lambda: get_names_from_dir("module.mod_doesnt_exist", directory), 754 | ) 755 | finally: 756 | os.chdir(curdir) 757 | 758 | 759 | def test_get_names_dynamically(tmpdir): 760 | os_path = get_names_dynamically("os.path") 761 | assert "isfile" in os_path 762 | assert "join" in os_path 763 | 764 | directory = tmpdir / "module" 765 | create_module(directory) 766 | sys_path = sys.path 767 | 768 | try: 769 | sys.path.insert(0, str(tmpdir)) 770 | assert get_names_dynamically("module.mod1") == mod1_names 771 | assert get_names_dynamically("module.mod2") == mod2_names 772 | assert get_names_dynamically("module.mod3") == mod3_names 773 | assert get_names_dynamically("module.mod4") == mod4_names 774 | assert get_names_dynamically("module.mod5") == mod5_names 775 | assert get_names_dynamically("module.mod6") == os_path 776 | assert get_names_dynamically("module.mod7") == os_path 777 | assert get_names_dynamically("module.mod8") == mod8_names 778 | assert get_names_dynamically("module.mod9") == mod9_names 779 | assert get_names_dynamically("module.mod_unfixable") == mod_unfixable_names 780 | assert ( 781 | get_names_dynamically("module.mod_commented_unused_star") 782 | == mod_commented_unused_star_names 783 | ) 784 | assert get_names_dynamically("module.mod_commented_star") == mod_commented_star_names 785 | assert get_names_dynamically("module.submod") == submod_dynamic_names 786 | assert get_names_dynamically("module.submod.submod1") == submod1_names 787 | assert get_names_dynamically("module.submod.submod2") == submod2_names 788 | assert get_names_dynamically("module.submod.submod3") == submod3_names 789 | pytest.raises(RuntimeError, lambda: get_names_dynamically("module.submod.submod4")) 790 | assert get_names_dynamically("module.submod_recursive") == submod_recursive_dynamic_names 791 | assert ( 792 | get_names_dynamically("module.submod_recursive.submod1") 793 | == submod_recursive_submod1_names 794 | ) 795 | assert ( 796 | get_names_dynamically("module.submod_recursive.submod2") 797 | == submod_recursive_submod2_dynamic_names 798 | ) 799 | # Doesn't actually import because of the undefined name 'd' 800 | # assert get_names_dynamically('module.submod.submod4') == submod4_names 801 | finally: 802 | sys.path = sys_path 803 | 804 | pytest.raises(RuntimeError, lambda: get_names_dynamically("notarealmodule")) 805 | 806 | 807 | def test_fix_code(tmpdir, capsys): 808 | # TODO: Test the verbose and quiet flags 809 | directory = tmpdir / "module" 810 | create_module(directory) 811 | 812 | assert fix_code(code_mod1, file=directory / "mod1.py") == code_mod1 813 | out, err = capsys.readouterr() 814 | assert not out 815 | assert not err 816 | 817 | assert fix_code(code_mod2, file=directory / "mod2.py") == code_mod2 818 | out, err = capsys.readouterr() 819 | assert not out 820 | assert not err 821 | 822 | assert fix_code(code_mod3, file=directory / "mod3.py") == code_mod3 823 | out, err = capsys.readouterr() 824 | assert not out 825 | assert not err 826 | 827 | assert fix_code(code_mod4, file=directory / "mod4.py") == code_mod4_fixed 828 | out, err = capsys.readouterr() 829 | assert not out 830 | assert "Warning" in err 831 | assert str(directory / "mod4.py") in err 832 | assert "'b'" in err 833 | assert "'a'" not in err 834 | assert "'.mod1'" in err 835 | assert "'.mod2'" in err 836 | assert "Using '.mod2'" in err 837 | assert "could not find import for 'd'" in err 838 | 839 | assert fix_code(code_mod5, file=directory / "mod5.py") == code_mod5_fixed 840 | out, err = capsys.readouterr() 841 | assert not out 842 | assert "Warning" in err 843 | assert str(directory / "mod5.py") in err 844 | assert "'b'" in err 845 | assert "'a'" not in err 846 | assert "'module.mod1'" in err 847 | assert "'module.mod2'" in err 848 | assert "Using 'module.mod2'" in err 849 | assert "could not find import for 'd'" in err 850 | 851 | assert fix_code(code_mod6, file=directory / "mod6.py") == code_mod6_fixed 852 | out, err = capsys.readouterr() 853 | assert not out 854 | assert not err 855 | 856 | assert pytest.raises( 857 | NotImplementedError, 858 | lambda: fix_code(code_mod6, file=directory / "mod6.py", allow_dynamic=False), 859 | ) 860 | 861 | assert fix_code(code_mod7, file=directory / "mod7.py") == code_mod7_fixed 862 | out, err = capsys.readouterr() 863 | assert not out 864 | assert not err 865 | 866 | assert pytest.raises( 867 | NotImplementedError, 868 | lambda: fix_code(code_mod7, file=directory / "mod7.py", allow_dynamic=False), 869 | ) 870 | 871 | assert fix_code(code_mod8, file=directory / "mod8.py") == code_mod8 872 | out, err = capsys.readouterr() 873 | assert not out 874 | assert not err 875 | 876 | assert fix_code(code_mod9, file=directory / "mod9.py") == code_mod9_fixed 877 | out, err = capsys.readouterr() 878 | assert not out 879 | assert not err 880 | 881 | assert fix_code(code_mod_unfixable, file=directory / "mod_unfixable.py") == code_mod_unfixable 882 | out, err = capsys.readouterr() 883 | assert not out 884 | assert "Warning" in err 885 | assert "Could not find the star imports for" in err 886 | for mod in ["'.mod1'", "'.mod2'"]: 887 | assert mod in err 888 | 889 | assert ( 890 | fix_code( 891 | code_mod_commented_unused_star, 892 | file=directory / "mod_commented_unused_star.py", 893 | ) 894 | == code_mod_commented_unused_star_fixed 895 | ) 896 | out, err = capsys.readouterr() 897 | assert not out 898 | assert "Warning" in err 899 | assert ( 900 | "The removed star import statement for '.mod1' had an inline " 901 | "comment which may not make sense without the import" 902 | ) in err 903 | 904 | assert ( 905 | fix_code(code_mod_commented_star, file=directory / "mod_commented_star.py") 906 | == code_mod_commented_star_fixed 907 | ) 908 | out, err = capsys.readouterr() 909 | assert not out 910 | assert not err 911 | 912 | submod = directory / "submod" 913 | 914 | assert fix_code(code_submod_init, file=submod / "__init__.py") == code_submod_init 915 | out, err = capsys.readouterr() 916 | assert not out 917 | assert not err 918 | 919 | assert fix_code(code_submod1, file=submod / "submod1.py") == code_submod1_fixed 920 | out, err = capsys.readouterr() 921 | assert not out 922 | assert "Warning" in err 923 | assert str(submod / "submod1.py") in err 924 | assert "'b'" in err 925 | assert "'a'" not in err 926 | assert "'..mod1'" in err 927 | assert "'..mod2'" in err 928 | assert "'.mod1'" not in err 929 | assert "'.mod2'" not in err 930 | assert "Using '..mod2'" in err 931 | assert "could not find import for 'd'" in err 932 | 933 | assert fix_code(code_submod2, file=submod / "submod2.py") == code_submod2_fixed 934 | out, err = capsys.readouterr() 935 | assert not out 936 | assert "Warning" in err 937 | assert str(submod / "submod2.py") in err 938 | assert "'b'" in err 939 | assert "'a'" not in err 940 | assert "'module.mod1'" in err 941 | assert "'module.mod2'" in err 942 | assert "'module.submod.submod3'" not in err 943 | assert "'module.submod.mod1'" not in err 944 | assert "'module.submod.mod2'" not in err 945 | assert "Using 'module.mod2'" in err 946 | assert "could not find import for 'd'" in err 947 | 948 | assert fix_code(code_submod3, file=submod / "submod3.py") == code_submod3 949 | out, err = capsys.readouterr() 950 | assert not out 951 | assert not err 952 | 953 | assert fix_code(code_submod4, file=submod / "submod4.py") == code_submod4_fixed 954 | out, err = capsys.readouterr() 955 | assert not out 956 | assert not err 957 | 958 | submod_recursive = directory / "submod_recursive" 959 | 960 | # TODO: It's not actually useful to test this 961 | assert fix_code(code_submod_recursive_init, file=submod_recursive / "__init__.py") == "" 962 | out, err = capsys.readouterr() 963 | assert not out 964 | assert not err 965 | 966 | assert ( 967 | fix_code(code_submod_recursive_submod1, file=submod_recursive / "submod1.py") 968 | == code_submod_recursive_submod1 969 | ) 970 | out, err = capsys.readouterr() 971 | assert not out 972 | assert not err 973 | 974 | assert ( 975 | fix_code(code_submod_recursive_submod2, file=submod_recursive / "submod2.py") 976 | == code_submod_recursive_submod2_fixed 977 | ) 978 | out, err = capsys.readouterr() 979 | assert not out 980 | assert not err 981 | 982 | pytest.raises(RuntimeError, lambda: fix_code(code_bad_syntax, file=directory / "mod_bad.py")) 983 | out, err = capsys.readouterr() 984 | assert not out 985 | assert not err 986 | 987 | 988 | def touch(f): 989 | with open(f, "w"): 990 | pass 991 | 992 | 993 | @pytest.mark.parametrize("relative", [True, False]) 994 | def test_get_mod_filename(tmpdir, relative): 995 | if relative: 996 | chdir = tmpdir 997 | tmpdir = Path(".") 998 | else: 999 | chdir = "." 1000 | curdir = os.path.abspath(".") 1001 | try: 1002 | os.chdir(chdir) 1003 | 1004 | module = tmpdir / "module" 1005 | os.makedirs(module) 1006 | touch(module / "__init__.py") 1007 | touch(module / "mod1.py") 1008 | submod = module / "submod" 1009 | os.makedirs(submod) 1010 | touch(submod / "__init__.py") 1011 | touch(submod / "mod1.py") 1012 | subsubmod = submod / "submod" 1013 | os.makedirs(subsubmod) 1014 | touch(subsubmod / "__init__.py") 1015 | touch(subsubmod / "mod1.py") 1016 | 1017 | def _test(mod, directory, expected): 1018 | result = os.path.abspath(get_mod_filename(mod, directory)) 1019 | assert result == os.path.abspath(expected) 1020 | 1021 | _test(".", module, module / "__init__.py") 1022 | _test(".mod1", module, module / "mod1.py") 1023 | _test(".submod", module, submod / "__init__.py") 1024 | _test(".submod.mod1", module, submod / "mod1.py") 1025 | _test(".submod.submod", module, subsubmod / "__init__.py") 1026 | _test(".submod.submod.mod1", module, subsubmod / "mod1.py") 1027 | pytest.raises(RuntimeError, lambda: get_mod_filename(".notreal", module)) 1028 | 1029 | _test("module", module, module / "__init__.py") 1030 | _test("module.mod1", module, module / "mod1.py") 1031 | _test("module.submod", module, submod / "__init__.py") 1032 | _test("module.submod.mod1", module, submod / "mod1.py") 1033 | _test("module.submod.submod", module, subsubmod / "__init__.py") 1034 | _test("module.submod.submod.mod1", module, subsubmod / "mod1.py") 1035 | pytest.raises(RuntimeError, lambda: get_mod_filename("module.notreal", module)) 1036 | pytest.raises(RuntimeError, lambda: get_mod_filename("module.submod.notreal", module)) 1037 | pytest.raises(ExternalModuleError, lambda: get_mod_filename("notreal.notreal", module)) 1038 | 1039 | _test("..", submod, module / "__init__.py") 1040 | _test("..mod1", submod, module / "mod1.py") 1041 | _test(".", submod, submod / "__init__.py") 1042 | _test(".mod1", submod, submod / "mod1.py") 1043 | _test("..submod", submod, submod / "__init__.py") 1044 | _test("..submod.mod1", submod, submod / "mod1.py") 1045 | _test(".submod", submod, subsubmod / "__init__.py") 1046 | _test(".submod.mod1", submod, subsubmod / "mod1.py") 1047 | _test("..submod.submod", submod, subsubmod / "__init__.py") 1048 | _test("..submod.submod.mod1", submod, subsubmod / "mod1.py") 1049 | pytest.raises(RuntimeError, lambda: get_mod_filename(".notreal", submod)) 1050 | pytest.raises(RuntimeError, lambda: get_mod_filename("..notreal", submod)) 1051 | 1052 | _test("module", submod, module / "__init__.py") 1053 | _test("module.mod1", submod, module / "mod1.py") 1054 | _test("module.submod", submod, submod / "__init__.py") 1055 | _test("module.submod.mod1", submod, submod / "mod1.py") 1056 | _test("module.submod.submod", submod, subsubmod / "__init__.py") 1057 | _test("module.submod.submod.mod1", submod, subsubmod / "mod1.py") 1058 | pytest.raises(RuntimeError, lambda: get_mod_filename("module.notreal", submod)) 1059 | pytest.raises(RuntimeError, lambda: get_mod_filename("module.submod.notreal", submod)) 1060 | pytest.raises(ExternalModuleError, lambda: get_mod_filename("notreal.notreal", submod)) 1061 | 1062 | _test("...", subsubmod, module / "__init__.py") 1063 | _test("...mod1", subsubmod, module / "mod1.py") 1064 | _test("..", subsubmod, submod / "__init__.py") 1065 | _test("..mod1", subsubmod, submod / "mod1.py") 1066 | _test("...submod", subsubmod, submod / "__init__.py") 1067 | _test("...submod.mod1", subsubmod, submod / "mod1.py") 1068 | _test(".", subsubmod, subsubmod / "__init__.py") 1069 | _test(".mod1", subsubmod, subsubmod / "mod1.py") 1070 | _test("...submod.submod", subsubmod, subsubmod / "__init__.py") 1071 | _test("...submod.submod.mod1", subsubmod, subsubmod / "mod1.py") 1072 | _test("..submod", subsubmod, subsubmod / "__init__.py") 1073 | _test("..submod.mod1", subsubmod, subsubmod / "mod1.py") 1074 | pytest.raises(RuntimeError, lambda: get_mod_filename(".notreal", subsubmod)) 1075 | pytest.raises(RuntimeError, lambda: get_mod_filename("..notreal", subsubmod)) 1076 | pytest.raises(RuntimeError, lambda: get_mod_filename("..notreal", subsubmod)) 1077 | 1078 | _test("module", subsubmod, module / "__init__.py") 1079 | _test("module.mod1", subsubmod, module / "mod1.py") 1080 | _test("module.submod", subsubmod, submod / "__init__.py") 1081 | _test("module.submod.mod1", subsubmod, submod / "mod1.py") 1082 | _test("module.submod.submod", subsubmod, subsubmod / "__init__.py") 1083 | _test("module.submod.submod.mod1", subsubmod, subsubmod / "mod1.py") 1084 | pytest.raises(RuntimeError, lambda: get_mod_filename("module.notreal", subsubmod)) 1085 | pytest.raises(RuntimeError, lambda: get_mod_filename("module.submod.notreal", subsubmod)) 1086 | pytest.raises(ExternalModuleError, lambda: get_mod_filename("notreal.notreal", subsubmod)) 1087 | finally: 1088 | os.chdir(curdir) 1089 | 1090 | 1091 | def test_replace_imports(): 1092 | # The verbose and quiet flags are already tested in test_fix_code 1093 | for code in [ 1094 | code_mod1, 1095 | code_mod2, 1096 | code_mod3, 1097 | code_mod8, 1098 | code_submod3, 1099 | code_submod_init, 1100 | code_submod_recursive_submod1, 1101 | code_mod_unfixable, 1102 | ]: 1103 | assert replace_imports(code, repls={}, verbose=False, quiet=True) == code 1104 | 1105 | assert ( 1106 | replace_imports( 1107 | code_mod4, 1108 | repls={".mod1": ["a"], ".mod2": ["b", "c"]}, 1109 | verbose=False, 1110 | quiet=True, 1111 | ) 1112 | == code_mod4_fixed 1113 | ) 1114 | 1115 | assert ( 1116 | replace_imports( 1117 | code_mod5, 1118 | repls={"module.mod1": ["a"], "module.mod2": ["b", "c"]}, 1119 | verbose=False, 1120 | quiet=True, 1121 | ) 1122 | == code_mod5_fixed 1123 | ) 1124 | assert ( 1125 | replace_imports( 1126 | code_mod6, repls={"os.path": ["isfile", "join"]}, verbose=False, quiet=False 1127 | ) 1128 | == code_mod6_fixed 1129 | ) 1130 | assert ( 1131 | replace_imports(code_mod7, repls={".mod6": []}, verbose=False, quiet=False) 1132 | == code_mod7_fixed 1133 | ) 1134 | assert ( 1135 | replace_imports(code_mod9, repls={".mod8": ["a", "b"]}, verbose=False, quiet=False) 1136 | == code_mod9_fixed 1137 | ) 1138 | 1139 | assert ( 1140 | replace_imports( 1141 | code_submod1, 1142 | repls={"..mod1": ["a"], "..mod2": ["b", "c"], ".submod3": ["e"]}, 1143 | verbose=False, 1144 | quiet=True, 1145 | ) 1146 | == code_submod1_fixed 1147 | ) 1148 | 1149 | assert ( 1150 | replace_imports( 1151 | code_submod2, 1152 | repls={ 1153 | "module.mod1": ["a"], 1154 | "module.mod2": ["b", "c"], 1155 | "module.submod.submod3": ["e"], 1156 | }, 1157 | verbose=False, 1158 | quiet=True, 1159 | ) 1160 | == code_submod2_fixed 1161 | ) 1162 | 1163 | assert ( 1164 | replace_imports(code_submod4, repls={".": ["func"]}, verbose=False, quiet=True) 1165 | == code_submod4_fixed 1166 | ) 1167 | 1168 | assert ( 1169 | replace_imports(code_submod_recursive_submod2, repls={".": ["a"]}) 1170 | == code_submod_recursive_submod2_fixed 1171 | ) 1172 | 1173 | assert ( 1174 | replace_imports( 1175 | code_mod_unfixable, 1176 | repls={".mod1": ["a"], ".mod2": ["c"], ".mod3": ["name"]}, 1177 | ) 1178 | == code_mod_unfixable 1179 | ) 1180 | 1181 | assert ( 1182 | replace_imports(code_mod_commented_unused_star, repls={".mod1": [], ".mod2": []}) 1183 | == code_mod_commented_unused_star_fixed 1184 | ) 1185 | 1186 | assert ( 1187 | replace_imports( 1188 | code_mod_commented_star, 1189 | repls={".mod1": ["a"], ".mod2": ["c"], ".mod3": ["name"]}, 1190 | ) 1191 | == code_mod_commented_star_fixed 1192 | ) 1193 | 1194 | 1195 | @pytest.mark.parametrize( 1196 | ("verbose_enabled", "verbose_kwarg"), 1197 | [ 1198 | (False, {}), # Default is False 1199 | (False, {"verbose": False}), 1200 | (True, {"verbose": True}), 1201 | ], 1202 | ids=["implicit no verbose", "explicit no verbose", "explicit verbose"], 1203 | ) 1204 | @pytest.mark.parametrize( 1205 | ("kwargs", "fixed_code", "verbose_messages"), 1206 | [ 1207 | ( 1208 | {"code": code_mod4, "repls": {".mod1": ["a"], ".mod2": ["b", "c"]}}, 1209 | code_mod4_fixed, 1210 | [ 1211 | green("Replacing 'from .mod1 import *' with 'from .mod1 import a'"), 1212 | green("Replacing 'from .mod2 import *' with 'from .mod2 import b, c'"), 1213 | ], 1214 | ), 1215 | ( 1216 | { 1217 | "code": code_mod4, 1218 | "repls": {".mod1": ["a"], ".mod2": ["b", "c"]}, 1219 | "file": "directory/mod4.py", 1220 | }, 1221 | code_mod4_fixed, 1222 | [ 1223 | green( 1224 | "directory/mod4.py: Replacing 'from .mod1 import *' with 'from .mod1 import a'" 1225 | ), 1226 | green( 1227 | "directory/mod4.py: Replacing 'from .mod2 import *' with 'from .mod2 import b, c'" # noqa: E501 1228 | ), 1229 | ], 1230 | ), 1231 | ( 1232 | { 1233 | "code": code_mod_commented_star, 1234 | "repls": {".mod1": ["a"], ".mod2": ["c"], ".mod3": ["name"]}, 1235 | }, 1236 | code_mod_commented_star_fixed, 1237 | [ 1238 | green("Replacing 'from .mod3 import *' with 'from .mod3 import name'"), 1239 | green("Retaining 'from .mod1 import *' due to noqa comment"), 1240 | green("Retaining 'from .mod2 import *' due to noqa comment"), 1241 | ], 1242 | ), 1243 | ( 1244 | { 1245 | "code": code_mod_commented_star, 1246 | "repls": {".mod1": ["a"], ".mod2": ["c"], ".mod3": ["name"]}, 1247 | "file": "directory/mod_commented_star.py", 1248 | }, 1249 | code_mod_commented_star_fixed, 1250 | [ 1251 | green( 1252 | "directory/mod_commented_star.py: Replacing 'from .mod3 import *' with 'from .mod3 import name'" # noqa: E501 1253 | ), 1254 | green( 1255 | "directory/mod_commented_star.py: Retaining 'from .mod1 import *' due to noqa comment" # noqa: E501 1256 | ), 1257 | green( 1258 | "directory/mod_commented_star.py: Retaining 'from .mod2 import *' due to noqa comment" # noqa: E501 1259 | ), 1260 | ], 1261 | ), 1262 | ], 1263 | ids=[ 1264 | "mod4 without file", 1265 | "mod4 with file", 1266 | "mod_commented_star without file", 1267 | "mod_commented_star with file", 1268 | ], 1269 | ) 1270 | def test_replace_imports_verbose_messages( 1271 | kwargs, fixed_code, verbose_messages, verbose_enabled, verbose_kwarg, capsys 1272 | ): 1273 | assert replace_imports(**kwargs, **verbose_kwarg) == fixed_code 1274 | _, err = capsys.readouterr() 1275 | if verbose_enabled: 1276 | assert sorted(err.splitlines()) == verbose_messages 1277 | else: 1278 | assert err == "" 1279 | 1280 | 1281 | def test_replace_imports_warnings(capsys): 1282 | assert ( 1283 | replace_imports( 1284 | code_mod_unfixable, 1285 | file="module/mod_unfixable.py", 1286 | repls={".mod1": ["a"], ".mod2": ["c"]}, 1287 | ) 1288 | == code_mod_unfixable 1289 | ) 1290 | out, err = capsys.readouterr() 1291 | assert set(err.splitlines()) == { 1292 | yellow("Warning: module/mod_unfixable.py: Could not find the star imports for '.mod1'"), 1293 | yellow("Warning: module/mod_unfixable.py: Could not find the star imports for '.mod2'"), 1294 | } 1295 | 1296 | assert ( 1297 | replace_imports(code_mod_unfixable, file=None, repls={".mod1": ["a"], ".mod2": ["c"]}) 1298 | == code_mod_unfixable 1299 | ) 1300 | out, err = capsys.readouterr() 1301 | assert set(err.splitlines()) == { 1302 | yellow("Warning: Could not find the star imports for '.mod1'"), 1303 | yellow("Warning: Could not find the star imports for '.mod2'"), 1304 | } 1305 | 1306 | assert ( 1307 | replace_imports(code_mod_unfixable, quiet=True, repls={".mod1": ["a"], ".mod2": ["c"]}) 1308 | == code_mod_unfixable 1309 | ) 1310 | out, err = capsys.readouterr() 1311 | assert err == "" 1312 | 1313 | assert ( 1314 | replace_imports( 1315 | code_mod_commented_unused_star, 1316 | file="module/mod_commented_unused_star.py", 1317 | repls={".mod1": [], ".mod2": []}, 1318 | ) 1319 | == code_mod_commented_unused_star_fixed 1320 | ) 1321 | out, err = capsys.readouterr() 1322 | assert set(err.splitlines()) == { 1323 | yellow( 1324 | "Warning: module/mod_commented_unused_star.py: The removed star import statement for '.mod1' had an inline comment which may not make sense without the import" # noqa: E501 1325 | ), 1326 | } 1327 | 1328 | assert ( 1329 | replace_imports(code_mod_commented_unused_star, file=None, repls={".mod1": [], ".mod2": []}) 1330 | == code_mod_commented_unused_star_fixed 1331 | ) 1332 | out, err = capsys.readouterr() 1333 | assert set(err.splitlines()) == { 1334 | yellow( 1335 | "Warning: The removed star import statement for '.mod1' had an inline comment which may not make sense without the import" # noqa: E501 1336 | ), 1337 | } 1338 | 1339 | assert ( 1340 | replace_imports( 1341 | code_mod_commented_unused_star, quiet=True, repls={".mod1": [], ".mod2": []} 1342 | ) 1343 | == code_mod_commented_unused_star_fixed 1344 | ) 1345 | out, err = capsys.readouterr() 1346 | assert err == "" 1347 | 1348 | 1349 | def test_replace_imports_line_wrapping(): 1350 | code = """\ 1351 | from reallyreallylongmodulename import * 1352 | 1353 | print(longname1, longname2, longname3, longname4, longname5, longname6, 1354 | longname7, longname8, longname9) 1355 | """ 1356 | code_fixed = """\ 1357 | {imp} 1358 | 1359 | print(longname1, longname2, longname3, longname4, longname5, longname6, 1360 | longname7, longname8, longname9) 1361 | """ 1362 | repls = { 1363 | "reallyreallylongmodulename": [ 1364 | "longname1", 1365 | "longname2", 1366 | "longname3", 1367 | "longname4", 1368 | "longname5", 1369 | "longname6", 1370 | "longname7", 1371 | "longname8", 1372 | "longname9", 1373 | ] 1374 | } 1375 | 1376 | assert replace_imports(code, repls) == code_fixed.format( 1377 | imp="""\ 1378 | from reallyreallylongmodulename import (longname1, longname2, longname3, longname4, longname5, 1379 | longname6, longname7, longname8, longname9)""" 1380 | ) 1381 | 1382 | # Make sure the first line has at least one imported name. 1383 | # There's no point to doing 1384 | # 1385 | # from mod import ( 1386 | # name, 1387 | # 1388 | # if we are aligning the names to the opening parenthesis anyway. 1389 | assert replace_imports(code, repls, max_line_length=49) == code_fixed.format( 1390 | imp="""\ 1391 | from reallyreallylongmodulename import (longname1, 1392 | longname2, 1393 | longname3, 1394 | longname4, 1395 | longname5, 1396 | longname6, 1397 | longname7, 1398 | longname8, 1399 | longname9)""" 1400 | ) 1401 | 1402 | assert replace_imports(code, repls, max_line_length=50) == code_fixed.format( 1403 | imp="""\ 1404 | from reallyreallylongmodulename import (longname1, 1405 | longname2, 1406 | longname3, 1407 | longname4, 1408 | longname5, 1409 | longname6, 1410 | longname7, 1411 | longname8, 1412 | longname9)""" 1413 | ) 1414 | 1415 | assert replace_imports(code, repls, max_line_length=51) == code_fixed.format( 1416 | imp="""\ 1417 | from reallyreallylongmodulename import (longname1, 1418 | longname2, 1419 | longname3, 1420 | longname4, 1421 | longname5, 1422 | longname6, 1423 | longname7, 1424 | longname8, 1425 | longname9)""" 1426 | ) 1427 | 1428 | assert replace_imports(code, repls, max_line_length=120) == code_fixed.format( 1429 | imp="""\ 1430 | from reallyreallylongmodulename import (longname1, longname2, longname3, longname4, longname5, longname6, longname7, 1431 | longname8, longname9)""" # noqa: E501 1432 | ) 1433 | 1434 | assert_to = 136 1435 | 1436 | assert ( 1437 | len( 1438 | "from reallyreallylongmodulename import longname1, longname2, longname3, longname4, longname5, longname6, longname7, longname8, longname9" # noqa: E501 1439 | ) 1440 | == assert_to 1441 | ) 1442 | 1443 | assert replace_imports(code, repls, max_line_length=137) == code_fixed.format( 1444 | imp="""\ 1445 | from reallyreallylongmodulename import longname1, longname2, longname3, longname4, longname5, longname6, longname7, longname8, longname9""" # noqa: E501 1446 | ) 1447 | 1448 | assert replace_imports(code, repls, max_line_length=136) == code_fixed.format( 1449 | imp="""\ 1450 | from reallyreallylongmodulename import longname1, longname2, longname3, longname4, longname5, longname6, longname7, longname8, longname9""" # noqa: E501 1451 | ) 1452 | 1453 | assert replace_imports(code, repls, max_line_length=135) == code_fixed.format( 1454 | imp="""\ 1455 | from reallyreallylongmodulename import (longname1, longname2, longname3, longname4, longname5, longname6, longname7, longname8, 1456 | longname9)""" # noqa: E501 1457 | ) 1458 | 1459 | assert replace_imports(code, repls, max_line_length=200) == code_fixed.format( 1460 | imp="""\ 1461 | from reallyreallylongmodulename import longname1, longname2, longname3, longname4, longname5, longname6, longname7, longname8, longname9""" # noqa: E501 1462 | ) 1463 | 1464 | assert replace_imports(code, repls, max_line_length=float("inf")) == code_fixed.format( 1465 | imp="""\ 1466 | from reallyreallylongmodulename import longname1, longname2, longname3, longname4, longname5, longname6, longname7, longname8, longname9""" # noqa: E501 1467 | ) 1468 | 1469 | 1470 | @pytest.mark.parametrize( 1471 | "case_permutation", 1472 | [lambda s: s, lambda s: s.upper(), lambda s: s.lower()], 1473 | ids=["same case", "upper case", "lower case"], 1474 | ) 1475 | @pytest.mark.parametrize( 1476 | ("allows_star", "comment"), 1477 | [ 1478 | (True, "# noqa"), 1479 | (True, "#noqa"), 1480 | (True, "# noqa "), 1481 | (False, "# noqa foo bar"), 1482 | (False, "# noqa:"), 1483 | (False, "# noqa :"), 1484 | (True, "# noqa: F401"), 1485 | (True, "#noqa:F401"), 1486 | (True, "# noqa: F401 "), 1487 | (True, "#\tnoqa:\tF401\t"), 1488 | (True, "# noqa: F403"), 1489 | (True, "# noqa: A1,F403,A1"), 1490 | (True, "# noqa: A1 F401 A1"), 1491 | (True, "# noqa: A1, F401, A1"), 1492 | (True, "# noqa: A1 , F401 , A1"), 1493 | (False, "# generic comment"), 1494 | (False, "#"), 1495 | (False, ""), 1496 | (False, "# foo: F401"), 1497 | (False, "# F401"), 1498 | (False, "# noqa F401"), # missing : after noqa 1499 | ], 1500 | ) 1501 | def test_is_noqa_comment_allowing_star_import(case_permutation, allows_star, comment): 1502 | assert is_noqa_comment_allowing_star_import(case_permutation(comment)) is allows_star 1503 | 1504 | 1505 | def _dirs_equal(cmp): 1506 | if cmp.diff_files: 1507 | return False 1508 | if not cmp.subdirs: 1509 | return True 1510 | return all(_dirs_equal(c) for c in cmp.subdirs.values()) 1511 | 1512 | 1513 | def test_cli(tmpdir): 1514 | from removestar.__main__ import __file__ 1515 | 1516 | # TODO: Test the verbose and quiet flags 1517 | directory_orig = tmpdir / "orig" / "module" 1518 | directory = tmpdir / "module" 1519 | create_module(directory) 1520 | create_module(directory_orig) 1521 | 1522 | cmp = dircmp(directory, directory_orig) 1523 | assert _dirs_equal(cmp) 1524 | 1525 | # Make sure we are running the command for the right file 1526 | p = subprocess.run( 1527 | [sys.executable, "-m", "removestar", "--_this-file", "none"], 1528 | capture_output=True, 1529 | encoding="utf-8", 1530 | check=False, 1531 | ) 1532 | assert p.stderr == "" 1533 | assert p.stdout == __file__ 1534 | 1535 | p = subprocess.run( 1536 | [sys.executable, "-m", "removestar", directory], 1537 | capture_output=True, 1538 | encoding="utf-8", 1539 | check=False, 1540 | ) 1541 | warnings = set( 1542 | f"""\ 1543 | Warning: {directory}/submod/submod1.py: 'b' comes from multiple modules: '..mod1', '..mod2'. Using '..mod2'. 1544 | Warning: {directory}/submod/submod1.py: could not find import for 'd' 1545 | Warning: {directory}/submod/submod2.py: 'b' comes from multiple modules: 'module.mod1', 'module.mod2'. Using 'module.mod2'. 1546 | Warning: {directory}/submod/submod2.py: could not find import for 'd' 1547 | Warning: {directory}/mod4.py: 'b' comes from multiple modules: '.mod1', '.mod2'. Using '.mod2'. 1548 | Warning: {directory}/mod4.py: could not find import for 'd' 1549 | Warning: {directory}/mod5.py: 'b' comes from multiple modules: 'module.mod1', 'module.mod2'. Using 'module.mod2'. 1550 | Warning: {directory}/mod5.py: could not find import for 'd' 1551 | Warning: {directory}/mod_unfixable.py: Could not find the star imports for '.mod1' 1552 | Warning: {directory}/mod_unfixable.py: Could not find the star imports for '.mod2' 1553 | Warning: {directory}/mod_commented_unused_star.py: The removed star import statement for '.mod1' had an inline comment which may not make sense without the import 1554 | """.splitlines() # noqa: E501 1555 | ) 1556 | colored_warnings = {yellow(warning) for warning in warnings} 1557 | 1558 | error = red( 1559 | f"Error with {directory}/mod_bad.py: SyntaxError: invalid syntax (mod_bad.py, line 1)" 1560 | ) 1561 | assert set(p.stderr.splitlines()) == colored_warnings.union({error}) 1562 | 1563 | diffs = [ 1564 | f"""\ 1565 | --- original/{directory}/mod4.py 1566 | +++ fixed/{directory}/mod4.py 1567 | @@ -1,5 +1,5 @@ 1568 | -from .mod1 import * 1569 | -from .mod2 import * 1570 | +from .mod1 import a 1571 | +from .mod2 import b, c 1572 | from .mod3 import name 1573 | \n\ 1574 | def func():\ 1575 | """, 1576 | f"""\ 1577 | --- original/{directory}/mod5.py 1578 | +++ fixed/{directory}/mod5.py 1579 | @@ -1,5 +1,5 @@ 1580 | -from module.mod1 import * 1581 | -from module.mod2 import * 1582 | +from module.mod1 import a 1583 | +from module.mod2 import b, c 1584 | from module.mod3 import name 1585 | \n\ 1586 | def func():\ 1587 | """, 1588 | f"""\ 1589 | --- original/{directory}/mod6.py 1590 | +++ fixed/{directory}/mod6.py 1591 | @@ -1,2 +1,2 @@ 1592 | -from os.path import * 1593 | +from os.path import isfile, join 1594 | isfile(join('a', 'b'))\ 1595 | """, 1596 | f"""\ 1597 | --- original/{directory}/mod7.py 1598 | +++ fixed/{directory}/mod7.py 1599 | @@ -1 +0,0 @@ 1600 | -from .mod6 import *\ 1601 | """, 1602 | f"""\ 1603 | --- original/{directory}/mod9.py 1604 | +++ fixed/{directory}/mod9.py 1605 | @@ -1,4 +1,4 @@ 1606 | -from .mod8 import * 1607 | +from .mod8 import a, b 1608 | \n\ 1609 | def func(): 1610 | return a + b\ 1611 | """, 1612 | f"""\ 1613 | --- original/{directory}/mod_commented_unused_star.py 1614 | +++ fixed/{directory}/mod_commented_unused_star.py 1615 | @@ -1,2 +1,2 @@ 1616 | -from .mod1 import * # comment about mod1 1617 | +# comment about mod1 1618 | from .mod2 import * # noqa\ 1619 | """, 1620 | f"""\ 1621 | --- original/{directory}/mod_commented_star.py 1622 | +++ fixed/{directory}/mod_commented_star.py 1623 | @@ -1,6 +1,6 @@ 1624 | from .mod1 import * # noqa 1625 | from .mod2 import * # noqa: F401 1626 | -from .mod3 import * # generic comment 1627 | +from .mod3 import name # generic comment 1628 | \n\ 1629 | def func():\ 1630 | """, 1631 | f"""\ 1632 | --- original/{directory}/submod/submod1.py 1633 | +++ fixed/{directory}/submod/submod1.py 1634 | @@ -1,7 +1,7 @@ 1635 | -from ..mod1 import * 1636 | -from ..mod2 import * 1637 | +from ..mod1 import a 1638 | +from ..mod2 import b, c 1639 | from ..mod3 import name 1640 | -from .submod3 import * 1641 | +from .submod3 import e 1642 | \n\ 1643 | def func(): 1644 | return a + b + c + d + d + e + name\ 1645 | """, 1646 | f"""\ 1647 | --- original/{directory}/submod/submod2.py 1648 | +++ fixed/{directory}/submod/submod2.py 1649 | @@ -1,7 +1,7 @@ 1650 | -from module.mod1 import * 1651 | -from module.mod2 import * 1652 | +from module.mod1 import a 1653 | +from module.mod2 import b, c 1654 | from module.mod3 import name 1655 | -from module.submod.submod3 import * 1656 | +from module.submod.submod3 import e 1657 | \n\ 1658 | def func(): 1659 | return a + b + c + d + d + e + name\ 1660 | """, 1661 | f"""\ 1662 | --- original/{directory}/submod/submod4.py 1663 | +++ fixed/{directory}/submod/submod4.py 1664 | @@ -1,3 +1,3 @@ 1665 | -from . import * 1666 | +from . import func 1667 | \n\ 1668 | func()\ 1669 | """, 1670 | f"""\ 1671 | --- original/{directory}/submod_recursive/submod2.py 1672 | +++ fixed/{directory}/submod_recursive/submod2.py 1673 | @@ -1,4 +1,4 @@ 1674 | -from . import * 1675 | +from . import a 1676 | \n\ 1677 | def func(): 1678 | return a + 1\ 1679 | """, 1680 | ] 1681 | unchanged = ["__init__.py", "mod_bad.py", "mod_unfixable.py"] 1682 | for d in diffs: 1683 | assert get_colored_diff(d) in p.stdout, p.stdout 1684 | for mod_path in unchanged: 1685 | assert f"--- original/{directory}/{mod_path}" not in p.stdout 1686 | cmp = dircmp(directory, directory_orig) 1687 | assert _dirs_equal(cmp) 1688 | 1689 | p = subprocess.run( 1690 | [sys.executable, "-m", "removestar", "--quiet", directory], 1691 | capture_output=True, 1692 | encoding="utf-8", 1693 | check=False, 1694 | ) 1695 | assert p.stderr == "" 1696 | for d in diffs: 1697 | assert get_colored_diff(d) in p.stdout 1698 | cmp = dircmp(directory, directory_orig) 1699 | assert _dirs_equal(cmp) 1700 | 1701 | p = subprocess.run( 1702 | [sys.executable, "-m", "removestar", "--verbose", directory], 1703 | capture_output=True, 1704 | encoding="utf-8", 1705 | check=False, 1706 | ) 1707 | changes = set( 1708 | f"""\ 1709 | {directory}/mod4.py: Replacing 'from .mod1 import *' with 'from .mod1 import a' 1710 | {directory}/mod4.py: Replacing 'from .mod2 import *' with 'from .mod2 import b, c' 1711 | {directory}/mod5.py: Replacing 'from module.mod1 import *' with 'from module.mod1 import a' 1712 | {directory}/mod5.py: Replacing 'from module.mod2 import *' with 'from module.mod2 import b, c' 1713 | {directory}/mod6.py: Replacing 'from os.path import *' with 'from os.path import isfile, join' 1714 | {directory}/mod7.py: Replacing 'from .mod6 import *' with '' 1715 | {directory}/mod9.py: Replacing 'from .mod8 import *' with 'from .mod8 import a, b' 1716 | {directory}/mod_commented_unused_star.py: Replacing 'from .mod1 import *' with '' 1717 | {directory}/mod_commented_unused_star.py: Retaining 'from .mod2 import *' due to noqa comment 1718 | {directory}/mod_commented_star.py: Replacing 'from .mod3 import *' with 'from .mod3 import name' 1719 | {directory}/mod_commented_star.py: Retaining 'from .mod1 import *' due to noqa comment 1720 | {directory}/mod_commented_star.py: Retaining 'from .mod2 import *' due to noqa comment 1721 | {directory}/submod/submod1.py: Replacing 'from ..mod1 import *' with 'from ..mod1 import a' 1722 | {directory}/submod/submod1.py: Replacing 'from ..mod2 import *' with 'from ..mod2 import b, c' 1723 | {directory}/submod/submod1.py: Replacing 'from .submod3 import *' with 'from .submod3 import e' 1724 | {directory}/submod/submod4.py: Replacing 'from . import *' with 'from . import func' 1725 | {directory}/submod/submod2.py: Replacing 'from module.mod1 import *' with 'from module.mod1 import a' 1726 | {directory}/submod/submod2.py: Replacing 'from module.mod2 import *' with 'from module.mod2 import b, c' 1727 | {directory}/submod/submod2.py: Replacing 'from module.submod.submod3 import *' with 'from module.submod.submod3 import e' 1728 | {directory}/submod_recursive/submod2.py: Replacing 'from . import *' with 'from . import a' 1729 | """.splitlines() # noqa: E501 1730 | ) 1731 | colored_changes = {green(change) for change in changes} 1732 | 1733 | assert set(p.stderr.splitlines()) == colored_changes.union({error}).union(colored_warnings) 1734 | for d in diffs: 1735 | assert get_colored_diff(d) in p.stdout, p.stdout 1736 | cmp = dircmp(directory, directory_orig) 1737 | assert _dirs_equal(cmp) 1738 | 1739 | p = subprocess.run( 1740 | [sys.executable, "-m", "removestar", "--no-dynamic-importing", directory], 1741 | capture_output=True, 1742 | encoding="utf-8", 1743 | check=False, 1744 | ) 1745 | static_error = set( 1746 | f"""\ 1747 | Error with {directory}/mod6.py: Static determination of external module imports is not supported. 1748 | Error with {directory}/mod7.py: Static determination of external module imports is not supported. 1749 | """.splitlines() 1750 | ) 1751 | colored_static_error = {red(err) for err in static_error} 1752 | assert set(p.stderr.splitlines()) == {error}.union(colored_static_error).union(colored_warnings) 1753 | for d in diffs: 1754 | if "mod6" in d: 1755 | assert get_colored_diff(d) not in p.stdout 1756 | else: 1757 | assert get_colored_diff(d) in p.stdout, p.stdout 1758 | cmp = dircmp(directory, directory_orig) 1759 | assert _dirs_equal(cmp) 1760 | 1761 | # Test --quiet hides both errors 1762 | p = subprocess.run( 1763 | [ 1764 | sys.executable, 1765 | "-m", 1766 | "removestar", 1767 | "--quiet", 1768 | "--no-dynamic-importing", 1769 | directory, 1770 | ], 1771 | capture_output=True, 1772 | encoding="utf-8", 1773 | check=False, 1774 | ) 1775 | assert p.stderr == "" 1776 | for d in diffs: 1777 | if "mod6" in d: 1778 | assert get_colored_diff(d) not in p.stdout 1779 | else: 1780 | assert get_colored_diff(d) in p.stdout, p.stdout 1781 | cmp = dircmp(directory, directory_orig) 1782 | assert _dirs_equal(cmp) 1783 | 1784 | # XXX: This modifies directory, so keep it at the end of the test 1785 | p = subprocess.run( 1786 | [sys.executable, "-m", "removestar", "--quiet", "-i", directory], 1787 | capture_output=True, 1788 | encoding="utf-8", 1789 | check=False, 1790 | ) 1791 | assert p.stderr == "" 1792 | assert p.stdout == "" 1793 | cmp = dircmp(directory, directory_orig) 1794 | assert not _dirs_equal(cmp) 1795 | assert cmp.diff_files == [ 1796 | "mod4.py", 1797 | "mod5.py", 1798 | "mod6.py", 1799 | "mod7.py", 1800 | "mod9.py", 1801 | "mod_commented_star.py", 1802 | "mod_commented_unused_star.py", 1803 | ] 1804 | assert cmp.subdirs["submod"].diff_files == [ 1805 | "submod1.py", 1806 | "submod2.py", 1807 | "submod4.py", 1808 | ] 1809 | assert cmp.subdirs["submod_recursive"].diff_files == ["submod2.py"] 1810 | with open(directory / "mod4.py") as f: 1811 | assert f.read() == code_mod4_fixed 1812 | with open(directory / "mod5.py") as f: 1813 | assert f.read() == code_mod5_fixed 1814 | with open(directory / "mod6.py") as f: 1815 | assert f.read() == code_mod6_fixed 1816 | with open(directory / "mod7.py") as f: 1817 | assert f.read() == code_mod7_fixed 1818 | with open(directory / "mod9.py") as f: 1819 | assert f.read() == code_mod9_fixed 1820 | with open(directory / "mod_commented_unused_star.py") as f: 1821 | assert f.read() == code_mod_commented_unused_star_fixed 1822 | with open(directory / "mod_commented_star.py") as f: 1823 | assert f.read() == code_mod_commented_star_fixed 1824 | with open(directory / "submod" / "submod1.py") as f: 1825 | assert f.read() == code_submod1_fixed 1826 | with open(directory / "submod" / "submod2.py") as f: 1827 | assert f.read() == code_submod2_fixed 1828 | with open(directory / "submod" / "submod4.py") as f: 1829 | assert f.read() == code_submod4_fixed 1830 | with open(directory / "submod_recursive" / "submod2.py") as f: 1831 | assert f.read() == code_submod_recursive_submod2_fixed 1832 | with open(directory / "mod_bad.py") as f: 1833 | assert f.read() == code_bad_syntax 1834 | with open(directory / "mod_unfixable.py") as f: 1835 | assert f.read() == code_mod_unfixable 1836 | 1837 | # Test error on nonexistent file 1838 | p = subprocess.run( 1839 | [sys.executable, "-m", "removestar", directory / "notarealfile.py"], 1840 | capture_output=True, 1841 | encoding="utf-8", 1842 | check=False, 1843 | ) 1844 | assert p.stderr == red(f"Error: {directory}/notarealfile.py: no such file or directory") + "\n" 1845 | assert p.stdout == "" 1846 | -------------------------------------------------------------------------------- /tests/test_removestar_nb.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | import pytest 5 | 6 | from removestar.removestar import fix_code, replace_in_nb 7 | 8 | nbf = pytest.importorskip("nbformat") 9 | nbc = pytest.importorskip("nbconvert") 10 | 11 | fixed_code = """#!/usr/bin/env python 12 | # coding: utf-8 13 | # # Notebook for testing. 14 | # In[ ]: 15 | ## import 16 | from os.path import exists 17 | # In[ ]: 18 | ## use of imported function 19 | exists('_test.ipynb')""" 20 | 21 | 22 | def prepare_nb(output_path="_test.ipynb"): 23 | """Make a demo notebook.""" 24 | 25 | nb = nbf.v4.new_notebook() 26 | nb["cells"] = [ 27 | nbf.v4.new_markdown_cell("# Notebook for testing."), 28 | nbf.v4.new_code_cell("## import\nfrom os.path import *"), 29 | nbf.v4.new_code_cell(f"## use of imported function\nexists('{output_path}')"), 30 | ] 31 | with open(output_path, "w") as f: 32 | nbf.write(nb, f) 33 | 34 | tmp_file = tempfile.NamedTemporaryFile() # noqa: SIM115 35 | tmp_path = tmp_file.name 36 | 37 | with open(output_path) as f: 38 | nb = nbf.reads(f.read(), nbf.NO_CONVERT) 39 | 40 | exporter = nbc.PythonExporter() 41 | code, _ = exporter.from_notebook_node(nb) 42 | tmp_file.write(code.encode("utf-8")) 43 | 44 | return code, tmp_path 45 | 46 | 47 | def test_fix_code_for_nb(): 48 | code, tmp_path = prepare_nb(output_path="_test.ipynb") 49 | assert os.path.exists("_test.ipynb") 50 | 51 | new_code = fix_code( 52 | code=code, 53 | file=tmp_path, 54 | return_replacements=True, 55 | ) 56 | assert new_code == {"from os.path import *": "from os.path import exists"} 57 | 58 | new_code_not_dict = fix_code( 59 | code=code, 60 | file=tmp_path, 61 | return_replacements=False, 62 | ) 63 | assert "\n".join([s for s in new_code_not_dict.split("\n") if s]) == fixed_code 64 | 65 | os.remove("_test.ipynb") 66 | 67 | 68 | def test_replace_nb(): 69 | prepare_nb(output_path="_test.ipynb") 70 | assert os.path.exists("_test.ipynb") 71 | new_code = {"from os.path import *": "from os.path import exists"} 72 | with open("_test.ipynb") as f: 73 | nb = nbf.reads(f.read(), nbf.NO_CONVERT) 74 | fixed_code = replace_in_nb( 75 | nb, 76 | new_code, 77 | cell_type="code", 78 | ) 79 | 80 | with open("_test.ipynb", "w+") as f: 81 | f.writelines(fixed_code) 82 | 83 | exporter = nbc.NotebookExporter() 84 | code, _ = exporter.from_filename("_test.ipynb") 85 | 86 | assert code == fixed_code 87 | 88 | os.remove("_test.ipynb") 89 | --------------------------------------------------------------------------------