Source code for testtrackpro

"""TestTrack Python Interface

`TestTrack Pro`_ is the Issue Management software from `Seapine Software`_.

`TestTrack`_ is a registered trademark of `Seapine Software`_.

This library uses the `suds`_ library to talk to the TestTrack SDK SOAP API
and includes some helpful extensions for managing your client code and
interactions.

Seapine documentation provides a number of python samples for interacting
with the `TestTrack SOAP API`_, but there are a number of problems with their
`TestTrack Python Tutorial`_.

The sample tutorial has syntax and functional errors, and does not work with
the latest versions of `TestTrack`_ and `suds`_. Even with the versions of
python and the `suds`_ library mentioned, the code will crash due to WSDL
non-compliance issues when custom fields are used.

This module addresses these issues as well as provides a more managed interface
to simplify development.


TestTrack API 'cookie' Management
---------------------------------

The `TestTrack SDK`_ SOAP API uses a client cookie argument on almost every
call. You end up writting code where every API call starts with ``cookie``.

.. code:: python
    
    # What object types can I query?
    adt = server.service.getTableList(cookie)
    
    # What field data is available for a given object type?
    atc = server.service.getColumnsForTable(cookie, tablename)
    
    # What filters are available?
    af = server.service.getFilterList(cookie)
    
    # Get defect 42
    d = server.service.getDefect(cookie, 42)

    # log out
    server.service.DatabaseLogoff(cookie)

You can view this argument as an implicit self for this python interface to
that API. That is you do not need to supply the cookie argument, as it will
be managed for you in the client object.

.. code:: python

    import testtrackpro
    ttp = testtrackpro.TTP('http://hostname/', 'Project', 'username', 'password')
    adt = ttp.getTableList()
    atc = ttp.getColumnsForTable(tablename)
    defect = ttp.getDefect(1) # maps to getDefect(cookie, 42)
    ttp.DatabaseLogoff()

Python Contexts
---------------

Due to the implicit write locks (with 15 min timeout) on all edit API calls,
clients normally would have to be very careful to trap all exceptions and
capture any and all locked entities and un lock them as part of the exception
handling. Thankfully python provides contexts via the 'with' statement that
are designed for exactly this problem.

All `suds`_ objects returned be API calls which start with the string 'edit'
will return `suds`_ objects which have been extended to be python contexts
which can be used with the ``with`` statement:

.. code:: python

    with ttp.editDefect(42) as defect:
        defect.priority = "Immediate"

At the end of the ``with`` block, a call to ``ttp.saveDefect(defect)`` will be
made automatically, saving any pending edits, and releasing the lock.
Explicit calls to ``saveDefect`` or ``cancelSaveDefect`` also work within
the context block.

If an exception occurs, then a call to ``ttp.cancelSaveDefect(defect.recordid)``
will be made automatically to release the lock, without saving the defect.

Also the TTP instance object is also a context object, and will log the session
out when used in a ``with`` statement.

.. code:: python

    with testtrackpro.TTP('http://hostname/', 'Project', 'username', 'password') as ttp:
        defect = ttp.getDefect(42)
    ## ttp.DatabaseLogOff() implicitly called on success or error




.. _suds: https://fedorahosted.org/suds/
.. _suds plugins: https://fedorahosted.org/suds/wiki/Documentation#PLUGINS
.. _Seapine Software: http://www.seapine.com/
.. _TestTrack: http://www.seapine.com/testtrack.html
.. _TestTrack Pro: http://www.seapine.com/ttpro.html
.. _TestTrack SOAP API: http://labs.seapine.com/TestTrackSDK.php
.. _TestTrack SDK: http://labs.seapine.com/TestTrackSDK.php
.. _TestTrack Python Tutorial: http://labs.seapine.com/wiki/index.php/TestTrack_SOAP_SDK_Tutorial_-_Python


Module Documentation
--------------------

"""
import logging
import re
import suds
import contextlib
import functools
import urlparse

## Exception Error Transformations
import urllib2 #.URLError
import xml.sax._exceptions  #.SAXParseException

## Deal with TestTrackPro WSDL non-conformities
import suds.plugin     ## cleanup TestTrack data vs dateTime WSDL errors
import suds.mx.encoded ## monkey patch for polymorphic arrays

__version__ = [0,1,1]
__version_string__ = '.'.join(str(x) for x in __version__)

__author__ = 'Doug Napoleone'
__email__ = 'doug.napoleone+testtrackpro@gmail.com'


