2 Object model for querying information and manipulating deployments
3 of autoinstalls. Every :class:`Deployment` has an :class:`ApplicationVersion`
4 which in turn has an :class:`Application`.
10 import distutils.version
15 from wizard import git, old_log, shell, util
17 ## -- Global Functions --
19 def get_install_lines(versions_store):
21 Low level function that retrieves a list of lines from the
22 :term:`versions store` that can be passed to :meth:`Deployment.parse`.
24 if os.path.isfile(versions_store):
25 return fileinput.input([versions_store])
26 return fileinput.input([versions_store + "/" + f for f in sorted(os.listdir(versions_store))])
28 def parse_install_lines(show, versions_store, yield_errors = False):
30 Generator function for iterating through all autoinstalls.
31 Each item is an instance of :class:`Deployment`, or possibly
32 a :class:`wizard.deploy.Error` if ``yield_errors`` is ``True``. You can
33 filter out applications and versions by specifying ``app``
34 or ``app-1.2.3`` in ``show``. This function may generate
35 log output. Make sure that ``show`` is a list and not a string.
37 if not show: show = applications()
38 show = frozenset(show)
39 for line in get_install_lines(versions_store):
42 d = Deployment.parse(line)
43 name = d.application.name
44 except NoSuchApplication as e:
49 # we consider this a worse error
50 logging.warning("Error with '%s'" % line.rstrip())
53 if name + "-" + str(d.version) in show or name in show:
60 ## -- Model Objects --
62 class Deployment(object):
64 Represents a deployment of an autoinstall, e.g. directory in a user's
65 web_scripts that has ``.scripts`` directory or ``.scripts-version``
66 file in it. Supply ``version`` with an :class:`ApplicationVersion` only if
67 you were reading from the :term:`versions store` and care about
68 speed (data from there can be stale).
70 #: Absolute path to the deployment
72 def __init__(self, location, version=None):
73 self.location = os.path.abspath(location)
74 self._app_version = version
75 # some cache variables
78 def read(self, file, force = False):
80 Reads a file's contents, possibly from cache unless ``force``
83 if force or file not in self._read_cache:
84 f = open(os.path.join(self.location, file))
85 self._read_cache[file] = f.read()
87 return self._read_cache[file]
90 Extracts all the values of all variables from deployment.
91 These variables may be used for parametrizing generic parent
92 commits and include things such as database access credentials
93 and local configuration.
95 return self.application.extract(self)
96 def parametrize(self, dir):
98 Edits files in ``dir`` to replace WIZARD_* variables with literal
99 instances. This is used for constructing virtual merge bases, and
100 as such dir will generally not equal :attr:`location`.
102 return self.application.parametrize(self, dir)
103 def prepareConfig(self):
105 Edits files in the deployment such that any user-specific configuration
106 is replaced with generic WIZARD_* variables.
108 return self.application.prepareConfig(self)
109 def checkConfig(self, deployment):
111 Checks if the application is configured.
117 Checks if this is an autoinstall, throws an exception if there
120 with util.ChangeDirectory(self.location):
121 has_git = os.path.isdir(".git")
122 has_scripts = os.path.isdir(".scripts")
123 if not has_git and has_scripts:
124 raise CorruptedAutoinstallError(self.location)
125 elif has_git and not has_scripts:
126 raise AlreadyVersionedError(self.location)
127 elif not has_git and not has_scripts:
128 if os.path.isfile(".scripts-version"):
129 raise NotMigratedError(self.location)
131 def verifyTag(self, srv_path):
133 Checks if the purported version has a corresponding tag
134 in the upstream repository.
136 repo = self.application.repository(srv_path)
138 shell.Shell().eval("git", "--git-dir", repo, "rev-parse", self.app_version.scripts_tag, '--')
139 except shell.CallError:
140 raise NoTagError(self.app_version.scripts_tag)
142 def verifyGit(self, srv_path):
144 Checks if the autoinstall's Git repository makes sense,
145 checking if the tag is parseable and corresponds to
146 a real application, and if the tag in this repository
147 corresponds to the one in the remote repository.
149 with util.ChangeDirectory(self.location):
151 repo = self.application.repository(srv_path)
152 def repo_rev_parse(tag):
153 return sh.eval("git", "--git-dir", repo, "rev-parse", tag)
154 def self_rev_parse(tag):
156 return sh.safeCall("git", "rev-parse", tag, strip=True)
157 except shell.CallError:
158 raise NoLocalTagError(tag)
159 def compare_tags(tag):
160 return repo_rev_parse(tag) == self_rev_parse(tag)
161 if not compare_tags(self.app_version.pristine_tag):
162 raise InconsistentPristineTagError(self.app_version.pristine_tag)
163 if not compare_tags(self.app_version.scripts_tag):
164 raise InconsistentScriptsTagError(self.app_version.scripts_tag)
165 parent = repo_rev_parse(self.app_version.scripts_tag)
166 merge_base = sh.safeCall("git", "merge-base", parent, "HEAD", strip=True)
167 if merge_base != parent:
168 raise HeadNotDescendantError(self.app_version.scripts_tag)
170 def verifyConfigured(self):
172 Checks if the autoinstall is configured running.
174 if not self.configured:
175 raise NotConfiguredError(self.location)
178 def configured(self):
179 """Whether or not an autoinstall has been configured/installed for use."""
180 return self.application.checkConfig(self)
183 """Whether or not the autoinstalls has been migrated."""
184 return os.path.isdir(self.scripts_dir)
186 def scripts_dir(self):
187 """The absolute path of the ``.scripts`` directory."""
188 return os.path.join(self.location, '.scripts')
190 def old_version_file(self):
192 The absolute path of either ``.scripts-version`` (for unmigrated
193 installs) or ``.scripts/version``.
197 Use of this is discouraged for migrated installs.
199 return os.path.join(self.location, '.scripts-version')
201 def version_file(self):
202 """The absolute path of the ``.scripts/version`` file."""
203 return os.path.join(self.scripts_dir, 'version')
205 def application(self):
206 """The :class:`Application` of this deployment."""
207 return self.app_version.application
211 The :class:`wizard.old_log.Log` of this deployment. This
212 is only applicable to un-migrated autoinstalls.
214 if not self._old_log:
215 self._old_log = old_log.DeployLog.load(self)
220 The :class:`distutils.version.LooseVersion` of this
223 return self.app_version.version
225 def app_version(self):
226 """The :class:`ApplicationVersion` of this deployment."""
227 if not self._app_version:
228 if os.path.isdir(os.path.join(self.location, ".git")):
230 with util.ChangeDirectory(self.location):
231 appname, _, version = git.describe().partition('-')
232 self._app_version = ApplicationVersion.make(appname, version)
233 except shell.CallError:
235 if not self._app_version:
236 self._app_version = self.old_log[-1].version
237 return self._app_version
241 Parses a line from the :term:`versions store`.
245 Use this method only when speed is of the utmost
246 importance. You should prefer to directly create a deployment
247 with only a ``location`` when possible.
251 location, deploydir = line.split(":")
253 return Deployment(line) # lazy loaded version
255 return Deployment(location, version=ApplicationVersion.parse(deploydir))
257 e.location = location
260 class Application(object):
261 """Represents an application, i.e. mediawiki or phpbb."""
262 #: String name of the application
264 #: Dictionary of version strings to :class:`ApplicationVersion`.
265 #: See also :meth:`makeVersion`.
267 #: List of files that need to be modified when parametrizing.
268 #: This is a class-wide constant, and should not normally be modified.
269 parametrized_files = []
270 def __init__(self, name):
274 self._extractors = {}
275 self._substitutions = {}
276 def repository(self, srv_path):
278 Returns the Git repository that would contain this application.
279 ``srv_path`` corresponds to ``options.srv_path`` from the global baton.
281 repo = os.path.join(srv_path, self.name + ".git")
282 if not os.path.isdir(repo):
283 repo = os.path.join(srv_path, self.name, ".git")
284 if not os.path.isdir(repo):
285 raise NoRepositoryError(self.name)
287 def makeVersion(self, version):
289 Creates or retrieves the :class:`ApplicationVersion` singleton for the
292 if version not in self.versions:
293 self.versions[version] = ApplicationVersion(distutils.version.LooseVersion(version), self)
294 return self.versions[version]
295 def extract(self, deployment):
296 """Extracts wizard variables from a deployment."""
298 for k,extractor in self.extractors.items():
299 result[k] = extractor(deployment)
301 def parametrize(self, deployment, dir):
303 Takes a generic source checkout at dir and parametrizes
304 it according to the values of deployment.
306 variables = deployment.extract()
307 for file in self.parametrized_files:
308 fullpath = os.path.join(dir, file)
310 contents = open(fullpath, "r").read()
313 for key, value in variables.items():
314 if value is None: continue
315 contents = contents.replace(key, value)
316 tmp = tempfile.NamedTemporaryFile(delete=False)
318 os.rename(tmp.name, fullpath)
319 def prepareConfig(self, deployment):
321 Takes a deployment and replaces any explicit instances
322 of a configuration variable with generic WIZARD_* constants.
323 There is a sane default implementation built on substitutions;
324 you can override this method to provide arbitrary extra
327 for key, subst in self.substitutions.items():
328 subs = subst(deployment)
329 if not subs and key not in self.deprecated_keys:
330 logging.warning("No substitutions for %s" % key)
331 def install(self, options):
333 Run for 'wizard configure' (and, by proxy, 'wizard install')
334 to configure an application.
337 def upgrade(self, options):
339 Run for 'wizard upgrade' to upgrade database schemas and other
340 non-versioned data in an application.
344 def extractors(self):
346 Dictionary of variable names to extractor functions. These functions
347 take a :class:`Deployment` as an argument and return the value of
348 the variable, or ``None`` if it could not be found.
349 See also :func:`wizard.app.filename_regex_extractor`.
353 def substitutions(self):
355 Dictionary of variable names to substitution functions. These functions
356 take a :class:`Deployment` as an argument and modify the deployment such
357 that an explicit instance of the variable is released with the generic
358 WIZARD_* constant. See also :func:`wizard.app.filename_regex_substitution`.
363 """Makes an application, but uses the correct subtype if available."""
365 __import__("wizard.app." + name)
366 return getattr(wizard.app, name).Application(name)
368 return Application(name)
370 class ApplicationVersion(object):
371 """Represents an abstract notion of a version for an application, where
372 ``version`` is a :class:`distutils.version.LooseVersion` and
373 ``application`` is a :class:`Application`."""
374 #: The :class:`distutils.version.LooseVersion` of this instance.
376 #: The :class:`Application` of this instance.
378 def __init__(self, version, application):
379 self.version = version
380 self.application = application
384 Returns the name of the git describe tag for the commit the user is
385 presently on, something like mediawiki-1.2.3-scripts-4-g123abcd
387 return "%s-%s" % (self.application, self.version)
389 def scripts_tag(self):
391 Returns the name of the Git tag for this version.
393 end = str(self.version).partition('-scripts')[2].partition('-')[0]
394 return "%s-scripts%s" % (self.pristine_tag, end)
396 def pristine_tag(self):
398 Returns the name of the Git tag for the pristine version corresponding
401 return "%s-%s" % (self.application.name, str(self.version).partition('-scripts')[0])
403 return cmp(x.version, y.version)
407 Parses a line from the :term:`versions store` and return
408 :class:`ApplicationVersion`.
410 Use this only for cases when speed is of primary importance;
411 the data in version is unreliable and when possible, you should
412 prefer directly instantiating a Deployment and having it query
413 the autoinstall itself for information.
415 The `value` to parse will vary. For old style installs, it
418 /afs/athena.mit.edu/contrib/scripts/deploy/APP-x.y.z
420 For new style installs, it will look like::
424 name = value.split("/")[-1]
426 if name.find("-") != -1:
427 app, _, version = name.partition("-")
429 # kind of poor, maybe should error. Generally this
430 # will actually result in a not found error
434 raise DeploymentParseError(deploydir)
435 return ApplicationVersion.make(app, version)
437 def make(app, version):
439 Makes/retrieves a singleton :class:`ApplicationVersion` from
440 a``app`` and ``version`` string.
443 # defer to the application for version creation to enforce
445 return applications()[app].makeVersion(version)
447 raise NoSuchApplication(app)
451 class Error(wizard.Error):
452 """Base error class for this module"""
455 class NoSuchApplication(Error):
457 You attempted to reference a :class:`Application` named
458 ``app``, which is not recognized by Wizard.
460 #: The name of the application that does not exist.
462 #: The location of the autoinstall that threw this variable.
463 #: This should be set by error handling code when it is availble.
465 def __init__(self, app):
468 class DeploymentParseError(Error):
470 Could not parse ``value`` from :term:`versions store`.
472 #: The value that failed to parse.
474 #: The location of the autoinstall that threw this variable.
475 #: This should be set by error handling code when it is available.
477 def __init__(self, value):
480 class NoRepositoryError(Error):
482 :class:`Application` does not appear to have a Git repository
483 in the normal location.
485 #: The name of the application that does not have a Git repository.
487 def __init__(self, app):
490 return """Could not find Git repository for '%s'. If you would like to use a local version, try specifying --srv-path or WIZARD_SRV_PATH.""" % self.app
492 class NotMigratedError(Error):
494 The deployment contains a .scripts-version file, but no .git
495 or .scripts directory.
497 def __init__(self, dir):
500 return """This installation was not migrated"""
502 class AlreadyVersionedError(Error):
503 def __init__(self, dir):
508 ERROR: Directory contains a .git directory, but not
509 a .scripts directory. If this is not a corrupt
510 migration, this means that the user was versioning their
511 install using Git."""
513 class NotConfiguredError(Error):
514 def __init__(self, dir):
519 ERROR: The install was well-formed, but not configured
520 (essential configuration files were not found.)"""
522 class CorruptedAutoinstallError(Error):
523 def __init__(self, dir):
528 ERROR: Directory contains a .scripts directory,
529 but not a .git directory."""
531 class NotAutoinstallError(Error):
532 def __init__(self, dir):
537 ERROR: Could not find .scripts-version file. Are you sure
538 this is an autoinstalled application?
541 class NoTagError(Error):
542 def __init__(self, tag):
547 ERROR: Could not find tag %s in repository.""" % self.tag
549 class NoLocalTagError(Error):
550 def __init__(self, tag):
555 ERROR: Could not find tag %s in local repository.""" % self.tag
557 class InconsistentPristineTagError(Error):
558 def __init__(self, tag):
563 ERROR: Local pristine tag %s did not match repository's. This
564 probably means an upstream rebase occured.""" % self.tag
566 class InconsistentScriptsTagError(Error):
567 def __init__(self, tag):
572 ERROR: Local scripts tag %s did not match repository's. This
573 probably means an upstream rebase occurred.""" % self.tag
575 class HeadNotDescendantError(Error):
576 def __init__(self, tag):
581 ERROR: HEAD is not a descendant of %s. This probably
582 means that an upstream rebase occurred, and new tags were
583 pulled, but local user commits were never rebased.""" % self.tag
585 _application_list = [
586 "mediawiki", "wordpress", "joomla", "e107", "gallery2",
587 "phpBB", "advancedbook", "phpical", "trac", "turbogears", "django",
588 # these are technically deprecated
589 "advancedpoll", "gallery",
594 """Hash table for looking up string application name to instance"""
596 if not _applications:
597 _applications = dict([(n,Application.make(n)) for n in _application_list ])