├── .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 |
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 |
--------------------------------------------------------------------------------