Skip to content

Commit 7b7370c

Browse files
committed
initial swag at mods for 0.9.4
1 parent 62c3a68 commit 7b7370c

File tree

8 files changed

+380
-0
lines changed

8 files changed

+380
-0
lines changed

dists/pyral-0.9.2.tar.gz

131 KB
Binary file not shown.

dists/pyral-0.9.2.zip

161 KB
Binary file not shown.

dists/pyral-0.9.3.tar.gz

131 KB
Binary file not shown.

dists/pyral-0.9.3.zip

154 KB
Binary file not shown.

dists/pyral-0.9.4.tar.gz

129 KB
Binary file not shown.

dists/pyral-0.9.4.zip

153 KB
Binary file not shown.

pyral/query_builder.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
#!/usr/bin/python
2+
3+
###################################################################################################
4+
#
5+
# pyral.query_builder - module to build Rally WSAPI compliant query clause
6+
#
7+
###################################################################################################
8+
9+
__version__ = (0, 9, 4)
10+
11+
import re
12+
import types
13+
import urllib
14+
15+
###################################################################################################
16+
17+
JSON_FORMAT = ".js"
18+
19+
class RallyUrlBuilder(object):
20+
"""
21+
An instance of this class is used to collect information needed to construct a
22+
valid URL that can be issued in a REST Request to Rally.
23+
The sequence of use is to obtain a RallyUrlBuilder for a named entity,
24+
provide qualifying criteria, augments, scoping criteria and any provision
25+
for a pretty response, and then call build to return the resulting resource URL.
26+
An instance can be re-used (for the same entity) by simply re-calling the
27+
specification methods with differing values and then re-calling the build method.
28+
"""
29+
parts = ['fetch', 'query', 'order',
30+
'workspace', 'project', 'projectScopeUp', 'projectScopeDown',
31+
'pagesize', 'start', 'pretty'
32+
]
33+
34+
def __init__(self, entity):
35+
self.entity = entity
36+
37+
def qualify(self, fetch, query, order, pagesize, startIndex):
38+
self.fetch = fetch
39+
self.query = query
40+
self.order = order
41+
self.pagesize = pagesize
42+
self.startIndex = startIndex
43+
self.workspace = None
44+
self.project = None
45+
self.scopeUp = None
46+
self.scopeDown = None
47+
self.pretty = False
48+
49+
50+
def build(self, pretty=None):
51+
if pretty:
52+
self.pretty = True
53+
54+
resource = "%s%s?" % (self.entity, JSON_FORMAT)
55+
56+
qualifiers = ['fetch=%s' % self.fetch]
57+
if self.query:
58+
#encodedQuery = self._prepQuery(self.query)
59+
#qualifiers.append('%s=%s' % ('query', encodedQuery if encodedQuery else ""))
60+
query_string = RallyQueryFormatter.parenGroups(self.query)
61+
##
62+
## print "query_string: |query=(%s)|" % query_string
63+
##
64+
qualifiers.append("query=(%s)" % query_string)
65+
if self.order:
66+
qualifiers.append("order=%s" % urllib.quote(self.order))
67+
if self.workspace:
68+
qualifiers.append(self.workspace)
69+
if self.project:
70+
qualifiers.append(self.project)
71+
if self.scopeUp:
72+
qualifiers.append(self.scopeUp)
73+
if self.scopeDown:
74+
qualifiers.append(self.scopeDown)
75+
76+
qualifiers.append('pagesize=%s' % self.pagesize)
77+
qualifiers.append('start=%s' % self.startIndex)
78+
79+
if self.pretty:
80+
qualifiers.append('pretty=true')
81+
82+
resource += "&".join(qualifiers)
83+
##
84+
## print "RallyUrlBuilder.build: resource= %s" % resource
85+
##
86+
return resource
87+
88+
def augmentWorkspace(self, augments, workspace_ref):
89+
wksp_augment = [aug for aug in augments if aug.startswith('workspace=')]
90+
self.workspace = "workspace=%s" % workspace_ref
91+
if wksp_augment:
92+
self.workspace = wksp_augment[0]
93+
94+
def augmentProject(self, augments, project_ref):
95+
proj_augment = [aug for aug in augments if aug.startswith('project=')]
96+
self.project = "project=%s" % project_ref
97+
if proj_augment:
98+
self.project = proj_augment[0]
99+
100+
def augmentScoping(self, augments):
101+
scopeUp = [aug for aug in augments if aug.startswith('projectScopeUp=')]
102+
if scopeUp:
103+
self.scopeUp = scopeUp[0]
104+
scopeDown = [aug for aug in augments if aug.startswith('projectScopeDown=')]
105+
if scopeDown:
106+
self.scopeDown = scopeDown[0]
107+
108+
def beautifyResponse(self):
109+
self.pretty = True
110+
111+
##################################################################################################
112+
113+
class RallyQueryFormatter(object):
114+
CONJUNCTIONS = ['and', 'AND', 'or', 'OR']
115+
CONJUNCTION_PATT = re.compile('\s+(AND|OR)\s+', re.I | re.M)
116+
117+
@staticmethod
118+
def parenGroups(criteria):
119+
"""
120+
Keep in mind that Rally WSAPI only supports a binary expression of (x) op (y)
121+
as in "(foo) and (bar)"
122+
or (foo) and ((bar) and (egg))
123+
Note that Rally doesn't handle (x and y and z) directly.
124+
Look at the criteria to see if there are any parens other than begin and end
125+
if the only parens are at begin and end, strip them and subject the criteria to our
126+
clause grouper and binary expression confabulator.
127+
Otherwise, we'll naively assume the caller knows what they are doing, ie., they are
128+
aware of the binary expression requirement.
129+
"""
130+
def _encode(condition):
131+
"""
132+
if cond has pattern of 'thing relation value', then urllib.quote it and return it
133+
if cond has pattern of '(thing relation value)', then urllib.quote content inside parens
134+
then pass that result enclosed in parens back to the caller
135+
"""
136+
first_last = "%s%s" % (condition[0], condition[-1])
137+
if first_last == "()":
138+
url_encoded = urllib.quote(condition)
139+
else:
140+
url_encoded = '(%s)' % urllib.quote(condition)
141+
142+
# replace the %xx encodings for '=', '(', ')', '!', and double quote characters
143+
readable_encoded = url_encoded.replace("%3D", '=')
144+
readable_encoded = readable_encoded.replace("%22", '"')
145+
readable_encoded = readable_encoded.replace("%28", '(')
146+
readable_encoded = readable_encoded.replace("%29", ')')
147+
readable_encoded = readable_encoded.replace("%21", '!')
148+
return readable_encoded
149+
##
150+
## print "RQF.parenGroups criteria parm: |%s|" % repr(criteria)
151+
##
152+
153+
if type(criteria) in [types.ListType, types.TupleType]:
154+
# by fiat (and until requested by a paying customer), we assume the criteria expressions are AND'ed
155+
#conditions = [_encode(expression) for expression in criteria]
156+
conditions = [expression for expression in criteria]
157+
criteria = " AND ".join(conditions)
158+
##
159+
## print "RallyQueryFormatter: criteria is sequence type resulting in |%s|" % criteria
160+
##
161+
162+
if type(criteria) == types.DictType:
163+
expressions = []
164+
for field, value in criteria.items():
165+
# have to enclose string value in double quotes, otherwise turn whatever the value is into a string
166+
tval = '"%s"' % value if type(value) == types.StringType else '%s' % value
167+
expression = ('%s = %s' % (field, tval)).replace(' ', '%20')
168+
if len(criteria) == 1:
169+
return expression
170+
expressions.append(expression)
171+
criteria = " AND ".join(expressions)
172+
173+
# if the caller has a simple query in the form "(something = a_value)"
174+
# then return the query as is (after stripping off the surrounding parens)
175+
if criteria.count('(') == 1 and criteria.count(')') == 1 and \
176+
criteria.strip()[0] == '(' and criteria.strip()[-1] == ')':
177+
return criteria.strip()[1:-1].replace(' ', '%20')
178+
179+
# if caller has more than one opening paren, summarily return the query
180+
# essentially untouched (except for encoding space chars).
181+
# The assumption is that the caller has correctly done the parenthisized grouping
182+
# to end up in a binary form
183+
if criteria.count('(') > 1:
184+
return criteria.strip().replace(' ', '%20')
185+
186+
parts = RallyQueryFormatter.CONJUNCTION_PATT.split(criteria.strip())
187+
##
188+
## print "RQF parts: %s" % repr(parts)
189+
##
190+
191+
# if no CONJUNCTION is in parts, use the condition as is (simple case)
192+
conjunctions = [p for p in parts if p in RallyQueryFormatter.CONJUNCTIONS]
193+
if not conjunctions:
194+
expression = criteria.strip().replace(' ', '%20')
195+
#expression = urllib.quote(criteria.strip()).replace('%28', '(').replace('%29', ')')
196+
##
197+
## print "RQF.no_conjunctions: |%s|" % expression
198+
##
199+
return expression
200+
201+
binary_expression = parts.pop()
202+
while parts:
203+
item = parts.pop()
204+
if item in RallyQueryFormatter.CONJUNCTIONS:
205+
conj = item
206+
binary_expression = "%s (%s)" % (conj, binary_expression)
207+
#binary_expression = "%s%%20(%s)" % (conj, binary_expression)
208+
else:
209+
cond = item
210+
binary_expression = "(%s) %s" % (cond, binary_expression)
211+
#binary_expression = "%s%%20(%s)" % (conj, binary_expression)
212+
213+
#final_expression = urllib.quote(binary_expression).replace('%28', '(').replace('%29', ')')
214+
final_expression = binary_expression.replace('%28', '(')
215+
final_expression = final_expression.replace('%29', ')')
216+
##
217+
## print "RQF.final_expression: |%s|" % final_expression
218+
## print "=============================================="
219+
##
220+
final_expression = final_expression.replace(' ', '%20')
221+
return final_expression
222+
223+
##################################################################################################

