diff --git a/django/core/management/commands/shell.py b/django/core/management/commands/shell.py index f55b346406aa..6a5588c9575c 100644 --- a/django/core/management/commands/shell.py +++ b/django/core/management/commands/shell.py @@ -3,6 +3,7 @@ import sys import traceback +from django.apps import apps from django.core.management import BaseCommand, CommandError from django.utils.datastructures import OrderedSet @@ -47,18 +48,21 @@ def add_arguments(self, parser): def ipython(self, options): from IPython import start_ipython - start_ipython(argv=[]) + start_ipython( + argv=[], + user_ns=self.get_and_report_namespace(options["verbosity"]), + ) def bpython(self, options): import bpython - bpython.embed() + bpython.embed(self.get_and_report_namespace(options["verbosity"])) def python(self, options): import code # Set up a dictionary to serve as the environment for the shell. - imported_objects = {} + imported_objects = self.get_and_report_namespace(options["verbosity"]) # We want to honor both $PYTHONSTARTUP and .pythonrc.py, so follow system # conventions and get $PYTHONSTARTUP first then .pythonrc.py. @@ -111,10 +115,35 @@ def python(self, options): # Start the interactive interpreter. code.interact(local=imported_objects) + def get_and_report_namespace(self, verbosity): + namespace = self.get_namespace() + + if verbosity >= 1: + self.stdout.write( + f"{len(namespace)} objects imported automatically", + self.style.SUCCESS, + ) + + return namespace + + def get_namespace(self): + apps_models = apps.get_models() + apps_models_modules = [ + (app.models_module, app.label) for app in apps.get_app_configs() + ] + namespace = {} + for model in reversed(apps_models): + if model.__module__: + namespace[model.__name__] = model + for app in apps_models_modules: + app_models_module, app_label = app + namespace[f"{app_label}_models"] = app_models_module + return namespace + def handle(self, **options): # Execute the command and exit. if options["command"]: - exec(options["command"], globals()) + exec(options["command"], {**globals(), **self.get_namespace()}) return # Execute stdin if it has anything to read and exit. @@ -124,7 +153,7 @@ def handle(self, **options): and not sys.stdin.isatty() and select.select([sys.stdin], [], [], 0)[0] ): - exec(sys.stdin.read(), globals()) + exec(sys.stdin.read(), {**globals(), **self.get_namespace()}) return available_shells = ( diff --git a/docs/howto/custom-management-commands.txt b/docs/howto/custom-management-commands.txt index d3775905d3ff..519e7ea66b88 100644 --- a/docs/howto/custom-management-commands.txt +++ b/docs/howto/custom-management-commands.txt @@ -157,6 +157,8 @@ Testing Information on how to test custom management commands can be found in the :ref:`testing docs `. +.. _overriding-commands: + Overriding commands =================== diff --git a/docs/howto/custom-shell.txt b/docs/howto/custom-shell.txt new file mode 100644 index 000000000000..15e09631d598 --- /dev/null +++ b/docs/howto/custom-shell.txt @@ -0,0 +1,53 @@ +====================================== +How to customize the ``shell`` command +====================================== + +Create a new :doc:`custom management command` +which subclasses the ``django.core.management.commands.shell.Command``, +overriding the existing ``shell`` management command. See the guide on +:ref:`overriding commands ` for more details. + +.. _customizing-shell-auto-imports: + +Customizing the auto-imports +============================ + +.. versionadded:: 5.2 + +To customize the auto-importing behavior of the :djadmin:`shell` management +command, you can override the ``get_namespace()`` method. + +For example: + +.. code-block:: python + :caption: ``polls/management/commands/shell.py`` + + from django.core.management.commands import shell + + + class Command(shell.Command): + def get_namespace(self): + from django.urls.base import resolve, reverse + + return { + **super().get_namespace(), + "resolve": resolve, + "reverse": reverse, + } + +This way, your custom shell command will import models from your apps along with +the ``resolve`` and ``reverse`` methods. You can then use them without needing +additional imports. + +If you prefer not to have any auto-imported models. You can omit calling +``**super().get_namespace()`` in the ``get_namespace()`` method: + +.. code-block:: python + :caption: ``polls/management/commands/shell.py`` + + from django.core.management.commands import shell + + + class Command(shell.Command): + def get_namespace(self): + return {} diff --git a/docs/howto/index.txt b/docs/howto/index.txt index 0034032ce25e..05128ce54f3c 100644 --- a/docs/howto/index.txt +++ b/docs/howto/index.txt @@ -18,6 +18,7 @@ you quickly accomplish common tasks. custom-template-backend custom-template-tags custom-file-storage + custom-shell deployment/index upgrade-version error-reporting diff --git a/docs/intro/tutorial02.txt b/docs/intro/tutorial02.txt index d43c82c5d2fe..9688672b58ef 100644 --- a/docs/intro/tutorial02.txt +++ b/docs/intro/tutorial02.txt @@ -352,7 +352,7 @@ Once you're in the shell, explore the :doc:`database API `: .. code-block:: pycon - >>> from polls.models import Choice, Question # Import the model classes we just wrote. + # No need to import Question as Django auto imports your models. # No questions are in the system yet. >>> Question.objects.all() @@ -386,6 +386,10 @@ Once you're in the shell, explore the :doc:`database API `: >>> Question.objects.all() ]> +.. versionchanged:: 5.2 + + Auto-importing of models in the shell was added. + Wait a minute. ```` isn't a helpful representation of this object. Let's fix that by editing the ``Question`` model (in the ``polls/models.py`` file) and adding a @@ -443,8 +447,6 @@ Save these changes and start a new Python interactive shell by running .. code-block:: pycon - >>> from polls.models import Choice, Question - # Make sure our __str__() addition worked. >>> Question.objects.all() ]> diff --git a/docs/intro/tutorial05.txt b/docs/intro/tutorial05.txt index 5f501ce92f5b..6c5b64218c3d 100644 --- a/docs/intro/tutorial05.txt +++ b/docs/intro/tutorial05.txt @@ -150,7 +150,6 @@ whose date lies in the future: >>> import datetime >>> from django.utils import timezone - >>> from polls.models import Question >>> # create a Question instance with pub_date 30 days in the future >>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30)) >>> # was it published recently? diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index 0546555a3029..941cf42d5e34 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -1049,7 +1049,23 @@ Mails the email addresses specified in :setting:`ADMINS` using .. django-admin:: shell -Starts the Python interactive interpreter. +Starts the Python interactive interpreter with all models from +:setting:`INSTALLED_APPS` auto-imported to the namespace. + +See the guide on :ref:`customizing the auto-importing behaviour +` to remove or add to the auto-imports. + +.. admonition:: Models from different apps with the same name + + Models from apps listed earlier in :setting:`INSTALLED_APPS` take precedence + over apps listed later in :setting:`INSTALLED_APPS`. In order to access + models that were overridden, you can use ``_models.``, + where ```` is the app name and ```` is the name of the + overridden model. + +.. versionchanged:: 5.2 + + Auto-importing of models was added. .. django-admin-option:: --interface {ipython,bpython,python}, -i {ipython,bpython,python} diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt index 60794ac8ede6..ff32ef97a93a 100644 --- a/docs/releases/5.2.txt +++ b/docs/releases/5.2.txt @@ -194,7 +194,10 @@ Logging Management Commands ~~~~~~~~~~~~~~~~~~~ -* ... +* The :djadmin:`shell` management command now auto-imports all models from your + :setting:`INSTALLED_APPS`. You can also :ref:`customize this behaviour + ` to prevent these auto-imports or to add + additional imports. Migrations ~~~~~~~~~~ diff --git a/tests/shell/models.py b/tests/shell/models.py new file mode 100644 index 000000000000..85b40bf2058e --- /dev/null +++ b/tests/shell/models.py @@ -0,0 +1,9 @@ +from django.db import models + + +class Marker(models.Model): + pass + + +class Phone(models.Model): + name = models.CharField(max_length=50) diff --git a/tests/shell/tests.py b/tests/shell/tests.py index ca823f6290a6..67a0f81e16b6 100644 --- a/tests/shell/tests.py +++ b/tests/shell/tests.py @@ -3,14 +3,27 @@ from unittest import mock from django import __version__ +from django.contrib.auth import models as auth_model_module +from django.contrib.auth.models import Group, Permission, User +from django.contrib.contenttypes import models as contenttypes_model_module +from django.contrib.contenttypes.models import ContentType from django.core.management import CommandError, call_command from django.core.management.commands import shell +from django.db import models from django.test import SimpleTestCase -from django.test.utils import captured_stdin, captured_stdout +from django.test.utils import ( + captured_stdin, + captured_stdout, + isolate_apps, + override_settings, +) +from django.urls.base import resolve, reverse + +from . import models as shell_models class ShellCommandTestCase(SimpleTestCase): - script_globals = 'print("__name__" in globals())' + script_globals = 'print("__name__" in globals() and "Phone" in globals())' script_with_inline_function = ( "import django\ndef f():\n print(django.__version__)\nf()" ) @@ -76,9 +89,12 @@ def test_ipython(self): mock_ipython = mock.Mock(start_ipython=mock.MagicMock()) with mock.patch.dict(sys.modules, {"IPython": mock_ipython}): - cmd.ipython({}) + cmd.ipython({"verbosity": 0}) - self.assertEqual(mock_ipython.start_ipython.mock_calls, [mock.call(argv=[])]) + self.assertEqual( + mock_ipython.start_ipython.mock_calls, + [mock.call(argv=[], user_ns=cmd.get_and_report_namespace(0))], + ) @mock.patch("django.core.management.commands.shell.select.select") # [1] @mock.patch.dict("sys.modules", {"IPython": None}) @@ -94,9 +110,11 @@ def test_bpython(self): mock_bpython = mock.Mock(embed=mock.MagicMock()) with mock.patch.dict(sys.modules, {"bpython": mock_bpython}): - cmd.bpython({}) + cmd.bpython({"verbosity": 0}) - self.assertEqual(mock_bpython.embed.mock_calls, [mock.call()]) + self.assertEqual( + mock_bpython.embed.mock_calls, [mock.call(cmd.get_and_report_namespace(0))] + ) @mock.patch("django.core.management.commands.shell.select.select") # [1] @mock.patch.dict("sys.modules", {"bpython": None}) @@ -112,9 +130,12 @@ def test_python(self): mock_code = mock.Mock(interact=mock.MagicMock()) with mock.patch.dict(sys.modules, {"code": mock_code}): - cmd.python({"no_startup": True}) + cmd.python({"verbosity": 0, "no_startup": True}) - self.assertEqual(mock_code.interact.mock_calls, [mock.call(local={})]) + self.assertEqual( + mock_code.interact.mock_calls, + [mock.call(local=cmd.get_and_report_namespace(0))], + ) # [1] Patch select to prevent tests failing when the test suite is run # in parallel mode. The tests are run in a subprocess and the subprocess's @@ -122,3 +143,99 @@ def test_python(self): # returns EOF and so select always shows that sys.stdin is ready to read. # This causes problems because of the call to select.select() toward the # end of shell's handle() method. + + @override_settings( + INSTALLED_APPS=["shell", "django.contrib.auth", "django.contrib.contenttypes"] + ) + def test_get_namespace(self): + cmd = shell.Command() + namespace = cmd.get_namespace() + + self.assertEqual( + namespace, + { + "Marker": shell_models.Marker, + "Phone": shell_models.Phone, + "ContentType": ContentType, + "Group": Group, + "Permission": Permission, + "User": User, + "auth_models": auth_model_module, + "contenttypes_models": contenttypes_model_module, + "shell_models": shell_models, + }, + ) + + @override_settings(INSTALLED_APPS=["basic", "shell"]) + @isolate_apps("basic", "shell", kwarg_name="apps") + def test_get_namespace_precedence(self, apps): + class Article(models.Model): + class Meta: + app_label = "basic" + + article_basic = Article + + class Article(models.Model): + class Meta: + app_label = "shell" + + article_shell = Article + + cmd = shell.Command() + + with mock.patch("django.apps.apps.get_models", return_value=apps.get_models()): + namespace = cmd.get_namespace() + self.assertIn(article_basic, namespace.values()) + self.assertNotIn(article_shell, namespace.values()) + + @override_settings( + INSTALLED_APPS=["shell", "django.contrib.auth", "django.contrib.contenttypes"] + ) + def test_overridden_get_namespace(self): + class Command(shell.Command): + def get_namespace(self): + from django.urls.base import resolve, reverse + + return { + **super().get_namespace(), + "resolve": resolve, + "reverse": reverse, + } + + cmd = Command() + namespace = cmd.get_namespace() + + self.assertEqual( + namespace, + { + "resolve": resolve, + "reverse": reverse, + "Marker": shell_models.Marker, + "Phone": shell_models.Phone, + "ContentType": ContentType, + "Group": Group, + "Permission": Permission, + "User": User, + "auth_models": auth_model_module, + "contenttypes_models": contenttypes_model_module, + "shell_models": shell_models, + }, + ) + + @override_settings( + INSTALLED_APPS=["shell", "django.contrib.auth", "django.contrib.contenttypes"] + ) + def test_message_with_stdout(self): + with captured_stdout() as stdout: + cmd = shell.Command() + cmd.get_and_report_namespace(verbosity=1) + self.assertEqual(stdout.getvalue().strip(), "9 objects imported automatically") + + @override_settings( + INSTALLED_APPS=["shell", "django.contrib.auth", "django.contrib.contenttypes"] + ) + def test_message_with_no_stdout(self): + with captured_stdout() as stdout: + cmd = shell.Command() + cmd.get_and_report_namespace(verbosity=0) + self.assertEqual(stdout.getvalue().strip(), "")