I don’t think that’s correct. It has, but one that’s consistent with Python’s rules about positional vs. keyword arguments. (Namely, that you can’t mix them out of order, and positional args have to come first.) What partial doesn’t do is give you a pass to break those rules.
You missed several more possibilities, though, and the rules for which of them you can and can’t use inform the semantics of partial as well.
These are OK:
foo(b=4, a=3)
foo(3, b=4)
These are not:
foo(4, a=3)
foo(b=4, 3)
The second is a SyntaxError (“positional argument follows keyword argument”).
The first is a TypeError, and as it turns out, the same as your g = partial(foo, a=3); g(4) because positional arguments always come first.
A partial with a keyword argument supplied doesn’t slot that argument in as the first positional argument, it slots it in as the first keyword argument.
That’s also what makes this perfectly OK. It doesn’t violate the ordering rules. (It only seems like it does, until you understand what’s going on.):
>>> h = partial(foo, b=4)
>>> h(3)
That works because it’s equivalent to foo(3, b=4), NOT foo(b=4, 3). Which is why g = partial(foo, a=3) => g(4) => foo(4, a=3) breaks the rules.
(And, returning to your original claim, with h = partial(foo, b=4) you can choose between h(3) and h(a=3).)
…I suppose I can’t disagree that this behavior is “not obvious”, but not everything in the language will be. And it is documented (emphasis added):
functools.partial(func, /, *args, **keywords)
Return a new partial object which when called will behave like func called with the positional arguments args and keyword arguments keywords. If more arguments are supplied to the call, they are appended to args. If additional keyword arguments are supplied, they extend and override keywords.