""" This module contains an object model for specifying "required options", 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. 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 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``. 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 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 os import logging 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 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): """ 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.""" 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 def execute(self, options): """No-op.""" options.web_host, options.web_path = self._tuple class ScriptsMysqlStrategy(Strategy): """ Performs scripts specific guesses for MySQL variables. This may create an appropriate database for the user. """ side_effects = True provides = frozenset(["mysql_host", "mysql_user", "mysql_password", "mysql_db"]) def prepare(self): """Uses :func:`wizard.scripts.get_sql_credentials`""" 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``.""" sh = shell.Shell() name = os.path.basename(os.getcwd()) options.mysql_host, options.mysql_user, options.mysql_password = self._triplet # race condition 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.""" 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. 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): """ 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 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"), ] 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"), ] 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"), ] class ArgSchema(object): """ Schema container for arguments. 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")) """ #: 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.args = {} preload_dict = preloads() args = list(args) args.append("web") for preload in args: try: 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.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): """ Load values from strategy. Must be called after :meth:`commit`. We omit strategies whose provided variables are completely specified already. Will raise :exc:`MissingRequiredParam` if strategies aren't sufficient to fill all options. """ unfilled = set(name for name in self.args if getattr(options, name) is None) missing = unfilled - self.provides if missing: raise MissingRequiredParam(missing) 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): """Installation failed.""" 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. Controllers should catch this exception and provide better behavior.""" #: The names of the arguments that were not specified. args = None def __init__(self, args): self.args = args def __str__(self): return "Missing required parameters: %s" % ', '.join(self.args)