2 This module deals with marshalling information from the user to the install
3 process for an application. We divide this process into two parts: this module
4 addresses the specification of what fields the application is requesting, and
5 submodules implement controller logic for actually getting this information
6 from the user. Common logic between controllers is stored in this module.
8 :class:`ArgSchema` is composed of two orthogonal components: a dictionary of
9 :class:`Arg` objects (which may be organized using :class:`ArgSet`) and a list
10 of :class:`Strategy` objects. An :class:`Arg` contains information about a
11 given argument, and specified at compile-time by an application, while a
12 :class:`Strategy` contains a possible procedure for automatically determining
13 what the contents of some argument are, and is specified at run-time by the
14 user (this is not quite true yet, but will be soon). Some arguments are most
15 commonly used together, so we group them together as an :class:`ArgSet` and
16 allow applications to refer to them as a single name.
20 from wizard.install import *
29 from wizard import scripts, shell, util
31 def dsn_callback(options):
32 if not isinstance(options.dsn, sqlalchemy.engine.url.URL):
33 options.dsn = sqlalchemy.engine.url.make_url(options.dsn)
34 # perform some sanity checks on the database
35 database = options.dsn.database
36 options.dsn.database = None
37 engine = sqlalchemy.create_engine(options.dsn)
38 # generates warnings http://groups.google.com/group/sqlalchemy/browse_thread/thread/b7123fefb7dd83d5
39 with warnings.catch_warnings():
40 warnings.simplefilter("ignore")
41 engine.execute("CREATE DATABASE IF NOT EXISTS `%s`" % database)
42 options.dsn.database = database
43 # XXX: another good thing to check might be that the database is empty
45 # XXX: This is in the wrong place
46 def fetch(options, path, post=None):
48 Fetches a web page from the autoinstall, usually to perform database
49 installation. ``path`` is the path of the file to retrieve, relative
50 to the autoinstall base (``options.web_path``), not the web root.
51 ``post`` is a dictionary to post. ``options`` is the options
52 object generated by :class:`OptionParser`.
54 return util.fetch(options.web_host, options.web_path, path, post)
56 class Strategy(object):
58 Represents a strategy for calculating arg values without user input.
60 Under many circumstances, making some assumptions about the server
61 environment means that we don't actually have to ask the user for values
62 such as the host or the path: these tend to be side effect free strategies.
63 Furthermore, we may have utility scripts present that can automatically
64 configure a new database for a user when one is necessary: these are side
65 effectful computations.
67 Note for an implementor: it is perfectly acceptable to calculate preliminary
68 results in :meth:`prepare`, store them as underscore prefixed variables,
69 and refer to them from :meth:`execute`.
71 #: Arguments that this strategy provides
72 provides = frozenset()
73 #: Whether or not this strategy has side effects.
77 Performs all side-effectless computation associated with this
78 strategy. It also detects if computation is possible, and
79 raises :exc:`StrategyFailed` if it isn't.
81 raise NotImplementedError
82 def execute(self, options):
84 Performs effectful computations associated with this strategy,
85 and mutates ``options`` with the new values. Behavior is
86 undefined if :meth:`prepare` was not called first. If this
87 method throws an exception, it should be treated as fatal.
89 raise NotImplementedError
91 class EnvironmentStrategy(Strategy):
93 Fills in values from environment variables, based off of
94 :attr:`Arg.envname` from ``schema``.
96 def __init__(self, schema):
99 for arg in schema.args.values():
100 if os.getenv(arg.envname) is not None:
101 self.provides.add(arg.name)
102 self.envlookup[arg.name] = arg.envname
104 """This strategy is always available."""
106 def execute(self, options):
107 """Sets undefined options to their environment variables."""
108 for name, envname in self.envlookup.items():
109 if getattr(options, name) is not None:
111 setattr(options, name, os.getenv(envname))
113 class ScriptsWebStrategy(Strategy):
114 """Performs scripts specific guesses for web variables."""
115 provides = frozenset(["web_host", "web_path"])
116 def __init__(self, dir):
119 """Uses :func:`wizard.scripts.get_web_host_and_path`."""
122 self._url = scripts.fill_url(self.dir, None)
125 def execute(self, options):
127 options.web_host = self._url.netloc # pylint: disable-msg=E1101
128 options.web_path = self._url.path # pylint: disable-msg=E1101
129 options.web_inferred = True # hacky: needed to see if we need a .scripts/url file
131 class ScriptsMysqlStrategy(Strategy):
133 Performs scripts specific guesses for MySQL variables. This
134 may create an appropriate database for the user.
137 provides = frozenset(["dsn"])
138 def __init__(self, application, dir):
139 self.application = application
142 """Uses :func:`wizard.scripts.get_sql_credentials`"""
143 if self.application.database != "mysql":
146 self._triplet = shell.eval("/mit/scripts/sql/bin/get-password").split()
147 except shell.CallError:
149 if len(self._triplet) != 3:
151 self._username = os.getenv('USER')
152 if self._username is None:
154 def execute(self, options):
155 """Creates a new database for the user using ``get-next-database`` and ``create-database``."""
156 host, username, password = self._triplet
158 name = shell.eval("/mit/scripts/sql/bin/get-next-database", os.path.basename(self.dir))
159 database = shell.eval("/mit/scripts/sql/bin/create-database", name)
160 options.dsn = sqlalchemy.engine.url.URL("mysql", username=username, password=password, host=host, database=database)
162 class ScriptsEmailStrategy(Strategy):
163 """Performs script specific guess for email."""
164 provides = frozenset(["email"])
166 """Uses :envvar:`USER` and assumes you are an MIT affiliate."""
167 # XXX: should double-check that you're on a scripts server
168 # and fail if you're not.
169 # XXX: This might be buggy, because locker might be set to USER
170 self._user = os.getenv("USER")
171 if self._user is None:
173 def execute(self, options):
175 options.email = self._user + "@mit.edu"
179 Represent a required, named argument for installation.
181 #: Attribute name of the argument
185 #: String to display if prompting a user for a value
187 #: String "type" of the argument, used for metavar
189 #: If true, is a password
191 #: Callback that this argument wants to get run on options after finished
195 """Name of the environment variable containing this arg."""
196 return 'WIZARD_' + self.name.upper()
197 def __init__(self, name, **kwargs):
199 for k,v in kwargs.items(): # cuz I'm lazy
200 if not hasattr(self, k):
201 raise TypeError("Arg() got unexpected keyword argument '%s'" % k)
203 if self.prompt is None:
204 self.prompt = self.help
206 class ArgSet(object):
208 Represents a set of named installation arguments that are required
209 for an installation to complete successfully. Arguments in a set
210 should share a common prefix and be related in functionality (the
211 litmus test is if you need one of these arguments, you should need
212 all of them). Register them in :func:`preloads`.
214 #: The :class:`Arg` objects that compose this argument set.
216 # XXX: probably could also use a callback attribute
220 class WebArgSet(ArgSet):
221 """Common arguments for any application that lives on the web."""
224 Arg("web_host", type="HOST", help="Host that the application will live on"),
225 Arg("web_path", type="PATH", help="Relative path to your application root"),
228 class DbArgSet(ArgSet):
229 """Common arguments for applications that use a database."""
232 Arg("dsn", type="DSN", help="Database Source Name, i.e. mysql://user:pass@host/dbname", callback=dsn_callback),
235 class AdminArgSet(ArgSet):
236 """Common arguments when an admin account is to be created."""
239 Arg("admin_name", type="NAME", help="Name of admin user to create",
240 prompt="You will be able to log in using a username of your choice. Please decide on a username and enter it below."),
241 Arg("admin_password", type="PWD", password=True, help="Password of admin user",
242 prompt="Please decide on an admin password."),
245 class EmailArgSet(ArgSet):
246 """Common arguments when an administrative email is required."""
249 Arg("email", help="Administrative email"),
252 class TitleArgSet(ArgSet):
253 """Common arguments when a title is required."""
256 Arg("title", help="Title of your new site",
257 prompt="Please decide on a title for your new website."),
262 Retrieves a dictionary of string names to precanned :class:`ArgSet` objects.
267 'admin': AdminArgSet(),
268 'email': EmailArgSet(),
269 'title': TitleArgSet(),
272 class ArgSchema(object):
274 Schema container for arguments.
276 Valid identifiers for subclasses of :class:`ArgSet` are:
278 * ``db``, which populates the option ``dsn``, which is a SQLAlchemy
279 database source name, with properties for ``drivername``,
280 ``username``, ``password``, ``host``, ``port``, ``database`` and
282 * ``admin``, which populates the options ``admin_name`` and
284 * ``email``, which populates the option ``email``.
285 * ``title``, which populates the option ``title``.
287 The options ``web_path`` and ``web_host`` are automatically required.
291 parser = ArgHandler("db", "admin", "email")
292 parser.add(Arg("title", help="Title of the new application"))
294 #: Dictionary of argument names to :class:`Arg` objects in schema.
296 #: List of :class:`Strategy` objects in schema.
298 #: Set of arguments that are already provided by :attr:`strategies`.
300 def __init__(self, *args):
302 preload_dict = preloads()
307 for arg in preload_dict[preload].args:
308 self.args[arg.name] = arg
310 raise UnrecognizedPreloads(preload)
312 """Adds an argument to our schema."""
313 self.args[arg.name] = arg
314 def commit(self, application, dir, web_stub_path):
315 """Populates :attr:`strategies` and :attr:`provides`"""
317 self.provides = set()
318 # XXX: separate out soon
320 EnvironmentStrategy(self),
321 ScriptsWebStrategy(dir),
322 ScriptsWebStrategy(web_stub_path),
323 ScriptsMysqlStrategy(application, dir),
324 ScriptsEmailStrategy(),
326 for strategy in raw_strategies:
329 self.provides |= strategy.provides
330 self.strategies.append(strategy)
331 except StrategyFailed:
333 # do non-effectful strategies first; this is a stable sort
334 self.strategies.sort(key=lambda x: x.side_effects)
335 def fill(self, options):
337 Fills an object with all arguments pre-set
341 if not hasattr(options, i):
342 setattr(options, i, None)
343 def load(self, options):
345 Load values from strategy. Must be called after :meth:`commit`. We
346 omit strategies whose provided variables are completely specified
347 already. Will raise :exc:`MissingRequiredParam` if strategies aren't
348 sufficient to fill all options. It will then run any callbacks on
351 unfilled = set(name for name in self.args if getattr(options, name) is None)
352 missing = unfilled - self.provides
354 raise MissingRequiredParam(missing)
355 for strategy in self.strategies:
356 if any(not hasattr(options, name) for name in strategy.provides):
357 if any(hasattr(options, name) for name in strategy.provides):
358 logging.warning("Ignored partial strategy %s" % strategy)
360 if all(getattr(options, name) is not None for name in strategy.provides):
362 for name in strategy.provides:
363 if getattr(options, name) is not None:
364 logging.warning("Overriding pre-specified value for %s", name)
365 strategy.execute(options)
366 for arg in self.args.values():
367 if arg.callback is None:
369 arg.callback(options)
371 class Error(wizard.Error):
372 """Base error class for this module."""
375 class StrategyFailed(Error):
376 """Strategy couldn't figure out values."""
379 class UnrecognizedPreloads(Error):
380 """You passed a preload that was not recognized."""
381 #: The preloads that were not recognized.
383 def __init__(self, preloads):
384 self.preloads = preloads
386 return "Did not recognize these preloads: " + ", ".join(self.preloads)
388 class MissingRequiredParam(Error):
389 """You missed a required argument, and we couldn't generate it.
390 Controllers should catch this exception and provide better behavior."""
391 #: The names of the arguments that were not specified.
393 def __init__(self, args):
396 return "Missing required parameters: %s" % ', '.join(self.args)