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:
parent
03a6488116
commit
0ab58c1209
|
@ -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})
|
||||
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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/'
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue