diff --git a/django/core/management/commands/makemigrations.py b/django/core/management/commands/makemigrations.py index 9077660467..4d26bfe278 100644 --- a/django/core/management/commands/makemigrations.py +++ b/django/core/management/commands/makemigrations.py @@ -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}) diff --git a/django/db/migrations/questioner.py b/django/db/migrations/questioner.py index 17883ca76c..3460e2b3ab 100644 --- a/django/db/migrations/questioner.py +++ b/django/db/migrations/questioner.py @@ -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.""" diff --git a/tests/migrations/test_commands.py b/tests/migrations/test_commands.py index bf78b60a85..ef5db241d6 100644 --- a/tests/migrations/test_commands.py +++ b/tests/migrations/test_commands.py @@ -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/' diff --git a/tests/migrations/test_questioner.py b/tests/migrations/test_questioner.py index c9690d0b99..472a3f248f 100644 --- a/tests/migrations/test_questioner.py +++ b/tests/migrations/test_questioner.py @@ -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)