From e1ec702ae6c455049afcb520d9d3680c6148cc1c Mon Sep 17 00:00:00 2001 From: "Edward Z. Yang" Date: Wed, 28 Oct 2009 23:49:34 -0400 Subject: [PATCH] Massively refactor install modules. * ArgHandler is now ArgSchema and installopt.Controller * Environment turned into a dynamic strategy * Strategies made into two stage objects, with prepare and execute * Rewrite priority code Signed-off-by: Edward Z. Yang --- doc/module/wizard.install.rst | 8 +- wizard/app/mediawiki.py | 4 +- wizard/command/configure.py | 6 +- wizard/install/__init__.py | 230 ++++++++++++++++++---------------- wizard/install/installopt.py | 27 ++++ 5 files changed, 156 insertions(+), 119 deletions(-) create mode 100644 wizard/install/installopt.py diff --git a/doc/module/wizard.install.rst b/doc/module/wizard.install.rst index 30cee20..24d5dc8 100644 --- a/doc/module/wizard.install.rst +++ b/doc/module/wizard.install.rst @@ -11,7 +11,7 @@ Classes :members: .. autoclass:: ArgSet :members: -.. autoclass:: ArgHandler +.. autoclass:: ArgSchema :members: Predefined classes @@ -24,6 +24,9 @@ Predefined classes :show-inheritance: .. autoclass:: EmailArgSet :show-inheritance: +.. autoclass:: EnvironmentStrategy + :members: + :show-inheritance: .. autoclass:: ScriptsWebStrategy :members: :show-inheritance: @@ -37,14 +40,11 @@ Predefined classes Functions --------- .. autofunction:: fetch -.. autofunction:: attr_to_option .. autofunction:: preloads Exceptions ---------- .. autoexception:: Error -.. autoexception:: Failure - :show-inheritance: .. autoexception:: StrategyFailed :show-inheritance: .. autoexception:: UnrecognizedPreloads diff --git a/wizard/app/mediawiki.py b/wizard/app/mediawiki.py index 74c0110..9a9ea30 100644 --- a/wizard/app/mediawiki.py +++ b/wizard/app/mediawiki.py @@ -27,8 +27,8 @@ class Application(app.Application): extractors.update(php.extractors) substitutions = app.make_substitutions(seed) substitutions.update(php.substitutions) - install_handler = install.ArgHandler("mysql", "admin", "email") - install_handler.add(install.Arg("title", help="Title of your new MediaWiki install")) + install_schema = install.ArgSchema("mysql", "admin", "email") + install_schema.add(install.Arg("title", help="Title of your new MediaWiki install")) def checkConfig(self, deployment): return os.path.isfile(os.path.join(deployment.location, "LocalSettings.php")) def detectVersion(self, deployment): diff --git a/wizard/command/configure.py b/wizard/command/configure.py index 516de37..299c65c 100644 --- a/wizard/command/configure.py +++ b/wizard/command/configure.py @@ -1,7 +1,8 @@ import optparse import distutils.version -from wizard import app, command, git, shell, util +from wizard import app, command, git, install, shell, util +from wizard.install import installopt def main(argv, baton): @@ -34,7 +35,8 @@ This is a plumbing command, normal users should use application, _, version = tag.partition('-') application = app.applications()[application] - handler = application.install_handler + schema = application.install_schema + handler = installopt.Controller(schema) parser = command.WizardOptionParser(usage) handler.push(parser) diff --git a/wizard/install/__init__.py b/wizard/install/__init__.py index 4296bb1..a8bf759 100644 --- a/wizard/install/__init__.py +++ b/wizard/install/__init__.py @@ -1,11 +1,12 @@ """ 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). +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. -Briefly, a :class:`Arg` is the simplest unit of this +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 @@ -13,9 +14,10 @@ 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 +``admin_user``); the argument capitalized and with ``WIZARD_`` prepended to it indicates a corresponding environment variable, i.e. -``WIZARD_ADMIN_USER``. +``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 @@ -58,6 +60,7 @@ the actual installation begins). """ import os +import logging import wizard from wizard import scripts, shell, util @@ -72,20 +75,12 @@ def fetch(options, path, post=None): """ 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 { + 'web': WebArgSet(), 'mysql': MysqlArgSet(), 'admin': AdminArgSet(), 'email': EmailArgSet(), @@ -93,28 +88,55 @@ def preloads(): 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): """ - 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.. + 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.""" - def execute(self, options): - """Guesses web path by splitting on web_scripts.""" - tuple = scripts.get_web_host_and_path() - if not tuple: + 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 - options.web_host, options.web_path = tuple + def execute(self, options): + """No-op.""" + options.web_host, options.web_path = self._tuple class ScriptsMysqlStrategy(Strategy): """ @@ -122,26 +144,37 @@ class ScriptsMysqlStrategy(Strategy): may create an appropriate database for the user. """ side_effects = True - def execute(self, options): - """Attempts to create a database using Scripts utilities.""" + provides = frozenset(["mysql_host", "mysql_user", "mysql_password", "mysql_db"]) + def prepare(self): + """Uses :func:`wizard.scripts.get_sql_credentials`""" sh = shell.Shell() - triplet = scripts.get_sql_credentials() - if not triplet: + 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``.""" 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) + 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.""" - def execute(self, options): - """Guesses email using username.""" + 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. - options.email = os.getenv("USER") + "@mit.edu" + 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): """ @@ -187,8 +220,6 @@ class ArgSet(object): """ #: 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 = [] @@ -199,7 +230,6 @@ class WebArgSet(ArgSet): 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.""" @@ -210,7 +240,6 @@ class MysqlArgSet(ArgSet): 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.""" @@ -226,18 +255,10 @@ class EmailArgSet(ArgSet): self.args = [ Arg("email", help="Administrative email"), ] - self.strategy = ScriptsEmailStrategy() -class ArgHandler(object): +class ArgSchema(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. + Schema container for arguments. Valid identifiers for subclasses of :class:`ArgSet` are: @@ -254,81 +275,68 @@ class ArgHandler(object): 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 + #: 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.argsets = [ArgSet(), WebArgSet()] + self.args = {} preload_dict = preloads() + args = list(args) + args.append("web") for preload in args: try: - self.argsets.append(preload_dict[preload]) + 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.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): + 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): """ - Takes the result of :meth:`optparse.OptionParser.parse_args` - and performs user interaction and/or calculations to complete - missing fields. + Load values from strategy. Must be called after :meth:`commit`. We + omit strategies whose provided variables are completely specified + already. """ - # 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) + 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): - """Web install process failed.""" - # XXX: we can give better error messages - pass - class StrategyFailed(Error): """Strategy couldn't figure out values.""" pass diff --git a/wizard/install/installopt.py b/wizard/install/installopt.py new file mode 100644 index 0000000..808d1bb --- /dev/null +++ b/wizard/install/installopt.py @@ -0,0 +1,27 @@ +def attr_to_option(variable): + """ + Converts Python attribute names to command line options. + + >>> attr_to_option("foo_bar") + '--foo-bar' + """ + return '--' + variable.replace('_', '-') + +class Controller(object): + """ + Simple controller that actually delegates to :class:`optparse.OptionParser`. + """ + def __init__(self, schema): + self.schema = schema + def push(self, parser): + """Pushes arg schema to :class:`optparse.OptionParser`.""" + for arg in self.schema.args.values(): + parser.add_option(attr_to_option(arg.name), dest=arg.name, metavar=arg.type, + default=None, help=arg.help) + def handle(self, options): + """ + Performs post-processing for the options, including throwing + errors if not all arguments are specified. + """ + self.schema.commit() + self.schema.load(options) -- 2.45.0