# Perforce Defect Tracking Integration Project # # # P4.PY -- PYTHON INTERFACE TO PERFORCE # # Gareth Rees, Ravenbrook Limited, 2000-09-25 # # # 1. INTRODUCTION # # This module defines the 'p4' class, which provides an interface to Perforce. # # "p4 help undoc" says: # # p4 -G # Causes all output (and batch input for form commands with -i) # to be formatted as marshalled Python dictionary objects. # # The intended readership of this document is project developers. # # This document is not confidential. # # # 1.1. Using the p4 class # # To use this class, create an instance, passing appropriate parameters if # necessary (if parameters are missing, the interface doesn't supply values for # them, so Perforce will pick up its normal defaults from environment # variables). # # import p4 # p4i = p4.p4(port = 'perforce:1666', user = 'root') # # The 'run' method takes a Perforce command and returns a list of dictionaries; # for example: # # >>> for c in p4i.run('changes -m 2'): # ... print c['change'], c['desc'] # ... # 10021 Explaining how to use the autom # 10020 Archiving new mail # # To pass information to Perforce, supply a dictionary as the second argument, # for example: # # >>> job = p4i.run('job -o job000001')[0] # >>> job['Title'] = string.replace(job['Title'], 'p4dti', 'P4DTI') # >>> p4i.run('job -i', job) # [{'code': 'info', 'data': 'Job job000001 saved.', 'level': 0}] # # Note the [0] at the end of line 1 of the above example: the run() method # always returns a list, even of 1 element. This point is easy to forget. import catalog import marshal import os import re import string import tempfile import types error = 'Perforce error' # 2. THE P4 CLASS class p4: client = None client_executable = None logger = None password = None port = None user = None # 2.1. Create an instance # # We supply a default value for the client_executable parameter, but for no # others; Perforce will use its own default values if these are not # supplied. If logger is None then no messages will be logged. # # We check that the Perforce client named by the client_executable # parameter is recent enough that it supports the -G option. It's # important to check this now because the error that we would get if we # just tried using the -G flag is "ValueError: bad marshal data" (the # marshal module is failing to read Perforce's error message "Invalid # option: -G."). # # We check that the Perforce client version supports "p4 -G" by running "p4 # -V" which produces some lines of text output, one of which looks like # "Rev. P4/NTX86/2000.2/19520 (2000/12/18)." The changelevel in this case # is 19520. If no line looks like this, then raise an error anyway. (This # makes the module fragile if Perforce change the format of the output of # "p4 -V".) # # We check that the Perforce server named by the port parameter is recent # enough that it supports p4 -G jobspec -i. We check the server # changelevel by running the "info" command and looking through the output # for a line like "Server version: P4D/FREEBSD/2000.2/19520 (2000/12/18)". # The last number before the date is the server changelevel. def __init__(self, client = None, client_executable = 'p4', logger = None, password = None, port = None, user = None): self.client = client self.client_executable = client_executable self.logger = logger self.password = password self.port = port self.user = user client_changelevel = 0 supported_client = 16895 p4_exe = self.client_executable if ' ' in p4_exe: command = '"%s" -V' % p4_exe else: command = '%s -V' % p4_exe lines = os.popen(command, 'r').readlines() client_version_re = re.compile('Rev\\. [^/]+/[^/]+/[^/]+/([0-9]+)') for l in lines: match = client_version_re.match(l) if match: client_changelevel = int(match.group(1)) if client_changelevel < supported_client: # "Perforce client changelevel %d is not supported by # P4DTI. Client must be at changelevel %d or above." raise error, catalog.msg(704, (client_changelevel, supported_client)) else: break if not client_changelevel: # "The command '%s' didn't report a recognizable version number. # Check your setting for the 'p4_client_executable' parameter." raise error, catalog.msg(705, command) # Check that the Perforce server version is supported by the # integration. server_version_re = re.compile('Server version: ' '[^/]+/[^/]+/[^/]+/([0-9]+)') server_changelevel = 0 supported_server = 18974 for x in self.run('info'): if x.has_key('code') and x['code'] == 'info' and x.has_key('data'): match = server_version_re.match(x['data']) if match: server_changelevel = int(match.group(1)) if server_changelevel < supported_server: # "The Perforce server changelevel %d is not supported # by the P4DTI. The server must be at changelevel %d # or above." raise error, catalog.msg(834, (server_changelevel, supported_server)) else: break if not server_changelevel: # "The Perforce command 'p4 info' didn't report a recognisable # version." raise error, catalog.msg(835) # 2.2. Write a message to the log # # But only if a logger was supplied. def log(self, id, args = ()): if self.logger: msg = catalog.msg(id, args) self.logger.log(msg) # 2.3. Run a Perforce command # # run(arguments, input): Run the Perforce client with the given # command-line arguments, passing the dictionary 'input' to the client's # standard input. # # The arguments should be a Perforce command and its arguments, like "jobs # -o //foo/...". Options should generally include -i or -o to avoid forms # being put up interactively. # # Return a list of dictionaries containing the output of the Perforce # command. (Each dictionary contains one Perforce entity, so "job -o" will # return a list of one element, but "jobs -o" will return a list of many # elements.) def run(self, arguments, input = None): assert isinstance(arguments, types.StringType) assert input is None or isinstance(input, types.DictType) # Build a command line suitable for use with CMD.EXE on Windows NT, or # /bin/sh on POSIX. Make sure to quote the Perforce command if it # contains spaces. See job000049. if ' ' in self.client_executable: command_words = ['"%s"' % self.client_executable] else: command_words = [self.client_executable] command_words.append('-G') if self.port: command_words.extend(['-p', self.port]) if self.user: command_words.extend(['-u', self.user]) if self.password: command_words.extend(['-P', self.password]) if self.client: command_words.extend(['-c', self.client]) command_words.append(arguments) # Pass the input dictionary (if any) to Perforce. temp_filename = None if input: tempfile.template = 'p4dti_data' temp_filename = tempfile.mktemp() # Python marshalled dictionaries are binary, so use mode 'wb'. temp_file = open(temp_filename, 'wb') marshal.dump(input, temp_file) temp_file.close() command_words.extend(['<', temp_filename]) # "Perforce input: '%s'." self.log(700, input) command = string.join(command_words, ' ') # "Perforce command: '%s'." self.log(701, command) # Python marshalled data is in binary, so we need mode 'rb'. However, # os.popen() doesn't support this mode on POSIX, so we use 'r' there. if os.name == 'nt': mode = 'rb' elif os.name == 'posix': mode = 'r' else: # "The Perforce interface does not support the operating system # '%s'." raise self.error, catalog.msg(709, os.name) stream = os.popen(command, mode) # Read the results of the Perforce command. results = [] try: while 1: results.append(marshal.load(stream)) except EOFError: if (temp_filename): os.remove(temp_filename) # Check the exit status of the Perforce command, rather than simply # returning empty output when the command didn't run for some reason # (such as the Perforce server being down). This code was inserted to # resolve job job000158. RB 2000-12-14 exit_status = stream.close() if exit_status != None: # "Perforce status: '%s'." self.log(702, exit_status) # "Perforce results: '%s'." self.log(703, results) # Check for errors from Perforce (either errors returned in the data, # or errors signalled by the exit status, or both) and raise a Python # exception. # # Perforce signals an error by the presence of a 'code' key in the # dictionary output. (This isn't a totally reliable way to spot an # error in a Perforce command, because jobs can have 'code' fields too. # See job000003. However, the P4DTI makes sure that its jobs don't # have such a field.) if (len(results) == 1 and results[0].has_key('code') and results[0]['code'] == 'error'): msg = results[0]['data'] if exit_status: # "%s The Perforce client exited with error code %d." raise error, catalog.msg(706, (msg, exit_status)) else: # "%s" raise error, catalog.msg(708, msg) elif exit_status: # "The Perforce client exited with error code %d. The # server might be down; the server address might be # incorrect; or your Perforce licence might have expired." raise error, catalog.msg(707, exit_status) else: return results # 2.4. Comparing jobspec field descriptions # # This is a function for passing to sort() which # allows us to sort jobspec field descriptions based on # the field number. def compare_field_by_number(self, x, y): if x[0] < y[0]: return -1 elif x[0] > y[0]: return 1 else: # "Jobspec fields '%s' and '%s' have the same # number %d." raise error, catalog.msg(710, (x[1], y[1], x[0])) # 2.5. Install a new jobspec # # We have to build a dictionary suitable for passing to "p4 -G # jobspec -i". This means that it will look like this: # # { 'Comments': '# Form comments...', # 'Fields0': '101 Job word 32 required', # 'Fields1': '102 State select 32 required', # 'Values1': '_new/assigned/closed/verified/deferred', # 'Presets1': '_new', # ... # } # # See [GDR 2000-10-16, 8.4] for the format of the "description" argument. def install_jobspec(self, description): comment, fields = description assert isinstance(fields, types.ListType) # "Installing jobspec from comment '%s' and fields %s." self.log(712, (comment, fields)) for field in fields: assert isinstance(field, types.TupleType) assert len(field) >= 8 def make_comment(field): if field[7] == None: return "" else: return "# %s: %s\n" % (field[1], field[7]) # we will need the jobspec as a dictionary in order to # give it to Perforce. jobspec_dict = {} fields.sort(self.compare_field_by_number) i = 0 for field in fields: jobspec_dict['Fields%d' % i] = ("%s %s %s %s %s" % field[0:5]) i = i + 1 i = 0 for field in fields: if field[6] != None: jobspec_dict['Values%d' % i] = "%s %s" % (field[1], field[6]) i = i + 1 i = 0 for field in fields: if field[5] != None: jobspec_dict['Presets%d' % i] = "%s %s" % (field[1], field[5]) i = i + 1 jobspec_dict['Comments'] = (comment + string.join(map(make_comment, fields), "")) self.run('jobspec -i', jobspec_dict) # 2.6. Get the jobspec. # # Does very little checking on the output of 'jobspec -o'. # Ought to validate it much more thoroughly than this. def get_jobspec(self): jobspec_dict = self.run('jobspec -o')[0] fields = [] fields_dict = {} fields_re = re.compile('^Fields[0-9]+$') presets_re = re.compile('^Presets[0-9]+$') values_re = re.compile('^Values[0-9]+$') comments_re = re.compile('^Comments$') comment = "" for k,v in jobspec_dict.items(): if fields_re.match(k): # found a field words = string.split(v) name = words[1] if not fields_dict.has_key(name): fields_dict[name] = {} fields_dict[name]['code'] = int(words[0]) fields_dict[name]['datatype'] = words[2] fields_dict[name]['length'] = int(words[3]) fields_dict[name]['disposition'] = words[4] elif presets_re.match(k): # preset for a non-optional field space = string.find(v,' ') name = v[0:space] preset = v[space+1:] if not fields_dict.has_key(name): fields_dict[name] = {} fields_dict[name]['preset'] = preset elif values_re.match(k): # values for a select field space = string.find(v,' ') name = v[0:space] values = v[space+1:] if not fields_dict.has_key(name): fields_dict[name] = {} fields_dict[name]['values'] = values elif comments_re.match(k): # comments for a field comment = v for k,v in fields_dict.items(): fields.append((v['code'], k, v['datatype'], v['length'], v['disposition'], v.get('preset', None), v.get('values', None), None, None)) fields.sort(self.compare_field_by_number) # "Decoded jobspec as comment '%s' and fields %s." self.log(711, (comment, fields)) return (comment, fields) # 2.7. Does the jobspec include P4DTI fields? # # In fact, this only checks the P4DTI-rid field on the assumption # that you are very unlikely to get this without the others. def jobspec_has_p4dti_fields(self, jobspec): comment, fields = jobspec for field in fields: if field[1] == 'P4DTI-rid': return 1 return 0 # A. REFERENCES # # [GDR 2000-10-16] "Perforce Defect Tracking Integration Integrator's # Guide"; Gareth Rees; Ravenbrook Limited; 2000-10-16; # . # # # B. DOCUMENT HISTORY # # 2000-09-25 GDR Created. Moved Perforce interface from replicator.py. # # 2000-12-07 GDR Provided defaults for all configuration parameters so that you # can make a p4 object passing no parameters to get the default Perforce # behaviour. # # 2000-12-14 RB Added check for the exit status of the "p4" command so that the # caller can tell the difference between empty output and a connection (or # other) error. # # 2000-12-15 NB Added verbosity control. # # 2001-01-23 GDR Added check that Perforce client version is supported. # # 2001-02-14 GDR Report the Perforce error message together with the exit # status when we have both. # # 2001-02-19 NB Keyword translation updated and moved here (as it is # Perforce-specific. # # 2001-02-21 GDR Moved keyword translator to its own file (keyword.py) so that # there's no import loop. # # 2001-03-02 RB Transferred copyright to Perforce under their license. # # 2001-03-12 GDR Use messages for errors and logging. # # 2001-03-13 GDR Removed verbose parameter and verbosity control; this was made # redundant by the log_level parameter. # # 2001-03-15 GDR Formatted as a document. Take configuration as variables. # # 2001-03-24 GDR Check the Perforce server changelevel. # # 2001-05-18 GDR Don't log Perforce exit status if it's None. # # 2001-06-22 NB New jobspec class containing common code for # converting lists of tuples into a jobspec dictionary. # # 2001-06-22 NB Get jobspec from p4 into our internal list-of-tuples # format. # # 2001-06-25 NB jobspec field codes need to be integers. # # 2001-06-29 NB Fixed jobspec parsing (it worked if the fields were in # the right order). Also added debugging messages 711 and 712. # # # C. COPYRIGHT AND LICENCE # # This file is copyright (c) 2001 Perforce Software, Inc. All rights # reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in # the documentation and/or other materials provided with the # distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS # OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR # TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE # USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH # DAMAGE. # # # $Id: //info.ravenbrook.com/project/p4dti/branch/2001-11-28/bugzilla-extra-fields/code/replicator/p4.py#2 $