├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .libcst.codemod.yaml ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── kwonly_transformer ├── __init__.py ├── main.py └── py.typed ├── pyproject.toml ├── setup.cfg ├── setup.py └── tests ├── __init__.py └── test_transformer.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | tests: 11 | name: "Python ${{ matrix.python-version }}" 12 | runs-on: ubuntu-latest 13 | 14 | timeout-minutes: 30 15 | strategy: 16 | matrix: 17 | python-version: ["3.7", "3.8", "3.9", "3.10"] 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: actions/setup-python@v3 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - uses: actions/cache@v3 26 | with: 27 | path: ~/.cache/pip 28 | key: pip-${{ matrix.python-version }} 29 | 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip setuptools wheel 33 | python -m pip install .[test] 34 | 35 | - name: Run pre-commit 36 | if: ${{ matrix.python-version == '3.10' }} 37 | run: | 38 | python -m pip install pre-commit 39 | pre-commit run --all-files --show-diff-on-failure 40 | 41 | - name: Install dependencies 42 | run: python -m pip install .[test] 43 | 44 | - name: Run tests 45 | run: python -m coverage run -m pytest 46 | 47 | # - name: Upload coverage data 48 | # uses: actions/upload-artifact@v3 49 | # with: 50 | # name: coverage-data 51 | # path: '.coverage.*' 52 | 53 | # coverage: 54 | # name: Coverage 55 | # runs-on: ubuntu-20.04 56 | # needs: tests 57 | # steps: 58 | # - uses: actions/checkout@v3 59 | 60 | # - uses: actions/setup-python@v3 61 | # with: 62 | # python-version: '3.10' 63 | 64 | # - name: Install dependencies 65 | # run: python -m pip install --upgrade coverage[toml] 66 | 67 | # - name: Download data 68 | # uses: actions/download-artifact@v2 69 | # with: 70 | # name: coverage-data 71 | 72 | # - name: Combine coverage and fail if it's <100% 73 | # run: | 74 | # python -m coverage combine 75 | # python -m coverage html --skip-covered --skip-empty 76 | # python -m coverage report --fail-under=100 77 | 78 | # - name: Upload HTML report 79 | # if: ${{ failure() }} 80 | # uses: actions/upload-artifact@v2 81 | # with: 82 | # name: html-report 83 | # path: htmlcov 84 | -------------------------------------------------------------------------------- /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | -------------------------------------------------------------------------------- /.libcst.codemod.yaml: -------------------------------------------------------------------------------- 1 | # String that LibCST should look for in code which indicates that the 2 | # module is generated code. 3 | generated_code_marker: "@generated" 4 | # Command line and arguments for invoking a code formatter. Anything 5 | # specified here must be capable of taking code via stdin and returning 6 | # formatted code via stdout. 7 | formatter: ["black", "-"] 8 | # List of regex patterns which LibCST will evaluate against filenames to 9 | # determine if the module should be touched. 10 | blacklist_patterns: [] 11 | # List of modules that contain codemods inside of them. 12 | modules: 13 | - "kwonly_transformer" 14 | # Absolute or relative path of the repository root, used for providing 15 | # full-repo metadata. Relative paths should be specified with this file 16 | # location as the base. 17 | repo_root: "." 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.10 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v4.2.0 7 | hooks: 8 | - id: check-toml 9 | - id: check-yaml 10 | - id: check-json 11 | - id: check-added-large-files 12 | - id: debug-statements 13 | - id: end-of-file-fixer 14 | - id: trailing-whitespace 15 | 16 | - repo: https://github.com/asottile/pyupgrade 17 | rev: v2.32.1 18 | hooks: 19 | - id: pyupgrade 20 | args: ["--py37-plus", "--keep-mock"] 21 | 22 | - repo: https://github.com/psf/black 23 | rev: 22.3.0 24 | hooks: 25 | - id: black 26 | 27 | - repo: https://github.com/pycqa/isort 28 | rev: 5.10.1 29 | hooks: 30 | - id: isort 31 | 32 | - repo: https://github.com/PyCQA/flake8 33 | rev: 4.0.1 34 | hooks: 35 | - id: flake8 36 | additional_dependencies: 37 | - flake8-bugbear 38 | - flake8-comprehensions 39 | - flake8-tidy-imports 40 | - flake8-typing-imports 41 | 42 | - repo: https://github.com/pre-commit/mirrors-mypy 43 | rev: v0.960 44 | hooks: 45 | - id: mypy 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Marcelo Trylesinski 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help 2 | help: ## Show this help 3 | @egrep -h '\s##\s' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' 4 | 5 | .PHONY: publish 6 | publish: ## Publish release to PyPI 7 | @echo "🔖 Publish to PyPI" 8 | python setup.py bdist_wheel 9 | twine upload dist/* 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | kwonly-transformer 3 |

4 |

5 | 6 | Package version 7 | 8 | 9 | 10 |

