Skip to content
Open
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
8 changes: 8 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

Unreleased

- {class}`Argument` derives its name the same way {class}`Option` does, and must be
a valid Python identifier, else raises `TypeError`. {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`
- Neither kind builds a parameter without a declaration. `click.argument()` and

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this saying that Parameter.name can no longer be None? Do we need to update the annotation? Would be great if so.

`click.option()` with none and `expose_value=False` used to name a parameter
`""`, which showed nothing on the usage line. {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
74 changes: 74 additions & 0 deletions docs/arguments.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,80 @@ 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.

```{eval-rst}
.. list-table:: Examples
:widths: 15 15
:header-rows: 1

* - Decorator Arguments
- Inferred Argument Name
* - ``"foo-bar"``
- foo_bar
* - ``"x"``
- x
* - ``"CamelCase"``
- camelcase
* - ``"Foo_Bar"``
- foo_bar
* - ``"café"``
- café
* - ``"ΟΔΟΣ"``
- οδος
* - ``"\N{KELVIN SIGN}"``
- k
* - ``"foo-٣"``
- foo_٣
* - ``"0-file"``
- :exc:`TypeError`
* - ``"٣foo"``
- :exc:`TypeError`
* - ``"foo.bar"``
- :exc:`TypeError`
* - ``"foo\N{NON-BREAKING HYPHEN}bar"``
- :exc:`TypeError`
* - ``"a\N{ZERO WIDTH SPACE}b"``
- :exc:`TypeError`
* - ``""``
- :exc:`TypeError`
* - ``"foo", "bar"``
- :exc:`TypeError`
```

The name must satisfy {meth}`str.isidentifier`. Options apply the same check,
and the {ref}`caution about reserved keywords <keyword-names>` applies here
too.

`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")`.

(unicode-names)=

```{caution}
Only the ASCII hyphen is replaced, so a separator that merely looks like one is
refused, as is any character that renders nothing.

The identifier set itself moves with the Unicode table Python ships: the
zero-width joiner (`U+200D`) entered it in Python 3.13, so a declaration holding
one is refused up to Python 3.12 and names a parameter from 3.13 on. Prefer a
declaration that is already a lower-case identifier with `-` for `_`.
```

Comment on lines +112 to +121

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably too much detail. I doubt anyone's writing these, but linking to isidentifier should already be enough.

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. An argument takes exactly one, which serves
as both the name and the metavar, so it is always transformed.

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

Expand Down
54 changes: 50 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,12 +83,60 @@ characters are replaced with `_`.
- dest
* - ``"--CamelCase"``
- camelcase
* - ``"-f", "--filename", "Dest"``
- Dest
* - ``"-f", "-fb"``
- f
* - ``"--f", "--foo-bar"``
- f
* - ``"---f"``
- _f
* - ``"--0-file"``
- :exc:`TypeError`
* - ``"--foo.bar"``
- :exc:`TypeError`
```

The name must satisfy {meth}`str.isidentifier`. {ref}`Arguments
<argument-names>` derive their name the same way and apply the same check,
including the {ref}`caution about Unicode declarations <unicode-names>`.

(keyword-names)=

```{caution}
A [reserved keyword](https://docs.python.org/3/reference/lexical_analysis.html#keywords)
satisfies that check, so Click accepts one: `click.option("--from")` names its
parameter `from`. Three things follow, and Click reports none of them.

- The callback cannot declare it. `def cmd(from)` is a {exc}`SyntaxError`, so
the command has to take `**kwargs` instead.
- That `**kwargs` then covers every other parameter too, and Python stops
checking the callback signature. An option you rename or drop used to raise
`TypeError: got an unexpected keyword argument`, and is now absorbed in
silence.
- {meth}`Context.invoke` cannot name it either. Write
`ctx.invoke(other, **{"from": value})`, because `ctx.invoke(other, from=value)`
is a {exc}`SyntaxError`. {meth}`Context.forward` is unaffected, since it
unpacks {attr}`Context.params`.

Pass an explicit name instead: `click.option("--from", "source")`. An argument
takes one declaration and has no explicit-name channel, so rename the
declaration there. Soft keywords (`match`, `case`, `type`, `_`) are contextual
and name a parameter fine, and `--True`, `--False` and `--None` lower case out
of the keyword set.
```

`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).
```
Comment on lines +104 to 140

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also seems like too much detail.

The example at the end about capitalization can be added to the table at the top if it's not there.


## Basic Example
Expand Down Expand Up @@ -509,6 +553,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
88 changes: 60 additions & 28 deletions src/click/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2353,9 +2353,7 @@ def __init__(
| None = None,
deprecated: bool | str = False,
) -> None:
self.name, self.opts, self.secondary_opts = self._parse_decls(
param_decls or (), expose_value
)
self.name, self.opts, self.secondary_opts = self._parse_decls(param_decls or ())
self.type: types.ParamType[t.Any] = types.convert_type(type, default)

# Default nargs to what the type tells us if we have that
Expand Down Expand Up @@ -2436,9 +2434,47 @@ def __repr__(self) -> str:

@abstractmethod
def _parse_decls(
self, decls: cabc.Sequence[str], expose_value: bool
self, decls: cabc.Sequence[str]
) -> 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. The name must satisfy :meth:`str.isidentifier`. A keyword
such as ``from`` passes, and can then only be received by a
``**kwargs`` callback. Every kind of parameter is held to this.

``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}"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Could not determine a valid Python identifier for param_type decls."

).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 +2965,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 @@ -3007,9 +3048,6 @@ def __init__(
# Phase 1: prompt-related attributes. ``_infer_flag_kind`` reads ``self.prompt``
# and ``self.prompt_required`` so this must run first.
if prompt is True:
if not self.name:
raise TypeError("'name' is required with 'prompt=True'.")

prompt_text = self.name.replace("_", " ").capitalize()
elif prompt is False:
prompt_text = None
Expand Down Expand Up @@ -3261,7 +3299,7 @@ def get_error_hint(self, ctx: Context | None) -> str:
return result

def _parse_decls(
self, decls: cabc.Sequence[str], expose_value: bool
self, decls: cabc.Sequence[str]
) -> tuple[str, list[str], list[str]]:
opts = []
secondary_opts = []
Expand Down Expand Up @@ -3297,18 +3335,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 @@ -3708,6 +3737,13 @@ class Argument(Parameter):

:param help: the help string.

.. versionchanged:: 8.5.1
Exactly one declaration is required, and it must name a Python
identifier once it is lower-cased and every ``-`` is replaced with
``_``. ``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 @@ -3778,22 +3814,18 @@ def make_metavar(self, ctx: Context) -> str:
return var

def _parse_decls(
self, decls: cabc.Sequence[str], expose_value: bool
self, decls: cabc.Sequence[str]
) -> tuple[str, list[str], list[str]]:
if not 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