#!/usr/bin/env python # Copyright (c) 2002 Trent Mick # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be included # in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ An OO interface to 'p4' (the Perforce client command line app). Usage: import p4lib p4 = p4lib.P4() result = p4.() For more information see the doc string on each command. For example: print p4lib.P4.opened.__doc__ Implemented commands with test suites: add (limited test), change, delete, opened, resolve, submit Other implemented commands: where, have, describe, changes, sync, edit, files, filelog, print (as print_), revert, client Partially implemented commands: diff2 """ import os import sys import pprint import cmd import re import types import marshal import getopt import tempfile #---- exceptions class P4LibError(Exception): pass #---- global data _version_ = (0, 6, 5) #---- internal logging facility class _Logger: DEBUG, INFO, WARN, ERROR, CRITICAL = range(5) def __init__(self, threshold=None, streamOrFileName=sys.stderr): if threshold is None: self.threshold = self.WARN else: self.threshold = threshold if type(streamOrFileName) == types.StringType: self.stream = open(streamOrFileName, 'w') self._opennedStream = 1 else: self.stream = streamOrFileName self._opennedStream = 0 def __del__(self): if self._opennedStream: self.stream.close() def _getLevelName(self, level): levelNameMap = { self.DEBUG: "DEBUG", self.INFO: "INFO", self.WARN: "WARN", self.ERROR: "ERROR", self.CRITICAL: "CRITICAL", } return levelNameMap[level] def log(self, level, msg): if level < self.threshold: return message = "%s: " % self._getLevelName(level).lower() message = message + msg + "\n" self.stream.write(message) self.stream.flush() def debug(self, msg): self.log(self.DEBUG, msg) def info(self, msg): self.log(self.INFO, msg) def warn(self, msg): self.log(self.WARN, msg) def error(self, msg): self.log(self.ERROR, msg) def fatal(self, msg): self.log(self.CRITICAL, msg) if 1: # normal log = _Logger(_Logger.WARN) else: # debugging log = _Logger(_Logger.DEBUG) #---- internal support stuff def _escapeArg(arg): """Escape the given command line argument for the shell.""" #XXX There is a *lot* more that we should escape here. return arg.replace('"', r'\"') def _joinArgv(argv): r"""Join an arglist to a string appropriate for running. >>> import os >>> _joinArgv(['foo', 'bar "baz']) 'foo "bar \\"baz"' """ cmdstr = "" for arg in argv: if ' ' in arg: cmdstr += '"%s"' % _escapeArg(arg) else: cmdstr += _escapeArg(arg) cmdstr += ' ' if cmdstr.endswith(' '): cmdstr = cmdstr[:-1] # strip trailing space return cmdstr def _run(argv): """Prepare and run the given arg vector, 'argv', and return the results. Returns (, , ). Note: 'argv' may also just be the command string. """ if type(argv) in (types.ListType, types.TupleType): cmd = _joinArgv(argv) else: cmd = argv log.debug("Running '%s'..." % cmd) if sys.platform.startswith('win'): i, o, e = os.popen3(cmd) output = o.readlines() error = e.readlines() i.close() e.close() retval = o.close() else: import popen2 p = popen2.Popen3(cmd, 1) i, o, e = p.tochild, p.fromchild, p.childerr output = o.readlines() error = e.readlines() i.close() o.close() e.close() retval = p.wait() >> 8 if retval: raise P4LibError("Error running '%s': error='%s' retval='%s'"\ % (cmd, error, retval)) return output, error, retval def _specialsLast(a, b, specials): """A cmp-like function, sorting in alphabetical order with 'special's last. """ if a in specials and b in specials: return cmp(a, b) elif a in specials: return 1 elif b in specials: return -1 else: return cmp(a, b) #---- public stuff def makeForm(**kwargs): """Return an appropriate P4 form filled out with the given data. In general this just means tranforming each keyword and (string) value to separate blocks in the form. The section name is the capitalized keyword. Single line values go on the same line as the section name. Multi-line value succeed the section name, prefixed with a tab, except some special section names (e.g. 'differences'). Text for "special" sections are NOT indented, have a blank line after the header, and are placed at the end of the form. Sections are separated by a blank line. The 'files' key is handled specially. It is expected to be a list of dicts of the form: {'action': 'add', # 'action' may or may not be there 'depotFile': '//depot/test_edit_pending_change.txt'} As well, the 'change' value may be an int. """ # Do special preprocessing on the data. for key, value in kwargs.items(): if key == 'files': strval = '' for f in value: if f.has_key('action'): strval += '%(depotFile)s\t# %(action)s\n' % f else: strval += '%(depotFile)s\n' % f kwargs[key] = strval if key == 'change': kwargs[key] = str(value) # Create the form form = '' specials = ['differences'] keys = kwargs.keys() keys.sort(lambda a,b,s=specials: _specialsLast(a,b,s)) for key in keys: value = kwargs[key] if value is None: pass elif len(value.split('\n')) > 1: # a multi-line block form += '%s:\n' % key.capitalize() if key in specials: form += '\n' for line in value.split('\n'): if key in specials: form += line + '\n' else: form += '\t' + line + '\n' else: form += '%s:\t%s\n' % (key.capitalize(), value) form += '\n' return form def parseForm(lines): """Parse an arbitrary Perforce form and return a dict result. The result is a dict with a key for each "section" in the form (the key name will be the section name lowercased), whose value will, in general, be a string with the following exceptions: - A "Files" section will translate into a list of dicts each with 'depotFile' and 'action' keys. - A "Change" value will be converted to an int if appropriate. """ # Example form: # # A Perforce Change Specification. # # # # Change: The change number. 'new' on a n... # # # to this changelist. You may de... # # Change: 1 # # Date: 2002/05/08 23:24:54 # # Description: # create the initial change # # Files: # //depot/test_edit_pending_change.txt # add spec = {} # Parse out all sections into strings. currkey = None # If non-None, then we are in a multi-line block. for line in lines: if line.strip().startswith('#'): continue # skip comment lines if currkey: # i.e. accumulating a multi-line block if line.startswith('\t'): spec[currkey] += line[1:] elif not line.strip(): spec[currkey] += '\n' else: # This is the start of a new section. Trim all # trailing newlines from block section, as # Perforce does. while spec[currkey].endswith('\n'): spec[currkey] = spec[currkey][:-1] currkey = None if not currkey: # i.e. not accumulating a multi-line block if not line.strip(): continue # skip empty lines key, remainder = line.split(':', 1) if not remainder.strip(): # this is a multi-line block currkey = key.lower() spec[currkey] = '' else: spec[key.lower()] = remainder.strip() if currkey: # Trim all trailing newlines from block section, as # Perforce does. while spec[currkey].endswith('\n'): spec[currkey] = spec[currkey][:-1] # Do any special processing on values. for key, value in spec.items(): if key == "change": try: spec[key] = int(value) except ValueError: pass elif key == "files": spec[key] = [] fileRe = re.compile('^(?P//.+?)\t'\ '# (?P\w+)$') for line in value.split('\n'): if not line.strip(): continue match = fileRe.match(line) try: spec[key].append(match.groupdict()) except AttributeError: pprint.pprint(value) pprint.pprint(spec) err = "Internal error: could not parse P4 form "\ "'Files:' section line: '%s'" % line raise P4LibError(err) return spec class P4: """A proxy to the Perforce client app 'p4'.""" def __init__(self, p4='p4', optv=[]): """Create a 'p4' proxy object. "p4" is the Perforce client to execute commands with. Defaults to 'p4'. "optv" is an optional list of p4 options to use for all invocations of the Perforce client app. For example, ['-c', 'my-client', '-p', 'localhost:4242']. """ self.p4 = p4 # Some of p4's options are not appropriate for later # invocations. For example, '-h' and '-V' override output from # running, say, 'p4 opened'; and '-G' and '-s' control the # output format which this module is parsing (hence this module # should control use of those options). optlist, dummy = getopt.getopt(optv, 'hVc:d:H:p:P:u:x:Gs') safeOptv = [] # Canonicalized and safe 'p4' option vector. for opt, optarg in optlist: if opt in ('-h', '-V', '-x'): raise P4LibError("The '%s' p4 option is not appropriate "\ "for p4lib.P4." % opt) elif opt in ('-G', '-s'): log.info("Dropping '%s' option from P4 optv." % opt) else: safeOptv.append(opt) if optarg: safeOptv.append(optarg) self.optv = safeOptv def opened(self, files=[], allClients=0, change=None): """Get a list of files opened in a pending changelist. "files" is a list of files or file wildcards to check. Defaults to the whole client view. "allClients" (-a) specifies to list opened files in all clients. "change" (-c) is a pending change with which to associate the opened file(s). Returns a list of dicts, each representing one opened file. The dict contains the keys 'depotFile', 'rev', 'action', 'change', 'type', and, as well, 'user' and 'client' if the -a option is used. """ # Output examples: # - normal: # //depot/apps/px/px.py#3 - edit default change (text) # - with '-a': # //depot/foo.txt#1 - edit change 12345 (text+w) by trentm@trentm-pliers # - none opened: # foo.txt - file(s) not opened on this client. optv = [] if allClients: optv += ['-a'] if change: optv += ['-c', str(change)] if type(files) in types.StringTypes: files = [files] argv = [self.p4] + self.optv + ['opened'] + optv if files: argv += files output, error, retval = _run(argv) lineRe = re.compile('''^ (?P.*?)\#(?P\d+) # //depot/foo.txt#1 \s-\s(?P\w+) # - edit \s(default\schange|change\s(?P\d+)) # change 12345 \s\((?P[\w+]+)\) # (text+w) (\sby\s)? # by ((?P[^\s@]+)@(?P[^\s@]+))? # trentm@trentm-pliers ''', re.VERBOSE) files = [] for line in output: match = lineRe.search(line) if not match: raise P4LibError("Internal error: 'p4 opened' regex did not "\ "match '%s'. Please report this to the "\ "author." % line) file = match.groupdict() file['rev'] = int(file['rev']) if not file['change']: file['change'] = 'default' else: file['change'] = int(file['change']) for key in file.keys(): if file[key] is None: del file[key] files.append(file) return files def where(self, files=[]): """Show how filenames map through the client view. "files" is a list of files or file wildcards to check. Defaults to the whole client view. Returns a list of dicts, each representing one element of the mapping. Each mapping include a 'depotFile', 'clientFile', and 'localFile' and a 'minus' boolean (indicating if the entry is an Exclusion. """ # Output examples: # -//depot/foo/Py-2_1/... //trentm-ra/foo/Py-2_1/... c:\trentm\foo\Py-2_1\... # //depot/foo/win/... //trentm-ra/foo/win/... c:\trentm\foo\win\... # //depot/foo/Py Exts.dsw //trentm-ra/foo/Py Exts.dsw c:\trentm\foo\Py Exts.dsw # //depot/foo/%1 //trentm-ra/foo/%1 c:\trentm\foo\%1 # The last one is surprising. It comes from using '*' in the # client spec. if type(files) in types.StringTypes: files = [files] argv = [self.p4] + self.optv + ['where'] if files: argv += files output, error, retval = _run(argv) results = [] for line in output: file = {} if line[-1] == '\n': line = line[:-1] if line.startswith('-'): file['minus'] = 1 line = line[1:] else: file['minus'] = 0 depotFileStart = line.find('//') clientFileStart = line.find('//', depotFileStart+2) file['depotFile'] = line[depotFileStart:clientFileStart-1] if sys.platform.startswith('win'): assert ':' not in file['depotFile'],\ "Current parsing cannot handle this line '%s'." % line localFileStart = line.find(':', clientFileStart+2) - 1 else: assert file['depotFile'].find(' /') == -1,\ "Current parsing cannot handle this line '%s'." % line localFileStart = line.find(' /', clientFileStart+2) + 1 file['clientFile'] = line[clientFileStart:localFileStart-1] file['localFile'] = line[localFileStart:] results.append(file) return results def have(self, files=[]): """Get list of file revisions last synced. "files" is a list of files or file wildcards to check. Defaults to the whole client view. Returns a list of dicts, each representing one "hit". Each "hit" includes 'depotFile', 'rev', and 'localFile' keys. """ if type(files) in types.StringTypes: files = [files] argv = [self.p4] + self.optv + ['have'] if files: argv += files output, error, retval = _run(argv) # Output format is 'depot-file#revision - client-file' hits = [] for line in output: if line[-1] == '\n': line = line[:-1] hit = {} hit['depotFile'], line = line.split('#') hit['rev'], hit['localFile'] = line.split(' - ') hit['rev'] = int(hit['rev']) hits.append(hit) return hits def describe(self, change, diffFormat='', shortForm=0): """Get a description of the given changelist. "change" is the changelist number to describe. "diffFormat" (-d) is a flag to pass to the built-in diff routine to control the output format. Valid values are '' (plain, default), 'n' (RCS), 'c' (context), 's' (summary), 'u' (unified). "shortForm" (-s) specifies to exclude the diff from the description. Returns a dict representing the change description. Keys are: 'change', 'date', 'client', 'user', 'description', 'files', 'diff' (the latter is not included iff 'shortForm'). """ if diffFormat not in ('', 'n', 'c', 's', 'u'): raise P4LibError("Incorrect diff format flag: '%s'" % diffFormat) optv = [] if diffFormat: optv.append('-d%s' % diffFormat) if shortForm: optv.append('-s') argv = [self.p4] + self.optv + ['describe'] + optv + [str(change)] output, error, retval = _run(argv) desc = {} output = [line for line in output if not line.strip().startswith("#")] changeRe = re.compile('^Change (?P\d+) by (?P[^\s@]+)@'\ '(?P[^\s@]+) on (?P[\d/ :]+)$') desc = changeRe.match(output[0]).groupdict() desc['change'] = int(desc['change']) filesIdx = output.index("Affected files ...\n") desc['description'] = "" for line in output[2:filesIdx-1]: desc['description'] += line[1:] # drop the leading \t if shortForm: diffsIdx = len(output) else: diffsIdx = output.index("Differences ...\n") desc['files'] = [] fileRe = re.compile('^... (?P.+?)#(?P\d+) '\ '(?P\w+)$') for line in output[filesIdx+2:diffsIdx-1]: file = fileRe.match(line).groupdict() file['rev'] = int(file['rev']) desc['files'].append(file) if not shortForm: desc['diff'] = self._parseDiffOutput(output[diffsIdx+2:]) return desc def change(self, files=None, description=None, change=None, delete=0): """Create, update, delete, or get a changelist description. Creating a changelist: p4.change([], "change description") OR p4.change(description="change description for all opened files") Updating a pending changelist: p4.change(description="change description", change=) OR p4.change(files=[], change=) Deleting a pending changelist: p4.change(change=, delete=1) Getting a change description: ch = p4.change(change=) Returns a dict. When getting a change desc the dict will include 'change', 'user', 'description', 'status', and possibly 'files' keys. For all other actions the dict will include a 'change' key, an 'action' key iff the intended action was successful, and possibly a 'comment' key. Limitations: The -s (jobs) and -f (force) flags are not supported. """ if type(files) in types.StringTypes: files = [files] action = None # note action to know how to parse output below if change and files is None and not description: if delete: # Delete a pending change list. action = 'delete' argv = [self.p4] + self.optv + ['change', '-d', str(change)] else: # Get a change description. action = 'get' argv = [self.p4] + self.optv + ['change', '-o', str(change)] formfile = None else: if delete: raise P4LibError("Cannot specify 'delete' with either "\ "'files' or 'description'.") if change: # Edit a current pending changelist. action = 'update' ch = self.change(change=change) if files is None: # 'files' was not specified. pass elif files == []: # Explicitly specified no files. # Explicitly specified no files. ch['files'] = [] else: depotfiles = [{'depotFile': f['depotFile']}\ for f in self.where(files)] ch['files'] = depotfiles if description: ch['description'] = description form = makeForm(**ch) elif description: # Creating a pending changelist. action = 'create' # Empty 'files' should default to all opened files in the # 'default' changelist. if files is None: files = [{'depotFile': f['depotFile']}\ for f in self.opened()] elif files == []: # Explicitly specified no files. pass else: #TODO: Add test to expect P4LibError if try to use # p4 wildcards in files. Currently *do* get # correct behaviour. files = [{'depotFile': f['depotFile']}\ for f in self.where(files)] form = makeForm(files=files, description=description, change='new') else: raise P4LibError("Incomplete/missing arguments.") # Build submission form file. formfile = tempfile.mktemp() fout = open(formfile, 'w') fout.write(form) fout.close() argv = [self.p4] + self.optv + ['change', '-i', '<', formfile] output, error, retval = _run(argv) if action == 'get': change = parseForm(output) elif action in ('create', 'update', 'delete'): resultRes = [ re.compile("^Change (?P\d+)"\ " (?Pcreated|updated|deleted)\.$"), re.compile("^Change (?P\d+) (?Pcreated)"\ " (?P.+?)\.$"), re.compile("^Change (?P\d+) (?Pupdated)"\ ", (?P.+?)\.$"), # e.g., Change 1 has 1 open file(s) associated with it and can't be deleted. re.compile("^Change (?P\d+) (?P.+?)\.$"), ] for resultRe in resultRes: match = resultRe.match(output[0]) if match: change = match.groupdict() change['change'] = int(change['change']) break else: err = "Internal error: could not parse change '%s' "\ "output: '%s'" % (action, output[0]) raise P4LibError(err) else: raise P4LibError("Internal error: unexpected action: '%s'"\ % action) if formfile: os.remove(formfile) return change def changes(self, files=[], followIntegrations=0, longOutput=0, max=None, status=None): """Return a list of pending and submitted changelists. "files" is a list of files or file wildcards that will limit the results to changes including these files. Defaults to the whole client view. "followIntegrations" (-i) specifies to include any changelists integrated into the given files. "longOutput" (-l) includes changelist descriptions. "max" (-m) limits the results to the given number of most recent relevant changes. "status" (-s) limits the output to 'pending' or 'submitted' changelists. Returns a list of dicts, each representing one change spec. Keys are: 'change', 'date', 'client', 'user', 'description'. """ if max is not None and type(max) != types.IntType: raise P4LibError("Incorrect 'max' value. It must be an integer: "\ "'%s' (type '%s')" % (max, type(max))) if status is not None and status not in ("pending", "submitted"): raise P4LibError("Incorrect 'status' value: '%s'" % status) if type(files) in types.StringTypes: files = [files] optv = [] if followIntegrations: optv.append('-i') if longOutput: optv.append('-l') if max is not None: optv += ['-m', str(max)] if status is not None: optv += ['-s', status] argv = [self.p4] + self.optv + ['changes'] + optv if files: argv += files output, error, retval = _run(argv) changes = [] if longOutput: changeRe = re.compile("^Change (?P\d+) on "\ "(?P[\d/]+) by (?P[^\s@]+)@"\ "(?P[^\s@]+)$") for line in output: if not line.strip(): continue # skip blank lines if line.startswith('\t'): # Append this line (minus leading tab) to last # change's description. changes[-1]['description'] += line[1:] else: change = changeRe.match(line).groupdict() change['change'] = int(change['change']) change['description'] = '' changes.append(change) else: changeRe = re.compile("^Change (?P\d+) on "\ "(?P[\d/]+) by (?P[^\s@]+)@"\ "(?P[^\s@]+) (\*pending\* )?"\ "'(?P.*?)'$") for line in output: match = changeRe.match(line) if match: change = match.groupdict() change['change'] = int(change['change']) changes.append(change) else: raise P4LibError("Internal error: could not parse "\ "'p4 changes' output line: '%s'" % line) return changes def sync(self, files=[], force=0, dryrun=0): """Synchronize the client with its view of the depot. "files" is a list of files or file wildcards to sync. Defaults to the whole client view. "force" (-f) forces resynchronization even if the client already has the file, and clobbers writable files. "dryrun" (-n) causes sync to go through the motions and report results but not actually make any changes. Returns a list of dicts representing the sync'd files. Keys are: 'depotFile', 'rev', 'comment', and possibly 'notes'. """ if type(files) in types.StringTypes: files = [files] optv = [] if force: optv.append('-f') if dryrun: optv.append('-n') argv = [self.p4] + self.optv + ['sync'] + optv if files: argv += files output, error, retval = _run(argv) # Forms of output: # //depot/foo#1 - updating C:\foo # //depot/foo#1 - is opened and not being changed # //depot/foo#1 - is opened at a later revision - not changed # //depot/foo#1 - deleted as C:\foo # ... //depot/foo - must resolve #2 before submitting # There are probably others forms. hits = [] lineRe = re.compile('^(?P.+?)#(?P\d+) - '\ '(?P.+?)$') for line in output: if line.startswith('... '): note = line.split(' - ')[-1].strip() hits[-1]['notes'].append(note) continue match = lineRe.match(line) if match: hit = match.groupdict() hit['rev'] = int(hit['rev']) hit['notes'] = [] hits.append(hit) continue raise P4LibError("Internal error: could not parse 'p4 sync'"\ "output line: '%s'" % line) return hits def edit(self, files, change=None, filetype=None): """Open an existing file for edit. "files" is a list of files or file wildcards to open for edit. "change" (-c) is a pending changelist number in which to put the opened files. "filetype" (-t) specifies to explicitly open the files with the given filetype. Returns a list of dicts representing commentary on each file opened for edit. Keys are: 'depotFile', 'rev', 'comment', 'notes'. """ if type(files) in types.StringTypes: files = [files] optv = [] if change: optv += ['-c', str(change)] if filetype: optv += ['-t', filetype] argv = [self.p4] + self.optv + ['edit'] + optv + files output, error, retval = _run(argv) # Example output: # //depot/build.py#142 - opened for edit # ... //depot/build.py - must sync/resolve #143,#148 before submitting # ... //depot/build.py - also opened by davida@davida-bertha # ... //depot/build.py - also opened by davida@davida-loom # ... //depot/build.py - also opened by davida@davida-marteau # ... //depot/build.py - also opened by trentm@trentm-razor # //depot/BuildNum.txt#3 - currently opened for edit hits = [] lineRe = re.compile('^(?P.+?)#(?P\d+) - '\ '(?P.*)$') for line in output: if line.startswith("..."): # this is a note for the latest hit note = line.split(' - ')[-1].strip() hits[-1]['notes'].append(note) else: hit = lineRe.match(line).groupdict() hit['rev'] = int(hit['rev']) hit['notes'] = [] hits.append(hit) return hits def add(self, files, change=None, filetype=None): """Open a new file to add it to the depot. "files" is a list of files or file wildcards to open for add. "change" (-c) is a pending changelist number in which to put the opened files. "filetype" (-t) specifies to explicitly open the files with the given filetype. Returns a list of dicts representing commentary on each file *attempted* to be opened for add. Keys are: 'depotFile', 'rev', 'comment', 'notes'. If a given file is NOT added then the 'rev' will be None. """ if type(files) in types.StringTypes: files = [files] optv = [] if change: optv += ['-c', str(change)] if filetype: optv += ['-t', filetype] argv = [self.p4] + self.optv + ['add'] + optv + files output, error, retval = _run(argv) # Example output: # //depot/apps/px/p4.py#1 - opened for add # c:\trentm\apps\px\p4.py - missing, assuming text. # # //depot/apps/px/px.py - can't add (already opened for edit) # ... //depot/apps/px/px.py - warning: add of existing file # # //depot/apps/px/px.cpp - can't add existing file # # //depot/apps/px/t#1 - opened for add # hits = [] hitRe = re.compile('^(?P//.+?)(#(?P\d+))? - '\ '(?P.*)$') for line in output: match = hitRe.match(line) if match: hit = match.groupdict() if hit['rev'] is not None: hit['rev'] = int(hit['rev']) hit['notes'] = [] hits.append(hit) else: if line.startswith("..."): note = line.split(' - ')[-1].strip() else: note = line.strip() hits[-1]['notes'].append(note) return hits def files(self, files): """List files in the depot. "files" is a list of files or file wildcards to list. Defaults to the whole client view. Returns a list of dicts, each representing one matching file. Keys are: 'depotFile', 'rev', 'type', 'change', 'action'. """ if type(files) in types.StringTypes: files = [files] if not files: raise P4LibError("Missing/wrong number of arguments.") argv = [self.p4] + self.optv + ['files'] + files output, error, retval = _run(argv) hits = [] fileRe = re.compile("^(?P//.*?)#(?P\d+) - "\ "(?P\w+) change (?P\d+) "\ "\((?P[\w+]+)\)$") for line in output: match = fileRe.match(line) hit = match.groupdict() hit['rev'] = int(hit['rev']) hit['change'] = int(hit['change']) hits.append(hit) return hits def filelog(self, files, followIntegrations=0, longOutput=0, maxRevs=None): """List revision histories of files. "files" is a list of files or file wildcards to describe. "followIntegrations" (-i) specifies to follow branches. "longOutput" (-l) includes changelist descriptions. "maxRevs" (-m) limits the results to the given number of most recent revisions. Returns a list of hits. Each hit is a dict with the following keys: 'depotFile', 'revs'. 'revs' is a list of dicts, each representing one submitted revision of 'depotFile' and containing the following keys: 'action', 'change', 'client', 'date', 'type', 'notes', 'rev', 'user'. """ if maxRevs is not None and type(maxRevs) != types.IntType: raise P4LibError("Incorrect 'maxRevs' value. It must be an "\ "integer: '%s' (type '%s')"\ % (maxRevs, type(maxRevs))) if type(files) in types.StringTypes: files = [files] if not files: raise P4LibError("Missing/wrong number of arguments.") optv = [] if followIntegrations: optv.append('-i') if longOutput: optv.append('-l') if maxRevs is not None: optv += ['-m', str(maxRevs)] argv = [self.p4] + self.optv + ['filelog'] + optv + files output, error, retval = _run(argv) hits = [] revRe = re.compile("^... #(?P\d+) change (?P\d+) "\ "(?P\w+) on (?P[\d/]+) by "\ "(?P[^\s@]+)@(?P[^\s@]+) "\ "\((?P[\w+]+)\)( '(?P.*?)')?$") for line in output: if longOutput and not line.strip(): continue # skip blank lines elif line.startswith('//'): hit = {'depotFile': line.strip(), 'revs': []} hits.append(hit) elif line.startswith('... ... '): hits[-1]['revs'][-1]['notes'].append(line[8:].strip()) elif line.startswith('... '): match = revRe.match(line) if match: d = match.groupdict('') d['change'] = int(d['change']) d['rev'] = int(d['rev']) hits[-1]['revs'].append(d) hits[-1]['revs'][-1]['notes'] = [] else: raise P4LibError("Internal parsing error: '%s'" % line) elif longOutput and line.startswith('\t'): # Append this line (minus leading tab) to last hit's # last rev's description. hits[-1]['revs'][-1]['description'] += line[1:] else: raise P4LibError("Unexpected 'p4 filelog' output: '%s'"\ % line) return hits def print_(self, files, localFile=None, quiet=0): """Retrieve depot file contents. "files" is a list of files or file wildcards to print. "localFile" (-o) is the name of a local file in which to put the output text. "quiet" (-q) suppresses some file meta-information. Returns a list of dicts, each representing one matching file. Keys are: 'depotFile', 'rev', 'type', 'change', 'action', and 'text'. If 'quiet', the first five keys will not be present. The 'text' key will not be present if the file is binary. If both 'quiet' and 'localFile', there will be no hits at all. """ if type(files) in types.StringTypes: files = [files] if not files: raise P4LibError("Missing/wrong number of arguments.") optv = [] if localFile: optv += ['-o', localFile] if quiet: optv.append('-q') # There is *no* to properly and reliably parse out multiple file # output without using -s or -G. Use the latter. argv = [self.p4, '-G'] + self.optv + ['print'] + optv + files cmd = _joinArgv(argv) log.debug("popen3 '%s'..." % cmd) i, o, e = os.popen3(cmd) hits = [] fileRe = re.compile("^(?P//.*?)#(?P\d+) - "\ "(?P\w+) change (?P\d+) "\ "\((?P[\w+]+)\)$") try: startHitWithNextNode = 1 while 1: node = marshal.load(o) if node['code'] == 'info': # Always start a new hit with an 'info' node. match = fileRe.match(node['data']) hit = match.groupdict() hit['change'] = int(hit['change']) hit['rev'] = int(hit['rev']) hits.append(hit) startHitWithNextNode = 0 elif node['code'] == 'text': if startHitWithNextNode: hit = {'text': node['data']} hits.append(hit) else: if not hits[-1].has_key('text')\ or hits[-1]['text'] is None: hits[-1]['text'] = node['data'] else: hits[-1]['text'] += node['data'] startHitWithNextNode = not node['data'] except EOFError: pass return hits def diff(self, files=[], diffFormat='', force=0, satisfying=None, text=0): """Display diff of client files with depot files. "files" is a list of files or file wildcards to diff. "diffFormat" (-d) is a flag to pass to the built-in diff routine to control the output format. Valid values are '' (plain, default), 'n' (RCS), 'c' (context), 's' (summary), 'u' (unified). "force" (-f) forces a diff of every file. "satifying" (-s) limits the output to the names of files satisfying certain criteria: 'a' Opened files that are different than the revision in the depot, or missing. 'd' Unopened files that are missing on the client. 'e' Unopened files that are different than the revision in the depot. 'r' Opened files that are the same as the revision in the depot. "text" (-t) forces diffs of non-text files. Returns a list of dicts representing each file diff'd. If "satifying" is specified each dict will simply include a 'localFile' key. Otherwise, each dict will include 'localFile', 'depotFile', 'rev', and 'binary' (boolean) keys and possibly a 'text' or a 'notes' key iff there are any differences. Generally you will get a 'notes' key for differing binary files. """ if type(files) in types.StringTypes: files = [files] if diffFormat not in ('', 'n', 'c', 's', 'u'): raise P4LibError("Incorrect diff format flag: '%s'" % diffFormat) if satisfying is not None\ and satisfying not in ('a', 'd', 'e', 'r'): raise P4LibError("Incorrect 'satisfying' flag: '%s'" % satisfying) optv = [] if diffFormat: optv.append('-d%s' % diffFormat) if satisfying: optv.append('-s%s' % satisfying) if force: optv.append('-f') if text: optv.append('-t') # There is *no* to properly and reliably parse out multiple file # output without using -s or -G. Use the latter. argv = [self.p4] + self.optv + ['diff'] + optv + files output, error, retval = _run(argv) if satisfying is not None: hits = [{'localFile': line[:-1]} for line in output] else: hits = self._parseDiffOutput(output) return hits def _parseDiffOutput(self, outputLines): hits = [] # Example header lines: # - from 'p4 describe': # ==== //depot/apps/px/ReadMe.txt#5 (text) ==== # - from 'p4 diff': # ==== //depot/apps/px/p4lib.py#12 - c:\trentm\apps\px\p4lib.py ==== # ==== //depot/foo.doc#42 - c:\trentm\foo.doc ==== (binary) header1Re = re.compile("^==== (?P//.*?)#(?P\d+) "\ "\((?P\w+)\) ====$") header2Re = re.compile("^==== (?P//.*?)#(?P\d+) - "\ "(?P.+?) ===="\ "(?P \(binary\))?$") for line in outputLines: header1 = header1Re.match(line) header2 = header2Re.match(line) if header1: hit = header1.groupdict() hit['rev'] = int(hit['rev']) hits.append(hit) elif header2: hit = header2.groupdict() hit['rev'] = int(hit['rev']) hit['binary'] = not not hit['binary'] # get boolean value hits.append(hit) elif not hits[-1].has_key('text')\ and line == "(... files differ ...)\n": hits[-1]['notes'] = [line] else: # This is a diff line. if not hits[-1].has_key('text'): hits[-1]['text'] = '' # XXX 'p4 describe' diff text includes a single # blank line after each header line before the # actual diff. Should this be stripped? hits[-1]['text'] += line return hits def diff2(self, file1, file2, diffFormat='', quiet=0, text=0): """Compare two depot files. "file1" and "file2" are the two files to diff. "diffFormat" (-d) is a flag to pass to the built-in diff routine to control the output format. Valid values are '' (plain, default), 'n' (RCS), 'c' (context), 's' (summary), 'u' (unified). "quiet" (-q) suppresses some meta information and all information if the files do not differ. Returns a dict representing the diff. Keys are: 'depotFile1', 'rev1', 'type1', 'depotFile2', 'rev2', 'type2', 'summary', 'notes', 'text'. There may not be a 'text' key if the files are the same or are binary. The first eight keys will not be present if 'quiet'. Note that the second 'p4 diff2' style is not supported: p4 diff2 [ -d -q -t ] -b branch [ [ file1 ] file2 ] """ if diffFormat not in ('', 'n', 'c', 's', 'u'): raise P4LibError("Incorrect diff format flag: '%s'" % diffFormat) optv = [] if diffFormat: optv.append('-d%s' % diffFormat) if quiet: optv.append('-q') if text: optv.append('-t') # There is *no* way to properly and reliably parse out multiple # file output without using -s or -G. Use the latter. argv = [self.p4, '-G'] + self.optv + ['diff2'] + optv + [file1, file2] cmd = _joinArgv(argv) i, o, e = os.popen3(cmd) diff = {} infoRe = re.compile("^==== (?P.+?)#(?P\d+) "\ "\((?P[\w+]+)\) - "\ "(?P.+?)#(?P\d+) "\ "\((?P[\w+]+)\) "\ "==== (?P\w+)$") try: while 1: node = marshal.load(o) if node['code'] == 'info'\ and node['data'] == '(... files differ ...)': if diff.has_key('notes'): diff['notes'].append(node['data']) else: diff['notes'] = [ node['data'] ] elif node['code'] == 'info': match = infoRe.match(node['data']) d = match.groupdict() d['rev1'] = int(d['rev1']) d['rev2'] = int(d['rev2']) diff.update( match.groupdict() ) elif node['code'] == 'text': if not diff.has_key('text') or diff['text'] is None: diff['text'] = node['data'] else: diff['text'] += node['data'] except EOFError: pass return diff def revert(self, files=[], change=None, unchangedOnly=0): """Discard changes for the given opened files. "files" is a list of files or file wildcards to revert. Default to the whole client view. "change" (-c) will limit to files opened in the given changelist. "unchangedOnly" (-a) will only revert opened files that are not different than the version in the depot. Returns a list of dicts representing commentary on each file reverted. Keys are: 'depotFile', 'rev', 'comment'. """ if type(files) in types.StringTypes: files = [files] optv = [] if change: optv += ['-c', str(change)] if unchangedOnly: optv += ['-a'] if not unchangedOnly and not files: raise P4LibError("Missing/wrong number of arguments.") argv = [self.p4] + self.optv + ['revert'] + optv + files output, error, retval = _run(argv) # Example output: # //depot/hello.txt#1 - was edit, reverted # //depot/test_g.txt#none - was add, abandoned hits = [] hitRe = re.compile('^(?P//.+?)(#(?P\w+))? - '\ '(?P.*)$') for line in output: match = hitRe.match(line) if match: hit = match.groupdict() try: hit['rev'] = int(hit['rev']) except ValueError: pass hits.append(hit) else: raise P4LibError("Internal parsing error: '%s'" % line) return hits def resolve(self, files=[], autoMode='', force=0, dryrun=0, text=0, verbose=0): """Merge open files with other revisions or files. This resolve, for obvious reasons, only supports the options to 'p4 resolve' that will result is *no* command line interaction. 'files' is a list of files, of file wildcards, to resolve. 'autoMode' (-a*) tells how to resolve merges. See below for valid values. 'force' (-f) allows previously resolved files to be resolved again. 'dryrun' (-n) lists the integrations that *would* be performed without performing them. 'text' (-t) will force a textual merge, even for binary file types. 'verbose' (-v) will cause markers to be placed in all changed files not just those that conflict. Valid values of 'autoMode' are: '' '-a' I believe this is equivalent to '-am'. 'f', 'force' '-af' Force acceptance of merged files with conflicts. 'm', 'merge' '-am' Attempts to merge. 's', 'safe' '-as' Does not attempt to merge. 't', 'theirs' '-at' Accepts "their" changes, OVERWRITING yours. 'y', 'yours' '-ay' Accepts your changes, OVERWRITING "theirs". Invalid values of 'autoMode': None As if no -a option had been specified. Invalid because this may result in command line interaction. Returns a list of dicts representing commentary on each file for which a resolve was attempted. Keys are: 'localFile', 'clientFile' 'comment', and 'action'; and possibly 'diff chunks' if there was anything to merge. """ if type(files) in types.StringTypes: files = [files] optv = [] if autoMode is None: raise P4LibError("'autoMode' must be non-None, otherwise "\ "'p4 resolve' may initiate command line "\ "interaction, which will hang this method.") else: optv += ['-a%s' % autoMode] if force: optv += ['-f'] if dryrun: optv += ['-n'] if text: optv += ['-t'] if verbose: optv += ['-v'] argv = [self.p4] + self.optv + ['resolve'] + optv + files output, error, retval = _run(argv) hits = [] # Example output: # C:\rootdir\foo.txt - merging //depot/foo.txt#2 # Diff chunks: 0 yours + 0 theirs + 0 both + 1 conflicting # //client-name/foo.txt - resolve skipped. # Proposed result: # [{'localFile': 'C:\\rootdir\\foo.txt', # 'depotFile': '//depot/foo.txt', # 'rev': 2 # 'clientFile': '//client-name/foo.txt', # 'diff chunks': {'yours': 0, 'theirs': 0, 'both': 0, # 'conflicting': 1} # 'action': 'resolve skipped'}] # # Example output: # C:\rootdir\foo.txt - vs //depot/foo.txt#2 # //client-name/foo.txt - ignored //depot/foo.txt # Proposed result: # [{'localFile': 'C:\\rootdir\\foo.txt', # 'depotFile': '//depot/foo.txt', # 'rev': 2 # 'clientFile': '//client-name/foo.txt', # 'diff chunks': {'yours': 0, 'theirs': 0, 'both': 0, # 'conflicting': 1} # 'action': 'ignored //depot/foo.txt'}] # introRe = re.compile('^(?P.+?) - (merging|vs) '\ '(?P//.+?)#(?P\d+)$') diffRe = re.compile('^Diff chunks: (?P\d+) yours \+ '\ '(?P\d+) theirs \+ (?P\d+) both '\ '\+ (?P\d+) conflicting$') actionRe = re.compile('^(?P//.+?) - (?P.+?)(\.)?$') for line in output: match = introRe.match(line) if match: hit = match.groupdict() hit['rev'] = int(hit['rev']) hits.append(hit) log.info("parsed resolve 'intro' line: '%s'" % line.strip()) continue match = diffRe.match(line) if match: diff = match.groupdict() diff['yours'] = int(diff['yours']) diff['theirs'] = int(diff['theirs']) diff['both'] = int(diff['both']) diff['conflicting'] = int(diff['conflicting']) hits[-1]['diff chunks'] = diff log.info("parsed resolve 'diff' line: '%s'" % line.strip()) continue match = actionRe.match(line) if match: hits[-1].update(match.groupdict()) log.info("parsed resolve 'action' line: '%s'" % line.strip()) continue raise P4LibError("Internal error: could not parse 'p4 resolve' "\ "output line: line='%s' argv=%s" % (line, argv)) return hits def submit(self, files=None, description=None, change=None): """Submit open files to the depot. There are two ways to call this method: - Submit specific files: p4.submit([...], "checkin message") - Submit a pending changelist: p4.submit(change=123) Note: 'change' should always be specified with a keyword argument. I reserve the right to extend this method by adding kwargs *before* the change arg. So p4.submit(None, None, 123) is not guaranteed to work. Returns a dict with a 'files' key (which is a list of dicts with 'depotFile', 'rev', and 'action' keys), and 'action' (=='submitted') and 'change' keys iff the submit is succesful. Note: An equivalent for the '-s' option to 'p4 submit' is not supported, because I don't know how to use it and have never. Nor is the '-i' option supported, although it *is* used internally to drive 'p4 submit'. """ #TODO: # - test when submission fails because files need to be # resolved if type(files) in types.StringTypes: files = [files] if change and not files and not description: argv = [self.p4] + self.optv + ['submit', '-c', str(change)] elif not change and files is not None and description: # Empty 'files' should default to all opened files in the # 'default' changelist. if not files: files = [{'depotFile': f['depotFile']}\ for f in self.opened()] else: #TODO: Add test to expect P4LibError if try to use # p4 wildcards in files. files = [{'depotFile': f['depotFile']}\ for f in self.where(files)] # Build submission form file. formfile = tempfile.mktemp() form = makeForm(files=files, description=description, change='new') fout = open(formfile, 'w') fout.write(form) fout.close() argv = [self.p4] + self.optv + ['submit', '-i', '<', formfile] else: raise P4LibError("Incorrect arguments. You must specify "\ "'change' OR you must specify 'files' and "\ "'description'.") output, error, retval = _run(argv) # Example output: # Change 1 created with 1 open file(s). # Submitting change 1. # Locking 1 files ... # add //depot/test_simple_submit.txt#1 # Change 1 submitted. # This returns (similar to .change() output): # {'change': 1, # 'action': 'submitted', # 'files': [{'depotFile': '//depot/test_simple_submit.txt', # 'rev': 1, # 'action': 'add'}]} # i.e. only the file actions and the last "submitted" line are # looked for. skipRes = [ re.compile('^Change \d+ created with \d+ open file\(s\)\.$'), re.compile('^Submitting change \d+\.$'), re.compile('^Locking \d+ files \.\.\.$')] fileRe = re.compile('^(?P\w+) (?P//.+?)'\ '#(?P\d+)$') resultRe = re.compile('^Change (?P\d+) '\ '(?Psubmitted)\.') result = {'files': []} for line in output: match = fileRe.match(line) if match: file = match.groupdict() file['rev'] = int(file['rev']) result['files'].append(file) log.info("parsed submit 'file' line: '%s'" % line.strip()) continue match = resultRe.match(line) if match: result.update(match.groupdict()) result['change'] = int(result['change']) log.info("parsed submit 'result' line: '%s'" % line.strip()) continue # The following is technically just overhead but it is # considered more robust if we explicitly try to recognize # all output. Unrecognized output can be warned or raised. for skipRe in skipRes: match = skipRe.match(line) if match: log.info("parsed submit 'skip' line: '%s'" % line.strip()) break else: log.warn("Unrecognized output line from running %s: '%s'. "\ "Please report this to the maintainer."\ % (argv, line)) return result def delete(self, files, change=None): """Open an existing file to delete it from the depot. "files" is a list of files or file wildcards to open for delete. "change" (-c) is a pending change with which to associate the opened file(s). Returns a list of dicts each representing a file *attempted* to be open for delete. Keys are 'depotFile', 'rev', and 'comment'. If the file could *not* be openned for delete then 'rev' will be None. """ if type(files) in types.StringTypes: files = [files] optv = [] if change: optv += ['-c', str(change)] argv = [self.p4] + self.optv + ['delete'] + optv + files output, error, retval = _run(argv) # Example output: # //depot/foo.txt#1 - opened for delete # //depot/foo.txt - can't delete (already opened for edit) hits = [] hitRe = re.compile('^(?P.+?)(#(?P\d+))? - '\ '(?P.*)$') for line in output: match = hitRe.match(line) if match: hit = match.groupdict() if hit['rev'] is not None: hit['rev'] = int(hit['rev']) hits.append(hit) else: raise P4LibError("Internal error: could not parse "\ "'p4 delete' output line: '%s'. Please "\ "report this to the author." % line) return hits