_bad_date_as_datetime_re = re.compile(
    '\<element name="(?P<name>date(?!time)[a-z]+|[a-z]+date|date)" type="xsd:dateTime"')
_bad_date_as_datetime_replace = '<element name="\g<name>" type="xsd:date"'

class _TTPWSDLFixPlugin(suds.plugin.DocumentPlugin):
    """There is a very bad bug in the TestTrack WSDL. There are a number
    of entries that set the type to be 'dateTime' when the data returned
    is always just of type 'date'. This causes SUDS to crash in parsing
    the result (like for a getDefect!) To fix this we use a plugin
    that will pre-processes the WSDL result. We are also not using
    the cache for loading the WSDL because of this, so we do not cause
    problems for other clients, just in case.
    """
    def loaded(self, context):
        if not context.url.endswith('ttsoapcgi.wsdl'):
            return
        context.document = _bad_date_as_datetime_re.sub(
            _bad_date_as_datetime_replace, context.document)

_ttpwsdlfixplugin = _TTPWSDLFixPlugin()



[docs]class TTPAPIError(Exception): """Base Exception for all API errors. """ pass
[docs]class TTPConnectionError(TTPAPIError): """Errors communicating with the TestTrack SOAP Service. """ pass
[docs]class TTPLogonError(TTPAPIError): """Errors with authentication against the TestTrack SOAP Service. """ pass
[docs]class TTP(object): """Client for communicating with the TestTrack SOAP Service. :param str url: [required] URL to the TestTrack SOAP WSDL File, or CGI EXE. should be a url which looks like ``http://127.0.0.1/ttsoapcgi.wsdl`` or can be the base website url ``http://hostname/``. :param str database_name: Name of the database (Project) to login to. May be supplied later in the :py:meth:`DatabaseLogon` or :py:meth:`ProjectLogon` methods. :param str username: Username to authenticate with. May be supplied later in the :py:meth:`DatabaseLogon` or :py:meth:`ProjectLogon` methods. :param str password: Password to authenticate with. May be supplied later in the :py:meth:`DatabaseLogon` or :py:meth:`ProjectLogon` methods. :param long cookie: Cookie value from another SOAP client session. Useful for cloning a client session. If you use this argument, you should not supply the ``database_name``, ``username``, or ``password`` arguments. :param list plugins: List of optional `suds plugins`_. """ def __init__(self, url, database_name=None, username=None, password=None, cookie=None, plugins=None): self.__method_cache = {} if not url.endswith('ttsoapcgi.wsdl'): if url.endswith('ttsoapcgi.exe'): url = urlparse.urlunsplit(urlparse.urlparse(url)[:2]+('',)*3) if not url.endswith('/'): url += '/' url += 'ttsoapcgi.wsdl' self._wsdl_url = url self._cookie = cookie self._database_name = database_name self._username = username self._password = password self._client = None if not plugins: plugins = [] plugins.append(_ttpwsdlfixplugin) try: self._client = suds.client.Client( self._wsdl_url, cache=None, plugins=plugins) except urllib2.URLError, e: raise TTPConnectionError(e) except xml.sax._exceptions.SAXParseException, e: raise TTPConnectionError( "Library could not connect to the TestTrackPro Soap API. " "Either this installation of TestTrackPro does not support " "the API, or the url, %s, is incorrect.\n\nError: %s" % ( self._wsdl_url, e)) if not cookie and database_name and username and password: self.DatabaseLogon() def _call_method(self, method, *args, **kwdargs): try: return method(self._cookie, *args, **kwdargs) except urllib2.URLError, e: raise TTPConnectionError(e) except suds.WebFault, e: raise TTPAPIError(e) def _call_context_method(self, method_name, table, modifier, method, entity, *args, **kwdargs): ## allow for non-context entities for save and record id's for cancel context = None if hasattr(entity, '__context__'): context = self._get_edit_context(entity) if context.table != table: raise TTPAPIError("Wrong type. Calling "+method_name+ ' on a '+context.cname+' '+modifier+'.') res = self._call_method(method, entity, *args, **kwdargs) if context: context._locked = False return res def _get_edit_context(self, entity): if not hasattr(entity, '__context__'): raise TTPAPIError("entity does not have an edit context.") context = entity.__context__() if context._ttp is not self: raise TTPAPIError("entity is not from this client instance.") return context def _build_partial(self, method_name): try: method = getattr(self._client.service, method_name) except suds.MethodNotFound, e: raise TTPAPIError(e) return functools.partial(self._call_method, method) def __build_method(self, method_name, method): if method_name.startswith('edit'): return functools.partial(_TTPEditContext.call_method, self, method_name, method) if method_name.startswith('save'): return functools.partial(self._call_context_method, method_name, method_name[4:], 'entity', method) if method_name.startswith('cancelSave'): return functools.partial(self._call_context_method, method_name, method_name[10:], 'recordid', method) return functools.partial(self._call_method, method) def __getattr__(self, name): if name.startswith('__'): ## prevents people from accessing '__len__' and other names which ## would overload things badly. Access through _client for those raise AttributeError("'%s' Object has no such attribute '%s'" % self.__class__.__name__, name) try: if not self.__method_cache.has_key(name): method = getattr(self._client.service, name) self.__method_cache[name] = self.__build_method(name, method) except suds.MethodNotFound, e: raise TTPAPIError(e) return self.__method_cache[name] def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): self.DatabaseLogoff(ignore_exceptions=True)
[docs] def create(self, name): """Factory Creation for TTPAPI structures :param str name: SOAP Entity type name. .. code:: python ## Create a new defect # Create the CDefect object. defect = ttp.create("CDefect") #suds doesn't automatically initialize the record id defect.recordid = 0; defect.summary = "This is a new defect" defect.product = "My Product" defect.priority = "Immediate" # Add the defect to TestTrack. lNewNum = ttp.addDefect(defect) ## Create a new project project = ttp.create("CProject") project.database = ttp.create("CDatabase") project.database.name = "MyProject" project.options = ttp.create("ArrayOfCProjectDataOption") project.options.append(ttp.create("CProjectDataOption")) project.options.append(ttp.create("CProjectDataOption")) project.options.append(ttp.create("CProjectDataOption")) project.options[0].name = "TestTrack Pro" # add TTP functionality. project.options[1].name = "TestTrack TCM" # add TCM functionality. project.options[2].name = "TestTrack RM" # add RM functionality. # Add the project to TestTrack. ttp.ProjectLogon(project, username, password) """ return self._client.factory.create(name)
[docs] def save(self, entity, *args, **kwdargs): """Save the edit locked context entity. :param CType entity: edit entity returned by a editXXXX method call. This is handy when you have a an entity but can not easilly get to it's type programatically. This is useful when you recieve polymorphic array data back from the TestTrack SOAP API. In ortherwords you can do this: .. code:: python with ttp.editDefect(a) as defecta, ttp.editDefect(b) as defectb: defecta.something = "value" ttp.save(defect) ## release the lock immediatly defectb.other = defecta.other ## defectb is saved Calling ``ttp.saveDefect(defecta)`` is also safe and will prevent the call to ``saveDefect(defecta)`` at the end of the context block. .. WARNING:: If you are using edit contexts then calls to ``saveDefect`` with a ``CDefect`` for the same defect from a different API call will result in ``saveDefect`` being called again at the end of the context causing an error. Example of errorful code: .. code:: python def bad(): getdefect = ttp.getDefect(24) with ttp.editDefectBeRecordId(getdefect.recordid) as defect: getdefect.priority = "Immediate" ttp.saveDefect(getdefect) ## at this point saveDefect(defect) will be called and will ## cause an error as there is no longer an edit lock on ## the defect. Correct way: .. code:: python def good(recordid): with ttp.editDefectByRecordId(recordid) as defect: ttp.cancelSave(defect) ## or with ttp.editDefectByRecordId(recordid) as defect: ttp.cancelSaveDefect(defect.recordid) """ return self._get_edit_context(entity).save(*args, **kwdargs)
[docs] def cancelSave(self, entity): """Cancel the save of the edit locked context entity. :param CType entity: edit entity returned by a editXXXX method call. This is handy when you have a an entity but can not easilly get to it's type programatically. This is useful when you recieve polymorphic array data back from the TestTrack SOAP API. In ortherwords you can do this: .. code:: python with ttp.editDefect(defectnum) as defect: defect.something = "value" if badthing: ttp.cancelSave(defect) Calling ``ttp.cancelSaveDefect(defect.recordid)`` is also safe and will prevent the call to ``saveDefect`` at the end of the context block. .. WARNING:: If you are using edit contexts then calls to ``cancelSaveDefect`` with a ``recordid`` for the entity in the context which comes from a different structure will cause an error. Example of errorful code: .. code:: python def bad(recordid): with ttp.editDefectBeRecordId(recordid) as defect: ttp.cancelSaveDefect(recordid) ## bad ## at this point saveDefect(defect) will be called Correct way: .. code:: python def good(recordid): with ttp.editDefectByRecordId(recordid) as defect: ttp.cancelSave(defect) ## or with ttp.editDefectByRecordId(recordid) as defect: ttp.cancelSaveDefect(defect.recordid) """ return self._get_edit_context(entity).cancelSave()
[docs] def getProjectList(self, username=None, password=None): """Return a list of CProject entities which the user has access to on the server. :param str username: Username to authenticate with. If not supplied, and one was supplied on client construction, the client stored version will be used. This will not update the client state. :param str password: PAssword to authenticate with. If not supplied, and one was supplied on client construction, the client stored version will be used. This will not update the client state. The ``username`` and ``password`` are only required if they were not supplied on client creation. If supplied they will NOT replace the client stored username, password, or client cookie. """ if not username and self._username: username = self._username if not password and self._password: password = self._password if not username or not password: raise TTPAPIError("Must supply a username and password") try: result = self._client.service.getProjectList(username, password) except urllib2.URLError, e: raise TTPConnectionError(e) except suds.WebFault, e: raise TTPLogonError(e) return result
[docs] def ProjectLogon(self, CProject=None, username=None, password=None): """Logon to the SOAP API and retrieve a new client cookie. :param CProject CProject: CProject entity describing a TestTrack Project Database. It is recommended to use the :py:meth:`getProjectList` method to retrieve a valid CProject entity. :param str username: Username to authenticate with. If supplied, this will update the client stored ``username``. If not supplied, it will use the client stored value if one exists, and error otherwise. :param str password: Password to authenticate with. If supplied, this will update the client stored ``username``. If not supplied, it will use the client stored value if one exists, and error otherwise. The appropriate CProject entity can be retrieved using the :py:meth:`getProjectList` method, of constructing one using the :py:meth:`create` method. If the client is currently logged on, it will first logoff. If the ``username`` and or ``password`` are supplied they will update the client stored versions of these values. If they are not supplied, then the versions supplied on construction will be used. This is now the prefered way to logon to TestTrack now that the :py:meth:`DatabaseLogon` method has been depricated. """ if self._cookie: self.DatabaseLogoff() database_name = CProject.database.name if database_name: self._database_name = database_name if username: self._username = username if password: self._password = username if not self._database_name or not self._username or not self._password: raise TTPAPIError( "Must supply a valid CProject, username, and password.") try: self._cookie = self._client.service.ProjectLogon( CProject, self._username, self._password) except urllib2.URLError, e: raise TTPConnectionError(e) except suds.WebFault, e: raise TTPLogonError(e)
[docs] def DatabaseLogon(self, database_name=None, username=None, password=None): """Logon to the SOAP API and retrieve a new client cookie. :param str database_name: Project database name to login to. :param str username: Username to authenticate with. If supplied, this will update the client stored ``username``. If not supplied, it will use the client stored value if one exists, and error otherwise. :param str password: Password to authenticate with. If supplied, this will update the client stored ``username``. If not supplied, it will use the client stored value if one exists, and error otherwise. If the client is currently logged on, it will first logoff. If the ``username`` and or ``password`` are supplied they will update the client stored versions of these values. If they are not supplied, then the versions supplied on construction will be used. .. warning:: The :py:meth:`DatabaseLogon` API method has been depricated by Seapine, and should no longer be used. The :py:meth:`ProjectLogon` API method should be used instead. """ if self._cookie: self.DatabaseLogoff() if database_name: self._database_name = database_name if username: self._username = username if password: self._password = username if not self._database_name or not self._username or not self._password: raise TTPAPIError( "Must supply a valid database_name, username, and password.") try: self._cookie = self._client.service.DatabaseLogon( self._database_name, self._username, self._password) except urllib2.URLError, e: raise TTPConnectionError(e) except suds.WebFault, e: raise TTPLogonError(e)
[docs] def DatabaseLogoff(self, ignore_exceptions=False): """Log out of the SOAP API session, and release the stored client cookie. :param bool ignore_exceptions: Set this to true to ignore connection and authentication based API errors. It can be useful to ignore connection and authenticaiton based errors when logging out, especially when using the client as a context. If there was an error communicating with the client, we want to ignore further errors due to the implicit logoff at the end of the context to preserve the ogitional initial connection error. """ if not self._cookie or not self._client: return try: self._client.service.DatabaseLogoff(self._cookie) self._cookie = None except Exception, e: self._cookie = None if str(e) != "Server raised fault: 'Session Dropped.'": if not ignore_exceptions: raise TTPLogonError(e) else: logging.warn( "Exception while attempting to logout " "with a call to: DatabaseLogoff\dError: " + str(e))
class _long(long): pass class _TTPEditContext(object): @classmethod def call_method(cls, ttp, method_name, method, *args, **kwdargs): context = cls(ttp, method_name, method, *args, **kwdargs) entity = context.entity entity.__enter__ = context.__enter__ entity.__exit__ = context.__exit__ entity.__context__ = context.__context__ entity.recordid = _long(entity.recordid) entity.recordid.__context__ = context.__context__ return entity def __init__(self, ttp, method_name, method, *args, **kwdargs): self._locked = False self._ttp = ttp self._method_name = method_name if method_name.endswith('ByRecordID'): self._editbyid_name = method_name self._edit_name = method_name[:-10] else: self._editbyid_name = method_name + 'ByRecordID' self._edit_name = method_name self._table = self._edit_name[4:] self._name = 'C' + self._table self._cancel_name = 'cancelSave' + self._table self._save_name = 'save' + self._table self._save = ttp._build_partial(self._save_name) self._cancel = ttp._build_partial(self._cancel_name) self._entity = ttp._call_method(method, *args, **kwdargs) self._locked = True def __context__(self): return self def __enter__(self): return self._entity def __exit__(self, exc_type, exc_value, traceback): if not self._locked: return if exc_type: try: self.cancelSave() except Exception, e: logging.warn( "Exception while attempting to release an edit lock " "with a call to: " + self._cancel_name + "\n Error: " + str(e)) else: try: self.save() except Exception, e: logging.warn( "Exception while attempting to release an edit lock " "with a call to: " + self._save_name + "\n Error: " + str(e)) try: self.cancelSave() except Exception, ex: logging.warn( "Exception while attempting to release an edit lock " "with a call to: " + self._cancel_name + "\n After a failed call to: " + self._save_name + "\n Error: " + str(ex)) ## re-raise the exception on the save raise e def __getattr__(self, name): return getattr(self._entity, name) def __setattr__(self, name, value): if not name.startswith('_') and hasattr(self._entity, name): return setattr(self._entity, name, value) return object.__setattr__(self, name, value) @property def entity(self): return self._entity @property def table(self): return self._table @property def cname(self): return self._name def save(self, *args, **kwdargs): #print self._save_name, if not self._locked: #print "already unlocked" return #print "unlocking" res = self._save(self._entity,*args,**kwdargs) self._locked = False return res def cancelSave(self): #print self._cancel_name, if not self._locked: #print "already unlocked" return #print "unlocking" res = self._cancel(self._entity.recordid) self._locked = False return res def _polymprphic_cast(self, content): """TestTrack WSDL has polymorphic arrays. That is it has a CEntityArray of SOAP Array Type CEntity. It then will return CEntityArrays that contain entities which inherit from CEntity in violation of Section 5 of the SOAP Standard. This is not a problem for suds to extract the data, but it is a major problem for suds to encode and send such arrays. So this is a replacement for the cast operation on encoding that we monkey patch into the appropriate class in suds. We double check the sxtype metadata to make sure it matches the object class instead of relyng on it matching the parent array element type. If it does not match, then we find the proper one and set that. """ aty = content.aty[1] resolved = content.type.resolve() array = suds.mx.encoded.Factory.object(resolved.name) array.item = [] query = suds.mx.encoded.TypeQuery(aty) ref = query.execute(self.schema) if ref is None: raise suds.mx.encoded.TypeNotFound(qref) for x in content.value: if isinstance(x, (list, tuple)): array.item.append(x) continue if isinstance(x, suds.mx.encoded.Object): md = x.__metadata__ ## removing: #md.sxtype = ref ## and replacing with: polyname = x.__class__.__name__ if ref.name == polyname: md.sxtype = ref else: query = suds.mx.encoded.TypeQuery((polyname, aty[1])) polyref = query.execute(self.schema) md.sxtype = polyref ## end replacement array.item.append(x) continue if isinstance(x, dict): x = suds.mx.encoded.Factory.object(ref.name, x) md = x.__metadata__ md.sxtype = ref array.item.append(x) continue x = suds.mx.encoded.Factory.property(ref.name, x) md = x.__metadata__ md.sxtype = ref array.item.append(x) content.value = array return self suds.mx.encoded.Encoded.cast = _polymprphic_cast