Allow inert / and * in function signatures [needs sponsor]

Consider the following function (signature):

def f(p1, p2, /, pk1, pk2, *, k1, k2): pass

This has two positional-only, two keyword-only, and two positional-or-keyword args.

If p2, is removed then it’s still valid, and now has only one positional-only arg.

If p1, is removed as well then it’s suddenly a SyntaxError:

>>> def f(p1, p2, /, pk1, pk2, *, k1, k2): pass
... 
>>> def f(p1, /, pk1, pk2, *, k1, k2): pass
... 
>>> def f(/, pk1, pk2, *, k1, k2): pass
  File "<stdin>", line 1
    def f(/, pk1, pk2, *, k1, k2): pass
          ^
SyntaxError: at least one argument must precede /

Similarly, if , k2 is removed then the signature is still valid and has only one keyword-only arg.

If , k1 is removed as well then it’s a SyntaxError:

>>> def f(p1, p2, /, pk1, pk2, *, k1, k2): pass
... 
>>> def f(p1, p2, /, pk1, pk2, *, k1): pass
... 
>>> def f(p1, p2, /, pk1, pk2, *): pass
  File "<stdin>", line 1
    def f(p1, p2, /, pk1, pk2, *): pass
                               ^
SyntaxError: named arguments must follow bare *

I feel that / at the start of args and * at the end of args should be allowed, for two reasons:

  1. Compare def f(a, b) with def f(a, /, b). Clearly, the latter indicates that the author has thought about which args should be positional-only, and has determined that a should be positional-only and b should not. In the former case, maybe the author’s intent was for both a and b to be positional-or-keyword, but maybe they hadn’t thought about whether they should be allowed to be passed as keyword-arguments at all. Now, compare def f(b) with def f(/, b). The latter case is currently a SyntaxError, but if allowed, it would indicate with certainty the author’s intent that b be allowed to be passed as a keyword-argument.
  2. During refactoring, if I have functions that have some positional-only args, and add/remove some/all of them at various points in the refactor (not knowing how the function will ultimately end up), if at any point all the positional-only args disappear, I either have to remove the / so that it’s not an error (which can lead to me forgetting to add it back later), or put up with there being an error until I’m finished, and only then remove any stray /'s.

The above are written regarding /, but equivalent logic applies to * when swapping “positional” with “keyword” and reversing the order of the arguments.

I imagine this should be a simple enough change to make in “next 3.x”.

If an author prefers the current behavior for code style reasons, that could be expressed as a linter rule. Similarly, with the new behavior, one could even imagine cases where an author wants to require the “positional-or-keyword-ness” of every argument to always be explicitly specified, which means a linter rule to require both / and * in every function signature.

Are there any downsides to this that I’m not noticing?

10 Likes

From an end user perspective this seems reasonable. If there’s a significant runtime or development cost, then I don’t see it as necessary.

4 Likes

For keyword arguments, you can add a sentinel (mypy Playground):

def f(a, *, __b=None): pass

f(1, __b=2) # Unexpected keyword argument "__b" for "f"

And you can denote positional-only arguments with a double underscore (mypy Playgound):

def f(__a, b): pass

f(__a=1, b=2) # Unexpected keyword argument "__a" for "f"
1 Like

Indeed, those feel like artificial restrictions - not inline with the spirit of, say, allowing an extra comma after the last item in a literal.

In practice, though, it looks for me it is just that some generated code would require some simpler logic to be rendered.

I could think of a prepending “/” indicating that overriding methods are free to include positional only parameters to a method signature - but then, those will already feature self as one of those, so this use case wouldn’t be affected.

I think that if we could think up on more advantages for this change it would get better chances - but I see no downside.

These would look like bugs and go against the definition of the separators:

Positional-Only Separator /

  • All parameters before / must be passed positionally, not as keywords.
def f(a, b, /): pass
f(1, 2)        # OK
f(a=1, b=2)    # TypeError

Keyword-Only Separator *

  • All parameters after * must be passed by keyword.
def f(*, a, b): pass
f(a=1, b=2)    # OK
f(1, 2)        # TypeError

Using / without any parameters before it, or * without any parameters after it, makes the function signature unclear. It becomes confusing which parameters these separators are intended to apply to.

I don’t find the syntax particularly appealing, but I don’t see how it’s confusing. The answer is “none” in both scenarios.

5 Likes

I don’t think it would go against the definition of the separators, unless you ignore the word “all”.

>>> all([])
True
5 Likes

It can always be passed as a keyword argument, as long as the parameter isn’t positional-only. That’s already made explicit.

That would be a highly desirable feature for modern IDEs to offer, but I don’t see it as something related to the language itself.


Overall, supporting inert or no-op syntax (name aside) would provide us with multiple ways to declare a function, implicitly reveal possible author intent, or simply highlight a missing parameter.

