1 #!/usr/bin/env python 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() 515