"""
This module contains an object model for specifying "required options",
-also known as "Args". Whereas :class:`optparse.OptionParser` might
-normally be configured by performing a bunch of function calls, we
-generalize this configuration in order to support other types
-of input methods (most notably interactive).
+also known as "Args". While the format of this schema is inspired
+by :mod:`optparse`, this is not a controller (that is the job
+of :mod:`wizard.install` submodules); it merely is a schema
+that controllers can consume in order to determine their desired
+behavior.
-Briefly, a :class:`Arg` is the simplest unit of this
+An :class:`Arg` is the simplest unit of this
model, and merely represents some named argument that an installer
script needs in order to finish the installation (i.e., the password
to the database, or the name of the new application). Instances
manages marshalling these objects to whatever object
is actually managing user input. An argument is any valid Python
variable name, usually categorized using underscores (i.e.
-admin_user); the argument capitalized and with ``WIZARD_`` prepended
+``admin_user``); the argument capitalized and with ``WIZARD_`` prepended
to it indicates a corresponding environment variable, i.e.
-``WIZARD_ADMIN_USER``.
+``WIZARD_ADMIN_USER``. Arguments must be unique; applications
+that define custom arguments are expected to namespace them.
Because autoinstallers will often have a number of themed
arguments (i.e. MySQL credentials) that are applicable across
"""
import os
+import logging
import wizard
from wizard import scripts, shell, util
"""
return util.fetch(options.web_host, options.web_path, path, post)
-def attr_to_option(variable):
- """
- Converts Python attribute names to command line options.
-
- >>> attr_to_option("foo_bar")
- '--foo-bar'
- """
- return '--' + variable.replace('_', '-')
-
def preloads():
"""
Retrieves a dictionary of string names to precanned :class:`ArgSet` objects.
"""
return {
+ 'web': WebArgSet(),
'mysql': MysqlArgSet(),
'admin': AdminArgSet(),
'email': EmailArgSet(),
class Strategy(object):
"""Represents a strategy for calculating arg values without user input."""
+ #: Arguments that this strategy provides
+ provides = frozenset()
#: Whether or not this strategy has side effects.
side_effects = False
+ def prepare(self):
+ """
+ Performs all side-effectless computation associated with this
+ strategy. It also detects if computation is possible, and
+ raises :exc:`StrategyFailed` if it isn't.
+ """
+ raise NotImplemented
def execute(self, options):
"""
- Calculates values for the arguments that this strategy has been
- associated with, and then mutates ``options`` to contain those new
- values. This function is atomic; when control leaves, all of the
- options should either have values, **or** a :exc:`FailedStrategy` was
- raised and none of the options should have been changed.
- Execution is bypassed if all options are explicitly specified, even
- in the case of strategies with side effects..
+ Performs effectful computations associated with this strategy,
+ and mutates ``options`` with the new values. Behavior is
+ undefined if :meth:`prepare` was not called first.
"""
raise NotImplemented
+class EnvironmentStrategy(Strategy):
+ """Fills in values from environment variables."""
+ def __init__(self, schema):
+ self.provides = set()
+ self.envlookup = {}
+ for arg in schema.args.values():
+ if os.getenv(arg.envname) is not None:
+ self.provides.add(arg.name)
+ self.envlookup[arg.name] = arg.envname
+ def prepare(self):
+ """This strategy is always available."""
+ return True
+ def execute(self, options):
+ """Sets undefined options to their environment variables."""
+ for name, envname in self.envlookup.items():
+ if getattr(options, name) is not None:
+ continue
+ setattr(options, name, os.getenv(envname))
+
class ScriptsWebStrategy(Strategy):
"""Performs scripts specific guesses for web variables."""
- def execute(self, options):
- """Guesses web path by splitting on web_scripts."""
- tuple = scripts.get_web_host_and_path()
- if not tuple:
+ provides = frozenset(["web_host", "web_path"])
+ def prepare(self):
+ """Uses :func:`wizard.scripts.get_web_host_and_path`."""
+ self._tuple = scripts.get_web_host_and_path()
+ if not self._tuple:
raise StrategyFailed
- options.web_host, options.web_path = tuple
+ def execute(self, options):
+ """No-op."""
+ options.web_host, options.web_path = self._tuple
class ScriptsMysqlStrategy(Strategy):
"""
may create an appropriate database for the user.
"""
side_effects = True
- def execute(self, options):
- """Attempts to create a database using Scripts utilities."""
+ provides = frozenset(["mysql_host", "mysql_user", "mysql_password", "mysql_db"])
+ def prepare(self):
+ """Uses :func:`wizard.scripts.get_sql_credentials`"""
sh = shell.Shell()
- triplet = scripts.get_sql_credentials()
- if not triplet:
+ self._triplet = scripts.get_sql_credentials()
+ if not self._triplet:
+ raise StrategyFailed
+ self._username = os.getenv('USER')
+ if self._username is None:
raise StrategyFailed
+ def execute(self, options):
+ """Creates a new database for the user using ``get-next-database`` and ``create-database``."""
name = os.path.basename(os.getcwd())
- username = os.getenv('USER')
options.mysql_host, options.mysql_user, options.mysql_password = triplet
# race condition
- options.mysql_db = username + '+' + sh.eval("/mit/scripts/sql/bin/get-next-database", name)
+ options.mysql_db = self._username + '+' + sh.eval("/mit/scripts/sql/bin/get-next-database", name)
sh.call("/mit/scripts/sql/bin/create-database", options.mysql_db)
class ScriptsEmailStrategy(Strategy):
"""Performs script specific guess for email."""
- def execute(self, options):
- """Guesses email using username."""
+ provides = frozenset(["email"])
+ def prepare(self):
+ """Uses :envvar:`USER` and assumes you are an MIT affiliate."""
# XXX: should double-check that you're on a scripts server
# and fail if you're not.
- options.email = os.getenv("USER") + "@mit.edu"
+ self._user = os.getenv("USER")
+ if self._user is None:
+ raise StrategyFailed
+ def execute(self, options):
+ """No-op."""
+ options.email = self._user + "@mit.edu"
class Arg(object):
"""
"""
#: The :class:`Arg` objects that compose this argument set.
args = None
- #: The :class:`Strategy` objects for this option
- strategy = None
def __init__(self):
self.args = []
Arg("web_host", type="HOST", help="Host that the application will live on"),
Arg("web_path", type="PATH", help="Relative path to your application root"),
]
- self.strategy = ScriptsWebStrategy()
class MysqlArgSet(ArgSet):
"""Common arguments for applications that use a MySQL database."""
Arg("mysql_user", type="USER", help="Name of user to access database with"),
Arg("mysql_password", type="PWD", password=True, help="Password of the database user"),
]
- self.strategy = ScriptsMysqlStrategy()
class AdminArgSet(ArgSet):
"""Common arguments when an admin account is to be created."""
self.args = [
Arg("email", help="Administrative email"),
]
- self.strategy = ScriptsEmailStrategy()
-class ArgHandler(object):
+class ArgSchema(object):
"""
- Generic controller which takes an argument specification of :class:`Arg`
- and configures either a command line flags parser
- (:class:`optparse.OptionParser`), environment variables,
- an interactive user prompt
- (:class:`OptionPrompt`) or possibly a web interface to request
- these arguments appropriately. This controller also
- handles :class:`ArgSet`, which group related
- functionality together and can be reused from installer to installer.
+ Schema container for arguments.
Valid identifiers for subclasses of :class:`ArgSet` are:
parser = ArgHandler("sql", "admin", "email")
parser.add(Arg("title", help="Title of the new application"))
"""
- #: List of :class:`ArgSet` objects in schema. The element at
- #: index 0 will always be an anonymous :class:`ArgSet` that you
- #: can add stray instances of :class:`Arg` to.
- argsets = None
+ #: Dictionary of argument names to :class:`Arg` objects in schema.
+ args = None
+ #: List of :class:`ArgStrategy` objects in schema.
+ strategies = None
+ #: Set of arguments that are already provided. (This doesn't
+ #: say how to get them: probably running strategies or environment variables.)
+ provides = None
def __init__(self, *args):
- self.argsets = [ArgSet(), WebArgSet()]
+ self.args = {}
preload_dict = preloads()
+ args = list(args)
+ args.append("web")
for preload in args:
try:
- self.argsets.append(preload_dict[preload])
+ for arg in preload_dict[preload].args:
+ self.args[arg.name] = arg
except KeyError:
raise UnrecognizedPreloads(preload)
def add(self, arg):
"""Adds an argument to our schema."""
- self.argsets[0].args.append(arg)
- def push(self, parser):
- """Pushes arg schema to :class:`optparse.OptionParser`."""
- for argset in self.argsets:
- for arg in argset.args:
- parser.add_option(attr_to_option(arg.name), dest=arg.name, metavar=arg.type,
- default=None, help=arg.help)
- def handle(self, options):
+ self.args[arg.name] = arg
+ def commit(self):
+ """Populates :attr:`strategies` and :attr:`provides`"""
+ self.strategies = []
+ self.provides = set()
+ # XXX: separate out soon
+ raw_strategies = [
+ EnvironmentStrategy(self),
+ ScriptsWebStrategy(),
+ ScriptsMysqlStrategy(),
+ ScriptsEmailStrategy(),
+ ]
+ for arg in self.args.values():
+ if os.getenv(arg.envname) is not None:
+ self.provides.add(arg.name)
+ for strategy in raw_strategies:
+ try:
+ strategy.prepare()
+ self.provides |= strategy.provides
+ self.strategies.append(strategy)
+ except StrategyFailed:
+ pass
+ # do non-effectful strategies first; this is a stable sort
+ self.strategies.sort(key=lambda x: x.side_effects)
+ def load(self, options):
"""
- Takes the result of :meth:`optparse.OptionParser.parse_args`
- and performs user interaction and/or calculations to complete
- missing fields.
+ Load values from strategy. Must be called after :meth:`commit`. We
+ omit strategies whose provided variables are completely specified
+ already.
"""
- # categorize the argsets
- argsets_nostrategy = []
- argsets_strategy = []
- argsets_strategy_with_side_effects = []
- for argset in self.argsets:
- # fill in environment variables
- for arg in argset.args:
- if getattr(options, arg.name) is None:
- val = os.getenv(arg.envname)
- if val is not None:
- setattr(options, arg.name, val)
- if not argset.strategy:
- argsets_nostrategy.append(argset)
- elif argset.strategy.side_effects:
- argsets_strategy_with_side_effects.append(argset)
- else:
- argsets_strategy.append(argset)
- for argset in argsets_nostrategy:
- for arg in argset.args:
- if getattr(options, arg.name) is None:
- # XXX: arg.prompt(options)
- raise MissingRequiredParam(arg)
- def all_set(argset):
- for arg in argset.args:
- if getattr(options, arg.name) is None:
- return False
- return True
- for sets in (argsets_strategy, argsets_strategy_with_side_effects):
- for argset in sets:
- if all_set(argset): continue
- try:
- argset.strategy.execute(options)
- except StrategyFailed:
- pass
- for arg in argset.args:
- if getattr(options, arg.name) is None:
- # XXX: arg.prompt(options)
- raise MissingRequiredParam(arg)
+ for strategy in self.strategies:
+ if all(getattr(options, name) is not None for name in strategy.provides):
+ continue
+ for name in strategy.provides:
+ if getattr(options, name) is not None:
+ logging.warning("Overriding pre-specified value for %s", name)
+ strategy.execute(options)
class Error(wizard.Error):
"""Base error class for this module."""
pass
-class Failure(Error):
- """Web install process failed."""
- # XXX: we can give better error messages
- pass
-
class StrategyFailed(Error):
"""Strategy couldn't figure out values."""
pass