1
2###################################################################################################
3#
4#  pyral.entity - defines the entities in Rally, exposes a classFor dict
5#                 to allow instantiation of concrete Rally entities
6#          dependencies:
7#               intra-package: the getResourceByOID and hydrateAnInstance functions from restapi
8#
9###################################################################################################
10
11__version__ = (1, 5, 2)
12
13import sys
14import re
15import types
16import time
17
18from .restapi   import hydrateAnInstance
19from .restapi   import getResourceByOID
20from .restapi   import getCollection
21
22from .config    import WEB_SERVICE, WS_API_VERSION
23
24##################################################################################################
25
26VERSION_ATTRIBUTES = ['_rallyAPIMajor', '_rallyAPIMinor', '_objectVersion']
27MINIMAL_ATTRIBUTES = ['_type', '_ref', '_refObjectName']
28PORTFOLIO_ITEM_SUB_TYPES = ['Strategy', 'Theme', 'Initiative', 'Feature']
29SLM_WS_VER = '/%s/' % (WEB_SERVICE % WS_API_VERSION)
30
31_rally_schema       = {}  # keyed by workspace at the first level, then by EntityName
32_rally_entity_cache = {}
33
34##################################################################################################
35
36class UnreferenceableOIDError(Exception):
37    """
38        An Exception to be raised in the case where an Entity/OID pair in a _ref field
39        cannot be retrieved.
40    """
41    pass
42
43class InvalidRallyTypeNameError(Exception):
44    """
45        An Exception to be raised in the case where a candidate Rally entity name
46        doesn't resolve to a valid Rally entity.
47    """
48    pass
49
50class UnrecognizedAllowedValuesReference(Exception):
51    """
52        An Exception to be raised in the case where a SchemaItemAttribute.AllowedValues
53        URL reference does not contain the expected path components.
54    """
55    pass
56
57##################################################################################################
58#
59# Classes for each entity in the Rally data model.  Refer to the Rally REST API document
60# for information on the Rally data model.  You'll note that in the data model there are
61# the equivalent of abstract classes and that the code here doesn't strictly enforce that.
62# However, the instantiation of any Rally related classes takes place through the classFor
63# mechanism which only enables instances of a concrete class to be provided.
64#
65class Persistable(object):
66    def __init__(self, oid, name, resource_url, context):
67        """
68            All sub-classes have an oid (Object ID), so it makes sense to provide the
69            attribute storage here.
70            All sub-classes also have a uuid (ObjectUUID), so it also makes sense
71            to provide the attribute storage here.
72        """
73        try:
74            self.oid = int(oid)
75        except:
76            self.oid = oid
77        self.Name = name
78        self._ref = resource_url
79        self._hydrated = False
80        self._context = context
81
82    def attributes(self):
83        """
84            return back the attributes of this instance minus the _context
85        """
86        attrs = sorted(self.__dict__.keys())
87        attrs.remove('_context')
88        return attrs
89
90    def __getattr__(self, name):
91        """
92           This is needed to implement the first swag of lazy attribute evaluation.
93           It only gets called if attribute lookup for the name has resulted in the "no-joy" situation.
94           Example: someone has an instance of a Project class.
95           They refer to p.Children[7].Owner.UserProfile.DefaultWorkspace.State
96           Example 2: someone has an instance of a UserStory class that isn't fully hydrated
97           They refer to s.Iterations or s.FormattedID (both of which weren't in the
98           original fetch spec)
99        """
100        global SLM_WS_VER
101
102        # access to this Entity's ref attribute is a special case and dealt with early in the logic flow
103        if name == "ref":
104            entity_name, oid = self._ref.split('/')[-2:]  # last two path elements in _ref are entity/oid
105            return '%s/%s' % (entity_name, oid)
106
107        if name == 'context':
108            raise Exception('Unsupported attempt to retrieve context attribute')
109
110        rallyEntityTypeName = self.__class__.__name__
111        PORTFOLIO_PREFIX = 'PortfolioItem_'
112        if rallyEntityTypeName.startswith(PORTFOLIO_PREFIX):
113            rallyEntityTypeName = re.sub(PORTFOLIO_PREFIX, '', rallyEntityTypeName)
114        # previous logic prior to 1.2.2
115        #entity_path, oid = self._ref.split(SLM_WS_VER)[-1].rsplit('/', 1)
116        #if entity_path.startswith('portfolioitem/'):
117        #    rallyEntityTypeName = entity_path.split('/')[-1].capitalize()
118
119        faultTrigger = "getattr fault detected on %s instance for attribute: %s  (hydrated? %s)" % \
120                       (rallyEntityTypeName, name, self._hydrated)
121##
122##        print(faultTrigger)
123##        sys.stdout.flush()
124##
125        if not self._hydrated:
126            #
127            # get "hydrated" by issuing a GET request for the resource referenced in self._ref
128            # and having an EntityHydrator fill out the attributes, !!* on this instance *!!
129            #
130            entity_name, oid = self._ref.split(SLM_WS_VER)[-1].rsplit('/', 1)
131##
132##            print("self._ref : %s" % self._ref)
133##            print("issuing OID specific get for %s OID: %s " % (entity_name, oid))
134##            print("Entity: %s context: %s" % (rallyEntityTypeName, self._context))
135##            sys.stdout.flush()
136##
137            response = getResourceByOID(self._context, entity_name, self.oid, unwrap=True)
138            if not response:
139                raise UnreferenceableOIDError("%s OID %s" % (rallyEntityTypeName, self.oid))
140            if not isinstance(response, object):
141                # TODO: would like to be specific with RallyRESTResponse here...
142                #print("bad guess on response type in __getattr__, response is a %s" % type(response))
143                raise UnreferenceableOIDError("%s OID %s" % (rallyEntityTypeName, self.oid))
144            if response.status_code != 200:
145##
146##                print(response)
147##
148                raise UnreferenceableOIDError("%s OID %s" % (rallyEntityTypeName, self.oid))
149
150            if rallyEntityTypeName == 'PortfolioItem':
151                actual_item_name = list(response.content.keys())[0]
152                if entity_name.split('/')[1].lower() == actual_item_name.lower():
153                    item = response.content[actual_item_name]
154                else:  # this would be unexpected, but the above getResourceByOID seems to have been successful...
155                    item = response.content[actual_item_name]  # take what we're given...
156            else:
157                item = response.content[rallyEntityTypeName]
158            hydrateAnInstance(self._context, item, existingInstance=self)
159            self._hydrated = True
160
161        if name in self.attributes():
162            return self.__dict__[name]
163        # accommodate custom field access by Name (by prefix 'c_' and squishing out any spaces in Name
164        name_with_custom_prefix = "c_%s" % name.replace(' ', '')
165        if name_with_custom_prefix in self.attributes():
166            return self.__dict__[name_with_custom_prefix]
167
168        # upon initial access of a Collection type field, we have to detect, retrieve the Collection
169        # and then torch the "lazy" evaluation field marker
170        coll_ref_field = '__collection_ref_for_%s' % name
171        if coll_ref_field in list(self.__dict__.keys()):
172            collection_ref = self.__dict__[coll_ref_field]
173##
174##            print("  chasing %s collection ref: %s" % (name, collection_ref))
175##            print("  using this context: %s" % repr(self._context))
176##
177            collection = getCollection(self._context, collection_ref, _disableAugments=False)
178            if name != "RevisionHistory":  # a "normal" Collections field ...
179                self.__dict__[name] = [item for item in collection]
180            else:  # RevisionHistory is a special case, the initial collection isn't really a Collection
181                self.__dict__[name] = self._hydrateRevisionHistory(collection_ref, collection)
182            del self.__dict__[coll_ref_field]
183            return self.__dict__[name]
184        else:
185            description = "%s instance has no attribute: '%s'" % (rallyEntityTypeName, name)
186##
187##            print("Rally entity getattr fault: %s" % description)
188##
189            raise AttributeError(description)
190
191
192    def _hydrateRevisionHistory(self, collection_ref, collection):
193        """
194            A Rally entity's RevisionHistory attribute is a special case, while it "looks" like a
195            collection, the results of chasing the collection ref don't result in a "real" collection.
196            What comes back contains the ref to the actual "collection" which is the Revisions data,
197            so that is retrieved and used to construct the "guts" of RevisionHistory, ie., the Revisions.
198        """
199        # pull the necessary fragment out from collection query,
200        rev_hist_raw = collection.data['QueryResult']['Results']['RevisionHistory']
201        rev_hist_oid = rev_hist_raw['ObjectID']
202        revs_ref     = rev_hist_raw['Revisions']['_ref']  # this is the "true" Revisions collection ref
203        # create a RevisionHistory instance with oid, Name and _ref field information
204        rev_hist = RevisionHistory(rev_hist_oid, 'RevisonHistory', collection_ref, self._context)
205        # chase the revs_ref set the RevisionHistory.Revisions attribute with that Revisions collection
206        revisions = getCollection(self._context, revs_ref, _disableAugments=False)
207        rev_hist.Revisions = [revision for revision in revisions]
208        # mark the RevisionHistory instance as being fully hydrated
209        rev_hist._hydrated = True
210        return rev_hist
211
212##################################################################################################
213#
214# subclasses (both abstract and concrete) that descend from Persistable
215#
216
217class Subscription(Persistable):  pass
218
219class AllowedAttributeValue(Persistable):  pass  # only used in an AttributeDefinition
220class AllowedQueryOperator (Persistable):  pass  # only used in an AttributeDefinition
221                                                 #  (for AllowedQueryOperators)
222
223class DomainObject(Persistable):
224    """ This is an Abstract Base class """
225    pass
226
227class User (DomainObject):
228    USER_ATTRIBUTES = ['oid', 'ref', 'ObjectID', 'ObjectUUID', '_ref',
229                       '_CreatedAt', '_hydrated',
230                       'UserName', 'DisplayName', 'EmailAddress',
231                       'FirstName', 'MiddleName', 'LastName',
232                       'ShortDisplayName',
233                       'SubscriptionAdmin',
234                       'Role',
235                       'UserPermissions',
236                       #'TeamMemberships',
237                       #'UserProfile'
238                      ]
239    def details(self):
240        """
241            Assemble a list of USER_ATTRIBUTES and values
242            and join it into a single string with newline "delimiters".
243            Return this string so that the caller can simply print it and have
244            a nicely formatted block of information about the specific User.
245        """
246        tank = ['%s' % self._type]
247        for attribute_name in self.USER_ATTRIBUTES[1:]:
248            try:
249                value = getattr(self, attribute_name)
250            except AttributeError:
251                continue
252            if value is None:
253                continue
254            if 'pyral.entity.' not in str(type(value)):
255                anv = '    %-20s  : %s' % (attribute_name, value)
256            else:
257                mo = re.search(r' \'pyral.entity.(\w+)\'>', str(type(value)))
258                if mo:
259                    cln = mo.group(1)  # cln -- class name
260                    anv = "    %-20s  : %-20.20s   (OID  %s  Name: %s)" % \
261                          (attribute_name, cln + '.ref', value.oid, value.Name)
262                else:
263                    anv = "    %-20s  : %s" % value
264            tank.append(anv)
265        return "\n".join(tank)
266
267class UserProfile     (DomainObject):
268    USER_PROFILE_ATTRIBUTES = ['oid', 'ref', 'ObjectID', 'ObjectUUID', '_ref',
269                               '_CreatedAt', '_hydrated',
270                               'DefaultWorkspace', 'DefaultProject',
271                               'TimeZone',
272                               'DateFormat', 'DateTimeFormat',
273                               'SessionTimeoutSeconds', 'SessionTimeoutWarning',
274                               'EmailNotificationEnabled',
275                               'WelcomePageHidden'
276                              ]
277    def details(self):
278        """
279            Assemble a list of USER_PROFILE_ATTRIBUTES and values
280            and join it into a single string with newline "delimiters".
281            Return this string so that the caller can simply print it and have
282            a nicely formatted block of information about the specific User.
283        """
284        tank = ['%s' % self._type]
285        for attribute_name in self.USER_PROFILE_ATTRIBUTES[1:]:
286            try:
287                value = getattr(self, attribute_name)
288            except AttributeError:
289                continue
290            if 'pyral.entity.' not in str(type(value)):
291                anv = '    %-24s  : %s' % (attribute_name, value)
292            else:
293                mo = re.search(r' \'pyral.entity.(\w+)\'>', str(type(value)))
294                if mo:
295                    cln = mo.group(1)
296                    anv = "    %-24s  : %-14.14s   (OID  %s  Name: %s)" % \
297                          (attribute_name, cln + '.ref', value.oid, value.Name)
298                else:
299                    anv = "    %-24s  : %s" % value
300            tank.append(anv)
301
302        return "\n".join(tank)
303
304class Workspace       (DomainObject): pass
305class Blocker         (DomainObject): pass
306class UserPermission  (DomainObject): pass
307class WorkspacePermission   (UserPermission): pass
308class ProjectPermission     (UserPermission): pass
309
310class WorkspaceDomainObject(DomainObject):
311    """
312        This is an Abstract Base class, with a convenience method (details) that
313        formats the attrbutes and corresponding values into an easily viewable
314        mulitiline string representation.
315    """
316    COMMON_ATTRIBUTES = ['_type',
317                         'oid', 'ref', 'ObjectID', 'ObjectUUID', '_ref',
318                         '_CreatedAt', '_hydrated',
319                         'Name', 'Subscription', 'Workspace',
320                         'FormattedID'
321                        ]
322
323    def details(self):
324        """
325            order we want to have the attributes appear in...
326
327            Class Name (aka _type)
328                oid
329                ref
330                _ref
331                _hydrated
332                _CreatedAt
333                ObjectID
334                ObjectUUID
335                Name         ** not all items will have this...
336                Subscription (oid, Name)
337                Workspace    (oid, Name)
338                FormattedID   ** not all items will have this...
339
340                alphabetical from here on out
341        """
342        tank = ['%s' % self._type]
343        for attribute_name in self.COMMON_ATTRIBUTES[1:]:
344            try:
345                value = getattr(self, attribute_name)
346            except AttributeError:
347                continue
348            if value is None:
349                continue
350            if 'pyral.entity.' not in str(type(value)):
351                anv = '    %-24s  : %s' % (attribute_name, value)
352            else:
353                mo = re.search(r' \'pyral.entity.(\w+)\'>', str(type(value)))
354                if mo:
355                    cln = mo.group(1)
356                    anv = "    %-24s  : %-20.20s   (OID  %s  Name: %s)" % \
357                          (attribute_name, cln + '.ref', value.oid, value.Name)
358                else:
359                    anv = "    %-24s  : %s" % (attribute_name, value)
360            tank.append(anv)
361        tank.append("")
362        other_attributes = set(self.attributes()) - set(self.COMMON_ATTRIBUTES)
363##
364##        print("other_attributes: %s" % ", ".join(other_attributes))
365##
366        for attribute_name in sorted(other_attributes):
367            #value = getattr(self, attribute_name)
368            #
369            # bypass any attributes that the item might have but doesn't have
370            # as a query fetch clause may have been False or didn't include the attribute
371            try:
372                value = getattr(self, attribute_name)
373            except AttributeError:
374##
375##                print("  unable to getattr for |%s|" % attribute_name)
376##
377                continue
378            attr_name = attribute_name
379            if attribute_name.startswith('c_'):
380                attr_name = attribute_name[2:]
381            if not isinstance(value, Persistable):
382                anv = "    %-24s  : %s" % (attr_name, value)
383            else:
384                mo = re.search(r' \'pyral.entity.(\w+)\'>', str(type(value)))
385                if not mo:
386                    anv = "    %-24s : %s" % (attr_name, value)
387                    continue
388
389                cln = mo.group(1)
390                anv = "    %-24s  : %-27.27s" % (attr_name, cln + '.ref')
391                if   isinstance(value, Artifact):
392                    # also want the OID, FormattedID
393                    anv = "%s (OID  %s  FomattedID  %s)" % (anv, value.oid, value.FormattedID)
394                elif isinstance(value, User):
395                    # also want the className, OID, UserName, DisplayName
396                    anv = "    %-24s  : %s.ref  (OID  %s  UserName %s  DisplayName %s)" % \
397                          (attr_name, cln, value.oid, value.UserName, value.DisplayName)
398                else:
399                    # also want the className, OID, Name)
400                    anv = "%s (OID  %s  Name %s)" % (anv, value.oid, value.Name)
401            tank.append(anv)
402        return "\n".join(tank)
403
404
405class WorkspaceConfiguration(WorkspaceDomainObject): pass
406class Type                  (WorkspaceDomainObject): pass
407class Program               (WorkspaceDomainObject): pass
408class Project               (WorkspaceDomainObject): pass
409class Release               (WorkspaceDomainObject): pass
410class Iteration             (WorkspaceDomainObject): pass  # query capable only
411class ArtifactNotification  (WorkspaceDomainObject): pass  # query capable only
412class AttributeDefinition   (WorkspaceDomainObject): pass  # query capable only
413class TypeDefinition        (WorkspaceDomainObject): pass  # query capable only
414class Attachment            (WorkspaceDomainObject): pass
415class AttachmentContent     (WorkspaceDomainObject): pass
416class Build                 (WorkspaceDomainObject): pass  # query capable only
417class BuildDefinition       (WorkspaceDomainObject): pass  # query capable only
418class BuildMetric           (WorkspaceDomainObject): pass  # query capable only
419class BuildMetricDefinition (WorkspaceDomainObject): pass  # query capable only
420class Change                (WorkspaceDomainObject): pass
421class Changeset             (WorkspaceDomainObject): pass
422class ConversationPost      (WorkspaceDomainObject): pass  # query capable only
423class FlowState             (WorkspaceDomainObject): pass
424class Milestone             (WorkspaceDomainObject): pass
425class Preference            (WorkspaceDomainObject): pass
426class PreliminaryEstimate   (WorkspaceDomainObject): pass
427class SCMRepository         (WorkspaceDomainObject): pass
428class State                 (WorkspaceDomainObject): pass
429class TestCaseStep          (WorkspaceDomainObject): pass
430class TestCaseResult        (WorkspaceDomainObject): pass
431class TestFolder            (WorkspaceDomainObject): pass
432class TestFolderStatus      (WorkspaceDomainObject): pass
433class Tag                   (WorkspaceDomainObject): pass
434class TimeEntryItem         (WorkspaceDomainObject): pass
435class TimeEntryValue        (WorkspaceDomainObject): pass
436class UserIterationCapacity (WorkspaceDomainObject): pass
437class RecycleBinEntry       (WorkspaceDomainObject): pass
438class RevisionHistory       (WorkspaceDomainObject): pass
439class ProfileImage          (WorkspaceDomainObject): pass
440class Revision              (WorkspaceDomainObject):
441    INFO_ATTRS = ['RevisionNumber', 'Description', 'CreationDate', 'User']
442    def info(self):
443        rev_num = self.RevisionNumber
444        desc    = self.Description
445        activity_timestamp = self.CreationDate
446        whodunit = self.User.Name  # self.User.UserName # can't do UserName as context is incomplete...
447        rev_blurb = "   %3d on %s by %s\n             %s" % (rev_num, activity_timestamp, whodunit, desc)
448        return rev_blurb
449
450class WebLinkDefinition(AttributeDefinition): pass
451
452class CumulativeFlowData(WorkspaceDomainObject):
453    """ This is an Abstract Base class """
454    pass
455
456class ReleaseCumulativeFlowData  (CumulativeFlowData): pass
457class IterationCumulativeFlowData(CumulativeFlowData): pass
458
459class Artifact(WorkspaceDomainObject):
460    """ This is an Abstract Base class """
461    pass
462
463class SchedulableArtifact(Artifact):
464    """ This is an Abstract Base class """
465    pass
466
467class Requirement  (SchedulableArtifact):
468    """ This is an Abstract Base class """
469    pass
470class HierarchicalRequirement(Requirement): pass
471
472UserStory = HierarchicalRequirement   # synonomous but more intutive
473Story     = HierarchicalRequirement   # ditto
474
475class Task         (Artifact): pass
476class Defect       (Artifact): pass
477class TestCase     (Artifact): pass
478class DefectSuite  (SchedulableArtifact): pass
479class TestSet      (SchedulableArtifact): pass
480
481class PortfolioItem(Artifact): pass
482class PortfolioItem_Strategy  (PortfolioItem): pass
483class PortfolioItem_Initiative(PortfolioItem): pass
484class PortfolioItem_Theme     (PortfolioItem): pass
485class PortfolioItem_Feature   (PortfolioItem): pass
486
487class Connection(WorkspaceDomainObject):
488
489    MINIMAL_WDO_ATTRIBUTES = ['_type',
490                         'oid', 'ref', 'ObjectID', 'ObjectUUID', '_ref',
491                         '_CreatedAt', '_hydrated', 'Subscription', 'Workspace']
492    CONNECTION_INFO_ATTRIBUTES = ['ExternalId', 'ExternalFormattedId', 'Name', 'Description', 'Url', 'Artifact']
493
494    def details(self):
495        tank = ['%s' % self._type]
496        for attribute_name in (Connection.MINIMAL_WDO_ATTRIBUTES + Connection.CONNECTION_INFO_ATTRIBUTES):
497            try:
498                value = getattr(self, attribute_name)
499            except AttributeError:
500                continue
501            if value is None:
502                continue
503            if 'pyral.entity.' not in str(type(value)):
504                anv = '    %-24s  : %s' % (attribute_name, value)
505            else:
506                mo = re.search(r' \'pyral.entity.(\w+)\'>', str(type(value)))
507                if mo:
508                    cln = mo.group(1)
509                    anv = "    %-24s  : %-20.20s   (OID  %s  Name: %s)" % \
510                          (attribute_name, cln + '.ref', value.oid, value.Name)
511                else:
512                    anv = "    %-24s  : %s" % (attribute_name, value)
513            tank.append(anv)
514        tank.append("")
515        return tank
516
517
518class PullRequest(Connection): pass
519
520class CustomField(object):
521    """
522        For non-Rally originated entities
523
524        TBD: does this need the __getattr__ hook also?
525    """
526    def __init__(self, oid, name, resource_url, context):
527        """
528        """
529        self.oid = int(oid)
530        self.Name = name
531        self._ref = resource_url
532        self._context  = context
533        self._hydrated = False
534
535def so_element_text(mo):
536    #if mo: return mo.group('field_name') + ': '
537    #else: return ""
538    return mo.group('field_name') + ': 'if mo else ""
539
540def so_bolded_text(mo):
541    #if mo: return "<bold>%s</bold>" % mo.group('word')
542    #else: return ""
543    return "<bold>%s</bold>" % mo.group('word') if mo else ""
544
545class SearchObject(object):
546    """
547        An instance of SearchObject is created for each artifact that
548        matches a search criteria.  A SearchObject is not a full-fledged
549        artifact however, it only has minimal identifying attributes and
550        snippets from each string field that contained text that matched
551        a part of the search criteria.
552    """
553
554    tagged_field_name_pattern = re.compile(r'<span class=\'alm-search-page matching-text-field-name\'>(?P<field_name>.*?): </span>')
555                                         #   <span class='alm-search-page matching-text-field-name'>Discussion: </span>
556    bolding_pattern = re.compile(r'<span id="keepMeBolded" class="alm-search-page matching-text-highlight">(?P<word>.*?)</span>')
557                               #   <span id="keepMeBolded" class="alm-search-page matching-text-highlight">bogus</span>
558
559    def __init__(self, oid, name, resource_url, context):
560        """
561            All sub-classes have an oid (normally an Object ID), but what we get for an oid here is actually a uuid,
562            attribute the ObjectID storage here (into which the uuid will be placed).
563        """
564        self.oid       = self.ObjectID = oid
565        self.Name      = name
566        self._ref      = resource_url
567        self._hydrated = True
568        self._context  = context
569
570    def __setattr__(self, item, value):
571        self.__dict__[item] = value
572        if item == 'MatchingText' and value is not None:
573            # scrub out the alm specific html tags
574            scrubbed = re.sub(self.tagged_field_name_pattern, so_element_text, value)
575            scrubbed = re.sub(self.bolding_pattern, so_bolded_text, scrubbed)
576            self.__dict__[item] = scrubbed
577        return self.__dict__[item]
578
579#################################################################################################
580
581# ultimately, the classFor dict is what is intended to be exposed as a means to limit
582# instantiation to concrete classes, although because of dyna-types that is no longer
583# very strictly enforced
584#
585
586classFor = { 'Persistable'             : Persistable,
587             'DomainObject'            : DomainObject,
588             'WorkspaceDomainObject'   : WorkspaceDomainObject,
589             'Subscription'            : Subscription,
590             'User'                    : User,
591             'UserProfile'             : UserProfile,
592             'UserPermission'          : UserPermission,
593             'Workspace'               : Workspace,
594             'WorkspaceConfiguration'  : WorkspaceConfiguration,
595             'WorkspacePermission'     : WorkspacePermission,
596             'Type'                    : Type,
597             'TypeDefinition'          : TypeDefinition,
598             'AttributeDefinition'     : AttributeDefinition,
599             'Program'                 : Program,
600             'Project'                 : Project,
601             'ProjectPermission'       : ProjectPermission,
602             'Artifact'                : Artifact,
603             'ArtifactNotification'    : ArtifactNotification,
604             'Release'                 : Release,
605             'Iteration'               : Iteration,
606             'Requirement'             : Requirement,
607             'HierarchicalRequirement' : HierarchicalRequirement,
608             'UserStory'               : UserStory,
609             'Story'                   : Story,
610             'Task'                    : Task,
611             'Tag'                     : Tag,
612             'Preference'              : Preference,
613             'SCMRepository'           : SCMRepository,
614             'RevisionHistory'         : RevisionHistory,
615             'ProfileImage'            : ProfileImage,
616             'Revision'                : Revision,
617             'Attachment'              : Attachment,
618             'AttachmentContent'       : AttachmentContent,
619             'TestCase'                : TestCase,
620             'TestCaseStep'            : TestCaseStep,
621             'TestCaseResult'          : TestCaseResult,
622             'TestSet'                 : TestSet,
623             'TestFolder'              : TestFolder,
624             'TestFolderStatus'        : TestFolderStatus,
625             'TimeEntryItem'           : TimeEntryItem,
626             'TimeEntryValue'          : TimeEntryValue,
627             'Build'                   : Build,
628             'BuildDefinition'         : BuildDefinition,
629             'BuildMetric'             : BuildMetric,
630             'BuildMetricDefinition'   : BuildMetricDefinition,
631             'Defect'                  : Defect,
632             'DefectSuite'             : DefectSuite,
633             'Change'                  : Change,
634             'Changeset'               : Changeset,
635             'FlowState'               : FlowState,
636             'PortfolioItem'           : PortfolioItem,
637             'PortfolioItem_Strategy'  : PortfolioItem_Strategy,
638             'PortfolioItem_Initiative': PortfolioItem_Initiative,
639             'PortfolioItem_Theme'     : PortfolioItem_Theme,
640             'PortfolioItem_Feature'   : PortfolioItem_Feature,
641             'State'                   : State,
642             'PreliminaryEstimate'     : PreliminaryEstimate,
643             'WebLinkDefinition'       : WebLinkDefinition,
644             'Milestone'               : Milestone,
645             'ConversationPost'        : ConversationPost,
646             'Blocker'                 : Blocker,
647             'AllowedAttributeValue'   : AllowedAttributeValue,
648             'AllowedQueryOperator'    : AllowedQueryOperator,
649             'CustomField'             : CustomField,
650             'UserIterationCapacity'   : UserIterationCapacity,
651             'CumulativeFlowData'      : CumulativeFlowData,
652             'ReleaseCumulativeFlowData'   : ReleaseCumulativeFlowData,
653             'IterationCumulativeFlowData' : IterationCumulativeFlowData,
654             'RecycleBinEntry'         : RecycleBinEntry,
655             'SearchObject'            : SearchObject,
656             'Connection'              : Connection,
657             'PullRequest'             : PullRequest,
658           }
659
660for entity_name, entity_class in list(classFor.items()):
661    _rally_entity_cache[entity_name] = entity_name
662entity_class = None  # reset...
663
664# now stuff whatever other classes we've defined in this module that aren't already in
665# _rally_entity_cache
666
667# Predicate to make sure the classes only come from the module in question
668def pred(c):
669    return inspect.isclass(c) and c.__module__ == pred.__module__
670# fetch all members of module __name__ matching 'pred'
671import inspect
672classes = inspect.getmembers(sys.modules[__name__], pred)
673for cls_name, cls in classes:
674    if cls_name not in _rally_entity_cache and re.search("^[A-Z]", cls_name):
675        _rally_entity_cache[cls_name] = cls_name
676
677##################################################################################################
678
679class SchemaItem(object):
680    def __init__(self, raw_info):
681        self._type = 'TypeDefinition'
682        # ElementName, DisplayName, Name
683        # Ordinal   # who knows what is for... looks to be only relevant for PortfolioItem sub-items
684        # ObjectID,
685        # Parent, Abstract, TypePath, IDPrefix,
686        # Creatable, ReadOnly, Queryable, Deletable, Restorable
687        # Attributes
688        # RevisionHistory
689        # Subscription, Workspace
690        self.ref    = "/".join(raw_info['_ref'].split('/')[-2:])
691        self.ObjectName  = str(raw_info['_refObjectName'])
692        self.ElementName = str(raw_info['ElementName'])
693        self.Name        = str(raw_info['Name'])
694        self.DisplayName = str(raw_info['DisplayName'])
695        self.TypePath    = str(raw_info['TypePath'])
696        self.IDPrefix    = str(raw_info['IDPrefix'])
697        self.Abstract    =     raw_info['Abstract']
698        self.Parent      =     raw_info['Parent']
699        if self.Parent:  # so apparently AdministratableProject doesn't have a Parent object
700            self.Parent = str(self.Parent['_refObjectName'])
701        self.Creatable   =     raw_info['Creatable']
702        self.Queryable   =     raw_info['Queryable']
703        self.ReadOnly    =     raw_info['ReadOnly']
704        self.Deletable   =     raw_info['Deletable']
705        self.Restorable  =     raw_info['Restorable']
706        self.Ordinal     =     raw_info['Ordinal']
707        self.RevisionHistory = raw_info['RevisionHistory'] # a ref to a Collection, defer chasing for now...
708        self.Attributes  = []
709        for attr in raw_info['Attributes']:
710            self.Attributes.append(SchemaItemAttribute(attr))
711        self.completed = False
712
713
714    def complete(self, context, getCollection):
715        """
716            This method is used to trigger the complete population of all Attributes,
717            in particular the resolution of refs to AllowedValues that are present after
718            the instantiation of each Attribute.
719            There are some standard attributes whose type is COLLECTION that are not to be
720            treated as "allowedValue" eligible; for the reason that the collections may be
721            arbitrarily large and more frequently updated as opposed to more "normal"
722            attributes like 'State', 'Severity', etc.,  AND in many cases the values are
723            on a per specific artifact/entity basis rather than values eligible for the
724            artifact/entity on a workspace-wide basis.
725            Sequence through each Attribute and call resolveAllowedValues for each Attribute.
726        """
727        NON_ELIGIBLE_ALLOWED_VALUES_ATTRIBUTES = \
728            [ 'Artifacts', 'Attachments', 'Changesets', 'Children', 'Collaborators',
729              'Defects', 'DefectSuites', 'Discussion', 'Duplicates', 'Milestones',
730              'Iteration', 'Release', 'Project',
731              'Owner', 'SubmittedBy', 'Predecessors', 'Successors',
732              'Tasks', 'TestCases', 'TestSets', 'Results', 'Steps', 'Tags',
733            ]
734
735        if self.completed:
736            return True
737        for attribute in sorted([attr for attr in self.Attributes if attr.AttributeType in ['RATING', 'STATE', 'COLLECTION']]):
738            # only an attribute whose AttributeType is RATING or STATE will have allowedValues
739            if attribute.ElementName in NON_ELIGIBLE_ALLOWED_VALUES_ATTRIBUTES:
740               continue
741            attribute.resolveAllowedValues(context, getCollection)
742
743        self.completed = True
744        return self.completed
745
746
747    def inheritanceChain(self):
748        """
749            Find the chain of inheritance for this Rally Type.
750            Exclude the basic Python object.
751            Return a list starting with the furthermost ancestor continuing on down to this Rally Type.
752        """
753        try:
754            klass = classFor[self.Name.replace(' ', '')]
755        except:
756            pi_qualified_name = 'PortfolioItem_%s' % self.Name
757            try:
758                klass = classFor[pi_qualified_name.replace(' ', '')]
759            except:
760                raise InvalidRallyTypeNameError(self.Name)
761
762        ancestors = []
763        for ancestor in klass.mro():
764            mo = re.search(r"'pyral\.entity\.(\w+)'", str(ancestor))
765            if mo:
766                ancestors.append(mo.group(1))
767        ancestors.reverse()
768        return ancestors
769
770
771    def __str__(self):
772        """
773            Apparently no items returned by the WSAPI 2.0 have Abstract == True,
774            so don't include it in the output string.
775            Also, the Parent info is essentially a duplicate of ElementName except for PortfolioItem sub-items,
776            so exclude that info from the output string, we'll cover this by handling the TypePath instead.
777            For the TypePath, only include that if the string contains a '/' character, in which case
778            include that on the head_line.
779        """
780        abstract   = "Abstract" if self.Abstract else "Concrete"
781        parentage  = "Parent -> %s" % self.Parent if self.Parent != self.ElementName else ""
782        abs_par    = "    %s  %s" % (abstract, parentage)
783        tp         = "TypePath: %s" % self.TypePath if '/' in self.TypePath else ""
784        head_line  = "%s  DisplayName: %s  IDPrefix: %s  %s"  % \
785                     (self.ElementName, self.DisplayName, self.IDPrefix, tp)
786        creatable  = "Creatable"  if self.Creatable  else "Non-Creatable"
787        read_only  = "ReadOnly"   if self.ReadOnly   else "Updatable"
788        queryable  = "Queryable"  if self.Queryable  else "Non-Queryable"
789        deletable  = "Deletable"  if self.Deletable  else "Non-Deletable"
790        restorable = "Restorable" if self.Restorable else "Non-Restorable"
791        ops_line   = "      %s  %s  %s  %s  %s" % (creatable, read_only, queryable, deletable, restorable)
792        attr_hdr   = "      Attributes:"
793        attrs      = [str(attr)+"\n" for attr in self.Attributes]
794
795        general = "\n".join([head_line, ops_line, "", attr_hdr])
796
797        return general + "\n" + "\n".join(attrs)
798
799class SchemaItemAttribute(object):
800    def __init__(self, attr_info):
801        self._type    = "AttributeDefinition"
802        self.ref      = "/".join(attr_info['_ref'][-2:])
803        self.ObjectName    = str(attr_info['_refObjectName'])
804        self.ElementName   = str(attr_info['ElementName'])
805        self.Name          = str(attr_info['Name'])
806        self.AttributeType = str(attr_info['AttributeType'])
807        self.Subscription  =     attr_info.get('Subscription', None)  # not having 'Subscription' should be rare
808        self.Workspace     =     attr_info.get('Workspace', None)     # apparently only custom fields will have a 'Workspace' value
809        self.Custom        =     attr_info['Custom']
810        self.Required      =     attr_info['Required']
811        self.ReadOnly      =     attr_info['ReadOnly']
812        self.Filterable    =     attr_info['Filterable']
813        self.Hidden        =     attr_info['Hidden']
814        self.SchemaType    =     attr_info['SchemaType']
815        self.Constrained   =     attr_info['Constrained']
816        self.AllowedValueType =  attr_info['AllowedValueType'] # has value iff this attribute has allowed values
817        self.AllowedValues    =  attr_info['AllowedValues']
818        self.MaxLength        =  attr_info['MaxLength']
819        self.MaxFractionalDigits = attr_info['MaxFractionalDigits']
820        self._allowed_values           =  False
821        self._allowed_values_resolved  =  False
822        if self.AllowedValues and type(self.AllowedValues) == dict:
823            self.AllowedValues = str(self.AllowedValues['_ref']) # take the ref as value
824            self._allowed_values = True
825        elif self.AllowedValues and type(self.AllowedValues) == list:
826            buffer = []
827            for item in self.AllowedValues:
828                name = item.get('LocalizedStringValue', item['StringValue'])
829                aav = AllowedAttributeValue(0, name, None, None)
830                aav.Name        = name
831                aav.StringValue = name
832                aav._hydrated   = True
833                buffer.append(aav)
834            self.AllowedValues   = buffer[:]
835            self._allowed_values = True
836            self._allowed_values_resolved = True
837
838    def __lt__(self, other):
839        return self.ElementName < other.ElementName
840
841    def resolveAllowedValues(self, context, getCollection):
842        """
843            Only if this Attribute has AllowedValues and those values have not yet been obtained
844            by chasing the collection URL left from initialization, does this method issue a
845            call to resolve the collection URL via the getCollection callable parm.
846            The need to use getCollection is based on whether the AllowedValues value
847            is a string that matches the regex r'^https?://.*/attributedefinition/-\\d+/AllowedValues'
848        """
849##
850##        print("in resolveAllowedValues for |%s| is a %s" % (self.Name, type(self.Name)))
851##        print("in resolveAllowedValues for %s   AllowedValues: %s" % (self.Name, self.AllowedValues))
852##
853        if not self._allowed_values:
854            self._allowed_values_resolved = True
855            return True
856        if self._allowed_values_resolved:
857            return True
858        if type(self.AllowedValues) != str:  #previously was   != bytes
859            return True
860        std_av_ref_pattern = r'^https?://.*/\w+/-?\d+/AllowedValues$'
861        mo = re.match(std_av_ref_pattern, self.AllowedValues)
862        if not mo:
863            anomaly = "Standard AllowedValues ref pattern |%s| not matched by candidate |%s|" % \
864                      (std_av_ref_pattern, self.AllowedValues)
865            raise UnrecognizedAllowedValuesReference(anomaly)
866##
867##        print("about to call getCollection for %s  on %s" % (self.AllowedValues, self.Name))
868##
869        collection = getCollection(context, self.AllowedValues)
870        self.AllowedValues = [value for value in collection]
871        self._allowed_values_resolved = True
872
873        return True
874
875
876    def __str__(self):
877        ident = self.ElementName
878        disp  = "|%s|" % self.Name if self.Name != self.ElementName else ""
879        custom = "" if not self.Custom else "Custom"
880        attr_type = self.AttributeType
881        required = "Required"  if self.Required    else "Optional"
882        ident_line = "         %-24.24s  %-6.6s  %10.10s  %8.8s  %s" % (ident, custom, attr_type, required, disp)
883        ro     = "ReadOnly"    if self.ReadOnly    else "Updatable"
884        filt   = "Filterable"  if self.Filterable  else ""
885        hidden = "Hidden"      if self.Hidden      else ""
886        constr = "Constrained" if self.Constrained else ""
887        misc_line  = "             %s  %s  %s  %s" % (ro, filt, hidden, constr)
888        st_line    = "             SchemaType: %s" % self.SchemaType
889
890        output_lines = [ident_line, misc_line]
891
892        if self.AllowedValueType and not self._allowed_values_resolved:
893            avt_ref = "/".join(self.AllowedValueType['_ref'].split('/')[-2:])
894            avt_line = "             AllowedValueType ref: %s" % avt_ref
895            #output_lines.append(avt_line)
896            avv_ref = "/".join(self.AllowedValues.split('/')[-3:])
897            avv_line = "             AllowedValues: %s" % avv_ref
898            output_lines.append(avv_line)
899        elif self._allowed_values_resolved:
900            if self.AllowedValues and type(self.AllowedValues) == list:
901                avs = []
902                for ix, item in enumerate(self.AllowedValues):
903                   if type(item) == dict:
904                       avs.append(str(item['StringValue']))
905                   else:
906                       avs.append(str(item.__dict__['StringValue']))
907
908                avv_line = "             AllowedValues: %s" % avs
909                output_lines.append(avv_line)
910
911        return "\n".join(output_lines)
912
913##################################################################################################
914
915def getEntityName(candidate):
916    """
917        Looks for an entry in the _rally_entity_cache of the form '*/candidate'
918        and returns that value if it exists.
919    """
920    global _rally_entity_cache
921
922    official_name = candidate
923    hits = [path for entity, path in list(_rally_entity_cache.items())
924                  if '/'  in path and path.split('/')[1] == candidate]
925##
926##    print("for candidate |%s|  hits: |%s|" % (candidate, hits))
927##
928    if hits:
929        official_name = hits.pop(0)
930    return official_name
931
932
933def validRallyType(candidate):
934    """
935        Given a candidate Rally entity name, see if the candidate is in our
936        _rally_entity_cache by virtue of being populated via a startup call
937        to processSchemaInfo.
938        Raise an exception when the candidate cannot be determined to be
939        the ElementName of a valid Rally Type.
940    """
941    global _rally_entity_cache
942
943    if candidate in _rally_entity_cache:
944        return getEntityName(candidate)
945
946    # Unfortunate hard-coding of standard Rally Portfolio item dyna-types
947    if candidate in PORTFOLIO_ITEM_SUB_TYPES:
948        pi_candidate = 'PortfolioItem/%s' % candidate
949        return getEntityName(pi_candidate)
950
951    raise InvalidRallyTypeNameError(candidate)
952
953
954def processSchemaInfo(workspace, schema_info):
955    """
956        Fill _rally_schema dict for the workspace's ref key with a dict of
957           SchemaItem objects for each block of entity information
958    """
959    wksp_name, wksp_ref = workspace
960    global _rally_schema
961    global _rally_entity_cache
962
963    _rally_schema[wksp_ref] = {}
964
965    for ix, raw_item_info in enumerate(schema_info):
966        item = SchemaItem(raw_item_info)
967        _rally_schema[wksp_ref][item.ElementName] = item
968        if item.Abstract:
969            continue
970        if  item.ElementName not in _rally_entity_cache:
971            _rally_entity_cache[item.ElementName] = item.ElementName
972        if item.TypePath != item.ElementName:
973            _rally_schema[wksp_ref][item.TypePath] = item
974            if item.TypePath not in _rally_entity_cache:
975                _rally_entity_cache[item.TypePath] = item.TypePath
976    _rally_schema[wksp_ref]['Story']     = _rally_schema[wksp_ref]['HierarchicalRequirement']
977    _rally_schema[wksp_ref]['UserStory'] = _rally_schema[wksp_ref]['HierarchicalRequirement']
978
979    unaccounted_for_entities = [entity_name for entity_name in list(_rally_schema[wksp_ref].keys())
980                                             if  entity_name not in classFor
981                                             and not entity_name.startswith('ObjectAttr')
982                               ]
983    for entity_name in unaccounted_for_entities:
984        if entity_name in ['ScopedAttributeDefinition']:
985            continue
986
987        entity = _rally_schema[wksp_ref][entity_name]
988        typePath = entity.TypePath
989        pyralized_class_name = str(typePath.replace('/', '_'))
990        if pyralized_class_name not in classFor:
991            parentClass = WorkspaceDomainObject
992            if entity.Parent:
993                try:
994                    parentClass = classFor[entity.Parent]
995                except:
996                    pass
997            rally_entity_class = _createClass(pyralized_class_name, parentClass)
998            classFor[typePath] = rally_entity_class
999
1000    augmentSchemaWithPullRequestInfo(workspace)
1001
1002
1003def puff(attr_name, attr_type, attr_required):
1004    ad = {'_ref'           : 'attributedefinition/123456',
1005          '_refObjectName' : attr_name,
1006          'ElementName'    : attr_name,
1007          'Name'           : attr_name,
1008          'AttributeType'  : attr_type,
1009          'Custom'         : False,
1010          'Required'       : attr_required,
1011          'ReadOnly'       : False,
1012          'Filterable'     : True,
1013          'SchemaType'     : 'abc',
1014          'Hidden'         : False,
1015          'Constrained'    : False,
1016          'AllowedValueType' : False,
1017          'AllowedValues'  : [],
1018          'MaxLength'      : 255,
1019          'MaxFractionalDigits' : 1
1020    }
1021    return ad
1022
1023
1024def augmentSchemaWithPullRequestInfo(workspace):
1025    wksp_name, wksp_ref = workspace
1026    global _rally_schema
1027    global _rally_entity_cache
1028
1029    pr_data  = {'_ref'            : 'pullrequest/1233456789',
1030                '_refObjectName'  : 'Pull Request',
1031                'ElementName'     : 'PullRequest',
1032                'Name'            : 'pullRequest',
1033                'DisplayName'     : 'PullRequest',
1034                'TypePath'        : 'PullRequest',
1035                'IDPrefix'        : 'PR',
1036                'Abstract'        : False,
1037                #'Parent'          : 'Connection',
1038                'Parent'          : None,
1039                'Creatable'       : True,
1040                'ReadOnly'        : False,
1041                'Queryable'       : False,
1042                'Deletable'       : True,
1043                'Restorable'      : False,
1044                'Ordinal'         : 1,
1045                'RevisionHistory' : 'putrid',
1046                'Attributes'      : [],
1047               }
1048    pr_attr_names = [('ExternalID',  'STRING', True),
1049                     ('ExternalFormattedId', 'STRING', True),
1050                     ('Name',        'STRING', True),
1051                     ('Description', 'TEXT',   False),
1052                     ('Url',         'STRING', True),
1053                     ('Artifact',    'OBJECT', True)]
1054    #pr_attr_names = ['ExternalId',
1055    #                 'ExternalFormattedId',
1056    #                 'Name',
1057    #                 'Description',
1058    #                 'Url',
1059    #                 'Artifact',
1060    #                ]
1061    #pr_data['Attributes']  = pr_attr_names
1062    for pr_attr, pr_type, pr_reqd in pr_attr_names:
1063        pr_data['Attributes'].append(puff(pr_attr, pr_type, pr_reqd))
1064    _rally_schema[wksp_ref]['PullRequest'] = SchemaItem(pr_data)
1065    _rally_schema[wksp_ref]['PullRequest'].completed = True
1066
1067
1068def getSchemaItem(workspace, entity_name):
1069    wksp_name, wksp_ref = workspace
1070    global _rally_schema
1071    if wksp_ref not in _rally_schema:
1072        raise Exception("Fault: no _rally_schema info for %s" % wksp_ref)
1073    schema = _rally_schema[wksp_ref]
1074    if entity_name not in schema:
1075        return None
1076    return schema[entity_name]
1077
1078
1079def _createClass(name, parentClass):
1080    """
1081        Dynamically create a class named for name whose parent is parent, and
1082        make the newly created class available by name in the global namespace.
1083    """
1084    rally_entity_class = type(name, (parentClass,), {})
1085
1086    globals()[name] = rally_entity_class
1087    return rally_entity_class
1088
1089def addEntity(name, parentClass):
1090    new_class = _createClass(name, parentClass)
1091    if parentClass.__name__ == 'PortfolioItem':
1092        full_name = "%s_%s" % (parentClass.__name__, name)
1093        classFor[full_name] = new_class
1094        PORTFOLIO_ITEM_SUB_TYPES.append(name)
1095    else:
1096        classFor[name] = new_class
1097    return new_class
1098
1099__all__ = [processSchemaInfo, classFor, validRallyType, getSchemaItem,
1100           InvalidRallyTypeNameError, UnrecognizedAllowedValuesReference,
1101           addEntity, PORTFOLIO_ITEM_SUB_TYPES
1102          ]
1103