Skip to content

Default name-mapping policy puts a CLI decision in the Python signature: reconsider the default, or let the policy be set once per process #246

Description

@thorwhalen

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

  1. 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.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions