├── .gitignore ├── LICENSE.txt ├── MANIFEST.in ├── README.txt ├── pyvcs ├── __init__.py ├── backends │ ├── __init__.py │ ├── bzr.py │ ├── git.py │ ├── hg.py │ └── subversion.py ├── commit.py ├── exceptions.py ├── repository.py └── utils.py ├── setup.py └── tests ├── alex_tests.py ├── andrew_tests.py ├── carlos_tests.py ├── justin_tests.py ├── setup_git_test.sh ├── simple_git_tests.py └── teardown_git_test.sh /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of pyvcs nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include README.txt 3 | -------------------------------------------------------------------------------- /README.txt: -------------------------------------------------------------------------------- 1 | pyvcs - A minimal VCS abstraction layer for Python 2 | ================================================== 3 | 4 | pyvcs is a minimal VCS abstraction layer for Python. It's goals are to provide 5 | as much functionality as is necessary, and no further. It doesn't try to 6 | abstract every layer or feature of a VCS, just what's necessary to build a code 7 | browsing UI. 8 | 9 | Currently supported VCS backends are:: 10 | 11 | * Mercurial 12 | * Git 13 | * Subversion 14 | * Bazaar 15 | 16 | Requirements:: 17 | 18 | * Python (2.4 or greater) 19 | 20 | Backend Specific Requirements:: 21 | 22 | * Git 23 | * Dulwich (http://github.com/jelmer/dulwich/) 24 | * Subversion 25 | * Pysvn (http://pysvn.tigris.org/) 26 | -------------------------------------------------------------------------------- /pyvcs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex/pyvcs/08806646c93297b169b16635243edb1bfca2e3af/pyvcs/__init__.py -------------------------------------------------------------------------------- /pyvcs/backends/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | AVAILABLE_BACKENDS = { 4 | 'bzr': 'bzr', 5 | 'git': 'git', 6 | 'hg': 'hg', 7 | 'svn': 'subversion', 8 | } 9 | 10 | def get_backend(backend): 11 | if backend in AVAILABLE_BACKENDS: 12 | path = 'pyvcs.backends.%s' % AVAILABLE_BACKENDS[backend] 13 | else: 14 | path = backend 15 | __import__(path) 16 | return sys.modules[path] 17 | -------------------------------------------------------------------------------- /pyvcs/backends/bzr.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | import os 3 | import StringIO 4 | from time import mktime 5 | 6 | from bzrlib import branch, diff, errors 7 | 8 | from pyvcs.commit import Commit 9 | from pyvcs.exceptions import CommitDoesNotExist, FileDoesNotExist, FolderDoesNotExist 10 | from pyvcs.repository import BaseRepository 11 | 12 | class Repository(BaseRepository): 13 | def __init__(self, *args, **kwargs): 14 | super(Repository, self).__init__(*args, **kwargs) 15 | 16 | # API-wise, pyvcs's notion of a "repository" probably maps more closely 17 | # to bzr's notion of a branch than of a bzr repository so, self._branch 18 | # is the bzrlib Branch structure mapping to the path in question. 19 | self._branch = branch.Branch.open(self.path.rstrip(os.path.sep)) 20 | 21 | # for purposes of naming, "commit ID" is used as pyvcs uses it: to describe 22 | # the user-facing name for a revision ("revision number" or "revno" in bzr 23 | # terms); "revision_id" or "rev_id" is used here to describe bzrlib's 24 | # internal string names for revisions. Finally, "rev" refers to an actual 25 | # bzrlib revision structure. 26 | def _rev_to_commit(self, rev): 27 | # TODO: this doesn't yet handle the case of multiple parent revisions 28 | current = self._branch.repository.revision_tree(rev.revision_id) 29 | if len(rev.parent_ids): 30 | prev = self._branch.repository.revision_tree(rev.parent_ids[0]) 31 | else: 32 | prev = self._branch.repository.revision_tree('null:') 33 | 34 | delta = current.changes_from(prev) 35 | files = [f[0] for f in delta.added + delta.removed + delta.renamed + delta.kind_changed + delta.modified] 36 | 37 | diff_file = StringIO.StringIO() 38 | 39 | diff_tree = diff.DiffTree(prev, current, diff_file) 40 | 41 | self._branch.lock_read() 42 | diff_tree.show_diff('') 43 | self._branch.unlock() 44 | 45 | diff_out = diff_file.getvalue() 46 | diff_file.close() 47 | 48 | return Commit(self._get_commit_id(rev.revision_id), rev.committer, 49 | datetime.fromtimestamp(rev.timestamp), rev.message, files, diff_out) 50 | 51 | def _get_rev_id(self, commit_id): 52 | return self._branch.get_rev_id(int(commit_id)) 53 | 54 | def _get_commit_id(self, rev_id): 55 | return self._branch.revision_id_to_revno(rev_id) 56 | 57 | def _get_commit_by_rev_id(self, rev_id): 58 | rev = self._branch.repository.get_revision(rev_id) 59 | return self._rev_to_commit(rev) 60 | 61 | def get_commit_by_id(self, commit_id): 62 | rev_id = self._get_rev_id(commit_id) 63 | return self._get_commit_by_rev_id(rev_id) 64 | 65 | def _get_tree(self, revision=None): 66 | if revision: 67 | return self._branch.repository.revision_tree(self._get_rev_id(revision)) 68 | else: 69 | return self._branch.repository.revision_tree(self._branch.last_revision()) 70 | 71 | def get_recent_commits(self, since=None): 72 | hist = self._branch.revision_history() 73 | hist.reverse() 74 | head = hist[0] 75 | 76 | if since is None: 77 | since = datetime.fromtimestamp(head.timestamp) - timedelta(days=5) 78 | 79 | since_ts = mktime(since.timetuple()) 80 | 81 | commits = [] 82 | for rev_id in hist: 83 | rev = self._branch.repository.get_revision(rev_id) 84 | if rev.timestamp < since_ts: 85 | break 86 | commits.append(self._rev_to_commit(rev)) 87 | 88 | return commits 89 | 90 | def list_directory(self, path, revision=None): 91 | path = path.rstrip(os.path.sep) 92 | tree = self._get_tree(revision) 93 | dir_iter = tree.walkdirs(path) 94 | try: 95 | entries = dir_iter.next() 96 | except StopIteration: 97 | raise FolderDoesNotExist 98 | 99 | plen = len(path) 100 | if plen != 0: 101 | plen += 1 102 | files, folders = [], [] 103 | for item in entries[1]: 104 | if item[2] == 'file': 105 | files.append(item[0][plen:]) 106 | elif item[2] == 'directory': 107 | folders.append(item[0][plen:]) 108 | 109 | return files, folders 110 | 111 | def file_contents(self, path, revision=None): 112 | tree = self._get_tree(revision) 113 | 114 | try: 115 | self._branch.lock_read() 116 | file_id = tree.path2id(path) 117 | if tree.kind(file_id) != 'file': 118 | # Django VCS expects file_contents to raise an exception on 119 | # directories, while bzrlib returns an empty string so check 120 | # explicitly, and raise an exception 121 | raise FileDoesNotExist 122 | out = tree.get_file(file_id).read() 123 | self._branch.unlock() 124 | except: 125 | raise FileDoesNotExist 126 | 127 | return out 128 | -------------------------------------------------------------------------------- /pyvcs/backends/git.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from operator import itemgetter, attrgetter 3 | import os 4 | 5 | from dulwich.repo import Repo 6 | from dulwich import objects 7 | from dulwich.errors import NotCommitError 8 | 9 | from pyvcs.commit import Commit 10 | from pyvcs.exceptions import CommitDoesNotExist, FileDoesNotExist, FolderDoesNotExist 11 | from pyvcs.repository import BaseRepository 12 | from pyvcs.utils import generate_unified_diff 13 | 14 | 15 | def traverse_tree(repo, tree): 16 | for mode, name, sha in tree.entries(): 17 | if isinstance(repo.get_object(sha), objects.Tree): 18 | for item in traverse_tree(repo, repo.get_object(sha)): 19 | yield os.path.join(name, item) 20 | else: 21 | yield name 22 | 23 | def get_differing_files(repo, past, current): 24 | past_files = {} 25 | current_files = {} 26 | if past is not None: 27 | past_files = dict([(name, sha) for mode, name, sha in past.entries()]) 28 | if current is not None: 29 | current_files = dict([(name, sha) for mode, name, sha in current.entries()]) 30 | 31 | added = set(current_files) - set(past_files) 32 | removed = set(past_files) - set(current_files) 33 | changed = [o for o in past_files if o in current_files and past_files[o] != current_files[o]] 34 | 35 | for name in added: 36 | sha = current_files[name] 37 | yield name 38 | if isinstance(repo.get_object(sha), objects.Tree): 39 | for item in get_differing_files(repo, None, repo.get_object(sha)): 40 | yield os.path.join(name, item) 41 | 42 | for name in removed: 43 | sha = past_files[name] 44 | yield name 45 | if isinstance(repo.get_object(sha), objects.Tree): 46 | for item in get_differing_files(repo, repo.get_object(sha), None): 47 | yield os.path.join(name, item) 48 | 49 | for name in changed: 50 | past_sha = past_files[name] 51 | current_sha = current_files[name] 52 | if isinstance(repo.get_object(past_sha), objects.Tree): 53 | for item in get_differing_files(repo, repo.get_object(past_sha), repo.get_object(current_sha)): 54 | yield os.path.join(name, item) 55 | else: 56 | yield name 57 | 58 | 59 | class Repository(BaseRepository): 60 | def __init__(self, *args, **kwargs): 61 | super(Repository, self).__init__(*args, **kwargs) 62 | 63 | self._repo = Repo(self.path) 64 | 65 | def _get_commit(self, commit_id): 66 | try: 67 | return self._repo[commit_id] 68 | except Exception, e: 69 | raise CommitDoesNotExist("%s is not a commit" % commit_id) 70 | 71 | def _get_obj(self, sha): 72 | return self._repo.get_object(sha) 73 | 74 | def _diff_files(self, commit_id1, commit_id2): 75 | if commit_id1 == 'NULL': 76 | commit_id1 = None 77 | if commit_id2 == 'NULL': 78 | commit_id2 = None 79 | tree1 = self._get_obj(self._get_obj(commit_id1).tree) if commit_id1 else None 80 | tree2 = self._get_obj(self._get_obj(commit_id2).tree) if commit_id2 else None 81 | return sorted(get_differing_files( 82 | self._repo, 83 | tree1, 84 | tree2, 85 | )) 86 | 87 | def get_commit_by_id(self, commit_id): 88 | commit = self._get_commit(commit_id) 89 | parent = commit.parents[0] if len(commit.parents) else 'NULL' 90 | files = self._diff_files(commit.id, parent) 91 | return Commit(commit.id, commit.committer, 92 | datetime.fromtimestamp(commit.commit_time), commit.message, files, 93 | lambda: generate_unified_diff(self, files, parent, commit.id)) 94 | 95 | def get_recent_commits(self, since=None): 96 | if since is None: 97 | #since = datetime.fromtimestamp(self._repo.commit(self._repo.head()).commit_time) - timedelta(days=5) 98 | since = datetime.fromtimestamp(self._repo[self._repo.head()].commit_time) - timedelta(days=5) 99 | pending_commits = self._repo.get_refs().values()#[self._repo.head()] 100 | history = {} 101 | while pending_commits: 102 | head = pending_commits.pop(0) 103 | try: 104 | commit = self._repo[head] 105 | except KeyError: 106 | raise CommitDoesNotExist 107 | if not isinstance(commit, objects.Commit) or commit.id in history or\ 108 | datetime.fromtimestamp(commit.commit_time) <= since: 109 | continue 110 | history[commit.id] = commit 111 | pending_commits.extend(commit.parents) 112 | commits = filter(lambda o: datetime.fromtimestamp(o.commit_time) >= since, history.values()) 113 | commits = map(lambda o: self.get_commit_by_id(o.id), commits) 114 | return sorted(commits, key=attrgetter('time'), reverse=True) 115 | 116 | def list_directory(self, path, revision=None): 117 | if revision is None: 118 | commit = self._get_commit(self._repo.head()) 119 | elif revision is 'NULL': 120 | return ([],[]) 121 | else: 122 | commit = self._get_commit(revision) 123 | tree = self._repo[commit.tree] 124 | path = filter(bool, path.split(os.path.sep)) 125 | while path: 126 | part = path.pop(0) 127 | found = False 128 | for mode, name, hexsha in self._repo[tree.id].entries(): 129 | if part == name: 130 | found = True 131 | tree = self._repo[hexsha] 132 | break 133 | if not found: 134 | raise FolderDoesNotExist 135 | files, folders = [], [] 136 | for mode, name, hexsha in tree.entries(): 137 | if isinstance(self._repo.get_object(hexsha), objects.Tree): 138 | folders.append(name) 139 | elif isinstance(self._repo.get_object(hexsha), objects.Blob): 140 | files.append(name) 141 | return files, folders 142 | 143 | def file_contents(self, path, revision=None): 144 | if revision is None: 145 | commit = self._get_commit(self._repo.head()) 146 | elif revision is 'NULL': 147 | return '' 148 | else: 149 | commit = self._get_commit(revision) 150 | tree = self._repo[commit.tree] 151 | path = path.split(os.path.sep) 152 | path, filename = path[:-1], path[-1] 153 | while path: 154 | part = path.pop(0) 155 | for mode, name, hexsha in self._repo[tree.id].entries(): 156 | if part == name: 157 | tree = self._repo[hexsha] 158 | break 159 | for mode, name, hexsha in tree.entries(): 160 | if name == filename: 161 | return self._repo[hexsha].as_pretty_string() 162 | raise FileDoesNotExist 163 | -------------------------------------------------------------------------------- /pyvcs/backends/hg.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from difflib import unified_diff 3 | import os 4 | 5 | from mercurial import ui 6 | from mercurial.localrepo import localrepository as hg_repo 7 | from mercurial.util import matchdate, Abort 8 | 9 | from pyvcs.commit import Commit 10 | from pyvcs.exceptions import CommitDoesNotExist, FileDoesNotExist, FolderDoesNotExist 11 | from pyvcs.repository import BaseRepository 12 | from pyvcs.utils import generate_unified_diff 13 | 14 | class Repository(BaseRepository): 15 | def __init__(self, path, **kwargs): 16 | """ 17 | path is the filesystem path where the repo exists, **kwargs are 18 | anything extra fnor accessing the repo 19 | """ 20 | self.repo = hg_repo(ui.ui(), path=path) 21 | self.path = path 22 | self.extra = kwargs 23 | 24 | def _ctx_to_commit(self, ctx): 25 | diff = generate_unified_diff(self, ctx.files(), ctx.parents()[0].rev(), ctx.rev()) 26 | 27 | return Commit(ctx.rev(), 28 | ctx.user(), 29 | datetime.fromtimestamp(ctx.date()[0]), 30 | ctx.description(), 31 | ctx.files(), 32 | diff) 33 | 34 | def _latest_from_parents(self, parent_list): 35 | pass 36 | 37 | def get_commit_by_id(self, commit_id): 38 | """ 39 | Returns a commit by it's id (nature of the ID is VCS dependent). 40 | """ 41 | changeset = self.repo.changectx(commit_id) 42 | return self._ctx_to_commit(changeset) 43 | 44 | def get_recent_commits(self, since=None): 45 | """ 46 | Returns all commits since since. If since is None returns all commits 47 | from the last 5 days of commits. 48 | """ 49 | cur_ctx = self.repo.changectx(self.repo.changelog.rev(self.repo.changelog.tip())) 50 | 51 | if since is None: 52 | since = datetime.fromtimestamp(cur_ctx.date()[0]) - timedelta(5) 53 | 54 | changesets = [] 55 | to_look_at = [cur_ctx] 56 | 57 | while to_look_at: 58 | head = to_look_at.pop(0) 59 | to_look_at.extend(head.parents()) 60 | if datetime.fromtimestamp(head.date()[0]) >= since: 61 | changesets.append(head) 62 | else: 63 | break 64 | 65 | return [self._ctx_to_commit(ctx) for ctx in changesets] 66 | 67 | def list_directory(self, path, revision=None): 68 | """ 69 | Returns a list of files in a directory (list of strings) at a given 70 | revision, or HEAD if revision is None. 71 | """ 72 | if revision is None: 73 | chgctx = self.repo.changectx('tip') 74 | else: 75 | chgctx = self.repo.changectx(revision) 76 | file_list = [] 77 | folder_list = set() 78 | found_path = False 79 | for file, node in chgctx.manifest().items(): 80 | if not file.startswith(path): 81 | continue 82 | found_path = True 83 | file = file[len(path):] 84 | if file.count(os.path.sep) >= 1: 85 | folder_list.add(file[:file.find(os.path.sep)]) 86 | else: 87 | file_list.append(file) 88 | if not found_path: 89 | # If we never found the path within the manifest, it does not exist. 90 | raise FolderDoesNotExist 91 | return file_list, sorted(list(folder_list)) 92 | 93 | def file_contents(self, path, revision=None): 94 | """ 95 | Returns the contents of a file as a string at a given revision, or 96 | HEAD if revision is None. 97 | """ 98 | if revision is None: 99 | chgctx = self.repo.changectx('tip') 100 | else: 101 | chgctx = self.repo.changectx(revision) 102 | try: 103 | return chgctx.filectx(path).data() 104 | except KeyError: 105 | raise FileDoesNotExist 106 | -------------------------------------------------------------------------------- /pyvcs/backends/subversion.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from tempfile import NamedTemporaryFile 3 | from time import mktime 4 | import os 5 | 6 | import pysvn 7 | 8 | from pyvcs.commit import Commit 9 | from pyvcs.exceptions import CommitDoesNotExist, FileDoesNotExist, FolderDoesNotExist 10 | from pyvcs.repository import BaseRepository 11 | from pyvcs.utils import generate_unified_diff 12 | 13 | class Repository(BaseRepository): 14 | def __init__(self, *args, **kwargs): 15 | super(Repository, self).__init__(*args, **kwargs) 16 | 17 | self._repo = pysvn.Client(self.path.rstrip(os.path.sep)) 18 | 19 | def _log_to_commit(self, log): 20 | info = self._repo.info(self.path) 21 | base, url = info['repos'], info['url'] 22 | at = url[len(base):] 23 | commit_files = [cp_dict['path'][len(at)+1:] for cp_dict in log['changed_paths']] 24 | 25 | def get_diff(): 26 | # Here we go back through history, 5 commits at a time, searching 27 | # for the first point at which there is a change along our path. 28 | oldrev_log = None 29 | i = 1 30 | # the start of our search is always at the previous commit 31 | while oldrev_log is None: 32 | i += 5 33 | diff_rev_start = pysvn.Revision(pysvn.opt_revision_kind.number, 34 | log['revision'].number - (i - 5)) 35 | diff_rev_end = pysvn.Revision(pysvn.opt_revision_kind.number, 36 | log['revision'].number - i) 37 | log_list = self._repo.log(self.path, 38 | revision_start=diff_rev_start, revision_end=diff_rev_end, 39 | discover_changed_paths=True) 40 | try: 41 | oldrev_log = log_list.pop(0) 42 | except IndexError: 43 | # If we've gone back through the entirety of history and 44 | # still not found anything, bail out, this commit doesn't 45 | # exist along our path (or perhaps at all) 46 | if i >= log['revision'].number: 47 | raise CommitDoesNotExist 48 | 49 | diff = self._repo.diff(NamedTemporaryFile().name, 50 | url_or_path=self.path, revision1=oldrev_log['revision'], 51 | revision2=log['revision'], 52 | ) 53 | return diff 54 | 55 | return Commit(log['revision'].number, log['author'], 56 | datetime.fromtimestamp(log['date']), log['message'], 57 | commit_files, get_diff) 58 | 59 | def get_commit_by_id(self, commit_id): 60 | rev = pysvn.Revision(pysvn.opt_revision_kind.number, commit_id) 61 | 62 | try: 63 | log_list = self._repo.log(self.path, revision_start=rev, 64 | revision_end=rev, discover_changed_paths=True) 65 | except pysvn.ClientError: 66 | raise CommitDoesNotExist 67 | 68 | # If log list is empty most probably the commit does not exists for a 69 | # given path or branch. 70 | try: 71 | log = log_list.pop(0) 72 | except IndexError: 73 | raise CommitDoesNotExist 74 | 75 | return self._log_to_commit(log) 76 | 77 | 78 | def get_recent_commits(self, since=None): 79 | revhead = pysvn.Revision(pysvn.opt_revision_kind.head) 80 | log = self._repo.log(self.path, revision_start=revhead, revision_end=revhead) 81 | 82 | if since is None: 83 | since = datetime.fromtimestamp(log['date']) - timedelta(days=5) 84 | 85 | # Convert from datetime to float (seconds since unix epoch) 86 | utime = mktime(since.timetuple()) 87 | 88 | rev = pysvn.Revision(pysvn.opt_revision_kind.date, utime) 89 | 90 | log_list = self._repo.log(self.path, revision_start=revhead, 91 | revision_end=rev, discover_changed_paths=True) 92 | 93 | commits = [self._log_to_commit(log) for log in log_list] 94 | return commits 95 | 96 | def list_directory(self, path, revision=None): 97 | if revision: 98 | rev = pysvn.Revision(pysvn.opt_revision_kind.number, revision) 99 | else: 100 | rev = pysvn.Revision(pysvn.opt_revision_kind.head) 101 | 102 | dir_path = os.path.join(self.path, path) 103 | 104 | try: 105 | entries = self._repo.list(dir_path, revision=rev, recurse=False) 106 | except pysvn.ClientError: 107 | raise FolderDoesNotExist 108 | 109 | files, folders = [], [] 110 | for file_info, file_pops in entries: 111 | if file_info['kind'] == pysvn.node_kind.dir: 112 | # TODO: Path is not always present, only repos_path 113 | # is guaranteed, in case of looking at a remote 114 | # repository (with no local working copy) we should 115 | # check against repos_path. 116 | if not dir_path.startswith(file_info['path']): 117 | folders.append(os.path.basename(file_info['repos_path'])) 118 | else: 119 | files.append(os.path.basename(file_info['repos_path'])) 120 | 121 | return files, folders 122 | 123 | def file_contents(self, path, revision=None): 124 | if revision: 125 | rev = pysvn.Revision(pysvn.opt_revision_kind.number, revision) 126 | else: 127 | rev = pysvn.Revision(pysvn.opt_revision_kind.head) 128 | 129 | file_path = os.path.join(self.path, path) 130 | 131 | try: 132 | return self._repo.cat(file_path, rev) 133 | except pysvn.ClientError: 134 | raise FileDoesNotExist 135 | -------------------------------------------------------------------------------- /pyvcs/commit.py: -------------------------------------------------------------------------------- 1 | class Commit(object): 2 | def __init__(self, commit_id, author, time, message, files, diff): 3 | """ 4 | commit_id should be a string, author a string, time a datetime object, 5 | message a string, files a list of filenames (strings), and diff a 6 | string 7 | """ 8 | self.commit_id = commit_id 9 | self.author = author 10 | self.time = time 11 | self.message = message 12 | self.files = files 13 | self.diff = diff 14 | 15 | def _get_diff(self): 16 | if callable(self._diff): 17 | self._diff = self._diff() 18 | return self._diff 19 | 20 | def _set_diff(self, diff): 21 | self._diff = diff 22 | 23 | diff = property(_get_diff, _set_diff) 24 | 25 | def __str__(self): 26 | return "" % (self.commit_id, self.author, self.time) 27 | 28 | __repr__ = __str__ 29 | -------------------------------------------------------------------------------- /pyvcs/exceptions.py: -------------------------------------------------------------------------------- 1 | class CommitDoesNotExist(Exception): 2 | pass 3 | 4 | class FileDoesNotExist(Exception): 5 | pass 6 | 7 | class FolderDoesNotExist(Exception): 8 | pass 9 | -------------------------------------------------------------------------------- /pyvcs/repository.py: -------------------------------------------------------------------------------- 1 | class BaseRepository(object): 2 | def __init__(self, path, **kwargs): 3 | """ 4 | path is the filesystem path where the repo exists, **kwargs are 5 | anything extra for accessing the repo 6 | """ 7 | self.path = path 8 | self.extra = kwargs 9 | 10 | def get_commit_by_id(self, commit_id): 11 | """ 12 | Returns a commit by its id (nature of the ID is VCS dependent). 13 | """ 14 | raise NotImplementedError 15 | 16 | def get_recent_commits(self, since=None): 17 | """ 18 | Returns all commits since since. If since is None returns all commits 19 | from the last 5 days. 20 | """ 21 | raise NotImplementedError 22 | 23 | def list_directory(self, path, revision=None): 24 | """ 25 | Returns a tuple of lists of files and folders in a given directory at a 26 | given revision, or HEAD if revision is None. 27 | """ 28 | raise NotImplementedError 29 | 30 | def file_contents(self, path, revision=None): 31 | """ 32 | Returns the contents of a file as a string at a given revision, or 33 | HEAD if revision is None. 34 | """ 35 | raise NotImplementedError 36 | -------------------------------------------------------------------------------- /pyvcs/utils.py: -------------------------------------------------------------------------------- 1 | from difflib import unified_diff 2 | 3 | from pyvcs.exceptions import FileDoesNotExist 4 | 5 | def generate_unified_diff(repository, changed_files, commit1, commit2): 6 | diffs = [] 7 | for file_name in changed_files: 8 | try: 9 | file1 = repository.file_contents(file_name, commit1) 10 | except FileDoesNotExist: 11 | file1 = '' 12 | try: 13 | file2 = repository.file_contents(file_name, commit2) 14 | except FileDoesNotExist: 15 | file2 = '' 16 | diffs.append(unified_diff( 17 | file1.splitlines(), file2.splitlines(), fromfile=file_name, 18 | tofile=file_name, fromfiledate=commit1, tofiledate=commit2 19 | )) 20 | return '\n'.join('\n'.join(map(lambda s: s.rstrip('\n'), diff)) for diff in diffs) 21 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name = 'pyvcs', 5 | version = '0.2.0', 6 | author = 'Alex Gaynor, Justin Lilly', 7 | author_email = 'alex.gaynor@gmail.com', 8 | description = "A lightweight abstraction layer over multiple VCSs.", 9 | url = 'http://github.com/alex/pyvcs/', 10 | packages = find_packages(), 11 | classifiers = [ 12 | 'Development Status :: 4 - Beta', 13 | 'Intended Audience :: Developers', 14 | 'License :: OSI Approved :: BSD License', 15 | 'Programming Language :: Python', 16 | 'Topic :: Software Development :: Version Control' 17 | ], 18 | ) 19 | -------------------------------------------------------------------------------- /tests/alex_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from datetime import datetime 3 | import unittest 4 | 5 | from pyvcs.backends import get_backend 6 | from pyvcs.exceptions import FileDoesNotExist, FolderDoesNotExist 7 | 8 | class GitTest(unittest.TestCase): 9 | def setUp(self): 10 | git = get_backend('git') 11 | self.repo = git.Repository('/home/alex/django_src/') 12 | 13 | def test_commits(self): 14 | commit = self.repo.get_commit_by_id('c3699190186561d5c216b2a77ecbfc487d42a734') 15 | self.assert_(commit.author.startswith('ubernostrum')) 16 | self.assertEqual(commit.time, datetime(2009, 6, 30, 13, 40, 29)) 17 | self.assert_(commit.message.startswith('Fixed #11357: contrib.admindocs')) 18 | self.assertEqual(commit.files, ['django/contrib/admindocs/views.py']) 19 | 20 | def test_recent_commits(self): 21 | results = self.repo.get_recent_commits() 22 | 23 | def test_list_directory(self): 24 | files, folders = self.repo.list_directory('tests/', 'c3699190186561d5c216b2a77ecbfc487d42a734') 25 | self.assertEqual(files, ['runtests.py', 'urls.py']) 26 | self.assertEqual(folders, ['modeltests', 'regressiontests', 'templates']) 27 | self.assertRaises(FolderDoesNotExist, self.repo.list_directory, 'tests/awesometests/') 28 | 29 | def test_file_contents(self): 30 | contents = self.repo.file_contents('django/db/models/fields/related.py', 31 | 'c3699190186561d5c216b2a77ecbfc487d42a734') 32 | self.assertEqual(contents.splitlines()[:2], [ 33 | 'from django.db import connection, transaction', 34 | 'from django.db.backends import util' 35 | ]) 36 | self.assertRaises(FileDoesNotExist, self.repo.file_contents, 'django/db/models/jesus.py') 37 | 38 | def test_diffs(self): 39 | self.assertEqual(self.repo._diff_files( 40 | '35fa967a05d54d5159eb1c620544e050114ab0ed', 41 | 'c3699190186561d5c216b2a77ecbfc487d42a734' 42 | ), ['django/contrib/admindocs/views.py']) 43 | files = [ 44 | 'AUTHORS', 45 | 'django/contrib/gis/db/models/aggregates.py', 46 | 'django/contrib/gis/db/models/query.py', 47 | 'django/contrib/gis/db/models/sql/aggregates.py', 48 | 'django/contrib/gis/db/models/sql/query.py', 49 | 'django/db/backends/__init__.py', 50 | 'django/db/backends/mysql/base.py', 51 | 'django/db/backends/oracle/query.py', 52 | 'django/db/backends/sqlite3/base.py', 53 | 'django/db/models/aggregates.py', 54 | 'django/db/models/__init__.py', 55 | 'django/db/models/manager.py', 56 | 'django/db/models/query.py', 57 | 'django/db/models/query_utils.py', 58 | 'django/db/models/sql/aggregates.py', 59 | 'django/db/models/sql/datastructures.py', 60 | 'django/db/models/sql/query.py', 61 | 'django/db/models/sql/subqueries.py', 62 | 'django/test/testcases.py', 63 | 'docs/index.txt', 64 | 'docs/ref/models/index.txt', 65 | 'docs/ref/models/querysets.txt', 66 | 'docs/topics/db/aggregation.txt', 67 | 'docs/topics/db/index.txt', 68 | 'tests/modeltests/aggregation', 69 | 'tests/modeltests/aggregation/fixtures', 70 | 'tests/modeltests/aggregation/fixtures/initial_data.json', 71 | 'tests/modeltests/aggregation/__init__.py', 72 | 'tests/modeltests/aggregation/models.py', 73 | 'tests/regressiontests/aggregation_regress', 74 | 'tests/regressiontests/aggregation_regress/fixtures', 75 | 'tests/regressiontests/aggregation_regress/fixtures/initial_data.json', 76 | 'tests/regressiontests/aggregation_regress/__init__.py', 77 | 'tests/regressiontests/aggregation_regress/models.py' 78 | ] 79 | self.assertEqual(set(self.repo._diff_files( 80 | '842e1d0dabfe057c1eeb4b6b83de0b2eb7dcb9e6', 81 | 'a6195888efe947f7b23c61248f43f4cab3c5200c', 82 | )), set(files)) 83 | 84 | 85 | if __name__ == '__main__': 86 | unittest.main() 87 | -------------------------------------------------------------------------------- /tests/andrew_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from datetime import datetime 3 | import unittest 4 | 5 | from pyvcs.backends import get_backend 6 | from pyvcs.exceptions import FileDoesNotExist, FolderDoesNotExist 7 | 8 | class BzrTest(unittest.TestCase): 9 | def setUp(self): 10 | bzr = get_backend('bzr') 11 | self.repo = bzr.Repository('/home/andrew/junk/django/') 12 | 13 | def test_commits(self): 14 | commit = self.repo.get_commit_by_id('6460') 15 | self.assert_(commit.author.startswith('gwilson')) 16 | self.assertEqual(commit.time, datetime(2008, 12, 23, 18, 25, 24, 19000)) 17 | self.assert_(commit.message.startswith('Fixed #8245 -- Added a LOADING flag')) 18 | self.assertEqual(commit.files, ['tests/regressiontests/bug8245', 'tests/regressiontests/bug8245/__init__.py', 'tests/regressiontests/bug8245/admin.py', 'tests/regressiontests/bug8245/models.py', 'tests/regressiontests/bug8245/tests.py', 'django/contrib/admin/__init__.py']) 19 | 20 | def test_recent_commits(self): 21 | results = self.repo.get_recent_commits() 22 | 23 | def test_list_directory(self): 24 | files, folders = self.repo.list_directory('tests/', '7254') 25 | self.assertEqual(files, ['runtests.py', 'urls.py']) 26 | self.assertEqual(folders, ['modeltests', 'regressiontests', 'templates']) 27 | self.assertRaises(FolderDoesNotExist, self.repo.list_directory, 'tests/awesometests/') 28 | 29 | def test_file_contents(self): 30 | contents = self.repo.file_contents('django/db/models/fields/related.py', 31 | '7254') 32 | self.assertEqual(contents.splitlines()[:2], [ 33 | 'from django.db import connection, transaction', 34 | 'from django.db.backends import util' 35 | ]) 36 | self.assertRaises(FileDoesNotExist, self.repo.file_contents, 'django/db/models/jesus.py') 37 | 38 | 39 | if __name__ == '__main__': 40 | unittest.main() 41 | -------------------------------------------------------------------------------- /tests/carlos_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from datetime import datetime 3 | import unittest 4 | 5 | from pyvcs.backends import get_backend 6 | from pyvcs.exceptions import FileDoesNotExist, FolderDoesNotExist 7 | 8 | 9 | class SVNTest(unittest.TestCase): 10 | def setUp(self): 11 | svn = get_backend('svn') 12 | self.repo = svn.Repository('/home/clsdaniel/Development/django') 13 | 14 | def test_commits(self): 15 | commit = self.repo.get_commit_by_id(11127) 16 | self.assert_(commit.author.startswith('ubernostrum')) 17 | self.assertEqual(commit.time, datetime(2009, 6, 30, 11, 40, 29, 647241)) 18 | self.assert_(commit.message.startswith('Fixed #11357: contrib.admindocs')) 19 | self.assertEqual(commit.files, ['/django/trunk/django/contrib/admindocs/views.py']) 20 | 21 | def test_recent_commits(self): 22 | results = self.repo.get_recent_commits() 23 | 24 | def test_list_directory(self): 25 | files, folders = self.repo.list_directory('tests/', 11127) 26 | self.assertEqual(files, ['runtests.py', 'urls.py']) 27 | self.assertEqual(folders, ['modeltests', 'regressiontests', 'templates']) 28 | self.assertRaises(FolderDoesNotExist, self.repo.list_directory, 'tests/awesometests/') 29 | 30 | def test_file_contents(self): 31 | contents = self.repo.file_contents('django/db/models/fields/related.py', 11127) 32 | self.assertEqual(contents.splitlines()[:2], [ 33 | 'from django.db import connection, transaction', 34 | 'from django.db.backends import util' 35 | ]) 36 | self.assertRaises(FileDoesNotExist, self.repo.file_contents, 'django/db/models/jesus.py') 37 | 38 | if __name__ == '__main__': 39 | unittest.main() 40 | -------------------------------------------------------------------------------- /tests/justin_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from datetime import datetime 3 | import unittest 4 | 5 | from pyvcs.backends import get_backend 6 | from pyvcs.exceptions import FileDoesNotExist, FolderDoesNotExist 7 | 8 | 9 | class HGTest(unittest.TestCase): 10 | def setUp(self): 11 | hg = get_backend('hg') 12 | self.repo = hg.Repository('/home/jlilly/Code/python/pyvcs/src/mercurial') 13 | 14 | def test_commits(self): 15 | commit = self.repo.get_commit_by_id(45) 16 | self.assert_(commit.author.startswith('mpm')) 17 | self.assertEqual(commit.time, datetime(2005, 5, 10, 4, 34, 57)) 18 | self.assert_(commit.message.startswith('Fix recursion depth')) 19 | 20 | def test_recent_commits(self): 21 | results = self.repo.get_recent_commits() 22 | 23 | def test_list_directory(self): 24 | files, folders = self.repo.list_directory('contrib/', 450) 25 | self.assertEqual(len(files), 3) 26 | self.assertEqual(folders, ['git-viz']) 27 | 28 | def test_file_contents(self): 29 | contents = self.repo.file_contents('tests/test-up-local-change', 450) 30 | self.assertEqual(contents.splitlines()[:1], ['#!/bin/bash']) 31 | 32 | if __name__ == '__main__': 33 | unittest.main() 34 | -------------------------------------------------------------------------------- /tests/setup_git_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | #NOTE: this script depends on git being installed and having access to the /tmp directory... 4 | 5 | echo Setting up the directory for the git repository 6 | mkdir /tmp/pyvcs-test 7 | mkdir /tmp/pyvcs-test/git-test 8 | 9 | cd /tmp/pyvcs-test/git-test 10 | git init 11 | echo this is a test README file for a mock project > README 12 | echo print 'hello, world!' > hello_world.py 13 | git add README 14 | git add hello_world.py 15 | git commit -m "initial add of files to repo" 16 | echo this is a new line added to the README >> README 17 | git commit -a -m "slight change to the README" 18 | 19 | -------------------------------------------------------------------------------- /tests/simple_git_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from datetime import datetime 3 | import unittest 4 | import os 5 | import subprocess 6 | 7 | from pyvcs.backends import get_backend 8 | from pyvcs.exceptions import FileDoesNotExist, FolderDoesNotExist, CommitDoesNotExist 9 | 10 | class GitSimpleTest(unittest.TestCase): 11 | 12 | def setUp(self): 13 | git = get_backend('git') 14 | ret = subprocess.call('./setup_git_test.sh') 15 | self.repo = git.Repository('/tmp/pyvcs-test/git-test/') 16 | 17 | def tearDown(self): 18 | ret = subprocess.call('./teardown_git_test.sh') 19 | 20 | def test_recent_commits(self): 21 | recent_commits = self.repo.get_recent_commits() 22 | self.assertEqual(len(recent_commits),2) 23 | 24 | def test_commits(self): 25 | recent_commits = self.repo.get_recent_commits() 26 | commit = self.repo.get_commit_by_id(recent_commits[1].commit_id) 27 | self.assert_(commit.message.startswith('initial add of files')) 28 | self.assertEqual(commit.time.date(), datetime.today().date()) 29 | self.assertEqual(commit.files, ['README', 'hello_world.py']) 30 | self.assert_('this is a test README file for a mock project' in commit.diff) 31 | self.assertRaises(CommitDoesNotExist,self.repo.get_commit_by_id,'crap') 32 | 33 | def test_list_directory(self): 34 | files, folders = self.repo.list_directory('') 35 | self.assertEqual(files, ['README', 'hello_world.py']) 36 | self.assertEqual(folders, []) 37 | self.assertRaises(FolderDoesNotExist, self.repo.list_directory, 'tests/awesometests/') 38 | 39 | def test_file_contents(self): 40 | contents = self.repo.file_contents('hello_world.py') 41 | self.assertEqual(contents,'print hello, world!\n') 42 | self.assertRaises(FileDoesNotExist, self.repo.file_contents, 'waybettertest.py') 43 | 44 | def test_diffs(self): 45 | recent_commits = self.repo.get_recent_commits() 46 | self.assertEqual(self.repo._diff_files(recent_commits[0].commit_id,recent_commits[1].commit_id),['README']) 47 | 48 | 49 | if __name__ == '__main__': 50 | unittest.main() 51 | -------------------------------------------------------------------------------- /tests/teardown_git_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | rm -r -f /tmp/pyvcs-test 3 | --------------------------------------------------------------------------------