#!/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(<p4options>)
result = p4.<command>(<options>)
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 (<stdout lines>, <stderr lines>, <return value>).
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...
# <snip>
# # to this changelist. You may de...
#
# Change: 1
#
# Date: 2002/05/08 23:24:54
# <snip>
# 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<depotFile>//.+?)\t'\
'# (?P<action>\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<depotFile>.*?)\#(?P<rev>\d+) # //depot/foo.txt#1
\s-\s(?P<action>\w+) # - edit
\s(default\schange|change\s(?P<change>\d+)) # change 12345
\s\((?P<type>[\w+]+)\) # (text+w)
(\sby\s)? # by
((?P<user>[^\s@]+)@(?P<client>[^\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<flag>) 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<change>\d+) by (?P<user>[^\s@]+)@'\
'(?P<client>[^\s@]+) on (?P<date>[\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<depotFile>.+?)#(?P<rev>\d+) '\
'(?P<action>\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([<list of opened files>], "change description")
OR
p4.change(description="change description for all opened files")
Updating a pending changelist:
p4.change(description="change description",
change=<a pending changelist#>)
OR
p4.change(files=[<new list of files>],
change=<a pending changelist#>)
Deleting a pending changelist:
p4.change(change=<a pending changelist#>, delete=1)
Getting a change description:
ch = p4.change(change=<a pending or submitted changelist#>)
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<change>\d+)"\
" (?P<action>created|updated|deleted)\.$"),
re.compile("^Change (?P<change>\d+) (?P<action>created)"\
" (?P<comment>.+?)\.$"),
re.compile("^Change (?P<change>\d+) (?P<action>updated)"\
", (?P<comment>.+?)\.$"),
# e.g., Change 1 has 1 open file(s) associated with it and can't be deleted.
re.compile("^Change (?P<change>\d+) (?P<comment>.+?)\.$"),
]
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<change>\d+) on "\
"(?P<date>[\d/]+) by (?P<user>[^\s@]+)@"\
"(?P<client>[^\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<change>\d+) on "\
"(?P<date>[\d/]+) by (?P<user>[^\s@]+)@"\
"(?P<client>[^\s@]+) (\*pending\* )?"\
"'(?P<description>.*?)'$")
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<depotFile>.+?)#(?P<rev>\d+) - '\
'(?P<comment>.+?)$')
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<depotFile>.+?)#(?P<rev>\d+) - '\
'(?P<comment>.*)$')
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<depotFile>//.+?)(#(?P<rev>\d+))? - '\
'(?P<comment>.*)$')
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<depotFile>//.*?)#(?P<rev>\d+) - "\
"(?P<action>\w+) change (?P<change>\d+) "\
"\((?P<type>[\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<rev>\d+) change (?P<change>\d+) "\
"(?P<action>\w+) on (?P<date>[\d/]+) by "\
"(?P<user>[^\s@]+)@(?P<client>[^\s@]+) "\
"\((?P<type>[\w+]+)\)( '(?P<description>.*?)')?$")
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<depotFile>//.*?)#(?P<rev>\d+) - "\
"(?P<action>\w+) change (?P<change>\d+) "\
"\((?P<type>[\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<flag>) 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<flag>) 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<depotFile>//.*?)#(?P<rev>\d+) "\
"\((?P<type>\w+)\) ====$")
header2Re = re.compile("^==== (?P<depotFile>//.*?)#(?P<rev>\d+) - "\
"(?P<localFile>.+?) ===="\
"(?P<binary> \(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<flag>) 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<flag> -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<depotFile1>.+?)#(?P<rev1>\d+) "\
"\((?P<type1>[\w+]+)\) - "\
"(?P<depotFile2>.+?)#(?P<rev2>\d+) "\
"\((?P<type2>[\w+]+)\) "\
"==== (?P<summary>\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<depotFile>//.+?)(#(?P<rev>\w+))? - '\
'(?P<comment>.*)$')
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<localFile>.+?) - (merging|vs) '\
'(?P<depotFile>//.+?)#(?P<rev>\d+)$')
diffRe = re.compile('^Diff chunks: (?P<yours>\d+) yours \+ '\
'(?P<theirs>\d+) theirs \+ (?P<both>\d+) both '\
'\+ (?P<conflicting>\d+) conflicting$')
actionRe = re.compile('^(?P<clientFile>//.+?) - (?P<action>.+?)(\.)?$')
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<action>\w+) (?P<depotFile>//.+?)'\
'#(?P<rev>\d+)$')
resultRe = re.compile('^Change (?P<change>\d+) '\
'(?P<action>submitted)\.')
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<depotFile>.+?)(#(?P<rev>\d+))? - '\
'(?P<comment>.*)$')
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