Skip to content

New "default" behaviour in Click 8.3.x is broken for negative boolean flags #3111

@ldionne

Description

@ldionne

The behaviour of default=... for flag values changed in Click 8.3.0, as documented in the release notes. The new behaviour is broken for negative flags, unless I am holding it wrong. Here's a minimal reproducer (put this in foo.py):

import click

@click.command('foo')
@click.option('--without-xyz', 'enable_xyz',
              help="Disable xyz", flag_value=False, default=True, show_default=True)
def foo(enable_xyz):
    print(f'enable_xyz = {enable_xyz}')

foo()

The intent here is that we have an argument representing whether xyz is enabled, and a flag named --without-xyz which defaults to being disabled. Here, "disabled" means that the flag is not passed, which means the default=True value is used to initialize the enable_xyz variable. If --without-xyz were passed, the flag_value=False would be used to initialize enable_xyz instead, which makes sense. Or at least, that was the case in Click 8.2.x:

% ./foo.py
enable_xyz = True

 % ./foo.py --without-xyz
enable_xyz = False

However, with Click 8.3.0, default=True is now being treated as a special case. Special cases are often surprising, and indeed here this leads to the following behaviour:

% ./foo.py
enable_xyz = False

% ./foo.py --without-xyz
enable_xyz = False

I don't see how the new behaviour makes sense, at least for negative flags. I do not understand why a special case is needed for flags, as documented:

But there is a special case for flags. If a flag has a flag_value, then setting default=True is interpreted as the flag should be activated by default. So instead of the underlying function receiving the True Python value, it will receive the flag_value.

Is there a reason for this special case?

FWIW, this is a pretty major breaking change that results in the negative flag being always unset. This will often result in a silent change in behaviour for software that used to work as intended. Such software may keep functioning (depending on what --without-xyz was disabling), but may change behaviour, performance characteristics, etc. This is pretty bad -- I would have much much preferred a loud error to this silent behaviour change.

Anyway, here are some thoughts:

  • Is the special case really useful and necessary?
  • Would it make sense to make this an explicit error instead of silently changing behaviour in Click 8.3.x?
  • If the Click developers find that this is working as designed, what's the canonical way of adding options like --without-xyz that default to being "off"? This solution should not require flipping the logic of the application, i.e. using a disable_xyz variable name instead (which indeed simplifies the Click option definition but creates double-negatives in the user code that wants to check if not disable_xyz).
  • Once the path forward is decided, I believe that a documentation update might be helpful to at least cover how negative options should be done.

Cheers and sorry in advance in case I missed something in the documentation that explains my confusion.

Environment:

  • Python version: Python 3.10 (but it should reproduce anywhere)
  • Click version: 8.3.x

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugf:parametersfeature: input parameter types

    Type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions