From 4643b250199a0e00aa8aa4c0cc39d8d932146aa5 Mon Sep 17 00:00:00 2001 From: Yoann Roman Date: Thu, 6 Jan 2011 13:54:55 -0500 Subject: [PATCH] Rewriting command support to use PasteScript since we're keeping that dependency for simplicity's sake --- pecan/commands.py | 363 ++++++-------------------- pecan/templates/__init__.py | 266 +------------------ pecan/templates/project/setup.py_tmpl | 15 +- setup.py | 7 +- 4 files changed, 104 insertions(+), 547 deletions(-) diff --git a/pecan/commands.py b/pecan/commands.py index 9dda571..b7fb75a 100644 --- a/pecan/commands.py +++ b/pecan/commands.py @@ -1,26 +1,34 @@ """ -Commands for Pecan, heavily inspired by Paste Script commands. +PasteScript commands for Pecan. """ from configuration import _runtime_conf, set_config +from paste.script import command as paste_command +from paste.script.create_distro import CreateDistroCommand from webtest import TestApp -from templates import Template +from templates import DEFAULT_TEMPLATE -import imp +import copy import optparse import os import pkg_resources -import re import sys -import textwrap +import warnings class CommandRunner(object): """ Dispatches command execution requests. + + This is a custom PasteScript command runner that is specific to Pecan + commands. For a command to show up, its name must begin with "pecan-". + It is also recommended that its group name be set to "Pecan" so that it + shows up under that group when using ``paster`` directly. """ def __init__(self): + + # set up the parser self.parser = optparse.OptionParser(add_help_option=False, version='Pecan %s' % self.get_version(), usage='%prog [options] COMMAND [command_options]') @@ -29,6 +37,17 @@ class CommandRunner(object): action='store_true', dest='show_help', help='show detailed help message') + + # suppress BaseException.message warnings for BadCommand + if sys.version_info < (2, 7): + warnings.filterwarnings( + 'ignore', + 'BaseException\.message has been deprecated as of Python 2\.6', + DeprecationWarning, + paste_command.__name__.replace('.', '\\.')) + + # register Pecan as a system plugin when using the custom runner + paste_command.system_plugins.append('Pecan') def get_command_template(self, command_names): if not command_names: @@ -37,14 +56,11 @@ class CommandRunner(object): max_length = max([len(name) for name in command_names]) return ' %%-%ds %%s\n' % max_length - def get_commands(self, cls=None): + def get_commands(self): commands = {} - if not cls: - cls = Command - for command in cls.__subclasses__(): - if hasattr(command, 'name'): - commands[command.name] = command - commands.update(self.get_commands(command)) + for name, command in paste_command.get_commands().iteritems(): + if name.startswith('pecan-'): + commands[name[6:]] = command.load() return commands def get_version(self): @@ -67,7 +83,10 @@ class CommandRunner(object): return command_template = self.get_command_template(commands.keys()) for name, command in commands.iteritems(): - command_groups.setdefault(command.group_name, {})[name] = command + group_name = command.group_name + if group_name.lower() == 'pecan': + group_name = '' + command_groups.setdefault(group_name, {})[name] = command command_groups = sorted(command_groups.items()) for i, (group, commands) in enumerate(command_groups): file.write('%s:\n' % (group or 'Commands')) @@ -99,155 +118,48 @@ class CommandRunner(object): self.print_known_commands() return 1 else: - command = commands[command_name]() + command = commands[command_name](command_name) if options.show_help: - return command('-h') + return command.run(['-h']) else: - return command(*args) + return command.run(args) @classmethod def handle_command_line(cls): try: runner = CommandRunner() exit_code = runner.run(sys.argv[1:]) - except CommandException, ex: + except paste_command.BadCommand, ex: sys.stderr.write('%s\n' % ex) exit_code = ex.exit_code sys.exit(exit_code) -class Command(object): +class Command(paste_command.Command): """ Base class for Pecan commands. - All commands should inherit from this class or one of its subclasses. To - be exposed, subclasses must at least define a `name` class attribute. + This provides some standard functionality for interacting with Pecan + applications and handles some of the basic PasteScript command cruft. - In addition, subclasses can define the following class attributes: - - - `group_name`: Group the command under this category in help messages. - - - `usage`: Usage information for the command. This is particularly useful - if your command accepts positional arguments in addition to standard - options. - - - `summary`: Short description for the command that appears in help - messages. - - - `description`: Longer description for the command that appears in its - help message only. - - - `required_options`: Tuple of 2-element tuples with the destination - variable name and option name for required options. If any of these are - not found after parsing the options, the command will abort. - - - `required_options_error`: The error message to display when a required - option is missing. This gets the variable name as `name` and the option - name as `option`. - - - `required_args`: Tuple of required argument names. If the provided list - of positional arguments is shorter than this, the command will abort. - - - `required_args_error`: The error message to display when a required - positional argument is missing. This gets the number of actual arguments - as `actual`, the number of missing arguments as `missing`, and the names - of missing arguments, comma separated, as `missing_names`. - - - `maximum_args`: The maximum number of arguments this command accepts. - If not `None` and the number of arguments exceeds this number, the - command will abort. - - - `maximum_args_error`: The error message to display when the number of - positional arguments exceeds the maximum number for the command. This - gets the number of actual arguments as `actual` and the maximum number - of arguments as `max`. - - - `default_verbosity`: The default verbosity for the command. This gets - increased by 1 for every `-v` option and decreased by 1 for every `-q` - option using the default option parser and stored as the `verbosity` - instance attribute. If you override how `-v` and `-q` work, the default - remains unchanged. - - - `return_code`: The default return for the command. If a subclass's - implementation of `run` doesn't return a value, this gets returned. - - Subclasses should override `run` to provide a command's specific - implementation. `run` should return a valid exit code (i.e., usually 0 - for successful). See also `return_code` above. - - If a command has custom options, its subclass should override `get_parser` - to provide a custom `OptionParser`. It is recommended to call the parent - `get_parser` to get the default verbosity options, but this is not - required for a command to work. - - No other methods should be overriden by subclasses. + See ``paste.script.command.Command`` for more information. """ # command information - group_name = '' - usage = '' + group_name = 'Pecan' summary = '' - description = '' - # command options/arguments - required_options = () - required_options_error = 'You must provide the option %(option)s' - required_args = () - required_args_error = 'You must provide the following arguments: %(missing_names)s' - maximum_args = None - maximum_args_error = 'You must provide no more than %(max)s arguments' + # command parser + parser = paste_command.Command.standard_parser() - # command execution - default_verbosity = 0 - return_code = 0 - - def __call__(self, *args): - - # parse the arguments - self.parser = self.get_parser() - options, args = self.parse_args(list(args)) - - # determine the verbosity - for name, value in [('quiet', 0), ('verbose', 0)]: - if not hasattr(options, name): - setattr(options, name, value) - self.verbosity = self.default_verbosity - if isinstance(options.verbose, int): - self.verbosity += options.verbose - if isinstance(options.quiet, int): - self.verbosity -= options.quiet - - # make sure all required options were provided - for name, option in self.required_options: - if not hasattr(options, name): - message = self.required_options_error % {'name': name, 'option': option} - raise CommandException(self.parser.error(message)) - - # make sure all required arguments were provided - if len(args) < len(self.required_args): - missing = self.required_args[len(args):] - message = self.required_args_error % {'actual': len(args), - 'missing': len(missing), - 'missing_names': ', '.join(missing)} - raise CommandException(self.parser.error(message)) - - # make sure not too many arguments were provided if there's a limit - if self.maximum_args and len(args) > self.maximum_args: - message = self.maximum_args_error % {'actual': len(args), - 'max': self.maximum_args} - raise CommandException(self.parser.error(message)) - - # execute the command - result = self.run(options, args) - if result is None: - result = self.return_code - return result + def run(self, args): + try: + return paste_command.Command.run(self, args) + except paste_command.BadCommand, ex: + ex.args[0] = self.parser.error(ex.args[0]) + raise def can_import(self, name): - """ - Attempt to __import__ the specified package/module, returning - True when succeeding, otherwise False. - """ try: __import__(name) return True @@ -259,18 +171,6 @@ class Command(object): return [] return [module.__name__ for module in config.app.modules if hasattr(module, '__name__')] - def get_parser(self): - parser = optparse.OptionParser() - parser.add_option('-v', '--verbose', - action='count', - dest='verbose', - help='increase verbosity') - parser.add_option('-q', '--quiet', - action='count', - dest='quiet', - help='decrease verbosity') - return parser - def load_configuration(self, name): set_config(name) return _runtime_conf @@ -282,7 +182,7 @@ class Command(object): module = sys.modules[module_name] if hasattr(module, 'setup_app'): return module.setup_app(config) - raise CommandException('No app.setup_app found in any of the configured app.modules') + raise paste_command.BadCommand('No app.setup_app found in any of the configured app.modules') def load_model(self, config): for package_name in self.get_package_names(config): @@ -291,51 +191,27 @@ class Command(object): return sys.modules[module_name] return None - def parse_args(self, args): - if self.usage: - usage = ' ' + self.usage - else: - usage = '' - self.parser.usage = "%%prog %s [options]%s\n%s" % (self.name, usage, self.summary) - if self.description: - self.parser.description = textwrap.dedent(self.description) - return self.parser.parse_args(args) - - def run(self, options, args): + def command(self): pass -class CommandException(Exception): - """ - Raised when a command fails. Use CommandException in commands instead of - Exception so that these are correctly reported when running from the - command line. The optional `exit_code`, which defaults to 1, will be used - as the system exit code when running from the command line. - """ - - def __init__(self, message, exit_code=1): - Exception.__init__(self, message) - self.exit_code = exit_code - - class ServeCommand(Command): """ Serve the described application. """ # command information - name = 'serve' usage = 'CONFIG_NAME' summary = __doc__.strip().splitlines()[0].rstrip('.') # command options/arguments - required_args = ('CONFIG_NAME', ) - maximum_args = 1 + min_args = 1 + max_args = 1 - def run(self, options, args): + def command(self): # load the application - config = self.load_configuration(args[0]) + config = self.load_configuration(self.args[0]) app = self.load_app(config) from paste import httpserver @@ -351,18 +227,17 @@ class ShellCommand(Command): """ # command information - name = 'shell' usage = 'CONFIG_NAME' summary = __doc__.strip().splitlines()[0].rstrip('.') # command options/arguments - required_args = ('CONFIG_NAME', ) - maximum_args = 1 + min_args = 1 + max_args = 1 - def run(self, options, args): + def command(self): # load the application - config = self.load_configuration(args[0]) + config = self.load_configuration(self.args[0]) setattr(config.app, 'reload', False) app = self.load_app(config) @@ -406,105 +281,35 @@ class ShellCommand(Command): shell.interact(shell_banner + banner) -class CreateCommand(Command): +class CreateCommand(CreateDistroCommand, Command): """ - Creates a new Pecan package using a template. + Creates the file layout for a new Pecan distribution. + + For a template to show up when using this command, its name must begin + with "pecan-". Although not required, it should also include the "Pecan" + egg plugin for user convenience. """ # command information - name = 'create' - usage = 'PACKAGE_NAME' summary = __doc__.strip().splitlines()[0].rstrip('.') + description = None - # command options/arguments - maximum_args = 1 + def command(self): + if not self.options.list_templates: + if not self.options.templates: + self.options.templates = [DEFAULT_TEMPLATE] + try: + return CreateDistroCommand.command(self) + except LookupError, ex: + sys.stderr.write('%s\n\n' % ex) + CreateDistroCommand.list_templates(self) + return 2 - # regex for package names - BAD_CHARS_RE = re.compile(r'[^a-zA-Z0-9_]') - - def run(self, options, args): - - # if listing templates, list and return - if options.list_templates: - self.list_templates() - return - - # check the specified template - template_name = options.template or 'default' - template = Template.get_templates().get(template_name) - if not template: - message = 'Template "%s" could not be found' % template_name - raise CommandException(self.parser.error(message)) - - # make sure a package name was specified - if not args: - message = 'You must provide a package name' - raise CommandException(self.parser.error(message)) - - # prepare the variables - template = template() - dist_name = args[0].lstrip(os.path.sep) - output_dir = os.path.join(options.output_dir, dist_name) - pkg_name = self.BAD_CHARS_RE.sub('', dist_name.lower()) - egg_name = pkg_resources.to_filename(pkg_resources.safe_name(dist_name)) - vars = { - 'project': dist_name, - 'package': pkg_name, - 'egg': egg_name - } - - # display the vars if verbose - if self.verbosity: - self.display_vars(vars) - - # create the template - self.create_template(template, output_dir, vars, overwrite=options.overwrite) - - def get_parser(self): - parser = Command.get_parser(self) - parser.add_option('-t', '--template', - dest='template', - metavar='TEMPLATE', - help='template to use (uses default if not specified)') - parser.add_option('-o', '--output-dir', - dest='output_dir', - metavar='DIR', - default='.', - help='output to DIR (defaults to current directory)') - parser.add_option('--list-templates', - dest='list_templates', - action='store_true', - help='show all available templates') - parser.add_option('-f', '--overwrite', - dest='overwrite', - action='store_true', - help='Overwrite files', - default=False) - return parser - - def create_template(self, template, output_dir, vars, overwrite=False): - if self.verbosity: - print 'Creating template %s' % template.name - template.run(output_dir, vars, verbosity=self.verbosity, overwrite=overwrite) - - def display_vars(self, vars, file=sys.stdout): - vars = sorted(vars.items()) - file.write('Variables:\n') - if not vars: - return - max_length = max([len(name) for name, value in vars]) - for name, value in vars: - file.write(' %s:%s %s\n' % (name, ' ' * (max_length - len(name)), value)) - - def list_templates(self, file=sys.stdout): - templates = Template.get_templates() - if not templates: - file.write('No templates registered.\n') - return - template_names = sorted(templates.keys()) - max_length = max([len(name) for name in template_names]) - file.write('Available templates:\n') - for name in template_names: - file.write(' %s:%s %s\n' % (name, - ' ' * (max_length - len(name)), - templates[name].summary)) + def all_entry_points(self): + entry_points = [] + for entry in CreateDistroCommand.all_entry_points(self): + if entry.name.startswith('pecan-'): + entry = copy.copy(entry) + entry_points.append(entry) + entry.name = entry.name[6:] + return entry_points diff --git a/pecan/templates/__init__.py b/pecan/templates/__init__.py index 01f3298..8056ca4 100644 --- a/pecan/templates/__init__.py +++ b/pecan/templates/__init__.py @@ -1,262 +1,8 @@ -import cgi -import os -import string -import sys -import urllib +from paste.script.templates import Template -class LaxTemplate(string.Template): - # This change of pattern allows for anything in braces, but - # only identifiers outside of braces: - pattern = r""" - \$(?: - (?P\$) | # Escape sequence of two delimiters - (?P[_a-z][_a-z0-9]*) | # delimiter and a Python identifier - {(?P.*?)} | # delimiter and a braced identifier - (?P) # Other ill-formed delimiter exprs - ) - """ +DEFAULT_TEMPLATE = 'base' - -class TypeMapper(dict): - def __getitem__(self, item): - options = item.split('|') - for op in options[:-1]: - try: - value = eval(op, dict(self.items())) - break - except (NameError, KeyError): - pass - except Exception, ex: - TemplateProcessor.add_exception_info(ex, 'in expression %r' % op) - raise - else: - value = eval(options[-1], dict(self.items())) - if value is None: - return '' - else: - return str(value) - - -class TemplateProcessor(object): - - def __init__(self, vars, verbosity=0, overwrite=False): - self.vars = vars.copy() - self.vars.setdefault('dot', '.') - self.vars.setdefault('plus', '+') - self.verbosity = verbosity - self.overwrite = overwrite - self.standard_vars = self._create_standard_vars(vars) - - def process(self, template, output_dir, indent=0): - self._process_directory(template.template_directory, output_dir, indent=indent) - - def _create_standard_vars(self, extra_vars={}): - - # method to skip a template file - def skip_file(condition=True, *args): - if condition: - raise SkipFileException(*args) - - # build the dictionary of standard vars - standard_vars = { - 'nothing': None, - 'html_quote': lambda s: s is None and '' or cgi.escape(str(s), 1), - 'url_quote': lambda s: s is None and '' or urllib.quote(str(s)), - 'empty': '""', - 'test': lambda check, true, false=None: check and true or false, - 'repr': repr, - 'str': str, - 'bool': bool, - 'SkipFileException': SkipFileException, - 'skip_file': skip_file, - } - - # add in the extra vars - standard_vars.update(extra_vars) - - return standard_vars - - def _process_directory(self, source, dest, indent=0): - - # determine the output padding - pad = ' ' * (indent * 2) - - # create the destination directory - if not os.path.exists(dest): - if self.verbosity >= 1: - print '%sCreating %s/' % (pad, dest) - self._create_directory_tree(dest) - elif self.verbosity >= 2: - print '%sDirectory %s exists' % (pad, dest) - - # step through the source files/directories - for name in sorted(os.listdir(source)): - - # get the full path - full = os.path.join(source, name) - - # check if the file should be skipped - reason = self._should_skip_file(name) - if reason: - if self.verbosity >= 2: - print pad + reason % {'filename': full} - continue - - # get the destination filename - dest_full = os.path.join(dest, self._substitute_filename(name)) - - # if a directory, recurse - if os.path.isdir(full): - if self.verbosity: - print '%sRecursing into %s' % (pad, os.path.basename(full)) - self._process_directory(full, dest_full, indent=indent + 1) - continue - - # check if we should substitute content - sub_file = False - if dest_full.endswith('_tmpl'): - dest_full = dest_full[:-5] - sub_file = True - - # read the file contents - f = open(full, 'rb') - content = f.read() - f.close() - - # perform the substitution - if sub_file: - try: - content = self._substitute_content(content, full) - except SkipFileException: - continue - if content is None: - continue - - # check if the file already exists - already_exists = os.path.exists(dest_full) - if already_exists: - f = open(dest_full, 'rb') - old_content = f.read() - f.close() - if old_content == content: - if self.verbosity: - print '%s%s already exists (same content)' % (pad, dest_full) - continue - if not self.overwrite: - continue - - # write out the new file - if self.verbosity: - print '%sCopying %s to %s' % (pad, os.path.basename(full), dest_full) - f = open(dest_full, 'wb') - f.write(content) - f.close() - - def _should_skip_file(self, name): - """ - Checks if a file should be skipped based on its name. - - If it should be skipped, returns the reason, otherwise returns - None. - """ - if name.startswith('.'): - return 'Skipping hidden file %(filename)s' - if name.endswith('~') or name.endswith('.bak'): - return 'Skipping backup file %(filename)s' - if name.endswith('.pyc'): - return 'Skipping .pyc file %(filename)s' - if name.endswith('$py.class'): - return 'Skipping $py.class file %(filename)s' - if name in ('CVS', '_darcs'): - return 'Skipping version control directory %(filename)s' - return None - - def _create_directory_tree(self, dir): - parent = os.path.dirname(os.path.abspath(dir)) - if not os.path.exists(parent): - self._create_directory_tree(parent) - os.mkdir(dir) - - def _substitute_filename(self, fn): - for var, value in self.vars.items(): - fn = fn.replace('+%s+' % var, str(value)) - return fn - - def _substitute_content(self, content, filename): - tmpl = LaxTemplate(content) - try: - return tmpl.substitute(TypeMapper(self.standard_vars.copy())) - except Exception, ex: - TemplateProcessor.add_exception_info(ex, ' in file %s' % filename) - raise - - @staticmethod - def add_exception_info(exc, info): - if not hasattr(exc, 'args') or exc.args is None: - return - args = list(exc.args) - if args: - args[0] += ' ' + info - else: - args = [info] - exc.args = tuple(args) - - -class SkipFileException(Exception): - """ - Raised to indicate that the file should not be copied over. - Raise this exception during the substitution of your file. - """ - pass - - -class Template(object): - - # template information - summary = '' - - @property - def module_directory(self): - module = sys.modules[self.__class__.__module__] - return os.path.dirname(module.__file__) - - @property - def template_directory(self): - if getattr(self, 'directory', None) is None: - raise Exception('Template "%s" did not set directory' % self.name) - return os.path.join(self.module_directory, self.directory) - - def run(self, output_dir, vars, **options): - self.pre(output_dir, vars, **options) - self.write_files(output_dir, vars, **options) - self.post(output_dir, vars, **options) - - def pre(self, output_dir, vars, **options): - pass - - def write_files(self, output_dir, vars, **options): - processor = TemplateProcessor(vars, options.get('verbosity', 0), - options.get('overwrite', False)) - processor.process(self, output_dir, indent=1) - - def post(self, output_dir, vars, **options): - pass - - @classmethod - def get_templates(cls, parent=None): - templates = {} - if not parent: - parent = cls - for template in parent.__subclasses__(): - if hasattr(template, 'name'): - templates[template.name] = template - templates.update(cls.get_templates(template)) - return templates - - -class DefaultTemplate(Template): - - # template information - name = 'default' - summary = 'Template for creating a basic Pecan package' - directory = 'project' +class BaseTemplate(Template): + summary = 'Template for creating a basic Pecan project' + _template_dir = 'project' + egg_plugins = ['Pecan'] diff --git a/pecan/templates/project/setup.py_tmpl b/pecan/templates/project/setup.py_tmpl index 74e6e24..864c61c 100644 --- a/pecan/templates/project/setup.py_tmpl +++ b/pecan/templates/project/setup.py_tmpl @@ -7,15 +7,16 @@ except ImportError: from setuptools import setup, find_packages setup( - name='${project}', - version='0.1', - description='', - author='', - author_email='', - install_requires=[ + name = '${project}', + version = '0.1', + description = '', + author = '', + author_email = '', + install_requires = [ "pecan", ], zip_safe = False, + paster_plugins = ${egg_plugins}, include_package_data = True, - packages=find_packages(exclude=['ez_setup']) + packages = find_packages(exclude=['ez_setup']) ) diff --git a/setup.py b/setup.py index 544dd97..dbb69e4 100644 --- a/setup.py +++ b/setup.py @@ -61,8 +61,13 @@ setup( cmdclass = {'test': PyTest}, install_requires = requirements, entry_points = """ + [paste.paster_command] + pecan-serve = pecan.commands:ServeCommand + pecan-shell = pecan.commands:ShellCommand + pecan-create = pecan.commands:CreateCommand + [paste.paster_create_template] - pecan-base = templates:NewProjectTemplate + pecan-base = pecan.templates:BaseTemplate [console_scripts] pecan = pecan.commands:CommandRunner.handle_command_line