From b1f6427606b67362de233588dd7a37496195b031 Mon Sep 17 00:00:00 2001 From: Claudio Jolowicz Date: Tue, 15 Jun 2021 19:31:55 +0200 Subject: [PATCH] Add support for click 8.x (#1569) * Make read_user_dict compatible with click 8.x The `read_user_dict` function uses a "default" sentinel instead of the actual default value. Being a JSON dict, the latter would often be hard to type. Under click 8.x, the default value for `click.prompt` is passed to the `read_proc` callback. We use this callback to load JSON from the user input, and this would choke on an input like "default" (without quotes). Therefore, change the callback to return the default value when it receives the "default" sentinel. Under click 7.x (which is our minimum version), the default value for `click.prompt` is returned as-is. Therefore, continue to handle the case where `click.prompt` returns "default" instead of the actual default value, but only if we're actually running under click 7.x. (Checking for click 7.x is only done for clarity. Under click 8.x, `click.prompt` would never return "default", even if a user entered it as a valid JSON string. This is because our callback requires a dict, not a string.) * test: Expect read_user_dict to call click.prompt with partial object Previously, tests for `read_user_dict` expected `process_json` to be passed to click.prompt directly. Instead, we now pass an instance of `functools.partial`, so adapt the mock to reflect that. * test: Avoid mocking `click.prompt` when testing defaults Do not mock `click.prompt` when testing that `read_user_dict` returns the proper default value (rather than the sentinel "default"). Mocking `click.prompt` prevents our callback from running, and under click >= 8.0 we process the sentinel in the callback. Instead, use `click.testing.CliRunner` to fake standard input. * test: Adapt regression test for default handling Expect `json.loads` not to be called with the sentinel ("default"). Previously, the test expected `process_json` not to be called, but under click >= 8.0 that is now where we handle the sentinel. * Update dependencies for click 8.x --- cookiecutter/prompt.py | 23 ++++++++++++++++------- setup.cfg | 2 +- tests/test_read_user_dict.py | 34 +++++++++++++++++----------------- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/cookiecutter/prompt.py b/cookiecutter/prompt.py index dfb8f3218..4b8b2fbe6 100644 --- a/cookiecutter/prompt.py +++ b/cookiecutter/prompt.py @@ -1,4 +1,5 @@ """Functions for prompting the user for project info.""" +import functools import json from collections import OrderedDict @@ -78,11 +79,18 @@ def read_user_choice(var_name, options): return choice_map[user_choice] -def process_json(user_value): +DEFAULT_DISPLAY = 'default' + + +def process_json(user_value, default_value=None): """Load user-supplied value as a JSON dict. :param str user_value: User-supplied value to load as a JSON dict """ + if user_value == DEFAULT_DISPLAY: + # Return the given default w/o any processing + return default_value + try: user_dict = json.loads(user_value, object_pairs_hook=OrderedDict) except Exception: @@ -107,15 +115,16 @@ def read_user_dict(var_name, default_value): if not isinstance(default_value, dict): raise TypeError - default_display = 'default' - user_value = click.prompt( - var_name, default=default_display, type=click.STRING, value_proc=process_json + var_name, + default=DEFAULT_DISPLAY, + type=click.STRING, + value_proc=functools.partial(process_json, default_value=default_value), ) - if user_value == default_display: - # Return the given default w/o any processing - return default_value + if click.__version__.startswith("7.") and user_value == DEFAULT_DISPLAY: + # click 7.x does not invoke value_proc on the default value. + return default_value # pragma: no cover return user_value diff --git a/setup.cfg b/setup.cfg index 609c2dc11..b9f2cf7a8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -62,7 +62,7 @@ setup_requires = install_requires = binaryornot>=0.4.4 Jinja2>=2.7,<4.0.0 - click>=7.0,<8.0.0 + click>=7.0,<9.0.0 pyyaml>=5.3.1 jinja2-time>=0.2.0 python-slugify>=4.0.0 diff --git a/tests/test_read_user_dict.py b/tests/test_read_user_dict.py index ccf632258..0ce50efc2 100644 --- a/tests/test_read_user_dict.py +++ b/tests/test_read_user_dict.py @@ -92,35 +92,35 @@ def test_should_call_prompt_with_process_json(mocker): read_user_dict('name', {'project_slug': 'pytest-plugin'}) - assert mock_prompt.call_args == mocker.call( - 'name', type=click.STRING, default='default', value_proc=process_json, - ) + args, kwargs = mock_prompt.call_args + + assert args == ('name',) + assert kwargs['type'] == click.STRING + assert kwargs['default'] == 'default' + assert kwargs['value_proc'].func == process_json -def test_should_not_call_process_json_default_value(mocker, monkeypatch): - """Make sure that `process_json` is not called when using default value.""" - mock_process_json = mocker.patch('cookiecutter.prompt.process_json', autospec=True) +def test_should_not_load_json_from_sentinel(mocker): + """Make sure that `json.loads` is not called when using default value.""" + mock_json_loads = mocker.patch( + 'cookiecutter.prompt.json.loads', autospec=True, return_value={} + ) runner = click.testing.CliRunner() with runner.isolation(input="\n"): read_user_dict('name', {'project_slug': 'pytest-plugin'}) - mock_process_json.assert_not_called() + mock_json_loads.assert_not_called() -def test_read_user_dict_default_value(mocker): +@pytest.mark.parametrize("input", ["\n", "default\n"]) +def test_read_user_dict_default_value(mocker, input): """Make sure that `read_user_dict` returns the default value. Verify return of a dict variable rather than the display value. """ - mock_prompt = mocker.patch( - 'cookiecutter.prompt.click.prompt', autospec=True, return_value='default', - ) - - val = read_user_dict('name', {'project_slug': 'pytest-plugin'}) - - assert mock_prompt.call_args == mocker.call( - 'name', type=click.STRING, default='default', value_proc=process_json, - ) + runner = click.testing.CliRunner() + with runner.isolation(input=input): + val = read_user_dict('name', {'project_slug': 'pytest-plugin'}) assert val == {'project_slug': 'pytest-plugin'}