cookiecutter
771 строка · 27.1 Кб
1"""Tests for `cookiecutter.prompt` module."""
2
3import json4import platform5import sys6from collections import OrderedDict7from pathlib import Path8
9import click10import pytest11
12from cookiecutter import environment, exceptions, prompt13
14
15@pytest.fixture(autouse=True)16def patch_readline_on_win(monkeypatch) -> None:17"""Fixture. Overwrite windows end of line to linux standard."""18if 'windows' in platform.platform().lower():19monkeypatch.setattr('sys.stdin.readline', lambda: '\n')20
21
22class TestRenderVariable:23"""Class to unite simple and complex tests for render_variable function."""24
25@pytest.mark.parametrize(26'raw_var, rendered_var',27[28(1, '1'),29(True, True),30('foo', 'foo'),31('{{cookiecutter.project}}', 'foobar'),32(None, None),33],34)35def test_convert_to_str(self, mocker, raw_var, rendered_var) -> None:36"""Verify simple items correctly rendered to strings."""37env = environment.StrictEnvironment()38from_string = mocker.patch(39'cookiecutter.utils.StrictEnvironment.from_string', wraps=env.from_string40)41context = {'project': 'foobar'}42
43result = prompt.render_variable(env, raw_var, context)44assert result == rendered_var45
46# Make sure that non None non str variables are converted beforehand47if raw_var is not None and not isinstance(raw_var, bool):48if not isinstance(raw_var, str):49raw_var = str(raw_var)50from_string.assert_called_once_with(raw_var)51else:52assert not from_string.called53
54@pytest.mark.parametrize(55'raw_var, rendered_var',56[57({1: True, 'foo': False}, {'1': True, 'foo': False}),58(59{'{{cookiecutter.project}}': ['foo', 1], 'bar': False},60{'foobar': ['foo', '1'], 'bar': False},61),62(['foo', '{{cookiecutter.project}}', None], ['foo', 'foobar', None]),63],64)65def test_convert_to_str_complex_variables(self, raw_var, rendered_var) -> None:66"""Verify tree items correctly rendered."""67env = environment.StrictEnvironment()68context = {'project': 'foobar'}69
70result = prompt.render_variable(env, raw_var, context)71assert result == rendered_var72
73
74class TestPrompt:75"""Class to unite user prompt related tests."""76
77@pytest.mark.parametrize(78'context',79[80{'cookiecutter': {'full_name': 'Your Name'}},81{'cookiecutter': {'full_name': 'Řekni či napiš své jméno'}},82],83ids=['ASCII default prompt/input', 'Unicode default prompt/input'],84)85def test_prompt_for_config(self, monkeypatch, context) -> None:86"""Verify `prompt_for_config` call `read_user_variable` on text request."""87monkeypatch.setattr(88'cookiecutter.prompt.read_user_variable',89lambda _var, default, _prompts, _prefix: default,90)91
92cookiecutter_dict = prompt.prompt_for_config(context)93assert cookiecutter_dict == context['cookiecutter']94
95@pytest.mark.parametrize(96'context',97[98{99'cookiecutter': {100'full_name': 'Your Name',101'check': ['yes', 'no'],102'nothing': 'ok',103'__prompts__': {104'full_name': 'Name please',105'check': 'Checking',106},107}108},109],110ids=['ASCII default prompt/input'],111)112def test_prompt_for_config_with_human_prompts(self, monkeypatch, context) -> None:113"""Verify call `read_user_variable` on request when human-readable prompts."""114monkeypatch.setattr(115'cookiecutter.prompt.read_user_variable',116lambda _var, default, _prompts, _prefix: default,117)118monkeypatch.setattr(119'cookiecutter.prompt.read_user_yes_no',120lambda _var, default, _prompts, _prefix: default,121)122monkeypatch.setattr(123'cookiecutter.prompt.read_user_choice',124lambda _var, default, _prompts, _prefix: default,125)126
127cookiecutter_dict = prompt.prompt_for_config(context)128assert cookiecutter_dict == context['cookiecutter']129
130@pytest.mark.parametrize(131'context',132[133{134'cookiecutter': {135'full_name': 'Your Name',136'check': ['yes', 'no'],137'__prompts__': {138'check': 'Checking',139},140}141},142{143'cookiecutter': {144'full_name': 'Your Name',145'check': ['yes', 'no'],146'__prompts__': {147'full_name': 'Name please',148'check': {'__prompt__': 'Checking', 'yes': 'Yes', 'no': 'No'},149},150}151},152{153'cookiecutter': {154'full_name': 'Your Name',155'check': ['yes', 'no'],156'__prompts__': {157'full_name': 'Name please',158'check': {'no': 'No'},159},160}161},162],163)164def test_prompt_for_config_with_human_choices(self, context) -> None:165"""Test prompts when human-readable labels for user choices."""166runner = click.testing.CliRunner()167with runner.isolation(input="\n\n\n"):168cookiecutter_dict = prompt.prompt_for_config(context)169
170assert dict(cookiecutter_dict) == {'full_name': 'Your Name', 'check': 'yes'}171
172def test_prompt_for_config_dict(self, monkeypatch) -> None:173"""Verify `prompt_for_config` call `read_user_variable` on dict request."""174monkeypatch.setattr(175'cookiecutter.prompt.read_user_dict',176lambda _var, _default, _prompts, _prefix: {"key": "value", "integer": 37},177)178context = {'cookiecutter': {'details': {}}}179
180cookiecutter_dict = prompt.prompt_for_config(context)181assert cookiecutter_dict == {'details': {'key': 'value', 'integer': 37}}182
183def test_should_render_dict(self) -> None:184"""Verify template inside dictionary variable rendered."""185context = {186'cookiecutter': {187'project_name': 'Slartibartfast',188'details': {189'{{cookiecutter.project_name}}': '{{cookiecutter.project_name}}'190},191}192}193
194cookiecutter_dict = prompt.prompt_for_config(context, no_input=True)195assert cookiecutter_dict == {196'project_name': 'Slartibartfast',197'details': {'Slartibartfast': 'Slartibartfast'},198}199
200def test_should_render_deep_dict(self) -> None:201"""Verify nested structures like dict in dict, rendered correctly."""202context = {203'cookiecutter': {204'project_name': "Slartibartfast",205'details': {206"key": "value",207"integer_key": 37,208"other_name": '{{cookiecutter.project_name}}',209"dict_key": {210"deep_key": "deep_value",211"deep_integer": 42,212"deep_other_name": '{{cookiecutter.project_name}}',213"deep_list": [214"deep value 1",215"{{cookiecutter.project_name}}",216"deep value 3",217],218},219"list_key": [220"value 1",221"{{cookiecutter.project_name}}",222"value 3",223],224},225}226}227
228cookiecutter_dict = prompt.prompt_for_config(context, no_input=True)229assert cookiecutter_dict == {230'project_name': "Slartibartfast",231'details': {232"key": "value",233"integer_key": "37",234"other_name": "Slartibartfast",235"dict_key": {236"deep_key": "deep_value",237"deep_integer": "42",238"deep_other_name": "Slartibartfast",239"deep_list": ["deep value 1", "Slartibartfast", "deep value 3"],240},241"list_key": ["value 1", "Slartibartfast", "value 3"],242},243}244
245def test_should_render_deep_dict_with_human_prompts(self) -> None:246"""Verify dict rendered correctly when human-readable prompts."""247context = {248'cookiecutter': {249'project_name': "Slartibartfast",250'details': {251"key": "value",252"integer_key": 37,253"other_name": '{{cookiecutter.project_name}}',254"dict_key": {255"deep_key": "deep_value",256},257},258'__prompts__': {'project_name': 'Project name'},259}260}261cookiecutter_dict = prompt.prompt_for_config(context, no_input=True)262assert cookiecutter_dict == {263'project_name': "Slartibartfast",264'details': {265"key": "value",266"integer_key": "37",267"other_name": "Slartibartfast",268"dict_key": {269"deep_key": "deep_value",270},271},272}273
274def test_internal_use_no_human_prompts(self) -> None:275"""Verify dict rendered correctly when human-readable prompts empty."""276context = {277'cookiecutter': {278'project_name': "Slartibartfast",279'__prompts__': {},280}281}282cookiecutter_dict = prompt.prompt_for_config(context, no_input=True)283assert cookiecutter_dict == {284'project_name': "Slartibartfast",285}286
287def test_prompt_for_templated_config(self, monkeypatch) -> None:288"""Verify Jinja2 templating works in unicode prompts."""289monkeypatch.setattr(290'cookiecutter.prompt.read_user_variable',291lambda _var, default, _prompts, _prefix: default,292)293context = {294'cookiecutter': OrderedDict(295[296('project_name', 'A New Project'),297(298'pkg_name',299'{{ cookiecutter.project_name|lower|replace(" ", "") }}',300),301]302)303}304
305exp_cookiecutter_dict = {306'project_name': 'A New Project',307'pkg_name': 'anewproject',308}309cookiecutter_dict = prompt.prompt_for_config(context)310assert cookiecutter_dict == exp_cookiecutter_dict311
312def test_dont_prompt_for_private_context_var(self, monkeypatch) -> None:313"""Verify `read_user_variable` not called for private context variables."""314monkeypatch.setattr(315'cookiecutter.prompt.read_user_variable',316lambda _var, _default: pytest.fail(317'Should not try to read a response for private context var'318),319)320context = {'cookiecutter': {'_copy_without_render': ['*.html']}}321cookiecutter_dict = prompt.prompt_for_config(context)322assert cookiecutter_dict == {'_copy_without_render': ['*.html']}323
324def test_should_render_private_variables_with_two_underscores(self) -> None:325"""Test rendering of private variables with two underscores.326
327There are three cases:
3281. Variables beginning with a single underscore are private and not rendered.
3292. Variables beginning with a double underscore are private and are rendered.
3303. Variables beginning with anything other than underscores are not private and
331are rendered.
332"""
333context = {334'cookiecutter': OrderedDict(335[336('foo', 'Hello world'),337('bar', 123),338('rendered_foo', '{{ cookiecutter.foo|lower }}'),339('rendered_bar', 123),340('_hidden_foo', '{{ cookiecutter.foo|lower }}'),341('_hidden_bar', 123),342('__rendered_hidden_foo', '{{ cookiecutter.foo|lower }}'),343('__rendered_hidden_bar', 123),344]345)346}347cookiecutter_dict = prompt.prompt_for_config(context, no_input=True)348assert cookiecutter_dict == OrderedDict(349[350('foo', 'Hello world'),351('bar', '123'),352('rendered_foo', 'hello world'),353('rendered_bar', '123'),354('_hidden_foo', '{{ cookiecutter.foo|lower }}'),355('_hidden_bar', 123),356('__rendered_hidden_foo', 'hello world'),357('__rendered_hidden_bar', '123'),358]359)360
361def test_should_not_render_private_variables(self) -> None:362"""Verify private(underscored) variables not rendered by `prompt_for_config`.363
364Private variables designed to be raw, same as context input.
365"""
366context = {367'cookiecutter': {368'project_name': 'Skip render',369'_skip_jinja_template': '{{cookiecutter.project_name}}',370'_skip_float': 123.25,371'_skip_integer': 123,372'_skip_boolean': True,373'_skip_nested': True,374}375}376cookiecutter_dict = prompt.prompt_for_config(context, no_input=True)377assert cookiecutter_dict == context['cookiecutter']378
379
380DEFAULT_PREFIX = ' [dim][1/1][/] '381
382
383class TestReadUserChoice:384"""Class to unite choices prompt related tests."""385
386def test_should_invoke_read_user_choice(self, mocker) -> None:387"""Verify correct function called for select(list) variables."""388prompt_choice = mocker.patch(389'cookiecutter.prompt.prompt_choice_for_config',390wraps=prompt.prompt_choice_for_config,391)392
393read_user_choice = mocker.patch('cookiecutter.prompt.read_user_choice')394read_user_choice.return_value = 'all'395
396read_user_variable = mocker.patch('cookiecutter.prompt.read_user_variable')397
398choices = ['landscape', 'portrait', 'all']399context = {'cookiecutter': {'orientation': choices}}400
401cookiecutter_dict = prompt.prompt_for_config(context)402
403assert not read_user_variable.called404assert prompt_choice.called405read_user_choice.assert_called_once_with(406'orientation', choices, {}, DEFAULT_PREFIX407)408assert cookiecutter_dict == {'orientation': 'all'}409
410def test_should_invoke_read_user_variable(self, mocker) -> None:411"""Verify correct function called for string input variables."""412read_user_variable = mocker.patch('cookiecutter.prompt.read_user_variable')413read_user_variable.return_value = 'Audrey Roy'414
415prompt_choice = mocker.patch('cookiecutter.prompt.prompt_choice_for_config')416
417read_user_choice = mocker.patch('cookiecutter.prompt.read_user_choice')418
419context = {'cookiecutter': {'full_name': 'Your Name'}}420
421cookiecutter_dict = prompt.prompt_for_config(context)422
423assert not prompt_choice.called424assert not read_user_choice.called425read_user_variable.assert_called_once_with(426'full_name', 'Your Name', {}, DEFAULT_PREFIX427)428assert cookiecutter_dict == {'full_name': 'Audrey Roy'}429
430def test_should_render_choices(self, mocker) -> None:431"""Verify Jinja2 templating engine works inside choices variables."""432read_user_choice = mocker.patch('cookiecutter.prompt.read_user_choice')433read_user_choice.return_value = 'anewproject'434
435read_user_variable = mocker.patch('cookiecutter.prompt.read_user_variable')436read_user_variable.return_value = 'A New Project'437
438rendered_choices = ['foo', 'anewproject', 'bar']439
440context = {441'cookiecutter': OrderedDict(442[443('project_name', 'A New Project'),444(445'pkg_name',446[447'foo',448'{{ cookiecutter.project_name|lower|replace(" ", "") }}',449'bar',450],451),452]453)454}455
456expected = {457'project_name': 'A New Project',458'pkg_name': 'anewproject',459}460cookiecutter_dict = prompt.prompt_for_config(context)461
462read_user_variable.assert_called_once_with(463'project_name', 'A New Project', {}, ' [dim][1/2][/] '464)465read_user_choice.assert_called_once_with(466'pkg_name', rendered_choices, {}, ' [dim][2/2][/] '467)468assert cookiecutter_dict == expected469
470
471class TestPromptChoiceForConfig:472"""Class to unite choices prompt related tests with config test."""473
474@pytest.fixture475def choices(self):476"""Fixture. Just populate choices variable."""477return ['landscape', 'portrait', 'all']478
479@pytest.fixture480def context(self, choices):481"""Fixture. Just populate context variable."""482return {'cookiecutter': {'orientation': choices}}483
484def test_should_return_first_option_if_no_input(485self, mocker, choices, context486) -> None:487"""Verify prompt_choice_for_config return first list option on no_input=True."""488read_user_choice = mocker.patch('cookiecutter.prompt.read_user_choice')489
490expected_choice = choices[0]491
492actual_choice = prompt.prompt_choice_for_config(493cookiecutter_dict=context,494env=environment.StrictEnvironment(),495key='orientation',496options=choices,497no_input=True, # Suppress user input498)499
500assert not read_user_choice.called501assert expected_choice == actual_choice502
503def test_should_read_user_choice(self, mocker, choices, context) -> None:504"""Verify prompt_choice_for_config return user selection on no_input=False."""505read_user_choice = mocker.patch('cookiecutter.prompt.read_user_choice')506read_user_choice.return_value = 'all'507
508expected_choice = 'all'509
510actual_choice = prompt.prompt_choice_for_config(511cookiecutter_dict=context,512env=environment.StrictEnvironment(),513key='orientation',514options=choices,515no_input=False, # Ask the user for input516)517read_user_choice.assert_called_once_with('orientation', choices, None, '')518assert expected_choice == actual_choice519
520
521class TestReadUserYesNo:522"""Class to unite boolean prompt related tests."""523
524@pytest.mark.parametrize(525'run_as_docker',526(527True,528False,529),530)531def test_should_invoke_read_user_yes_no(self, mocker, run_as_docker) -> None:532"""Verify correct function called for boolean variables."""533read_user_yes_no = mocker.patch('cookiecutter.prompt.read_user_yes_no')534read_user_yes_no.return_value = run_as_docker535
536read_user_variable = mocker.patch('cookiecutter.prompt.read_user_variable')537
538context = {'cookiecutter': {'run_as_docker': run_as_docker}}539
540cookiecutter_dict = prompt.prompt_for_config(context)541
542assert not read_user_variable.called543read_user_yes_no.assert_called_once_with(544'run_as_docker', run_as_docker, {}, DEFAULT_PREFIX545)546assert cookiecutter_dict == {'run_as_docker': run_as_docker}547
548def test_boolean_parameter_no_input(self) -> None:549"""Verify boolean parameter sent to prompt for config with no input."""550context = {551'cookiecutter': {552'run_as_docker': True,553}554}555cookiecutter_dict = prompt.prompt_for_config(context, no_input=True)556assert cookiecutter_dict == context['cookiecutter']557
558
559@pytest.mark.parametrize(560'context',561(562{'cookiecutter': {'foo': '{{cookiecutter.nope}}'}},563{'cookiecutter': {'foo': ['123', '{{cookiecutter.nope}}', '456']}},564{'cookiecutter': {'foo': {'{{cookiecutter.nope}}': 'value'}}},565{'cookiecutter': {'foo': {'key': '{{cookiecutter.nope}}'}}},566),567ids=[568'Undefined variable in cookiecutter dict',569'Undefined variable in cookiecutter dict with choices',570'Undefined variable in cookiecutter dict with dict_key',571'Undefined variable in cookiecutter dict with key_value',572],573)
574def test_undefined_variable(context) -> None:575"""Verify `prompt.prompt_for_config` raises correct error."""576with pytest.raises(exceptions.UndefinedVariableInTemplate) as err:577prompt.prompt_for_config(context, no_input=True)578
579error = err.value580assert error.message == "Unable to render variable 'foo'"581assert error.context == context582
583
584@pytest.mark.parametrize(585"template_dir,expected",586[587["fake-nested-templates", "fake-project"],588["fake-nested-templates-old-style", "fake-package"],589],590)
591def test_cookiecutter_nested_templates(template_dir: str, expected: str) -> None:592"""Test nested_templates generation."""593from cookiecutter import prompt594
595main_dir = (Path("tests") / template_dir).resolve()596cookiecuter_context = json.loads((main_dir / "cookiecutter.json").read_text())597context = {"cookiecutter": cookiecuter_context}598output_dir = prompt.choose_nested_template(context, main_dir, no_input=True)599expected = (Path(main_dir) / expected).resolve()600assert output_dir == f"{expected}"601
602
603@pytest.mark.skipif(sys.platform.startswith('win'), reason="Linux / macos test")604@pytest.mark.parametrize(605"path",606[607"",608"/tmp",609"/foo",610],611)
612def test_cookiecutter_nested_templates_invalid_paths(path: str) -> None:613"""Test nested_templates generation."""614from cookiecutter import prompt615
616main_dir = (Path("tests") / "fake-nested-templates").resolve()617cookiecuter_context = json.loads((main_dir / "cookiecutter.json").read_text())618cookiecuter_context["templates"]["fake-project"]["path"] = path619context = {"cookiecutter": cookiecuter_context}620with pytest.raises(ValueError) as exc:621prompt.choose_nested_template(context, main_dir, no_input=True)622assert "Illegal template path" in str(exc)623
624
625@pytest.mark.skipif(not sys.platform.startswith('win'), reason="Win only test")626@pytest.mark.parametrize(627"path",628[629"",630"C:/tmp",631"D:/tmp",632],633)
634def test_cookiecutter_nested_templates_invalid_win_paths(path: str) -> None:635"""Test nested_templates generation."""636from cookiecutter import prompt637
638main_dir = (Path("tests") / "fake-nested-templates").resolve()639cookiecuter_context = json.loads((main_dir / "cookiecutter.json").read_text())640cookiecuter_context["templates"]["fake-project"]["path"] = path641context = {"cookiecutter": cookiecuter_context}642with pytest.raises(ValueError) as exc:643prompt.choose_nested_template(context, main_dir, no_input=True)644assert "Illegal template path" in str(exc)645
646
647def test_prompt_should_ask_and_rm_repo_dir(mocker, tmp_path) -> None:648"""In `prompt_and_delete()`, if the user agrees to delete/reclone the \649repo, the repo should be deleted."""
650mock_read_user = mocker.patch(651'cookiecutter.prompt.read_user_yes_no', return_value=True652)653repo_dir = Path(tmp_path, 'repo')654repo_dir.mkdir()655
656deleted = prompt.prompt_and_delete(str(repo_dir))657
658assert mock_read_user.called659assert not repo_dir.exists()660assert deleted661
662
663def test_prompt_should_ask_and_exit_on_user_no_answer(mocker, tmp_path) -> None:664"""In `prompt_and_delete()`, if the user decline to delete/reclone the \665repo, cookiecutter should exit."""
666mock_read_user = mocker.patch(667'cookiecutter.prompt.read_user_yes_no',668return_value=False,669)670mock_sys_exit = mocker.patch('sys.exit', return_value=True)671repo_dir = Path(tmp_path, 'repo')672repo_dir.mkdir()673
674deleted = prompt.prompt_and_delete(str(repo_dir))675
676assert mock_read_user.called677assert repo_dir.exists()678assert not deleted679assert mock_sys_exit.called680
681
682def test_prompt_should_ask_and_rm_repo_file(mocker, tmp_path) -> None:683"""In `prompt_and_delete()`, if the user agrees to delete/reclone a \684repo file, the repo should be deleted."""
685mock_read_user = mocker.patch(686'cookiecutter.prompt.read_user_yes_no', return_value=True, autospec=True687)688
689repo_file = tmp_path.joinpath('repo.zip')690repo_file.write_text('this is zipfile content')691
692deleted = prompt.prompt_and_delete(str(repo_file))693
694assert mock_read_user.called695assert not repo_file.exists()696assert deleted697
698
699def test_prompt_should_ask_and_keep_repo_on_no_reuse(mocker, tmp_path) -> None:700"""In `prompt_and_delete()`, if the user wants to keep their old \701cloned template repo, it should not be deleted."""
702mock_read_user = mocker.patch(703'cookiecutter.prompt.read_user_yes_no', return_value=False, autospec=True704)705repo_dir = Path(tmp_path, 'repo')706repo_dir.mkdir()707
708with pytest.raises(SystemExit):709prompt.prompt_and_delete(str(repo_dir))710
711assert mock_read_user.called712assert repo_dir.exists()713
714
715def test_prompt_should_ask_and_keep_repo_on_reuse(mocker, tmp_path) -> None:716"""In `prompt_and_delete()`, if the user wants to keep their old \717cloned template repo, it should not be deleted."""
718
719def answer(question, _default):720return 'okay to delete' not in question721
722mock_read_user = mocker.patch(723'cookiecutter.prompt.read_user_yes_no', side_effect=answer, autospec=True724)725repo_dir = Path(tmp_path, 'repo')726repo_dir.mkdir()727
728deleted = prompt.prompt_and_delete(str(repo_dir))729
730assert mock_read_user.called731assert repo_dir.exists()732assert not deleted733
734
735def test_prompt_should_not_ask_if_no_input_and_rm_repo_dir(mocker, tmp_path) -> None:736"""Prompt should not ask if no input and rm dir.737
738In `prompt_and_delete()`, if `no_input` is True, the call to
739`prompt.read_user_yes_no()` should be suppressed.
740"""
741mock_read_user = mocker.patch(742'cookiecutter.prompt.read_user_yes_no', return_value=True, autospec=True743)744repo_dir = Path(tmp_path, 'repo')745repo_dir.mkdir()746
747deleted = prompt.prompt_and_delete(str(repo_dir), no_input=True)748
749assert not mock_read_user.called750assert not repo_dir.exists()751assert deleted752
753
754def test_prompt_should_not_ask_if_no_input_and_rm_repo_file(mocker, tmp_path) -> None:755"""Prompt should not ask if no input and rm file.756
757In `prompt_and_delete()`, if `no_input` is True, the call to
758`prompt.read_user_yes_no()` should be suppressed.
759"""
760mock_read_user = mocker.patch(761'cookiecutter.prompt.read_user_yes_no', return_value=True, autospec=True762)763
764repo_file = tmp_path.joinpath('repo.zip')765repo_file.write_text('this is zipfile content')766
767deleted = prompt.prompt_and_delete(str(repo_file), no_input=True)768
769assert not mock_read_user.called770assert not repo_file.exists()771assert deleted772