]> scripts.mit.edu Git - wizard.git/blob - wizard/install/__init__.py
Fix bad qualification, add better error message.
[wizard.git] / wizard / install / __init__.py
1 """
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.
7
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.
17
18 .. testsetup:: *
19
20     from wizard.install import *
21 """
22
23 import os
24 import logging
25 import sqlalchemy
26 import warnings
27 import pkg_resources
28
29 import wizard
30 from wizard import deploy, shell, sql, util
31
32 def dsn_callback(options):
33     if not isinstance(options.dsn, sqlalchemy.engine.url.URL):
34         options.dsn = sqlalchemy.engine.url.make_url(options.dsn)
35     # do some guessing with sql
36     options.dsn = sql.auth(options.dsn)
37     # perform some sanity checks on the database
38     database = options.dsn.database
39     options.dsn.database = None
40     engine = sqlalchemy.create_engine(options.dsn)
41     # generates warnings http://groups.google.com/group/sqlalchemy/browse_thread/thread/b7123fefb7dd83d5
42     with warnings.catch_warnings():
43         warnings.simplefilter("ignore")
44         engine.execute("CREATE DATABASE IF NOT EXISTS `%s`" % database)
45     options.dsn.database = database
46     # XXX: another good thing to check might be that the database is empty
47
48 # XXX: This is in the wrong place
49 def fetch(options, path, post=None):
50     """
51     Fetches a web page from the autoinstall, usually to perform database
52     installation.  ``path`` is the path of the file to retrieve, relative
53     to the autoinstall base (``options.web_path``), not the web root.
54     ``post`` is a dictionary to post.  ``options`` is the options
55     object generated by :class:`OptionParser`.
56     """
57     return util.fetch(options.web_host, options.web_path, path, post)
58
59 class Strategy(object):
60     """
61     Represents a strategy for calculating arg values without user input.
62
63     Under many circumstances, making some assumptions about the server
64     environment means that we don't actually have to ask the user for values
65     such as the host or the path: these tend to be side effect free strategies.
66     Furthermore, we may have utility scripts present that can automatically
67     configure a new database for a user when one is necessary: these are side
68     effectful computations.
69
70     Note for an implementor: it is perfectly acceptable to calculate preliminary
71     results in :meth:`prepare`, store them as underscore prefixed variables,
72     and refer to them from :meth:`execute`.
73     """
74     #: Arguments that this strategy provides
75     provides = frozenset()
76     #: Whether or not this strategy has side effects.
77     side_effects = False
78     # Set at runtime
79     #: The :class:`ArgSchema` being created
80     schema = None
81     #: The :class:`wizard.app.Application` being installed.
82     application = None
83     #: The directory we are being installed to.
84     dir = None
85     #: The directory web stub files are being installed to.
86     web_stub_path = None
87     def __init__(self, schema, application, dir, web_stub_path):
88         self.schema = schema
89         self.application = application
90         self.dir = dir
91         self.web_stub_path = web_stub_path
92     def prepare(self):
93         """
94         Performs all side-effectless computation associated with this
95         strategy.  It also detects if computation is possible, and
96         raises :exc:`StrategyFailed` if it isn't.
97         """
98         raise NotImplementedError
99     def execute(self, options):
100         """
101         Performs effectful computations associated with this strategy,
102         and mutates ``options`` with the new values.  Behavior is
103         undefined if :meth:`prepare` was not called first.  If this
104         method throws an exception, it should be treated as fatal.
105         """
106         raise NotImplementedError
107
108 class EnvironmentStrategy(Strategy):
109     """
110     Fills in values from environment variables, based off of
111     :attr:`Arg.envname` from ``schema``.
112     """
113     def __init__(self, *args):
114         Strategy.__init__(self, *args)
115         self.provides = set()
116         self.envlookup = {}
117         for arg in self.schema.args.values():
118             if os.getenv(arg.envname) is not None:
119                 self.provides.add(arg.name)
120                 self.envlookup[arg.name] = arg.envname
121     def prepare(self):
122         """This strategy is always available."""
123         return True
124     def execute(self, options):
125         """Sets undefined options to their environment variables."""
126         for name, envname in self.envlookup.items():
127             if getattr(options, name) is not None:
128                 continue
129             setattr(options, name, os.getenv(envname))
130
131 class WebStrategy(Strategy):
132     """Performs guesses for web variables using the URL hook."""
133     provides = frozenset(["web_host", "web_path"])
134     def prepare(self):
135         """Uses :func:`deploy.web`."""
136         if self.dir is None:
137             raise StrategyFailed
138         if not os.path.exists(self.dir):
139             os.mkdir(self.dir)
140         try:
141             urls = deploy.web(self.dir, None)
142             if not urls:
143                 raise StrategyFailed
144             try:
145                 self._url = urls.next() # pylint: disable-msg=E1101
146             except StopIteration:
147                 raise StrategyFailed
148         except:
149             os.rmdir(self.dir)
150             raise
151     def execute(self, options):
152         """No-op."""
153         options.web_host = self._url.netloc # pylint: disable-msg=E1101
154         options.web_path = self._url.path   # pylint: disable-msg=E1101
155         options.web_inferred = True # hacky: needed to see if we need a .scripts/url file
156
157 class Arg(object):
158     """
159     Represent a required, named argument for installation.
160     """
161     #: Attribute name of the argument
162     name = None
163     #: Help string
164     help = None
165     #: String to display if prompting a user for a value
166     prompt = None
167     #: String "type" of the argument, used for metavar
168     type = None
169     #: If true, is a password
170     password = False
171     #: Callback that this argument wants to get run on options after finished
172     callback = None
173     @property
174     def envname(self):
175         """Name of the environment variable containing this arg."""
176         return 'WIZARD_' + self.name.upper()
177     def __init__(self, name, **kwargs):
178         self.name = name
179         for k,v in kwargs.items(): # cuz I'm lazy
180             if not hasattr(self, k):
181                 raise TypeError("Arg() got unexpected keyword argument '%s'" % k)
182             setattr(self, k, v)
183         if self.prompt is None:
184             self.prompt = self.help
185
186 class ArgSet(object):
187     """
188     Represents a set of named installation arguments that are required
189     for an installation to complete successfully.  Arguments in a set
190     should share a common prefix and be related in functionality (the
191     litmus test is if you need one of these arguments, you should need
192     all of them).  Register them in :func:`preloads`.
193     """
194     #: The :class:`Arg` objects that compose this argument set.
195     args = None
196     # XXX: probably could also use a callback attribute
197     def __init__(self):
198         self.args = []
199
200 class WebArgSet(ArgSet):
201     """Common arguments for any application that lives on the web."""
202     def __init__(self):
203         self.args = [
204                 Arg("web_host", type="HOST", help="Host that the application will live on"),
205                 Arg("web_path", type="PATH", help="Relative path to your application root"),
206                 ]
207
208 class DbArgSet(ArgSet):
209     """Common arguments for applications that use a database."""
210     def __init__(self):
211         self.args = [
212                 Arg("dsn", type="DSN", help="Database Source Name, i.e. mysql://user:pass@host/dbname", callback=dsn_callback),
213                 ]
214
215 class AdminArgSet(ArgSet):
216     """Common arguments when an admin account is to be created."""
217     def __init__(self):
218         self.args = [
219                 Arg("admin_name", type="NAME", help="Name of admin user to create",
220                     prompt="You will be able to log in using a username of your choice.  Please decide on a username and enter it below."),
221                 Arg("admin_password", type="PWD", password=True, help="Password of admin user",
222                     prompt="Please decide on an admin password."),
223                 ]
224
225 class EmailArgSet(ArgSet):
226     """Common arguments when an administrative email is required."""
227     def __init__(self):
228         self.args = [
229                 Arg("email", help="Administrative email"),
230                 ]
231
232 class TitleArgSet(ArgSet):
233     """Common arguments when a title is required."""
234     def __init__(self):
235         self.args = [
236                 Arg("title", help="Title of your new site",
237                     prompt="Please decide on a title for your new website."),
238                 ]
239
240 def preloads():
241     """
242     Retrieves a dictionary of string names to precanned :class:`ArgSet` objects.
243     """
244     return {
245             'web': WebArgSet(),
246             'db': DbArgSet(),
247             'admin': AdminArgSet(),
248             'email': EmailArgSet(),
249             'title': TitleArgSet(),
250             }
251
252 class ArgSchema(object):
253     """
254     Schema container for arguments.
255
256     Valid identifiers for subclasses of :class:`ArgSet` are:
257
258     * ``db``, which populates the option ``dsn``, which is a SQLAlchemy
259       database source name, with properties for ``drivername``,
260       ``username``, ``password``, ``host``, ``port``, ``database`` and
261       ``query``.
262     * ``admin``, which populates the options ``admin_name`` and
263       ``admin_password``.
264     * ``email``, which populates the option ``email``.
265     * ``title``, which populates the option ``title``.
266
267     The options ``web_path`` and ``web_host`` are automatically required.
268
269     Example::
270
271         parser = ArgHandler("db", "admin", "email")
272         parser.add(Arg("title", help="Title of the new application"))
273     """
274     #: Dictionary of argument names to :class:`Arg` objects in schema.
275     args = None
276     #: List of :class:`Strategy` objects in schema.
277     strategies = None
278     #: Set of arguments that are already provided by :attr:`strategies`.
279     provides = None
280     def __init__(self, *args):
281         self.args = {}
282         preload_dict = preloads()
283         args = list(args)
284         args.append("web")
285         for preload in args:
286             try:
287                 for arg in preload_dict[preload].args:
288                     self.args[arg.name] = arg
289             except KeyError:
290                 raise UnrecognizedPreloads(preload)
291     def add(self, arg):
292         """Adds an argument to our schema."""
293         self.args[arg.name] = arg
294     def commit(self, application, dir, web_stub_path):
295         """Populates :attr:`strategies` and :attr:`provides`"""
296         self.strategies = []
297         self.provides = set()
298         raw_strategies = [
299                 EnvironmentStrategy,
300                 WebStrategy,
301                 ]
302         for entry in pkg_resources.iter_entry_points("wizard.install.strategy"):
303             cls = entry.load()
304             raw_strategies.append(cls)
305         for strategy_cls in raw_strategies:
306             try:
307                 strategy = strategy_cls(self, application, dir, web_stub_path)
308                 strategy.prepare()
309                 self.provides |= strategy.provides
310                 self.strategies.append(strategy)
311             except StrategyFailed:
312                 pass
313         # do non-effectful strategies first; this is a stable sort
314         self.strategies.sort(key=lambda x: x.side_effects)
315     def fill(self, options):
316         """
317         Fills an object with all arguments pre-set
318         to ``None``.
319         """
320         for i in self.args:
321             if not hasattr(options, i):
322                 setattr(options, i, None)
323     def load(self, options):
324         """
325         Load values from strategy.  Must be called after :meth:`commit`.  We
326         omit strategies whose provided variables are completely specified
327         already.  Will raise :exc:`MissingRequiredParam` if strategies aren't
328         sufficient to fill all options.  It will then run any callbacks on
329         arguments.
330         """
331         unfilled = set(name for name in self.args if getattr(options, name) is None)
332         missing = unfilled - self.provides
333         if missing:
334             raise MissingRequiredParam(missing)
335         for strategy in self.strategies:
336             # If the application being installed doesn't need all of the
337             # parameters a strategy could provide, we don't use it.
338             if any(not hasattr(options, name) for name in strategy.provides):
339                 if any(hasattr(options, name) for name in strategy.provides):
340                     logging.warning("Ignored partial strategy %s" % strategy)
341                 continue
342             if all(getattr(options, name) is not None for name in strategy.provides):
343                 continue
344             for name in strategy.provides:
345                 if getattr(options, name) is not None:
346                     logging.warning("Overriding pre-specified value for %s", name)
347             strategy.execute(options)
348         for arg in self.args.values():
349             if arg.callback is None:
350                 continue
351             arg.callback(options)
352
353 class Error(wizard.Error):
354     """Base error class for this module."""
355     pass
356
357 class StrategyFailed(Error):
358     """Strategy couldn't figure out values."""
359     pass
360
361 class UnrecognizedPreloads(Error):
362     """You passed a preload that was not recognized."""
363     #: The preloads that were not recognized.
364     preloads = None
365     def __init__(self, preloads):
366         self.preloads = preloads
367     def __str__(self):
368         return "Did not recognize these preloads: " + ", ".join(self.preloads)
369
370 class MissingRequiredParam(Error):
371     """You missed a required argument, and we couldn't generate it.
372     Controllers should catch this exception and provide better behavior."""
373     #: The names of the arguments that were not specified.
374     args = None
375     def __init__(self, args):
376         self.args = args
377     def __str__(self):
378         return "Missing required parameters: %s" % ', '.join(self.args)