test/test_attachments.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
#!/op/local/bin/python2.6
2+
3+
import sys, os
4+
import types
5+
import py
6+
7+
import pyral
8+
from pyral import Rally
9+
10+
RallyRESTAPIError = pyral.context.RallyRESTAPIError
11+
12+
##################################################################################################
13+
14+
TRIAL = "trial.rallydev.com"
15+
16+
TRIAL_USER = "[email protected]"
17+
TRIAL_PSWD = "************"
18+
19+
EXAMPLE_ATTACHMENT_CONTENT = "The quck brown fox eluded the lumbering sloth\n"
20+
21+
##################################################################################################
22+
23+
def conjureUpAttachmentFile(filename, content=None, mimetype="text/plain"):
24+
"""
25+
"""
26+
file_content = content or EXAMPLE_ATTACHMENT_CONTENT
27+
with open(filename, 'w') as af:
28+
af.write(file_content)
29+
return True
30+
31+
32+
def retrieveAttachment(rally, artifact, attachmentFileName):
33+
"""
34+
35+
"""
36+
pass
37+
38+
def test_add_attachment():
39+
"""
40+
"""
41+
rally = Rally(server=TRIAL, user=TRIAL_USER, password=TRIAL_PSWD)
42+
# find a Project with some US artifacts
43+
# pick one with no attachments
44+
# create an attachment file (or choose a smallish file with a commonly used suffix)
45+
# create the attachment in Rally and link it to the US artifact
46+
47+
wksp = rally.getWorkspace()
48+
assert wksp.Name == "Yeti Manual Test Workspace"
49+
50+
response = rally.get('Project', fetch=False, limit=10)
51+
assert response != None
52+
assert response.status_code == 200
53+
54+
proj = rally.getProject() # proj.Name == My Project
55+
assert proj.Name == "My Project"
56+
57+
#response = rally.get("UserStory", fetch="FormattedID,Name,Attachments")
58+
#for story in response:
59+
# print "%s %-48.48s %d" % (story.FormattedID, story.Name, len(story.Attachments))
60+
61+
candidate_story = "US96"
62+
response = rally.get("UserStory", fetch="FormattedID,Name,Attachments",
63+
query='FormattedID = "%s"' % candidate_story)
64+
print response.resultCount
65+
story = response.next()
66+
##
67+
return True
68+
##
69+
assert len(story.Attachments) == 0
70+
71+
attachment_name = "Addendum.txt"
72+
att_ok = conjureUpAttachmentFile(attachment_name)
73+
assert att_ok == True
74+
75+
att = rally.addAttachment(story, attachment_name)
76+
assert att.Name == attachment_name
77+
78+
79+
def test_get_attachment():
80+
"""
81+
"""
82+
rally = Rally(server=TRIAL, user=TRIAL_USER, password=TRIAL_PSWD)
83+
candidate_story = "US80"
84+
target = 'FormattedID = "%s"' % candidate_story
85+
response = rally.get("UserStory", fetch=True, query=target, project=None)
86+
assert response.resultCount == 1
87+
story = response.next()
88+
##
89+
assert True == True
90+
return True
91+
##
92+
assert len(story.Attachments) == 1
93+
attachment = story.Attachments[0]
94+
expected_attachment_name = "Addendum.txt"
95+
assert attachment.Name == expected_attachment_name
96+
97+
attachment = rally.getAttachment(candidate_story, expected_attachment_name)
98+
assert attachment.Name == expected_attachment_name
99+
assert attachment.Content == EXAMPLE_ATTACHMENT_CONTENT
100+
101+
102+
def x_test_detach_attachment():
103+
"""
104+
"""
105+
rally = Rally(server=TRIAL, user=TRIAL_USER, password=TRIAL_PSWD)
106+
candidate_story = "S78"
107+
target = 'FormattedID = "%s"' % candidate_story
108+
109+
response = rally.get("UserStory", fetch=True, query=target, project=None)
110+
assert response.resultCount == 1
111+
story = response.next()
112+
assert len(story.Attachments) == 1
113+
attachment = story.Attachments[0]
114+
expected_attachment_name = "Addendum.txt"
115+
assert attachment.Name == expected_attachment_name
116+
117+
result = rally.deleteAttachment(story, expected_attachment_name)
118+
assert result != False
119+
assert len(result.Attachments) == (len(story.Attachments) - 1)
120+
121+
122+
def x_test_replace_attachment():
123+
"""
124+
"""
125+
126+
127+
def x_test_add_attachments():
128+
"""
129+
"""
130+
131+
132+
def x_test_get_attachments():
133+
"""
134+
"""
135+
136+
137+
def x_test_detach_attachments():
138+
"""
139+
"""
140+
141+
142+
def x_test_replace_attachments():
143+
"""
144+
"""
145+
146+
#expectedErrMsg = "hostname '%s' non-existent or unreachable" % bogus_server
147+
#with py.test.raises(RallyRESTAPIError) as excinfo:
148+
# rally = Rally(server=bogus_server,
149+
# user=TRIAL_USER,
150+
# password=TRIAL_PSWD)
151+
#actualErrVerbiage = excinfo.value.args[0] # becuz Python2.6 deprecates message :-(
152+
#assert excinfo.value.__class__.__name__ == 'RallyRESTAPIError'
153+
#assert actualErrVerbiage == expectedErrMsg
154+
155+
##########################################################################################
156+
157+

0 commit comments

Comments
 (0)