1"""RCS interface module.
2
3Defines the class RCS, which represents a directory with rcs version
4files and (possibly) corresponding work files.
5
6"""
7
8
9import fnmatch
10import os
11import re
12import string
13import tempfile
14
15
16class RCS:
17
18    """RCS interface class (local filesystem version).
19
20    An instance of this class represents a directory with rcs version
21    files and (possible) corresponding work files.
22
23    Methods provide access to most rcs operations such as
24    checkin/checkout, access to the rcs metadata (revisions, logs,
25    branches etc.) as well as some filesystem operations such as
26    listing all rcs version files.
27
28    XXX BUGS / PROBLEMS
29
30    - The instance always represents the current directory so it's not
31    very useful to have more than one instance around simultaneously
32
33    """
34
35    # Characters allowed in work file names
36    okchars = string.ascii_letters + string.digits + '-_=+'
37
38    def __init__(self):
39        """Constructor."""
40        pass
41
42    def __del__(self):
43        """Destructor."""
44        pass
45
46    # --- Informational methods about a single file/revision ---
47
48    def log(self, name_rev, otherflags = ''):
49        """Return the full log text for NAME_REV as a string.
50
51        Optional OTHERFLAGS are passed to rlog.
52
53        """
54        f = self._open(name_rev, 'rlog ' + otherflags)
55        data = f.read()
56        status = self._closepipe(f)
57        if status:
58            data = data + "%s: %s" % status
59        elif data[-1] == '\n':
60            data = data[:-1]
61        return data
62
63    def head(self, name_rev):
64        """Return the head revision for NAME_REV"""
65        dict = self.info(name_rev)
66        return dict['head']
67
68    def info(self, name_rev):
69        """Return a dictionary of info (from rlog -h) for NAME_REV
70
71        The dictionary's keys are the keywords that rlog prints
72        (e.g. 'head' and its values are the corresponding data
73        (e.g. '1.3').
74
75        XXX symbolic names and locks are not returned
76
77        """
78        f = self._open(name_rev, 'rlog -h')
79        dict = {}
80        while 1:
81            line = f.readline()
82            if not line: break
83            if line[0] == '\t':
84                # XXX could be a lock or symbolic name
85                # Anything else?
86                continue
87            i = string.find(line, ':')
88            if i > 0:
89                key, value = line[:i], string.strip(line[i+1:])
90                dict[key] = value
91        status = self._closepipe(f)
92        if status:
93            raise IOError, status
94        return dict
95
96    # --- Methods that change files ---
97
98    def lock(self, name_rev):
99        """Set an rcs lock on NAME_REV."""
100        name, rev = self.checkfile(name_rev)
101        cmd = "rcs -l%s %s" % (rev, name)
102        return self._system(cmd)
103
104    def unlock(self, name_rev):
105        """Clear an rcs lock on NAME_REV."""
106        name, rev = self.checkfile(name_rev)
107        cmd = "rcs -u%s %s" % (rev, name)
108        return self._system(cmd)
109
110    def checkout(self, name_rev, withlock=0, otherflags=""):
111        """Check out NAME_REV to its work file.
112
113        If optional WITHLOCK is set, check out locked, else unlocked.
114
115        The optional OTHERFLAGS is passed to co without
116        interpretation.
117
118        Any output from co goes to directly to stdout.
119
120        """
121        name, rev = self.checkfile(name_rev)
122        if withlock: lockflag = "-l"
123        else: lockflag = "-u"
124        cmd = 'co %s%s %s %s' % (lockflag, rev, otherflags, name)
125        return self._system(cmd)
126
127    def checkin(self, name_rev, message=None, otherflags=""):
128        """Check in NAME_REV from its work file.
129
130        The optional MESSAGE argument becomes the checkin message
131        (default "<none>" if None); or the file description if this is
132        a new file.
133
134        The optional OTHERFLAGS argument is passed to ci without
135        interpretation.
136
137        Any output from ci goes to directly to stdout.
138
139        """
140        name, rev = self._unmangle(name_rev)
141        new = not self.isvalid(name)
142        if not message: message = "<none>"
143        if message and message[-1] != '\n':
144            message = message + '\n'
145        lockflag = "-u"
146        if new:
147            f = tempfile.NamedTemporaryFile()
148            f.write(message)
149            f.flush()
150            cmd = 'ci %s%s -t%s %s %s' % \
151                  (lockflag, rev, f.name, otherflags, name)
152        else:
153            message = re.sub(r'([\"$`])', r'\\\1', message)
154            cmd = 'ci %s%s -m"%s" %s %s' % \
155                  (lockflag, rev, message, otherflags, name)
156        return self._system(cmd)
157
158    # --- Exported support methods ---
159
160    def listfiles(self, pat = None):
161        """Return a list of all version files matching optional PATTERN."""
162        files = os.listdir(os.curdir)
163        files = filter(self._isrcs, files)
164        if os.path.isdir('RCS'):
165            files2 = os.listdir('RCS')
166            files2 = filter(self._isrcs, files2)
167            files = files + files2
168        files = map(self.realname, files)
169        return self._filter(files, pat)
170
171    def isvalid(self, name):
172        """Test whether NAME has a version file associated."""
173        namev = self.rcsname(name)
174        return (os.path.isfile(namev) or
175                os.path.isfile(os.path.join('RCS', namev)))
176
177    def rcsname(self, name):
178        """Return the pathname of the version file for NAME.
179
180        The argument can be a work file name or a version file name.
181        If the version file does not exist, the name of the version
182        file that would be created by "ci" is returned.
183
184        """
185        if self._isrcs(name): namev = name
186        else: namev = name + ',v'
187        if os.path.isfile(namev): return namev
188        namev = os.path.join('RCS', os.path.basename(namev))
189        if os.path.isfile(namev): return namev
190        if os.path.isdir('RCS'):
191            return os.path.join('RCS', namev)
192        else:
193            return namev
194
195    def realname(self, namev):
196        """Return the pathname of the work file for NAME.
197
198        The argument can be a work file name or a version file name.
199        If the work file does not exist, the name of the work file
200        that would be created by "co" is returned.
201
202        """
203        if self._isrcs(namev): name = namev[:-2]
204        else: name = namev
205        if os.path.isfile(name): return name
206        name = os.path.basename(name)
207        return name
208
209    def islocked(self, name_rev):
210        """Test whether FILE (which must have a version file) is locked.
211
212        XXX This does not tell you which revision number is locked and
213        ignores any revision you may pass in (by virtue of using rlog
214        -L -R).
215
216        """
217        f = self._open(name_rev, 'rlog -L -R')
218        line = f.readline()
219        status = self._closepipe(f)
220        if status:
221            raise IOError, status
222        if not line: return None
223        if line[-1] == '\n':
224            line = line[:-1]
225        return self.realname(name_rev) == self.realname(line)
226
227    def checkfile(self, name_rev):
228        """Normalize NAME_REV into a (NAME, REV) tuple.
229
230        Raise an exception if there is no corresponding version file.
231
232        """
233        name, rev = self._unmangle(name_rev)
234        if not self.isvalid(name):
235            raise os.error, 'not an rcs file %r' % (name,)
236        return name, rev
237
238    # --- Internal methods ---
239
240    def _open(self, name_rev, cmd = 'co -p', rflag = '-r'):
241        """INTERNAL: open a read pipe to NAME_REV using optional COMMAND.
242
243        Optional FLAG is used to indicate the revision (default -r).
244
245        Default COMMAND is "co -p".
246
247        Return a file object connected by a pipe to the command's
248        output.
249
250        """
251        name, rev = self.checkfile(name_rev)
252        namev = self.rcsname(name)
253        if rev:
254            cmd = cmd + ' ' + rflag + rev
255        return os.popen("%s %r" % (cmd, namev))
256
257    def _unmangle(self, name_rev):
258        """INTERNAL: Normalize NAME_REV argument to (NAME, REV) tuple.
259
260        Raise an exception if NAME contains invalid characters.
261
262        A NAME_REV argument is either NAME string (implying REV='') or
263        a tuple of the form (NAME, REV).
264
265        """
266        if type(name_rev) == type(''):
267            name_rev = name, rev = name_rev, ''
268        else:
269            name, rev = name_rev
270        for c in rev:
271            if c not in self.okchars:
272                raise ValueError, "bad char in rev"
273        return name_rev
274
275    def _closepipe(self, f):
276        """INTERNAL: Close PIPE and print its exit status if nonzero."""
277        sts = f.close()
278        if not sts: return None
279        detail, reason = divmod(sts, 256)
280        if reason == 0: return 'exit', detail   # Exit status
281        signal = reason&0x7F
282        if signal == 0x7F:
283            code = 'stopped'
284            signal = detail
285        else:
286            code = 'killed'
287        if reason&0x80:
288            code = code + '(coredump)'
289        return code, signal
290
291    def _system(self, cmd):
292        """INTERNAL: run COMMAND in a subshell.
293
294        Standard input for the command is taken from /dev/null.
295
296        Raise IOError when the exit status is not zero.
297
298        Return whatever the calling method should return; normally
299        None.
300
301        A derived class may override this method and redefine it to
302        capture stdout/stderr of the command and return it.
303
304        """
305        cmd = cmd + " </dev/null"
306        sts = os.system(cmd)
307        if sts: raise IOError, "command exit status %d" % sts
308
309    def _filter(self, files, pat = None):
310        """INTERNAL: Return a sorted copy of the given list of FILES.
311
312        If a second PATTERN argument is given, only files matching it
313        are kept.  No check for valid filenames is made.
314
315        """
316        if pat:
317            def keep(name, pat = pat):
318                return fnmatch.fnmatch(name, pat)
319            files = filter(keep, files)
320        else:
321            files = files[:]
322        files.sort()
323        return files
324
325    def _remove(self, fn):
326        """INTERNAL: remove FILE without complaints."""
327        try:
328            os.unlink(fn)
329        except os.error:
330            pass
331
332    def _isrcs(self, name):
333        """INTERNAL: Test whether NAME ends in ',v'."""
334        return name[-2:] == ',v'
335