Pull request walkthrough

Parameter.spec: naming a parameter the way the user types it

Click now names options in errors, warnings and help the way users type them: --my-option, not my_option.

pallets/click #3877at 2247b357 files, +266/−26~12 min read

The big picture

Click is a library for building command-line programs. You declare options and arguments with decorators, and Click parses sys.argv, prints --help, and reports mistakes. Before this PR, some of those reports named an option by its Python name: The option 'my_option' is deprecated. The user never typed my_option. They typed --my-option.

The PR gives every parameter one property, Parameter.spec, that returns the user-facing spelling. It moves get_help_spec up from Option to Parameter so arguments have it too. It deprecates the old property human_readable_name, which returned the wrong string for options. It targets Click 8.6.0 (CHANGES.md:9).

Who notices: end users reading error text, developers whose tests assert on that text, and libraries like cloup that subclass Parameter (the author says so in the PR thread).

Like you're five

Think of a coffee shop. The customer writes an order slip (the decls: '-g', '--gluten-free/--no-gluten-free'). The barista turns it into a ticket number for the kitchen (the name: gluten_free). The menu board on the wall shows the drink as the customer orders it (the spec: --gluten-free). Before this PR, when a drink was discontinued, the barista shouted the kitchen ticket number at the customer. Now the barista reads from the menu board.

Where the analogy breaks: an argument has no flag to order by, so its "menu board" entry is just its name in capitals (RECIPE).

flowchart LR
  dev(["Developer: @click.option(...)"]) --> parse["_parse_decls()"]
  parse --> name["name: gluten_free"]
  parse --> opts["opts / secondary_opts"]
  opts --> spec["spec: --gluten-free"]
  hrn["human_readable_name (deprecated)"] --> spec
  spec --> warn["deprecation message"]
  spec --> hint["get_error_hint()"]
  hint --> exc["exceptions.py: BadParameter"]
  opts --> helpspec["get_help_spec()"]
  helpspec --> help["--help left column"]
  name --> cb["callback(gluten_free=...)"]
  classDef new fill:#e3f5ea,stroke:#1f8a4c,color:#0f3d22
  classDef changed fill:#fdf0dc,stroke:#b26b00,color:#4a2c00
  class spec,helpspec new
  class hrn,warn,hint changed
    
Notice the split: name only feeds the Python callback, and everything the user reads now flows through spec or get_help_spec().

Before and after

