Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

Unreleased

- {class}`Argument` derives its name the same way {class}`Option` does, and
holds it to the same check: a declaration that does not yield a Python
identifier now raises `TypeError`. Declarations such as `0-file`, `foo.bar` and
`foo bar` are affected. {pr}`3827`
- `expose_value=False` no longer excuses that check on either kind. Pass an
explicit name to {class}`Option`, or rename an {class}`Argument` and pass
`metavar` to keep its display. {pr}`3827`
- Fix `copy.deepcopy()` and `pickle` on a `Parameter`, `Option` or `Command`. {pr}`3805`
- A `KeyboardInterrupt` arriving while `Command.main()` reports an abort or an error,
or while it exits, no longer escapes as an unhandled traceback. The command still
Expand Down
38 changes: 38 additions & 0 deletions docs/arguments.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,44 @@ recognized, otherwise {data}`STRING` is used. If no default value is
provided, the type is assumed to be {data}`STRING`. See
{ref}`type-inference` for the types that are recognized.

(argument-names)=

## Argument Names

The single declaration is not used as the name verbatim. Every `-` is replaced
with `_` and the result is lower cased, so `click.argument("input-file")` names
its parameter `input_file`. That is the same transform options apply, and it is
likewise not reversible: `input-file`, `Input-File` and `INPUT_FILE` all name
`input_file`.

A name is passed to the command callback as a keyword argument. Click requires
it to be a Python identifier, so `click.argument("0-file")` raises
{exc}`TypeError`: `0_file` is not one. This is Click's own rule and not a Python
limit, since a callback declared with `**kwargs` carries any string as a key. It
keeps every parameter reachable by a named argument in the callback signature.
Options apply that same check.

`expose_value=False` is no exception. The name is also the key the parser stores
the value under, so an argument that gave it up would share that key with the
next one. Rename the declaration and pass `metavar` to keep the old display:
`click.argument("zero_file", expose_value=False, metavar="0-FILE")`.

```{caution}
This covers more than digits. A declaration holding a dot, a space, a hyphen
that is not the ASCII one, or a zero-width character is refused too, since none
of them yields an identifier. What counts as an identifier follows the Unicode
version Python ships: the zero-width joiner, for one, entered that set in
Python 3.13. Prefer a declaration that is already a lower-case identifier with
`-` for `_`.
```

One difference from {ref}`option names <options>` remains. An option takes
several declarations, so one that is already an identifier is read as an
explicit name and kept as written: `click.option("--in-file", "Input_File")`
names its parameter `Input_File`. An argument takes exactly one declaration,
which has to serve as both the name and the metavar, so it is always
transformed and `click.argument("Input_File")` names `input_file`.

```{admonition} Note on Required Arguments
:class: note

Expand Down
29 changes: 25 additions & 4 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,6 @@ follows:
declared is chosen.
3. Otherwise, the first positional argument prefixed with `-` is chosen.

To get the argument name, the chosen positional argument is converted to lower
case, a leading `-` or `--` is removed if found, and any remaining `-`
characters are replaced with `_`.

```{eval-rst}
.. list-table:: Examples
:widths: 15 15
Expand All @@ -87,6 +83,8 @@ characters are replaced with `_`.
- dest
* - ``"--CamelCase"``
- camelcase
* - ``"-f", "--filename", "Dest"``
- Dest
* - ``"-f", "-fb"``
- f
* - ``"--f", "--foo-bar"``
Expand All @@ -95,6 +93,27 @@ characters are replaced with `_`.
- _f
```

A name derived this way is passed to the command callback as a keyword
argument. Click requires it to be a Python identifier, so
`click.option("--0-file")` raises {exc}`TypeError`: `0_file` is not one. This is
Click's own rule and not a Python limit, since a callback declared with
`**kwargs` carries any string as a key. It keeps every parameter reachable by a
named argument in the callback signature. {ref}`Arguments <argument-names>`
derive their name the same way and apply the same check.

`expose_value=False` is no exception. The name is also the parser dest the value
is stored under, so two options that gave it up would share that dest and each
read the other's value. Pass an explicit name instead:
`click.option("--0-file", "zero_file", expose_value=False)`.

```{caution}
Transformation from option name to argument name is not reversible. And is many-to-one: several option names can map to the same argument name.

For example, `--foo-bar`, `--Foo-Bar` and `--FOO-BAR` all map to `foo_bar`.

