Refs #29026 -- Allowed customizing InteractiveMigrationQuestioner's prompt destination.

Previously, the questioner did not obey the value of stdout provided
to the command.
This commit is contained in:
Jacob Walls 2021-12-31 10:30:48 -05:00 committed by Mariusz Felisiak
parent 03a6488116
commit 0ab58c1209
4 changed files with 74 additions and 57 deletions

View File

@ -144,7 +144,11 @@ class Command(BaseCommand):
return self.handle_merge(loader, conflicts)
if self.interactive:
questioner = InteractiveMigrationQuestioner(specified_apps=app_labels, dry_run=self.dry_run)
questioner = InteractiveMigrationQuestioner(
specified_apps=app_labels,
dry_run=self.dry_run,
prompt_output=self.stdout,
)
else:
questioner = NonInteractiveMigrationQuestioner(
specified_apps=app_labels,
@ -250,7 +254,7 @@ class Command(BaseCommand):
if it's safe; otherwise, advises on how to fix it.
"""
if self.interactive:
questioner = InteractiveMigrationQuestioner()
questioner = InteractiveMigrationQuestioner(prompt_output=self.stdout)
else:
questioner = MigrationQuestioner(defaults={'ask_merge': True})

View File

@ -4,6 +4,7 @@ import os
import sys
from django.apps import apps
from django.core.management.base import OutputWrapper
from django.db.models import NOT_PROVIDED
from django.utils import timezone
from django.utils.version import get_docs_version
@ -87,20 +88,26 @@ class MigrationQuestioner:
class InteractiveMigrationQuestioner(MigrationQuestioner):
def __init__(self, defaults=None, specified_apps=None, dry_run=None, prompt_output=None):
super().__init__(defaults=defaults, specified_apps=specified_apps, dry_run=dry_run)
self.prompt_output = prompt_output or OutputWrapper(sys.stdout)
def _boolean_input(self, question, default=None):
result = input("%s " % question)
self.prompt_output.write(f'{question} ', ending='')
result = input()
if not result and default is not None:
return default
while not result or result[0].lower() not in "yn":
result = input("Please answer yes or no: ")
self.prompt_output.write('Please answer yes or no: ', ending='')
result = input()
return result[0].lower() == "y"
def _choice_input(self, question, choices):
print(question)
self.prompt_output.write(f'{question}')
for i, choice in enumerate(choices):
print(" %s) %s" % (i + 1, choice))
result = input("Select an option: ")
self.prompt_output.write(' %s) %s' % (i + 1, choice))
self.prompt_output.write('Select an option: ', ending='')
result = input()
while True:
try:
value = int(result)
@ -109,7 +116,8 @@ class InteractiveMigrationQuestioner(MigrationQuestioner):
else:
if 0 < value <= len(choices):
return value
result = input("Please select a valid option: ")
self.prompt_output.write('Please select a valid option: ', ending='')
result = input()
def _ask_default(self, default=''):
"""
@ -119,34 +127,35 @@ class InteractiveMigrationQuestioner(MigrationQuestioner):
string) which will be shown to the user and used as the return value
if the user doesn't provide any other input.
"""
print('Please enter the default value as valid Python.')
self.prompt_output.write('Please enter the default value as valid Python.')
if default:
print(
self.prompt_output.write(
f"Accept the default '{default}' by pressing 'Enter' or "
f"provide another value."
)
print(
self.prompt_output.write(
'The datetime and django.utils.timezone modules are available, so '
'it is possible to provide e.g. timezone.now as a value.'
)
print("Type 'exit' to exit this prompt")
self.prompt_output.write("Type 'exit' to exit this prompt")
while True:
if default:
prompt = "[default: {}] >>> ".format(default)
else:
prompt = ">>> "
code = input(prompt)
self.prompt_output.write(prompt, ending='')
code = input()
if not code and default:
code = default
if not code:
print("Please enter some code, or 'exit' (without quotes) to exit.")
self.prompt_output.write("Please enter some code, or 'exit' (without quotes) to exit.")
elif code == "exit":
sys.exit(1)
else:
try:
return eval(code, {}, {'datetime': datetime, 'timezone': timezone})
except (SyntaxError, NameError) as e:
print("Invalid input: %s" % e)
self.prompt_output.write('Invalid input: %s' % e)
def ask_not_null_addition(self, field_name, model_name):
"""Adding a NOT NULL field to a model."""

View File

@ -1304,7 +1304,15 @@ class MakeMigrationsTests(MigrationTestBase):
# Monkeypatch interactive questioner to auto reject
with mock.patch('builtins.input', mock.Mock(return_value='N')):
with self.temporary_migration_module(module="migrations.test_migrations_conflict") as migration_dir:
call_command("makemigrations", "migrations", name="merge", merge=True, interactive=True, verbosity=0)
with captured_stdout():
call_command(
'makemigrations',
'migrations',
name='merge',
merge=True,
interactive=True,
verbosity=0,
)
merge_file = os.path.join(migration_dir, '0003_merge.py')
self.assertFalse(os.path.exists(merge_file))
@ -1766,6 +1774,10 @@ class MakeMigrationsTests(MigrationTestBase):
' - remove field silly_field from author\n'
' - add field rating to author\n'
' - create model book\n'
'\n'
'merging will only work if the operations printed above do not conflict\n'
'with each other (working on different fields or models)\n'
'should these migration branches be merged? [y/n] '
)
def test_makemigrations_with_custom_name(self):
@ -1886,30 +1898,25 @@ class MakeMigrationsTests(MigrationTestBase):
"It is impossible to add the field 'creation_date' with "
"'auto_now_add=True' to entry without providing a default. This "
"is because the database needs something to populate existing "
"rows.\n\n"
"rows.\n"
" 1) Provide a one-off default now which will be set on all "
"existing rows\n"
" 2) Quit and manually define a default value in models.py."
)
# Monkeypatch interactive questioner to auto accept
with mock.patch('django.db.migrations.questioner.sys.stdout', new_callable=io.StringIO) as prompt_stdout:
out = io.StringIO()
with self.temporary_migration_module(module='migrations.test_auto_now_add'):
call_command('makemigrations', 'migrations', interactive=True, stdout=out)
output = out.getvalue()
prompt_output = prompt_stdout.getvalue()
self.assertIn(input_msg, prompt_output)
self.assertIn(
'Please enter the default value as valid Python.',
prompt_output,
)
self.assertIn(
"Accept the default 'timezone.now' by pressing 'Enter' or "
"provide another value.",
prompt_output,
)
self.assertIn("Type 'exit' to exit this prompt", prompt_output)
self.assertIn("Add field creation_date to entry", output)
prompt_stdout = io.StringIO()
with self.temporary_migration_module(module='migrations.test_auto_now_add'):
call_command('makemigrations', 'migrations', interactive=True, stdout=prompt_stdout)
prompt_output = prompt_stdout.getvalue()
self.assertIn(input_msg, prompt_output)
self.assertIn('Please enter the default value as valid Python.', prompt_output)
self.assertIn(
"Accept the default 'timezone.now' by pressing 'Enter' or provide "
"another value.",
prompt_output,
)
self.assertIn("Type 'exit' to exit this prompt", prompt_output)
self.assertIn("Add field creation_date to entry", prompt_output)
@mock.patch('builtins.input', return_value='2')
def test_makemigrations_auto_now_add_interactive_quit(self, mock_input):
@ -1960,7 +1967,7 @@ class MakeMigrationsTests(MigrationTestBase):
input_msg = (
f'Callable default on unique field book.created will not generate '
f'unique values upon migrating.\n'
f'Please choose how to proceed:\n\n'
f'Please choose how to proceed:\n'
f' 1) Continue making this migration as the first step in writing '
f'a manual migration to generate unique values described here: '
f'https://docs.djangoproject.com/en/{version}/howto/'

View File

@ -1,12 +1,14 @@
import datetime
from io import StringIO
from unittest import mock
from django.core.management.base import OutputWrapper
from django.db.migrations.questioner import (
InteractiveMigrationQuestioner, MigrationQuestioner,
)
from django.db.models import NOT_PROVIDED
from django.test import SimpleTestCase
from django.test.utils import captured_stdout, override_settings
from django.test.utils import override_settings
class QuestionerTests(SimpleTestCase):
@ -24,65 +26,60 @@ class QuestionerTests(SimpleTestCase):
@mock.patch('builtins.input', return_value='2')
def test_ask_not_null_alteration_not_provided(self, mock):
questioner = InteractiveMigrationQuestioner()
with captured_stdout():
question = questioner.ask_not_null_alteration('field_name', 'model_name')
questioner = InteractiveMigrationQuestioner(prompt_output=OutputWrapper(StringIO()))
question = questioner.ask_not_null_alteration('field_name', 'model_name')
self.assertEqual(question, NOT_PROVIDED)
class QuestionerHelperMethodsTests(SimpleTestCase):
questioner = InteractiveMigrationQuestioner()
def setUp(self):
self.prompt = OutputWrapper(StringIO())
self.questioner = InteractiveMigrationQuestioner(prompt_output=self.prompt)
@mock.patch('builtins.input', return_value='datetime.timedelta(days=1)')
def test_questioner_default_timedelta(self, mock_input):
questioner = InteractiveMigrationQuestioner()
with captured_stdout():
value = questioner._ask_default()
value = self.questioner._ask_default()
self.assertEqual(value, datetime.timedelta(days=1))
@mock.patch('builtins.input', return_value='')
def test_questioner_default_no_user_entry(self, mock_input):
with captured_stdout():
value = self.questioner._ask_default(default='datetime.timedelta(days=1)')
value = self.questioner._ask_default(default='datetime.timedelta(days=1)')
self.assertEqual(value, datetime.timedelta(days=1))
@mock.patch('builtins.input', side_effect=['', 'exit'])
def test_questioner_no_default_no_user_entry(self, mock_input):
with captured_stdout() as stdout, self.assertRaises(SystemExit):
with self.assertRaises(SystemExit):
self.questioner._ask_default()
self.assertIn(
"Please enter some code, or 'exit' (without quotes) to exit.",
stdout.getvalue(),
self.prompt.getvalue(),
)
@mock.patch('builtins.input', side_effect=['bad code', 'exit'])
def test_questioner_no_default_bad_user_entry_code(self, mock_input):
with captured_stdout() as stdout, self.assertRaises(SystemExit):
with self.assertRaises(SystemExit):
self.questioner._ask_default()
self.assertIn('Invalid input: ', stdout.getvalue())
self.assertIn('Invalid input: ', self.prompt.getvalue())
@mock.patch('builtins.input', side_effect=['', 'n'])
def test_questioner_no_default_no_user_entry_boolean(self, mock_input):
with captured_stdout():
value = self.questioner._boolean_input('Proceed?')
value = self.questioner._boolean_input('Proceed?')
self.assertIs(value, False)
@mock.patch('builtins.input', return_value='')
def test_questioner_default_no_user_entry_boolean(self, mock_input):
with captured_stdout():
value = self.questioner._boolean_input('Proceed?', default=True)
value = self.questioner._boolean_input('Proceed?', default=True)
self.assertIs(value, True)
@mock.patch('builtins.input', side_effect=[10, 'garbage', 1])
def test_questioner_bad_user_choice(self, mock_input):
question = 'Make a choice:'
with captured_stdout() as stdout:
value = self.questioner._choice_input(question, choices='abc')
value = self.questioner._choice_input(question, choices='abc')
expected_msg = (
f'{question}\n'
f' 1) a\n'
f' 2) b\n'
f' 3) c\n'
)
self.assertIn(expected_msg, stdout.getvalue())
self.assertIn(expected_msg, self.prompt.getvalue())
self.assertEqual(value, 1)