# # WC.py - working copy interface # # Copyright (c) 2006-2007 The DITrack Project, www.ditrack.org. # # $Id: WC.py 2052 2007-09-07 15:13:43Z gli $ # $HeadURL: https://127.0.0.1/ditrack/src/tags/0.7/DITrack/DB/WC.py $ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # import datetime import errno import os.path import re import shelve import shutil # DITrack modules import DITrack.SVN import DITrack.DB.Issue # Comment file name regular expression. comment_fname_re = re.compile("^comment(\\d+)$") META_FILE = "meta" # # Classes # # XXX: should use inheritance to avoid code duplication in different Ops. class WC_Operation: """ Class representing a single operation in a working copy. """ def __init__(self): raise NotImplementedError def fill_txn(self, txn): """ Include the change into given Subversion transaction TXN. """ raise NotImplementedError, self class WC_Op_Modification(WC_Operation): """ Modification of an existing file. """ def __init__(self, fname): self.fname = fname def fill_txn(self, txn): # Just record the file name in the transaction. txn.include(self.fname) class WC_Op_NewDirectory(WC_Operation): """ New directory creation. """ def __init__(self, fname): self.fname = fname def fill_txn(self, txn): txn.add(self.fname) class WC_Op_NewFile(WC_Operation): """ New file addition. """ def __init__(self, fname): self.fname = fname def fill_txn(self, txn): txn.add(self.fname) class WC_Op_RemovedFile(WC_Operation): """ A file removal. """ def __init__(self, fname): self.fname = fname def fill_txn(self, txn): txn.remove(self.fname) class WorkingCopy: """ Class encapsulating working copy operations. """ def __del__(self): self.meta.close() def __getitem__(self, key): """ Read an issue named by KEY from the working copy. """ # We deal with issues only (not comments). assert("." not in key) issue_dir = self._issue_path(key) return DITrack.DB.Issue.Issue.load(issue_dir) def __init__(self, path, svn_path): """ PATH is a path to an issue database. SVN_PATH is a path to Subversion command-line client. """ self.path = path self.data_path = os.path.join(path, "data") self.next_num_path = os.path.join(path, "meta", "next-id") self.svn = DITrack.SVN.Client(svn_path) datadir = os.path.join(path, DITrack.DB.Common.LOCAL_DT_DIR) fname = os.path.join(datadir, META_FILE) self.meta = shelve.open(fname) def _get_next_comment_number(self, path): """ Look up the issue directory PATH (it has to exist) and return next available comment number. """ assert os.path.exists(path), "path='%s'" % path numbers = [] for fn in os.listdir(path): m = comment_fname_re.match(fn) if m: numbers.append(int(m.group(1))) numbers.sort() if numbers: return numbers[-1] + 1 else: return 0 def _get_next_issue_number(self): """ Look up the next available issue number and return it. """ if not os.path.exists(self.next_num_path): raise DITrack.DB.Exceptions.CorruptedDBError( self.next_num_path + " doesn't exist") f = open(self.next_num_path) s = f.readline() if not s: f.close() raise DITrack.DB.Exceptions.CorruptedDBError( self.next_num_path + " is empty") try: num = int(s.strip()) except ValueError: raise DITrack.DB.Exceptions.CorruptedDBError( "Contents of " + self.next_num_path + " are invalid") f.close() return num def _issue_path(self, id): """ Returns directory path of issue ID. Raises KeyError if no such issue exists. """ issue_dir = os.path.join(self.data_path, "i%s" % id) if not os.path.exists(issue_dir): raise KeyError return issue_dir def _set_next_issue_number(self, num): """ Update the next available issue number. Returns WC_Operation obejct. """ f = open(self.next_num_path, "w") f.write("%s\n" % num) f.close() return WC_Op_Modification(self.next_num_path) def comments(self, issue_id): """ Return a sorted list of tuples (ID, COMMENT) for all comments of the issue ISSUE_ID in the working copy. """ # XXX: do we need this? issue_path = self._issue_path(issue_id) comments = {} for fn in os.listdir(issue_path): m = comment_fname_re.match(fn) if m: n = m.group(1) fname = os.path.join(issue_path, "comment" + n) comments[int(n)] = DITrack.DB.Issue.Comment.load(fname) keys = comments.keys() keys.sort() return [(x, comments[x]) for x in keys] def commit(self, changes, logmsg): """ Attempts to commit the CHANGES list of operations with log message LOGMSG. """ txn = self.svn.start_txn() for op in changes: op.fill_txn(txn) # XXX: if failed to commit, revert local changes. txn.commit(logmsg) def issues(self): """ Return a sorted list of tuples (ID, ISSUE) for all issues in the working copy. """ issue_re = re.compile("^i(\\d+)$") lst = [] for fn in os.listdir(self.data_path): m = issue_re.match(fn) if m: lst.append(int(m.group(1))) lst.sort() res = [] for id in lst: path = os.path.join(self.data_path, "i%s" % id) issue = DITrack.DB.Issue.Issue.load(path) res.append((id, issue)) return res def new_comment(self, issue_num, comment): """ Writes a comment COMMENT for the issue ISSUE_NUM to the working copy and returns a tuple (NUMBER, OPS) where the NUMBER is a newly assigned comment number and the OPS is a list of corresponding operations. """ issue_dir = self._issue_path(issue_num) # Fetch next comment number num = self._get_next_comment_number(issue_dir) fname = os.path.join(issue_dir, "comment%d" % num) # Write out the comment. f = open(fname, 'w') comment.write(dest=f) f.close() # Remember that ops = [WC_Op_NewFile(fname)] # Handle the attachments if comment.attachments_changed(): # XXX: What if the names collide? I.e. a user has removed # [previously existent] file 'a.txt' and then added another # 'a.txt'? This should be prohibited. added = comment.attachments_added() removed = comment.attachments_removed() attaches_path = os.path.join(issue_dir, "attaches") # If we are removing something, it means that the attaches # directory is already there, otherwise it's a corrupted working # copy. if removed: if not os.path.exists(attaches_path): raise DITrack.DB.Exceptions.CorruptedDBError( "%s doesn't exist; yet comment %d tries to remove " "attachment(s)" % (attaches_path, num) ) for a in removed: attach_fname = os.path.join(attaches_path, a.name) assert os.path.exists(attach_fname) os.unlink(attach_fname) ops.append(WC_Op_RemovedFile(attach_fname)) else: # No attachments removed. So we might need to create the # attachments directory. add_dir = True try: os.mkdir(attaches_path) except OSError, (err, str): if err == errno.EEXIST: # XXX: Assuming its addition has already been # scheduled. This might not be true though. add_dir = False else: raise if add_dir: ops.append(WC_Op_NewDirectory(attaches_path)) for a in added: assert a.is_local attach_fname = os.path.join(attaches_path, a.name) # XXX: shouldn't be assertions really assert os.path.exists(a.path), "path=%s" % a.path assert not os.path.exists(attach_fname), \ "path=%s" % attach_fname shutil.copyfile(a.path, attach_fname) ops.append(WC_Op_NewFile(attach_fname)) return num, ops def new_issue(self, issue): """ Writes issue ISSUE data to the working copy and returns a tuple (NUMBER, OPS) where the NUMBER is a newly assigned issue number and the OPS is a list of corresponding operations. """ # Fetch next issue number num = self._get_next_issue_number() issue_dir = os.path.join(self.data_path, "i%s" % num) ops = [] # Update next issue numer ops.append(self._set_next_issue_number(num + 1)) # Create the issue directory os.mkdir(issue_dir, 0755) # Remember that we've created the directory ops.append(WC_Op_NewDirectory(issue_dir)) # XXX: for now we assume that the very first comment in this issue is # named 'A'. This may not be actually the case. Also, we write out one # comment only (as 'comment0'). assert("A" in issue) fname = os.path.join(issue_dir, "comment0") # Write out the info f = open(fname, 'w') issue["A"].write(dest=f) f.close() # Remember that ops.append(WC_Op_NewFile(fname)) return num, ops def update(self, maxage=None): """ Update the working copy (sync with the repository). If MAXAGE is None, update the database. If the database was updated more than MAXAGE seconds back, update the database. Otherwise return taking no action. On success returns the UpdateStatus object returned by the backend or None if no update happened. On failure raises DITrack.DB.Exceptions.BackendError, with the backend exception object stored inside for later examination. """ if (maxage is not None) and ("last_update" in self.meta): maxage = int(maxage) assert maxage >= 0, maxage maxage = datetime.timedelta(days=0, seconds=maxage) delta = datetime.datetime.today() - self.meta["last_update"] if delta <= maxage: # No update needed return None try: us = self.svn.update(self.path) except DITrack.Backend.Common.Error, e: raise DITrack.DB.Exceptions.BackendError( "Failed to update the database", e ) self.meta["last_update"] = datetime.datetime.today() return us