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 deploy, shell, sql, 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 # do some guessing with sql
35 options.dsn = sql.fill_url(options.dsn)
36 # perform some sanity checks on the database
37 database = options.dsn.database
38 options.dsn.database = None
39 engine = sqlalchemy.create_engine(options.dsn)
40 # generates warnings http://groups.google.com/group/sqlalchemy/browse_thread/thread/b7123fefb7dd83d5
41 with warnings.catch_warnings():
42 warnings.simplefilter("ignore")
43 engine.execute("CREATE DATABASE IF NOT EXISTS `%s`" % database)
44 options.dsn.database = database
45 # XXX: another good thing to check might be that the database is empty
47 # XXX: This is in the wrong place
48 def fetch(options, path, post=None):
50 Fetches a web page from the autoinstall, usually to perform database
51 installation. ``path`` is the path of the file to retrieve, relative
52 to the autoinstall base (``options.web_path``), not the web root.
53 ``post`` is a dictionary to post. ``options`` is the options
54 object generated by :class:`OptionParser`.
56 return util.fetch(options.web_host, options.web_path, path, post)
58 class Strategy(object):
60 Represents a strategy for calculating arg values without user input.
62 Under many circumstances, making some assumptions about the server
63 environment means that we don't actually have to ask the user for values
64 such as the host or the path: these tend to be side effect free strategies.
65 Furthermore, we may have utility scripts present that can automatically
66 configure a new database for a user when one is necessary: these are side
67 effectful computations.
69 Note for an implementor: it is perfectly acceptable to calculate preliminary
70 results in :meth:`prepare`, store them as underscore prefixed variables,
71 and refer to them from :meth:`execute`.
73 #: Arguments that this strategy provides
74 provides = frozenset()
75 #: Whether or not this strategy has side effects.
79 Performs all side-effectless computation associated with this
80 strategy. It also detects if computation is possible, and
81 raises :exc:`StrategyFailed` if it isn't.
83 raise NotImplementedError
84 def execute(self, options):
86 Performs effectful computations associated with this strategy,
87 and mutates ``options`` with the new values. Behavior is
88 undefined if :meth:`prepare` was not called first. If this
89 method throws an exception, it should be treated as fatal.
91 raise NotImplementedError
93 class EnvironmentStrategy(Strategy):
95 Fills in values from environment variables, based off of
96 :attr:`Arg.envname` from ``schema``.
98 def __init__(self, schema):
101 for arg in schema.args.values():
102 if os.getenv(arg.envname) is not None:
103 self.provides.add(arg.name)
104 self.envlookup[arg.name] = arg.envname
106 """This strategy is always available."""
108 def execute(self, options):
109 """Sets undefined options to their environment variables."""
110 for name, envname in self.envlookup.items():
111 if getattr(options, name) is not None:
113 setattr(options, name, os.getenv(envname))
115 class ScriptsWebStrategy(Strategy):
116 """Performs scripts specific guesses for web variables."""
117 # XXX: This actually isn't too scripts specific
118 provides = frozenset(["web_host", "web_path"])
119 def __init__(self, dir):
122 """Uses :func:`deploy.web`."""
125 urls = deploy.web(self.dir, None)
129 self._url = urls.next()
130 except StopIteration:
132 def execute(self, options):
134 options.web_host = self._url.netloc # pylint: disable-msg=E1101
135 options.web_path = self._url.path # pylint: disable-msg=E1101
136 options.web_inferred = True # hacky: needed to see if we need a .scripts/url file
138 class ScriptsMysqlStrategy(Strategy):
140 Performs scripts specific guesses for MySQL variables. This
141 may create an appropriate database for the user.
144 provides = frozenset(["dsn"])
145 def __init__(self, application, dir):
146 self.application = application
149 """Uses the SQL programs in the scripts locker"""
150 if self.application.database != "mysql":
153 self._triplet = shell.eval("/mit/scripts/sql/bin/get-password").split()
154 except shell.CallError:
156 if len(self._triplet) != 3:
158 self._username = os.getenv('USER')
159 if self._username is None:
161 def execute(self, options):
162 """Creates a new database for the user using ``get-next-database`` and ``create-database``."""
163 host, username, password = self._triplet
165 name = shell.eval("/mit/scripts/sql/bin/get-next-database", os.path.basename(self.dir))
166 database = shell.eval("/mit/scripts/sql/bin/create-database", name)
167 options.dsn = sqlalchemy.engine.url.URL("mysql", username=username, password=password, host=host, database=database)
169 class ScriptsEmailStrategy(Strategy):
170 """Performs script specific guess for email."""
171 provides = frozenset(["email"])
173 """Uses :envvar:`USER` and assumes you are an MIT affiliate."""
174 # XXX: should double-check that you're on a scripts server
175 # and fail if you're not.
176 # XXX: This might be buggy, because locker might be set to USER
177 self._user = os.getenv("USER")
178 if self._user is None:
180 def execute(self, options):
182 options.email = self._user + "@mit.edu"
186 Represent a required, named argument for installation.
188 #: Attribute name of the argument
192 #: String to display if prompting a user for a value
194 #: String "type" of the argument, used for metavar
196 #: If true, is a password
198 #: Callback that this argument wants to get run on options after finished
202 """Name of the environment variable containing this arg."""
203 return 'WIZARD_' + self.name.upper()
204 def __init__(self, name, **kwargs):
206 for k,v in kwargs.items(): # cuz I'm lazy
207 if not hasattr(self, k):
208 raise TypeError("Arg() got unexpected keyword argument '%s'" % k)
210 if self.prompt is None:
211 self.prompt = self.help
213 class ArgSet(object):
215 Represents a set of named installation arguments that are required
216 for an installation to complete successfully. Arguments in a set
217 should share a common prefix and be related in functionality (the
218 litmus test is if you need one of these arguments, you should need
219 all of them). Register them in :func:`preloads`.
221 #: The :class:`Arg` objects that compose this argument set.
223 # XXX: probably could also use a callback attribute
227 class WebArgSet(ArgSet):
228 """Common arguments for any application that lives on the web."""
231 Arg("web_host", type="HOST", help="Host that the application will live on"),
232 Arg("web_path", type="PATH", help="Relative path to your application root"),
235 class DbArgSet(ArgSet):
236 """Common arguments for applications that use a database."""
239 Arg("dsn", type="DSN", help="Database Source Name, i.e. mysql://user:pass@host/dbname", callback=dsn_callback),
242 class AdminArgSet(ArgSet):
243 """Common arguments when an admin account is to be created."""
246 Arg("admin_name", type="NAME", help="Name of admin user to create",
247 prompt="You will be able to log in using a username of your choice. Please decide on a username and enter it below."),
248 Arg("admin_password", type="PWD", password=True, help="Password of admin user",
249 prompt="Please decide on an admin password."),
252 class EmailArgSet(ArgSet):
253 """Common arguments when an administrative email is required."""
256 Arg("email", help="Administrative email"),
259 class TitleArgSet(ArgSet):
260 """Common arguments when a title is required."""
263 Arg("title", help="Title of your new site",
264 prompt="Please decide on a title for your new website."),
269 Retrieves a dictionary of string names to precanned :class:`ArgSet` objects.
274 'admin': AdminArgSet(),
275 'email': EmailArgSet(),
276 'title': TitleArgSet(),
279 class ArgSchema(object):
281 Schema container for arguments.
283 Valid identifiers for subclasses of :class:`ArgSet` are:
285 * ``db``, which populates the option ``dsn``, which is a SQLAlchemy
286 database source name, with properties for ``drivername``,
287 ``username``, ``password``, ``host``, ``port``, ``database`` and
289 * ``admin``, which populates the options ``admin_name`` and
291 * ``email``, which populates the option ``email``.
292 * ``title``, which populates the option ``title``.
294 The options ``web_path`` and ``web_host`` are automatically required.
298 parser = ArgHandler("db", "admin", "email")
299 parser.add(Arg("title", help="Title of the new application"))
301 #: Dictionary of argument names to :class:`Arg` objects in schema.
303 #: List of :class:`Strategy` objects in schema.
305 #: Set of arguments that are already provided by :attr:`strategies`.
307 def __init__(self, *args):
309 preload_dict = preloads()
314 for arg in preload_dict[preload].args:
315 self.args[arg.name] = arg
317 raise UnrecognizedPreloads(preload)
319 """Adds an argument to our schema."""
320 self.args[arg.name] = arg
321 def commit(self, application, dir, web_stub_path):
322 """Populates :attr:`strategies` and :attr:`provides`"""
324 self.provides = set()
325 # XXX: separate out soon
327 EnvironmentStrategy(self),
328 ScriptsWebStrategy(dir),
329 ScriptsWebStrategy(web_stub_path),
330 ScriptsMysqlStrategy(application, dir),
331 ScriptsEmailStrategy(),
333 for strategy in raw_strategies:
336 self.provides |= strategy.provides
337 self.strategies.append(strategy)
338 except StrategyFailed:
340 # do non-effectful strategies first; this is a stable sort
341 self.strategies.sort(key=lambda x: x.side_effects)
342 def fill(self, options):
344 Fills an object with all arguments pre-set
348 if not hasattr(options, i):
349 setattr(options, i, None)
350 def load(self, options):
352 Load values from strategy. Must be called after :meth:`commit`. We
353 omit strategies whose provided variables are completely specified
354 already. Will raise :exc:`MissingRequiredParam` if strategies aren't
355 sufficient to fill all options. It will then run any callbacks on
358 unfilled = set(name for name in self.args if getattr(options, name) is None)
359 missing = unfilled - self.provides
361 raise MissingRequiredParam(missing)
362 for strategy in self.strategies:
363 # If the application being installed doesn't need all of the
364 # parameters a strategy could provide, we don't use it.
365 if any(not hasattr(options, name) for name in strategy.provides):
366 if any(hasattr(options, name) for name in strategy.provides):
367 logging.warning("Ignored partial strategy %s" % strategy)
369 if all(getattr(options, name) is not None for name in strategy.provides):
371 for name in strategy.provides:
372 if getattr(options, name) is not None:
373 logging.warning("Overriding pre-specified value for %s", name)
374 strategy.execute(options)
375 for arg in self.args.values():
376 if arg.callback is None:
378 arg.callback(options)
380 class Error(wizard.Error):
381 """Base error class for this module."""
384 class StrategyFailed(Error):
385 """Strategy couldn't figure out values."""
388 class UnrecognizedPreloads(Error):
389 """You passed a preload that was not recognized."""
390 #: The preloads that were not recognized.
392 def __init__(self, preloads):
393 self.preloads = preloads
395 return "Did not recognize these preloads: " + ", ".join(self.preloads)
397 class MissingRequiredParam(Error):
398 """You missed a required argument, and we couldn't generate it.
399 Controllers should catch this exception and provide better behavior."""
400 #: The names of the arguments that were not specified.
402 def __init__(self, args):
405 return "Missing required parameters: %s" % ', '.join(self.args)