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