Home | History | Annotate | Download | only in bin
      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