""" This module deals with marshalling information from the user to the install process for an application. We divide this process into two parts: this module addresses the specification of what fields the application is requesting, and submodules implement controller logic for actually getting this information from the user. Common logic between controllers is stored in this module. :class:`ArgSchema` is composed of two orthogonal components: a dictionary of :class:`Arg` objects (which may be organized using :class:`ArgSet`) and a list of :class:`Strategy` objects. An :class:`Arg` contains information about a given argument, and specified at compile-time by an application, while a :class:`Strategy` contains a possible procedure for automatically determining what the contents of some argument are, and is specified at run-time by the user (this is not quite true yet, but will be soon). Some arguments are most commonly used together, so we group them together as an :class:`ArgSet` and allow applications to refer to them as a single name. .. testsetup:: * from wizard.install import * """ import os import logging import wizard from wizard import scripts, shell, util # XXX: This is in the wrong place 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(), 'title': TitleArgSet(), } class Strategy(object): """ Represents a strategy for calculating arg values without user input. Under many circumstances, making some assumptions about the server environment means that we don't actually have to ask the user for values such as the host or the path: these tend to be side effect free strategies. Furthermore, we may have utility scripts present that can automatically configure a new database for a user when one is necessary: these are side effectful computations. Note for an implementor: it is perfectly acceptable to calculate preliminary results in :meth:`prepare`, store them as underscore prefixed variables, and refer to them from :meth:`execute`. """ #: 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 NotImplementedError 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. If this method throws an exception, it should be treated as fatal. """ raise NotImplementedError class EnvironmentStrategy(Strategy): """ Fills in values from environment variables, based off of :attr:`Arg.envname` from ``schema``. """ 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 __init__(self, dir): self.dir = dir def prepare(self): """Uses :func:`wizard.scripts.get_web_host_and_path`.""" self._tuple = scripts.get_web_host_and_path(self.dir) 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 __init__(self, dir): self.dir = dir 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() 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", os.path.basename(self.dir)) 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. """ #: Attribute name of the argument name = None #: Help string help = None #: String to display if prompting a user for a value prompt = None #: String "type" of the argument, used for metavar type = None #: If true, is a password password = False @property def envname(self): """Name of the environment variable containing this arg.""" return 'WIZARD_' + self.name.upper() def __init__(self, name, **kwargs): self.name = name for k,v in kwargs.items(): # cuz I'm lazy if not hasattr(self, k): raise TypeError("Arg() got unexpected keyword argument '%s'" % k) setattr(self, k, v) if self.prompt is None: self.prompt = self.help 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). Register them in :func:`preloads`. """ #: 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", prompt="You will be able to log in using a username of your choice. Please decide on a username and enter it below."), Arg("admin_password", type="PWD", password=True, help="Password of admin user", prompt="Please decide on an admin password."), ] class EmailArgSet(ArgSet): """Common arguments when an administrative email is required.""" def __init__(self): self.args = [ Arg("email", help="Administrative email"), ] class TitleArgSet(ArgSet): """Common arguments when a title is required.""" def __init__(self): self.args = [ Arg("title", help="Title of your new site", prompt="Please decide on a title for your new website."), ] 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``. * ``title``, which populates the option ``title``. The options ``web_path`` and ``web_host`` are automatically required. Example:: parser = ArgHandler("mysql", "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:`Strategy` objects in schema. strategies = None #: Set of arguments that are already provided by :attr:`strategies`. 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, dir): """Populates :attr:`strategies` and :attr:`provides`""" self.strategies = [] self.provides = set() # XXX: separate out soon raw_strategies = [ EnvironmentStrategy(self), ScriptsWebStrategy(dir), ScriptsMysqlStrategy(dir), 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 fill(self, options): """ Fills an object with all arguments pre-set to ``None``. """ for i in self.args: if not hasattr(options, i): setattr(options, i, None) 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 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)