Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed #35515 -- Added auto-importing to shell command. #18158

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions django/core/management/commands/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -111,10 +115,35 @@ def python(self, options):
# Start the interactive interpreter.
code.interact(local=imported_objects)

def get_and_report_namespace(self, verbosity):
salvo-polizzi marked this conversation as resolved.
Show resolved Hide resolved
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):
salvo-polizzi marked this conversation as resolved.
Show resolved Hide resolved
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"], {**self.get_namespace()})
return

# Execute stdin if it has anything to read and exit.
Expand All @@ -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(), {**self.get_namespace()})
return

available_shells = (
Expand Down
73 changes: 73 additions & 0 deletions docs/howto/custom-shell.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
=============================================
How to customize the ``shell`` command
=============================================

Django provides functionality to auto-import project models in the shell. In
your project, you might want to either avoid importing models or include
additional imports in the shell. You can do this by subclassing the
:djadmin:`shell` command and overriding a method. To set up the directory of
your project you can follow this :doc:`guide
</howto/custom-management-commands>` on how to override an existing command.

Customizing the shell
=====================

To override the current shell's functionality, you have to override the
``shell.Command.get_namespace`` in order to return a namespace that maps every
name to the object imported. For example, if you want to import ``resolve`` and
``reverse`` methods in you shell, you can edit
``polls/management/commands/shell.py`` to look like this::

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 customized shell will import models from your apps along with the
``resolve`` and ``reverse`` methods, allowing you to use them without needing
additional imports.

.. note::

If you prefer your shell without any auto-imported model, you can omit
calling the ``**super().get_namespace()`` method when returning the
namespace, as it can be empty.


Implement your own python shell runner
======================================

By default, Django will use ``IPython`` or ``bpython`` as the shell runner if
they are installed. To implement your own shell runner, you need to subclass the
``shell.Command`` class and add a new method where you define your shell runner.
Then, you must include the name of this new method in the ``shells`` attribute,
like this::


from django.core.management.commands import shell


class Command(shell.Command):
...

shell.Command.shells.append("ptpython")

...

def ptpython(self, options):
from ptpython.repl import embed

imported_objects = self.get_and_report_namespace(options["verbosity"])
...

embed(globals=imported_objects)


1 change: 1 addition & 0 deletions docs/howto/index.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion docs/ref/django-admin.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1049,7 +1049,14 @@ Mails the email addresses specified in :setting:`ADMINS` using

.. django-admin:: shell

Starts the Python interactive interpreter.
Starts the Python interactive interpreter.

.. versionchanged:: 5.1

Auto-importing models feature was added.

You can customize which imports you want or add a new shell runner by following
the :doc:`custom-shell</howto/custom-shell>` guide.

salvo-polizzi marked this conversation as resolved.
Show resolved Hide resolved
.. django-admin-option:: --interface {ipython,bpython,python}, -i {ipython,bpython,python}

Expand Down
9 changes: 9 additions & 0 deletions tests/shell/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.db import models


class Marker(models.Model):
pass


class Phone(models.Model):
name = models.CharField(max_length=50)
153 changes: 137 additions & 16 deletions tests/shell/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_import = 'print("Phone" in globals())'
script_with_inline_function = (
"import django\ndef f():\n print(django.__version__)\nf()"
)
Expand All @@ -26,16 +39,16 @@ def test_command_option(self):
)
self.assertEqual(cm.records[0].getMessage(), __version__)

def test_command_option_globals(self):
with captured_stdout() as stdout:
call_command("shell", command=self.script_globals)
self.assertEqual(stdout.getvalue().strip(), "True")

def test_command_option_inline_function_call(self):
with captured_stdout() as stdout:
call_command("shell", command=self.script_with_inline_function)
self.assertEqual(stdout.getvalue().strip(), __version__)

def test_command_option_with_imports(self):
with captured_stdout() as stdout:
call_command("shell", command=self.script_globals_import)
self.assertEqual(stdout.getvalue().strip(), "True")

@unittest.skipIf(
sys.platform == "win32", "Windows select() doesn't support file descriptors."
)
Expand All @@ -52,9 +65,9 @@ def test_stdin_read(self, select):
"Windows select() doesn't support file descriptors.",
)
@mock.patch("django.core.management.commands.shell.select") # [1]
def test_stdin_read_globals(self, select):
def test_stdin_read_globals_import(self, select):
with captured_stdin() as stdin, captured_stdout() as stdout:
stdin.write(self.script_globals)
stdin.write(self.script_globals_import)
salvo-polizzi marked this conversation as resolved.
Show resolved Hide resolved
stdin.seek(0)
call_command("shell")
self.assertEqual(stdout.getvalue().strip(), "True")
Expand All @@ -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})
Expand All @@ -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})
Expand All @@ -112,13 +130,116 @@ 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
# [1] Patch select to prevent tests failing when when the test suite is run
# in parallel mode. The tests are run in a subprocess and the subprocess's
# stdin is closed and replaced by /dev/null. Reading from /dev/null always
# 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", "basic"])
@isolate_apps("shell", "basic", kwarg_name="apps")
def test_get_namespace_precedence_1(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_shell, namespace.values())
self.assertNotIn(article_basic, 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,
},
)