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.
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).
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
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') |
spec | RECIPE | --gluten-free |
name | recipe | gluten_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_namereturnedself.namefor options and the metavar for arguments (basesrc/click/core.py:2520,src/click/core.py:3838at base). A deprecated option printedThe option 'my_option' is deprecated. - After:
Parameter.specreturns the longest-prefix declaration for options and the metavar for arguments. The message readsThe 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 renamenametoid, 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
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.optionor@click.argument, stored asparam_decls. Parsed insrc/click/core.py:3449(options) andsrc/click/core.py:3963(arguments). name- The internal id and the keyword the callback receives, like
gluten_free. Documented atsrc/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:
--timesfor an option,FILENAMEfor an argument.src/click/core.py:2570. get_help_spec(ctx)- The longer form shown in the left column of
--help, like-c, --config TEXTor[FILENAME].... Now onParameteratsrc/click/core.py:2605. - metavar
- The placeholder shown for a value, like
TEXTorFILENAME.Argument.make_metavaradds[...]and...markers for optional and repeated values. - prefix
- The leading
-,--or/of a flag, split off by_split_optinsrc/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 byget_error_hint(src/click/core.py:3023) and read bysrc/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
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:
@click.option("--my-option", deprecated=True, default="d")builds anOption._parse_declssetsopts = ['--my-option']and derivesname = 'my_option'(src/click/core.py:3486).- The user runs
cli --my-option hello brioche.Command.parse_argscallsparam.handle_parse_resultfor each parameter (src/click/core.py:1404). consume_valuereturns"hello"with sourceCOMMANDLINE, which is belowDEFAULT_MAP, so the deprecation branch runs (src/click/core.py:2960).self.specreturns'--my-option': the only entry inopts.- stderr gets
DeprecationWarning: The option '--my-option' is deprecated., then stdout getsbrioche hello. Both lines are the real output I captured.
Where it can fail
- Missing argument. Run
cliwith no args.process_valueraisesMissingParameter(src/click/core.py:2827), whose message asksArgument.get_error_hint(ctx). That now callsget_help_spec(ctx)and printsError: Missing argument 'RECIPE'.(captured). - Required and deprecated at once.
Parameter.__init__raisesValueErrornaming the parameter byspec(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.
@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]))
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.
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
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.
@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.
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 ""
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)
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 '...'.
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}'"
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.
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.
@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
stacklevel=2 hereA 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.
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",)),
)
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
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__.
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
@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
| file | what it's for | read it when |
|---|---|---|
docs/parameters.md | The decls / spec / name vocabulary, with the example | First, before any code |
src/click/core.py (class Parameter, from line 2241) | spec, get_help_spec, the deprecation alias | Changing what any parameter is called in output |
src/click/core.py (class Option, from line 3057) | _parse_decls, the help-spec override | Changing flag parsing or help layout |
src/click/core.py (class Argument, from line 3894) | Metavar-based spec and hints | Changing positional-argument output |
src/click/exceptions.py | Where error hints get used | Changing error wording |
tests/test_options.py, tests/test_arguments.py, tests/test_deprecations.py | The tests for all of the above | Before you open a PR |
Read these yourself
docs/parameters.md:32-77: the decls / spec / name vocabulary and the help-page example. 4 min.src/click/core.py:3449-3514:Option._parse_decls, how declarations becomename,optsandsecondary_opts. 5 min.src/click/core.py:3023-3031:Parameter.get_error_hintlists every entry ofoptsand usesspeconly whenoptsis empty. 1 min.src/click/exceptions.py:146-156:BadParameter.format_message, where the hint reaches the user. 2 min.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
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).
- ⚠️ not run here:
uv run --locked --no-default-groups --group dev tox run, the CI command. Ortox run -e py3.13for one interpreter. - ✅ 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.pygave971 passed in 0.62s. - ✅ ran:
python -m pytest tests/test_options.py -k test_spec, the main-path test (tests/test_options.py:113), gave7 passed. - ✅ ran:
python -m pytest, the whole suite, gave2269 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 newDeprecationWarningneeds apytest.warnsin the test that triggers it. - No ternaries: coverage cannot measure both branches (
docs/contributing.md:35). Note howArgument.specusesifand two returns. Older code such asOption.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.mdentry 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.mdwith the migration path (docs/upgrade-guides.md:19).
Recipe: change what users see as a parameter's name
- Decide whether you need the short form (
spec, no context) or the decorated help form (get_help_spec(ctx)). Messages naming a parameter usespec. The help column usesget_help_spec. - For all parameters, edit
Parameter.specatsrc/click/core.py:2570. For arguments only, editArgument.specatsrc/click/core.py:3932. - If
namederivation changes too, keepOption._parse_decls(src/click/core.py:3484) andParameter.specchoosing the same declaration. - Add a case to
test_spec(tests/test_options.py:102) ortest_spec_and_help_spec(tests/test_arguments.py:874). - If output wording changes, grep tests for the old string.
test_deprecated_warningasserts on it (tests/test_options.py:146). - Add a
CHANGES.mdbullet under Unreleased and a docstringversionchanged.
- Do not call
human_readable_namefrom Click code. It warns, and withfilterwarnings = errorevery test that reaches it fails. nameis 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_hintlists allopts('-t' / '--times'), not justspec. Only arguments, and options withoutopts, fall back tospec.- An option declared only with a false-side flag has empty
opts, sospecfalls 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 theabcmodule, not at the user'sclassline.
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).
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 theif/return style, no ternary. - Also check
Parameter.get_error_hint(src/click/core.py:3030), which readsself.opts or [self.spec]. - Test: add a case to
test_specintests/test_options.py:102, or a new test besidetest_spec_with_explicit_name. Note the last assertion intest_specmaps spec back to name, which will not hold here, so a separate test is cleaner. - Done when
python -m pytest tests/test_options.pypasses and aCHANGES.mdbullet 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.