]> scripts.mit.edu Git - wizard.git/commitdiff
Massively refactor install modules.
authorEdward Z. Yang <ezyang@mit.edu>
Thu, 29 Oct 2009 03:49:34 +0000 (23:49 -0400)
committerEdward Z. Yang <ezyang@mit.edu>
Thu, 29 Oct 2009 03:58:59 +0000 (23:58 -0400)
* 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 <ezyang@mit.edu>
doc/module/wizard.install.rst
wizard/app/mediawiki.py
wizard/command/configure.py
wizard/install/__init__.py
wizard/install/installopt.py [new file with mode: 0644]

index 30cee20991d625372824e62bd09fd0d659300bd0..24d5dc8349ff6d920fe7f87c5e9eea667416ba4a 100644 (file)
@@ -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
index 74c01100fc302d0590537646890dd8c95b7ffa09..9a9ea30f9de55a9fc05a3bca30ba9157842c32ae 100644 (file)
@@ -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):
index 516de3705ea38aa04347f1e283500312662342a3..299c65c10b24717e217daa025132474bced172b9 100644 (file)
@@ -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)
index 4296bb1170042164950b6e00de173b2e0b5b3103..a8bf7599818c0a38aa5420d28aab8727501b4afe 100644 (file)
@@ -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 (file)
index 0000000..808d1bb
--- /dev/null
@@ -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)