""" 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 sqlalchemy import warnings import pkg_resources import wizard from wizard import deploy, shell, sql, util def dsn_callback(options): if not isinstance(options.dsn, sqlalchemy.engine.url.URL): options.dsn = sqlalchemy.engine.url.make_url(options.dsn) # do some guessing with sql options.dsn = sql.auth(options.dsn) # perform some sanity checks on the database database = options.dsn.database options.dsn.database = None engine = sqlalchemy.create_engine(options.dsn) # generates warnings http://groups.google.com/group/sqlalchemy/browse_thread/thread/b7123fefb7dd83d5 with warnings.catch_warnings(): warnings.simplefilter("ignore") engine.execute("CREATE DATABASE IF NOT EXISTS `%s`" % database) options.dsn.database = database # XXX: another good thing to check might be that the database is empty # 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) 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 # Set at runtime #: The :class:`ArgSchema` being created schema = None #: The :class:`wizard.app.Application` being installed. application = None #: The directory we are being installed to. dir = None #: The directory web stub files are being installed to. web_stub_path = None def __init__(self, schema, application, dir, web_stub_path): self.schema = schema self.application = application self.dir = dir self.web_stub_path = web_stub_path 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, *args): Strategy.__init__(self, *args) self.provides = set() self.envlookup = {} for arg in self.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 WebStrategy(Strategy): """Performs guesses for web variables using the URL hook.""" provides = frozenset(["web_host", "web_path"]) def prepare(self): """Uses :func:`deploy.web`.""" if self.dir is None: raise StrategyFailed if not os.path.exists(self.dir): os.mkdir(self.dir) try: urls = deploy.web(self.dir, None) if not urls: raise StrategyFailed try: self._url = urls.next() # pylint: disable-msg=E1101 except StopIteration: raise StrategyFailed except: os.rmdir(self.dir) raise def execute(self, options): """No-op.""" options.web_host = self._url.netloc # pylint: disable-msg=E1101 options.web_path = self._url.path # pylint: disable-msg=E1101 options.web_inferred = True # hacky: needed to see if we need a .wizard/url file 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 #: Callback that this argument wants to get run on options after finished callback = None @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 # XXX: probably could also use a callback attribute 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 DbArgSet(ArgSet): """Common arguments for applications that use a database.""" def __init__(self): self.args = [ Arg("dsn", type="DSN", help="Database Source Name, i.e. mysql://user:pass@host/dbname", callback=dsn_callback), ] 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."), ] def preloads(): """ Retrieves a dictionary of string names to precanned :class:`ArgSet` objects. """ return { 'web': WebArgSet(), 'db': DbArgSet(), 'admin': AdminArgSet(), 'email': EmailArgSet(), 'title': TitleArgSet(), } class ArgSchema(object): """ Schema container for arguments. Valid identifiers for subclasses of :class:`ArgSet` are: * ``db``, which populates the option ``dsn``, which is a SQLAlchemy database source name, with properties for ``drivername``, ``username``, ``password``, ``host``, ``port``, ``database`` and ``query``. * ``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("db", "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, application, dir, web_stub_path): """Populates :attr:`strategies` and :attr:`provides`""" self.strategies = [] self.provides = set() raw_strategies = [ EnvironmentStrategy, WebStrategy, ] for entry in pkg_resources.iter_entry_points("wizard.install.strategy"): cls = entry.load() raw_strategies.append(cls) for strategy_cls in raw_strategies: try: strategy = strategy_cls(self, application, dir, web_stub_path) # don't bother with strategies who are already # fully handled if strategy.provides <= self.provides: # this could exacerbate a bug in a strategy # which thinks that it's OK post prepare, but # actually isn't. There's now not an easy way to # say "don't actually run that strategy, please. continue 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. It will then run any callbacks on arguments. """ 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 the application being installed doesn't need all of the # parameters a strategy could provide, we don't use it. if any(not hasattr(options, name) for name in strategy.provides): if any(hasattr(options, name) for name in strategy.provides): logging.warning("Ignored partial strategy %s" % strategy) continue 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) for arg in self.args.values(): if arg.callback is None: continue arg.callback(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)