├── .gitignore ├── example.py ├── .coveragerc ├── posonly_params.py ├── tests.py └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | *.pyc 3 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 3 | 4 | from posonly_params import positional_only 5 | 6 | 7 | @positional_only("x") 8 | def adder(x, y, z): 9 | return x + y + z 10 | 11 | 12 | print(adder(1, 2, 3) // 6) 13 | print(adder(x=1, y=2, z=3)) 14 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | include = 4 | posonly_params.py 5 | tests.py 6 | omit = 7 | **/.virtualenvs/**/*.py 8 | /usr/local/**/*.py 9 | **/python3.6/**/*.py 10 | /opt/python/**/*.py 11 | 12 | [report] 13 | show_missing = true 14 | fail_under = 100 15 | -------------------------------------------------------------------------------- /posonly_params.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | # This file is part of https://github.com/alexwlchan/positional-only-parameters 4 | # 5 | # This source code is released under the MIT licence. 6 | 7 | import functools 8 | import inspect 9 | 10 | 11 | try: 12 | getfullargspec = inspect.getfullargspec 13 | except AttributeError: # pragma: no cover 14 | getfullargspec = inspect.getargspec 15 | 16 | 17 | def positional_only(*positional_only_args): 18 | positional_only_args = set(positional_only_args) 19 | 20 | def inner(f): 21 | argspec = getfullargspec(f) 22 | assert all(arg in argspec.args for arg in positional_only_args) 23 | 24 | @functools.wraps(f) 25 | def wrapper(*args, **kwargs): 26 | bad_args = set(kwargs.keys()) & positional_only_args 27 | 28 | if bad_args: 29 | if len(bad_args) == 1: 30 | message = "You can only pass %s as a positional parameter" 31 | else: 32 | message = "You can only pass %s as positional parameters" 33 | 34 | raise TypeError(message % ", ".join(bad_args)) 35 | 36 | return f(*args, **kwargs) 37 | 38 | return wrapper 39 | 40 | return inner 41 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | 3 | import pytest 4 | 5 | from posonly_params import positional_only 6 | 7 | 8 | @positional_only("x") 9 | def adder(x, y, z): 10 | return x + y + z 11 | 12 | 13 | @positional_only("y") 14 | def multiplier(x, y, z): 15 | return x + y + z 16 | 17 | 18 | @positional_only("x", "y") 19 | def doubler(x, y, z): 20 | return (x + y + z) * 2 21 | 22 | 23 | @pytest.mark.parametrize("function, args, kwargs", [ 24 | (adder, (1, ), {"y": 2, "z": 3}), 25 | (adder, (1, 2, ), {"z": 3}), 26 | (adder, (1, 2, 3, ), {}), 27 | 28 | (multiplier, (1, 2, ), {"z": 3}), 29 | (multiplier, (1, 2, 3, ), {}), 30 | 31 | (doubler, (1, 2, ), {"z": 3}), 32 | (doubler, (1, 2, 3), {}), 33 | ]) 34 | def test_allows_calling_positional_args(function, args, kwargs): 35 | function(*args, **kwargs) 36 | 37 | 38 | @pytest.mark.parametrize("function, args, kwargs", [ 39 | (adder, (), {"x": 1, "y": 2, "z": 3}), 40 | 41 | (multiplier, (1, ), {"y": 2, "z": 3}), 42 | (multiplier, (1, ), {"x": 1, "y": 2, "z": 3}), 43 | 44 | (doubler, (1, ), {"y": 2, "z": 3}), 45 | (doubler, (1, ), {"x": 2, "z": 3}), 46 | ]) 47 | def test_blocks_calling_positional_only_arg_as_kwarg(function, args, kwargs): 48 | with pytest.raises(TypeError) as exc: 49 | function(*args, **kwargs) 50 | 51 | assert exc.value.args[0].startswith("You can only pass") 52 | assert exc.value.args[0].endswith("as a positional parameter") 53 | 54 | 55 | @pytest.mark.parametrize("function, args, kwargs", [ 56 | (doubler, (), {"x": 1, "y": 2, "z": 3}), 57 | ]) 58 | def test_blocks_calling_multiple_positional_only_arg_as_kwarg(function, args, kwargs): 59 | with pytest.raises(TypeError) as exc: 60 | function(*args, **kwargs) 61 | 62 | assert exc.value.args[0].startswith("You can only pass") 63 | assert exc.value.args[0].endswith("as positional parameters") 64 | 65 | 66 | @pytest.mark.parametrize("function, args, kwargs", [ 67 | (multiplier, (1, 2, ), {"x": 1}), 68 | (multiplier, (1, 2, ), {"x": 1, "z": 3}), 69 | ]) 70 | def test_other_weirdness_is_still_typeerror(function, args, kwargs): 71 | with pytest.raises(TypeError): 72 | function(*args, **kwargs) 73 | 74 | 75 | def test_errors_if_try_to_define_non_existent_positional_only_arg(): 76 | 77 | with pytest.raises(AssertionError): 78 | @positional_only("a", "b", "c") 79 | def tupler(x, y, z): # pragma: no cover 80 | return (x, y, z) 81 | 82 | with pytest.raises(AssertionError): 83 | @positional_only("a", "x") 84 | def lister(x, y, z): # pragma: no cover 85 | return [x, y, z] 86 | 87 | 88 | def test_attributes_preserved(): 89 | def setter(x, y, z): # pragma: no cover 90 | """Combine the arguments into a set.""" 91 | return {x, y, z} 92 | 93 | pos_only_setter = positional_only("x")(setter) 94 | 95 | assert setter.func_defaults == pos_only_setter.func_defaults 96 | assert setter.func_dict == pos_only_setter.func_dict 97 | assert setter.func_doc == pos_only_setter.func_doc 98 | assert setter.func_name == pos_only_setter.func_name 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # positional-only-arguments 2 | 3 | This is a proof-of-concept decorator that lets you specify positional-only parameters in Python. 4 | 5 | For example: 6 | 7 | ```python 8 | from posonly_params import positional_only 9 | 10 | 11 | @positional_only("x") 12 | def adder(x, y, z): 13 | return x + y + z 14 | 15 | 16 | adder(1, 2, 3) 17 | # 6 18 | 19 | adder(x=1, y=2, z=3) 20 | # TypeError: You can only pass x as a positional parameter 21 | ``` 22 | 23 | I've tested it with Python 2.7 and 3.6. 24 | 25 | It's not meant to be a replacement for PEP 570 -- it was a fun project just to see if it would be possible to backport this, and possibly provide a deprecation path for pre-3.8 Pythons. 26 | 27 | 28 | 29 | ## Why? 30 | 31 | PEP 570 adds [positional-only arguments](https://www.python.org/dev/peps/pep-0570/) in Python 3.8, using a slash. 32 | 33 | In Python 3.8, you can define functions as follows: 34 | 35 | ```python 36 | def multiplier(a, b, /, c, d, *, e, f): 37 | ... 38 | ``` 39 | 40 | Here: 41 | 42 | * `a` and `b` are positional-only; you can't use them as keyword arguments 43 | * `c` and `d` can be positional or keyword arguments 44 | * `e` and `f` must be keyword arguments (using the existing star syntax added [in Python 3](https://www.python.org/dev/peps/pep-3102/)) 45 | 46 | At first I thought this was a bit odd (I still think it's a weird choice of syntax), but a few people on Twitter persuaded me that positional-only arguments can be useful sometimes. 47 | So now I think it's a reasonable idea, why not wait for the feature to land in Python 3.8? 48 | 49 | A couple of reasons: 50 | 51 | * **A lot of libraries will have to wait for this feature.** 52 | 53 | One of the touted benefits of PEP 570 is that third-party libraries can use positional-only arguments, similar to the standard library. 54 | But a library can't assume a Python 3.8 feature until 3.8 is the oldest version it supports. 55 | 56 | If the library already exists, that could mean waiting until 3.7 is completely out of support. 57 | According to the [release schedule](https://www.python.org/dev/peps/pep-0537/), the last bugfix release is a year away, and security fixes will be coming for three years after that. 58 | 59 | Plus, there are plenty of codebases still stuck on earlier versions of Python 3, and lots of Python 2 still in the world. 60 | 61 | You can use this decorator *right now*. 62 | 63 | * **This lets you deprecate using keywords with positional-only arguments, without breaking them immediately.** 64 | 65 | Changing an existing function to use positional-only arguments is a breaking change, and it would be nice to emit deprecation warnings for a while before turning it on. 66 | 67 | This is inspired by the [Hypothesis deprecation policy](https://hypothesis.readthedocs.io/en/latest/healthchecks.html#deprecations) -- deprecated features emit a warning for at least six months before they're removed. 68 | It would be nice to the same for positional-only arguments. 69 | 70 | We used to do something similar when changing parameter names (which is mentioned in the PEP), with [the renamed_arguments() decorator](https://github.com/HypothesisWorks/hypothesis/blob/8431a80dcaea2c4302d725540a1ff486be52cf23/hypothesis-python/src/hypothesis/internal/renaming.py). 71 | It worked with both the new and old parmeter names, and dropped a deprecation warning if you used the old name. 72 | 73 | * **It doesn't mean mucking about with \*args and tuple unpacking.** 74 | 75 | You can replicate this feature if you change your function signature to take `*args` and unpack them in the body, but that always felt fiddly and weird to me. 76 | 77 | Plus, when you do eventually get to Python 3.8, taking that out is a bit harder. 78 | Moving from this decorator to the Python 3.8 syntax is a two-line change. 79 | 80 | * **The 3.8 slash syntax looks weird to me.** 81 | 82 | Until it's more common, I'm going to be confused whenever I see it. 83 | (I still do a double-take at the star sometimes.) 84 | 85 | A named decorator gives a bigger clue about what it's doing. 86 | 87 | * **Fun!** 88 | 89 | I thought this would a neat challenge (and I thought it would involve more mucking around with the [inspect module](https://docs.python.org/3/library/inspect.html)). 90 | 91 | I know, I'm a bit weird. 92 | 93 | What this is: 94 | 95 | * Scratching an intellectual itch 96 | * A possible deprecation path for positional-only arguments pre-3.8 97 | 98 | What this is not: 99 | 100 | * Me trying to say "this is how PEP 570 should have been implemented". 101 | There's a section in the PEP about why [they rejected decorators](https://www.python.org/dev/peps/pep-0570/#decorators). 102 | 103 | 104 | 105 | 106 | ## Usage 107 | 108 | Copy the contents of `posonly_params.py` into your codebase. 109 | It's a single file. 110 | 111 | To use it, import `positional_only` and apply the decorator to a function you want to use with positional-only arguments. 112 | Pass the list of parameter names you want to be positional-only as string arguments to the decorator: 113 | 114 | ```python 115 | from posonly_params import positional_only 116 | 117 | 118 | @positional_only("x", "y") 119 | def doubler(x, y, z): 120 | return (x + y + z) * 2 121 | ``` 122 | 123 | You can see more examples in `tests.py`. 124 | 125 | 126 | 127 | ## Tests 128 | 129 | You can run the tests with pytest and coverage: 130 | 131 | ```console 132 | $ pip install pytest-cov 133 | $ coverage run -m py.test tests.py; coverage report 134 | ``` 135 | 136 | 137 | 138 | ## Possible improvements 139 | 140 | See the [issues list](https://github.com/alexwlchan/positional-only-parameters/issues). 141 | 142 | 143 | 144 | ## Support 145 | 146 | This was a fun experiment I wrote on my commute and my lunch break, and I spent two hours on it, tops. 147 | 148 | I'm not planning to spend lots more time on it. 149 | 150 | 151 | 152 | ## License 153 | 154 | MIT. 155 | --------------------------------------------------------------------------------