You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

514 lines
19 KiB

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