11 | 12 | This is a very opinionated tool. The idea is that we want functions with multiple parameters to have **exclusively** keyword only parameters. 13 | 14 | As an example, let's consider a function with multiple parameters. When we are reading the call for that function, 15 | we lose time either checking the reference, or trying to map in our brains what argument matches a specific function parameter. 16 | ```python 17 | def do_something(a, b, c, d, e, f): 18 | ... 19 | 20 | do_something(True, "potato", 1, "haha", None, False) 21 | ``` 22 | `kwonly-transformer` is a tool that formats the above into the following: 23 | 24 | ```python 25 | def do_something(*, a, b, c, d, e, f): 26 | ... 27 | 28 | do_something(a=True, b="potato", c=1, d="haha", e=None, f=False) 29 | ``` 30 | 31 | ## Installation 32 | 33 | ```bash 34 | pip install kwonly-transformer 35 | ``` 36 | 37 | ## License 38 | 39 | This project is licensed under the terms of the MIT license. 40 | -------------------------------------------------------------------------------- /kwonly_transformer/__init__.py: -------------------------------------------------------------------------------- 1 | from kwonly_transformer.main import ( 2 | CallArgumentsTransformer, 3 | FunctionParametersTransformer, 4 | ) 5 | -------------------------------------------------------------------------------- /kwonly_transformer/main.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | import libcst as cst 4 | from libcst import SimpleWhitespace 5 | 6 | # TODO: `functions` dictionary should use as name. 7 | 8 | 9 | class FunctionParametersTransformer(cst.CSTTransformer): 10 | def __init__(self, threshold: int = 2) -> None: 11 | self.functions: Dict[str, cst.Parameters] = {} 12 | self.threshold = threshold 13 | 14 | def leave_FunctionDef( 15 | self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef 16 | ) -> cst.FunctionDef: 17 | params_params = list(updated_node.params.params) 18 | kwonly_params = list(updated_node.params.kwonly_params) 19 | if len(updated_node.params.params) > self.threshold: 20 | new_params = updated_node.params.with_changes( 21 | params=[], kwonly_params=params_params + kwonly_params 22 | ) 23 | self.functions[original_node.name.value] = new_params 24 | return updated_node.with_changes(params=new_params) 25 | return updated_node 26 | 27 | 28 | class CallArgumentsTransformer(cst.CSTTransformer): 29 | def __init__(self, functions: Dict[str, cst.Parameters]) -> None: 30 | self.functions = functions 31 | 32 | def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Call: 33 | params = None 34 | if isinstance(original_node.func, cst.Name): 35 | params = self.functions.get(original_node.func.value) 36 | elif isinstance(original_node.func, cst.Attribute): 37 | params = self.functions.get(original_node.func.attr.value) 38 | if params: 39 | posonly_args_len = len(params.posonly_params) 40 | posonly_args = list(original_node.args[:posonly_args_len]) 41 | kwonly_args = [ 42 | arg.with_changes( 43 | keyword=params.kwonly_params[idx].name, 44 | equal=cst.AssignEqual( 45 | whitespace_before=SimpleWhitespace(value=""), 46 | whitespace_after=SimpleWhitespace(value=""), 47 | ), 48 | ) 49 | for idx, arg in enumerate(original_node.args[posonly_args_len:]) 50 | ] 51 | return updated_node.with_changes(args=posonly_args + kwonly_args) 52 | return original_node 53 | -------------------------------------------------------------------------------- /kwonly_transformer/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/kwonly-transformer/21a4ee5f9a68af7b561dc28bc30d33be4fc2203e/kwonly_transformer/py.typed -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 40.6.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | target-version = ['py37'] 7 | 8 | [tool.isort] 9 | profile = "black" 10 | 11 | [tool.mypy] 12 | check_untyped_defs = true 13 | disallow_any_generics = true 14 | disallow_incomplete_defs = true 15 | disallow_untyped_defs = true 16 | no_implicit_optional = true 17 | show_error_codes = true 18 | warn_unreachable = true 19 | warn_unused_ignores = true 20 | 21 | [tool.pytest.ini_options] 22 | addopts = """\ 23 | --strict-config 24 | --strict-markers 25 | """ 26 | filterwarnings = """\ 27 | error 28 | """ 29 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = kwonly-transformer 3 | version = 0.0.0 4 | description = We don't like positional args, we like keyword only args! 🎉 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/Kludex/kwonly-transformer 8 | author = Marcelo Trylesinski 9 | author_email = marcelotryle@email.com 10 | license = MIT 11 | license_file = LICENSE 12 | classifiers = 13 | License :: OSI Approved :: MIT License 14 | Intended Audience :: Developers 15 | Natural Language :: English 16 | Operating System :: OS Independent 17 | Programming Language :: Python :: 3 :: Only 18 | Programming Language :: Python :: 3 19 | Programming Language :: Python :: 3.7 20 | Programming Language :: Python :: 3.8 21 | Programming Language :: Python :: 3.9 22 | Programming Language :: Python :: 3.10 23 | project_urls = 24 | Twitter = https://twitter.com/marcelotryle 25 | 26 | [options] 27 | packages = find: 28 | include_package_data = True 29 | install_requires = 30 | libcst 31 | python_requires = >=3.7 32 | 33 | [options.extras_require] 34 | test = 35 | coverage[toml] >= 6.2 36 | pytest >= 6.2.5 37 | 38 | [flake8] 39 | statistics = True 40 | max-line-length = 88 41 | ignore = E203,E501,W503 42 | per-file-ignores = 43 | __init__.py:F401 44 | 45 | [coverage:run] 46 | source_pkgs = kwonly_transformer, tests 47 | 48 | [coverage:report] 49 | show_missing = True 50 | skip_covered = True 51 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/kwonly-transformer/21a4ee5f9a68af7b561dc28bc30d33be4fc2203e/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_transformer.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import textwrap 3 | 4 | import libcst as cst 5 | import pytest 6 | 7 | from kwonly_transformer import CallArgumentsTransformer, FunctionParametersTransformer 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "input,expected_func,expected_call_args", 12 | ( 13 | pytest.param( 14 | textwrap.dedent( 15 | """ 16 | def home(a, b, c, *, d=None): 17 | ... 18 | 19 | home(1, 2, c=3) 20 | """ 21 | ), 22 | textwrap.dedent( 23 | """ 24 | def home(*, a, b, c, d=None): 25 | ... 26 | 27 | home(1, 2, c=3) 28 | """ 29 | ), 30 | textwrap.dedent( 31 | """ 32 | def home(a, b, c, *, d=None): 33 | ... 34 | 35 | home(a=1, b=2, c=3) 36 | """ 37 | ), 38 | id="function", 39 | ), 40 | pytest.param( 41 | textwrap.dedent( 42 | """ 43 | async def home(a, b, c, *, d=None): 44 | ... 45 | 46 | await home(1, 2, c=3) 47 | """ 48 | ), 49 | textwrap.dedent( 50 | """ 51 | async def home(*, a, b, c, d=None): 52 | ... 53 | 54 | await home(1, 2, c=3) 55 | """ 56 | ), 57 | textwrap.dedent( 58 | """ 59 | async def home(a, b, c, *, d=None): 60 | ... 61 | 62 | await home(a=1, b=2, c=3) 63 | """ 64 | ), 65 | id="async", 66 | ), 67 | pytest.param( 68 | textwrap.dedent( 69 | """ 70 | def home(a, b, *, d=None): 71 | ... 72 | 73 | home(1, 2, c=3) 74 | """ 75 | ), 76 | textwrap.dedent( 77 | """ 78 | def home(a, b, *, d=None): 79 | ... 80 | 81 | home(1, 2, c=3) 82 | """ 83 | ), 84 | textwrap.dedent( 85 | """ 86 | def home(a, b, *, d=None): 87 | ... 88 | 89 | home(1, 2, c=3) 90 | """ 91 | ), 92 | id="below threshold", 93 | ), 94 | pytest.param( 95 | textwrap.dedent( 96 | """ 97 | def home(a, /, b, c, d=None, *, e=None): 98 | ... 99 | 100 | home(1, 2, c=3) 101 | """ 102 | ), 103 | textwrap.dedent( 104 | """ 105 | def home(a, /, *, b, c, d=None, e=None): 106 | ... 107 | 108 | home(1, 2, c=3) 109 | """ 110 | ), 111 | textwrap.dedent( 112 | """ 113 | def home(a, /, b, c, d=None, *, e=None): 114 | ... 115 | 116 | home(1, b=2, c=3) 117 | """ 118 | ), 119 | id="posonly params", 120 | marks=pytest.mark.skipif( 121 | sys.version_info < (3, 8), 122 | reason="Python 3.7 doesn't support posonly arguments.", 123 | ), 124 | ), 125 | pytest.param( 126 | textwrap.dedent( 127 | """ 128 | class Potato: 129 | def home(a, b, c, *, d=None): 130 | ... 131 | 132 | Potato().home(1, 2, c=3) 133 | """ 134 | ), 135 | textwrap.dedent( 136 | """ 137 | class Potato: 138 | def home(*, a, b, c, d=None): 139 | ... 140 | 141 | Potato().home(1, 2, c=3) 142 | """ 143 | ), 144 | textwrap.dedent( 145 | """ 146 | class Potato: 147 | def home(a, b, c, *, d=None): 148 | ... 149 | 150 | Potato().home(a=1, b=2, c=3) 151 | """ 152 | ), 153 | id="objects", 154 | ), 155 | ), 156 | ) 157 | def test_transformer(input: str, expected_func: str, expected_call_args: str) -> None: 158 | source_tree = cst.parse_module(input) 159 | function_transformer = FunctionParametersTransformer() 160 | modified_tree = source_tree.visit(function_transformer) 161 | assert modified_tree.code == expected_func 162 | call_args_transformer = CallArgumentsTransformer(function_transformer.functions) 163 | modified_tree = source_tree.visit(call_args_transformer) 164 | assert modified_tree.code == expected_call_args 165 | --------------------------------------------------------------------------------