comparison planemo/bin/mturk @ 0:d30785e31577 draft

"planemo upload commit 6eee67778febed82ddd413c3ca40b3183a3898f1"
author guerler
date Fri, 31 Jul 2020 00:18:57 -0400
parents
children
comparison
equal deleted inserted replaced
-1:000000000000 0:d30785e31577
1 #!/Users/guerler/spring/springsuite/planemo/bin/python3
2 # Copyright 2012, 2014 Kodi Arfer
3 #
4 # Permission is hereby granted, free of charge, to any person obtaining a
5 # copy of this software and associated documentation files (the
6 # "Software"), to deal in the Software without restriction, including
7 # without limitation the rights to use, copy, modify, merge, publish, dis-
8 # tribute, sublicense, and/or sell copies of the Software, and to permit
9 # persons to whom the Software is furnished to do so, subject to the fol-
10 # lowing conditions:
11 #
12 # The above copyright notice and this permission notice shall be included
13 # in all copies or substantial portions of the Software.
14 #
15 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
16 # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL-
17 # ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
18 # SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
19 # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
21
22 import argparse # Hence, Python 2.7 is required.
23 import sys
24 import os.path
25 import string
26 import inspect
27 import datetime, calendar
28 import boto.mturk.connection, boto.mturk.price, boto.mturk.question, boto.mturk.qualification
29 from boto.compat import json
30
31 # --------------------------------------------------
32 # Globals
33 # -------------------------------------------------
34
35 interactive = False
36 con = None
37 mturk_website = None
38
39 default_nicknames_path = os.path.expanduser('~/.boto_mturkcli_hit_nicknames')
40 nicknames = {}
41 nickname_pool = set(string.ascii_lowercase)
42
43 get_assignments_page_size = 100
44
45 time_units = dict(
46 s = 1,
47 min = 60,
48 h = 60 * 60,
49 d = 24 * 60 * 60)
50
51 qual_requirements = dict(
52 Adult = '00000000000000000060',
53 Locale = '00000000000000000071',
54 NumberHITsApproved = '00000000000000000040',
55 PercentAssignmentsSubmitted = '00000000000000000000',
56 PercentAssignmentsAbandoned = '00000000000000000070',
57 PercentAssignmentsReturned = '000000000000000000E0',
58 PercentAssignmentsApproved = '000000000000000000L0',
59 PercentAssignmentsRejected = '000000000000000000S0')
60
61 qual_comparators = {v : k for k, v in dict(
62 LessThan = '<', LessThanOrEqualTo = '<=',
63 GreaterThan = '>', GreaterThanOrEqualTo = '>=',
64 EqualTo = '==', NotEqualTo = '!=',
65 Exists = 'exists').items()}
66
67 example_config_file = '''Example configuration file:
68
69 {
70 "title": "Pick your favorite color",
71 "description": "In this task, you are asked to pick your favorite color.",
72 "reward": 0.50,
73 "assignments": 10,
74 "duration": "20 min",
75 "keywords": ["color", "favorites", "survey"],
76 "lifetime": "7 d",
77 "approval_delay": "14 d",
78 "qualifications": [
79 "PercentAssignmentsApproved > 90",
80 "Locale == US",
81 "2ARFPLSP75KLA8M8DH1HTEQVJT3SY6 exists"
82 ],
83 "question_url": "http://example.com/myhit",
84 "question_frame_height": 450
85 }'''
86
87 # --------------------------------------------------
88 # Subroutines
89 # --------------------------------------------------
90
91 def unjson(path):
92 with open(path) as o:
93 return json.load(o)
94
95 def add_argparse_arguments(parser):
96 parser.add_argument('-P', '--production',
97 dest = 'sandbox', action = 'store_false', default = True,
98 help = 'use the production site (default: use the sandbox)')
99 parser.add_argument('--nicknames',
100 dest = 'nicknames_path', metavar = 'PATH',
101 default = default_nicknames_path,
102 help = 'where to store HIT nicknames (default: {})'.format(
103 default_nicknames_path))
104
105 def init_by_args(args):
106 init(args.sandbox, args.nicknames_path)
107
108 def init(sandbox = False, nicknames_path = default_nicknames_path):
109 global con, mturk_website, nicknames, original_nicknames
110
111 mturk_website = 'workersandbox.mturk.com' if sandbox else 'www.mturk.com'
112 con = boto.mturk.connection.MTurkConnection(
113 host = 'mechanicalturk.sandbox.amazonaws.com' if sandbox else 'mechanicalturk.amazonaws.com')
114
115 try:
116 nicknames = unjson(nicknames_path)
117 except IOError:
118 nicknames = {}
119 original_nicknames = nicknames.copy()
120
121 def save_nicknames(nicknames_path = default_nicknames_path):
122 if nicknames != original_nicknames:
123 with open(nicknames_path, 'w') as o:
124 json.dump(nicknames, o, sort_keys = True, indent = 4)
125 print >>o
126
127 def parse_duration(s):
128 '''Parses durations like "2 d", "48 h", "2880 min",
129 "172800 s", or "172800".'''
130 x = s.split()
131 return int(x[0]) * time_units['s' if len(x) == 1 else x[1]]
132 def display_duration(n):
133 for unit, m in sorted(time_units.items(), key = lambda x: -x[1]):
134 if n % m == 0:
135 return '{} {}'.format(n / m, unit)
136
137 def parse_qualification(inp):
138 '''Parses qualifications like "PercentAssignmentsApproved > 90",
139 "Locale == US", and "2ARFPLSP75KLA8M8DH1HTEQVJT3SY6 exists".'''
140 inp = inp.split()
141 name, comparator, value = inp.pop(0), inp.pop(0), (inp[0] if len(inp) else None)
142 qtid = qual_requirements.get(name)
143 if qtid is None:
144 # Treat "name" as a Qualification Type ID.
145 qtid = name
146 if qtid == qual_requirements['Locale']:
147 return boto.mturk.qualification.LocaleRequirement(
148 qual_comparators[comparator],
149 value,
150 required_to_preview = False)
151 return boto.mturk.qualification.Requirement(
152 qtid,
153 qual_comparators[comparator],
154 value,
155 required_to_preview = qtid == qual_requirements['Adult'])
156 # Thus required_to_preview is true only for the
157 # Worker_Adult requirement.
158
159 def preview_url(hit):
160 return 'https://{}/mturk/preview?groupId={}'.format(
161 mturk_website, hit.HITTypeId)
162
163 def parse_timestamp(s):
164 '''Takes a timestamp like "2012-11-24T16:34:41Z".
165
166 Returns a datetime object in the local time zone.'''
167 return datetime.datetime.fromtimestamp(
168 calendar.timegm(
169 datetime.datetime.strptime(s, '%Y-%m-%dT%H:%M:%SZ').timetuple()))
170
171 def get_hitid(nickname_or_hitid):
172 return nicknames.get(nickname_or_hitid) or nickname_or_hitid
173
174 def get_nickname(hitid):
175 for k, v in nicknames.items():
176 if v == hitid:
177 return k
178 return None
179
180 def display_datetime(dt):
181 return dt.strftime('%e %b %Y, %l:%M %P')
182
183 def display_hit(hit, verbose = False):
184 et = parse_timestamp(hit.Expiration)
185 return '\n'.join([
186 '{} - {} ({}, {}, {})'.format(
187 get_nickname(hit.HITId),
188 hit.Title,
189 hit.FormattedPrice,
190 display_duration(int(hit.AssignmentDurationInSeconds)),
191 hit.HITStatus),
192 'HIT ID: ' + hit.HITId,
193 'Type ID: ' + hit.HITTypeId,
194 'Group ID: ' + hit.HITGroupId,
195 'Preview: ' + preview_url(hit),
196 'Created {} {}'.format(
197 display_datetime(parse_timestamp(hit.CreationTime)),
198 'Expired' if et <= datetime.datetime.now() else
199 'Expires ' + display_datetime(et)),
200 'Assignments: {} -- {} avail, {} pending, {} reviewable, {} reviewed'.format(
201 hit.MaxAssignments,
202 hit.NumberOfAssignmentsAvailable,
203 hit.NumberOfAssignmentsPending,
204 int(hit.MaxAssignments) - (int(hit.NumberOfAssignmentsAvailable) + int(hit.NumberOfAssignmentsPending) + int(hit.NumberOfAssignmentsCompleted)),
205 hit.NumberOfAssignmentsCompleted)
206 if hasattr(hit, 'NumberOfAssignmentsAvailable')
207 else 'Assignments: {} total'.format(hit.MaxAssignments),
208 # For some reason, SearchHITs includes the
209 # NumberOfAssignmentsFoobar fields but GetHIT doesn't.
210 ] + ([] if not verbose else [
211 '\nDescription: ' + hit.Description,
212 '\nKeywords: ' + hit.Keywords
213 ])) + '\n'
214
215 def digest_assignment(a):
216 return dict(
217 answers = {str(x.qid): str(x.fields[0]) for x in a.answers[0]},
218 **{k: str(getattr(a, k)) for k in (
219 'AcceptTime', 'SubmitTime',
220 'HITId', 'AssignmentId', 'WorkerId',
221 'AssignmentStatus')})
222
223 # --------------------------------------------------
224 # Commands
225 # --------------------------------------------------
226
227 def get_balance():
228 return con.get_account_balance()
229
230 def show_hit(hit):
231 return display_hit(con.get_hit(hit)[0], verbose = True)
232
233 def list_hits():
234 'Lists your 10 most recently created HITs, with the most recent last.'
235 return '\n'.join(reversed(map(display_hit, con.search_hits(
236 sort_by = 'CreationTime',
237 sort_direction = 'Descending',
238 page_size = 10))))
239
240 def make_hit(title, description, keywords, reward, question_url, question_frame_height, duration, assignments, approval_delay, lifetime, qualifications = []):
241 r = con.create_hit(
242 title = title,
243 description = description,
244 keywords = con.get_keywords_as_string(keywords),
245 reward = con.get_price_as_price(reward),
246 question = boto.mturk.question.ExternalQuestion(
247 question_url,
248 question_frame_height),
249 duration = parse_duration(duration),
250 qualifications = boto.mturk.qualification.Qualifications(
251 map(parse_qualification, qualifications)),
252 max_assignments = assignments,
253 approval_delay = parse_duration(approval_delay),
254 lifetime = parse_duration(lifetime))
255 nick = None
256 available_nicks = nickname_pool - set(nicknames.keys())
257 if available_nicks:
258 nick = min(available_nicks)
259 nicknames[nick] = r[0].HITId
260 if interactive:
261 print 'Nickname:', nick
262 print 'HIT ID:', r[0].HITId
263 print 'Preview:', preview_url(r[0])
264 else:
265 return r[0]
266
267 def extend_hit(hit, assignments_increment = None, expiration_increment = None):
268 con.extend_hit(hit, assignments_increment, expiration_increment)
269
270 def expire_hit(hit):
271 con.expire_hit(hit)
272
273 def delete_hit(hit):
274 '''Deletes a HIT using DisableHIT.
275
276 Unreviewed assignments get automatically approved. Unsubmitted
277 assignments get automatically approved upon submission.
278
279 The API docs say DisableHIT doesn't work with Reviewable HITs,
280 but apparently, it does.'''
281 con.disable_hit(hit)
282 global nicknames
283 nicknames = {k: v for k, v in nicknames.items() if v != hit}
284
285 def list_assignments(hit, only_reviewable = False):
286 # Accumulate all relevant assignments, one page of results at
287 # a time.
288 assignments = []
289 page = 1
290 while True:
291 rs = con.get_assignments(
292 hit_id = hit,
293 page_size = get_assignments_page_size,
294 page_number = page,
295 status = 'Submitted' if only_reviewable else None)
296 assignments += map(digest_assignment, rs)
297 if len(assignments) >= int(rs.TotalNumResults):
298 break
299 page += 1
300 if interactive:
301 print json.dumps(assignments, sort_keys = True, indent = 4)
302 print ' '.join([a['AssignmentId'] for a in assignments])
303 print ' '.join([a['WorkerId'] + ',' + a['AssignmentId'] for a in assignments])
304 else:
305 return assignments
306
307 def grant_bonus(message, amount, pairs):
308 for worker, assignment in pairs:
309 con.grant_bonus(worker, assignment, con.get_price_as_price(amount), message)
310 if interactive: print 'Bonused', worker
311
312 def approve_assignments(message, assignments):
313 for a in assignments:
314 con.approve_assignment(a, message)
315 if interactive: print 'Approved', a
316
317 def reject_assignments(message, assignments):
318 for a in assignments:
319 con.reject_assignment(a, message)
320 if interactive: print 'Rejected', a
321
322 def unreject_assignments(message, assignments):
323 for a in assignments:
324 con.approve_rejected_assignment(a, message)
325 if interactive: print 'Unrejected', a
326
327 def notify_workers(subject, text, workers):
328 con.notify_workers(workers, subject, text)
329
330 def give_qualification(qualification, workers, value = 1, notify = True):
331 for w in workers:
332 con.assign_qualification(qualification, w, value, notify)
333 if interactive: print 'Gave to', w
334
335 def revoke_qualification(qualification, workers, message = None):
336 for w in workers:
337 con.revoke_qualification(w, qualification, message)
338 if interactive: print 'Revoked from', w
339
340 # --------------------------------------------------
341 # Mainline code
342 # --------------------------------------------------
343
344 if __name__ == '__main__':
345 interactive = True
346
347 parser = argparse.ArgumentParser()
348 add_argparse_arguments(parser)
349 subs = parser.add_subparsers()
350
351 sub = subs.add_parser('bal',
352 help = 'display your prepaid balance')
353 sub.set_defaults(f = get_balance, a = lambda: [])
354
355 sub = subs.add_parser('hit',
356 help = 'get information about a HIT')
357 sub.add_argument('HIT',
358 help = 'nickname or ID of the HIT to show')
359 sub.set_defaults(f = show_hit, a = lambda:
360 [get_hitid(args.HIT)])
361
362 sub = subs.add_parser('hits',
363 help = 'list all your HITs')
364 sub.set_defaults(f = list_hits, a = lambda: [])
365
366 sub = subs.add_parser('new',
367 help = 'create a new HIT (external questions only)',
368 epilog = example_config_file,
369 formatter_class = argparse.RawDescriptionHelpFormatter)
370 sub.add_argument('JSON_PATH',
371 help = 'path to JSON configuration file for the HIT')
372 sub.add_argument('-u', '--question-url', dest = 'question_url',
373 metavar = 'URL',
374 help = 'URL for the external question')
375 sub.add_argument('-a', '--assignments', dest = 'assignments',
376 type = int, metavar = 'N',
377 help = 'number of assignments')
378 sub.add_argument('-r', '--reward', dest = 'reward',
379 type = float, metavar = 'PRICE',
380 help = 'reward amount, in USD')
381 sub.set_defaults(f = make_hit, a = lambda: dict(
382 unjson(args.JSON_PATH).items() + [(k, getattr(args, k))
383 for k in ('question_url', 'assignments', 'reward')
384 if getattr(args, k) is not None]))
385
386 sub = subs.add_parser('extend',
387 help = 'add assignments or time to a HIT')
388 sub.add_argument('HIT',
389 help = 'nickname or ID of the HIT to extend')
390 sub.add_argument('-a', '--assignments', dest = 'assignments',
391 metavar = 'N', type = int,
392 help = 'number of assignments to add')
393 sub.add_argument('-t', '--time', dest = 'time',
394 metavar = 'T',
395 help = 'amount of time to add to the expiration date')
396 sub.set_defaults(f = extend_hit, a = lambda:
397 [get_hitid(args.HIT), args.assignments,
398 args.time and parse_duration(args.time)])
399
400 sub = subs.add_parser('expire',
401 help = 'force a HIT to expire without deleting it')
402 sub.add_argument('HIT',
403 help = 'nickname or ID of the HIT to expire')
404 sub.set_defaults(f = expire_hit, a = lambda:
405 [get_hitid(args.HIT)])
406
407 sub = subs.add_parser('rm',
408 help = 'delete a HIT')
409 sub.add_argument('HIT',
410 help = 'nickname or ID of the HIT to delete')
411 sub.set_defaults(f = delete_hit, a = lambda:
412 [get_hitid(args.HIT)])
413
414 sub = subs.add_parser('as',
415 help = "list a HIT's submitted assignments")
416 sub.add_argument('HIT',
417 help = 'nickname or ID of the HIT to get assignments for')
418 sub.add_argument('-r', '--reviewable', dest = 'only_reviewable',
419 action = 'store_true',
420 help = 'show only unreviewed assignments')
421 sub.set_defaults(f = list_assignments, a = lambda:
422 [get_hitid(args.HIT), args.only_reviewable])
423
424 for command, fun, helpmsg in [
425 ('approve', approve_assignments, 'approve assignments'),
426 ('reject', reject_assignments, 'reject assignments'),
427 ('unreject', unreject_assignments, 'approve previously rejected assignments')]:
428 sub = subs.add_parser(command, help = helpmsg)
429 sub.add_argument('ASSIGNMENT', nargs = '+',
430 help = 'ID of an assignment')
431 sub.add_argument('-m', '--message', dest = 'message',
432 metavar = 'TEXT',
433 help = 'feedback message shown to workers')
434 sub.set_defaults(f = fun, a = lambda:
435 [args.message, args.ASSIGNMENT])
436
437 sub = subs.add_parser('bonus',
438 help = 'give some workers a bonus')
439 sub.add_argument('AMOUNT', type = float,
440 help = 'bonus amount, in USD')
441 sub.add_argument('MESSAGE',
442 help = 'the reason for the bonus (shown to workers in an email sent by MTurk)')
443 sub.add_argument('WIDAID', nargs = '+',
444 help = 'a WORKER_ID,ASSIGNMENT_ID pair')
445 sub.set_defaults(f = grant_bonus, a = lambda:
446 [args.MESSAGE, args.AMOUNT,
447 [p.split(',') for p in args.WIDAID]])
448
449 sub = subs.add_parser('notify',
450 help = 'send a message to some workers')
451 sub.add_argument('SUBJECT',
452 help = 'subject of the message')
453 sub.add_argument('MESSAGE',
454 help = 'text of the message')
455 sub.add_argument('WORKER', nargs = '+',
456 help = 'ID of a worker')
457 sub.set_defaults(f = notify_workers, a = lambda:
458 [args.SUBJECT, args.MESSAGE, args.WORKER])
459
460 sub = subs.add_parser('give-qual',
461 help = 'give a qualification to some workers')
462 sub.add_argument('QUAL',
463 help = 'ID of the qualification')
464 sub.add_argument('WORKER', nargs = '+',
465 help = 'ID of a worker')
466 sub.add_argument('-v', '--value', dest = 'value',
467 metavar = 'N', type = int, default = 1,
468 help = 'value of the qualification')
469 sub.add_argument('--dontnotify', dest = 'notify',
470 action = 'store_false', default = True,
471 help = "don't notify workers")
472 sub.set_defaults(f = give_qualification, a = lambda:
473 [args.QUAL, args.WORKER, args.value, args.notify])
474
475 sub = subs.add_parser('revoke-qual',
476 help = 'revoke a qualification from some workers')
477 sub.add_argument('QUAL',
478 help = 'ID of the qualification')
479 sub.add_argument('WORKER', nargs = '+',
480 help = 'ID of a worker')
481 sub.add_argument('-m', '--message', dest = 'message',
482 metavar = 'TEXT',
483 help = 'the reason the qualification was revoked (shown to workers in an email sent by MTurk)')
484 sub.set_defaults(f = revoke_qualification, a = lambda:
485 [args.QUAL, args.WORKER, args.message])
486
487 args = parser.parse_args()
488
489 init_by_args(args)
490
491 f = args.f
492 a = args.a()
493 if isinstance(a, dict):
494 # We do some introspective gymnastics so we can produce a
495 # less incomprehensible error message if some arguments
496 # are missing.
497 spec = inspect.getargspec(f)
498 missing = set(spec.args[: len(spec.args) - len(spec.defaults)]) - set(a.keys())
499 if missing:
500 raise ValueError('Missing arguments: ' + ', '.join(missing))
501 doit = lambda: f(**a)
502 else:
503 doit = lambda: f(*a)
504
505 try:
506 x = doit()
507 except boto.mturk.connection.MTurkRequestError as e:
508 print 'MTurk error:', e.error_message
509 sys.exit(1)
510
511 if x is not None:
512 print x
513
514 save_nicknames()