├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── README.md ├── pymender ├── __init__.py ├── __main__.py ├── cli.py └── commands │ ├── __init__.py │ ├── fastapi_annotated.py │ └── tests │ ├── __init__.py │ └── test_fastapi_annotated.py ├── pyproject.toml ├── requirements-dev.txt └── requirements.txt /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # Based on 2 | # https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 3 | 4 | name: Publish Python distributions to PyPI 5 | 6 | on: 7 | push: 8 | tags: 9 | - '*' 10 | 11 | jobs: 12 | build-n-publish: 13 | name: Build and publish Python distributions to PyPI 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Python 3.10 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: "3.10" 21 | - name: Install pypa/build 22 | run: >- 23 | python -m 24 | pip install 25 | build 26 | --user 27 | - name: Build a binary wheel and a source tarball 28 | run: >- 29 | python -m 30 | build 31 | --sdist 32 | --wheel 33 | --outdir dist/ 34 | . 35 | - name: Publish distribution 📦 to Test PyPI 36 | uses: pypa/gh-action-pypi-publish@release/v1 37 | with: 38 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 39 | repository-url: https://test.pypi.org/legacy/ 40 | - name: Publish distribution to PyPI 41 | if: startsWith(github.ref, 'refs/tags') 42 | uses: pypa/gh-action-pypi-publish@release/v1 43 | with: 44 | password: ${{ secrets.PYPI_API_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: push 4 | 5 | jobs: 6 | 7 | test: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Set up Python 3.10 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: "3.10" 16 | - run: pip3 install -r requirements-dev.txt 17 | - run: pytest -vv 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | ### Python ### 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # poetry 100 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 101 | # This is especially recommended for binary packages to ensure reproducibility, and is more 102 | # commonly ignored for libraries. 103 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 104 | #poetry.lock 105 | 106 | # pdm 107 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 108 | #pdm.lock 109 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 110 | # in version control. 111 | # https://pdm.fming.dev/#use-with-ide 112 | .pdm.toml 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env* 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | 164 | ### Python Patch ### 165 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 166 | poetry.toml 167 | 168 | # ruff 169 | .ruff_cache/ 170 | 171 | # LSP config files 172 | pyrightconfig.json 173 | 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 👷‍♀️ PyMender 2 | 3 | Perform entire codebase refactors in a way that is _reproducible_, _testable_ and _reviewable_. Obeys `.gitignore` by default. 4 | 5 | ## Usage 6 | 7 | ```bash 8 | pip install pymender==0.2.0 9 | 10 | pymender 11 | ``` 12 | 13 | ## What codemods are available? 14 | 15 | ### ⚡ `FastAPIAnnotated` 16 | 17 | Converts FastAPI endpoints to use the preferred `Annotated[, Depends()]` syntax rather than `: = Depends()`. 18 | 19 | **Why?** 20 | 21 | - *Default* value of the function parameter is the *actual default* value. 22 | - *Reuse* of the function is now possible. 23 | - *Type-safe* usage of the functions, previously 'default' values where not type checked. 24 | - *Multi-purpose* e.g. `Annotated[str, fastapi.Query(), typer.Argument()]` is now possible. 25 | 26 | ```bash 27 | pymender FastAPIAnnotated 28 | ``` 29 | 30 | | Before | After | 31 | | --- | --- | 32 | |
@router.get('/example')
def example_function(
value: int,
query: str = Query("foo"),
zar: str = Query(default="bar", alias="z"),
foo: str = Depends(get_foo),
*,
bar: str = Depends(get_bar),
body: str = Body(...),
) -> str:
return 'example'
|
@router.get('/example')
def example_function(
value: int,
foo: Annotated[str, Depends(get_foo)],
query: Annotated[str, Query()] = "foo",
zar: Annotated[str, Query(alias="z")] = "bar",
*,
bar: Annotated[str, Depends(get_bar)],
body: Annotated[str, Body()],
) -> str:
return 'example'
| 33 | 34 | ## Developer Guide 35 | 36 | ```bash 37 | # Run a particular codemod 38 | python3 -m pymender 39 | # e.g. 40 | python3 -m pymender FastAPIAnnotated 41 | 42 | # Run the codemod directly 43 | python3 -m libcst.tool codemode fastapi_annotated.FastAPIAnnotated 44 | 45 | # Run tests 46 | pytest -vv 47 | 48 | ``` 49 | 50 | ## Thanks to: 51 | 52 | - [libCST](https://github.com/Instagram/LibCST) which does a lot of the hardwork for this. 53 | - [autotyping](https://github.com/JelleZijlstra/autotyping) for showing what was possible. 54 | -------------------------------------------------------------------------------- /pymender/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DJRHails/PyMender/5530d1c3af025e672cd83553605f5304b58506ff/pymender/__init__.py -------------------------------------------------------------------------------- /pymender/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from .cli import main 3 | 4 | if __name__ == "__main__": 5 | sys.exit(main()) -------------------------------------------------------------------------------- /pymender/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import inspect 3 | import os 4 | import pkgutil 5 | import sys 6 | from pathlib import Path 7 | from libcst.codemod import ( 8 | CodemodCommand, 9 | VisitorBasedCodemodCommand, 10 | CodemodContext, 11 | gather_files, 12 | parallel_exec_transform_with_prettyprint, 13 | ) 14 | from gitignore_parser import parse_gitignore 15 | from pymender import commands 16 | 17 | def run_command(Command: type[CodemodCommand], repo_root: str = ".") -> int: 18 | os.environ["LIBCST_PARSER_TYPE"] = "native" 19 | parser = argparse.ArgumentParser( 20 | description=Command.DESCRIPTION, 21 | ) 22 | parser.add_argument( 23 | "command", 24 | metavar="COMMAND", 25 | ) 26 | parser.add_argument( 27 | "path", 28 | metavar="PATH", 29 | nargs="+", 30 | help=( 31 | "Path to codemod. Can be a directory, file, or multiple of either. To " 32 | + 'instead read from stdin and write to stdout, use "-"' 33 | ), 34 | ) 35 | parser.add_argument( 36 | "--gitignore", 37 | default=".gitignore", 38 | action="store", 39 | metavar="GITIGNORE", 40 | help=( 41 | "Path to a gitignore file. Files that match the gitignore will not be " 42 | + "codemodded." 43 | ), 44 | ) 45 | args = parser.parse_args() 46 | 47 | context = CodemodContext() 48 | command_instance = Command(context) 49 | files = gather_files(args.path) 50 | print(f"Found {len(files)} files in {args.path}", file=sys.stderr) 51 | 52 | gitignore_path = Path(args.gitignore) if args.gitignore else Path(repo_root) / ".gitignore" 53 | 54 | if gitignore_path.exists() and (validator := parse_gitignore(gitignore_path)): 55 | print(f"Filtering files through '{gitignore_path}'...", file=sys.stderr) 56 | files = [file for file in files if not validator(file)] 57 | print(f"Found {len(files)} files after filtering", file=sys.stderr) 58 | 59 | if not files: 60 | print("No files to modify! Check if you need to adjust the .gitignore", file=sys.stderr) 61 | return 1 62 | 63 | try: 64 | result = parallel_exec_transform_with_prettyprint( 65 | command_instance, files, repo_root=repo_root, 66 | ) 67 | except KeyboardInterrupt: 68 | print("Interrupted!", file=sys.stderr) 69 | return 2 70 | 71 | # Print a fancy summary at the end. 72 | print( 73 | f"Finished codemodding {result.successes + result.skips + result.failures} files!", 74 | file=sys.stderr, 75 | ) 76 | print(f" - Transformed {result.successes} files successfully.", file=sys.stderr) 77 | print(f" - Skipped {result.skips} files.", file=sys.stderr) 78 | print(f" - Failed to codemod {result.failures} files.", file=sys.stderr) 79 | print(f" - {result.warnings} warnings were generated.", file=sys.stderr) 80 | if result.failures > 0: 81 | return 1 82 | return 0 83 | 84 | 85 | 86 | def discover_commands() -> list[type[CodemodCommand]]: 87 | codemod_command_classes = set() 88 | for loader, name, _ in pkgutil.walk_packages(commands.__path__, commands.__name__ + '.'): 89 | module = loader.find_module(name).load_module(name) 90 | for name, obj in inspect.getmembers(module): 91 | if inspect.isclass(obj) and issubclass(obj, CodemodCommand) and not inspect.isabstract(obj): 92 | codemod_command_classes.add(obj) 93 | codemod_command_classes -= {VisitorBasedCodemodCommand} 94 | return codemod_command_classes 95 | 96 | def main() -> int: 97 | commands = discover_commands() 98 | command_lookup = {cmd.__name__: cmd for cmd in commands} 99 | 100 | parser = argparse.ArgumentParser( 101 | description="Mends Python code to be more idiomatic." 102 | ) 103 | parser.add_argument( 104 | "command", 105 | metavar="COMMAND", 106 | choices=command_lookup.keys(), 107 | help=f"The codemod to run, choose from: {', '.join(command_lookup.keys())}", 108 | ) 109 | args, _ = parser.parse_known_args() 110 | 111 | Command = command_lookup.get(args.command) 112 | if not Command: 113 | print(f"Unknown command {args.command}", file=sys.stderr) 114 | return 1 115 | return run_command(Command) 116 | -------------------------------------------------------------------------------- /pymender/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DJRHails/PyMender/5530d1c3af025e672cd83553605f5304b58506ff/pymender/commands/__init__.py -------------------------------------------------------------------------------- /pymender/commands/fastapi_annotated.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import libcst as cst 5 | from libcst.codemod import VisitorBasedCodemodCommand 6 | from libcst.codemod.visitors import AddImportsVisitor 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def wrap_subscript_elements_with_annotated( 12 | param: cst.Param, 13 | elements: list[cst.BaseExpression | None], 14 | default: cst.BaseExpression | None, 15 | ) -> cst.Param: 16 | annotated = cst.Annotation( 17 | cst.Subscript( 18 | value=cst.Name("Annotated"), 19 | slice=tuple( 20 | [ 21 | cst.SubscriptElement(cst.Index(elem)) 22 | for elem in elements 23 | if elem is not None 24 | ] 25 | ), 26 | ) 27 | ) 28 | return param.with_changes( 29 | annotation=annotated, equal=cst.MaybeSentinel.DEFAULT, default=default 30 | ) 31 | 32 | 33 | def adapt_param(param: cst.Param) -> cst.Param | None: 34 | match param.default: 35 | case cst.Call(func=cst.Name("Depends")): 36 | logger.info(f"Found Depends annotation") 37 | elements: list[cst.BaseExpression | None] = [ 38 | param.annotation.annotation if param.annotation is not None else None, 39 | param.default, 40 | ] 41 | return wrap_subscript_elements_with_annotated(param, elements, None) 42 | case cst.Call(func=cst.Name("Body")) | cst.Call(func=cst.Name("Query")): 43 | logger.info(f"Found Body annotation") 44 | 45 | call = param.default 46 | 47 | # Find the default value (either first unnamed argument or default=) 48 | first_unnamed_arg = next( 49 | (arg for arg in call.args if arg.keyword is None), 50 | None, 51 | ) 52 | default_keyword = next( 53 | ( 54 | arg 55 | for arg in call.args 56 | if arg.keyword and arg.keyword.value == "default" 57 | ), 58 | None, 59 | ) 60 | default = default_keyword or first_unnamed_arg 61 | 62 | if default is not None: 63 | call = call.with_changes( 64 | args=[arg for arg in call.args if arg != default] 65 | ) 66 | 67 | elements = [ 68 | param.annotation.annotation if param.annotation is not None else None, 69 | call, 70 | ] 71 | 72 | def should_be_included_as_default(default: cst.BaseExpression) -> bool: 73 | return default and not isinstance(default.value, cst.Ellipsis) 74 | 75 | return wrap_subscript_elements_with_annotated( 76 | param, 77 | elements, 78 | default.value 79 | if should_be_included_as_default(default) 80 | else None, 81 | ) 82 | 83 | case _: 84 | return None 85 | 86 | 87 | def sort_params_preserving_spacing(params: list[cst.Param]) -> list[cst.Param]: 88 | return [ 89 | param.with_changes(comma=params[idx].comma) 90 | for idx, param in enumerate( 91 | sorted(params, key=lambda param: param.default is None, reverse=True) 92 | ) 93 | ] 94 | 95 | 96 | class FastAPIAnnotated(VisitorBasedCodemodCommand): 97 | DESCRIPTION = "Converts FastAPI annotations to use Annotated." 98 | 99 | def visit_FunctionDef(self, node: cst.FunctionDef) -> None: 100 | new_parameters: list[cst.Param] = [] 101 | new_kwonlyparams: list[cst.Param] = [] 102 | has_changes = False 103 | 104 | for param in node.params.params: 105 | adapted_param = adapt_param(param) 106 | if adapted_param is not None: 107 | new_parameters.append(adapted_param) 108 | has_changes = True 109 | else: 110 | new_parameters.append(param) 111 | 112 | for param in node.params.kwonly_params: 113 | adapted_param = adapt_param(param) 114 | if adapted_param is not None: 115 | new_kwonlyparams.append(adapted_param) 116 | has_changes = True 117 | else: 118 | new_kwonlyparams.append(param) 119 | 120 | if not has_changes: 121 | return 122 | 123 | # Sort the parameters to make sure default parameters are at the end 124 | # Preserve the original comma formats 125 | new_parameters = sort_params_preserving_spacing(new_parameters) 126 | new_kwonlyparams = sort_params_preserving_spacing(new_kwonlyparams) 127 | 128 | logger.debug( 129 | f"Converted {node.name.value} parameters: {new_parameters} * {new_kwonlyparams}" 130 | ) 131 | 132 | self.context.scratch[node] = node.with_changes( 133 | params=node.params.with_changes( 134 | params=new_parameters, kwonly_params=new_kwonlyparams 135 | ) 136 | ) 137 | 138 | def leave_FunctionDef( 139 | self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef 140 | ) -> cst.FunctionDef: 141 | new_node = self.context.scratch.get(original_node, updated_node) 142 | if original_node in self.context.scratch: 143 | AddImportsVisitor.add_needed_import(self.context, "typing", "Annotated") 144 | return new_node 145 | 146 | -------------------------------------------------------------------------------- /pymender/commands/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DJRHails/PyMender/5530d1c3af025e672cd83553605f5304b58506ff/pymender/commands/tests/__init__.py -------------------------------------------------------------------------------- /pymender/commands/tests/test_fastapi_annotated.py: -------------------------------------------------------------------------------- 1 | from libcst.codemod import CodemodTest 2 | 3 | from ..fastapi_annotated import FastAPIAnnotated 4 | 5 | EXAMPLE_FUNCTION = """ 6 | 7 | @router.get('/example') 8 | def example_function( 9 | value: int, 10 | query: str = Query("foo"), 11 | zar: str = Query(default="bar", alias="z"), 12 | foo: str = Depends(get_foo), 13 | *, 14 | bar: str = Depends(get_bar), 15 | body: str = Body(...), 16 | ) -> str: 17 | return 'example' 18 | 19 | """ 20 | 21 | EXAMPLE_FUNCTION_WITH_ANNOTATED = """ 22 | from typing import Annotated 23 | 24 | @router.get('/example') 25 | def example_function( 26 | value: int, 27 | foo: Annotated[str, Depends(get_foo)], 28 | query: Annotated[str, Query()] = "foo", 29 | zar: Annotated[str, Query(alias="z")] = "bar", 30 | *, 31 | bar: Annotated[str, Depends(get_bar)], 32 | body: Annotated[str, Body()], 33 | ) -> str: 34 | return 'example' 35 | 36 | """ 37 | 38 | EXAMPLE_ASYNC_FUNCTION = """ 39 | @contacts_enrich_industry_router.post( 40 | "/enrich-industry", 41 | response_model=list[SQSEvent[EnrichCompanyIndustrySchema]], 42 | status_code=status.HTTP_200_OK, 43 | ) 44 | async def enrich_companies_with_industry(*, session: Session = Depends(get_session)): 45 | return [] 46 | """ 47 | 48 | EXAMPLE_ASYNC_FUNCTION_CORRECT = """ 49 | from typing import Annotated 50 | 51 | @contacts_enrich_industry_router.post( 52 | "/enrich-industry", 53 | response_model=list[SQSEvent[EnrichCompanyIndustrySchema]], 54 | status_code=status.HTTP_200_OK, 55 | ) 56 | async def enrich_companies_with_industry(*, session: Annotated[Session, Depends(get_session)]): 57 | return [] 58 | """ 59 | 60 | 61 | class TestFastAPIAnnotatedCommand(CodemodTest): 62 | # The codemod that will be instantiated for us in assertCodemod. 63 | TRANSFORM = FastAPIAnnotated 64 | 65 | def test_substitution(self) -> None: 66 | self.maxDiff = None 67 | self.assertCodemod(EXAMPLE_FUNCTION, EXAMPLE_FUNCTION_WITH_ANNOTATED) 68 | 69 | def test_async_sub(self) -> None: 70 | self.maxDiff = None 71 | self.assertCodemod(EXAMPLE_ASYNC_FUNCTION, EXAMPLE_ASYNC_FUNCTION_CORRECT) 72 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pymender" 3 | version = "0.2.0" 4 | authors = [ 5 | { name="Daniel Hails", email="pymender@hails.info" }, 6 | ] 7 | description = "Mend your Python projects with reproducible refactors" 8 | requires-python = ">=3.10" 9 | dynamic = ["dependencies", "readme"] 10 | 11 | [project.urls] 12 | "Homepage" = "https://github.com/DJRHails/pymender" 13 | "Bug Tracker" = "https://github.com/DJRHails/pymender/issues" 14 | 15 | [build-system] 16 | requires = ["setuptools", "wheel"] 17 | build-backend = "setuptools.build_meta" 18 | 19 | [tool.setuptools.dynamic] 20 | dependencies = {file = ["requirements.txt"]} 21 | readme = {file = ["README.md"], content-type = "text/markdown"} 22 | 23 | [tool.setuptools.packages] 24 | find = {} # Scan the project directory with the default parameters 25 | 26 | 27 | [project.scripts] 28 | pymender = "pymender.cli:main" 29 | 30 | [tool.bumpver] 31 | current_version = "0.2.0" 32 | version_pattern = "MAJOR.MINOR.PATCH" 33 | commit_message = "bump version {old_version} -> {new_version}" 34 | commit = true 35 | tag = true 36 | push = true 37 | 38 | [tool.bumpver.file_patterns] 39 | "pyproject.toml" = [ 40 | 'current_version = "{version}"', 41 | 'version = "{version}"', 42 | ] 43 | "README.md" = [ 44 | "{version}", 45 | ] 46 | 47 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | pytest 3 | bumpver -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | libcst 2 | gitignore_parser --------------------------------------------------------------------------------