Background
In Python, function arguments are defined within the parentheses following the function name in the function definition. There are different ways to present function arguments, and they can be categorised into three types:
- Positional-only arguments,
- Positional or keyword arguments, and
- Keyword-only arguments
Each of these may also have a default value.
Example function (de)composition
Let's break down an example function definition:
def fn(q: str, /, z: int, x: float, b: int = 1, *, c: int = 1) -> None:
...
Positional-only argument (
q: str
): The parameterq
is a positional-only argument because it is defined before the slash (/
) in the argument list. It means that this argument can only be passed by its position and not by using a keyword. For example, you can call the function asfn("hello", 2.5, 3)
.Positional or keyword argument (
z: int
,x: float
): The parametersz
andx
are defined after the slash (/
), but they are not marked as keyword-only arguments. It means that these arguments can be passed either by their position or by using their corresponding keyword. For example, you can call the function asfn("hello", 5, x=2.5)
orfn("hello", z=5, x=2.5)
.Default argument (
b: int = 1
): The parameterb
has a default value of1
. It means that if no argument is provided forb
when calling the function, it will automatically be assigned the default value. For example, you can call the function asfn("hello", 5, 2.5, c=3)
orfn("hello", 5, 2.5, 2)
.Keyword-only argument (
c: int = 1
): The parameterc
is a keyword-only argument because it is defined after the asterisk (*
). It means that this argument can only be passed by using its corresponding keyword and cannot be passed by position. For example, you can call the function asfn("hello", 5, 2.5, c=3)
.Return type (
-> None
): The-> None
annotation specifies the return type of the function. In this case, the function is expected to returnNone
. This can be ignored.
N.B. Non-default positional arguments can not come after a default argument. That is, def fn_bad(a: int = 1, / b: int, *, c: int = 1, d: str) -> None:...
is invalid due to b
. b
is a positional or keyword argument but it comes after a defaulted positional argument. d
is valid however since this is a keyword only argument and their order does not matter.
Valid method invocations
There are 14 possible ways to call the function fn
based on its function definition. Here are all the combinations:
fn("hello", 2, 3.0)
fn("hello", 2, 3.0, 1)
fn("hello", 2, 3.0, c=1)
fn("hello", 2, x=3.0)
fn("hello", 2, x=3.0, b=1)
fn("hello", 2, x=3.0, c=1)
fn("hello", z=2, x=3.0)
fn("hello", z=2, x=3.0, b=1)
fn("hello", z=2, x=3.0, c=1)
fn("hello", 2, 3.0, b=1, c=1)
fn("hello", z=2, x=3.0, b=1, c=1)
fn("hello", z=2, x=3.0, b=1, c=1)
fn("hello", 2, x=3.0, b=1, c=1)
fn("hello", 2, 3.0, 1, c=1)
In the above examples, "hello"
is passed as the value for the positional-only argument q
, 2
is passed for the positional or keyword argument z
, 3.0
is passed for the positional or keyword argument x
, 1
is passed for the default argument b
, and 1
is passed for the keyword-only argument c
. The arguments can be passed either by position or by using their corresponding keywords, depending on the argument type.
Keyword arguments are order independent, that is fn("hello", b=1, c=1, x=3.0, z=2)
and fn("hello", x=3.0, b=1, z=2, c=1)
are congruent.
Invalid invocation examples include:
fn("hello", 2, x=3.0, 1)
since a positional argument comes after a keyword argument, and
fn("hello", z=2, b=1, c=1)
due to missing required argument x
The Challenge
Create a script which generates all possible python function invocation signatures, describing the function arguments as their argument name, if it is passed as a keyword argument, and the type of the argument.
To make things fair across languages; let's say: the inputs are three lists; positional_only
, positional_or_keyword
and keyword_only
where their elements describe the python function's arguments in the format of [string name, string type, bool has_default]
Or some similar data structure. These inputs are not counted towards the character count. The output can be any intelligible format.
(N.B. This challenge disregards any function which contains args or kwargs catch all variables (*foo
, or **bar
))
Test cases (need more)
# def fn(q: str, /, z: int, x: float, b: int = 1, *, c: int = 1) -> None:
positional_only = [["q", "str", false]]
positional_or_keyword = [["z", "int", false], ["x", "float", false], ["b", "int", true]]
keyword_only =[["c", "int", true]]
generate_signatures(positional_only, positional_or_keyword, keyword_only)
# returns:
[
[(None, 'str'), ('x', 'float'), ('z', 'int')],
[(None, 'str'), ('c', 'int'), ('x', 'float'), ('z', 'int')],
[(None, 'str'), ('b', 'int'), ('x', 'float'), ('z', 'int')],
[(None, 'str'), ('b', 'int'), ('c', 'int'), ('x', 'float'), ('z', 'int')],
[(None, 'str'), (None, 'int'), ('x', 'float')],
[(None, 'str'), (None, 'int'), ('c', 'int'), ('x', 'float')],
[(None, 'str'), (None, 'int'), ('b', 'int'), ('x', 'float')],
[(None, 'str'), (None, 'int'), ('b', 'int'), ('c', 'int'), ('x', 'float')],
[(None, 'str'), (None, 'int'), (None, 'float')],
[(None, 'str'), (None, 'int'), (None, 'float'), ('c', 'int')],
[(None, 'str'), (None, 'int'), (None, 'float'), ('b', 'int')],
[(None, 'str'), (None, 'int'), (None, 'float'), ('b', 'int'), ('c', 'int')],
[(None, 'str'), (None, 'int'), (None, 'float'), (None, 'int')],
[(None, 'str'), (None, 'int'), (None, 'float'), (None, 'int'), ('c', 'int')],
]
# def fn_2(a: int, b: int = 1, *, d: int, c:int = 1) -> None: ...
positional_only = []
positional_or_keyword = [["a", "int", false], ["b", "int", true]]
keyword_only =[["d", "int", false], ["c", "int", true]]
generate_signatures(positional_only, positional_or_keyword, keyword_only)
# returns
[
[('a', int), ('d', int)],
[('a', int), ('c', int), ('d', int)],
[('a', int), ('b', int), ('d', int)],
[('a', int), ('b', int), ('c', int), ('d', int)],
[(None, int), ('d', int)],
[(None, int), ('c', int), ('d', int)],
[(None, int), ('b', int), ('d', int)],
[(None, int), ('b', int), ('c', int), ('d', int)],
[(None, int), (None, int), ('d', int)],
[(None, int), (None, int), ('c', int), ('d', int)]
]
# def fn_3(a: int, b:int = 1, /, q:int = 1, *, r: int): ...
positional_only = [(None, 'int', False), (None, 'int', True)]
positional_or_keyword = [('q', 'int', True)]
keyword_only = [('r', 'int', False)]
generate_signatures(positional_only, positional_or_keyword, keyword_only)
# returns
[
[(None, int), ('r', int)],
[(None, int), ('q', int), ('r', int)],
[(None, int), (None, int), ('r', int)],
[(None, int), (None, int), ('q', int), ('r', int)]
[(None, int), (None, int), (None, int), ('r', int)]
]
The way these outputs are structured is a tuple of argument name and type. If the argument name is None
then the argument is a positional argument.
That is (None, int)
is a positional argument (e.g. fn(1)
) whereas ("x", int)
is a keyword argument e.g. fn(x=1)
.
[(None, int), (None, int), ('c', int), ('d', int)]
describes a function call of fn(1, 1, c=1, d=1)
Testing your outputs
A neat way to check is go to vscode.dev, install the python plugin, convert your list of lists to function invocations and paste it into a new document. The static analyser will tell you which are (in)valid. I think there is a way to run pyright in code, but this was quicker:
for i, sig in enumerate(sigs):
print(f"# {i} - {sig}")
s = 'fn('
for arg in sig:
if arg[0] is not None:
s += f"{arg[0]}="
typ = arg[1]
val = None
if typ == 'str':
val = "\"str\""
elif typ == 'int':
val = "1"
elif typ == 'float':
val = "1.0"
else:
val = "UNKNOWN"
s += f"{val},"
print(f"{s})\n")
Bonus challenge
Given fn_a(...)
and fn_b(...)
determine any ambiguous valid signatures to these methods.
e.g. fn_a(x: str, y: int) -> None: ...
fn_b(z: str, /, x: int) -> None: ...
are distinct for fn(x="foo", y=1)
and fn(x="foo", z=1)
but conflict for the signature fn("foo", 1)
That is, both functions contain the valid signature of [(None, "str"), (None, "int")]
fn(a: int, b: int, *, c: int):...
the callsfn(c=1, b=1, a=1)
andfn(a=1, b=1, c=1)
are "identical" \$\endgroup\$