Factor out the Subversion wrapper to a separate module.
authorDavid Anderson <david.jc.anderson@gmail.com>
Sat, 14 Mar 2009 03:49:16 +0000
changeset 1849 f8728d5e2e07
parent 1848 a0cae3be1412
child 1850 1e39b32ff4d7
Factor out the Subversion wrapper to a separate module.
scripts/release/error.py
scripts/release/release.py
scripts/release/subversion.py
--- a/scripts/release/error.py	Sat Mar 14 00:12:22 2009 +0000
+++ b/scripts/release/error.py	Sat Mar 14 03:49:16 2009 +0000
@@ -20,3 +20,11 @@
 
 class Error(Exception):
   """Base class for release script exceptions."""
+
+
+class ObstructionError(Error):
+  """An operation was obstructed by existing data."""
+
+
+class ExpectationFailed(Error):
+  """An unexpected state was encountered by an automated step."""
--- a/scripts/release/release.py	Sat Mar 14 00:12:22 2009 +0000
+++ b/scripts/release/release.py	Sat Mar 14 03:49:16 2009 +0000
@@ -49,6 +49,7 @@
 
 import error
 import log
+import subversion
 import util
 
 
@@ -71,14 +72,6 @@
   """The operation was aborted by the user."""
 
 
-class ObstructionError(Error):
-  """An operation was obstructed by existing data."""
-
-
-class ExpectationFailed(Error):
-  """An unexpected state was encountered by an automated step."""
-
-
 class FileAccessError(Error):
   """An error occured while accessing a file."""
 
@@ -191,285 +184,6 @@
     raise FileAccessError(str(e))
 
 
