The problem
Since v0.30 the default name-mapping policy BY_NAME_IF_KWONLY maps a plain def f(foo, bar=None) so that bar becomes a CLI positional. The only documented way to keep bar an option (--bar) is to reshape the Python signature to def f(foo, *, bar=None). The changelog states it directly:
The following function does not map to func foo [--bar] anymore: def func(foo, bar=None): .... Since this release it maps to func foo [bar] instead. Please update the function this way to keep bar an "option": def func(foo, *, bar=None).
The trouble is that this edits the function for a reason that has nothing to do with the function. The same f(foo, bar=None) is usually also called from notebooks, the REPL, tests, and other modules, and sometimes it is a third-party function I am only wrapping and cannot edit. In all of those Python contexts an argument with a default may be passed positionally or by name, at the caller's discretion. Writing *, (or, when keyword-only and defaulted-positional args mix, handling a hard ArgumentNameMappingError) forces a CLI-only constraint onto callers who never touch the CLI. A dispatcher is middleware between a Python function and an argv vector, and reconciling the two calling conventions is its job. Right now that job leaks into the Python signature. I expand on this principle in a comment below.
What I am proposing
- Reconsider the default so an ordinary
def f(foo, bar=None) maps bar back to --bar. Ideally not via plain BY_NAME_IF_HAS_DEFAULT (it has a real flaw with keyword-only args that lack a default, which I cover in a comment below) but via a refined mapping: an argument becomes an option if it is keyword-only or has a default. That honors * fully and keeps ordinary defaulted args as options.
- If the default cannot change (I know the transition plan aims to make
BY_NAME_IF_KWONLY the silent default around v0.33/v1.0): let the policy be set once, process-wide (a module-level setter, an environment variable, or a config), defaulting to today's behaviour so that nobody who does not opt in is affected. Today the only knob is name_mapping_policy= on every add_commands / set_default_command / dispatch_command(s) call, which is the per-call repetition I would like to retire.
- Improve the error or warning so it fires only on the genuinely ambiguous case and its message names the one-line global setter from (2).
Options at a glance
| Option |
What it entails |
Pros |
Cons |
Flip the default back to BY_NAME_IF_HAS_DEFAULT |
restore the pre-0.30 default |
zero signature edits for ordinary functions |
another breaking change; contradicts the roadmap; mistreats def f(*, x) with no default |
| New "option if keyword-only or has-default" policy as default |
one extra enum member plus a default change |
honors * fully and keeps defaulted args as options |
still a default change, needs a transition window |
| Process-wide policy setter, default unchanged |
one setter plus a precedence rule (per-call > env > config > built-in) |
zero churn for non-opters; removes per-call boilerplate |
global state is action-at-a-distance; published packages should still set it per call |
| Status quo plus better docs and error message |
wording change only |
trivial, no behaviour change |
the boilerplate and the signature pressure remain |
My order of preference: the process-wide setter is the no-regret step (opt-in, back-compat-safe, removes the repetition); the "option if keyword-only or has-default" policy is the principled long-term default if a breaking change is ever on the table again; the docs and error-message fix is worth doing regardless.
Further research
I dug into this fairly deeply before and while writing the issue: the original rationale, the prior threads where it surfaced, the exact edge cases, and the full trade-off analysis. To keep the issue itself readable I have put that material in the comments below rather than inline.
The problem
Since v0.30 the default name-mapping policy
BY_NAME_IF_KWONLYmaps a plaindef f(foo, bar=None)so thatbarbecomes a CLI positional. The only documented way to keepbaran option (--bar) is to reshape the Python signature todef f(foo, *, bar=None). The changelog states it directly:The trouble is that this edits the function for a reason that has nothing to do with the function. The same
f(foo, bar=None)is usually also called from notebooks, the REPL, tests, and other modules, and sometimes it is a third-party function I am only wrapping and cannot edit. In all of those Python contexts an argument with a default may be passed positionally or by name, at the caller's discretion. Writing*,(or, when keyword-only and defaulted-positional args mix, handling a hardArgumentNameMappingError) forces a CLI-only constraint onto callers who never touch the CLI. A dispatcher is middleware between a Python function and anargvvector, and reconciling the two calling conventions is its job. Right now that job leaks into the Python signature. I expand on this principle in a comment below.What I am proposing
def f(foo, bar=None)mapsbarback to--bar. Ideally not via plainBY_NAME_IF_HAS_DEFAULT(it has a real flaw with keyword-only args that lack a default, which I cover in a comment below) but via a refined mapping: an argument becomes an option if it is keyword-only or has a default. That honors*fully and keeps ordinary defaulted args as options.BY_NAME_IF_KWONLYthe silent default around v0.33/v1.0): let the policy be set once, process-wide (a module-level setter, an environment variable, or a config), defaulting to today's behaviour so that nobody who does not opt in is affected. Today the only knob isname_mapping_policy=on everyadd_commands/set_default_command/dispatch_command(s)call, which is the per-call repetition I would like to retire.Options at a glance
BY_NAME_IF_HAS_DEFAULTdef f(*, x)with no default*fully and keeps defaulted args as optionsMy order of preference: the process-wide setter is the no-regret step (opt-in, back-compat-safe, removes the repetition); the "option if keyword-only or has-default" policy is the principled long-term default if a breaking change is ever on the table again; the docs and error-message fix is worth doing regardless.
Further research
I dug into this fairly deeply before and while writing the issue: the original rationale, the prior threads where it surfaced, the exact edge cases, and the full trade-off analysis. To keep the issue itself readable I have put that material in the comments below rather than inline.
argv, so reconciling their two calling conventions is its job, not the function author's.destis ignored #224, and the constraints (the roadmap, the "no more forced edits" objection) that the asks are shaped around.BY_NAME_IF_HAS_DEFAULTdoes not fully honor*: why I am asking for a refined "option if keyword-only or has-default" mapping rather than plainBY_NAME_IF_HAS_DEFAULT, with a side-by-side table.