The PR author lists three strings for three audiences (PR #3877 description):

@click.argument('recipe')@click.option('-g', '--gluten-free/--no-gluten-free')
decls('recipe',)('-g', '--gluten-free/--no-gluten-free')
specRECIPE--gluten-free
namerecipegluten_free
opts['recipe']['-g', '--gluten-free']
secondary_opts[]['--no-gluten-free']

The same table now lives in the docs at docs/parameters.md:68.

  • Before: Parameter.human_readable_name returned self.name for options and the metavar for arguments (base src/click/core.py:2520, src/click/core.py:3838 at base). A deprecated option printed The option 'my_option' is deprecated.
  • After: Parameter.spec returns the longest-prefix declaration for options and the metavar for arguments. The message reads The option '--my-option' is deprecated. I ran this on the merge commit and got exactly that line (see the trace below).
  • Why: this follows PR #3821, which added Option.get_help_spec. While discussing it, the author "realized we had 3 aspects of a parameter for 3 different audiences" and harmonised the vocabulary (PR description). He chose not to rename name to id, to "keep this proposal reasonable".
flowchart LR
  subgraph Before
    o1["Option --my-option"] --> h1["human_readable_name"]
    h1 --> n1["my_option"]
  end
  subgraph After
    o2["Option --my-option"] --> s2["spec"]
    s2 --> n2["--my-option"]
    h2["human_readable_name"] -. "warns, then" .-> s2
  end
  classDef new fill:#e3f5ea,stroke:#1f8a4c,color:#0f3d22
  classDef changed fill:#fdf0dc,stroke:#b26b00,color:#4a2c00
  class s2 new
  class h2 changed
    
The old property still works, but it now emits a DeprecationWarning and returns the new value. Code that relied on it returning my_option for options gets --my-option.

The review thread is short: one comment from the author pointing a cloup maintainer at the change. There was no rework after review. The PR merged as a squash of one commit (9f0bcdf into 2247b35).

Glossary

decls
The strings a developer passes to @click.option or @click.argument, stored as param_decls. Parsed in src/click/core.py:3449 (options) and src/click/core.py:3963 (arguments).
name
The internal id and the keyword the callback receives, like gluten_free. Documented at src/click/core.py:2352.
opts
Every spelling that sets the parameter, like ['-g', '--gluten-free']. For an argument it holds the single declaration. src/click/core.py:2359.
secondary_opts
The spellings that set a boolean flag to False, the right side of --color/--no-color. src/click/core.py:2370.
spec
New. The one spelling users read and type: --times for an option, FILENAME for an argument. src/click/core.py:2570.
get_help_spec(ctx)
The longer form shown in the left column of --help, like -c, --config TEXT or [FILENAME].... Now on Parameter at src/click/core.py:2605.
metavar
The placeholder shown for a value, like TEXT or FILENAME. Argument.make_metavar adds [...] and ... markers for optional and repeated values.
prefix
The leading -, -- or / of a flag, split off by _split_opt in src/click/parser.py:111.
human_readable_name
The old property. Deprecated in 8.6.0, removed in 9.0. src/click/core.py:2586.
error hint
The quoted parameter text in an error like Missing argument 'RECIPE'. Built by get_error_hint (src/click/core.py:3023) and read by src/click/exceptions.py:150.

The main path: a deprecated option is used

sequenceDiagram
  autonumber
  participant U as User shell
  participant C as Command.parse_args
  participant P as Parameter.handle_parse_result
  participant S as Option.spec
  participant E as echo to stderr
  U->>C: cli --my-option hello brioche
  C->>P: for each param, handle_parse_result(ctx, opts, args)
  P->>P: consume_value returns hello, source COMMANDLINE
  P->>S: deprecated and source below DEFAULT_MAP, read self.spec
  S-->>P: --my-option, the longest-prefix decl
  P->>E: DeprecationWarning - The option '--my-option' is deprecated.
  P->>P: process_value, then store in ctx.params
    
The only line this PR changes on the path is step 4: the message reads self.spec where it read self.human_readable_name.

Worked example

Input taken from tests/test_options.py:131, run on the merge commit with an added argument recipe:

  1. @click.option("--my-option", deprecated=True, default="d") builds an Option. _parse_decls sets opts = ['--my-option'] and derives name = 'my_option' (src/click/core.py:3486).
  2. The user runs cli --my-option hello brioche. Command.parse_args calls param.handle_parse_result for each parameter (src/click/core.py:1404).
  3. consume_value returns "hello" with source COMMANDLINE, which is below DEFAULT_MAP, so the deprecation branch runs (src/click/core.py:2960).
  4. self.spec returns '--my-option': the only entry in opts.
  5. stderr gets DeprecationWarning: The option '--my-option' is deprecated., then stdout gets brioche hello. Both lines are the real output I captured.

Where it can fail

  • Missing argument. Run cli with no args. process_value raises MissingParameter (src/click/core.py:2827), whose message asks Argument.get_error_hint(ctx). That now calls get_help_spec(ctx) and prints Error: Missing argument 'RECIPE'. (captured).
  • Required and deprecated at once. Parameter.__init__ raises ValueError naming the parameter by spec (src/click/core.py:2485).
  • Subclass overrides the old property. The class statement itself emits a DeprecationWarning (src/click/core.py:2392). Click's test config turns every warning into an error (pyproject.toml:86), so this fails tests that define such a subclass.

Code tour

1. Parameter.spec: pick the longest prefix

What it does: returns the declaration with the longest prefix, so --times beats -t. A parameter with no opts falls back to name.

src/click/core.py:2569-2583new
    @property
    def spec(self) -> str:
        """Returns how the user spells this parameter on the command line.

        This is the declaration with the longest prefix, so an option declared
        as ``"-t", "--times"`` returns ``--times``. Click derives :attr:`name`
        from that same declaration, unless the developer passed an explicit
        name. A parameter without declarations returns its :attr:`name`.

        .. versionadded:: 8.6.0
        """
        if not self.opts:
            return self.name

        return max(self.opts, key=lambda opt: len(_split_opt(opt)[0]))
Why it's built this way

It mirrors how name is chosen, below, so spec and name come from the same declaration. max() returns the first of equal-length candidates, and the name sort is stable, so ties also agree: ['-t', '--times', '--repeat'] gives --times and times. test_spec asserts that link for every case.

2. Where name comes from (unchanged)

What it does: sorts candidate declarations by prefix length and turns the winner into a Python identifier.

src/click/core.py:3482-3486context
                    possible_names.append(_split_opt(decl))
                    opts.append(decl)

        if name is None and possible_names:
            possible_names.sort(key=lambda x: -len(x[0]))  # group long options first
Why it matters

If you ever change this rule, spec must change with it, or error messages will name a different flag than the one name came from. The one exception is an explicit name: Option(["--times", "repeat_count"]) has name = "repeat_count" and spec = "--times" (tests/test_options.py:123).

3. Argument.spec: an argument has no flag

What it does: returns the metavar if the developer gave one, else the name in capitals. This is the body the old Argument.human_readable_name had. Only the name changed.

src/click/core.py:3931-3942changed
    @property
    def spec(self) -> str:
        """Returns the argument's metavar, which is how the user reads it in
        ``--help``. An argument has no flag to spell, so this is the
        :attr:`metavar` if one was given, and the uppercased :attr:`name`
        otherwise.

        .. versionadded:: 8.6.0
        """
        if self.metavar is not None:
            return self.metavar
        return self.name.upper()

4. get_help_spec: the longer help-page form

What it does: Parameter.get_help_spec returns spec. Option overrides it to join every spelling plus the metavar. Argument overrides it to return make_metavar(ctx), which adds [...] for optional and ... for repeated values.

src/click/core.py:3987-4006new
    def get_help_spec(self, ctx: Context) -> str:
        """Returns the left column of the argument's help record: its metavar,
        like ``FILENAME`` or ``[FILENAME]``.

        .. versionadded:: 8.6.0
        """
        return self.make_metavar(ctx)
    …
        return self.get_help_spec(ctx), self.help or ""
src/click/core.py:3558-3587context
    def get_help_spec(self, ctx: Context) -> str:
    …

        rv = [_write_opts(self.opts)]

        if self.secondary_opts:
            rv.append(_write_opts(self.secondary_opts))

        return ("; " if any_prefix_is_slash else " / ").join(rv)
Why two methods

spec needs no context and names the parameter. get_help_spec(ctx) needs a context because the metavar can depend on it, and it decorates the value. The tests pin the difference: nargs=-1 gives spec FILENAME but help spec [FILENAME]... (tests/test_arguments.py:874). Unlike get_help_record, it also works for hidden options (docstring at src/click/core.py:2614).

5. Error hints switch to the new names

What it does: the text inside Missing argument '...' or Invalid value for '...'.

src/click/core.py:3899-3902 (before)removed
    def get_error_hint(self, ctx: Context | None) -> str:
        if ctx is not None:
            return f"'{self.make_metavar(ctx)}'"
        return f"'{self.human_readable_name}'"
src/click/core.py:4008-4011changed
    def get_error_hint(self, ctx: Context | None) -> str:
        if ctx is not None:
            return f"'{self.get_help_spec(ctx)}'"
        return f"'{self.spec}'"

The output is the same string as before for arguments. The point is that nothing inside Click reads human_readable_name any more, so the deprecated property can be deleted in 9.0 without touching callers. The base Parameter.get_error_hint still prefers opts and only falls back to spec (src/click/core.py:3030).

6. The deprecation message

What it does: the user-visible change in this PR.

src/click/core.py:2960-2972changed
            if (
                self.deprecated
                and value is not UNSET
                and source < ParameterSource.DEFAULT_MAP
            ):
                message = _(
                    "DeprecationWarning: The {param_type} {name!r} is deprecated."
                    "{extra_message}"
                ).format(
                    param_type=self.param_type_name,
                    name=self.spec,
                    extra_message=_format_deprecated_suffix(self.deprecated),
                )

7. The old property becomes an alias that warns

What it does: keeps human_readable_name working until 9.0, warns on every read, and returns spec.

src/click/core.py:2585-2603changed
    @property
    def human_readable_name(self) -> str:
        """Returns how the user spells this parameter on the command line.
    …
        import warnings

        warnings.warn(
            "'human_readable_name' is deprecated and will be removed in Click"
            " 9.0. Use 'spec' instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.spec
Why stacklevel=2 here

A property read is one frame from the caller, so a fixed level points at the caller's line. warnings is imported inside the function, which is how the rest of core.py does it (for example src/click/core.py:2396) (inferred: keeps import time down).

8. Catching subclasses that override the old property

What it does: at class-definition time, warns if a subclass defines human_readable_name. Click no longer calls it, so such an override would silently stop working.

src/click/core.py:2392-2404new
    def __init_subclass__(cls, **kwargs: t.Any) -> None:
        super().__init_subclass__(**kwargs)

        if "human_readable_name" in cls.__dict__:
            import warnings

            warnings.warn(
                f"{cls.__name__} overrides 'human_readable_name'. Click no"
                " longer reads that property. Override 'spec' instead. Click"
                " will remove 'human_readable_name' in 9.0.",
                DeprecationWarning,
                stacklevel=_outside_click_stacklevel(skip=("abc",)),
            )
src/click/core.py:108-127changed
def _outside_click_stacklevel(skip: cabc.Container[str] = ()) -> int:
    …

    while frame is not None:
        module = frame.f_globals.get("__name__", "")

        if module != "click" and not module.startswith("click.") and module not in skip:
            return level

        frame = frame.f_back
        level += 1
Like you're five

A warning has to point at a line of the user's code. _outside_click_stacklevel walks up the call stack, skipping Click's own frames, until it finds the user. The new skip argument adds abc to the "not the user" list, because Parameter is an ABC and Python's abc module sits between the class statement and __init_subclass__.

Why at class time

A read-time warning would never fire, because Click itself no longer reads the property. Only the class definition shows the override (docs/upgrade-guides.md:19).

9. The test that pins the rule

tests/test_options.py:102-120new
@pytest.mark.parametrize(
    ("param_decls", "expected_spec", "expected_name"),
    [
        (["-t", "--times"], "--times", "times"),
        (["--times", "-t"], "--times", "times"),
        (["-t"], "-t", "t"),
        (["-t", "--times", "--repeat"], "--times", "times"),
        (["--color/--no-color"], "--color", "color"),
        (["/debug;/no-debug"], "/debug", "debug"),
    ],
)
def test_spec(param_decls, expected_spec, expected_name):
    """The spec is the declaration the name was derived from, so the longest
    prefix wins over declaration order.
    """
    opt = click.Option(param_decls)
    assert opt.spec == expected_spec
    assert opt.name == expected_name
    assert _split_opt(opt.spec)[1].replace("-", "_").lower() == opt.name

Reading order

filewhat it's forread it when
docs/parameters.mdThe decls / spec / name vocabulary, with the exampleFirst, before any code
src/click/core.py (class Parameter, from line 2241)spec, get_help_spec, the deprecation aliasChanging what any parameter is called in output
src/click/core.py (class Option, from line 3057)_parse_decls, the help-spec overrideChanging flag parsing or help layout
src/click/core.py (class Argument, from line 3894)Metavar-based spec and hintsChanging positional-argument output
src/click/exceptions.pyWhere error hints get usedChanging error wording
tests/test_options.py, tests/test_arguments.py, tests/test_deprecations.pyThe tests for all of the aboveBefore you open a PR

Read these yourself

  1. docs/parameters.md:32-77: the decls / spec / name vocabulary and the help-page example. 4 min.
  2. src/click/core.py:3449-3514: Option._parse_decls, how declarations become name, opts and secondary_opts. 5 min.
  3. src/click/core.py:3023-3031: Parameter.get_error_hint lists every entry of opts and uses spec only when opts is empty. 1 min.
  4. src/click/exceptions.py:146-156: BadParameter.format_message, where the hint reaches the user. 2 min.
  5. tests/test_deprecations.py:71-104: the tests for the alias warning and the subclass warning. 3 min.

Who overrides what

classDiagram
  class Parameter {
    +name: str
    +opts: list
    +secondary_opts: list
    +spec() str
    +get_help_spec(ctx) str
    +get_error_hint(ctx) str
    +human_readable_name() str
  }
  class Option {
    +get_help_spec(ctx) str
    +get_error_hint(ctx) str
  }
  class Argument {
    +spec() str
    +get_help_spec(ctx) str
    +get_error_hint(ctx) str
  }
  Parameter <|-- Option
  Parameter <|-- Argument
    
Option inherits spec from Parameter; only Argument overrides it. Both override get_help_spec. Nobody should override human_readable_name any more.

How to contribute here

Set up and run

Click's own docs defer setup to the Pallets guide (docs/contributing.md:3). CI runs everything through uv and tox (.github/workflows/tests.yaml:39). Python 3.10 or later (pyproject.toml:16).

  1. ⚠️ not run here: uv run --locked --no-default-groups --group dev tox run, the CI command. Or tox run -e py3.13 for one interpreter.
  2. ✅ ran: a plain venv with pip install -e . pytest. This is the fastest path to a green run and is all you need for this area.

Test it

  • ✅ ran: python -m pytest tests/test_options.py tests/test_arguments.py tests/test_deprecations.py gave 971 passed in 0.62s.
  • ✅ ran: python -m pytest tests/test_options.py -k test_spec, the main-path test (tests/test_options.py:113), gave 7 passed.
  • ✅ ran: python -m pytest, the whole suite, gave 2269 passed, 25 skipped, 1 xfailed in 3.58s. Stress tests are deselected by default (pyproject.toml:91).
  • ⚠️ not run here: tox r -e typing (mypy and pyright), tox r -e style (pre-commit, including ruff), tox r -e docs (Sphinx with -W, so a broken {attr} role fails the build).

Tests ran against the clone at ac0e297, two commits after the merge. src/click/core.py and the three test files are identical at both commits.

Conventions that apply here

  • Warnings are errors in tests (pyproject.toml:86). Any new DeprecationWarning needs a pytest.warns in the test that triggers it.
  • No ternaries: coverage cannot measure both branches (docs/contributing.md:35). Note how Argument.spec uses if and two returns. Older code such as Option.get_help_spec (stop 4) still has a ternary; do not copy it into new code.
  • Wrap Markdown at 80 characters (docs/contributing.md:45).
  • Every change gets a CHANGES.md entry with {pr}`NNNN`, plus .. versionadded:: or .. versionchanged:: in the docstring (.github/pull_request_template.md:23).
  • Deprecations also get a bullet in docs/upgrade-guides.md with the migration path (docs/upgrade-guides.md:19).

Recipe: change what users see as a parameter's name

  1. Decide whether you need the short form (spec, no context) or the decorated help form (get_help_spec(ctx)). Messages naming a parameter use spec. The help column uses get_help_spec.
  2. For all parameters, edit Parameter.spec at src/click/core.py:2570. For arguments only, edit Argument.spec at src/click/core.py:3932.
  3. If name derivation changes too, keep Option._parse_decls (src/click/core.py:3484) and Parameter.spec choosing the same declaration.
  4. Add a case to test_spec (tests/test_options.py:102) or test_spec_and_help_spec (tests/test_arguments.py:874).
  5. If output wording changes, grep tests for the old string. test_deprecated_warning asserts on it (tests/test_options.py:146).
  6. Add a CHANGES.md bullet under Unreleased and a docstring versionchanged.
Watch out
  • Do not call human_readable_name from Click code. It warns, and with filterwarnings = error every test that reaches it fails.
  • name is not for users. It is the callback keyword and the source of auto env var names (src/click/core.py:3626), and __repr__ still uses it (src/click/core.py:2525). Changing it breaks callbacks and env vars.
  • Parameter.get_error_hint lists all opts ('-t' / '--times'), not just spec. Only arguments, and options without opts, fall back to spec.
  • An option declared only with a false-side flag has empty opts, so spec falls back to the internal name. I checked: Option(["shout", " /--no-shout"], is_flag=True).spec == "shout".
  • The skip=("abc",) in __init_subclass__ is load-bearing. Without it the warning points inside the abc module, not at the user's class line.

Who knows this

Kevin Deldycke (PR author, most commits to src/click/core.py since mid-2025) and Rowlando13 (merged it).

Check yourself

An option is declared "-v", "--verbose" and is deprecated. What does the warning name it, and which line decides that?

'--verbose'. Parameter.spec picks the longest prefix with max() (code tour stop 1), and the warning formats self.spec (stop 6).

You want --help to show arguments differently. Which method do you change, and which test fails first?

Argument.get_help_spec (stop 4), which get_help_record calls. test_spec_and_help_spec and test_help_spec_matches_help_record in tests/test_arguments.py pin it.

A plugin subclasses click.Option and overrides human_readable_name. What happens when it imports, and what happens to its custom value?

The class statement emits a DeprecationWarning pointing at the plugin's line (stop 8). The custom value is ignored, because Click reads spec. The fix is to override spec.

What would break if spec picked the first declaration instead of the longest prefix?

test_spec fails on ["-t", "--times"]: spec would be -t while name is times, and the last assertion that spec maps back to name fails (stop 9).

Why can you not use name in a new user-facing message?

It is the Python identifier (my_option), which the user never typed. That is the bug this PR fixed (Before and after).

Starter task

Give false-side-only options a real spec. Option(["shout", " /--no-shout"], is_flag=True) has opts == [] and secondary_opts == ["--no-shout"], so spec and the error hint both return the internal name shout. Make spec fall back to secondary_opts before name.

  • Code: Parameter.spec, src/click/core.py:2570. Keep the if/return style, no ternary.
  • Also check Parameter.get_error_hint (src/click/core.py:3030), which reads self.opts or [self.spec].
  • Test: add a case to test_spec in tests/test_options.py:102, or a new test beside test_spec_with_explicit_name. Note the last assertion in test_spec maps spec back to name, which will not hold here, so a separate test is cleaner.
  • Done when python -m pytest tests/test_options.py passes and a CHANGES.md bullet is added. Open an issue first, per .github/pull_request_template.md:2.

Read next

  • PR #3821: introduced Option.get_help_spec, the starting point for this one.
  • docs/parameters.md, section "Parameter Names": the user-facing version of this page.
  • src/click/exceptions.py: every place a parameter's hint ends up in an error.
  • docs/upgrade-guides.md: how Click writes deprecations, so yours match.