"""
-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.
+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:: *
import os
import logging
+import sqlalchemy
+import warnings
import wizard
-from wizard import scripts, shell, util
-
+from wizard import scripts, 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.fill_url(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
"""
return util.fetch(options.web_host, options.web_path, path, post)
-def preloads():
+class Strategy(object):
"""
- Retrieves a dictionary of string names to precanned :class:`ArgSet` objects.
+ 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`.
"""
- 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.
strategy. It also detects if computation is possible, and
raises :exc:`StrategyFailed` if it isn't.
"""
- raise NotImplemented
+ 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.
+ undefined if :meth:`prepare` was not called first. If this
+ method throws an exception, it should be treated as fatal.
"""
- raise NotImplemented
+ raise NotImplementedError
class EnvironmentStrategy(Strategy):
- """Fills in values from environment variables."""
+ """
+ Fills in values from environment variables, based off of
+ :attr:`Arg.envname` from ``schema``.
+ """
def __init__(self, schema):
self.provides = set()
self.envlookup = {}
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()
- if not self._tuple:
+ if self.dir is None:
+ raise StrategyFailed
+ self._url = scripts.fill_url(self.dir, None)
+ if not self._url:
raise StrategyFailed
def execute(self, options):
"""No-op."""
- options.web_host, options.web_path = self._tuple
+ 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 .scripts/url file
class ScriptsMysqlStrategy(Strategy):
"""
may create an appropriate database for the user.
"""
side_effects = True
- provides = frozenset(["mysql_host", "mysql_user", "mysql_password", "mysql_db"])
+ provides = frozenset(["dsn"])
+ def __init__(self, application, dir):
+ self.application = application
+ self.dir = dir
def prepare(self):
"""Uses :func:`wizard.scripts.get_sql_credentials`"""
- sh = shell.Shell()
- self._triplet = scripts.get_sql_credentials()
- if not self._triplet:
+ if self.application.database != "mysql":
+ raise StrategyFailed
+ try:
+ self._triplet = shell.eval("/mit/scripts/sql/bin/get-password").split()
+ except shell.CallError:
+ raise StrategyFailed
+ if len(self._triplet) != 3:
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``."""
- name = os.path.basename(os.getcwd())
- options.mysql_host, options.mysql_user, options.mysql_password = triplet
+ host, username, 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)
+ name = shell.eval("/mit/scripts/sql/bin/get-next-database", os.path.basename(self.dir))
+ database = shell.eval("/mit/scripts/sql/bin/create-database", name)
+ options.dsn = sqlalchemy.engine.url.URL("mysql", username=username, password=password, host=host, database=database)
class ScriptsEmailStrategy(Strategy):
"""Performs script specific guess for email."""
"""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.
+ # XXX: This might be buggy, because locker might be set to USER
self._user = os.getenv("USER")
if self._user is None:
raise StrategyFailed
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.
+ 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 = None
- @property
- def option(self):
- """Full string of the option."""
- return attr_to_option(self.name)
+ 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 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):
+ def __init__(self, name, **kwargs):
self.name = name
- self.password = password
- self.help = help or "UNDOCUMENTED"
- self.type = type
+ 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):
"""
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).
+ 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 = []
Arg("web_path", type="PATH", help="Relative path to your application root"),
]
-class MysqlArgSet(ArgSet):
- """Common arguments for applications that use a MySQL database."""
+class DbArgSet(ArgSet):
+ """Common arguments for applications that use a 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"),
+ 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"),
- Arg("admin_password", type="PWD", password=True, help="Password of admin user"),
+ 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):
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:
- * ``mysql``, which populates the options ``mysql_host``, ``mysql_db``,
- ``mysql_user`` and ``mysql_password``.
+ * ``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("sql", "admin", "email")
+ 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:`ArgStrategy` objects in schema.
+ #: List of :class:`Strategy` 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.)
+ #: Set of arguments that are already provided by :attr:`strategies`.
provides = None
def __init__(self, *args):
self.args = {}
def add(self, arg):
"""Adds an argument to our schema."""
self.args[arg.name] = arg
- def commit(self):
+ def commit(self, application, dir, web_stub_path):
"""Populates :attr:`strategies` and :attr:`provides`"""
self.strategies = []
self.provides = set()
# XXX: separate out soon
raw_strategies = [
EnvironmentStrategy(self),
- ScriptsWebStrategy(),
- ScriptsMysqlStrategy(),
+ ScriptsWebStrategy(dir),
+ ScriptsWebStrategy(web_stub_path),
+ ScriptsMysqlStrategy(application, 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()
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.
+ 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."""
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
+ """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 parameter %s; try specifying option %s or environment variable %s" % (self.arg.name, self.arg.option, self.arg.envname)
+ return "Missing required parameters: %s" % ', '.join(self.args)