Snargs : triple-star packing/unpacking of self-named arguments (and alternative to PEP 736)

I’ve never said it in any thread before but the allowance of this syntax literally freaks me out ! as well as

f(a=, b=2)

I mean, allowing this implies to remove a SyntaxError check, and in the middle of a long code, if this line was meant to assign the value 1 to a but it is missing, I’m pretty sure a human would not spot this from a rush of the eyes, and neither the linter, neither the interpreter traceback would help finding the bug. This is the kind of thing (like a trailing comma) that makes you lose a day or two of work time because of one mistyped characters.

And this is not too bad for function calls either.

I am not sure about the grand scheme of things, but I rarely encounter cases where I need such and this would be more than sufficient:

foo(**{
    : very_long_argument_and_value_name1,
    : very_long_argument_and_value_name2,
    : very_long_argument_and_value_name3,
    : very_long_argument_and_value_name4,
})

Does {:a, 'b': 2} freak you out as well?

Yes. I think every assignment operators (=, :, whatever else…) should have both a left-hand and a right-hand side on solid ground. This is one fundamental protection from silent bugs.

Both variants seem quite ok to me. {a:, 'b': 2} is probably a bit riskier, but {:a, 'b': 2} looks quite solid to me.

There already is a case of similar pattern in f'{a=}'. I don’t think I have ever made a mistake there or had any bugs with it. And I am using f-strings not only for logging but for logic as well (I know many would advise me not to, but I am happy with it - it is fast, short and does everything well).

Could you give an example case of high risk silent bug that {:a, 'b': 2} would introduce?

I actually managed to control my fear and went comfortable using it, it is not an assignment btw.

{'b':2, ***(a)} feels much safer to me, and foremost, does not imply to turn off syntax error policy for an accidental deletion of any left-hand-side of :.
→ which lights up another question : should the two following lines be equivalent ? (I think yes)

{'b':2, ***(a,c)}
dict(b=2, ***(a,c))

It might be worth noting PEP 736 was rejected by the steering council.

4 Likes

Maybe you are right, but you have to give me something as I can not see it the same way and can not come up with anything easily.

A reasonable example would be very helpful to see whether there is an issue here or not.

Not sure this perfectly answers the question yet it is the main point I think :

def connect_db(host, port, user, password) : ...

# Now we convert this function arguments to self-named ones

def connect_db(host, :port, :user, :password) : ...  # one error here, would you spot it in the middle of 100 lines ???
def connect_db(***(host, port, user, password)) : ... # global refactoring is easier this way

I like:

connect_db(**{:host, :port, :user, :password})

What syntax would you have then for the self-named unpacking ? e.g. replacing this
(assuming connect_db returns a dict with 3 items) :

connection, terminal, metadata = ***connect_db(***(host, port, user, password))

True, this only addresses construction of dict and not the other aspect of this proposal.

In essence I think there are 2 things that this does:

  1. It validates that correct values of dictionary are assigned to correct variables
  2. It ensures that variable names are the same as dictionary keys from which values are assigned

As of (1), tuple can often be used instead. Or:

c, t, m = [(c := connect_db())[k] for k in ['connection', 'terminal', 'metadata']]

# Or even neater
c, t, m = itemgetter('connection', 'terminal', 'metadata')(connect_db())

As of (2), I am not certain whether this is a good idea as this syntax is limited to use-cases where local namespace is free of variables named as those keys.

This would override local variable:

connection = SocketConnection()
connection, terminal, metadata = ***connect_db()

And to avoid it, I need to either:
a) not use proposed syntax.
b) change the name of some other variable

(b) violates “there should be one and only one…” in a fairly awkward manner.

As for for (a), very often this can be undesirable as it would break long working variable naming schemes or I am not the owner of the code and it is required that I make as little change as possible.

After encountering 2 such cases where I can not do (b) and need to think about different construct, I would probably stop using this all together.

It might be possible that this works even if connect_db does not return a dict, simply the names in the calling namespace will be checked against the ones in the function space…
Possibly that triple star unpacking could have several uses :

  • dict unpacking
  • function outputs unpacking with name checks
  • (possibly) namespace unpacking as well

… idk how complicated this would be …

Ah, there is the 3rd:
3. It ensures that dict has the same number of items as number of variables being assigned to.

For example:

connection, terminal = ***connect_db(***(host, port, user, password))

Would be broken if metadata was added to return dictionary. And this is kind of one of main points of using dict as return value - return additions do not break existing use cases.

Otherwise, if this aspect is not needed, dict is an overkill and tuple would be more appropriate.

1 Like

True, and it would also be broken if any of connection, terminal was renamed → back to PRO#2 PRO#1.

I did’t get that, could you clarify?

I think this sort of feature could be separate problem from dict creation. Maybe could be part of “typing” stuff, where user could optionally opt in to match not only return types, but also return variable names.

Something along the lines of:

def min_max() -> tuple[int, int].named('minimum', 'maximum'):
    return 1, 2

minimu, maximum = min_max()

Would raise some sort of warning/error in mypy.

But I don’t use typing, not sure how much this is needed or how much sense it makes.

I actually meant PRO#1 in the OP

1 Like

The simple use-case of partially extracting keys (not enforcing the key number matching) from a dict could be done by using _ :

var_a, var_b, _ = ***mydict
# instead of 
var_a, var_b = [mydict[key] for key in ['var_a', 'var_b']

This might provide an interest to be added to the advantages of self-named arguments in functions def and calls (or PEP736).

Wouldn’t this be acceptable?

{"var_a": var_a, "var_b": var_b, **_} =  mydict

The above wouldn’t even work, because _ is not special in any way.