From 76abefad20a361c81f38f10f99e205d52a87498c Mon Sep 17 00:00:00 2001 From: "Evgeny V. Generalov" Date: Sat, 12 Dec 2015 15:40:07 +0500 Subject: [PATCH] Refactored command line argument parsing (fixes #7) --- README.md | 8 ++ django_maven/compat.py | 10 ++- django_maven/management/argparse_command.py | 13 +++ django_maven/management/commands/maven.py | 98 +++++++++------------ django_maven/management/optparse_command.py | 54 ++++++++++++ django_maven/tests.py | 67 ++++++++++++++ setup.py | 5 +- test_project/test_project/settings.py | 15 ++-- tox.ini | 22 +++++ 9 files changed, 223 insertions(+), 69 deletions(-) create mode 100644 django_maven/management/argparse_command.py create mode 100644 django_maven/management/optparse_command.py create mode 100644 django_maven/tests.py create mode 100644 tox.ini diff --git a/README.md b/README.md index 71a5b8d..c722bd6 100644 --- a/README.md +++ b/README.md @@ -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 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 -------- diff --git a/django_maven/compat.py b/django_maven/compat.py index baaa9b5..6cba1fc 100644 --- a/django_maven/compat.py +++ b/django_maven/compat.py @@ -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 @@ -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 @@ -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 not hasattr(BaseCommand, 'use_argparse'): + from django_maven.management.argparse_command import MavenBaseCommand +else: + from django_maven.management.optparse_command import MavenBaseCommand diff --git a/django_maven/management/argparse_command.py b/django_maven/management/argparse_command.py new file mode 100644 index 0000000..452b8b7 --- /dev/null +++ b/django_maven/management/argparse_command.py @@ -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) diff --git a/django_maven/management/commands/maven.py b/django_maven/management/commands/maven.py index 8fa599f..09ea3d3 100644 --- a/django_maven/management/commands/maven.py +++ b/django_maven/management/commands/maven.py @@ -1,20 +1,53 @@ 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 +from django_maven.compat import MavenBaseCommand, OutputWrapper from raven import Client -from django_maven.compat import OutputWrapper +class Command(MavenBaseCommand): + help = 'Capture exceptions and send in Sentry' -class Command(BaseCommand): + def __init__(self, stdout=None, stderr=None, no_color=False): + super().__init__(stdout, stderr, no_color) + self._argv = None - help = 'Capture exceptions and send in Sentry' - args = '' + def run_from_argv(self, argv): + self._argv = argv + + try: + return super(Command, self).run_from_argv(argv) + except (Exception, SystemExit) as e: + if not isinstance(e, CommandError) or ( + isinstance(e, SystemExit) and e.code != 0): + sentry = self._get_sentry() + if not sentry: + raise + sentry.get_ident(sentry.captureException()) + + self._write_error_in_stderr(e) + + def execute(self, *args, **options): + if not args: + return self.print_help(self._argv[0], self._argv[1]) + + subcommand = self._get_subcommand_class(args[0]) + subcommand_argv = [self._argv[0]] + list(args) + return subcommand.run_from_argv(subcommand_argv) + + 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 None + sentry = Client(dsn) + if not sentry.is_enabled(): + return None + return sentry def _get_subcommand_class(self, command): commands = get_commands() @@ -26,52 +59,3 @@ def _write_error_in_stderr(self, exc): self.style.ERROR)) stderr.write('%s: %s' % (exc.__class__.__name__, exc)) sys.exit(1) - - 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 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) - else: - return OptionParser(prog=prog_name, - usage=subcommand_class.usage(subcommand), - version=subcommand_class.get_version(), - option_list=subcommand_class.option_list) - - 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) - - 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:]) - cmd_options = vars(options) - args = cmd_options.pop('args', ()) - else: - options, args = parser.parse_args(argv[3:]) - 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) diff --git a/django_maven/management/optparse_command.py b/django_maven/management/optparse_command.py new file mode 100644 index 0000000..df8d425 --- /dev/null +++ b/django_maven/management/optparse_command.py @@ -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) diff --git a/django_maven/tests.py b/django_maven/tests.py new file mode 100644 index 0000000..9a2404e --- /dev/null +++ b/django_maven/tests.py @@ -0,0 +1,67 @@ +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.assertEquals(args, []) + self.assertEquals(int(options['verbosity']), 2) + + def test_parse_subcommand(self): + args, options = self._handle_argv(['subcommand']) + self.assertEquals(args, ['subcommand']) + + def test_should_keep_subcommand_default_options(self): + args, options = self._handle_argv(['subcommand', '--help']) + self.assertEquals(args, ['subcommand', '--help']) + + def test_should_keep_subcommand_custom_options(self): + args, options = self._handle_argv(['subcommand', '--foo']) + self.assertEquals(args, ['subcommand', '--foo']) + + 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) + print(options) + 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 diff --git a/setup.py b/setup.py index a26f12b..0d62edd 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,6 @@ import os -from setuptools import setup, find_packages + +from setuptools import find_packages, setup def read(filename): @@ -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' ], ) diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 8b3d383..99e92eb 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -11,14 +11,9 @@ DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. - 'NAME': 'sqlite3.db', # Or path to database file if using sqlite3. - # The following settings are not used with sqlite3: - 'USER': '', - 'PASSWORD': '', - 'HOST': '', # Empty for localhost through domain sockets or '127.0.0.1' for localhost through TCP. - 'PORT': '', # Set to empty string for default. - } + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + }, } # Hosts/domain names that are valid for this site; required if DEBUG is False @@ -79,7 +74,7 @@ STATICFILES_FINDERS = ( 'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder', -# 'django.contrib.staticfiles.finders.DefaultStorageFinder', + # 'django.contrib.staticfiles.finders.DefaultStorageFinder', ) # Make this unique, and don't share it with anybody. @@ -89,7 +84,7 @@ TEMPLATE_LOADERS = ( 'django.template.loaders.filesystem.Loader', 'django.template.loaders.app_directories.Loader', -# 'django.template.loaders.eggs.Loader', + # 'django.template.loaders.eggs.Loader', ) MIDDLEWARE_CLASSES = ( diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..ec67c40 --- /dev/null +++ b/tox.ini @@ -0,0 +1,22 @@ +[tox] +envlist = + py{26}-dj{15,16} + py{27,34}-dj{15,16,17,18,19} + py{33}-dj{15,16,17,18} + py{35}-dj{18,19} +skipsdist = True + +[testenv] +changedir = {toxinidir}/test_project +commands = python manage.py test django_maven +deps = + raven + py26: unittest2 + dj15: Django>=1.5,<1.6 + dj16: Django>=1.6,<1.7 + dj17: Django>=1.7,<1.8 + dj18: Django>=1.8,<1.9 + dj19: Django>=1.9,<1.10 +setenv = + PYTHONPATH = {toxinidir}:{toxinidir}/test_project +usedevelop = True