From 302eeaf1904a2f3852b223a563617d4999b4e9ae Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 21 Sep 2007 16:19:20 +0000 Subject: [PATCH] Fixed #5516 -- Added the ability for applications to define their own management commands. Pieces of this patch taken from a contribution by Todd O'Bryan. Thanks Todd. git-svn-id: http://code.djangoproject.com/svn/django/trunk@6400 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/core/management/__init__.py | 159 ++++++++++++++---- django/core/management/base.py | 16 +- docs/django-admin.txt | 29 ++++ tests/modeltests/user_commands/__init__.py | 0 .../user_commands/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/dance.py | 9 + tests/modeltests/user_commands/models.py | 30 ++++ 8 files changed, 203 insertions(+), 40 deletions(-) create mode 100644 tests/modeltests/user_commands/__init__.py create mode 100644 tests/modeltests/user_commands/management/__init__.py create mode 100644 tests/modeltests/user_commands/management/commands/__init__.py create mode 100644 tests/modeltests/user_commands/management/commands/dance.py create mode 100644 tests/modeltests/user_commands/models.py diff --git a/django/core/management/__init__.py b/django/core/management/__init__.py index a4731652f5..1cf483eab5 100644 --- a/django/core/management/__init__.py +++ b/django/core/management/__init__.py @@ -1,18 +1,100 @@ import django +from django.core.management.base import BaseCommand, CommandError, handle_default_options from optparse import OptionParser import os import sys +from imp import find_module # For backwards compatibility: get_version() used to be in this module. get_version = django.get_version -def load_command_class(name): +# A cache of loaded commands, so that call_command +# doesn't have to reload every time it is called +_commands = None + +def find_commands(management_dir): """ - Given a command name, returns the Command class instance. Raises - ImportError if it doesn't exist. + Given a path to a management directory, return a list of all the command names + that are available. Returns an empty list if no commands are defined. """ - # Let the ImportError propogate. - return getattr(__import__('django.core.management.commands.%s' % name, {}, {}, ['Command']), 'Command')() + command_dir = os.path.join(management_dir,'commands') + try: + return [f[:-3] for f in os.listdir(command_dir) if not f.startswith('_') and f.endswith('.py')] + except OSError: + return [] + +def find_management_module(app_name): + """ + Determine the path to the management module for the application named, + without acutally importing the application or the management module. + + Raises ImportError if the management module cannot be found for any reason. + """ + parts = app_name.split('.') + parts.append('management') + parts.reverse() + path = None + while parts: + part = parts.pop() + f,path,descr = find_module(part, path and [path] or None) + return path + +def load_command_class(app_name, name): + """ + Given a command name and an application name, returns the Command + class instance. All errors raised by the importation process + (ImportError, AttributeError) are allowed to propagate. + """ + return getattr(__import__('%s.management.commands.%s' % (app_name, name), + {}, {}, ['Command']), 'Command')() + +def get_commands(load_user_commands=True, project_directory=None): + """ + Returns a dictionary of commands against the application in which + those commands can be found. This works by looking for a + management.commands package in django.core, and in each installed + application -- if a commands package exists, all commands in that + package are registered. + + Core commands are always included; user-defined commands will also + be included if ``load_user_commands`` is True. If a project directory + is provided, the startproject command will be disabled, and the + startapp command will be modified to use that directory. + + The dictionary is in the format {command_name: app_name}. Key-value + pairs from this dictionary can then be used in calls to + load_command_class(app_name, command_name) + + The dictionary is cached on the first call, and reused on subsequent + calls. + """ + global _commands + if _commands is None: + _commands = dict([(name, 'django.core') + for name in find_commands(__path__[0])]) + if load_user_commands: + # Get commands from all installed apps + from django.conf import settings + for app_name in settings.INSTALLED_APPS: + try: + path = find_management_module(app_name) + _commands.update(dict([(name, app_name) + for name in find_commands(path)])) + except ImportError: + pass # No management module - ignore this app + + if project_directory: + # Remove the "startproject" command from self.commands, because + # that's a django-admin.py command, not a manage.py command. + del _commands['startproject'] + + # Override the startapp command so that it always uses the + # project_directory, not the current working directory + # (which is default). + from django.core.management.commands.startapp import ProjectCommand + _commands['startapp'] = ProjectCommand(project_directory) + + return _commands def call_command(name, *args, **options): """ @@ -25,8 +107,22 @@ def call_command(name, *args, **options): call_command('shell', plain=True) call_command('sqlall', 'myapp') """ - klass = load_command_class(name) + try: + app_name = get_commands()[name] + klass = load_command_class(app_name, name) + except KeyError: + raise CommandError, "Unknown command: %r" % name return klass.execute(*args, **options) + +class LaxOptionParser(OptionParser): + """ + An option parser that doesn't raise any errors on unknown options. + + This is needed because the --settings and --pythonpath options affect + the commands (and thus the options) that are available to the user. + """ + def error(self, msg): + pass class ManagementUtility(object): """ @@ -38,21 +134,9 @@ class ManagementUtility(object): def __init__(self, argv=None): self.argv = argv or sys.argv[:] self.prog_name = os.path.basename(self.argv[0]) - self.commands = self.default_commands() - - def default_commands(self): - """ - Returns a dictionary of instances of all available Command classes. - - This works by looking for and loading all Python modules in the - django.core.management.commands package. - - The dictionary is in the format {name: command_instance}. - """ - command_dir = os.path.join(__path__[0], 'commands') - names = [f[:-3] for f in os.listdir(command_dir) if not f.startswith('_') and f.endswith('.py')] - return dict([(name, load_command_class(name)) for name in names]) - + self.project_directory = None + self.user_commands = False + def main_help_text(self): """ Returns the script's main help text, as a string. @@ -61,7 +145,7 @@ class ManagementUtility(object): usage.append('Django command line tool, version %s' % django.get_version()) usage.append("Type '%s help ' for help on a specific subcommand." % self.prog_name) usage.append('Available subcommands:') - commands = self.commands.keys() + commands = get_commands(self.user_commands, self.project_directory).keys() commands.sort() for cmd in commands: usage.append(' %s' % cmd) @@ -74,16 +158,26 @@ class ManagementUtility(object): django-admin.py or manage.py) if it can't be found. """ try: - return self.commands[subcommand] + app_name = get_commands(self.user_commands, self.project_directory)[subcommand] + klass = load_command_class(app_name, subcommand) except KeyError: sys.stderr.write("Unknown command: %r\nType '%s help' for usage.\n" % (subcommand, self.prog_name)) sys.exit(1) - + return klass + def execute(self): """ Given the command-line arguments, this figures out which subcommand is being run, creates a parser appropriate to that command, and runs it. """ + # Preprocess options to extract --settings and --pythonpath. These options + # could affect the commands that are available, so they must be processed + # early + parser = LaxOptionParser(version=get_version(), + option_list=BaseCommand.option_list) + options, args = parser.parse_args(self.argv) + handle_default_options(options) + try: subcommand = self.argv[1] except IndexError: @@ -91,8 +185,8 @@ class ManagementUtility(object): sys.exit(1) if subcommand == 'help': - if len(self.argv) > 2: - self.fetch_command(self.argv[2]).print_help(self.prog_name, self.argv[2]) + if len(args) > 2: + self.fetch_command(args[2]).print_help(self.prog_name, args[2]) else: sys.stderr.write(self.main_help_text() + '\n') sys.exit(1) @@ -116,16 +210,9 @@ class ProjectManagementUtility(ManagementUtility): """ def __init__(self, argv, project_directory): super(ProjectManagementUtility, self).__init__(argv) - - # Remove the "startproject" command from self.commands, because - # that's a django-admin.py command, not a manage.py command. - del self.commands['startproject'] - - # Override the startapp command so that it always uses the - # project_directory, not the current working directory (which is default). - from django.core.management.commands.startapp import ProjectCommand - self.commands['startapp'] = ProjectCommand(project_directory) - + self.project_directory = project_directory + self.user_commands = True + def setup_environ(settings_mod): """ Configure the runtime environment. This can also be used by external diff --git a/django/core/management/base.py b/django/core/management/base.py index d883fe23dc..26ecef4ff5 100644 --- a/django/core/management/base.py +++ b/django/core/management/base.py @@ -9,6 +9,17 @@ import os class CommandError(Exception): pass +def handle_default_options(options): + """ + Include any default options that all commands should accept + here so that ManagementUtility can handle them before searching + for user commands. + """ + if options.settings: + os.environ['DJANGO_SETTINGS_MODULE'] = options.settings + if options.pythonpath: + sys.path.insert(0, options.pythonpath) + class BaseCommand(object): # Metadata about this command. option_list = ( @@ -55,10 +66,7 @@ class BaseCommand(object): def run_from_argv(self, argv): parser = self.create_parser(argv[0], argv[1]) options, args = parser.parse_args(argv[2:]) - if options.settings: - os.environ['DJANGO_SETTINGS_MODULE'] = options.settings - if options.pythonpath: - sys.path.insert(0, options.pythonpath) + handle_default_options(options) self.execute(*args, **options.__dict__) def execute(self, *args, **options): diff --git a/docs/django-admin.txt b/docs/django-admin.txt index 0f99987bad..f098dfa988 100644 --- a/docs/django-admin.txt +++ b/docs/django-admin.txt @@ -735,3 +735,32 @@ distribution. It enables tab-completion of ``django-admin.py`` and * Press [TAB] to see all available options. * Type ``sql``, then [TAB], to see all available options whose names start with ``sql``. + +Customized actions +================== + +**New in Django development version** + +If you want to add an action of your own to ``manage.py``, you can. +Simply add a ``management/commands`` directory to your application. +Each python module in that directory will be discovered and registered as +a command that can be executed as an action when you run ``manage.py``:: + + /fancy_blog + __init__.py + models.py + /management + __init__.py + /commands + __init__.py + explode.py + views.py + +In this example, ``explode`` command will be made available to any project +that includes the ``fancy_blog`` application in ``settings.INSTALLED_APPS``. + +The ``explode.py`` module has only one requirement -- it must define a class +called ``Command`` that extends ``django.core.management.base.BaseCommand``. + +For more details on how to define your own commands, look at the code for the +existing ``django-admin.py`` commands, in ``/django/core/management/commands``. diff --git a/tests/modeltests/user_commands/__init__.py b/tests/modeltests/user_commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/modeltests/user_commands/management/__init__.py b/tests/modeltests/user_commands/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/modeltests/user_commands/management/commands/__init__.py b/tests/modeltests/user_commands/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/modeltests/user_commands/management/commands/dance.py b/tests/modeltests/user_commands/management/commands/dance.py new file mode 100644 index 0000000000..5886cd1d8f --- /dev/null +++ b/tests/modeltests/user_commands/management/commands/dance.py @@ -0,0 +1,9 @@ +from django.core.management.base import BaseCommand + +class Command(BaseCommand): + help = "Dance around like a madman." + args = '' + requires_model_validation = True + + def handle(self, *args, **options): + print "I don't feel like dancing." \ No newline at end of file diff --git a/tests/modeltests/user_commands/models.py b/tests/modeltests/user_commands/models.py new file mode 100644 index 0000000000..5f96806dac --- /dev/null +++ b/tests/modeltests/user_commands/models.py @@ -0,0 +1,30 @@ +""" +37. User-registered management commands + +The manage.py utility provides a number of useful commands for managing a +Django project. If you want to add a utility command of your own, you can. + +The user-defined command 'dance' is defined in the management/commands +subdirectory of this test application. It is a simple command that responds +with a printed message when invoked. + +For more details on how to define your own manage.py commands, look at the +django.core.management.commands directory. This directory contains the +definitions for the base Django manage.py commands. +""" + +__test__ = {'API_TESTS': """ +>>> from django.core import management + +# Invoke a simple user-defined command +>>> management.call_command('dance') +I don't feel like dancing. + +# Invoke a command that doesn't exist +>>> management.call_command('explode') +Traceback (most recent call last): +... +CommandError: Unknown command: 'explode' + + +"""} \ No newline at end of file