-class Subversion(util.Paths):
-  """Wrapper for operations on a Subversion working copy.
-
-  An instance of this class is bound to a specific working copy
-  directory, and provides an API to perform various Subversion
-  operations on this working copy.
-
-  Some methods take a 'depth' argument. Depth in Subversion is a
-  feature that allows the creation of arbitrarily shallow or deep
-  working copies on a per-directory basis. Possible values are
-  'none' (no files or directories), 'files' (only files in .),
-  'immediates' (files and directories in ., directories checked out
-  at depth 'none') or 'infinity' (a normal working copy with
-  everything).
-
-  This class also provides a few static functions that run the 'svn'
-  tool against remote repositories to gather information or retrieve
-  data.
-
-  Note that this wrapper also doubles as a Paths object, offering an
-  easy way to get or check the existence of paths in the working
-  copy.
-  """
-
-  def __init__(self, wc_dir):
-    util.Paths.__init__(self, wc_dir)
-
-  def _unknownAndMissing(self, path):
-    """Returns lists of unknown and missing files in the working copy.
-
-    Args:
-      path: The working copy path to scan.
-
-    Returns:
-
-      Two lists. The first is a list of all unknown paths
-      (subversion has no knowledge of them), the second is a list
-      of missing paths (subversion knows about them, but can't
-      find them). Paths in either list are relative to the input
-      path.
-    """
-    assert self.exists()
-    unknown = []
-    missing = []
-    for line in self.status(path):
-      if not line.strip():
-        continue
-      if line[0] == '?':
-        unknown.append(line[7:])
-      elif line[0] == '!':
-        missing.append(line[7:])
-    return unknown, missing
-
-  def checkout(self, url, depth='infinity'):
-    """Check out a working copy from the given URL.
-
-    Args:
-      url: The Subversion repository URL to check out.
-      depth: The depth of the working copy root.
-    """
-    assert not self.exists()
-    util.run(['svn', 'checkout', '--depth=' + depth, url, self.path()])
-
-  def update(self, path='', depth=None):
-    """Update a working copy path, optionally changing depth.
-
-    Args:
-      path: The working copy path to update.
-      depth: If set, change the depth of the path before updating.
-    """
-    assert self.exists()
-    if depth is None:
-      util.run(['svn', 'update', self.path(path)])
-    else:
-      util.run(['svn', 'update', '--set-depth=' + depth, self.path(path)])
-
-  def revert(self, path=''):
-    """Recursively revert a working copy path.
-
-    Note that this command is more zealous than the 'svn revert'
-    command, as it will also delete any files which subversion
-    does not know about.
-    """
-    util.run(['svn', 'revert', '-R', self.path(path)])
-
-    unknown, missing = self._unknownAndMissing(path)
-    unknown = [os.path.join(self.path(path), p) for p in unknown]
-
-    if unknown:
-      # rm -rf makes me uneasy. Verify that all paths to be deleted
-      # are within the release working copy.
-      for p in unknown:
-        assert p.startswith(self.path())
-
-      util.run(['rm', '-rf', '--'] + unknown)
-
-  def ls(self, dir=''):
-    """List the contents of a working copy directory.
-
-    Note that this returns the contents of the directory as seen
-    by the server, not constrained by the depth settings of the
-    local path.
-    """
-    assert self.exists()
-    return util.run(['svn', 'ls', self.path(dir)], capture=True)
-
-  def copy(self, src, dest):
-    """Copy a working copy path.
-
-    The copy is only scheduled for commit, not committed.
-
-    Args:
-      src: The source working copy path.
-      dst: The destination working copy path.
-    """
-    assert self.exists()
-    util.run(['svn', 'cp', self.path(src), self.path(dest)])
-
-  def propget(self, prop_name, path):
-    """Get the value of a property on a working copy path.
-
-    Args:
-      prop_name: The property name, eg. 'svn:externals'.
-      path: The working copy path on which the property is set.
-    """
-    assert self.exists()
-    return util.run(['svn', 'propget', prop_name, self.path(path)],
-            capture=True)
-
-  def propset(self, prop_name, prop_value, path):
-    """Set the value of a property on a working copy path.
-
-    The property change is only scheduled for commit, not committed.
-
-    Args:
-      prop_name: The property name, eg. 'svn:externals'.
-      prop_value: The value that should be set.
-      path: The working copy path on which to set the property.
-    """
-    assert self.exists()
-    util.run(['svn', 'propset', prop_name, prop_value, self.path(path)])
-
-  def add(self, paths):
-    """Schedule working copy paths for addition.
-
-    The paths are only scheduled for addition, not committed.
-
-    Args:
-      paths: The list of working copy paths to add.
-    """
-    assert self.exists()
-    paths = [self.path(p) for p in paths]
-    util.run(['svn', 'add'] + paths)
-
-  def remove(self, paths):
-    """Schedule working copy paths for deletion.
-
-    The paths are only scheduled for deletion, not committed.
-
-    Args:
-      paths: The list of working copy paths to delete.
-    """
-    assert self.exists()
-    paths = [self.path(p) for p in paths]
-    util.run(['svn', 'rm'] + paths)
-
-  def status(self, path=''):
-    """Return the status of a working copy path.
-
-    The status returned is the verbatim output of 'svn status' on
-    the path.
-
-    Args:
-      path: The path to examine.
-    """
-    assert self.exists()
-    return util.run(['svn', 'status', self.path(path)], capture=True)
-
-  def addRemove(self, path=''):
-    """Perform an "addremove" operation a working copy path.
-
-    An "addremove" runs 'svn status' and schedules all the unknown
-    paths (listed as '?') for addition, and all the missing paths
-    (listed as '!') for deletion. Its main use is to synchronize
-    working copy state after applying a patch in unified diff
-    format.
-
-    Args:
-      path: The path under which unknown/missing files should be
-        added/removed.
-    """
-    assert self.exists()
-    unknown, missing = self._unknownAndMissing(path)
-    if unknown:
-      self.add(unknown)
-    if missing:
-      self.remove(missing)
-
-  def commit(self, message, path=''):
-    """Commit scheduled changes to the source repository.
-
-    Args:
-      message: The commit message to use.
-      path: The path to commit.
-    """
-    assert self.exists()
-    util.run(['svn', 'commit', '-m', message, self.path(path)])
-
-  @staticmethod
-  def export(url, revision, dest_path):
-    """Export the contents of a repository to a local path.
-
-    Note that while the underlying 'svn export' only requires a
-    URL, we require that both a URL and a revision be specified,
-    to fully qualify the data to export.
-
-    Args:
-      url: The repository URL to export.
-      revision: The revision to export.
-      dest_path: The destination directory for the export. Note
-           that this is an absolute path, NOT a working copy
-           relative path.
-    """
-    assert os.path.isabs(dest_path)
-    if os.path.exists(dest_path):
-      raise ObstructionError('Cannot export to obstructed path %s' %
-                   dest_path)
-    util.run(['svn', 'export', '-r', str(revision), url, dest_path])
-
-  @staticmethod
-  def find_tag_rev(url):
-    """Return the revision at which a remote tag was created.
-
-    Since tags are immutable by convention, usually the HEAD of a
-    tag should be the tag creation revision. However, mistakes can
-    happen, so this function will walk the history of the given
-    tag URL, stopping on the first revision that was created by
-    copy.
-
-    This detection is not foolproof. For example: it will be
-    fooled by a tag that was created, deleted, and recreated by
-    copy at a different revision. It is not clear what the desired
-    behavior in these edge cases are, and no attempt is made to
-    handle them. You should request user confirmation before using
-    the result of this function.
-
-    Args:
-      url: The repository URL of the tag to examine.
-    """
-    try:
-      output = util.run(['svn', 'log', '-q', '--stop-on-copy', url],
-                capture=True)
-    except util.SubprocessFailed:
-      raise ExpectationFailed('No tag at URL ' + url)
-    first_rev_line = output[-2]
-    first_rev = int(first_rev_line.split()[0][1:])
-    return first_rev
-
-  @staticmethod
-  def diff(url, revision):
-    """Retrieve a revision from a remote repository as a unified diff.
-
-    Args:
-      url: The repository URL on which to perform the diff.
-      revision: The revision to extract at the given url.
-
-    Returns:
-      A string containing the changes extracted from the remote
-      repository, in unified diff format suitable for application
-      using 'patch'.
-    """
-    try:
-      return util.run(['svn', 'diff', '-c', str(revision), url],
-              capture=True, split_capture=False)
-    except util.SubprocessFailed:
-      raise ExpectationFailed('Could not get diff for r%d '
-                  'from remote repository' % revision)
-
-
 #
 # Decorators for use in ReleaseEnvironment.
 #