This is allowed so that options can deliberately form a [feature switch group](#feature-switch-group).
```

## Basic Example

A simple {class}`click.Option` takes one option name. By default, it's assumed
Expand Down Expand Up @@ -509,6 +528,8 @@ literally.
¹: `default=True` is substituted with `flag_value`.
```

(feature-switch-group)=

#### Feature switch groups (multiple flags sharing one variable)

Several `flag_value` options can target the same parameter name to form a
Expand Down
72 changes: 57 additions & 15 deletions src/click/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2439,6 +2439,45 @@ def _parse_decls(
self, decls: cabc.Sequence[str], expose_value: bool
) -> tuple[str, list[str], list[str]]: ...

@staticmethod
def _name_from_spec(spec: str) -> str:
"""Derive a parameter name from a single declaration.

The declaration is lower-cased and every ``-`` becomes a ``_``, so
``--input-file``, ``--Input-File`` and ``INPUT_FILE`` all name
``input_file``. An option passes the declaration with its prefix
already stripped; an argument passes its sole declaration whole.

The transform is many-to-one, and so cannot be reversed: the name does
not tell you which declaration produced it.
"""
return spec.replace("-", "_").lower()

def _resolve_name(self, name: str | None, decls: cabc.Sequence[str]) -> str:
"""Settle the name derived from ``decls``, or refuse it.

A parameter's value reaches the command callback as a keyword argument.
Click requires that name to be a Python identifier. Python itself would
carry any string into a ``**kwargs`` callback, so this is Click's own
rule: it keeps every parameter reachable by a named argument in the
callback signature. Every kind of parameter is held to it.

``expose_value=False`` is no exception. The name is also the key the
parser stores the value under, so two parameters that gave it up would
share that key and each read the other's value.

:raises TypeError: when no name was derived, or the one derived is not
an identifier.
"""
if name is not None and name.isidentifier():
return name

raise TypeError(
_(
"Could not determine name for {param_type} with declarations {decls!r}"
).format(param_type=self.param_type_name, decls=decls)
)

@property
def human_readable_name(self) -> str:
"""Returns the human readable name of this parameter. This is the
Expand Down Expand Up @@ -2929,6 +2968,11 @@ class Option(Parameter):
:param hidden: hide this option from help outputs.
:param attrs: Other command arguments described in :class:`Parameter`.

.. versionchanged:: 8.5.1
``expose_value=False`` no longer excuses a declaration that names no
Python identifier. Pass an explicit name, such as
``click.option("--0-file", "zero_file", expose_value=False)``.

.. versionchanged:: 8.4.0
Non-basic ``flag_value`` types (not ``str``, ``int``, ``float``, or
``bool``) are passed through unchanged instead of being stringified.
Expand Down Expand Up @@ -3297,18 +3341,9 @@ def _parse_decls(

if name is None and possible_names:
possible_names.sort(key=lambda x: -len(x[0])) # group long options first
name = possible_names[0][1].replace("-", "_").lower()
if not name.isidentifier():
name = None
name = self._name_from_spec(possible_names[0][1])

if name is None:
if not expose_value:
return "", opts, secondary_opts
raise TypeError(
_(
"Could not determine name for option with declarations {decls!r}"
).format(decls=decls)
)
name = self._resolve_name(name, decls)

if not opts and not secondary_opts:
raise TypeError(
Expand Down Expand Up @@ -3697,6 +3732,12 @@ class Argument(Parameter):

:param help: the help string.

.. versionchanged:: 8.5.1
The declaration must name a Python identifier once it is lower-cased
and every ``-`` is replaced with ``_``, and ``expose_value=False`` is
no exception. This aligns with option's behavior. Pass ``metavar`` to
render a display the declaration can no longer carry.

.. versionchanged:: 8.5.0
Added the ``help`` parameter.
"""
Expand Down Expand Up @@ -3773,16 +3814,17 @@ def _parse_decls(
if not expose_value:
return "", [], []
raise TypeError("Argument is marked as exposed, but does not have a name.")
if len(decls) == 1:
name = arg = decls[0]
name = name.replace("-", "_").lower()
else:

if len(decls) != 1:
raise TypeError(
_(
"Arguments take exactly one parameter declaration, got"
" {length}: {decls}."
).format(length=len(decls), decls=decls)
)

arg = decls[0]
name = self._resolve_name(self._name_from_spec(arg), decls)
return name, [arg], []

def get_usage_pieces(self, ctx: Context) -> list[str]:
Expand Down
Loading