""" 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:: * from wizard.install import * """ import optparse import os import httplib import urllib import subprocess import getpass import wizard from wizard import scripts, shell, util def fetch(options, path, post=None): """ Fetches a web page from the autoinstall, usually to perform database installation. ``path`` is the path of the file to retrieve, relative to the autoinstall base (``options.web_path``), not the web root. ``post`` is a dictionary to post. ``options`` is the options object generated by :class:`OptionParser`. """ 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 { '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.""" def execute(self, options): """Guesses web path by splitting on web_scripts.""" tuple = scripts.get_web_host_and_path() if not tuple: raise StrategyFailed options.web_host, options.web_path = tuple class ScriptsMysqlStrategy(Strategy): """ 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() triplet = scripts.get_sql_credentials() if not triplet: 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) 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" class Arg(object): """ 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 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): """ 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 identifiers for subclasses of :class:`ArgSet` are: * ``mysql``, which populates the options ``mysql_host``, ``mysql_db``, ``mysql_user`` and ``mysql_password``. * ``admin``, which populates the options ``admin_name`` and ``admin_password``. * ``email``, which populates the option ``email``. The options ``web_path`` and ``web_host`` are automatically required. Example:: 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 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): """ Takes the result of :meth:`optparse.OptionParser.parse_args` and performs user interaction and/or calculations to complete missing fields. """ # 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) 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 class UnrecognizedPreloads(Error): """You passed a preload that was not recognized.""" #: The preloads that were not recognized. preloads = None def __init__(self, preloads): self.preloads = preloads def __str__(self): return "Did not recognize these preloads: " + ", ".join(self.preloads) class MissingRequiredParam(Error): """You missed a required argument, and we couldn't generate it.""" #: The :class:`Arg` that was not specified. param = None def __init__(self, arg): self.arg = arg def __str__(self): return "Missing required parameter %s; try specifying option %s or environment variable %s" % (self.arg.name, self.arg.option, self.arg.envname)