@@ -487,7 +201,7 @@
   @functools.wraps(f)
   def check_branch(self, *args, **kwargs):
     if self.branch is None:
-      raise ExpectationFailed(
+      raise error.ExpectationFailed(
         'This operation requires an active release branch')
     return f(self, *args, **kwargs)
   return check_branch
@@ -516,7 +230,7 @@
       upstream_repos: The URL to the Melange upstream repository root.
     """
     util.Paths.__init__(self, root)
-    self.wc = Subversion(self.path('google-soc'))
+    self.wc = subversion.WorkingCopy(self.path('google-soc'))
     self.release_repos = release_repos.strip('/')
     self.upstream_repos = upstream_repos.strip('/')
 
@@ -621,7 +335,7 @@
     """Switch to another Melange release branch"""
     branches = self._listBranches()
     if not branches:
-      raise ExpectationFailed(
+      raise error.ExpectationFailed(
         'No branches available. Please import one.')
 
     choice = getChoice('Available release branches:',
@@ -677,7 +391,7 @@
         tmpl[i] = line.replace('/p/soc/', '/p/soc-google/')
         break
     else:
-      raise ExpectationFailed(
+      raise error.ExpectationFailed(
         'No source code link found in base.html')
     linesToFile(tmpl_file, tmpl)
 
@@ -696,7 +410,7 @@
       raise ObstructionError('Release %s already imported' % release)
 
     tag_url = '%s/tags/%s' % (self.upstream_repos, release)
-    release_rev = Subversion.find_tag_rev(tag_url)
+    release_rev = subversion.find_tag_rev(tag_url)
 
     if confirm('Confirm import of release %s, tagged at r%d?' %
            (release, release_rev)):
@@ -709,7 +423,7 @@
                'release %s at r%d.' % (release, release_rev))
 
       # Export the tag into the release repository's branches
-      Subversion.export(tag_url, release_rev, self.wc.path(branch_dir))
+      subversion.export(tag_url, release_rev, self.wc.path(branch_dir))
 
       # Add and commit the branch add (very long operation!)
       self.wc.add([branch_dir])
@@ -732,9 +446,9 @@
     rev = getNumber('Revision number to cherry-pick:')
     bug = getNumber('Issue fixed by this change:')
 
-    diff = self.wc.diff(self.upstream_repos + '/trunk', rev)
+    diff = subversion.diff(self.upstream_repos + '/trunk', rev)
     if not diff.strip():
-      raise ExpectationFailed(
+      raise error.ExpectationFailed(
         'Retrieved diff is empty. '
         'Did you accidentally cherry-pick a branch change?')
     util.run(['patch', '-p0'], cwd=self.wc.path(self.branch_dir),
@@ -811,7 +525,7 @@
         return
       try:
         self.MENU_ORDER[choice](self)
-      except Error, e:
+      except error.Error, e:
         log.error(str(e))
       else:
         done.append(choice)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/scripts/release/subversion.py	Sat Mar 14 03:49:16 2009 +0000
@@ -0,0 +1,305 @@
+# Copyright 2009 the Melange authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Subversion commandline wrapper.
+
+This module provides access to a restricted subset of the Subversion
+commandline tool. The main functionality offered is an object wrapping
+a working copy, providing version control operations within that
+working copy.
+
+A few standalone commands are also implemented to extract data from
+arbitrary remote repositories.
+"""
+
+__authors__ = [
+    # alphabetical order by last name, please
+    '"David Anderson" <dave@natulte.net>',
+    ]
+
+import error
+import util
+
+
+def export(url, revision, dest_path):
+  """Export the contents of a repository to a local path.
+
+  Note that while the underlying 'svn export' only requires a URL, we
+  require that both a URL and a revision be specified, to fully
+  qualify the data to export.
+
+  Args:
+    url: The repository URL to export.
+    revision: The revision to export.
+    dest_path: The destination directory for the export. Note that
+               this is an absolute path, NOT a working copy relative
+               path.
+    """
+  assert os.path.isabs(dest_path)
+  if os.path.exists(dest_path):
+    raise error.ObstructionError('Cannot export to obstructed path %s' %
+                                 dest_path)
+  util.run(['svn', 'export', '-r', str(revision), url, dest_path])
+
+
+def find_tag_rev(url):
+  """Return the revision at which a remote tag was created.
+
+  Since tags are immutable by convention, usually the HEAD of a tag
+  should be the tag creation revision. However, mistakes can happen,
+  so this function will walk the history of the given tag URL,
+  stopping on the first revision that was created by copy.
+
+  This detection is not foolproof. For example: it will be fooled by a
+  tag that was created, deleted, and recreated by copy at a different
+  revision. It is not clear what the desired behavior in these edge
+  cases are, and no attempt is made to handle them. You should request
+  user confirmation before using the result of this function.
+
+  Args:
+    url: The repository URL of the tag to examine.
+  """
+  try:
+    output = util.run(['svn', 'log', '-q', '--stop-on-copy', url],
+                      capture=True)
+  except util.SubprocessFailed:
+    raise error.ExpectationFailed('No tag at URL ' + url)
+  first_rev_line = output[-2]
+  first_rev = int(first_rev_line.split()[0][1:])
+  return first_rev
+
+
+def diff(url, revision):
+  """Retrieve a revision from a remote repository as a unified diff.
+
+  Args:
+    url: The repository URL on which to perform the diff.
+    revision: The revision to extract at the given url.
+
+  Returns:
+    A string containing the changes extracted from the remote
+    repository, in unified diff format suitable for application using
+    'patch'.
+  """
+  try:
+    return util.run(['svn', 'diff', '-c', str(revision), url],
+                    capture=True, split_capture=False)
+  except util.SubprocessFailed:
+    raise error.ExpectationFailed('Could not get diff for r%d '
+                                  'from remote repository' % revision)
+
+
+class WorkingCopy(util.Paths):
+  """Wrapper for operations on a Subversion working copy.
+
+  An instance of this class is bound to a specific working copy
+  directory, and provides an API to perform various Subversion
+  operations on this working copy.
+
+  Some methods take a 'depth' argument. Depth in Subversion is a
+  feature that allows the creation of arbitrarily shallow or deep
+  working copies on a per-directory basis. Possible values are
+  'none' (no files or directories), 'files' (only files in .),
+  'immediates' (files and directories in ., directories checked out
+  at depth 'none') or 'infinity' (a normal working copy with
+  everything).
+
+  Note that this wrapper also doubles as a Paths object, offering an
+  easy way to get or check the existence of paths in the working
+  copy.
+  """
+
+  def __init__(self, wc_dir):
+    util.Paths.__init__(self, wc_dir)
+
+  def _unknownAndMissing(self, path):
+    """Returns lists of unknown and missing files in the working copy.
+
+    Args:
+      path: The working copy path to scan.
+
+    Returns:
+
+      Two lists. The first is a list of all unknown paths
+      (subversion has no knowledge of them), the second is a list
+      of missing paths (subversion knows about them, but can't
+      find them). Paths in either list are relative to the input
+      path.
+    """
+    assert self.exists()
+    unknown = []
+    missing = []
+    for line in self.status(path):
+      if not line.strip():
+        continue
+      if line[0] == '?':
+        unknown.append(line[7:])
+      elif line[0] == '!':
+        missing.append(line[7:])
+    return unknown, missing
+
+  def checkout(self, url, depth='infinity'):
+    """Check out a working copy from the given URL.
+
+    Args:
+      url: The Subversion repository URL to check out.
+      depth: The depth of the working copy root.
+    """
+    assert not self.exists()
+    util.run(['svn', 'checkout', '--depth=' + depth, url, self.path()])
+
+  def update(self, path='', depth=None):
+    """Update a working copy path, optionally changing depth.
+
+    Args:
+      path: The working copy path to update.
+      depth: If set, change the depth of the path before updating.
+    """
+    assert self.exists()
+    if depth is None:
+      util.run(['svn', 'update', self.path(path)])
+    else:
+      util.run(['svn', 'update', '--set-depth=' + depth, self.path(path)])
+
+  def revert(self, path=''):
+    """Recursively revert a working copy path.
+
+    Note that this command is more zealous than the 'svn revert'
+    command, as it will also delete any files which subversion
+    does not know about.
+    """
+    util.run(['svn', 'revert', '-R', self.path(path)])
+
+    unknown, missing = self._unknownAndMissing(path)
+    unknown = [os.path.join(self.path(path), p) for p in unknown]
+
+    if unknown:
+      # rm -rf makes me uneasy. Verify that all paths to be deleted
+      # are within the release working copy.
+      for p in unknown:
+        assert p.startswith(self.path())
+
+      util.run(['rm', '-rf', '--'] + unknown)
+
+  def ls(self, dir=''):
+    """List the contents of a working copy directory.
+
+    Note that this returns the contents of the directory as seen
+    by the server, not constrained by the depth settings of the
+    local path.
+    """
+    assert self.exists()
+    return util.run(['svn', 'ls', self.path(dir)], capture=True)
+
+  def copy(self, src, dest):
+    """Copy a working copy path.
+
+    The copy is only scheduled for commit, not committed.
+
+    Args:
+      src: The source working copy path.
+      dst: The destination working copy path.
+    """
+    assert self.exists()
+    util.run(['svn', 'cp', self.path(src), self.path(dest)])
+
+  def propget(self, prop_name, path):
+    """Get the value of a property on a working copy path.
+
+    Args:
+      prop_name: The property name, eg. 'svn:externals'.
+      path: The working copy path on which the property is set.
+    """
+    assert self.exists()
+    return util.run(['svn', 'propget', prop_name, self.path(path)],
+            capture=True)
+
+  def propset(self, prop_name, prop_value, path):
+    """Set the value of a property on a working copy path.
+
+    The property change is only scheduled for commit, not committed.
+
+    Args:
+      prop_name: The property name, eg. 'svn:externals'.
+      prop_value: The value that should be set.
+      path: The working copy path on which to set the property.
+    """
+    assert self.exists()
+    util.run(['svn', 'propset', prop_name, prop_value, self.path(path)])
+
+  def add(self, paths):
+    """Schedule working copy paths for addition.
+
+    The paths are only scheduled for addition, not committed.
+
+    Args:
+      paths: The list of working copy paths to add.
+    """
+    assert self.exists()
+    paths = [self.path(p) for p in paths]
+    util.run(['svn', 'add'] + paths)
+
+  def remove(self, paths):
+    """Schedule working copy paths for deletion.
+
+    The paths are only scheduled for deletion, not committed.
+
+    Args:
+      paths: The list of working copy paths to delete.
+    """
+    assert self.exists()
+    paths = [self.path(p) for p in paths]
+    util.run(['svn', 'rm'] + paths)
+
+  def status(self, path=''):
+    """Return the status of a working copy path.
+
+    The status returned is the verbatim output of 'svn status' on
+    the path.
+
+    Args:
+      path: The path to examine.
+    """
+    assert self.exists()
+    return util.run(['svn', 'status', self.path(path)], capture=True)
+
+  def addRemove(self, path=''):
+    """Perform an "addremove" operation a working copy path.
+
+    An "addremove" runs 'svn status' and schedules all the unknown
+    paths (listed as '?') for addition, and all the missing paths
+    (listed as '!') for deletion. Its main use is to synchronize
+    working copy state after applying a patch in unified diff
+    format.
+
+    Args:
+      path: The path under which unknown/missing files should be
+        added/removed.
+    """
+    assert self.exists()
+    unknown, missing = self._unknownAndMissing(path)
+    if unknown:
+      self.add(unknown)
+    if missing:
+      self.remove(missing)
+
+  def commit(self, message, path=''):
+    """Commit scheduled changes to the source repository.
+
+    Args:
+      message: The commit message to use.
+      path: The path to commit.
+    """
+    assert self.exists()
+    util.run(['svn', 'commit', '-m', message, self.path(path)])