Skip to content

Commit 758d9be

Browse files
committed
Auto-detect type=UNPROCESSED when flag_value has non-basic types
Closes #2012
1 parent f1f191e commit 758d9be

3 files changed

Lines changed: 92 additions & 19 deletions

File tree

CHANGES.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ Unreleased
4040
- Change :class:`ParameterSource` to an :class:`~enum.IntEnum` and reorder
4141
its members from most to least explicit, so values can be compared to
4242
check whether a parameter was explicitly provided. :issue:`2879` :pr:`3248`
43+
- Auto-detect ``type=UNPROCESSED`` for ``flag_value`` of non-basic types
44+
(not ``str``, ``int``, ``float``, or ``bool``), so programmer-provided
45+
Python objects like classes and enum members are passed through unchanged
46+
instead of being stringified. Previously ``type=click.UNPROCESSED`` had
47+
to be set explicitly. :issue:`2012`
4348

4449
Version 8.3.2
4550
-------------

src/click/core.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2714,6 +2714,11 @@ class Option(Parameter):
27142714
:param hidden: hide this option from help outputs.
27152715
:param attrs: Other command arguments described in :class:`Parameter`.
27162716
2717+
.. versionchanged:: 8.4
2718+
Non-basic ``flag_value`` types (not ``str``, ``int``, ``float``, or
2719+
``bool``) are passed through unchanged instead of being stringified.
2720+
Previously, ``type=click.UNPROCESSED`` was required to preserve them.
2721+
27172722
.. versionchanged:: 8.2
27182723
``envvar`` used with ``flag_value`` will always use the ``flag_value``,
27192724
previously it would use the value of the environment variable.
@@ -2731,7 +2736,8 @@ class Option(Parameter):
27312736
default value is ``False``.
27322737
27332738
.. versionchanged:: 8.0.1
2734-
``type`` is detected from ``flag_value`` if given.
2739+
``type`` is detected from ``flag_value`` if given, for basic Python
2740+
types (``str``, ``int``, ``float``, ``bool``).
27352741
"""
27362742

27372743
param_type_name = "option"
@@ -2831,7 +2837,20 @@ def __init__(
28312837
self.type = types.BoolParamType()
28322838
# Otherwise, guess the type from the flag value.
28332839
else:
2834-
self.type = types.convert_type(None, flag_value)
2840+
guessed = types.convert_type(None, flag_value)
2841+
if (
2842+
isinstance(guessed, types.StringParamType)
2843+
and not isinstance(flag_value, str)
2844+
and flag_value is not None
2845+
):
2846+
# The flag_value type couldn't be auto-detected
2847+
# (not str, int, float, or bool). Since flag_value
2848+
# is a programmer-provided Python object, not CLI
2849+
# input, pass it through unchanged instead of
2850+
# stringifying it.
2851+
self.type = types.UNPROCESSED
2852+
else:
2853+
self.type = guessed
28352854

28362855
self.is_flag: bool = bool(is_flag)
28372856
self.is_bool_flag: bool = bool(

tests/test_options.py

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,6 +1406,11 @@ def test_type_from_flag_value():
14061406
assert param.type is click.INT
14071407
param = click.Option(["-b", "x"], flag_value=8)
14081408
assert param.type is click.INT
1409+
# Non-basic types auto-detect as UNPROCESSED to avoid stringification.
1410+
param = click.Option(["-c", "x"], flag_value=EngineType.OSS)
1411+
assert param.type is click.UNPROCESSED
1412+
param = click.Option(["-d", "x"], flag_value=frozenset())
1413+
assert param.type is click.UNPROCESSED
14091414

14101415

14111416
@pytest.mark.parametrize(
@@ -2127,13 +2132,13 @@ class Class2:
21272132
[],
21282133
EngineType.OSS,
21292134
),
2130-
# Type is not specified and default to string, so the default value is
2131-
# returned as a string, even if it is a boolean. Also, defaults to the
2132-
# flag_value instead of the default value to support legacy behavior.
2135+
# Type is not specified. For string flag_value, STRING type is used and
2136+
# the default value is converted to string. For non-basic types (like
2137+
# enums), UNPROCESSED is used and values pass through unchanged.
21332138
({"flag_value": "1", "default": True}, [], "1"),
21342139
({"flag_value": "1", "default": 42}, [], "42"),
2135-
({"flag_value": EngineType.OSS, "default": True}, [], "EngineType.OSS"),
2136-
({"flag_value": EngineType.OSS, "default": 42}, [], "42"),
2140+
({"flag_value": EngineType.OSS, "default": True}, [], EngineType.OSS),
2141+
({"flag_value": EngineType.OSS, "default": 42}, [], 42),
21372142
# See: the result is the same if we force the type to be str.
21382143
({"type": str, "flag_value": 1, "default": True}, [], "1"),
21392144
({"type": str, "flag_value": 1, "default": 42}, [], "42"),
@@ -2199,28 +2204,29 @@ def scan(pro):
21992204
["--opt2"],
22002205
EngineType.PRO,
22012206
),
2202-
# Check that passing exotic flag values like classes is supported, but are
2203-
# rendered to strings when the type is not specified.
2207+
# Exotic flag values like classes are passed through unchanged when no
2208+
# explicit type is given (UNPROCESSED is auto-detected).
2209+
# https://github.com/pallets/click/issues/2012
22042210
# https://github.com/pallets/click/issues/3121
22052211
(
22062212
{"flag_value": Class1, "default": True},
22072213
{"flag_value": Class2},
22082214
[],
2209-
"<class 'test_options.Class1'>",
2215+
Class1,
22102216
),
22112217
(
22122218
{"flag_value": Class1, "default": True},
22132219
{"flag_value": Class2},
22142220
["--opt1"],
2215-
"<class 'test_options.Class1'>",
2221+
Class1,
22162222
),
22172223
(
22182224
{"flag_value": Class1, "default": True},
22192225
{"flag_value": Class2},
22202226
["--opt2"],
2221-
"<class 'test_options.Class2'>",
2227+
Class2,
22222228
),
2223-
# Even the default is processed as a string.
2229+
# String and None defaults pass through unchanged.
22242230
({"flag_value": Class1, "default": "True"}, {"flag_value": Class2}, [], "True"),
22252231
({"flag_value": Class1, "default": None}, {"flag_value": Class2}, [], None),
22262232
# To get the classes as-is, we need to specify the type as UNPROCESSED.
@@ -2245,18 +2251,18 @@ def scan(pro):
22452251
),
22462252
# Setting the default to a class, an instance of the class is returned instead
22472253
# of the class itself, because the default is allowed to be callable (and
2248-
# consummd). And this happens whatever the type is.
2254+
# consumed). And this happens whatever the type is.
22492255
(
22502256
{"flag_value": Class1, "default": Class1},
22512257
{"flag_value": Class2},
22522258
[],
2253-
re.compile(r"'<test_options.Class1 object at 0x[0-9A-Fa-f]+>'"),
2259+
re.compile(r"<test_options.Class1 object at 0x[0-9A-Fa-f]+>"),
22542260
),
22552261
(
22562262
{"flag_value": Class1, "default": Class2},
22572263
{"flag_value": Class2},
22582264
[],
2259-
re.compile(r"'<test_options.Class2 object at 0x[0-9A-Fa-f]+>'"),
2265+
re.compile(r"<test_options.Class2 object at 0x[0-9A-Fa-f]+>"),
22602266
),
22612267
(
22622268
{"flag_value": Class1, "type": UNPROCESSED, "default": Class1},
@@ -2322,12 +2328,13 @@ def cli(dual_option):
23222328
["--opt"],
23232329
Class1,
23242330
),
2325-
# Without UNPROCESSED, the class is str()-ified by the default STRING type.
2326-
({"flag_value": Class1, "default": True}, [], "<class 'test_options.Class1'>"),
2331+
# Without explicit UNPROCESSED, the class still passes through unchanged
2332+
# because UNPROCESSED is auto-detected for non-basic flag_value types.
2333+
({"flag_value": Class1, "default": True}, [], Class1),
23272334
(
23282335
{"flag_value": Class1, "default": True},
23292336
["--opt"],
2330-
"<class 'test_options.Class1'>",
2337+
Class1,
23312338
),
23322339
# Explicit default=Class1 (not via default=True alignment): callable IS invoked,
23332340
# because the user explicitly set a callable as the default.
@@ -2473,6 +2480,48 @@ def cli(value):
24732480
assert opt.get_default(ctx, call=True) is expected_get_default
24742481

24752482

2483+
def test_flag_value_not_stringified_for_custom_types(runner):
2484+
"""Non-basic flag_value types are passed through unchanged without
2485+
requiring ``type=click.UNPROCESSED``.
2486+
2487+
Regression test for https://github.com/pallets/click/issues/2012
2488+
"""
2489+
2490+
@click.command()
2491+
@click.option("--cls1", "config_cls", flag_value=Class1, default=True)
2492+
@click.option("--cls2", "config_cls", flag_value=Class2)
2493+
def cli(config_cls):
2494+
click.echo(repr(config_cls), nl=False)
2495+
2496+
# Default activates --cls1 (default=True resolves to flag_value).
2497+
result = runner.invoke(cli, [])
2498+
assert result.exit_code == 0
2499+
assert result.output == repr(Class1)
2500+
2501+
result = runner.invoke(cli, ["--cls1"])
2502+
assert result.exit_code == 0
2503+
assert result.output == repr(Class1)
2504+
2505+
result = runner.invoke(cli, ["--cls2"])
2506+
assert result.exit_code == 0
2507+
assert result.output == repr(Class2)
2508+
2509+
# Enum flag_value without explicit type is also preserved.
2510+
@click.command()
2511+
@click.option("--oss", "engine", flag_value=EngineType.OSS, default=True)
2512+
@click.option("--pro", "engine", flag_value=EngineType.PRO)
2513+
def cli2(engine):
2514+
click.echo(repr(engine), nl=False)
2515+
2516+
result = runner.invoke(cli2, [])
2517+
assert result.exit_code == 0
2518+
assert result.output == repr(EngineType.OSS)
2519+
2520+
result = runner.invoke(cli2, ["--pro"])
2521+
assert result.exit_code == 0
2522+
assert result.output == repr(EngineType.PRO)
2523+
2524+
24762525
def test_custom_type_frozenset_flag_value(runner):
24772526
"""Check that frozenset is correctly handled as a type, a flag value and a default.
24782527

0 commit comments

Comments
 (0)