forked from progrium/tracker-widget
-
Notifications
You must be signed in to change notification settings - Fork 0
/
pytracker.py
509 lines (396 loc) · 14.9 KB
/
pytracker.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
#!/usr/bin/env python
#
# Copyright 2009 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""pytracker is a Python wrapper around the Tracker API."""
__author__ = 'dcoker@google.com (Doug Coker)'
import calendar
import cookielib
import re
import time
import urllib
import urllib2
import xml.dom
from xml.dom import minidom
import xml.parsers.expat
import xml.sax.saxutils
DEFAULT_BASE_API_URL = 'https://www.pivotaltracker.com/services/v3/'
# Some fields specify UTC, some GMT?
_TRACKER_DATETIME_RE = re.compile(r'^\d{4}/\d{2}/\d{2} .*(GMT|UTC)$')
def TrackerDatetimeToYMD(pdt):
assert _TRACKER_DATETIME_RE.match(pdt)
pdt = pdt.split()[0]
pdt = pdt.replace('/', '-')
return pdt
class Tracker(object):
"""Tracker API."""
def __init__(self, project_id, auth,
base_api_url=DEFAULT_BASE_API_URL):
"""Constructor.
If you are debugging API calls, you may want to use a non-HTTPS API URL:
base_api_url="http://www.pivotaltracker.com/services/v2/"
Args:
project_id: the Tracker ID (integer).
auth: a TrackerAuth instance.
base_api_url: the base URL of the HTTP API (with trailing /).
"""
self.project_id = project_id
self.base_api_url = base_api_url
cookies = cookielib.CookieJar()
self.opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookies))
self.token = auth.EstablishAuthToken(self.opener)
def _Api(self, request, method, body=None):
url = self.base_api_url + 'projects/%d/%s' % (self.project_id,
request)
headers = {}
if self.token:
headers['X-TrackerToken'] = self.token
if not body and method == 'GET':
# Do a GET
req = urllib2.Request(url, None, headers)
else:
headers['Content-Type'] = 'application/xml'
req = urllib2.Request(url, body, headers)
req.get_method = lambda: method
try:
res = self.opener.open(req)
except urllib2.HTTPError, e:
message = "HTTP Status Code: %s\nMessage: %s\nURL: %s\nError: %s" % (e.code, e.msg, e.geturl(), e.read())
raise TrackerApiException(message)
return res.read()
def _ApiQueryStories(self, query=None):
if query:
output = self._Api('stories?filter=' + urllib.quote_plus(query),
'GET')
else:
output = self._Api('stories', 'GET')
# Hack: throw an exception if we didn't get valid XML.
xml.parsers.expat.ParserCreate('utf-8').Parse(output, True)
return output
def GetStoriesXml(self):
return self._ApiQueryStories()
def GetReleaseStoriesXml(self):
return self._ApiQueryStories('type:release')
def GetIterationStories(self, iteration=None, offset=None, limit=None):
iteration = ('/%s' % iteration) if iteration else ''
params = []
if offset:
params.append('offset=%s' % urllib.quote_plus(str(offset)))
if limit:
params.append('limit=%s' % urllib.quote_plus(str(limit)))
response = self._Api('iterations%s?%s' % (iteration, '&'.join(params)), 'GET')
# Hack: throw an exception if we didn't get valid XML.
xml.parsers.expat.ParserCreate('utf-8').Parse(response, True)
parsed = xml.dom.minidom.parseString(response)
els = parsed.getElementsByTagName('story')
lst = []
for el in els:
lst.append(Story.FromXml(el.toxml()))
return lst
def GetStories(self, filt=None):
"""Fetch all Stories that satisfy the filter.
Args:
filt: a Tracker search filter.
Returns:
List of Story().
"""
stories = self._ApiQueryStories(filt)
parsed = xml.dom.minidom.parseString(stories)
els = parsed.getElementsByTagName('story')
lst = []
for el in els:
lst.append(Story.FromXml(el.toxml()))
return lst
def GetStory(self, story_id):
story_xml = self._Api('stories/%d' % story_id, 'GET')
return Story.FromXml(story_xml)
def AddComment(self, story_id, comment):
comment = '<note><text>%s</text></note>' % xml.sax.saxutils.escape(comment)
self._Api('stories/%d/notes' % story_id, 'POST', comment)
def AddNewStory(self, story):
"""Persists a new story to Tracker and returns the new Story."""
story_xml = story.ToXml()
res = self._Api('stories', 'POST', story_xml)
story = Story.FromXml(res)
return story
def UpdateStoryById(self, story_id, story):
"""Persist changes to an existing story to Tracker.
Use this method if you are changing a story without first retreiving the
story.
Args:
story_id: The ID of the story to mutate
story: The Story containing values to change.
Returns:
The updated Story().
"""
story_xml = story.ToXml()
res = self._Api('stories/%d' % story_id, 'PUT', story_xml)
return Story.FromXml(res)
def UpdateStory(self, story):
"""Persists changes to an existing story to Tracker.
Use this method if you have a full Story object created by one of the query
methods.
Args:
story: a Story()
Returns:
The updated Story().
"""
story_xml = story.ToXml()
res = self._Api('stories/%d' % story.GetStoryId(), 'PUT', story_xml)
return Story.FromXml(res)
def DeleteStory(self, story_id):
"""Deletes a story by story ID."""
self._Api('stories/%d' % story_id, 'DELETE', '')
class TrackerAuth(object):
"""Abstract base class for establishing credentials for pytracker."""
def __init__(self, username, password):
self.username = username
self.password = password
def EstablishAuthToken(self, opener):
"""Returns the value for use as the X-TrackerToken HTTP header, or None.
This method may mutate the cookie jar via opener.
Args:
opener: a urllib2.OpenerDirector instance that will be used for
subsequent HTTP API calls.
"""
raise NotImplementedError()
class TrackerAuthException(Exception):
"""Raised when something goes wrong with authentication."""
class NoTokensAvailableException(Exception):
"""Raised when HostedTrackerAuth can't find any tokens for this user."""
class TrackerApiException(Exception):
"""Raised when Tracker returns an error."""
class HostedTrackerAuth(TrackerAuth):
"""Authentication rules for hosted Tracker instances."""
def EstablishAuthToken(self, opener):
"""Returns the first auth token returned by /services/tokens/active."""
url = 'https://www.pivotaltracker.com/services/v3/tokens/active'
data = urllib.urlencode((('username', self.username),
('password', self.password)))
try:
req = opener.open(url, data)
except urllib2.HTTPError, e:
if e.code == 404:
raise NoTokensAvailableException(
'Did you create any? Check https://www.pivotaltracker.com/profile')
else:
raise
res = req.read()
dom = minidom.parseString(res)
token = dom.getElementsByTagName('guid')[0].firstChild.data
return token
class Story(object):
"""Represents a Story.
This class can be used to represent a complete Story (generally queried from
the Tracker class), or can contain partial information for update or create
operations (constructed with default constructor).
Internally, Story uses None to indicate that the client has not specified a
value for the field or that it has not been parsed from XML. This enables us
to use the same Story object to define an update to multiple stories, without
requiring that the client first fetch, parse, and update an existing story.
This is supported by all mutable fields except for labels, which are
represented by Tracker as a comma-separated list of strings in a single tag
body. For label operations on existing stories to be performed correctly,
the Story must first be fetched from the server so that the existing labels
are not lost.
"""
# Fields that can be treated as strings when embedding in XML.
UPDATE_FIELDS = ('story_type', 'current_state', 'name',
'description', 'estimate', 'requested_by', 'owned_by')
# Type: immutable ints.
story_id = None
iteration_number = None
# Type: immutable times (secs since epoch)
created_at = None
# Type: mutable time (secs since epoch)
deadline = None
# Type: mutable set (API methods expose as string)
labels = None
# Type: immutable strings
url = None
# Type: mutable strings
requested_by = None
owned_by = None
story_type = None
current_state = None
description = None
name = None
estimate = None
def __str__(self):
return "Story(%r)" % self.__dict__
@staticmethod
def FromXml(as_xml):
"""Parses an XML string into a Story.
Args:
as_xml: a full XML document from the Tracker API.
Returns:
Story()
"""
parsed = minidom.parseString(as_xml.encode('utf-8'))
story = Story()
story.story_id = int(parsed.getElementsByTagName('id')[0].firstChild.data)
story.url = parsed.getElementsByTagName('url')[0].firstChild.data
story.owned_by = Story._GetDataFromTag(parsed, 'owned_by')
story.created_at = Story._ParseDatetimeIntoSecs(parsed, 'created_at')
story.requested_by = Story._GetDataFromTag(parsed, 'requested_by')
iteration = Story._GetDataFromTag(parsed, 'number')
if iteration:
story.iteration_number = int(iteration)
story.SetStoryType(
parsed.getElementsByTagName('story_type')[0].firstChild.data)
story.SetCurrentState(
parsed.getElementsByTagName('current_state')[0].firstChild.data)
story.SetName(Story._GetDataFromTag(parsed, 'name'))
story.SetDescription(Story._GetDataFromTag(parsed, 'description'))
story.SetDeadline(Story._ParseDatetimeIntoSecs(parsed, 'deadline'))
estimate = Story._GetDataFromTag(parsed, 'estimate')
if estimate is not None:
story.estimate = estimate
labels = Story._GetDataFromTag(parsed, 'labels')
if labels is not None:
story.AddLabelsFromString(labels)
return story
@staticmethod
def _GetDataFromTag(dom, tag):
"""Retrieve value associated with the tag, if any.
Args:
dom: XML DOM object
tag: name of the desired tag
Returns:
None (if tag doesn't exist), empty string (if tag exists, but body is
empty), or the tag body.
"""
tags = dom.getElementsByTagName(tag)
if not tags:
return None
elif tags[0].hasChildNodes():
return tags[0].firstChild.data
else:
return ''
@staticmethod
def _ParseDatetimeIntoSecs(dom, tag):
"""Returns the tag body parsed into seconds-since-epoch."""
el = dom.getElementsByTagName(tag)
if not el:
return None
assert el[0].getAttribute('type') == 'datetime'
data = el[0].firstChild.data
# Tracker emits datetime strings in UTC or GMT.
# The [:-4] strips the timezone indicator
when = time.strptime(data[:-4], '%Y/%m/%d %H:%M:%S')
# calendar.timegm treats the tuple as GMT
return calendar.timegm(when)
# Immutable fields
def GetStoryId(self):
return self.story_id
def GetIteration(self):
return self.iteration_number
def GetUrl(self):
return self.url
# Mutable fields
def GetRequestedBy(self):
return self.requested_by
def SetRequestedBy(self, requested_by):
self.requested_by = requested_by
def GetOwnedBy(self):
return self.owned_by
def SetOwnedBy(self, owned_by):
self.owned_by = owned_by
def GetStoryType(self):
return self.story_type
def SetStoryType(self, story_type):
assert story_type in ['bug', 'chore', 'release', 'feature']
self.story_type = story_type
def GetCurrentState(self):
return self.current_state
def SetCurrentState(self, current_state):
self.current_state = current_state
def GetName(self):
return self.name
def SetName(self, name):
self.name = name
def GetEstimate(self):
return self.estimate
def SetEstimate(self, estimate):
self.estimate = estimate
def GetDescription(self):
return self.description
def SetDescription(self, description):
self.description = description
def GetDeadline(self):
return self.deadline
def SetDeadline(self, secs_since_epoch):
self.deadline = secs_since_epoch
def GetCreatedAt(self):
return self.created_at
def SetCreatedAt(self, secs_since_epoch):
self.created_at = secs_since_epoch
def AddLabel(self, label):
"""Adds a label (see caveat in class comment)."""
if self.labels is None:
self.labels = set()
self.labels.add(label)
def RemoveLabel(self, label):
"""Removes a label (see caveat in class comment)."""
if self.labels is None:
self.labels = set()
else:
try:
self.labels.remove(label)
except KeyError:
pass
def AddLabelsFromString(self, labels):
"""Adds a set of labels from a comma-delimited string (see class caveat)."""
if self.labels is None:
self.labels = set()
self.labels = self.labels.union([x.strip() for x in labels.split(',')])
def GetLabelsAsString(self):
"""Returns the labels as a comma delimited list of strings."""
if self.labels is None:
return None
lst = list(self.labels)
lst.sort()
return ','.join(lst)
def ToXml(self):
"""Converts this Story to an XML string."""
doc = xml.dom.getDOMImplementation().createDocument(None, 'story', None)
story = doc.getElementsByTagName('story')[0]
# Most fields are just simple strings or ints, so we treat them all in the
# same way.
for field_name in self.UPDATE_FIELDS:
v = getattr(self, field_name)
if v is not None:
new_tag = doc.createElement(field_name)
new_tag.appendChild(doc.createTextNode(unicode(v)))
story.appendChild(new_tag)
# Labels are represented internally as sets.
if self.labels:
labels_tag = doc.createElement('labels')
labels_tag.appendChild(doc.createTextNode(self.GetLabelsAsString()))
story.appendChild(labels_tag)
# Dates are special
DATE_FORMAT = '%Y/%m/%d %H:%M:%S UTC'
if self.deadline:
formatted = time.strftime(DATE_FORMAT, time.gmtime(self.deadline))
deadline_tag = doc.createElement('deadline')
deadline_tag.setAttribute('type', 'datetime')
deadline_tag.appendChild(doc.createTextNode(formatted))
story.appendChild(deadline_tag)
if self.created_at:
formatted = time.strftime(DATE_FORMAT, time.gmtime(self.created_at))
created_at_tag = doc.createElement('created_at')
created_at_tag.setAttribute('type', 'datetime')
created_at_tag.appendChild(doc.createTextNode(formatted))
story.appendChild(created_at_tag)
return doc.toxml('utf-8')