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
39 elif isinstance(show, str):
40 # otherwise, frozenset will treat string as an iterable
41 show = frozenset([show])
43 show = frozenset(show)
44 for line in get_install_lines(versions_store):
47 d = Deployment.parse(line)
48 name = d.application.name
49 except NoSuchApplication as e:
54 # we consider this a worse error
55 logging.warning("Error with '%s'" % line.rstrip())
58 if name + "-" + str(d.version) in show or name in show:
65 ## -- Model Objects --
67 class Deployment(object):
69 Represents a deployment of an autoinstall, e.g. directory in a user's
70 web_scripts that has ``.scripts`` directory or ``.scripts-version``
71 file in it. Supply ``version`` with an :class:`ApplicationVersion` only if
72 you were reading from the :term:`versions store` and care about
73 speed (data from there can be stale).
75 #: Absolute path to the deployment
77 def __init__(self, location, version=None):
78 self.location = os.path.abspath(location)
79 self._app_version = version
80 # some cache variables
83 def read(self, file, force = False):
85 Reads a file's contents, possibly from cache unless ``force``
88 if force or file not in self._read_cache:
89 f = open(os.path.join(self.location, file))
90 self._read_cache[file] = f.read()
92 return self._read_cache[file]
95 Extracts all the values of all variables from deployment.
96 These variables may be used for parametrizing generic parent
97 commits and include things such as database access credentials
98 and local configuration.
100 return self.application.extract(self)
101 def parametrize(self, dir):
103 Edits files in ``dir`` to replace WIZARD_* variables with literal
104 instances. This is used for constructing virtual merge bases, and
105 as such dir will generally not equal :attr:`location`.
107 return self.application.parametrize(self, dir)
108 def prepareConfig(self):
110 Edits files in the deployment such that any user-specific configuration
111 is replaced with generic WIZARD_* variables.
113 return self.application.prepareConfig(self)
114 def checkConfig(self, deployment):
116 Checks if the application is configured.
122 Checks if this is an autoinstall, throws an exception if there
125 with util.ChangeDirectory(self.location):
126 has_git = os.path.isdir(".git")
127 has_scripts = os.path.isdir(".scripts")
128 if not has_git and has_scripts:
129 raise CorruptedAutoinstallError(self.location)
130 elif has_git and not has_scripts:
131 raise AlreadyVersionedError(self.location)
132 elif not has_git and not has_scripts:
133 if os.path.isfile(".scripts-version"):
134 raise NotMigratedError(self.location)
136 def verifyTag(self, srv_path):
138 Checks if the purported version has a corresponding tag
139 in the upstream repository.
141 repo = self.application.repository(srv_path)
143 shell.Shell().eval("git", "--git-dir", repo, "rev-parse", self.app_version.scripts_tag, '--')
144 except shell.CallError:
145 raise NoTagError(self.app_version.scripts_tag)
147 def verifyGit(self, srv_path):
149 Checks if the autoinstall's Git repository makes sense,
150 checking if the tag is parseable and corresponds to
151 a real application, and if the tag in this repository
152 corresponds to the one in the remote repository.
154 with util.ChangeDirectory(self.location):
156 repo = self.application.repository(srv_path)
157 def repo_rev_parse(tag):
158 return sh.eval("git", "--git-dir", repo, "rev-parse", tag)
159 def self_rev_parse(tag):
161 return sh.safeCall("git", "rev-parse", tag, strip=True)
162 except shell.CallError:
163 raise NoLocalTagError(tag)
164 def compare_tags(tag):
165 return repo_rev_parse(tag) == self_rev_parse(tag)
166 if not compare_tags(self.app_version.pristine_tag):
167 raise InconsistentPristineTagError(self.app_version.pristine_tag)
168 if not compare_tags(self.app_version.scripts_tag):
169 raise InconsistentScriptsTagError(self.app_version.scripts_tag)
170 parent = repo_rev_parse(self.app_version.scripts_tag)
171 merge_base = sh.safeCall("git", "merge-base", parent, "HEAD", strip=True)
172 if merge_base != parent:
173 raise HeadNotDescendantError(self.app_version.scripts_tag)
175 def verifyConfigured(self):
177 Checks if the autoinstall is configured running.
179 if not self.configured:
180 raise NotConfiguredError(self.location)
182 def verifyVersion(self):
184 Checks if our version and the version number recorded in a file
187 real = self.application.detectVersion(self)
189 raise VersionDetectionError
190 elif not str(real) == self.app_version.pristine_tag.partition('-')[2]:
191 raise VersionMismatchError(real, self.version)
194 def configured(self):
195 """Whether or not an autoinstall has been configured/installed for use."""
196 return self.application.checkConfig(self)
199 """Whether or not the autoinstalls has been migrated."""
200 return os.path.isdir(self.scripts_dir)
202 def scripts_dir(self):
203 """The absolute path of the ``.scripts`` directory."""
204 return os.path.join(self.location, '.scripts')
206 def old_version_file(self):
208 The absolute path of either ``.scripts-version`` (for unmigrated
209 installs) or ``.scripts/version``.
213 Use of this is discouraged for migrated installs.
215 return os.path.join(self.location, '.scripts-version')
217 def version_file(self):
218 """The absolute path of the ``.scripts/version`` file."""
219 return os.path.join(self.scripts_dir, 'version')
221 def application(self):
222 """The :class:`Application` of this deployment."""
223 return self.app_version.application
227 The :class:`wizard.old_log.Log` of this deployment. This
228 is only applicable to un-migrated autoinstalls.
230 if not self._old_log:
231 self._old_log = old_log.DeployLog.load(self)
236 The :class:`distutils.version.LooseVersion` of this
239 return self.app_version.version
241 def app_version(self):
242 """The :class:`ApplicationVersion` of this deployment."""
243 if not self._app_version:
244 if os.path.isdir(os.path.join(self.location, ".git")):
246 with util.ChangeDirectory(self.location):
247 appname, _, version = git.describe().partition('-')
248 self._app_version = ApplicationVersion.make(appname, version)
249 except shell.CallError:
251 if not self._app_version:
252 self._app_version = self.old_log[-1].version
253 return self._app_version
257 Parses a line from the :term:`versions store`.
261 Use this method only when speed is of the utmost
262 importance. You should prefer to directly create a deployment
263 with only a ``location`` when possible.
267 location, deploydir = line.split(":")
269 return Deployment(line) # lazy loaded version
271 return Deployment(location, version=ApplicationVersion.parse(deploydir))
273 e.location = location
276 class Application(object):
277 """Represents an application, i.e. mediawiki or phpbb."""
278 #: String name of the application
280 #: Dictionary of version strings to :class:`ApplicationVersion`.
281 #: See also :meth:`makeVersion`.
283 #: List of files that need to be modified when parametrizing.
284 #: This is a class-wide constant, and should not normally be modified.
285 parametrized_files = []
286 def __init__(self, name):
290 self._extractors = {}
291 self._substitutions = {}
292 def repository(self, srv_path):
294 Returns the Git repository that would contain this application.
295 ``srv_path`` corresponds to ``options.srv_path`` from the global baton.
297 repo = os.path.join(srv_path, self.name + ".git")
298 if not os.path.isdir(repo):
299 repo = os.path.join(srv_path, self.name, ".git")
300 if not os.path.isdir(repo):
301 raise NoRepositoryError(self.name)
303 def makeVersion(self, version):
305 Creates or retrieves the :class:`ApplicationVersion` singleton for the
308 if version not in self.versions:
309 self.versions[version] = ApplicationVersion(distutils.version.LooseVersion(version), self)
310 return self.versions[version]
311 def extract(self, deployment):
312 """Extracts wizard variables from a deployment."""
314 for k,extractor in self.extractors.items():
315 result[k] = extractor(deployment)
317 def parametrize(self, deployment, dir):
319 Takes a generic source checkout at dir and parametrizes
320 it according to the values of deployment.
322 variables = deployment.extract()
323 for file in self.parametrized_files:
324 fullpath = os.path.join(dir, file)
326 contents = open(fullpath, "r").read()
329 for key, value in variables.items():
330 if value is None: continue
331 contents = contents.replace(key, value)
332 tmp = tempfile.NamedTemporaryFile(delete=False)
334 os.rename(tmp.name, fullpath)
335 def prepareConfig(self, deployment):
337 Takes a deployment and replaces any explicit instances
338 of a configuration variable with generic WIZARD_* constants.
339 There is a sane default implementation built on substitutions;
340 you can override this method to provide arbitrary extra
343 for key, subst in self.substitutions.items():
344 subs = subst(deployment)
345 if not subs and key not in self.deprecated_keys:
346 logging.warning("No substitutions for %s" % key)
347 def install(self, options):
349 Run for 'wizard configure' (and, by proxy, 'wizard install')
350 to configure an application.
353 def upgrade(self, options):
355 Run for 'wizard upgrade' to upgrade database schemas and other
356 non-versioned data in an application.
359 def detectVersion(self, deployment):
361 Checks source files to determine the version manually.
365 def extractors(self):
367 Dictionary of variable names to extractor functions. These functions
368 take a :class:`Deployment` as an argument and return the value of
369 the variable, or ``None`` if it could not be found.
370 See also :func:`wizard.app.filename_regex_extractor`.
374 def substitutions(self):
376 Dictionary of variable names to substitution functions. These functions
377 take a :class:`Deployment` as an argument and modify the deployment such
378 that an explicit instance of the variable is released with the generic
379 WIZARD_* constant. See also :func:`wizard.app.filename_regex_substitution`.
384 """Makes an application, but uses the correct subtype if available."""
386 __import__("wizard.app." + name)
387 return getattr(wizard.app, name).Application(name)
389 return Application(name)
391 class ApplicationVersion(object):
392 """Represents an abstract notion of a version for an application, where
393 ``version`` is a :class:`distutils.version.LooseVersion` and
394 ``application`` is a :class:`Application`."""
395 #: The :class:`distutils.version.LooseVersion` of this instance.
397 #: The :class:`Application` of this instance.
399 def __init__(self, version, application):
400 self.version = version
401 self.application = application
405 Returns the name of the git describe tag for the commit the user is
406 presently on, something like mediawiki-1.2.3-scripts-4-g123abcd
408 return "%s-%s" % (self.application, self.version)
410 def scripts_tag(self):
412 Returns the name of the Git tag for this version.
414 end = str(self.version).partition('-scripts')[2].partition('-')[0]
415 return "%s-scripts%s" % (self.pristine_tag, end)
417 def pristine_tag(self):
419 Returns the name of the Git tag for the pristine version corresponding
422 return "%s-%s" % (self.application.name, str(self.version).partition('-scripts')[0])
424 return cmp(x.version, y.version)
428 Parses a line from the :term:`versions store` and return
429 :class:`ApplicationVersion`.
431 Use this only for cases when speed is of primary importance;
432 the data in version is unreliable and when possible, you should
433 prefer directly instantiating a Deployment and having it query
434 the autoinstall itself for information.
436 The `value` to parse will vary. For old style installs, it
439 /afs/athena.mit.edu/contrib/scripts/deploy/APP-x.y.z
441 For new style installs, it will look like::
445 name = value.split("/")[-1]
447 if name.find("-") != -1:
448 app, _, version = name.partition("-")
450 # kind of poor, maybe should error. Generally this
451 # will actually result in a not found error
455 raise DeploymentParseError(deploydir)
456 return ApplicationVersion.make(app, version)
458 def make(app, version):
460 Makes/retrieves a singleton :class:`ApplicationVersion` from
461 a``app`` and ``version`` string.
464 # defer to the application for version creation to enforce
466 return applications()[app].makeVersion(version)
468 raise NoSuchApplication(app)
472 class Error(wizard.Error):
473 """Base error class for this module"""
476 class NoSuchApplication(Error):
478 You attempted to reference a :class:`Application` named
479 ``app``, which is not recognized by Wizard.
481 #: The name of the application that does not exist.
483 #: The location of the autoinstall that threw this variable.
484 #: This should be set by error handling code when it is availble.
486 def __init__(self, app):
489 class DeploymentParseError(Error):
491 Could not parse ``value`` from :term:`versions store`.
493 #: The value that failed to parse.
495 #: The location of the autoinstall that threw this variable.
496 #: This should be set by error handling code when it is available.
498 def __init__(self, value):
501 class NoRepositoryError(Error):
503 :class:`Application` does not appear to have a Git repository
504 in the normal location.
506 #: The name of the application that does not have a Git repository.
508 def __init__(self, app):
511 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
513 class NotMigratedError(Error):
515 The deployment contains a .scripts-version file, but no .git
516 or .scripts directory.
518 #: Directory of deployment
520 def __init__(self, dir):
523 return """This installation was not migrated"""
525 class AlreadyVersionedError(Error):
526 """The deployment contained a .git directory but no .scripts directory."""
527 #: Directory of deployment
529 def __init__(self, dir):
534 ERROR: Directory contains a .git directory, but not
535 a .scripts directory. If this is not a corrupt
536 migration, this means that the user was versioning their
537 install using Git."""
539 class NotConfiguredError(Error):
540 """The install was missing essential configuration."""
541 #: Directory of unconfigured install
543 def __init__(self, dir):
548 ERROR: The install was well-formed, but not configured
549 (essential configuration files were not found.)"""
551 class CorruptedAutoinstallError(Error):
552 """The install was missing a .git directory, but had a .scripts directory."""
553 #: Directory of the corrupted install
555 def __init__(self, dir):
560 ERROR: Directory contains a .scripts directory,
561 but not a .git directory."""
563 class NotAutoinstallError(Error):
564 """The directory was not an autoinstall, due to missing .scripts-version file."""
565 #: Directory in question
567 def __init__(self, dir):
572 ERROR: Could not find .scripts-version file. Are you sure
573 this is an autoinstalled application?
576 class NoTagError(Error):
577 """Deployment has a tag that does not have an equivalent in upstream repository."""
580 def __init__(self, tag):
585 ERROR: Could not find tag %s in repository.""" % self.tag
587 class NoLocalTagError(Error):
588 """Could not find tag in local repository."""
591 def __init__(self, tag):
596 ERROR: Could not find tag %s in local repository.""" % self.tag
598 class InconsistentPristineTagError(Error):
599 """Pristine tag commit ID does not match upstream pristine tag commit ID."""
602 def __init__(self, tag):
607 ERROR: Local pristine tag %s did not match repository's. This
608 probably means an upstream rebase occured.""" % self.tag
610 class InconsistentScriptsTagError(Error):
611 """Scripts tag commit ID does not match upstream scripts tag commit ID."""
614 def __init__(self, tag):
619 ERROR: Local scripts tag %s did not match repository's. This
620 probably means an upstream rebase occurred.""" % self.tag
622 class HeadNotDescendantError(Error):
623 """HEAD is not connected to tag."""
624 #: Tag that HEAD should have been descendant of.
626 def __init__(self, tag):
631 ERROR: HEAD is not a descendant of %s. This probably
632 means that an upstream rebase occurred, and new tags were
633 pulled, but local user commits were never rebased.""" % self.tag
635 class VersionDetectionError(Error):
636 """Could not detect real version of application."""
640 ERROR: Could not detect the real version of the application."""
642 class VersionMismatchError(Error):
643 """Git version of application does not match detected version."""
648 def __init__(self, real_version, git_version):
649 self.real_version = real_version
650 self.git_version = git_version
654 ERROR: The detected version %s did not match the Git
655 version %s.""" % (self.real_version, self.git_version)
657 _application_list = [
658 "mediawiki", "wordpress", "joomla", "e107", "gallery2",
659 "phpBB", "advancedbook", "phpical", "trac", "turbogears", "django",
660 # these are technically deprecated
661 "advancedpoll", "gallery",
666 """Hash table for looking up string application name to instance"""
668 if not _applications:
669 _applications = dict([(n,Application.make(n)) for n in _application_list ])