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)
58 Retrieves a dictionary of string names to precanned :class:`ArgSet` objects.
63 'admin': AdminArgSet(),
64 'email': EmailArgSet(),
65 'title': TitleArgSet(),
68 class Strategy(object):
70 Represents a strategy for calculating arg values without user input.
72 Under many circumstances, making some assumptions about the server
73 environment means that we don't actually have to ask the user for values
74 such as the host or the path: these tend to be side effect free strategies.
75 Furthermore, we may have utility scripts present that can automatically
76 configure a new database for a user when one is necessary: these are side
77 effectful computations.
79 Note for an implementor: it is perfectly acceptable to calculate preliminary
80 results in :meth:`prepare`, store them as underscore prefixed variables,
81 and refer to them from :meth:`execute`.
83 #: Arguments that this strategy provides
84 provides = frozenset()
85 #: Whether or not this strategy has side effects.
89 Performs all side-effectless computation associated with this
90 strategy. It also detects if computation is possible, and
91 raises :exc:`StrategyFailed` if it isn't.
93 raise NotImplementedError
94 def execute(self, options):
96 Performs effectful computations associated with this strategy,
97 and mutates ``options`` with the new values. Behavior is
98 undefined if :meth:`prepare` was not called first. If this
99 method throws an exception, it should be treated as fatal.
101 raise NotImplementedError
103 class EnvironmentStrategy(Strategy):
105 Fills in values from environment variables, based off of
106 :attr:`Arg.envname` from ``schema``.
108 def __init__(self, schema):
109 self.provides = set()
111 for arg in schema.args.values():
112 if os.getenv(arg.envname) is not None:
113 self.provides.add(arg.name)
114 self.envlookup[arg.name] = arg.envname
116 """This strategy is always available."""
118 def execute(self, options):
119 """Sets undefined options to their environment variables."""
120 for name, envname in self.envlookup.items():
121 if getattr(options, name) is not None:
123 setattr(options, name, os.getenv(envname))
125 class ScriptsWebStrategy(Strategy):
126 """Performs scripts specific guesses for web variables."""
127 provides = frozenset(["web_host", "web_path"])
128 def __init__(self, dir):
131 """Uses :func:`wizard.scripts.get_web_host_and_path`."""
132 self._url = scripts.fill_url(self.dir, None)
135 def execute(self, options):
137 options.web_host = self._url.netloc # pylint: disable-msg=E1101
138 options.web_path = self._url.path # pylint: disable-msg=E1101
139 options.web_inferred = True # hacky: needed to see if we need a .scripts/url file
141 class ScriptsMysqlStrategy(Strategy):
143 Performs scripts specific guesses for MySQL variables. This
144 may create an appropriate database for the user.
147 provides = frozenset(["dsn"])
148 def __init__(self, application, dir):
149 self.application = application
152 """Uses :func:`wizard.scripts.get_sql_credentials`"""
153 if self.application.database != "mysql":
156 self._triplet = shell.Shell().eval("/mit/scripts/sql/bin/get-password").split()
157 except shell.CallError:
159 self._username = os.getenv('USER')
160 if self._username is None:
162 def execute(self, options):
163 """Creates a new database for the user using ``get-next-database`` and ``create-database``."""
165 host, username, password = self._triplet
167 database = self._username + '+' + sh.eval("/mit/scripts/sql/bin/get-next-database", os.path.basename(self.dir))
168 sh.call("/mit/scripts/sql/bin/create-database", database)
169 options.dsn = sqlalchemy.engine.url.URL("mysql", username=username, password=password, host=host, database=database)
171 class ScriptsEmailStrategy(Strategy):
172 """Performs script specific guess for email."""
173 provides = frozenset(["email"])
175 """Uses :envvar:`USER` and assumes you are an MIT affiliate."""
176 # XXX: should double-check that you're on a scripts server
177 # and fail if you're not.
178 self._user = os.getenv("USER")
179 if self._user is None:
181 def execute(self, options):
183 options.email = self._user + "@mit.edu"
187 Represent a required, named argument for installation.
189 #: Attribute name of the argument
193 #: String to display if prompting a user for a value
195 #: String "type" of the argument, used for metavar
197 #: If true, is a password
199 #: Callback that this argument wants to get run on options after finished
203 """Name of the environment variable containing this arg."""
204 return 'WIZARD_' + self.name.upper()
205 def __init__(self, name, **kwargs):
207 for k,v in kwargs.items(): # cuz I'm lazy
208 if not hasattr(self, k):
209 raise TypeError("Arg() got unexpected keyword argument '%s'" % k)
211 if self.prompt is None:
212 self.prompt = self.help
214 class ArgSet(object):
216 Represents a set of named installation arguments that are required
217 for an installation to complete successfully. Arguments in a set
218 should share a common prefix and be related in functionality (the
219 litmus test is if you need one of these arguments, you should need
220 all of them). Register them in :func:`preloads`.
222 #: The :class:`Arg` objects that compose this argument set.
224 # XXX: probably could also use a callback attribute
228 class WebArgSet(ArgSet):
229 """Common arguments for any application that lives on the web."""
232 Arg("web_host", type="HOST", help="Host that the application will live on"),
233 Arg("web_path", type="PATH", help="Relative path to your application root"),
236 class DbArgSet(ArgSet):
237 """Common arguments for applications that use a database."""
240 Arg("dsn", type="DSN", help="Database Source Name, i.e. mysql://user:pass@host/dbname", callback=dsn_callback),
243 class AdminArgSet(ArgSet):
244 """Common arguments when an admin account is to be created."""
247 Arg("admin_name", type="NAME", help="Name of admin user to create",
248 prompt="You will be able to log in using a username of your choice. Please decide on a username and enter it below."),
249 Arg("admin_password", type="PWD", password=True, help="Password of admin user",
250 prompt="Please decide on an admin password."),
253 class EmailArgSet(ArgSet):
254 """Common arguments when an administrative email is required."""
257 Arg("email", help="Administrative email"),
260 class TitleArgSet(ArgSet):
261 """Common arguments when a title is required."""
264 Arg("title", help="Title of your new site",
265 prompt="Please decide on a title for your new website."),
268 class ArgSchema(object):
270 Schema container for arguments.
272 Valid identifiers for subclasses of :class:`ArgSet` are:
274 * ``mysql``, which populates the options ``mysql_host``, ``mysql_db``,
275 ``mysql_user`` and ``mysql_password``.
276 * ``admin``, which populates the options ``admin_name`` and
278 * ``email``, which populates the option ``email``.
279 * ``title``, which populates the option ``title``.
281 The options ``web_path`` and ``web_host`` are automatically required.
285 parser = ArgHandler("mysql", "admin", "email")
286 parser.add(Arg("title", help="Title of the new application"))
288 #: Dictionary of argument names to :class:`Arg` objects in schema.
290 #: List of :class:`Strategy` objects in schema.
292 #: Set of arguments that are already provided by :attr:`strategies`.
294 def __init__(self, *args):
296 preload_dict = preloads()
301 for arg in preload_dict[preload].args:
302 self.args[arg.name] = arg
304 raise UnrecognizedPreloads(preload)
306 """Adds an argument to our schema."""
307 self.args[arg.name] = arg
308 def commit(self, application, dir):
309 """Populates :attr:`strategies` and :attr:`provides`"""
311 self.provides = set()
312 # XXX: separate out soon
314 EnvironmentStrategy(self),
315 ScriptsWebStrategy(dir),
316 ScriptsMysqlStrategy(application, dir),
317 ScriptsEmailStrategy(),
319 for arg in self.args.values():
320 if os.getenv(arg.envname) is not None:
321 self.provides.add(arg.name)
322 for strategy in raw_strategies:
325 self.provides |= strategy.provides
326 self.strategies.append(strategy)
327 except StrategyFailed:
329 # do non-effectful strategies first; this is a stable sort
330 self.strategies.sort(key=lambda x: x.side_effects)
331 def fill(self, options):
333 Fills an object with all arguments pre-set
337 if not hasattr(options, i):
338 setattr(options, i, None)
339 def load(self, options):
341 Load values from strategy. Must be called after :meth:`commit`. We
342 omit strategies whose provided variables are completely specified
343 already. Will raise :exc:`MissingRequiredParam` if strategies aren't
344 sufficient to fill all options. It will then run any callbacks on
347 unfilled = set(name for name in self.args if getattr(options, name) is None)
348 missing = unfilled - self.provides
350 raise MissingRequiredParam(missing)
351 for strategy in self.strategies:
352 if all(getattr(options, name) is not None for name in strategy.provides):
354 for name in strategy.provides:
355 if getattr(options, name) is not None:
356 logging.warning("Overriding pre-specified value for %s", name)
357 strategy.execute(options)
358 for arg in self.args.values():
359 if arg.callback is None:
361 arg.callback(options)
363 class Error(wizard.Error):
364 """Base error class for this module."""
367 class StrategyFailed(Error):
368 """Strategy couldn't figure out values."""
371 class UnrecognizedPreloads(Error):
372 """You passed a preload that was not recognized."""
373 #: The preloads that were not recognized.
375 def __init__(self, preloads):
376 self.preloads = preloads
378 return "Did not recognize these preloads: " + ", ".join(self.preloads)
380 class MissingRequiredParam(Error):
381 """You missed a required argument, and we couldn't generate it.
382 Controllers should catch this exception and provide better behavior."""
383 #: The names of the arguments that were not specified.
385 def __init__(self, args):
388 return "Missing required parameters: %s" % ', '.join(self.args)