But Python already supports an inert or no-op syntax such as the optional comma after the last parameter in a function header:

def f(
   a,
   b # OK
): ...
def f(
   a,
   b, # OK
): ...

And I think very few if any people find it confusing, at least not more than they find it helpful to diffing and refactoring. The same principle applies to the proposal here.

3 Likes

I am curious; in what situations do you find it helpful to use / in a function signature?

/ is useful for arguments that don’t make sense to be named in a call, such as arg1 and arg2 in the max function:

max(arg1, arg2, /, *args, key=None)

or when the function has only one primary subject:

bool(object=False, /)
sorted(iterable, /, *, key=None, reverse=False)
1 Like

For example PySide6 (Qt for Python) makes heavy use of / for their C/Cpp objects, these don’t allow keyword arguments (just like some python stdlib/builtins that are actually C.

Ad the actual topic:

I think this discussion is caught up in the question of „usefulness“ while I personally think it should be seen in the context of the language and how it fits or not fits into it.

Python allows a lot of unecessary characters/meta-stuff. From trailing , almost everywhere, to explicit object inheritance and excessive indentation (as long as it is the same excessive indentation).

Allowing these two to also be used in an unecessary way, would just, in my opinion, confirm them to the language at large.

Also I think, that this change would not really be breaking any code should be also be mentioned.

I personally thought they work that way actually, the only reason I wasn’t bitten by it was that I am just recently started to use these two in my scripts and applications.

I think the actual question should be, how hard it would be to implement that and how motivated the maintainers are to actually do it.

3 Likes

Right now, there’s no possibility for any confusion.

If we allowed both * and / to be used in similar positions, would you question your past self—was it a mistake? Did you mean to use * or /? I’m sure most users wouldn’t have the slightest idea what the author intended:
def f(/, a, b): pass
def f(*, a, b): pass
def f(a, b, *): pass
def f(a, b, /): pass

I haven’t seen this kind of confusion with other no-op syntaxes. Are there any examples? If there are other such examples, are we really heading in the right direction by making the language more ambiguous? Should we be discouraging such usage?

My assumption would be “they intended the thing that they wrote down in the code”. The four examples mean four different things. Each one is distinct and unambiguous.

I’m very confused by why this would be confusing but f(a, /, b) or f(a, *, b) isn’t.

6 Likes

Could you elaborate on what the author meant? It seems the OP’s intent is to use the syntax more as a form of note-keeping, almost like a comment (emphasis mine):

It seems the syntax would be used solely for debugging purposes. It wouldn’t even qualify as no-op syntax or a placeholder—the OP refers to it as ‘stray syntax.’

I was mistaken–the first and third of those examples are equivalent, so it’s not true that all four are distinct, but they are unambiguous.

I read the OP[1] as saying that the / and * syntax convey information about how a signature is intended to be used, and a leading / or trailing * says “I thought about this, and this is the behavior I wanted”. i.e. it conveys some additional intent (mostly to other developers) about how the signature was written.

def f(/, a, b): pass  # it's fine to pass these by position or keyword
def f(*, a, b): pass  # both of these are keyword-only
def f(a, b, *): pass  # same as the first one
def f(a, b, /): pass  # both of these are position-only

The comment about debugging is related, but this isn’t about debugging. Because these edge-case forms are not currently valid, changing a signature that uses them requires some boilerplate edits that can remove the signal of intent accidentally.


  1. particularly the section starting with “Compare def f(a, b) with def f(a, /, b)…” ↩︎

3 Likes

I think you meant all(), but it would raise an exception:
TypeError: all() takes exactly one argument (0 given)

Yes, that’s very helpful while debugging code, you can simply comment out parameters as you please without worrying about dangling commas.

Following the same formatting, you can easily debug code using separators in the function signature:

def f(
   # /,
   a,
   b, # OK
   # *,
   c,
): ...

I’m a pretty neurotic when it comes to code formatting, and it’s pretty satisfying to be able to perfectly align function parameters, so I get where you’re coming from.
But it’s very likely that if this would be implemented, linters like ruff and black would immediately add rules that disallow you from using it. One of the reasons for that might ring a bell:

There should be one-- and preferably only one --obvious way to do it.

Adding syntactical no-ops like this pretty much goes against that :person_shrugging:.
(I’m dutch btw)

3 Likes

And they absolutely should! Because linter rules can be configured based on the needs of individual projects. Those rules would be useful for codebases whose maintainers believe that the status quo is desirable, while people like me would turn those rules off.

I don’t believe it falls foul of “only one obvious way to do it”, either - for authors who just want to write a function, def f(a, b) is the obvious way to do it, while for authors who want to write a whole set of functions consistently and express that some should have some positional-only arguments and others should have none, including the / in every function is the obvious way to do it.

9 Likes