Skip to content
Closed
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

- `count=True` options with a non-zero `default` (or a `default_map`
baseline) now increment relative to that baseline when the flag is
passed on the command line, matching `argparse` `action="count"`.
Previously the parser always counted from `0`, so fewer flag
occurrences than the default could produce a value lower than the
default. {issue}`3841`

## Version 8.5.0

Released 2026-08-24
Expand Down
27 changes: 27 additions & 0 deletions src/click/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2893,6 +2893,9 @@ class Option(Parameter):
in how it works but supports arbitrary number of
arguments.
:param count: this flag makes an option increment an integer.
Occurrences on the command line are counted relative to
the option's default (including :attr:`Context.default_map`),
matching :mod:`argparse`'s ``action="count"`` behavior.
:param allow_from_autoenv: if this is enabled then the value of this
parameter will be pulled from an environment
variable in case a prefix is defined on the
Expand All @@ -2901,6 +2904,12 @@ class Option(Parameter):
:param hidden: hide this option from help outputs.
:param attrs: Other command arguments described in :class:`Parameter`.

.. versionchanged:: 8.5.1
``count=True`` increments relative to the option default (and
``default_map``) instead of always counting from ``0``. This matches
:mod:`argparse` and means a non-zero default is no longer replaced by a
smaller command-line count.

.. 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 @@ -3600,6 +3609,24 @@ def consume_value(
"""
value, source = super().consume_value(ctx, opts)

# Count options increment relative to their default baseline (argparse-
# compatible). The parser always counts from 0, so when the value came
# from the command line add the default / default_map baseline.
# Refs: https://github.com/pallets/click/issues/3841
if (
self.count
and source is ParameterSource.COMMANDLINE
and value is not UNSET
and value is not FLAG_NEEDS_VALUE
):
baseline: t.Any = 0
if self.name is not None and ctx._default_map_has(self.name):
mapped = ctx.lookup_default(self.name)
baseline = 0 if mapped is None else mapped
elif self.default is not UNSET and self.default is not None:
baseline = self.default
value = baseline + value

# The parser emits a sentinel when a flag is allowed to be used without a value.
# Resolve it to a prompt or to the activation value depending on the option's
# configuration.
Expand Down
42 changes: 42 additions & 0 deletions tests/test_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,48 @@ def cli(v):
assert re.search(r"-v\s+Verbosity", result.output) is not None


@pytest.mark.parametrize(
("default", "args", "expected"),
[
(3, [], "3"),
(3, ["-v"], "4"),
(3, ["-vv"], "5"),
(3, ["-vvv"], "6"),
(1, [], "1"),
(1, ["-v"], "2"),
(0, [], "0"),
(0, ["-v"], "1"),
(0, ["-vv"], "2"),
],
)
def test_counting_respects_nonzero_default(runner, default, args, expected):
"""Count increments relative to default (argparse-compatible). See #3841."""

@click.command()
@click.option("-v", count=True, default=default)
def cli(v):
click.echo(v)

result = runner.invoke(cli, args)
assert not result.exception
assert result.output == f"{expected}\n"


def test_counting_respects_default_map_baseline(runner):
@click.command()
@click.option("-v", count=True, default=0)
def cli(v):
click.echo(v)

result = runner.invoke(cli, ["-v"], default_map={"v": 3})
assert not result.exception
assert result.output == "4\n"

result = runner.invoke(cli, [], default_map={"v": 3})
assert not result.exception
assert result.output == "3\n"


@pytest.mark.parametrize("unknown_flag", ["--foo", "-f"])
def test_unknown_options(runner, unknown_flag):
@click.command()
Expand Down