Rewriting command support to use PasteScript since we're keeping that dependency for simplicity's sake

This commit is contained in:
Yoann Roman
2011-01-06 13:54:55 -05:00
parent f178537681
commit 4643b25019
4 changed files with 104 additions and 547 deletions

View File

@@ -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

View File

@@ -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<escaped>\$) | # Escape sequence of two delimiters
(?P<named>[_a-z][_a-z0-9]*) | # delimiter and a Python identifier
{(?P<braced>.*?)} | # delimiter and a braced identifier
(?P<invalid>) # 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']

View File

@@ -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'])
)

View File

@@ -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