Index: checkout.py |
diff --git a/checkout.py b/checkout.py |
new file mode 100644 |
index 0000000000000000000000000000000000000000..fcdea220a2373df292ff5502e035738789fc5e9c |
--- /dev/null |
+++ b/checkout.py |
@@ -0,0 +1,625 @@ |
+# coding=utf8 |
+# Copyright (c) 2011 The Chromium Authors. All rights reserved. |
+# Use of this source code is governed by a BSD-style license that can be |
+# found in the LICENSE file. |
+"""Manages a project checkout. |
+ |
+Includes support for svn, git-svn and git. |
+""" |
+ |
+from __future__ import with_statement |
+import ConfigParser |
+import fnmatch |
+import logging |
+import os |
+import re |
+import subprocess |
+import sys |
+import tempfile |
+ |
+import patch |
+import scm |
+import subprocess2 |
+ |
+ |
+def get_code_review_setting(path, key, |
+ codereview_settings_file='codereview.settings'): |
+ """Parses codereview.settings and return the value for the key if present. |
+ |
+ Don't cache the values in case the file is changed.""" |
+ # TODO(maruel): Do not duplicate code. |
+ settings = {} |
+ try: |
+ settings_file = open(os.path.join(path, codereview_settings_file), 'r') |
+ try: |
+ for line in settings_file.readlines(): |
+ if not line or line.startswith('#'): |
+ continue |
+ if not ':' in line: |
+ # Invalid file. |
+ return None |
+ k, v = line.split(':', 1) |
+ settings[k.strip()] = v.strip() |
+ finally: |
+ settings_file.close() |
+ except OSError: |
+ return None |
+ return settings.get(key, None) |
+ |
+ |
+class PatchApplicationFailed(Exception): |
+ """Patch failed to be applied.""" |
+ def __init__(self, filename, status): |
+ super(PatchApplicationFailed, self).__init__(filename, status) |
+ self.filename = filename |
+ self.status = status |
+ |
+ |
+class CheckoutBase(object): |
+ # Set to None to have verbose output. |
+ VOID = subprocess2.VOID |
+ |
+ def __init__(self, root_dir, project_name): |
+ self.root_dir = root_dir |
+ self.project_name = project_name |
+ self.project_path = os.path.join(self.root_dir, self.project_name) |
+ # Only used for logging purposes. |
+ self._last_seen_revision = None |
+ assert self.root_dir |
+ assert self.project_name |
+ assert self.project_path |
+ |
+ def get_settings(self, key): |
+ return get_code_review_setting(self.project_path, key) |
+ |
+ def prepare(self): |
+ """Checks out a clean copy of the tree and removes any local modification. |
+ |
+ This function shouldn't throw unless the remote repository is inaccessible, |
+ there is no free disk space or hard issues like that. |
+ """ |
+ raise NotImplementedError() |
+ |
+ def apply_patch(self, patches): |
+ """Applies a patch and returns the list of modified files. |
+ |
+ This function should throw patch.UnsupportedPatchFormat or |
+ PatchApplicationFailed when relevant. |
+ """ |
+ raise NotImplementedError() |
+ |
+ def commit(self, commit_message, user): |
+ """Commits the patch upstream, while impersonating 'user'.""" |
+ raise NotImplementedError() |
+ |
+ |
+class RawCheckout(CheckoutBase): |
+ """Used to apply a patch locally without any intent to commit it. |
+ |
+ To be used by the try server. |
+ """ |
+ def prepare(self): |
+ """Stubbed out.""" |
+ pass |
+ |
+ def apply_patch(self, patches): |
+ for p in patches: |
+ try: |
+ stdout = '' |
+ filename = os.path.join(self.project_path, p.filename) |
+ if p.is_delete: |
+ os.remove(filename) |
+ else: |
+ dirname = os.path.dirname(p.filename) |
+ full_dir = os.path.join(self.project_path, dirname) |
+ if dirname and not os.path.isdir(full_dir): |
+ os.makedirs(full_dir) |
+ if p.is_binary: |
+ with open(os.path.join(filename), 'wb') as f: |
+ f.write(p.get()) |
+ else: |
+ stdout = subprocess2.check_output( |
+ ['patch', '-p%s' % p.patchlevel], |
+ stdin=p.get(), |
+ cwd=self.project_path) |
+ # Ignore p.svn_properties. |
+ except OSError, e: |
+ raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e)) |
+ except subprocess.CalledProcessError, e: |
+ raise PatchApplicationFailed( |
+ p.filename, '%s%s' % (stdout, getattr(e, 'stdout', None))) |
+ |
+ def commit(self, commit_message, user): |
+ """Stubbed out.""" |
+ raise NotImplementedError('RawCheckout can\'t commit') |
+ |
+ |
+class SvnConfig(object): |
+ """Parses a svn configuration file.""" |
+ def __init__(self, svn_config_dir=None): |
+ self.svn_config_dir = svn_config_dir |
+ self.default = not bool(self.svn_config_dir) |
+ if not self.svn_config_dir: |
+ if sys.platform == 'win32': |
+ self.svn_config_dir = os.path.join(os.environ['APPDATA'], 'Subversion') |
+ else: |
+ self.svn_config_dir = os.path.join(os.environ['HOME'], '.subversion') |
+ svn_config_file = os.path.join(self.svn_config_dir, 'config') |
+ parser = ConfigParser.SafeConfigParser() |
+ if os.path.isfile(svn_config_file): |
+ parser.read(svn_config_file) |
+ else: |
+ parser.add_section('auto-props') |
+ self.auto_props = dict(parser.items('auto-props')) |
+ |
+ |
+class SvnMixIn(object): |
+ """MixIn class to add svn commands common to both svn and git-svn clients.""" |
+ # These members need to be set by the subclass. |
+ commit_user = None |
+ commit_pwd = None |
+ svn_url = None |
+ project_path = None |
+ # Override at class level when necessary. If used, --non-interactive is |
+ # implied. |
+ svn_config = SvnConfig() |
+ # Set to True when non-interactivity is necessary but a custom subversion |
+ # configuration directory is not necessary. |
+ non_interactive = False |
+ |
+ def _add_svn_flags(self, args, non_interactive): |
+ args = ['svn'] + args |
+ if not self.svn_config.default: |
+ args.extend(['--config-dir', self.svn_config.svn_config_dir]) |
+ if not self.svn_config.default or self.non_interactive or non_interactive: |
+ args.append('--non-interactive') |
+ if self.commit_user: |
+ args.extend(['--username', self.commit_user]) |
+ if self.commit_pwd: |
+ args.extend(['--password', self.commit_pwd]) |
+ return args |
+ |
+ def _check_call_svn(self, args, **kwargs): |
+ """Runs svn and throws an exception if the command failed.""" |
+ kwargs.setdefault('cwd', self.project_path) |
+ kwargs.setdefault('stdout', self.VOID) |
+ return subprocess2.check_call(self._add_svn_flags(args, False), **kwargs) |
+ |
+ def _check_output_svn(self, args, **kwargs): |
+ """Runs svn and throws an exception if the command failed. |
+ |
+ Returns the output. |
+ """ |
+ kwargs.setdefault('cwd', self.project_path) |
+ return subprocess2.check_output(self._add_svn_flags(args, True), **kwargs) |
+ |
+ @staticmethod |
+ def _parse_svn_info(output, key): |
+ """Returns value for key from svn info output. |
+ |
+ Case insensitive. |
+ """ |
+ values = {} |
+ key = key.lower() |
+ for line in output.splitlines(False): |
+ if not line: |
+ continue |
+ k, v = line.split(':', 1) |
+ k = k.strip().lower() |
+ v = v.strip() |
+ assert not k in values |
+ values[k] = v |
+ return values.get(key, None) |
+ |
+ |
+class SvnCheckout(CheckoutBase, SvnMixIn): |
+ """Manages a subversion checkout.""" |
+ def __init__(self, root_dir, project_name, commit_user, commit_pwd, svn_url): |
+ super(SvnCheckout, self).__init__(root_dir, project_name) |
+ self.commit_user = commit_user |
+ self.commit_pwd = commit_pwd |
+ self.svn_url = svn_url |
+ assert bool(self.commit_user) >= bool(self.commit_pwd) |
+ assert self.svn_url |
+ |
+ def prepare(self): |
+ """Creates the initial checkouts for the repo.""" |
+ # Will checkout if the directory is not present. |
+ if not os.path.isdir(self.project_path): |
+ logging.info('Checking out %s in %s' % |
+ (self.project_name, self.project_path)) |
+ revision = self._revert() |
+ if revision != self._last_seen_revision: |
+ logging.info('Updated at revision %d' % revision) |
+ self._last_seen_revision = revision |
+ return revision |
+ |
+ def apply_patch(self, patches): |
+ """Applies a patch.""" |
+ for p in patches: |
+ try: |
+ stdout = '' |
+ if p.is_delete: |
+ stdout += self._check_output_svn(['delete', p.filename, '--force']) |
+ else: |
+ new = not os.path.exists(p.filename) |
+ |
+ # svn add while creating directories otherwise svn add on the |
+ # contained files will silently fail. |
+ # First, find the root directory that exists. |
+ dirname = os.path.dirname(p.filename) |
+ dirs_to_create = [] |
+ while (dirname and |
+ not os.path.isdir(os.path.join(self.project_path, dirname))): |
+ dirs_to_create.append(dirname) |
+ dirname = os.path.dirname(dirname) |
+ for dir_to_create in reversed(dirs_to_create): |
+ os.mkdir(os.path.join(self.project_path, dir_to_create)) |
+ stdout += self._check_output_svn( |
+ ['add', dir_to_create, '--force']) |
+ |
+ if p.is_binary: |
+ with open(os.path.join(self.project_path, p.filename), 'wb') as f: |
+ f.write(p.get()) |
+ else: |
+ cmd = ['patch', '-p%s' % p.patchlevel, '--forward', '--force'] |
+ stdout += subprocess2.check_output( |
+ cmd, stdin=p.get(), cwd=self.project_path) |
+ if new: |
+ stdout += self._check_output_svn(['add', p.filename, '--force']) |
+ for prop in p.svn_properties: |
+ stdout += self._check_output_svn( |
+ ['propset', prop[0], prop[1], p.filename]) |
+ for prop, value in self.svn_config.auto_props.iteritems(): |
+ if fnmatch.fnmatch(p.filename, prop): |
+ stdout += self._check_output_svn( |
+ ['propset'] + value.split('=', 1) + [p.filename]) |
+ except OSError, e: |
+ raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e)) |
+ except subprocess.CalledProcessError, e: |
+ raise PatchApplicationFailed( |
+ p.filename, '%s%s' % (stdout, getattr(e, 'stdout', ''))) |
+ |
+ def commit(self, commit_message, user): |
+ logging.info('Committing patch for %s' % user) |
+ assert self.commit_user |
+ handle, commit_filename = tempfile.mkstemp(text=True) |
+ try: |
+ os.write(handle, commit_message) |
+ os.close(handle) |
+ # When committing, svn won't update the Revision metadata of the checkout, |
+ # so if svn commit returns "Committed revision 3.", svn info will still |
+ # return "Revision: 2". Since running svn update right after svn commit |
+ # creates a race condition with other committers, this code _must_ parse |
+ # the output of svn commit and use a regexp to grab the revision number. |
+ # Note that "Committed revision N." is localized but subprocess2 forces |
+ # LANGUAGE=en. |
+ args = ['commit', '--file', commit_filename] |
+ # realauthor is parsed by a server-side hook. |
+ if user and user != self.commit_user: |
+ args.extend(['--with-revprop', 'realauthor=%s' % user]) |
+ out = self._check_output_svn(args) |
+ finally: |
+ os.remove(commit_filename) |
+ lines = filter(None, out.splitlines()) |
+ match = re.match(r'^Committed revision (\d+).$', lines[-1]) |
+ if not match: |
+ raise PatchApplicationFailed( |
+ None, |
+ 'Couldn\'t make sense out of svn commit message:\n' + out) |
+ return int(match.group(1)) |
+ |
+ def _revert(self): |
+ """Reverts local modifications or checks out if the directory is not |
+ present. Use depot_tools's functionality to do this. |
+ """ |
+ flags = ['--ignore-externals'] |
+ if not os.path.isdir(self.project_path): |
+ logging.info( |
+ 'Directory %s is not present, checking it out.' % self.project_path) |
+ self._check_call_svn( |
+ ['checkout', self.svn_url, self.project_path] + flags, cwd=None) |
+ else: |
+ scm.SVN.Revert(self.project_path) |
+ # Revive files that were deleted in scm.SVN.Revert(). |
+ self._check_call_svn(['update', '--force'] + flags) |
+ |
+ out = self._check_output_svn(['info', '.']) |
+ return int(self._parse_svn_info(out, 'revision')) |
+ |
+ |
+class GitCheckoutBase(CheckoutBase): |
+ """Base class for git checkout. Not to be used as-is.""" |
+ def __init__(self, root_dir, project_name, remote_branch): |
+ super(GitCheckoutBase, self).__init__(root_dir, project_name) |
+ # There is no reason to not hardcode it. |
+ self.remote = 'origin' |
+ self.remote_branch = remote_branch |
+ self.working_branch = 'working_branch' |
+ assert self.remote_branch |
+ |
+ def prepare(self): |
+ """Resets the git repository in a clean state. |
+ |
+ Checks it out if not present and deletes the working branch. |
+ """ |
+ assert os.path.isdir(self.project_path) |
+ self._check_call_git(['reset', '--hard', '--quiet']) |
+ branches, active = self._branches() |
+ if active != 'master': |
+ self._check_call_git(['checkout', 'master', '--force', '--quiet']) |
+ self._check_call_git(['pull', self.remote, self.remote_branch, '--quiet']) |
+ if self.working_branch in branches: |
+ self._call_git(['branch', '-D', self.working_branch]) |
+ |
+ def apply_patch(self, patches): |
+ """Applies a patch on 'working_branch' and switch to it.""" |
+ # It this throws, the checkout is corrupted. Maybe worth deleting it and |
+ # trying again? |
+ self._check_call_git( |
+ ['checkout', '-b', self.working_branch, |
+ '%s/%s' % (self.remote, self.remote_branch)]) |
+ for p in patches: |
+ try: |
+ stdout = '' |
+ if p.is_delete: |
+ stdout += self._check_output_git(['rm', p.filename]) |
+ else: |
+ dirname = os.path.dirname(p.filename) |
+ full_dir = os.path.join(self.project_path, dirname) |
+ if dirname and not os.path.isdir(full_dir): |
+ os.makedirs(full_dir) |
+ if p.is_binary: |
+ with open(os.path.join(self.project_path, p.filename), 'wb') as f: |
+ f.write(p.get()) |
+ stdout += self._check_output_git(['add', p.filename]) |
+ else: |
+ stdout += self._check_output_git( |
+ ['apply', '--index', '-p%s' % p.patchlevel], stdin=p.get()) |
+ for prop in p.svn_properties: |
+ # Ignore some known auto-props flags through .subversion/config, |
+ # bails out on the other ones. |
+ # TODO(maruel): Read ~/.subversion/config and detect the rules that |
+ # applies here to figure out if the property will be correctly |
+ # handled. |
+ if not prop[0] in ('svn:eol-style', 'svn:executable'): |
+ raise patch.UnsupportedPatchFormat( |
+ p.filename, |
+ 'Cannot apply svn property %s to file %s.' % ( |
+ prop[0], p.filename)) |
+ except OSError, e: |
+ raise PatchApplicationFailed(p.filename, '%s%s' % (stdout, e)) |
+ except subprocess.CalledProcessError, e: |
+ raise PatchApplicationFailed( |
+ p.filename, '%s%s' % (stdout, getattr(e, 'stdout', None))) |
+ # Once all the patches are processed and added to the index, commit the |
+ # index. |
+ self._check_call_git(['commit', '-m', 'Committed patch']) |
+ # TODO(maruel): Weirdly enough they don't match, need to investigate. |
+ #found_files = self._check_output_git( |
+ # ['diff', 'master', '--name-only']).splitlines(False) |
+ #assert sorted(patches.filenames) == sorted(found_files), ( |
+ # sorted(out), sorted(found_files)) |
+ |
+ def commit(self, commit_message, user): |
+ """Updates the commit message. |
+ |
+ Subclass needs to dcommit or push. |
+ """ |
+ self._check_call_git(['commit', '--amend', '-m', commit_message]) |
+ return self._check_output_git(['rev-parse', 'HEAD']).strip() |
+ |
+ def _check_call_git(self, args, **kwargs): |
+ kwargs.setdefault('cwd', self.project_path) |
+ kwargs.setdefault('stdout', self.VOID) |
+ return subprocess2.check_call(['git'] + args, **kwargs) |
+ |
+ def _call_git(self, args, **kwargs): |
+ """Like check_call but doesn't throw on failure.""" |
+ kwargs.setdefault('cwd', self.project_path) |
+ kwargs.setdefault('stdout', self.VOID) |
+ return subprocess2.call(['git'] + args, **kwargs) |
+ |
+ def _check_output_git(self, args, **kwargs): |
+ kwargs.setdefault('cwd', self.project_path) |
+ return subprocess2.check_output(['git'] + args, **kwargs) |
+ |
+ def _branches(self): |
+ """Returns the list of branches and the active one.""" |
+ out = self._check_output_git(['branch']).splitlines(False) |
+ branches = [l[2:] for l in out] |
+ active = None |
+ for l in out: |
+ if l.startswith('*'): |
+ active = l[2:] |
+ break |
+ return branches, active |
+ |
+ |
+class GitSvnCheckoutBase(GitCheckoutBase, SvnMixIn): |
+ """Base class for git-svn checkout. Not to be used as-is.""" |
+ def __init__(self, |
+ root_dir, project_name, remote_branch, |
+ commit_user, commit_pwd, |
+ svn_url, trunk): |
+ """trunk is optional.""" |
+ super(GitSvnCheckoutBase, self).__init__( |
+ root_dir, project_name + '.git', remote_branch) |
+ self.commit_user = commit_user |
+ self.commit_pwd = commit_pwd |
+ # svn_url in this case is the root of the svn repository. |
+ self.svn_url = svn_url |
+ self.trunk = trunk |
+ assert bool(self.commit_user) >= bool(self.commit_pwd) |
+ assert self.svn_url |
+ assert self.trunk |
+ self._cache_svn_auth() |
+ |
+ def prepare(self): |
+ """Resets the git repository in a clean state.""" |
+ self._check_call_git(['reset', '--hard', '--quiet']) |
+ branches, active = self._branches() |
+ if active != 'master': |
+ if not 'master' in branches: |
+ self._check_call_git( |
+ ['checkout', '--quiet', '-b', 'master', |
+ '%s/%s' % (self.remote, self.remote_branch)]) |
+ else: |
+ self._check_call_git(['checkout', 'master', '--force', '--quiet']) |
+ # git svn rebase --quiet --quiet doesn't work, use two steps to silence it. |
+ self._check_call_git_svn(['fetch', '--quiet', '--quiet']) |
+ self._check_call_git( |
+ ['rebase', '--quiet', '--quiet', |
+ '%s/%s' % (self.remote, self.remote_branch)]) |
+ if self.working_branch in branches: |
+ self._call_git(['branch', '-D', self.working_branch]) |
+ return int(self._git_svn_info('revision')) |
+ |
+ def _git_svn_info(self, key): |
+ """Calls git svn info. This doesn't support nor need --config-dir.""" |
+ return self._parse_svn_info(self._check_output_git(['svn', 'info']), key) |
+ |
+ def commit(self, commit_message, user): |
+ """Commits a patch.""" |
+ logging.info('Committing patch for %s' % user) |
+ # Fix the commit message and author. It returns the git hash, which we |
+ # ignore unless it's None. |
+ if not super(GitSvnCheckoutBase, self).commit(commit_message, user): |
+ return None |
+ # TODO(maruel): git-svn ignores --config-dir as of git-svn version 1.7.4 and |
+ # doesn't support --with-revprop. |
+ # Either learn perl and upstream or suck it. |
+ kwargs = {} |
+ if self.commit_pwd: |
+ kwargs['stdin'] = self.commit_pwd + '\n' |
+ self._check_call_git_svn( |
+ ['dcommit', '--rmdir', '--find-copies-harder', |
+ '--username', self.commit_user], |
+ **kwargs) |
+ revision = int(self._git_svn_info('revision')) |
+ return revision |
+ |
+ def _cache_svn_auth(self): |
+ """Caches the svn credentials. It is necessary since git-svn doesn't prompt |
+ for it.""" |
+ if not self.commit_user or not self.commit_pwd: |
+ return |
+ # Use capture to lower noise in logs. |
+ self._check_output_svn(['ls', self.svn_url], cwd=None) |
+ |
+ def _check_call_git_svn(self, args, **kwargs): |
+ """Handles svn authentication while calling git svn.""" |
+ args = ['svn'] + args |
+ if not self.svn_config.default: |
+ args.extend(['--config-dir', self.svn_config.svn_config_dir]) |
+ return self._check_call_git(args, **kwargs) |
+ |
+ def _get_revision(self): |
+ revision = int(self._git_svn_info('revision')) |
+ if revision != self._last_seen_revision: |
+ logging.info('Updated at revision %d' % revision) |
+ self._last_seen_revision = revision |
+ return revision |
+ |
+ |
+class GitSvnPremadeCheckout(GitSvnCheckoutBase): |
+ """Manages a git-svn clone made out from an initial git-svn seed. |
+ |
+ This class is very similar to GitSvnCheckout but is faster to bootstrap |
+ because it starts right off with an existing git-svn clone. |
+ """ |
+ def __init__(self, |
+ root_dir, project_name, remote_branch, |
+ commit_user, commit_pwd, |
+ svn_url, trunk, git_url): |
+ super(GitSvnPremadeCheckout, self).__init__( |
+ root_dir, project_name, remote_branch, |
+ commit_user, commit_pwd, |
+ svn_url, trunk) |
+ self.git_url = git_url |
+ assert self.git_url |
+ |
+ def prepare(self): |
+ """Creates the initial checkout for the repo.""" |
+ if not os.path.isdir(self.project_path): |
+ logging.info('Checking out %s in %s' % |
+ (self.project_name, self.project_path)) |
+ assert self.remote == 'origin' |
+ # self.project_path doesn't exist yet. |
+ self._check_call_git( |
+ ['clone', self.git_url, self.project_name], |
+ cwd=self.root_dir) |
+ try: |
+ configured_svn_url = self._check_output_git( |
+ ['config', 'svn-remote.svn.url']).strip() |
+ except subprocess.CalledProcessError: |
+ configured_svn_url = '' |
+ |
+ if configured_svn_url.strip() != self.svn_url: |
+ self._check_call_git_svn( |
+ ['init', |
+ '--prefix', self.remote + '/', |
+ '-T', self.trunk, |
+ self.svn_url]) |
+ self._check_call_git_svn(['fetch']) |
+ super(GitSvnPremadeCheckout, self).prepare() |
+ return self._get_revision() |
+ |
+ |
+class GitSvnCheckout(GitSvnCheckoutBase): |
+ """Manages a git-svn clone. |
+ |
+ Using git-svn hides some of the complexity of using a svn checkout. |
+ """ |
+ def __init__(self, |
+ root_dir, project_name, |
+ commit_user, commit_pwd, |
+ svn_url, trunk): |
+ super(GitSvnCheckout, self).__init__( |
+ root_dir, project_name, 'trunk', |
+ commit_user, commit_pwd, |
+ svn_url, trunk) |
+ |
+ def prepare(self): |
+ """Creates the initial checkout for the repo.""" |
+ if not os.path.isdir(self.project_path): |
+ logging.info('Checking out %s in %s' % |
+ (self.project_name, self.project_path)) |
+ # TODO: Create a shallow clone. |
+ # self.project_path doesn't exist yet. |
+ self._check_call_git_svn( |
+ ['clone', |
+ '--prefix', self.remote + '/', |
+ '-T', self.trunk, |
+ self.svn_url, self.project_path], |
+ cwd=self.root_dir) |
+ super(GitSvnCheckout, self).prepare() |
+ return self._get_revision() |
+ |
+ |
+class ReadOnlyCheckout(object): |
+ """Converts a checkout into a read-only one.""" |
+ def __init__(self, checkout): |
+ self.checkout = checkout |
+ |
+ def prepare(self): |
+ return self.checkout.prepare() |
+ |
+ def get_settings(self, key): |
+ return self.checkout.get_settings(key) |
+ |
+ def apply_patch(self, patches): |
+ return self.checkout.apply_patch(patches) |
+ |
+ def commit(self, message, user): # pylint: disable=R0201 |
+ logging.info('Would have committed for %s with message: %s' % ( |
+ user, message)) |
+ return 'FAKE' |
+ |
+ @property |
+ def project_name(self): |
+ return self.checkout.project_name |
+ |
+ @property |
+ def project_path(self): |
+ return self.checkout.project_path |