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.
62 'webstub': WebStubArgSet(),
64 'admin': AdminArgSet(),
65 'email': EmailArgSet(),
66 'title': TitleArgSet(),
69 class Strategy(object):
71 Represents a strategy for calculating arg values without user input.
73 Under many circumstances, making some assumptions about the server
74 environment means that we don't actually have to ask the user for values
75 such as the host or the path: these tend to be side effect free strategies.
76 Furthermore, we may have utility scripts present that can automatically
77 configure a new database for a user when one is necessary: these are side
78 effectful computations.
80 Note for an implementor: it is perfectly acceptable to calculate preliminary
81 results in :meth:`prepare`, store them as underscore prefixed variables,
82 and refer to them from :meth:`execute`.
84 #: Arguments that this strategy provides
85 provides = frozenset()
86 #: Whether or not this strategy has side effects.
90 Performs all side-effectless computation associated with this
91 strategy. It also detects if computation is possible, and
92 raises :exc:`StrategyFailed` if it isn't.
94 raise NotImplementedError
95 def execute(self, options):
97 Performs effectful computations associated with this strategy,
98 and mutates ``options`` with the new values. Behavior is
99 undefined if :meth:`prepare` was not called first. If this
100 method throws an exception, it should be treated as fatal.
102 raise NotImplementedError
104 class EnvironmentStrategy(Strategy):
106 Fills in values from environment variables, based off of
107 :attr:`Arg.envname` from ``schema``.
109 def __init__(self, schema):
110 self.provides = set()
112 for arg in schema.args.values():
113 if os.getenv(arg.envname) is not None:
114 self.provides.add(arg.name)
115 self.envlookup[arg.name] = arg.envname
117 """This strategy is always available."""
119 def execute(self, options):
120 """Sets undefined options to their environment variables."""
121 for name, envname in self.envlookup.items():
122 if getattr(options, name) is not None:
124 setattr(options, name, os.getenv(envname))
126 class ScriptsWebStrategy(Strategy):
127 """Performs scripts specific guesses for web variables."""
128 provides = frozenset(["web_host", "web_path"])
129 def __init__(self, dir):
132 """Uses :func:`wizard.scripts.get_web_host_and_path`."""
133 self._url = scripts.fill_url(self.dir, None)
136 def execute(self, options):
138 options.web_host = self._url.netloc # pylint: disable-msg=E1101
139 options.web_path = self._url.path # pylint: disable-msg=E1101
140 options.web_inferred = True # hacky: needed to see if we need a .scripts/url file
142 class ScriptsMysqlStrategy(Strategy):
144 Performs scripts specific guesses for MySQL variables. This
145 may create an appropriate database for the user.
148 provides = frozenset(["dsn"])
149 def __init__(self, application, dir):
150 self.application = application
153 """Uses :func:`wizard.scripts.get_sql_credentials`"""
154 if self.application.database != "mysql":
157 self._triplet = shell.eval("/mit/scripts/sql/bin/get-password").split()
158 except shell.CallError:
160 self._username = os.getenv('USER')
161 if self._username is None:
163 def execute(self, options):
164 """Creates a new database for the user using ``get-next-database`` and ``create-database``."""
165 host, username, password = self._triplet
167 name = shell.eval("/mit/scripts/sql/bin/get-next-database", os.path.basename(self.dir))
168 database = shell.eval("/mit/scripts/sql/bin/create-database", name)
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 WebStubArgSet(ArgSet):
238 Common arguments for any application that has an extra folder
239 necessary to place "stub" code for an application, i.e. a
240 FastCGI script. Most Python applications will require this.
244 Arg("web_stub_path", type="PATH", help="Absolute path to the directory containing the web stub"),
247 class DbArgSet(ArgSet):
248 """Common arguments for applications that use a database."""
251 Arg("dsn", type="DSN", help="Database Source Name, i.e. mysql://user:pass@host/dbname", callback=dsn_callback),
254 class AdminArgSet(ArgSet):
255 """Common arguments when an admin account is to be created."""
258 Arg("admin_name", type="NAME", help="Name of admin user to create",
259 prompt="You will be able to log in using a username of your choice. Please decide on a username and enter it below."),
260 Arg("admin_password", type="PWD", password=True, help="Password of admin user",
261 prompt="Please decide on an admin password."),
264 class EmailArgSet(ArgSet):
265 """Common arguments when an administrative email is required."""
268 Arg("email", help="Administrative email"),
271 class TitleArgSet(ArgSet):
272 """Common arguments when a title is required."""
275 Arg("title", help="Title of your new site",
276 prompt="Please decide on a title for your new website."),
279 class ArgSchema(object):
281 Schema container for arguments.
283 Valid identifiers for subclasses of :class:`ArgSet` are:
285 * ``mysql``, which populates the options ``mysql_host``, ``mysql_db``,
286 ``mysql_user`` and ``mysql_password``.
287 * ``admin``, which populates the options ``admin_name`` and
289 * ``email``, which populates the option ``email``.
290 * ``title``, which populates the option ``title``.
292 The options ``web_path`` and ``web_host`` are automatically required.
296 parser = ArgHandler("mysql", "admin", "email")
297 parser.add(Arg("title", help="Title of the new application"))
299 #: Dictionary of argument names to :class:`Arg` objects in schema.
301 #: List of :class:`Strategy` objects in schema.
303 #: Set of arguments that are already provided by :attr:`strategies`.
305 def __init__(self, *args):
307 preload_dict = preloads()
312 for arg in preload_dict[preload].args:
313 self.args[arg.name] = arg
315 raise UnrecognizedPreloads(preload)
317 """Adds an argument to our schema."""
318 self.args[arg.name] = arg
319 def commit(self, application, dir):
320 """Populates :attr:`strategies` and :attr:`provides`"""
322 self.provides = set()
323 # XXX: separate out soon
325 EnvironmentStrategy(self),
326 ScriptsWebStrategy(dir),
327 ScriptsMysqlStrategy(application, dir),
328 ScriptsEmailStrategy(),
330 for arg in self.args.values():
331 if os.getenv(arg.envname) is not None:
332 self.provides.add(arg.name)
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 any(not hasattr(options, name) for name in strategy.provides):
364 if any(hasattr(options, name) for name in strategy.provides):
365 logging.warning("Ignored partial strategy %s" % strategy)
367 if all(getattr(options, name) is not None for name in strategy.provides):
369 for name in strategy.provides:
370 if getattr(options, name) is not None:
371 logging.warning("Overriding pre-specified value for %s", name)
372 strategy.execute(options)
373 for arg in self.args.values():
374 if arg.callback is None:
376 arg.callback(options)
378 class Error(wizard.Error):
379 """Base error class for this module."""
382 class StrategyFailed(Error):
383 """Strategy couldn't figure out values."""
386 class UnrecognizedPreloads(Error):
387 """You passed a preload that was not recognized."""
388 #: The preloads that were not recognized.
390 def __init__(self, preloads):
391 self.preloads = preloads
393 return "Did not recognize these preloads: " + ", ".join(self.preloads)
395 class MissingRequiredParam(Error):
396 """You missed a required argument, and we couldn't generate it.
397 Controllers should catch this exception and provide better behavior."""
398 #: The names of the arguments that were not specified.
400 def __init__(self, args):
403 return "Missing required parameters: %s" % ', '.join(self.args)