PageRenderTime 89ms CodeModel.GetById 47ms app.highlight 35ms RepoModel.GetById 1ms app.codeStats 0ms

/boto-2.5.2/boto/mturk/connection.py

#
Python | 920 lines | 765 code | 50 blank | 105 comment | 35 complexity | e2099f4b275b2b556a8adaf6e3bbf63d MD5 | raw file
  1# Copyright (c) 2006,2007 Mitch Garnaat http://garnaat.org/
  2#
  3# Permission is hereby granted, free of charge, to any person obtaining a
  4# copy of this software and associated documentation files (the
  5# "Software"), to deal in the Software without restriction, including
  6# without limitation the rights to use, copy, modify, merge, publish, dis-
  7# tribute, sublicense, and/or sell copies of the Software, and to permit
  8# persons to whom the Software is furnished to do so, subject to the fol-
  9# lowing conditions:
 10#
 11# The above copyright notice and this permission notice shall be included
 12# in all copies or substantial portions of the Software.
 13#
 14# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
 15# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL-
 16# ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
 17# SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 
 18# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 19# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
 20# IN THE SOFTWARE.
 21
 22import xml.sax
 23import datetime
 24import itertools
 25
 26from boto import handler
 27from boto import config
 28from boto.mturk.price import Price
 29import boto.mturk.notification
 30from boto.connection import AWSQueryConnection
 31from boto.exception import EC2ResponseError
 32from boto.resultset import ResultSet
 33from boto.mturk.question import QuestionForm, ExternalQuestion
 34
 35class MTurkRequestError(EC2ResponseError):
 36    "Error for MTurk Requests"
 37    # todo: subclass from an abstract parent of EC2ResponseError
 38
 39class MTurkConnection(AWSQueryConnection):
 40    
 41    APIVersion = '2008-08-02'
 42    
 43    def __init__(self, aws_access_key_id=None, aws_secret_access_key=None,
 44                 is_secure=True, port=None, proxy=None, proxy_port=None,
 45                 proxy_user=None, proxy_pass=None,
 46                 host=None, debug=0,
 47                 https_connection_factory=None):
 48        if not host:
 49            if config.has_option('MTurk', 'sandbox') and config.get('MTurk', 'sandbox') == 'True':
 50                host = 'mechanicalturk.sandbox.amazonaws.com'
 51            else:
 52                host = 'mechanicalturk.amazonaws.com'
 53
 54        AWSQueryConnection.__init__(self, aws_access_key_id,
 55                                    aws_secret_access_key,
 56                                    is_secure, port, proxy, proxy_port,
 57                                    proxy_user, proxy_pass, host, debug,
 58                                    https_connection_factory)
 59    
 60    def _required_auth_capability(self):
 61        return ['mturk']
 62
 63    def get_account_balance(self):
 64        """
 65        """
 66        params = {}
 67        return self._process_request('GetAccountBalance', params,
 68                                     [('AvailableBalance', Price),
 69                                      ('OnHoldBalance', Price)])
 70    
 71    def register_hit_type(self, title, description, reward, duration,
 72                          keywords=None, approval_delay=None, qual_req=None):
 73        """
 74        Register a new HIT Type
 75        title, description are strings
 76        reward is a Price object
 77        duration can be a timedelta, or an object castable to an int
 78        """
 79        params = dict(
 80            Title=title,
 81            Description=description,
 82            AssignmentDurationInSeconds=
 83                self.duration_as_seconds(duration),
 84            )
 85        params.update(MTurkConnection.get_price_as_price(reward).get_as_params('Reward'))
 86
 87        if keywords:
 88            params['Keywords'] = self.get_keywords_as_string(keywords)
 89
 90        if approval_delay is not None:
 91            d = self.duration_as_seconds(approval_delay)
 92            params['AutoApprovalDelayInSeconds'] = d
 93
 94        if qual_req is not None:
 95            params.update(qual_req.get_as_params())
 96
 97        return self._process_request('RegisterHITType', params, [('HITTypeId', HITTypeId)])
 98
 99
