Skip to content

Commit

Permalink
Refactored command line argument parsing (fixes saippuakauppias#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
generalov committed Dec 12, 2015
1 parent 423f0c9 commit bdf8b37
Show file tree
Hide file tree
Showing 21 changed files with 345 additions and 69 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ And command with `django-maven`:

If `rebuild_index` command raising exception (server die or error creating index) you see their in your Sentry.

Tests
-----

Tests should run with tox >=1.8. Running `tox` will run all tests for all environments.
Use tox -e <env> to run a certain environment, a list of all environments can be found with tox -l.

Hint: it is possible to run all environments in parallel with `detox`.

The name
--------

Expand Down
10 changes: 9 additions & 1 deletion django_maven/compat.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from django import VERSION
from django.core.management.base import BaseCommand

__all__ = ['OutputWrapper', 'MavenBaseCommand']

if VERSION > (1, 5,):
from django.core.management.base import OutputWrapper
Expand All @@ -10,6 +12,7 @@ class OutputWrapper(object):
"""
Wrapper around stdout/stderr from django 1.5
"""

def __init__(self, out, style_func=None, ending='\n'):
self._out = out
self.style_func = None
Expand All @@ -24,6 +27,11 @@ def write(self, msg, style_func=None, ending=None):
ending = ending is None and self.ending or ending
if ending and not msg.endswith(ending):
msg += ending
style_func = [f for f in (style_func, self.style_func, lambda x:x)
style_func = [f for f in (style_func, self.style_func, lambda x: x)
if f is not None][0]
self._out.write(force_unicode(style_func(msg)))

if hasattr(BaseCommand, 'use_argparse'):
from django_maven.management.argparse_command import MavenBaseCommand
else:
from django_maven.management.optparse_command import MavenBaseCommand
13 changes: 13 additions & 0 deletions django_maven/management/argparse_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import argparse

from django.core.management.base import BaseCommand


class MavenBaseCommand(BaseCommand):
"""The *argparse* compatible command"""

def add_arguments(self, parser):
"""
:type parser: argparse.ArgumentParser
"""
parser.add_argument('args', nargs=argparse.REMAINDER)
121 changes: 64 additions & 57 deletions django_maven/management/commands/maven.py
Original file line number Diff line number Diff line change
@@ -1,77 +1,84 @@
import sys
from optparse import OptionParser

from django.conf import settings
from django.core.management import get_commands, load_command_class
from django.core.management.base import (BaseCommand, handle_default_options,
CommandError)

from django.core.management.base import CommandError, handle_default_options
from django_maven.compat import MavenBaseCommand, OutputWrapper
from raven import Client

from django_maven.compat import OutputWrapper

VERBOSE_OUTPUT = 2

class Command(BaseCommand):

class Command(MavenBaseCommand):
help = 'Capture exceptions and send in Sentry'
args = '<command>'

def _get_subcommand_class(self, command):
commands = get_commands()
app_name = commands[command]
return load_command_class(app_name, command)
def __init__(self, *args, **kw):
super(MavenBaseCommand, self).__init__(*args, **kw)
self._argv = None

def _write_error_in_stderr(self, exc):
stderr = getattr(self, 'stderr', OutputWrapper(sys.stderr,
self.style.ERROR))
stderr.write('%s: %s' % (exc.__class__.__name__, exc))
sys.exit(1)
def run_from_argv(self, argv):
self._argv = argv
return super(Command, self).run_from_argv(argv)

def usage(self, subcommand):
usage = 'Usage: %s %s [command options]' % (subcommand, self.args)
if self.help:
return '%s\n\n%s' % (usage, self.help)
else:
return usage
def execute(self, *args, **options):
if not args:
self.print_help(self._argv[0], self._argv[1])
return

subcommand = self._get_subcommand_class(args[0])
subcommand_argv = [self._argv[0]] + list(args)

# this is a lightweight version of the BaseCommand.run_from_argv
# it should be compatible with Django-1.5..1.9
subcommand_args, subcommand_options = self._handle_argv(
subcommand, subcommand_argv)
try:
subcommand.execute(*subcommand_args, **subcommand_options)
except Exception as e:
if not isinstance(e, CommandError):
if int(options['verbosity']) >= VERBOSE_OUTPUT:
# self.stderr is not guaranteed to be set here
stderr = getattr(self, 'stderr', OutputWrapper(
sys.stderr, self.style.ERROR))
stderr.write('Beautiful is better than %s. '
'Errors should never pass silently.' %
(e.__class__.__name__))

def create_parser(self, prog_name, subcommand, subcommand_class):
if hasattr(self, 'use_argparse') and self.use_argparse:
return super(Command, self).create_parser(prog_name, subcommand)
sentry = self._get_sentry()
if sentry:
sentry.captureException()
# use default 'maven' options to deal with traceback
raise

def _get_sentry(self):
if hasattr(settings, 'SENTRY_DSN'):
dsn = settings.SENTRY_DSN
elif hasattr(settings, 'RAVEN_CONFIG'):
dsn = settings.RAVEN_CONFIG.get('dsn')
else:
return OptionParser(prog=prog_name,
usage=subcommand_class.usage(subcommand),
version=subcommand_class.get_version(),
option_list=subcommand_class.option_list)
return None

def run_from_argv(self, argv):
if len(argv) <= 2 or argv[2] in ['-h', '--help']:
stdout = OutputWrapper(sys.stdout)
stdout.write(self.usage(argv[1]))
sys.exit(1)
sentry = Client(dsn)
if not sentry.is_enabled():
return None
return sentry

def _get_subcommand_class(self, command):
commands = get_commands()
app_name = commands[command]
return load_command_class(app_name, command)

subcommand_class = self._get_subcommand_class(argv[2])
parser = self.create_parser(argv[0], argv[2], subcommand_class)
if hasattr(self, 'use_argparse') and self.use_argparse:
subcommand_class.add_arguments(parser)
options = parser.parse_args(argv[3:])
def _handle_argv(self, subcommand, argv):
"""The universal Django command arguments parser."""
parser = subcommand.create_parser(argv[0], argv[1])

if hasattr(subcommand, 'use_argparse') and subcommand.use_argparse:
options = parser.parse_args(argv[2:])
cmd_options = vars(options)
# Move positional args out of options to mimic legacy optparse
args = cmd_options.pop('args', ())
else:
options, args = parser.parse_args(argv[3:])
options, args = parser.parse_args(argv[2:])
cmd_options = vars(options)
handle_default_options(options)
try:
subcommand_class.execute(*args, **options.__dict__)
except Exception as e:
if not isinstance(e, CommandError):
if hasattr(settings, 'SENTRY_DSN'):
dsn = settings.SENTRY_DSN
elif hasattr(settings, 'RAVEN_CONFIG'):
dsn = settings.RAVEN_CONFIG.get('dsn')
else:
raise
sentry = Client(dsn)
if not sentry.is_enabled():
raise
sentry.get_ident(sentry.captureException())

self._write_error_in_stderr(e)
return args, cmd_options
54 changes: 54 additions & 0 deletions django_maven/management/optparse_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import types
from optparse import AmbiguousOptionError, BadOptionError

from django.core.management.base import BaseCommand


class MavenBaseCommand(BaseCommand):
"""The *optparse* compatible command
manage.py maven [maven options] subcommand [subcommand options]
"""

@property
def use_argparse(self):
return False

def create_parser(self, prog_name, subcommand):
parser = super(MavenBaseCommand, self).create_parser(
prog_name, subcommand)
parser._process_args = types.MethodType(_process_args, parser)
return parser


def _process_args(self, largs, rargs, values):
max_own_positional_args = 1

while rargs:
try:
arg = rargs[0]
# XXX: e.generalov@ added condition to skip processing
# arguments after subcommand name
if len(largs) >= max_own_positional_args:
return # stop now, leave this arg in rargs
# We handle bare "--" explicitly, and bare "-" is handled by the
# standard arg handler since the short arg case ensures that the
# len of the opt string is greater than 1.
elif arg == '--':
del rargs[0]
return
elif arg[0:2] == '--':
# process a single long option (possibly with value(s))
self._process_long_opt(rargs, values)
elif arg[:1] == '-' and len(arg) > 1:
# process a cluster of short options (possibly with
# value(s) for the last one only)
self._process_short_opts(rargs, values)
elif self.allow_interspersed_args:
largs.append(arg)
del rargs[0]
else:
return # stop now, leave this arg in rargs

except (BadOptionError, AmbiguousOptionError) as e:
largs.append(e.opt_str)
72 changes: 72 additions & 0 deletions django_maven/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
try:
from unittest.case import skipIf
except ImportError:
from unittest2.case import skipIf

from django.core.management.base import BaseCommand, handle_default_options
from django.test import TestCase
from django_maven.management.argparse_command import \
MavenBaseCommand as ArgparseMavenBaseCommand
from django_maven.management.optparse_command import \
MavenBaseCommand as OptparseMavenBaseCommand


class TestCommandParserMixin(object):

def test_parse_maven_default_options(self):
args, options = self._handle_argv(['--verbosity', '2'])
self.assertEqual(args, [])
self.assertEqual(int(options['verbosity']), 2)

def test_parse_subcommand(self):
args, options = self._handle_argv(['subcommand'])
self.assertEqual(args, ['subcommand'])

def test_should_keep_subcommand_default_options(self):
args, options = self._handle_argv(['subcommand', '--help'])
self.assertEqual(args, ['subcommand', '--help'])

def test_should_keep_subcommand_custom_options(self):
args, options = self._handle_argv(['subcommand', '--foo'])
self.assertEqual(args, ['subcommand', '--foo'])

def test_mavens_and_subcommands_default_options_should_not_conflict(self):
args, options = self._handle_argv(
['--verbosity', '2', 'subcommand', '--verbosity', '3'])
self.assertEqual(int(options['verbosity']), 2)
self.assertEqual(args, ['subcommand', '--verbosity', '3'])

def _handle_argv(self, argv):
raise NotImplementedError()


@skipIf(not hasattr(BaseCommand, 'use_argparse'),
'This Django doesn\'t use argparse')
class ArgparseCommandParserTest(TestCommandParserMixin, TestCase):
"""
:type parser: django.core.management.base.CommandParser"""

def setUp(self):
self.cmd = ArgparseMavenBaseCommand()
self.parser = self.cmd.create_parser('manage.py', 'maven')

def _handle_argv(self, argv):
options = self.parser.parse_args(argv)
cmd_options = vars(options)
args = cmd_options.pop('args', ())
handle_default_options(options)
return args, cmd_options


class OptparseCommandParserTest(TestCommandParserMixin, TestCase):
""":type parser: django.core.management.base.OptionParser"""

def setUp(self):
self.cmd = OptparseMavenBaseCommand()
self.parser = self.cmd.create_parser('manage.py', 'maven')

def _handle_argv(self, argv):
options, args = self.parser.parse_args(argv)
cmd_options = vars(options)
handle_default_options(options)
return args, cmd_options
5 changes: 4 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
from setuptools import setup, find_packages

from setuptools import find_packages, setup


def read(filename):
Expand Down Expand Up @@ -32,6 +33,8 @@ def read(filename):
'Programming Language :: Python',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Topic :: Internet :: WWW/HTTP'
],
)
File renamed without changes.
File renamed without changes.
File renamed without changes.
Empty file added tests/test_app/__init__.py
Empty file.
Empty file.
Empty file.
24 changes: 24 additions & 0 deletions tests/test_app/management/commands/testcmd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from optparse import make_option

from django.core.management.base import BaseCommand


class IgnoramusException(Exception):
pass


class Command(BaseCommand):
help = 'Test command'

option_list = BaseCommand.option_list + (
make_option('--fail',
action='store_true',
dest='fail',
default=False,
help='Raise exception'),
)

def handle(self, *args, **options):
if options['fail']:
raise IgnoramusException('use jQuery')
print ('OK')
1 change: 1 addition & 0 deletions tests/test_app/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# naked file
Loading

0 comments on commit bdf8b37

Please sign in to comment.