"""
-Common code for installation scripts that live in .scripts/install
+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).
+
+Briefly, a :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
+of :class:`Arg` can be registered to the :class:`ArgHandler`, which
+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
+to it indicates a corresponding environment variable, i.e.
+``WIZARD_ADMIN_USER``.
+
+Because autoinstallers will often have a number of themed
+arguments (i.e. MySQL credentials) that are applicable across
+autoinstallers, :class:`ArgSet` can be use to group :class:`Arg`
+instances together, as well as promote reuse of these arguments.
+There are a number of precanned :class:`ArgSet` subclasses
+that serve this purpose, such as :class:`MysqlArgSet`.
+:class:`ArgHandler` also contains some convenience syntax in its
+constructor for loading predefined instances of :class:`ArgSet`.
+
+Certain arguments will vary from install to install, but
+can be automatically calculated if certain assumptions about the
+server environment are made. For example, an application might
+request an email; if we are on an Athena machine, one would
+reasonably expect the currently logged in user + @mit.edu to be
+a valid email address. :class:`Strategy` objects are responsible
+for this sort of calculation, and may be attached to any
+:class:`ArgSet` instance. (If you would like to attach a strategy
+to a single arg, you should put the arg in a :class:`ArgSet` and
+then set the strategy).
+
+Finally, certain :class:`Strategy` objects may perform operations
+with side effects (as marked by :attr:`Strategy.side_effects`).
+The primary use case for this is automatic creation of databases
+during an autoinstall. Marking a :class:`Strategy` as having
+side effects is important, so as to delay executing it until
+absolutely necessary (at the end of options parsing, but before
+the actual installation begins).
+
+.. note:
+
+ Because Wizard is eventually intended for public use,
+ some hook mechanism for overloading the default strategies will
+ need to be created. Setting up environment variables may act
+ as a vaguely reasonable workaround in the interim.
.. testsetup:: *
import httplib
import urllib
import subprocess
+import getpass
import wizard
-from wizard import util
+from wizard import shell, util
def fetch(options, path, post=None):
"""
"""
return '--' + variable.replace('_', '-')
-def calculate_web(options):
+def preloads():
+ """
+ Retrieves a dictionary of string names to precanned :class:`ArgSet` objects.
+ """
+ return {
+ 'mysql': MysqlArgSet(),
+ 'admin': AdminArgSet(),
+ 'email': EmailArgSet(),
+ }
+
+class Strategy(object):
+ """Represents a strategy for calculating arg values without user input."""
+ #: Whether or not this strategy has side effects.
+ side_effects = False
+ 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..
+ """
+ raise NotImplemented
+
+class ScriptsWebStrategy(Strategy):
"""Performs scripts specific guesses for web variables."""
# XXX: THIS CODE SUCKS
- if options.web_path or options.web_host: return
- _, _, web_path = os.getcwd().partition("/web_scripts")
- if not web_path: return
- options.web_path = web_path
- options.web_host = util.get_dir_owner() + ".scripts.mit.edu"
+ def execute(self, options):
+ """Guesses web path by splitting on web_scripts."""
+ _, _, web_path = os.getcwd().partition("/web_scripts")
+ if not web_path:
+ raise StrategyFailed
+ options.web_path = web_path
+ options.web_host = util.get_dir_owner() + ".scripts.mit.edu"
-def calculate_mysql(options):
+class ScriptsMysqlStrategy(Strategy):
"""
- Performs scripts specific guesses for mysql variables.
+ Performs scripts specific guesses for MySQL variables. This
+ may create an appropriate database for the user.
+ """
+ side_effects = True
+ def execute(self, options):
+ """Attempts to create a database using Scripts utilities."""
+ sh = shell.Shell()
+ try:
+ triplet = sh.eval("/mit/scripts/sql/bin/get-password").split()
+ except:
+ raise StrategyFailed
+ 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)
+ sh.call("/mit/scripts/sql/bin/create-database", options.mysql_db)
- .. note::
+class ScriptsEmailStrategy(Strategy):
+ """Performs script specific guess for email."""
+ def execute(self, options):
+ """Guesses email using username."""
+ # XXX: should double-check that you're on a scripts server
+ # and fail if you're not.
+ options.email = os.getenv("USER") + "@mit.edu"
- This might create the database using the sql signup service.
+class Arg(object):
"""
- if options.mysql_host or options.mysql_db or options.mysql_user or options.mysql_password: return
- try:
- triplet = subprocess.Popen("/mit/scripts/sql/bin/get-password", stdout=subprocess.PIPE).communicate()[0].rstrip().split()
- except:
- raise
- name = os.path.basename(os.getcwd())
- username = os.getenv('USER')
- options.mysql_host, options.mysql_user, options.mysql_password = triplet
- options.mysql_db = username + '+' + subprocess.Popen(["/mit/scripts/sql/bin/get-next-database", name], stdout=subprocess.PIPE).communicate()[0].rstrip()
- subprocess.Popen(["/mit/scripts/sql/bin/create-database", options.mysql_db], stdout=subprocess.PIPE).communicate()
-
-def calculate_email(options):
- """Performs script specific guess for email."""
- options.email = os.getenv("USER") + "@mit.edu"
+ Represent a required, named argument for installation. These
+ cannot have strategies associated with them, so if you'd like
+ to have a strategy associated with a single argument, create
+ an :class:`ArgSet` with one item in it.
+ """
+ #: Attribute name of the argument
+ name = None
+ #: Help string
+ help = None
+ #: String "type" of the argument, used for metavar
+ type = None
+ #: If true, is a password
+ password = None
+ @property
+ def option(self):
+ """Full string of the option."""
+ return attr_to_option(self.name)
+ @property
+ def envname(self):
+ """Name of the environment variable containing this arg."""
+ return 'WIZARD_' + self.name.upper()
+ def prompt(self, options):
+ """Interactively prompts for a value and sets it to options."""
+ # XXX: put a sane default implementation; we'll probably need
+ # "big" descriptions for this, since 'help' is too sparse.
+ pass
+ def __init__(self, name, password=False, type=None, help=None):
+ self.name = name
+ self.password = password
+ self.help = help or "UNDOCUMENTED"
+ self.type = type
+
+class ArgSet(object):
+ """
+ Represents a set of named installation arguments that are required
+ for an installation to complete successfully. Arguments in a set
+ should share a common prefix and be related in functionality (the
+ litmus test is if you need one of these arguments, you should need
+ all of them).
+ """
+ #: 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 = []
+
+class WebArgSet(ArgSet):
+ """Common arguments for any application that lives on the web."""
+ 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."""
+ def __init__(self):
+ self.args = [
+ Arg("mysql_host", type="HOST", help="Host that your MySQL server lives on"),
+ Arg("mysql_db", type="DB", help="Name of the database to populate"),
+ 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."""
+ def __init__(self):
+ self.args = [
+ Arg("admin_name", type="NAME", help="Name of admin user to create"),
+ Arg("admin_password", type="PWD", password=True, help="Password of admin user"),
+ ]
-class OptionParser(object):
+class EmailArgSet(ArgSet):
+ """Common arguments when an administrative email is required."""
+ def __init__(self):
+ self.args = [
+ Arg("email", help="Administrative email"),
+ ]
+ self.strategy = ScriptsEmailStrategy()
+
+class ArgHandler(object):
"""
- Wrapper around :class:`optparse.OptionParser` which adds support
- for required name parameters (i.e. "required options") and also
- includes support for common parameters (from ``preloads``) that you'd want
- when installing an application.
+ 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.
- Valid preloads are:
+ Valid identifiers for subclasses of :class:`ArgSet` are:
* ``mysql``, which populates the options ``mysql_host``, ``mysql_db``,
``mysql_user`` and ``mysql_password``.
Example::
- parser = OptionParser("sql", "admin", "email")
+ parser = ArgHandler("sql", "admin", "email")
+ parser.add(Arg("title", help="Title of the new application"))
"""
- #: Instance of :class:`optparse.OptionParser`, for specifying
- #: non-interactive options.
- parser = None
- #: Parameters that are required by this parser.
- params = None
- def __init__(self, *preloads):
- self.parser = optparse.OptionParser()
- self.params = set()
- self._user_params = set()
- self._calculators = []
- preloads = set(preloads)
- self.add_param("web_path", auto=True, help="Path to your install, e.g. /myapp")
- self.add_param("web_host", auto=True, help="Host of your install, e.g. example.com")
- self._calculators.append(calculate_web)
- if "mysql" in preloads:
- preloads.remove("mysql")
- self.add_param("mysql_host", auto=True, help="Host that your MySQL server lives on")
- self.add_param("mysql_db", auto=True, help="Name of the database to populate")
- self.add_param("mysql_user", auto=True, help="Name of user to access database with")
- self.add_param("mysql_password", auto=True, help="Password of the database user")
- self._calculators.append(calculate_mysql)
- if "admin" in preloads:
- preloads.remove("admin")
- self.add_param("admin_name", help="Name of admin user to create")
- self.add_param("admin_password", help="Password of admin user")
- if "email" in preloads:
- preloads.remove("email")
- self.add_param("email", auto=True, help="Administrative email")
- self._calculators.append(calculate_email)
- if preloads:
- raise UnrecognizedPreloads(preloads)
- def add_param(self, name, auto=False, help=None):
- """
- Adds a required parameter ``name``. This parameter can be asked
- for interactively or specified command line with ``--name`` (with
- underscores replaced with dashes). The ``help`` shows up both
- in ``--help`` output as well as interactive operation. If
- ``auto`` is ``True``, that means that we might be able to
- automatically calculate a sensible value.
-
- .. note:
-
- This API is most certainly going to change.
+ #: 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
+ def __init__(self, *args):
+ self.argsets = [ArgSet(), WebArgSet()]
+ preload_dict = preloads()
+ for preload in args:
+ try:
+ self.argsets.append(preload_dict[preload])
+ 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.parser.add_option(attr_to_option(name), dest=name,
- default=None, help=help)
- self.params.add(name)
- if not auto: self._user_params.add(name)
- def parse_args(self):
+ Takes the result of :meth:`optparse.OptionParser.parse_args`
+ and performs user interaction and/or calculations to complete
+ missing fields.
"""
- Performs option parsing and required option validation. Returns
- a tuple of ``(options, args)``, like
- :meth:`optparse.OptionParser.parse_args`.
- """
- options, args = self.parser.parse_args()
- # XXX: Something more robust, as in "here are the parameters
- # that need to be set at a certain point in time, and incrementally
- # increase validation as things go on". These means we need
- # an actual object for preloads
- for param in self._user_params:
- if getattr(options, param) is None:
- raise MissingRequiredParam(param)
- for calculator in self._calculators:
- calculator(options)
- for param in self.params:
- if getattr(options, param) is None:
- raise MissingRequiredParam(param)
- return options, param
+ # 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
+ argset.strategy.execute(options)
+ for arg in argset.args:
+ if getattr(options, arg.name) is None:
+ # XXX: arg.prompt(options)
+ raise MissingRequiredParam(arg)
class Error(wizard.Error):
"""Base error class for this module."""
# XXX: we can give better error messages
pass
+class StrategyFailed(Error):
+ """Strategy couldn't figure out values."""
+ pass
+
class UnrecognizedPreloads(Error):
"""You passed a preload that was not recognized."""
- #: The preloads that were not recognized
+ #: The preloads that were not recognized.
preloads = None
def __init__(self, preloads):
self.preloads = preloads
- self.message = str(self)
def __str__(self):
return "Did not recognize these preloads: " + ", ".join(self.preloads)
class MissingRequiredParam(Error):
- """You missed a required parameter, and we couldn't generate it."""
- #: The param variable name that was not recognized
+ """You missed a required argument, and we couldn't generate it."""
+ #: The :class:`Arg` that was not specified.
param = None
- def __init__(self, param):
- self.param = param
- self.message = str(self)
+ def __init__(self, arg):
+ self.arg = arg
def __str__(self):
- return "Missing required parameter %s; try specifying %s" % (self.param, attr_to_option(self.param))
+ return "Missing required parameter %s; try specifying option %s or environment variable %s" % (self.arg.name, self.arg.option, self.arg.envname)