100    def set_email_notification(self, hit_type, email, event_types=None):
101        """
102        Performs a SetHITTypeNotification operation to set email
103        notification for a specified HIT type
104        """
105        return self._set_notification(hit_type, 'Email', email, event_types)
106    
107    def set_rest_notification(self, hit_type, url, event_types=None):
108        """
109        Performs a SetHITTypeNotification operation to set REST notification
110        for a specified HIT type
111        """
112        return self._set_notification(hit_type, 'REST', url, event_types)
113        
114    def _set_notification(self, hit_type, transport, destination, event_types=None):
115        """
116        Common SetHITTypeNotification operation to set notification for a
117        specified HIT type
118        """
119        assert isinstance(hit_type, str), "hit_type argument should be a string."
120        
121        params = {'HITTypeId': hit_type}
122        
123        # from the Developer Guide:
124        # The 'Active' parameter is optional. If omitted, the active status of
125        # the HIT type's notification specification is unchanged. All HIT types
126        # begin with their notification specifications in the "inactive" status.
127        notification_params = {'Destination': destination,
128                               'Transport': transport,
129                               'Version': boto.mturk.notification.NotificationMessage.NOTIFICATION_VERSION,
130                               'Active': True,
131                               }
132
133        # add specific event types if required
134        if event_types:
135            self.build_list_params(notification_params, event_types, 'EventType')
136        
137        # Set up dict of 'Notification.1.Transport' etc. values
138        notification_rest_params = {}
139        num = 1
140        for key in notification_params:
141            notification_rest_params['Notification.%d.%s' % (num, key)] = notification_params[key]
142        
143        # Update main params dict
144        params.update(notification_rest_params)
145        
146        # Execute operation
147        return self._process_request('SetHITTypeNotification', params)
148    
149    def create_hit(self, hit_type=None, question=None,
150                   lifetime=datetime.timedelta(days=7),
151                   max_assignments=1, 
152                   title=None, description=None, keywords=None,
153                   reward=None, duration=datetime.timedelta(days=7),
154                   approval_delay=None, annotation=None,
155                   questions=None, qualifications=None,
156                   response_groups=None):
157        """
158        Creates a new HIT.
159        Returns a ResultSet
160        See: http://docs.amazonwebservices.com/AWSMechanicalTurkRequester/2006-10-31/ApiReference_CreateHITOperation.html
161        """
162        
163        # handle single or multiple questions
164        neither = question is None and questions is None
165        both = question is not None and questions is not None
166        if neither or both:
167            raise ValueError("Must specify either question (single Question instance) or questions (list or QuestionForm instance), but not both")
168
169        if question:
170            questions = [question]
171        question_param = QuestionForm(questions)
172        if isinstance(question, QuestionForm):
173            question_param = question
174        elif isinstance(question, ExternalQuestion):
175            question_param = question
176        
177        # Handle basic required arguments and set up params dict
178        params = {'Question': question_param.get_as_xml(),
179                  'LifetimeInSeconds':
180                      self.duration_as_seconds(lifetime),
181                  'MaxAssignments': max_assignments,
182                  }
183
184        # if hit type specified then add it
185        # else add the additional required parameters
186        if hit_type:
187            params['HITTypeId'] = hit_type
188        else:
189            # Handle keywords
190            final_keywords = MTurkConnection.get_keywords_as_string(keywords)
191            
192            # Handle price argument
193            final_price = MTurkConnection.get_price_as_price(reward)
194            
195            final_duration = self.duration_as_seconds(duration)
196
197            additional_params = dict(
198                Title=title,
199                Description=description,
200                Keywords=final_keywords,
201                AssignmentDurationInSeconds=final_duration,
202                )
203            additional_params.update(final_price.get_as_params('Reward'))
204
205            if approval_delay is not None:
206                d = self.duration_as_seconds(approval_delay)
207                additional_params['AutoApprovalDelayInSeconds'] = d
208
209            # add these params to the others
210            params.update(additional_params)
211
212        # add the annotation if specified
213        if annotation is not None:
214            params['RequesterAnnotation'] = annotation
215               
216        # Add the Qualifications if specified
217        if qualifications is not None:
218            params.update(qualifications.get_as_params())
219
220        # Handle optional response groups argument
221        if response_groups:
222            self.build_list_params(params, response_groups, 'ResponseGroup')
223                
224        # Submit
225        return self._process_request('CreateHIT', params, [('HIT', HIT),])
226
227    def change_hit_type_of_hit(self, hit_id, hit_type):
228        """
229        Change the HIT type of an existing HIT. Note that the reward associated
230        with the new HIT type must match the reward of the current HIT type in
231        order for the operation to be valid.
232        
233        :type hit_id: str
234        :type hit_type: str
235        """
236        params = {'HITId' : hit_id,
237                  'HITTypeId': hit_type}
238
239        return self._process_request('ChangeHITTypeOfHIT', params)
240    
241    def get_reviewable_hits(self, hit_type=None, status='Reviewable',
242                            sort_by='Expiration', sort_direction='Ascending', 
243                            page_size=10, page_number=1):
244        """
245        Retrieve the HITs that have a status of Reviewable, or HITs that
246        have a status of Reviewing, and that belong to the Requester
247        calling the operation.
248        """
249        params = {'Status' : status,
250                  'SortProperty' : sort_by,
251                  'SortDirection' : sort_direction,
252                  'PageSize' : page_size,
253                  'PageNumber' : page_number}
254
255        # Handle optional hit_type argument
256        if hit_type is not None:
257            params.update({'HITTypeId': hit_type})
258
259        return self._process_request('GetReviewableHITs', params, [('HIT', HIT),])
260
261    @staticmethod
262    def _get_pages(page_size, total_records):
263        """
264        Given a page size (records per page) and a total number of
265        records, return the page numbers to be retrieved.
266        """
267        pages = total_records/page_size+bool(total_records%page_size)
268        return range(1, pages+1)
269
270
271    def get_all_hits(self):
272        """
273        Return all of a Requester's HITs
274        
275        Despite what search_hits says, it does not return all hits, but
276        instead returns a page of hits. This method will pull the hits
277        from the server 100 at a time, but will yield the results
278        iteratively, so subsequent requests are made on demand.
279        """
280        page_size = 100
281        search_rs = self.search_hits(page_size=page_size)
282        total_records = int(search_rs.TotalNumResults)
283        get_page_hits = lambda page: self.search_hits(page_size=page_size, page_number=page)
284        page_nums = self._get_pages(page_size, total_records)
285        hit_sets = itertools.imap(get_page_hits, page_nums)
286        return itertools.chain.from_iterable(hit_sets)
287
288    def search_hits(self, sort_by='CreationTime', sort_direction='Ascending', 
289                    page_size=10, page_number=1, response_groups=None):
290        """
291        Return a page of a Requester's HITs, on behalf of the Requester.
292        The operation returns HITs of any status, except for HITs that
293        have been disposed with the DisposeHIT operation.
294        Note:
295        The SearchHITs operation does not accept any search parameters
296        that filter the results.
297        """
298        params = {'SortProperty' : sort_by,
299                  'SortDirection' : sort_direction,
300                  'PageSize' : page_size,
301                  'PageNumber' : page_number}
302        # Handle optional response groups argument
303        if response_groups:
304            self.build_list_params(params, response_groups, 'ResponseGroup')
305                
306
307        return self._process_request('SearchHITs', params, [('HIT', HIT),])
308
309    def get_assignments(self, hit_id, status=None,
310                            sort_by='SubmitTime', sort_direction='Ascending', 
311                            page_size=10, page_number=1, response_groups=None):
312        """
313        Retrieves completed assignments for a HIT. 
314        Use this operation to retrieve the results for a HIT.
315
316        The returned ResultSet will have the following attributes:
317
318        NumResults
319                The number of assignments on the page in the filtered results
320                list, equivalent to the number of assignments being returned
321                by this call.
322                A non-negative integer
323        PageNumber
324                The number of the page in the filtered results list being
325                returned.
326                A positive integer
327        TotalNumResults
328                The total number of HITs in the filtered results list based
329                on this call.
330                A non-negative integer
331
332        The ResultSet will contain zero or more Assignment objects 
333
334        """
335        params = {'HITId' : hit_id,
336                  'SortProperty' : sort_by,
337                  'SortDirection' : sort_direction,
338                  'PageSize' : page_size,
339                  'PageNumber' : page_number}
340
341        if status is not None:
342            params['AssignmentStatus'] = status
343
344        # Handle optional response groups argument
345        if response_groups:
346            self.build_list_params(params, response_groups, 'ResponseGroup')
347                
348        return self._process_request('GetAssignmentsForHIT', params,
349                                     [('Assignment', Assignment),])
350
351    def approve_assignment(self, assignment_id, feedback=None):
352        """
353        """
354        params = {'AssignmentId': assignment_id,}
355        if feedback:
356            params['RequesterFeedback'] = feedback
357        return self._process_request('ApproveAssignment', params)
358
359    def reject_assignment(self, assignment_id, feedback=None):
360        """
361        """
362        params = {'AssignmentId': assignment_id,}
363        if feedback:
364            params['RequesterFeedback'] = feedback
365        return self._process_request('RejectAssignment', params)
366
367    def get_hit(self, hit_id, response_groups=None):
368        """
369        """
370        params = {'HITId': hit_id,}
371        # Handle optional response groups argument
372        if response_groups:
373            self.build_list_params(params, response_groups, 'ResponseGroup')
374                
375        return self._process_request('GetHIT', params, [('HIT', HIT),])
376
377    def set_reviewing(self, hit_id, revert=None):
378        """
379        Update a HIT with a status of Reviewable to have a status of Reviewing, 
380        or reverts a Reviewing HIT back to the Reviewable status.
381
382        Only HITs with a status of Reviewable can be updated with a status of
383        Reviewing.  Similarly, only Reviewing HITs can be reverted back to a
384        status of Reviewable.
385        """
386        params = {'HITId': hit_id,}
387        if revert:
388            params['Revert'] = revert
389        return self._process_request('SetHITAsReviewing', params)
390
391    def disable_hit(self, hit_id, response_groups=None):
392        """
393        Remove a HIT from the Mechanical Turk marketplace, approves all
394        submitted assignments that have not already been approved or rejected,
395        and disposes of the HIT and all assignment data.
396
397        Assignments for the HIT that have already been submitted, but not yet
398        approved or rejected, will be automatically approved. Assignments in
399        progress at the time of the call to DisableHIT will be approved once
400        the assignments are submitted. You will be charged for approval of
401        these assignments.  DisableHIT completely disposes of the HIT and
402        all submitted assignment data. Assignment results data cannot be
403        retrieved for a HIT that has been disposed.
404
405        It is not possible to re-enable a HIT once it has been disabled.
406        To make the work from a disabled HIT available again, create a new HIT.
407        """
408        params = {'HITId': hit_id,}
409        # Handle optional response groups argument
410        if response_groups:
411            self.build_list_params(params, response_groups, 'ResponseGroup')
412                
413        return self._process_request('DisableHIT', params)
414
415    def dispose_hit(self, hit_id):
416        """
417        Dispose of a HIT that is no longer needed.
418
419        Only HITs in the "reviewable" state, with all submitted
420        assignments approved or rejected, can be disposed. A Requester
421        can call GetReviewableHITs to determine which HITs are
422        reviewable, then call GetAssignmentsForHIT to retrieve the
423        assignments.  Disposing of a HIT removes the HIT from the
424        results of a call to GetReviewableHITs.  """
425        params = {'HITId': hit_id,}
426        return self._process_request('DisposeHIT', params)
427
428    def expire_hit(self, hit_id):
429
430        """
431        Expire a HIT that is no longer needed.
432
433        The effect is identical to the HIT expiring on its own. The
434        HIT no longer appears on the Mechanical Turk web site, and no
435        new Workers are allowed to accept the HIT. Workers who have
436        accepted the HIT prior to expiration are allowed to complete
437        it or return it, or allow the assignment duration to elapse
438        (abandon the HIT). Once all remaining assignments have been
439        submitted, the expired HIT becomes"reviewable", and will be
440        returned by a call to GetReviewableHITs.
441        """
442        params = {'HITId': hit_id,}
443        return self._process_request('ForceExpireHIT', params)
444
445    def extend_hit(self, hit_id, assignments_increment=None, expiration_increment=None):
446        """
447        Increase the maximum number of assignments, or extend the
448        expiration date, of an existing HIT.
449        
450        NOTE: If a HIT has a status of Reviewable and the HIT is
451        extended to make it Available, the HIT will not be returned by
452        GetReviewableHITs, and its submitted assignments will not be
453        returned by GetAssignmentsForHIT, until the HIT is Reviewable
454        again.  Assignment auto-approval will still happen on its
455        original schedule, even if the HIT has been extended. Be sure
456        to retrieve and approve (or reject) submitted assignments
457        before extending the HIT, if so desired.
458        """
459        # must provide assignment *or* expiration increment
460        if (assignments_increment is None and expiration_increment is None) or \
461           (assignments_increment is not None and expiration_increment is not None):
462            raise ValueError("Must specify either assignments_increment or expiration_increment, but not both")
463
464        params = {'HITId': hit_id,}
465        if assignments_increment:
466            params['MaxAssignmentsIncrement'] = assignments_increment
467        if expiration_increment:
468            params['ExpirationIncrementInSeconds'] = expiration_increment
469
470        return self._process_request('ExtendHIT', params)
471
472    def get_help(self, about, help_type='Operation'):
473        """
474        Return information about the Mechanical Turk Service
475        operations and response group NOTE - this is basically useless
476        as it just returns the URL of the documentation
477
478        help_type: either 'Operation' or 'ResponseGroup'
479        """
480        params = {'About': about, 'HelpType': help_type,}
481        return self._process_request('Help', params)
482
483    def grant_bonus(self, worker_id, assignment_id, bonus_price, reason):
484        """
485        Issues a payment of money from your account to a Worker.  To
486        be eligible for a bonus, the Worker must have submitted
487        results for one of your HITs, and have had those results
488        approved or rejected. This payment happens separately from the
489        reward you pay to the Worker when you approve the Worker's
490        assignment.  The Bonus must be passed in as an instance of the
491        Price object.
492        """
493        params = bonus_price.get_as_params('BonusAmount', 1)
494        params['WorkerId'] = worker_id
495        params['AssignmentId'] = assignment_id
496        params['Reason'] = reason
497
498        return self._process_request('GrantBonus', params)
499
500    def block_worker(self, worker_id, reason):
501        """
502        Block a worker from working on my tasks.
503        """
504        params = {'WorkerId': worker_id, 'Reason': reason}
505
506        return self._process_request('BlockWorker', params)
507
508    def unblock_worker(self, worker_id, reason):
509        """
510        Unblock a worker from working on my tasks.
511        """
512        params = {'WorkerId': worker_id, 'Reason': reason}
513
514        return self._process_request('UnblockWorker', params)
515    
516    def notify_workers(self, worker_ids, subject, message_text):
517        """
518        Send a text message to workers.
519        """
520        params = {'Subject' : subject,
521                  'MessageText': message_text}
522        self.build_list_params(params, worker_ids, 'WorkerId')
523
524        return self._process_request('NotifyWorkers', params)
525
526    def create_qualification_type(self,
527                                  name,
528                                  description,
529                                  status,
530                                  keywords=None,
531                                  retry_delay=None,
532                                  test=None,
533                                  answer_key=None,
534                                  answer_key_xml=None,
535                                  test_duration=None,
536                                  auto_granted=False,
537                                  auto_granted_value=1):
538        """
539        Create a new Qualification Type.
540
541        name: This will be visible to workers and must be unique for a
542           given requester.
543
544        description: description shown to workers.  Max 2000 characters.
545
546        status: 'Active' or 'Inactive'
547
548        keywords: list of keyword strings or comma separated string.
549           Max length of 1000 characters when concatenated with commas.
550
551        retry_delay: number of seconds after requesting a
552           qualification the worker must wait before they can ask again.
553           If not specified, workers can only request this qualification
554           once.
555
556        test: a QuestionForm
557
558        answer_key: an XML string of your answer key, for automatically
559           scored qualification tests.
560           (Consider implementing an AnswerKey class for this to support.)
561
562        test_duration: the number of seconds a worker has to complete the test.
563
564        auto_granted: if True, requests for the Qualification are granted
565           immediately.  Can't coexist with a test.
566
567        auto_granted_value: auto_granted qualifications are given this value.
568
569        """
570
571        params = {'Name': name,
572                  'Description': description,
573                  'QualificationTypeStatus': status,
574                  }
575        if retry_delay is not None:
576            params['RetryDelayInSeconds'] = retry_delay
577
578        if test is not None:
579            assert(isinstance(test, QuestionForm))
580            assert(test_duration is not None)
581            params['Test'] = test.get_as_xml()
582
583        if test_duration is not None:
584            params['TestDurationInSeconds'] = test_duration
585
586        if answer_key is not None:
587            if isinstance(answer_key, basestring):
588                params['AnswerKey'] = answer_key # xml
589            else:
590                raise TypeError
591                # Eventually someone will write an AnswerKey class.
592
593        if auto_granted:
594            assert(test is None)
595            params['AutoGranted'] = True
596            params['AutoGrantedValue'] = auto_granted_value
597
598        if keywords:
599            params['Keywords'] = self.get_keywords_as_string(keywords)
600
601        return self._process_request('CreateQualificationType', params,
602                                     [('QualificationType', QualificationType),])
603
604    def get_qualification_type(self, qualification_type_id):
605        params = {'QualificationTypeId' : qualification_type_id }
606        return self._process_request('GetQualificationType', params,
607                                     [('QualificationType', QualificationType),])
608
609    def get_qualifications_for_qualification_type(self, qualification_type_id):
610        params = {'QualificationTypeId' : qualification_type_id }
611        return self._process_request('GetQualificationsForQualificationType', params,
612                                     [('QualificationType', QualificationType),])
613
614    def update_qualification_type(self, qualification_type_id,
615                                  description=None,
616                                  status=None,
617                                  retry_delay=None,
618                                  test=None,
619                                  answer_key=None,
620                                  test_duration=None,
621                                  auto_granted=None,
622                                  auto_granted_value=None):
623
624        params = {'QualificationTypeId' : qualification_type_id }
625
626        if description is not None:
627            params['Description'] = description
628
629        if status is not None:
630            params['QualificationTypeStatus'] = status
631
632        if retry_delay is not None:
633            params['RetryDelayInSeconds'] = retry_delay
634
635        if test is not None:
636            assert(isinstance(test, QuestionForm))
637            params['Test'] = test.get_as_xml()
638
639        if test_duration is not None:
640            params['TestDurationInSeconds'] = test_duration
641
642        if answer_key is not None:
643            if isinstance(answer_key, basestring):
644                params['AnswerKey'] = answer_key # xml
645            else:
646                raise TypeError
647                # Eventually someone will write an AnswerKey class.
648
649        if auto_granted is not None:
650            params['AutoGranted'] = auto_granted
651
652        if auto_granted_value is not None:
653            params['AutoGrantedValue'] = auto_granted_value
654
655        return self._process_request('UpdateQualificationType', params,
656                                     [('QualificationType', QualificationType),])
657
658    def dispose_qualification_type(self, qualification_type_id):
659        """TODO: Document."""
660        params = {'QualificationTypeId' : qualification_type_id}
661        return self._process_request('DisposeQualificationType', params)
662
663    def search_qualification_types(self, query=None, sort_by='Name',
664                                   sort_direction='Ascending', page_size=10,
665                                   page_number=1, must_be_requestable=True,
666                                   must_be_owned_by_caller=True):
667        """TODO: Document."""
668        params = {'Query' : query,
669                  'SortProperty' : sort_by,
670                  'SortDirection' : sort_direction,
671                  'PageSize' : page_size,
672                  'PageNumber' : page_number,
673                  'MustBeRequestable' : must_be_requestable,
674                  'MustBeOwnedByCaller' : must_be_owned_by_caller}
675        return self._process_request('SearchQualificationTypes', params,
676                    [('QualificationType', QualificationType),])
677
678    def get_qualification_requests(self, qualification_type_id,
679                                   sort_by='Expiration',
680                                   sort_direction='Ascending', page_size=10,
681                                   page_number=1):
682        """TODO: Document."""
683        params = {'QualificationTypeId' : qualification_type_id,
684                  'SortProperty' : sort_by,
685                  'SortDirection' : sort_direction,
686                  'PageSize' : page_size,
687                  'PageNumber' : page_number}
688        return self._process_request('GetQualificationRequests', params,
689                    [('QualificationRequest', QualificationRequest),])
690
691    def grant_qualification(self, qualification_request_id, integer_value=1):
692        """TODO: Document."""
693        params = {'QualificationRequestId' : qualification_request_id,
694                  'IntegerValue' : integer_value}
695        return self._process_request('GrantQualification', params)
696
697    def revoke_qualification(self, subject_id, qualification_type_id,
698                             reason=None):
699        """TODO: Document."""
700        params = {'SubjectId' : subject_id,
701                  'QualificationTypeId' : qualification_type_id,
702                  'Reason' : reason}
703        return self._process_request('RevokeQualification', params)
704
705    def assign_qualification(self, qualification_type_id, worker_id,
706                             value=1, send_notification=True):
707        params = {'QualificationTypeId' : qualification_type_id,
708                  'WorkerId' : worker_id,
709                  'IntegerValue' : value,
710                  'SendNotification' : send_notification}
711        return self._process_request('AssignQualification', params)
712
713    def get_qualification_score(self, qualification_type_id, worker_id):
714        """TODO: Document."""
715        params = {'QualificationTypeId' : qualification_type_id,
716                  'SubjectId' : worker_id}
717        return self._process_request('GetQualificationScore', params,
718                    [('Qualification', Qualification),])
719
720    def update_qualification_score(self, qualification_type_id, worker_id,
721                                   value):
722        """TODO: Document."""
723        params = {'QualificationTypeId' : qualification_type_id,
724                  'SubjectId' : worker_id,
725                  'IntegerValue' : value}
726        return self._process_request('UpdateQualificationScore', params)
727
728    def _process_request(self, request_type, params, marker_elems=None):
729        """
730        Helper to process the xml response from AWS
731        """
732        response = self.make_request(request_type, params, verb='POST')
733        return self._process_response(response, marker_elems)
734
735    def _process_response(self, response, marker_elems=None):
736        """
737        Helper to process the xml response from AWS
738        """
739        body = response.read()
740        #print body
741        if '<Errors>' not in body:
742            rs = ResultSet(marker_elems)
743            h = handler.XmlHandler(rs, self)
744            xml.sax.parseString(body, h)
745            return rs
746        else:
747            raise MTurkRequestError(response.status, response.reason, body)
748
749    @staticmethod
750    def get_keywords_as_string(keywords):
751        """
752        Returns a comma+space-separated string of keywords from either
753        a list or a string
754        """
755        if isinstance(keywords, list):
756            keywords = ', '.join(keywords)
757        if isinstance(keywords, str):
758            final_keywords = keywords
759        elif isinstance(keywords, unicode):
760            final_keywords = keywords.encode('utf-8')
761        elif keywords is None:
762            final_keywords = ""
763        else:
764            raise TypeError("keywords argument must be a string or a list of strings; got a %s" % type(keywords))
765        return final_keywords
766    
767    @staticmethod
768    def get_price_as_price(reward):
769        """
770        Returns a Price data structure from either a float or a Price
771        """
772        if isinstance(reward, Price):
773            final_price = reward
774        else:
775            final_price = Price(reward)
776        return final_price
777
778    @staticmethod
779    def duration_as_seconds(duration):
780        if isinstance(duration, datetime.timedelta):
781            duration = duration.days*86400 + duration.seconds
782        try:
783            duration = int(duration)
784        except TypeError:
785            raise TypeError("Duration must be a timedelta or int-castable, got %s" % type(duration))
786        return duration
787
788class BaseAutoResultElement:
789    """
790    Base class to automatically add attributes when parsing XML
791    """
792    def __init__(self, connection):
793        pass
794
795    def startElement(self, name, attrs, connection):
796        return None
797
798    def endElement(self, name, value, connection):
799        setattr(self, name, value)
800
801class HIT(BaseAutoResultElement):
802    """
803    Class to extract a HIT structure from a response (used in ResultSet)
804    
805    Will have attributes named as per the Developer Guide, 
806    e.g. HITId, HITTypeId, CreationTime
807    """
808
809    # property helper to determine if HIT has expired
810    def _has_expired(self):
811        """ Has this HIT expired yet? """
812        expired = False
813        if hasattr(self, 'Expiration'):
814            now = datetime.datetime.utcnow()
815            expiration = datetime.datetime.strptime(self.Expiration, '%Y-%m-%dT%H:%M:%SZ')
816            expired = (now >= expiration)
817        else:
818            raise ValueError("ERROR: Request for expired property, but no Expiration in HIT!")
819        return expired
820
821    # are we there yet?
822    expired = property(_has_expired)
823
824class HITTypeId(BaseAutoResultElement):
825    """
826    Class to extract an HITTypeId structure from a response 
827    """
828
829    pass
830
831class Qualification(BaseAutoResultElement):
832    """
833    Class to extract an Qualification structure from a response (used in
834    ResultSet)
835    
836    Will have attributes named as per the Developer Guide such as
837    QualificationTypeId, IntegerValue. Does not seem to contain GrantTime.
838    """
839    
840    pass
841
842class QualificationType(BaseAutoResultElement):
843    """
844    Class to extract an QualificationType structure from a response (used in
845    ResultSet)
846    
847    Will have attributes named as per the Developer Guide, 
848    e.g. QualificationTypeId, CreationTime, Name, etc
849    """
850    
851    pass
852
853class QualificationRequest(BaseAutoResultElement):
854    """
855    Class to extract an QualificationRequest structure from a response (used in
856    ResultSet)
857    
858    Will have attributes named as per the Developer Guide, 
859    e.g. QualificationRequestId, QualificationTypeId, SubjectId, etc
860
861    TODO: Ensure that Test and Answer attribute are treated properly if the
862          qualification requires a test. These attributes are XML-encoded.
863    """
864    
865    pass
866
867class Assignment(BaseAutoResultElement):
868    """
869    Class to extract an Assignment structure from a response (used in
870    ResultSet)
871    
872    Will have attributes named as per the Developer Guide, 
873    e.g. AssignmentId, WorkerId, HITId, Answer, etc
874    """
875
876    def __init__(self, connection):
877        BaseAutoResultElement.__init__(self, connection)
878        self.answers = []
879
880    def endElement(self, name, value, connection):
881        # the answer consists of embedded XML, so it needs to be parsed independantly
882        if name == 'Answer':
883            answer_rs = ResultSet([('Answer', QuestionFormAnswer),])
884            h = handler.XmlHandler(answer_rs, connection)
885            value = connection.get_utf8_value(value)
886            xml.sax.parseString(value, h)
887            self.answers.append(answer_rs)
888        else:
889            BaseAutoResultElement.endElement(self, name, value, connection)
890
891class QuestionFormAnswer(BaseAutoResultElement):
892    """
893    Class to extract Answers from inside the embedded XML
894    QuestionFormAnswers element inside the Answer element which is
895    part of the Assignment structure
896
897    A QuestionFormAnswers element contains an Answer element for each
898    question in the HIT or Qualification test for which the Worker
899    provided an answer. Each Answer contains a QuestionIdentifier
900    element whose value corresponds to the QuestionIdentifier of a
901    Question in the QuestionForm. See the QuestionForm data structure
902    for more information about questions and answer specifications.
903
904    If the question expects a free-text answer, the Answer element
905    contains a FreeText element. This element contains the Worker's
906    answer
907
908    *NOTE* - currently really only supports free-text and selection answers
909    """
910
911    def __init__(self, connection):
912        BaseAutoResultElement.__init__(self, connection)
913        self.fields = []
914        self.qid = None
915
916    def endElement(self, name, value, connection):
917        if name == 'QuestionIdentifier':
918            self.qid = value
919        elif name in ['FreeText', 'SelectionIdentifier', 'OtherSelectionText'] and self.qid:
920            self.fields.append( value )