├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── demo ├── experience.py ├── highest_voted.py ├── narcissism.py ├── object_explorer.py ├── question.py ├── recent_questions.py ├── search.py ├── stats.py └── versus.py ├── list-of-methods.txt ├── setup.py ├── stackauth.py ├── stackexchange ├── __init__.py ├── core.py ├── models.py ├── site.py ├── sites.py └── web.py ├── testsuite.py └── tools ├── _genconsts.py ├── api.yml ├── makedoc.py ├── release.sh ├── se_inter.py └── test /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__ 3 | *.pyd 4 | *.swp 5 | *.swo 6 | *~ 7 | dist/* 8 | build/* 9 | *.egg-info 10 | api.html 11 | .env 12 | tags 13 | makefile 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011, Lucas Jones. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | Neither the name of the project nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 10 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | recursive-include demo *.py 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Py-StackExchange is a simple Python binding to the API of the StackExchange network of sites. 2 | 3 | **NOTE**: Py-StackExchange is not an official product of, and is not affiliated in any way, with Stack Overflow Internet Services, Inc. 4 | 5 | # Getting Started 6 | You can get Py-StackExchange in two ways: 7 | 8 | 1. Clone the Git repository from Github. 9 | If feasible, this is the recommended approach - you'll always get the latest code with the newest features. 10 | 11 | git clone git://github.com/lucjon/Py-StackExchange.git 12 | 13 | You'll need to set up paths, etc. on your own though. 14 | 15 | 2. Install from the Python Package Index (PyPI) 16 | It's easier to install the library with the easy_install command, but be warned: while major bug fixes will be deployed as soon as possible to PyPI, you might need to wait a while for new features. 17 | 18 | easy_install Py-StackExchange 19 | 20 | # Using the Library 21 | 22 | Fire up your Python interpreter and import the stackexchange module. Yay, you're done! 23 | 24 | import stackexchange 25 | 26 | The primary class in the bindings is the Site class; it is used for querying information from specific sites in the network. 27 | 28 | so = stackexchange.Site(stackexchange.StackOverflow) 29 | 30 | A number of site URLs come predefined; the list is rebuilt every so often. A new site's URL may not be defined, in this case, simply use a string in the form `'api.joelfanclub.stackexchange.com'`. Don't put in `http://` or anything like that. 31 | 32 | Once you have your site object, you can start to get some data dumped (using Py3/print_function syntax): 33 | 34 | u = so.user(41981) 35 | print(u.reputation.format()) 36 | print(u.answers.count, 'answers') 37 | 38 | ## API Keys 39 | If you're planning to use the API in an application or web service, especially one which will be making a large number of requests, you'll want to sign up for an API key on [StackApps](http://stackapps.com). 40 | 41 | The API is rate-limited. The default rate limit, if no key is provided, is 300 requests per day. With an API key, however, this is bumped up to 10,000! You can also then use StackApps to publicise your app. 42 | 43 | To use an API key with the library. simply pass it in like so: 44 | 45 | so = stackexchange.Site(stackexchange.StackOverflow, 'my_api_key') 46 | 47 | Be aware, though, that even with an API key, requests are limited to thirty per five seconds. By default, the library will return an error before even making an HTTP request if you'll go over this limit. Alternatively, you can configure it such that it will wait until it can make another request without returning an error. To enable this behaviour. set the `impose_throttling` property: 48 | 49 | so.impose_throttling = True 50 | so.throttle_stop = False 51 | 52 | ## Lazy Lists 53 | Py-StackExchange tries to limit the number of HTTP requests it makes, to help you squeeze the most out of your allotment. To this end, accessing any property which would require another call to fulfil requires that this be made explicit. 54 | 55 | me = so.user(41981) 56 | qs = me.questions 57 | 58 | This will not return all the required data. You should instead use: 59 | 60 | qs = me.questions.fetch() 61 | 62 | With the fetch call, the list contains the appropriate data. Note that after this, `me.questions` will contain the data; you only need to call `fetch()` once. 63 | 64 | ### Navigating Lists 65 | The API uses a page-based system for navigating large datasets. This means that a set number (usually 30 or 60) of elements are fetched initially, with more available on-demand: 66 | 67 | print(qs.pagesize) 68 | # processing 69 | # Get more 70 | qs = qs.extend_next() 71 | # We have more questions 72 | 73 | The `extend_next()` function returns a list with the extra data appended, `fetch_next()` returns just the new data. 74 | 75 | # Next Steps 76 | Read, and run, the example code. The programs are simple and cover most of the basic usage of the API. Many of the examples also have wiki pages on the Github site. 77 | 78 | If you encounter any problems at all, feel free to leave an answer on [StackApps](https://stackapps.com/questions/198/py-stackexchange-an-api-wrapper-for-python), or to e-mail me at `lucas @ lucasjones . co.uk`. Yay, vanity domains. 79 | -------------------------------------------------------------------------------- /demo/experience.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | 4 | # a hack so you can run it 'python demo/experience.py' 5 | import sys 6 | sys.path.append('.') 7 | sys.path.append('..') 8 | from stackexchange import Site, StackOverflow 9 | 10 | user_id = 41981 if len(sys.argv) < 2 else int(sys.argv[1]) 11 | print('StackOverflow user %d\'s experience:' % user_id) 12 | 13 | so = Site(StackOverflow) 14 | user = so.user(user_id) 15 | 16 | print('Most experienced on %s.' % user.top_answer_tags.fetch()[0].tag_name) 17 | print('Most curious about %s.' % user.top_question_tags.fetch()[0].tag_name) 18 | 19 | total_questions = len(user.questions.fetch()) 20 | unaccepted_questions = len(user.unaccepted_questions.fetch()) 21 | accepted = total_questions - unaccepted_questions 22 | rate = accepted / float(total_questions) * 100 23 | print('Accept rate is %.2f%%.' % rate) 24 | -------------------------------------------------------------------------------- /demo/highest_voted.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | 4 | # a hack so you can run it 'python demo/highest_voted.py' 5 | import sys 6 | sys.path.append('.') 7 | sys.path.append('..') 8 | from stackauth import StackAuth 9 | from stackexchange import Site, StackOverflow, Sort, DESC 10 | 11 | so = Site(StackOverflow) 12 | 13 | print('The highest voted question on StackOverflow is:') 14 | question = so.questions(sort=Sort.Votes, order=DESC)[0] 15 | print('\t%s\t%d' % (question.title, question.score)) 16 | print() 17 | print('Look, see:', question.url) 18 | -------------------------------------------------------------------------------- /demo/narcissism.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | 4 | # a hack so you can run it 'python demo/narcissism.py' 5 | import sys 6 | sys.path.append('.') 7 | sys.path.append('..') 8 | from stackauth import StackAuth 9 | from stackexchange import Site, StackOverflow 10 | 11 | user_id = 41981 if len(sys.argv) < 2 else int(sys.argv[1]) 12 | print('StackOverflow user %d\'s accounts:' % user_id) 13 | 14 | stack_auth = StackAuth() 15 | so = Site(StackOverflow, impose_throttling=True) 16 | accounts = stack_auth.api_associated(so, user_id) 17 | reputation = {} 18 | 19 | for account in accounts: 20 | print(' %s / %d reputation' % (account.on_site.name, account.reputation)) 21 | 22 | # This may seem a slightly backwards way of storing it, but it's easier for finding the max 23 | reputation[account.reputation] = account.on_site.name 24 | 25 | print('Most reputation on: %s' % reputation[max(reputation)]) 26 | -------------------------------------------------------------------------------- /demo/object_explorer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | 4 | # a hack so you can run it 'python demo/object_explorer.py' 5 | import sys 6 | sys.path.append('.') 7 | sys.path.append('..') 8 | from stackauth import StackAuth 9 | from stackexchange import Site, StackOverflow, StackExchangeLazySequence 10 | 11 | try: 12 | get_input = raw_input 13 | except NameError: 14 | get_input = input 15 | 16 | 17 | site = None 18 | 19 | print('Loading sites...',) 20 | sys.stdout.flush() 21 | all_sites = StackAuth().sites() 22 | chosen_site_before = False 23 | code_so_far = [] 24 | 25 | user_api_key = get_input("Please enter an API key if you have one (Return for none):") 26 | 27 | def choose_site(): 28 | global chosen_site_before 29 | 30 | print('\r \rSelect a site (0 exits):') 31 | 32 | i = 1 33 | for site in all_sites: 34 | print('%d) %s' % (i, site.name)) 35 | i += 1 36 | 37 | if i == 0: 38 | return 39 | else: 40 | site_def = all_sites[int(get_input('\nSite ID: ')) - 1] 41 | 42 | site = site_def.get_site() 43 | site.app_key = user_api_key or '1_9Gj-egW0q_k1JaweDG8Q' 44 | site.impose_throttling = True 45 | site.be_inclusive = True 46 | 47 | if not chosen_site_before: 48 | print('Use function names you would when using the Site, etc. objects.') 49 | print('return: Move back up an object.') 50 | print('exit: Quits.') 51 | print('dir: Shows meaningful methods and properties on the current object.') 52 | print('dir*: Same as dir, but includes *all* methods and properties.') 53 | print('code: Show the code you\'d need to get to where you are now.') 54 | print('! before a non-function means "explore anyway."') 55 | print('a prompt ending in []> means the current item is a list.') 56 | 57 | chosen_site_before = True 58 | return (site, site_def) 59 | 60 | def explore(ob, nm, pname=None): 61 | global code_so_far 62 | 63 | # sometimes, we have to use a different name for variables 64 | vname = nm if pname is None else pname 65 | 66 | is_dict = isinstance(ob, dict) 67 | is_list = isinstance(ob, list) or isinstance(ob, tuple) or is_dict 68 | suffix = '{}' if is_dict else '[]' if is_list else '' 69 | 70 | while True: 71 | # kind of hackish, but oh, well! 72 | inp = get_input('%s%s> ' % (nm, suffix)) 73 | punt_to_default = False 74 | 75 | if inp == 'exit': 76 | sys.exit(0) 77 | elif inp == 'return': 78 | code_so_far = code_so_far[:-1] 79 | return 80 | elif inp == 'dir': 81 | if is_list: 82 | i = 0 83 | for item in ob: 84 | print('%d) %s' % (i, str(item))) 85 | i += 1 86 | else: 87 | print(repr([x for x in dir(ob) if not x.startswith('_') and x[0].lower() == x[0]])) 88 | elif inp == 'dir*': 89 | print(repr(dir(ob))) 90 | elif inp == 'code': 91 | print('\n'.join(code_so_far)) 92 | elif is_list: 93 | try: 94 | item = ob[inp if is_dict else int(inp)] 95 | code_so_far.append('%s_item = %s[%s]' % (vname, vname, inp)) 96 | explore(item, vname + '_item') 97 | except: 98 | print('Not in list... continuing as if was an attribute.') 99 | punt_to_default = True 100 | elif hasattr(ob, inp) or (len(inp) > 0 and inp[0] == '!') or punt_to_default: 101 | should_explore = False 102 | if inp[0] == '!': 103 | inp = inp[1:] 104 | should_explore = True 105 | 106 | rval = getattr(ob, inp) 107 | extra_code = '' 108 | 109 | if hasattr(rval, 'func_code'): 110 | # it's a function! 111 | 112 | if inp != 'fetch': 113 | should_explore = True 114 | 115 | # we ask the user for each parameter in turn. we offset by one for self, after using reflection to find the parameter names. 116 | args = [] 117 | for i in range(rval.func_code.co_argcount - 1): 118 | name = rval.func_code.co_varnames[i + 1] 119 | value = get_input(name + ': ') 120 | 121 | if value == '': 122 | value = None 123 | else: 124 | value = eval(value) 125 | 126 | args.append(value) 127 | 128 | if len(args) > 0: 129 | extra_code = '(' 130 | for arg in args: 131 | extra_code += repr(arg) + ', ' 132 | extra_code = '%s)' % extra_code[:-2] 133 | else: 134 | extra_code = '()' 135 | 136 | rval = rval(*args) 137 | 138 | if isinstance(rval, StackExchangeLazySequence): 139 | print('Fetching data...',) 140 | sys.stdout.flush() 141 | rval = rval.fetch() 142 | print('\r \rFetched. You\'ll need to remember to call .fetch() in your code.') 143 | 144 | extra_code = '.fetch()' 145 | should_explore = True 146 | 147 | if isinstance(rval, list) or isinstance(rval, tuple): 148 | should_explore = True 149 | 150 | print(repr(rval)) 151 | if should_explore: 152 | # generate code 153 | code = '%s = %s.%s%s' % (inp, vname, inp, extra_code) 154 | code_so_far.append(code) 155 | 156 | explore(rval, inp) 157 | else: 158 | print('Invalid response.') 159 | 160 | code_so_far.append('import stackexchange') 161 | while True: 162 | site, site_def = choose_site() 163 | code_so_far.append('site = stackexchange.Site("' + site_def.api_endpoint[7:] + '")') 164 | explore(site, site_def.name, 'site') 165 | -------------------------------------------------------------------------------- /demo/question.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | 4 | # Same directory hack 5 | import sys 6 | sys.path.append('.') 7 | sys.path.append('..') 8 | 9 | try: 10 | get_input = raw_input 11 | except NameError: 12 | get_input = input 13 | 14 | user_api_key = get_input("Please enter an API key if you have one (Return for none):") 15 | if not user_api_key: user_api_key = None 16 | 17 | import stackexchange 18 | site = stackexchange.Site(stackexchange.StackOverflow, app_key=user_api_key) 19 | site.be_inclusive() 20 | 21 | id = int(get_input("Enter a question ID: ")) 22 | question = site.question(id) 23 | 24 | print('--- %s ---' % question.title) 25 | print(question.body) 26 | print() 27 | print('%d answers.' % len(question.answers)) 28 | -------------------------------------------------------------------------------- /demo/recent_questions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | from six.moves import input 4 | 5 | # Same directory hack 6 | import sys 7 | sys.path.append('.') 8 | sys.path.append('..') 9 | 10 | user_api_key = input("Please enter an API key if you have one (Return for none):") 11 | if not user_api_key: user_api_key = None 12 | 13 | import stackexchange 14 | so = stackexchange.Site(stackexchange.StackOverflow, app_key=user_api_key, impose_throttling=True) 15 | so.be_inclusive() 16 | 17 | sys.stdout.write('Loading...') 18 | sys.stdout.flush() 19 | 20 | questions = so.recent_questions(pagesize=10, filter='_b') 21 | print('\r # vote ans view') 22 | 23 | cur = 1 24 | for question in questions[:10]: 25 | print('%2d %3d %3d %3d \t%s' % (cur, question.score, len(question.answers), question.view_count, question.title)) 26 | cur += 1 27 | 28 | num = int(input('Question no.: ')) 29 | qu = questions[num - 1] 30 | print('--- %s' % qu.title) 31 | print('%d votes, %d answers, %d views.' % (qu.score, len(qu.answers), qu.view_count)) 32 | print('Tagged: ' + ', '.join(qu.tags)) 33 | print() 34 | print(qu.body[:250] + ('...' if len(qu.body) > 250 else '')) 35 | -------------------------------------------------------------------------------- /demo/search.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | 4 | # a hack so you can run it 'python demo/search.py' 5 | import sys 6 | sys.path.append('.') 7 | sys.path.append('..') 8 | 9 | try: 10 | get_input = raw_input 11 | except NameError: 12 | get_input = input 13 | 14 | user_api_key = get_input("Please enter an API key if you have one (Return for none):") 15 | if not user_api_key: user_api_key = None 16 | 17 | import stackexchange 18 | so = stackexchange.Site(stackexchange.StackOverflow, app_key=user_api_key, impose_throttling=True) 19 | 20 | if __name__ == '__main__': 21 | if len(sys.argv) < 2: 22 | term = get_input('Please provide a search term:') 23 | else: 24 | term = ' '.join(sys.argv[1:]) 25 | print('Searching for %s...' % term,) 26 | sys.stdout.flush() 27 | 28 | qs = so.search(intitle=term) 29 | 30 | print('\r--- questions with "%s" in title ---' % (term)) 31 | 32 | for q in qs: 33 | print('%8d %s' % (q.id, q.title)) 34 | 35 | -------------------------------------------------------------------------------- /demo/stats.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | 4 | # a hack so you can run it 'python demo/stats.py' 5 | import sys 6 | sys.path.append('.') 7 | sys.path.append('..') 8 | 9 | import stackexchange 10 | so = stackexchange.Site(stackexchange.StackOverflow, impose_throttling=True) 11 | stats = so.stats() 12 | 13 | print('Total questions:\t%d' % stats.total_questions) 14 | print('\tAnswered:\t%d' % (stats.total_questions - stats.total_unanswered)) 15 | print('\tUnanswered:\t%d' % (stats.total_unanswered)) 16 | 17 | percent = (stats.total_unanswered / float(stats.total_questions)) * 100 18 | print('%.2f%% unanswered. (%.2f%% answered!)' % (percent, 100 - percent)) 19 | -------------------------------------------------------------------------------- /demo/versus.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | 4 | import sys 5 | sys.path.append('.') 6 | sys.path.append('..') 7 | import stackexchange, stackauth 8 | 9 | if len(sys.argv) < 3: 10 | print('Usage: versus.py YOUR_SO_UID THEIR_SO_UID') 11 | sys.exit(1) 12 | 13 | so = stackexchange.Site(stackexchange.StackOverflow, impose_throttling=True) 14 | 15 | user1, user2 = (int(x) for x in sys.argv[1:]) 16 | rep1, rep2 = {}, {} 17 | username1, username2 = (so.user(x).display_name for x in (user1, user2)) 18 | total_rep1, total_rep2 = 0, 0 19 | 20 | sites = [] 21 | 22 | for site in stackauth.StackAuth().api_associated(so, user1): 23 | rep1[site.on_site.name] = site.reputation 24 | sites.append(site.on_site.name) 25 | for site in stackauth.StackAuth().api_associated(so, user2): 26 | rep2[site.on_site.name] = site.reputation 27 | 28 | for site in sites: 29 | total_rep1 += rep1[site] 30 | if site in rep2: 31 | total_rep2 += rep2[site] 32 | 33 | max_user = username1 34 | max_rep, other_rep = rep1[site], rep2.get(site, 0) 35 | if rep2.get(site, 0) > rep1[site]: 36 | max_user = username2 37 | max_rep, other_rep = other_rep, max_rep 38 | 39 | diff = max_rep - other_rep 40 | 41 | print('%s: %s wins (+%d)' % (site, max_user, diff)) 42 | 43 | print('Overall: %s wins (+%d)' % (username1 if total_rep1 >= total_rep2 else username2, max(total_rep1, total_rep2) - min(total_rep1, total_rep2))) 44 | 45 | 46 | -------------------------------------------------------------------------------- /list-of-methods.txt: -------------------------------------------------------------------------------- 1 | Statistics 2 | total_questions 3 | total_unanswered 4 | total_answers 5 | total_comments 6 | total_votes 7 | total_badges 8 | total_users 9 | questions_per_minute 10 | answers_per_minute 11 | badges_per_minute 12 | display_name 13 | 14 | Answer 15 | accepted 16 | locked_date 17 | question_id 18 | up_vote_count 19 | down_vote_count 20 | view_count 21 | score 22 | community_owned 23 | title 24 | body 25 | 26 | id = answer_id 27 | comments = {/answers//comments}: Comment 28 | ?owner_id = owner['user_id'] 29 | ?owner_info = owner.values() 30 | creation_date: datetime 31 | ?last_edit_date: datetime 32 | ?last_activity_date: datetime 33 | revisions = {/revisions/}: PostRevision 34 | ?votes = (up_vote_count, down_vote_count) 35 | url = http:///questions//# 36 | question = Question(question_id) 37 | owner = User(owner_id) 38 | 39 | Question 40 | id 41 | tags 42 | favorite_count 43 | up_vote_count 44 | down_vote_count 45 | view_count 46 | score 47 | community_owned 48 | title 49 | body 50 | 51 | id = question_id 52 | timeline = {/questions//timeline}: TimelineEvent 53 | revisions = {/revisions/}: PostRevision 54 | creation_date: datetime 55 | comments = {/questions//comments}: Comment 56 | ?answers = {answers}: Answer 57 | ?owner_id = owner['user_id'] 58 | ?owner = User.partial(owner_id) 59 | 60 | linked() = site.questions(linked_to = id) 61 | related() = site.questions(related_to = id) 62 | 63 | Comment 64 | post_id 65 | score 66 | edit_count 67 | body 68 | 69 | id 70 | creation_date: datetime 71 | ?owner_id = owner['owner_id' or 'user_id'] 72 | ?owner = User.partial(owner_id) 73 | ?reply_to_user_id = reply_to['user_id'] 74 | ?reply_to = User.partial(reply_to_user_id) 75 | post_type: PostType 76 | post = Question(post_id) or Answer(post_id) 77 | 78 | RevisionType(Enumeration): 79 | SingleUser = 'single_user' 80 | VoteBased = 'vote_based' 81 | 82 | PostRevision: 83 | body 84 | comment 85 | is_question 86 | is_rollback 87 | last_body 88 | last_title 89 | revision_guid 90 | revision_number 91 | title 92 | set_community_wiki 93 | post_id 94 | last_tags 95 | tags 96 | 97 | creation_date: datetime 98 | revision_type: datetime 99 | user = User.partial(user['user_id']) 100 | post = Question(post_id) or Answer(post_id) 101 | post_type = Question if is_question else Answer: PostType 102 | 103 | TagSynonym: 104 | from_tag 105 | to_tag 106 | applied_count 107 | 108 | creation_date: datetime 109 | last_applied_date: datetime 110 | 111 | TagWiki: 112 | tag_name 113 | 114 | body = wiki_body 115 | excerpt = wiki_excerpt 116 | body_last_edit_date: datetime 117 | excerpt_last_edit_date: datetime 118 | last_body_editor = User.partial(last_body_editor['user_id']) 119 | excerpt_editor = User.partial(last_excerpt_editor['user_id']) 120 | 121 | Period(Enumeration): 122 | AllTime = 'all_time' 123 | Month = 'month' 124 | 125 | TopUser: 126 | score 127 | post_count 128 | 129 | user = Partial(user['user_id']) 130 | 131 | Tag: 132 | name 133 | count 134 | fulfills_required 135 | 136 | !id = name 137 | synonyms = {/tags//synonyms}: TagSynonym 138 | wiki = TagWiki(/tags//wikis) 139 | 140 | top_askers() = {/tags//top-askers/} 141 | top_answerers() = {/tags//top-answerers/} 142 | 143 | BadgeType(Enumeration): 144 | Bronze = 1 145 | Silver = 2 146 | Gold = 3 147 | 148 | Badge: 149 | name 150 | description 151 | award_count 152 | tag_based 153 | 154 | id = badge_id 155 | recipients = {/badges//recipients}: User 156 | 157 | RepChange: 158 | user_id 159 | post_id 160 | post_type 161 | title 162 | positive_rep 163 | negative_rep 164 | 165 | on_date: datetime 166 | score = positive_rep - negative_rep 167 | 168 | TimelineEventType: 169 | Comment = 'comment' 170 | AskOrAnswered = 'askoranswered' 171 | Badge = 'badge' 172 | Revision = 'revision' 173 | Accepted = 'accepted' 174 | 175 | TimelineEvent: 176 | user_id 177 | post_id 178 | comment_id 179 | action 180 | description 181 | detail 182 | 183 | timeline_type: TimelineEventType 184 | ?post_type: PostType 185 | ?creation_date: datetime 186 | ?post = Post(post_id) 187 | ?comment = Comment(post_id) 188 | ?badge = Badge(description) 189 | 190 | PostType(Enumeration): 191 | Question = 'question' 192 | Answer = 'answer' 193 | 194 | UserType(Enumeration): 195 | Anonymous = 'anonymous' 196 | Registered = 'registered' 197 | Unregistered = 'unregistered' 198 | Moderator = 'moderator' 199 | 200 | TopTag: 201 | tag_name 202 | question_score 203 | question_count 204 | answer_score 205 | answer_count 206 | 207 | User: 208 | display_name 209 | email_hash 210 | age 211 | website_url 212 | location 213 | about_me 214 | view_count 215 | up_vote_count 216 | down_vote_count 217 | account_id 218 | 219 | id = user_id 220 | creation_date: datetime 221 | last_access_date: date 222 | reputation: FormattedReputation 223 | association_id = account_id 224 | 225 | questions = {/users//questions}: Question 226 | favorites = {/users//favorites}: Question 227 | no_answers_questions = {/users//questions/no-answers}: Question 228 | unanswered_questions = {/users//questions/unanswered}: Question 229 | unaccepted_questions = {/users//questions/unaccepted}: Question 230 | answers = {/users//answers}: Answer 231 | tags = {/users//tags}: Tag 232 | badges = {/users//badges}: Badge 233 | timeline = {/users//timeline}: TimelineEvent 234 | reputation = {/users//reputation}: RepChange 235 | mentioned = {/users//mentioned}: Comment 236 | comments = {/users//comments}: Comment 237 | top_answer_tags = {/users//top-answer-tags}: TopTag 238 | top_question_tags = {/users//top-question-tags}: TopTag 239 | 240 | ?vote_counts = (up_vote_count, down_vote_count) 241 | gold_badges = badge_counts['gold'] or 0 242 | silver_badges = badge_counts['silver'] or 0 243 | bronze_badges = badge_counts['bronze'] or 0 244 | badge_counts_t = (gold_badges, silver_badges, bronze_badges) 245 | badge_counts = {BadgeType.Gold: gold_badges, BadgeType.Silver: silver_badges, BadgeType.Bronze: bronze_badges} 246 | badge_total = gold_badges + silver_badges + bronze_badges 247 | ?type = user_type: UserType 248 | is_moderator = type == UserType.Moderator 249 | url = http:///users/ 250 | 251 | has_privelege(p: Privelege) => reputation >= p.reputation 252 | top_answers_in_tag(tag) => {/users//tags//top-answers}: Answer 253 | top_questions_in_tag(tag) => {/users//tags//top-questions}: Question 254 | comments_to(u: User or int) => {/users//comments/}: Comment 255 | 256 | Privelege: 257 | short_description 258 | description 259 | reputation 260 | 261 | 262 | ----- 263 | Site is not a model, so it's a bit more complicated to describe. Here's a skeleton of its public API, with some 264 | 'not-really-public' APIs marked with !. 265 | 266 | @keyword means that documentation for the corresponding API method is available at 267 | http://api.stackexchange.com/docs/keyword 268 | 269 | Site(domain, app_key = None, cache = 1800): 270 | domain 271 | app_key 272 | api_version 273 | impose_throttling 274 | throttle_stop 275 | cache_options 276 | include_body 277 | include_comments 278 | root_domain 279 | 280 | be_inclusive() 281 | !build(url, typ, collection, kw = {}) 282 | !build_from_snippet(json, typ) 283 | !vectorise(lst, or_of_type = None) 284 | 285 | user(nid, **) @users-by-ids 286 | users(ids = [], **) @users @users-by-ids 287 | users_by_name(name, **) 288 | moderators(**) @moderators 289 | moderators_elected(**) @elected-moderators 290 | answer(nid, **) @answers-by-ids 291 | answers(ids = None, **) @answers @answers-by-ids 292 | comment(nid, **) @comments-by-ids 293 | comments(ids = None, posts = None, **) @comments @comments-by-ids 294 | question(nid, **) @questions-by-ids 295 | questions(ids = None, user_id = None, **) @questions @questions-by-ids 296 | questions.linked_to(qns, **) @linked-questions 297 | questions.related_to(qns, **) @related-questions 298 | questions.by_user(user, **) @questions-on-users 299 | questions.unanswered(by = None, **) @unanswered-questions 300 | questions.no_answers(by = None, **) @no-answer-questions 301 | questions.unaccepted(by, **) @unaccepted-questions-on-users 302 | questions.favorited_by(by, **) @favorites-on-users 303 | recent_questions(**) @questions 304 | users_with_badge(bid, **) @badge-recipients-by-ids 305 | all_badges(**) @badges 306 | badges(ids = None, **) @badges-by-ids 307 | badge(nid, name = None, **) @badges-by-ids @badges-by-name 308 | privileges(**) @privileges 309 | all_nontag_badges(**) @badges-by-name 310 | all_tag_badges(**) @badges-by-tag 311 | all_tags(**) @tags 312 | stats(**) 313 | revision(post, guid, **) @revisions-by-guids 314 | revisions(post, **) @revisions-by-ids 315 | search(**) @search 316 | similar(title, tagged = None, nontagged = None, **) @similar 317 | tags(**) @tags 318 | tag(tag, **) @tags-by-name 319 | tag_synonyms(**) @tag-synonyms 320 | 321 | StackAuth 322 | sites() = {/sites}: SiteDefinition 323 | # The api_* equivalents only return values for sites which can be accessed by the API, 324 | # i.e. not Area 51 325 | associated_from_assoc(assoc_id) = {/users//associated}: UserAssociation 326 | api_associated_from_assoc(assoc_id) 327 | associated(site: Site, user_id) = associated_from_assoc(association ID of user on site) 328 | api_associated(site: Site, user_id) 329 | 330 | SiteDefinition 331 | name 332 | logo_url 333 | api_endpoint 334 | site_url 335 | description 336 | icon_url 337 | aliases 338 | 339 | get_site(): Site 340 | 341 | UserAssociation 342 | display_name 343 | reputation 344 | email_hash 345 | 346 | id = user_id 347 | user_type: UserType 348 | on_site: Area51 or SiteDefinition 349 | has_endpoint = isinstance(on_site, SiteDefinition) 350 | 351 | get_user(): User 352 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Setuptools is required for the use_2to3 option below. You should install it 4 | # from the Distribute home page, http://packages.python.org/distribute/ 5 | import sys 6 | from setuptools import setup 7 | 8 | options = {} 9 | #if sys.version_info > (3, 0): 10 | # Automatically run 2to3 when installing under Python 3 11 | #options['use_2to3'] = True 12 | 13 | 14 | setup( 15 | name = 'py-stackexchange', 16 | py_modules = ['stackexchange.core', 'stackexchange.sites', 'stackexchange.web', 'stackexchange.models', 'stackexchange.site', 'stackauth'], 17 | version = '2.2.007', 18 | description = 'A Python binding to the StackExchange (Stack Overflow, Server Fault, etc.) website APIs.', 19 | author = 'Lucas Jones', 20 | author_email = 'lucas@lucasjones.co.uk', 21 | url = 'http://stackapps.com/questions/198/py-stackexchange-an-api-wrapper-for-python', 22 | download_url = 'https://github.com/lucjon/Py-StackExchange/tarball/master', 23 | keywords = ['stackexchange', 'se', 'stackoverflow'], 24 | install_requires = ['six >= 1.8.0'], 25 | classifiers = [ 26 | 'Programming Language :: Python', 27 | 'Programming Language :: Python :: 2.6', 28 | 'Programming Language :: Python :: 2.7', 29 | 'Programming Language :: Python :: 3', 30 | 'Development Status :: 4 - Beta', 31 | 'Environment :: Other Environment', 32 | 'Intended Audience :: Developers', 33 | 'License :: OSI Approved :: BSD License', 34 | 'Operating System :: OS Independent', 35 | 'Topic :: Software Development :: Libraries :: Python Modules', 36 | 'Topic :: Internet', 37 | ], 38 | long_description = '''**IMPORTANT**: Py-StackExchange now targets version 2.x of the StackExchange API, which introduces some small but breaking changes --- in particular, you will need to register for a new API key. Read the wiki page https://github.com/lucjon/Py-StackExchange/wiki/Updating-to-v2.x for more information. 39 | 40 | Please see http://stackapps.com/questions/198/py-stackexchange-an-api-wrapper-for-python for a full description.''', 41 | **options 42 | ) 43 | -------------------------------------------------------------------------------- /stackauth.py: -------------------------------------------------------------------------------- 1 | # stackauth.py - Implements basic StackAuth support for Py-StackExchange 2 | 3 | from stackexchange.web import WebRequestManager 4 | from stackexchange.core import * 5 | from stackexchange import Site, User, UserType, SiteState, SiteType, MarkdownExtensions, SiteDefinition 6 | import datetime, re 7 | 8 | class Area51(object): 9 | def __getattr__(self, attr): 10 | raise Exception("You have encountered, through StackAuth association, Area51. Area51 is not accessible through the API.") 11 | 12 | class UserAssociationSiteListing(JSONModel): 13 | transfer = () 14 | 15 | def _extend(self, json, stackauth): 16 | self.name = json.site_name 17 | self.api_endpoint = json.site_url 18 | self.site_url = json.site_url 19 | 20 | class UserAssociation(JSONModel): 21 | transfer = ('display_name', 'reputation', 'email_hash') 22 | has_endpoint = True 23 | 24 | def _extend(self, json, stackauth): 25 | self.id = json.user_id 26 | self.user_type = UserType.from_string(json.user_type) 27 | 28 | if not hasattr(json, 'site_url'): 29 | # assume it's Area 51 if we can't get a site out of it 30 | self.on_site = Area51() 31 | self.has_endpoint = False 32 | else: 33 | self.on_site = UserAssociationSiteListing(self.json, stackauth) 34 | 35 | def get_user(self): 36 | return self.on_site.get_site().user(self.id) 37 | 38 | class StackAuth(object): 39 | def __init__(self, domain='api.stackexchange.com'): 40 | # 2010-07-03: There's no reason to change this now, but you never know. 41 | # 2013-11-11: Proven right, in a way, by v2.x... 42 | self.domain = domain 43 | self.api_version = '2.1' 44 | 45 | # These methods are slightly more complex than they 46 | # could be so they retain rough compatibility with 47 | # their StackExchange counterparts for paginated sets 48 | 49 | def url(self, u): 50 | # We need to stick an API version in now for v2.x 51 | return self.domain + '/' + self.api_version + '/' + u 52 | 53 | def build(self, url, typ, collection, kw = {}): 54 | mgr = WebRequestManager() 55 | json, info = mgr.json_request(url, kw) 56 | 57 | return JSONMangler.json_to_resultset(self, json, typ, collection, (self, url, typ, collection, kw)) 58 | 59 | def sites(self): 60 | """Returns information about all the StackExchange sites currently listed.""" 61 | # For optimisation purposes, it is explicitly expected in the documentation to have higher 62 | # values for the page size for this method. 63 | return self.build(self.url('sites'), SiteDefinition, 'api_sites', {'pagesize': 120}) 64 | 65 | def api_associated_from_assoc(self, assoc_id): 66 | return self.associated_from_assoc(assoc_id, only_valid=True) 67 | 68 | def associated_from_assoc(self, assoc_id, only_valid = False): 69 | """Returns, given a user's *association ID*, all their accounts on other StackExchange sites.""" 70 | # In API v2.x, the user_type attribute is not included by default, so we 71 | # need a filter. 72 | accounts = self.build(self.url('users/%s/associated' % assoc_id), UserAssociation, 'associated_users', {'filter': '0lWhwQSz'}) 73 | if only_valid: 74 | return tuple([acc for acc in accounts if acc.has_endpoint]) 75 | else: 76 | return accounts 77 | 78 | def associated(self, site, user_id, **kw): 79 | """Returns, given a target site object and a user ID for that site, their associated accounts on other StackExchange sites.""" 80 | user = site.user(user_id) 81 | if hasattr(user, 'account_id'): 82 | assoc = user.account_id 83 | return self.associated_from_assoc(assoc, **kw) 84 | else: 85 | return [] 86 | 87 | def api_associated(self, site, uid): 88 | return self.associated(site, uid, only_valid=True) 89 | 90 | -------------------------------------------------------------------------------- /stackexchange/__init__.py: -------------------------------------------------------------------------------- 1 | from stackexchange.models import * 2 | from stackexchange.site import * 3 | from stackexchange.sites import * 4 | -------------------------------------------------------------------------------- /stackexchange/core.py: -------------------------------------------------------------------------------- 1 | # stackcore.py - JSONModel/Enumeration + other utility classes that don't really belong now that the API's multi-file 2 | # This file is relatively safe to "import *" 3 | 4 | import datetime 5 | from math import floor 6 | from six.moves import urllib 7 | 8 | ## JSONModel base class 9 | class JSONModel(object): 10 | """The base class of all the objects which describe API objects directly - ie, those which take JSON objects as parameters to their constructor.""" 11 | 12 | def __init__(self, json, site, skip_ext = False): 13 | self.json = json 14 | self.json_ob = DictObject(json) 15 | self.site = site 16 | 17 | # we have four ways of specifying a field: 18 | # - ('a', 'b') in alias: .a = JSON.b 19 | # - 'name' in transfer: .name = JSON.name 20 | # - ('a', 'b', t) in alias: .a = t(JSON.b) 21 | # - ('name', t) in transfer: .name = t(JSON.name) 22 | # we unify these all into one set of descriptions 23 | alias = self.alias if hasattr(self, 'alias') else () 24 | transfer = self.transfer if hasattr(self, 'transfer') else () 25 | fields = ([A + (None,) for A in alias if len(A) == 2] + 26 | [(k, k, None) for k in transfer if isinstance(k, str)] + 27 | [A for A in alias if len(A) == 3] + 28 | [(k[0],) + k for k in transfer if not isinstance(k, str)]) 29 | 30 | for dest, key, transform in fields: 31 | if hasattr(self.json_ob, key): 32 | value = getattr(self.json_ob, key) 33 | 34 | if isinstance(transform, ComplexTransform): 35 | value = transform(key, value, self) 36 | elif transform is not None: 37 | value = transform(value) 38 | 39 | setattr(self, dest, value) 40 | elif isinstance(transform, ComplexTransform): 41 | value = transform.no_value(key, self) 42 | if value is not None: 43 | setattr(self, dest, value) 44 | 45 | if hasattr(self, '_extend') and not skip_ext: 46 | self._extend(self.json_ob, site) 47 | 48 | def fetch(self): 49 | """Fetches all the data that the model can describe, not just the attributes which were specified in the original response.""" 50 | if hasattr(self, 'fetch_callback'): 51 | res = self.fetch_callback(self) 52 | 53 | if isinstance(res, dict): 54 | self.__init__(res, self.site) 55 | elif hasattr(res, 'json'): 56 | self.__init__(res.json, self.site) 57 | else: 58 | raise ValueError('Supplied fetch callback did not return a usable value.') 59 | else: 60 | return False 61 | 62 | # Allows the easy creation of updateable, partial classes 63 | @classmethod 64 | def partial(cls, fetch_callback, site, populate): 65 | """Creates a partial description of the API object, with the proviso that the full set of data can be fetched later.""" 66 | 67 | model = cls({}, site, True) 68 | 69 | for k, v in populate.items(): 70 | setattr(model, k, v) 71 | 72 | model.fetch_callback = fetch_callback 73 | return model 74 | 75 | # for use with Lazy classes that need a callback to actually set the model property 76 | def _up(self, a): 77 | """Returns a function which can be used with the LazySequence class to actually update the results properties on the model with the 78 | new fetched data.""" 79 | 80 | def inner(m): 81 | setattr(self, a, m) 82 | return inner 83 | 84 | def LaterClassIn(name, module): 85 | def constructor(*a, **kw): 86 | cls = getattr(module, name) 87 | return cls(*a, **kw) 88 | constructor.__name__ = name 89 | return constructor 90 | 91 | # a convenience 'type constructor' for producing datetime's from UNIX timestamps 92 | UNIXTimestamp = lambda n: datetime.datetime.fromtimestamp(n) 93 | 94 | # Some transforms are more complicated and need access to the model object 95 | # itself, including Sites, etc. This is a base class to indicate this to the 96 | # constructor. 97 | class ComplexTransform(object): 98 | def no_value(self, key, model): 99 | pass 100 | 101 | class ListOf(ComplexTransform): 102 | def __init__(self, transform): 103 | self.transform = transform 104 | 105 | def no_value(self, key, model): 106 | return [] 107 | 108 | def __call__(self, key, value, model): 109 | if isinstance(self.transform, ComplexTransform): 110 | return [self.transform(key, v, model) for v in value] 111 | else: 112 | return [self.transform(v) for v in value] 113 | 114 | class ModelRef(ComplexTransform): 115 | '''A convenience for foreign model references that take a JSON value and a reference to the underlying site object.''' 116 | def __init__(self, model_type): 117 | self.model_type = model_type 118 | 119 | def __call__(self, key, value, model): 120 | return self.model_type(value, model.site) 121 | 122 | class PartialModelRef(ComplexTransform): 123 | def __init__(self, model_type, callback, extend = False): 124 | self.model_type = model_type 125 | self.callback = callback 126 | self.extend = extend 127 | 128 | def __call__(self, key, value, model): 129 | if self.extend: 130 | value = self.model_type(value, model.site, True) 131 | value.fetch_callback = self.callback 132 | return value 133 | else: 134 | return self.model_type.partial(self.callback, model.site, value) 135 | 136 | class LazySequenceField(ComplexTransform): 137 | def __init__(self, m_type, url_format, count = None, update_key = None, response_key = None, **kw): 138 | self.m_type = m_type 139 | self.url_format = url_format 140 | self.count = count 141 | self.update_key = update_key 142 | self.response_key = response_key 143 | self.kw = kw 144 | 145 | def no_value(self, key, model): 146 | model_id = getattr(model, 'id', getattr(model.json_ob, key + '_id', None)) 147 | url = self.url_format.format(id = model_id) 148 | response_key = key if self.response_key is None else self.response_key 149 | update_key = key if self.update_key is None else self.update_key 150 | return StackExchangeLazySequence(self.m_type, self.count, model.site, 151 | url, model._up(update_key), 152 | response_key, **self.kw) 153 | 154 | def __call__(self, key, value, model): 155 | return self.no_value(key, model) 156 | 157 | class Enumeration(object): 158 | """Provides a base class for enumeration classes. (Similar to 'enum' types in other languages.)""" 159 | 160 | @classmethod 161 | def from_string(cls, text, typ = None): 162 | 'Returns the appropriate enumeration value for the given string, mapping underscored names to CamelCase, or the input string if a mapping could not be made.' 163 | if typ is not None: 164 | if hasattr(typ, '_map') and text in typ._map: 165 | return getattr(typ, typ._map[text]) 166 | elif hasattr(typ, text[0].upper() + text[1:]): 167 | return getattr(typ, text[0].upper() + text[1:]) 168 | elif '_' in text: 169 | real_name = ''.join(x.title() for x in text.split('_')) 170 | if hasattr(typ, real_name): 171 | return getattr(typ, real_name) 172 | else: 173 | return text 174 | else: 175 | return text 176 | else: 177 | return cls.from_string(text, cls) 178 | 179 | class StackExchangeError(Exception): 180 | """A generic error thrown on a bad HTTP request during a StackExchange API request.""" 181 | UNKNOWN = -1 182 | 183 | def __init__(self, code = UNKNOWN, name = None, message = None): 184 | self.code = code 185 | self.name = name 186 | self.message = message 187 | 188 | def __str__(self): 189 | if self.code == self.UNKNOWN: 190 | return 'unrecognised error' 191 | else: 192 | return '%d [%s]: %s' % (self.code, self.name, self.message) 193 | 194 | class EmptyResultset(tuple): 195 | def __new__(cls, json): 196 | instance = tuple.__new__(cls, []) 197 | 198 | for key in ('page_size', 'page', 'has_more', 'total', 'quota_max', 'quota_remaining', 'type'): 199 | if key in json: 200 | setattr(instance, key, json[key]) 201 | 202 | return instance 203 | 204 | class StackExchangeResultset(tuple): 205 | """Defines an immutable, paginated resultset. This class can be used as a tuple, but provides extended metadata as well, including methods 206 | to fetch the next page.""" 207 | 208 | def __new__(cls, items, build_info, has_more = True, page = 1, pagesize = None): 209 | if pagesize is None: 210 | pagesize = len(items) 211 | 212 | instance = tuple.__new__(cls, items) 213 | instance.page, instance.pagesize, instance.build_info = page, pagesize, build_info 214 | instance.items = items 215 | instance.has_more = has_more 216 | 217 | return instance 218 | 219 | def reload(self): 220 | """Refreshes the data in the resultset with fresh API data. Note that this doesn't work with extended resultsets.""" 221 | # kind of a cheat, but oh well 222 | return self.fetch_page(self.page) 223 | 224 | def fetch_page(self, page, **kw): 225 | """Returns a new resultset containing data from the specified page of the results. It re-uses all parameters that were passed in 226 | to the initial function which created the resultset.""" 227 | new_params = list(self.build_info) 228 | new_params[4] = new_params[4].copy() 229 | new_params[4].update(kw) 230 | new_params[4]['page'] = page 231 | 232 | new_set = new_params[0].build(*new_params[1:]) 233 | new_set.page = page 234 | return new_set 235 | 236 | def fetch_extended(self, page): 237 | """Returns a new resultset containing data from this resultset AND from the specified page.""" 238 | next = self.fetch_page(page) 239 | extended = self + next 240 | 241 | # max(0, ...) is so a non-zero, positive result for page is always found 242 | return StackExchangeResultset(extended, self.build_info, next.has_more, page) 243 | 244 | def fetch_next(self): 245 | """Returns the resultset of the data in the next page.""" 246 | return self.fetch_page(self.page + 1) 247 | 248 | def extend_next(self): 249 | """Returns a new resultset containing data from this resultset AND from the next page.""" 250 | return self.fetch_extended(self.page + 1) 251 | 252 | def fetch(self): 253 | # Do nothing, but allow multiple fetch calls 254 | return self 255 | 256 | def __iter__(self): 257 | return self.next() 258 | 259 | def next(self): 260 | current = self 261 | while True: 262 | for obj in current.items: 263 | yield obj 264 | if not current.has_more: 265 | return 266 | 267 | try: 268 | current = current.fetch_next() 269 | if len(current) == 0: 270 | return 271 | except urllib.error.HTTPError: 272 | return 273 | 274 | class NeedsAwokenError(Exception): 275 | """An error raised when an attempt is made to access a property of a lazy collection that requires the data to have been fetched, 276 | but whose data has not yet been requested.""" 277 | 278 | def __init__(self, lazy): 279 | self.lazy = lazy 280 | def __str__(self): 281 | return 'Could not return requested data; the sequence of "%s" has not been fetched.' % self.lazy.m_lazy 282 | 283 | class StackExchangeLazySequence(list): 284 | """Provides a sequence which *can* contain extra data available on an object. It is 'lazy' in the sense that data is only fetched when 285 | required - not on object creation.""" 286 | 287 | def __init__(self, m_type, count, site, url, fetch = None, collection = None, **kw): 288 | self.m_type = m_type 289 | self.count = count 290 | self.site = site 291 | self.url = url 292 | self.fetch_callback = fetch 293 | self.kw = kw 294 | self.collection = collection if collection != None else self._collection(url) 295 | 296 | def _collection(self, c): 297 | return c.split('/')[-1] 298 | 299 | def __len__(self): 300 | if self.count != None: 301 | return self.count 302 | else: 303 | raise NeedsAwokenError(repr(self)) 304 | 305 | def __repr__(self): 306 | if self.count is None: 307 | return '' % self.m_type.__name__ 308 | else: 309 | return list.__repr__(self) 310 | 311 | def fetch(self, **direct_kw): 312 | """Fetch, from the API, the data this sequence is meant to hold.""" 313 | # If we have any default parameters, include them, but overwrite any 314 | # passed in here directly. 315 | kw = dict(self.kw) 316 | kw.update(direct_kw) 317 | 318 | res = self.site.build(self.url, self.m_type, self.collection, kw) 319 | if self.fetch_callback != None: 320 | self.fetch_callback(res) 321 | return res 322 | 323 | class StackExchangeLazyObject(list): 324 | """Provides a proxy to fetching a single item from a collection, lazily.""" 325 | 326 | def __init__(self, m_type, site, url, fetch = None, collection = None): 327 | self.m_type = m_type 328 | self.site = site 329 | self.url = url 330 | self.fetch_callback = fetch 331 | self.collection = collection if collection != None else self._collection(url) 332 | 333 | def fetch(self, **kw): 334 | """Fetch, from the API, the data supposed to be held.""" 335 | res = self.site.build(self.url, self.m_type, self.collection, kw)[0] 336 | if self.fetch_callback != None: 337 | self.fetch_callback(res) 338 | return res 339 | 340 | def __getattr__(self, key): 341 | raise NeedsAwokenError 342 | 343 | #### Hack, because I can't be bothered to fix my mistaking JSON's output for an object not a dict 344 | # (Si jeunesse savait, si vieillesse pouvait...) 345 | # Attrib: Eli Bendersky, http://stackoverflow.com/questions/1305532/convert-python-dict-to-object/1305663#1305663 346 | class DictObject: 347 | def __init__(self, entries): 348 | self.__dict__.update(entries) 349 | 350 | class JSONMangler(object): 351 | """This class handles all sorts of random JSON-handling stuff""" 352 | 353 | @staticmethod 354 | def paginated_to_resultset(site, json, typ, collection, params): 355 | # N.B.: We ignore the 'collection' parameter for now, given that it is 356 | # no longer variable in v2.x, having been replaced by a generic field 357 | # 'items'. To perhaps be removed completely at some later point. 358 | items = [] 359 | 360 | # create strongly-typed objects from the JSON items 361 | for json_item in json['items']: 362 | json_item['_params_'] = params[-1] # convenient access to the kw hash 363 | items.append(typ(json_item, site)) 364 | 365 | rs = StackExchangeResultset(items, params, json['has_more']) 366 | if 'total' in json: 367 | rs.total = json['total'] 368 | 369 | return rs 370 | 371 | @staticmethod 372 | def normal_to_resultset(site, json, typ, collection): 373 | # the parameter 'collection' may be need in future, and was needed pre-2.0 374 | return tuple([typ(x, site) for x in json['items']]) 375 | 376 | @classmethod 377 | def json_to_resultset(cls, site, json, typ, collection, params = None): 378 | # this is somewhat of a special case, introduced by some filters in 379 | # post-2.0 allowing only 'metadata' to be returned 380 | if 'items' not in json: 381 | return EmptyResultset(json) 382 | elif 'has_more' in json: 383 | # we have a paginated resultset 384 | return cls.paginated_to_resultset(site, json, typ, collection, params) 385 | else: 386 | # this isn't paginated (unlikely but possible - eg badges) 387 | return cls.normal_to_resultset(site, json, typ, collection) 388 | 389 | def format_relative_date(date, relative_to = None): 390 | """Takes a datetime object and returns the date formatted as a string e.g. "3 minutes ago", like the real site, relative to the given datetime. If no datetime is given, default to the current time.""" 391 | # This is based roughly on George Edison's code from StackApps: 392 | # http://stackapps.com/questions/1009/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site/1018#1018""" 393 | 394 | now = datetime.datetime.now() if relative_to is None else relative_to 395 | diff = (now - date).seconds 396 | 397 | # Anti-repetition! These simplify the code somewhat. 398 | plural = lambda d: 's' if d != 1 else '' 399 | frmt = lambda d: (diff / float(d), plural(diff / float(d))) 400 | 401 | if diff < 60: 402 | return '%d second%s ago' % frmt(1) 403 | elif diff < 3600: 404 | return '%d minute%s ago' % frmt(60) 405 | elif diff < 86400: 406 | return '%d hour%s ago' % frmt(3600) 407 | elif diff < 172800: 408 | return 'yesterday' 409 | else: 410 | return date.strftime('M j / y - H:i') 411 | 412 | class Sort(Enumeration): 413 | Activity = 'activity' 414 | Views = 'views' 415 | Creation = 'creation' 416 | Votes = 'votes' 417 | 418 | ASC = 'asc' 419 | DESC = 'desc' 420 | -------------------------------------------------------------------------------- /stackexchange/models.py: -------------------------------------------------------------------------------- 1 | import re, sys 2 | from stackexchange.core import * 3 | 4 | LaterClass = lambda name: LaterClassIn(name, sys.modules[__name__]) 5 | 6 | #### Revisions # 7 | class RevisionType(Enumeration): 8 | SingleUser = 'single_user' 9 | VoteBased = 'vote_based' 10 | 11 | class PostRevision(JSONModel): 12 | transfer = ('body', 'comment', 'is_question', 'is_rollback', 'last_body', 13 | 'last_title', 'revision_guid', 'revision_number', 'title', 14 | 'set_community_wiki', 'post_id', 'last_tags', 'tags', 15 | ('creation_date', UNIXTimestamp), 16 | ('revision_type', RevisionType.from_string)) 17 | 18 | def _extend(self, json, site): 19 | part = json.user 20 | self.user = User.partial(lambda self: self.site.user(self.id), site, { 21 | 'id': part['user_id'], 22 | 'user_type': Enumeration.from_string(part['user_type'], UserType), 23 | 'display_name': part['display_name'], 24 | 'reputation': part['reputation'], 25 | 'profile_image': part['profile_image'] 26 | }) 27 | 28 | def _get_post(self): 29 | if self.is_question: 30 | return self.site.question(self.post_id) 31 | else: 32 | return self.site.answer(self.post_id) 33 | post = property(_get_post) 34 | 35 | # The SE API seems quite inconsistent in this regard; the other methods give a post_type in their JSON 36 | def _get_post_type(self): 37 | return PostType.Question if self.is_question else PostType.Answer 38 | post_type = property(_get_post_type) 39 | 40 | def __repr__(self): 41 | return '' % (self.revision_number, 'Q' if self.is_question else 'A', self.post_id) 42 | 43 | class PostType(Enumeration): 44 | """Denotes the type of a post: a question or an answer.""" 45 | Question, Answer = 'question', 'answer' 46 | 47 | ## Timeline ## 48 | class TimelineEventType(Enumeration): 49 | """Denotes the type of a timeline event.""" 50 | _map = {'askoranswered': 'AskOrAnswered'} 51 | 52 | Comment = 'comment' 53 | AskOrAnswered = 'askoranswered' 54 | Badge = 'badge' 55 | Revision = 'revision' 56 | Accepted = 'accepted' 57 | 58 | class TimelineEvent(JSONModel): 59 | transfer = ('user_id', 'post_id', 'comment_id', 'action', 'description', 60 | 'detail', 'comment_id', 61 | ('timeline_type', TimelineEventType.from_string), 62 | ('post_type', PostType.from_string), 63 | ('creation_date', UNIXTimestamp)) 64 | 65 | _post_related = (TimelineEventType.AskOrAnswered, TimelineEventType.Revision, TimelineEventType.Comment) 66 | def _get_post(self): 67 | if self.timeline_type in self._post_related: 68 | if self.post_type == PostType.Question: 69 | return self.site.question(self.post_id) 70 | else: 71 | return self.site.answer(self.post_id) 72 | else: 73 | return None 74 | 75 | def _get_comment(self): 76 | if self.timeline_type == TimelineEventType.Comment: 77 | return self.site.comment(self.comment_id) 78 | else: 79 | return None 80 | 81 | def _get_badge(self): 82 | if self.timeline_type == TimelineEventType.Badge: 83 | return self.site.badge(name = self.description) 84 | else: 85 | return None 86 | 87 | post = property(_get_post) 88 | comment = property(_get_comment) 89 | badge = property(_get_badge) 90 | 91 | 92 | ##### Content Types ### 93 | class Comment(JSONModel): 94 | """Describes a comment to a question or answer on a StackExchange site.""" 95 | transfer = ('post_id', 'score', 'edit_count', 'body', 96 | ('creation_date', UNIXTimestamp), ('post_type', PostType.from_string)) 97 | 98 | def _extend(self, json, site): 99 | self.id = json.comment_id 100 | 101 | if hasattr(json, 'owner'): 102 | self.owner_id = json.owner['owner_id'] if 'owner_id' in json.owner else json.owner['user_id'] 103 | self.owner = User.partial(lambda self: self.site.user(self.id), site, { 104 | 'id': self.owner_id, 105 | 'user_type': Enumeration.from_string(json.owner['user_type'], UserType), 106 | 'display_name': json.owner['display_name'], 107 | 'reputation': json.owner['reputation'], 108 | 'profile_image': json.owner['profile_image']}) 109 | else: 110 | self.owner = None 111 | 112 | if hasattr(json, 'reply_to'): 113 | self.reply_to_user_id = json.reply_to['user_id'] 114 | self.reply_to = User.partial(lambda self: self.site.user(self.id), site, { 115 | 'id': self.reply_to_user_id, 116 | 'user_type': Enumeration.from_string(json.reply_to['user_type'], UserType), 117 | 'display_name': json.reply_to['display_name'], 118 | 'reputation': json.reply_to['reputation'], 119 | 'profile_image': json.reply_to['profile_image']}) 120 | 121 | @property 122 | def post(self): 123 | if self.post_type == PostType.Question: 124 | return self.site.question(self.post_id) 125 | elif self.post_type == PostType.Answer: 126 | return self.site.answer(self.post_id) 127 | else: 128 | return None 129 | 130 | def __unicode__(self): 131 | return u'Comment ' + str(self.id) 132 | def __str__(self): 133 | return str(unicode(self)) 134 | 135 | class Answer(JSONModel): 136 | """Describes an answer on a StackExchange site.""" 137 | 138 | transfer = ('is_accepted', 'locked_date', 'question_id', 'up_vote_count', 139 | 'down_vote_count', 'view_count', 'score', 'community_owned', 'title', 140 | 'body', 'body_markdown', ('creation_date', UNIXTimestamp), 141 | ('last_edit_date', UNIXTimestamp), 142 | ('last_activity_date', UNIXTimestamp), 143 | ('revisions', LazySequenceField(PostRevision, 'posts/{id}/revisions'))) 144 | alias = (('id', 'answer_id'), ('accepted', 'is_accepted')) 145 | 146 | def _extend(self, json, site): 147 | if not hasattr(json, '_params_'): 148 | comment = False 149 | else: 150 | comment = ('comment' in json._params_ and json._params_['comment']) 151 | 152 | answer_comments_url = 'answers/%d/comments' % self.id 153 | self.comments = site.build_from_snippet(json.comments, Comment) if comment else StackExchangeLazySequence(Comment, None, site, answer_comments_url, self._up('comments'), filter = '!-*7AsUyrEan0') 154 | 155 | self._question, self._owner = None, None 156 | if hasattr(json, 'owner'): 157 | self.owner_id = json.owner.get('user_id') 158 | self.owner_info = tuple(json.owner.values()) 159 | 160 | if hasattr(self, 'up_vote_count') and hasattr(self, 'down_vote_count'): 161 | self.votes = (self.up_vote_count, self.down_vote_count) 162 | 163 | self.url = 'http://' + self.site.root_domain + '/questions/' + str(self.question_id) + '/' + str(self.id) + '#' + str(self.id) 164 | 165 | @property 166 | def owner(self): 167 | if self._owner is None: 168 | self._owner = self.site.user(self.owner_id) 169 | return self._owner 170 | 171 | @property 172 | def question(self): 173 | if self._question is None: 174 | self._question = self.site.question(self.question_id) 175 | return self._question 176 | 177 | def fetch_callback(self, _, site): 178 | return site.answer(self.id) 179 | 180 | def __unicode__(self): 181 | return u'Answer %d' % self.id 182 | 183 | def __str__(self): 184 | return str(unicode(self)) 185 | 186 | def __repr__(self): 187 | return '' % (self.id, id(self)) 188 | 189 | class IDPartial(ComplexTransform): 190 | def __init__(self, model_type, fetch_callback): 191 | self.partial = PartialModelRef(model_type, fetch_callback) 192 | 193 | def __call__(self, key, value, model): 194 | return self.partial(key, {'id': value}, model) 195 | 196 | class Question(JSONModel): 197 | """Describes a question on a StackExchange site.""" 198 | transfer = ('tags', 'favorite_count', 'up_vote_count', 'down_vote_count', 199 | 'view_count', 'score', 'community_owned', 'title', 'body', 200 | 'body_markdown', 'is_answered', 'link', 'answer_count', 'can_close', 201 | 'can_flag', 'close_vote_count', 'closed_reason', 'comment_count', 202 | 'community_owned_date', 'delete_vote_count', 'down_vote_count', 203 | 'downvoted', 'favorite_count', 'favorited', 'is_answered', 204 | 'accepted_answer_id', 'question_id', 'bounty_amount', 'upvoted', 205 | 'reopen_vote_count', 'share_link', 'up_vote_count', 206 | ('creation_date', UNIXTimestamp), 207 | ('closed_date', UNIXTimestamp), 208 | ('last_edit_date', UNIXTimestamp), 209 | ('last_activity_date', UNIXTimestamp), 210 | ('bounty_closes_date', UNIXTimestamp), 211 | ('locked_date', UNIXTimestamp), 212 | ('protected_date', UNIXTimestamp), 213 | # XXX 214 | #('bounty_user', PartialModelRef(User, lambda s: s.site.user(s.id), extend = True)), 215 | #('last_editor', PartialModelRef(User, lambda s: s.site.user(s.id), extend = True)), 216 | ('timeline', LazySequenceField(TimelineEvent, 'questions/{id}/timeline')), 217 | ('revisions', LazySequenceField(PostRevision, 'posts/{id}/revisions')), 218 | ('comments', LazySequenceField(Comment, 'questions/{id}/comments', filter = '!-*7AsUyrEan0')), 219 | ('answers', ListOf(ModelRef(Answer)))) 220 | alias = (('id', 'question_id'), 221 | ('accepted_answer', 'accepted_answer_id', IDPartial(Answer, lambda a: a.site.answer(a.id)))) 222 | 223 | def _extend(self, json, site): 224 | if hasattr(json, 'owner') and 'user_id' in json.owner: 225 | self.owner_id = json.owner['user_id'] 226 | 227 | owner_dict = dict(json.owner) 228 | owner_dict['id'] = self.owner_id 229 | del owner_dict['user_id'] 230 | owner_dict['user_type'] = UserType.from_string(owner_dict['user_type']) 231 | 232 | self.owner = User.partial(lambda self: self.site.user(self.id), site, owner_dict) 233 | 234 | self.url = 'http://' + self.site.root_domain + '/questions/' + str(self.id) 235 | 236 | def fetch_callback(self, _): 237 | return self.site.question(self.id) 238 | 239 | def linked(self): 240 | return self.site.questions(linked_to = self.id) 241 | 242 | def related(self): 243 | return self.site.questions(related_to = self.id) 244 | 245 | def __repr__(self): 246 | return "" % (self.title, id(self)) 247 | 248 | ##### Tags ##### 249 | class TagSynonym(JSONModel): 250 | transfer = ('from_tag', 'to_tag', 'applied_count', 251 | ('creation_date', UNIXTimestamp), 252 | ('last_applied_date', UNIXTimestamp)) 253 | 254 | def __repr__(self): 255 | return "'%s'>" % (self.from_tag, self.to_tag) 256 | 257 | class TagWiki(JSONModel): 258 | transfer = ('tag_name', 'body', 'excerpt', 259 | ('body_last_edit_date', UNIXTimestamp), 260 | ('excerpt_last_edit_date', UNIXTimestamp)) 261 | 262 | def _extend(self, json, site): 263 | if hasattr(json, 'last_body_editor'): 264 | body_editor = dict(json.last_body_editor) 265 | body_editor['id'] = body_editor['user_id'] 266 | del body_editor['user_id'] 267 | self.last_body_editor = User.partial(lambda s: s.site.user(self.id), site, body_editor) 268 | 269 | if hasattr(json, 'last_excerpt_editor'): 270 | excerpt_editor = dict(json.last_excerpt_editor) 271 | excerpt_editor['id'] = excerpt_editor['user_id'] 272 | del excerpt_editor['user_id'] 273 | self.last_excerpt_editor = User.partial(lambda s: s.site.user(self.id), site, excerpt_editor) 274 | 275 | class Period(Enumeration): 276 | AllTime, Month = 'all-time', 'month' 277 | 278 | class TopUser(JSONModel): 279 | transfer = ('score', 'post_count') 280 | 281 | def _extend(self, json, site): 282 | user_dict = dict(json.user) 283 | user_dict['id'] = user_dict['user_id'] 284 | del user_dict['user_id'] 285 | self.user = User.partial(lambda self: self.site.user(self.id), site, user_dict) 286 | 287 | def __repr__(self): 288 | return "" % (self.user.display_name, self.score) 289 | 290 | class Tag(JSONModel): 291 | transfer = ('name', 'count', 'fulfills_required') 292 | # Hack so that Site.vectorise() works correctly 293 | id = property(lambda self: self.name) 294 | 295 | def _extend(self, json, site): 296 | self.synonyms = StackExchangeLazySequence(TagSynonym, None, site, 'tags/%s/synonyms' % self.name, self._up('synonyms'), 'tag_synonyms') 297 | self.wiki = StackExchangeLazyObject(TagWiki, site, 'tags/%s/wikis' % self.name, self._up('wiki'), 'tag_wikis') 298 | 299 | def top_askers(self, period, **kw): 300 | return self.site.build('tags/%s/top-askers/%s' % (self.name, period), TopUser, 'top_users', kw) 301 | 302 | def top_answerers(self, period, **kw): 303 | return self.site.build('tags/%s/top-answerers/%s' % (self.name, period), TopUser, 'top_users', kw) 304 | 305 | class RepChange(JSONModel): 306 | """Describes an event which causes a change in reputation.""" 307 | transfer = ('user_id', 'post_id', 'post_type', 'title', 'positive_rep', 308 | 'negative_rep', ('on_date', UNIXTimestamp)) 309 | 310 | def _extend(self, json, site): 311 | if hasattr(json, 'positive_rep') and hasattr(json, 'negative_rep'): 312 | self.score = json.positive_rep - json.negative_rep 313 | 314 | class UserType(Enumeration): 315 | """Denotes the status of a user on a site: whether it is Anonymous, Unregistered, Registered or a Moderator.""" 316 | Anonymous = 'anonymous' 317 | Registered = 'registered' 318 | Unregistered = 'unregistered' 319 | Moderator = 'moderator' 320 | 321 | class FormattedReputation(int): 322 | def format(rep): 323 | """Formats the reputation score like it is formatted on the sites. Heavily based on CMS' JavaScript implementation at 324 | http://stackapps.com/questions/1012/how-to-format-reputation-numbers-similar-to-stack-exchange-sites/1019#1019""" 325 | str_rep = str(rep) 326 | 327 | if rep < 1000: 328 | return str_rep 329 | elif rep < 10000: 330 | return '%s,%s' % (str_rep[0], str_rep[1:]) 331 | elif rep % 1000 == 0: 332 | return '%dk' % (rep / 1000.0) 333 | else: 334 | return '%.1fk' % (rep / 1000.0) 335 | 336 | class TopTag(JSONModel): 337 | transfer = ('tag_name', 'question_score', 'question_count', 'answer_score', 'answer_count') 338 | 339 | def __repr__(self): 340 | return "" % (self.tag_name, self.question_score, self.answer_score) 341 | 342 | class User(JSONModel): 343 | """Describes a user on a StackExchange site.""" 344 | transfer = ('display_name', 'profile_image', 'age', 'website_url', 345 | 'location', 'about_me', 'view_count', 'up_vote_count', 346 | 'down_vote_count', 'account_id', 'profile_image', 'is_employee', 347 | ('creation_date', UNIXTimestamp), 348 | ('last_access_date', UNIXTimestamp), 349 | ('reputation', FormattedReputation), 350 | ('favorites', LazySequenceField(Question, 'users/{id}/favorites', response_key = 'questions')), 351 | ('no_answers_questions', LazySequenceField(Question, 'users/{id}/questions/no-answers', response_key = 'questions')), 352 | ('unanswered_questions', LazySequenceField(Question, 'users/{id}/questions/unanswered', response_key = 'questions')), 353 | ('unaccepted_questions', LazySequenceField(Question, 'users/{id}/questions/unaccepted', response_key = 'questions')), 354 | ('tags', LazySequenceField(Tag, 'users/{id}/tags')), 355 | ('badges', LazySequenceField(LaterClass('Badge'), 'users/{id}/badges')), 356 | ('timeline', LazySequenceField(TimelineEvent, 'users/{id}/timeline', response_key = 'user_timelines')), 357 | ('reputation_detail', LazySequenceField(RepChange, 'users/{id}/reputation')), 358 | ('mentioned', LazySequenceField(Comment, 'users/{id}/mentioned', response_key = 'comments')), 359 | ('comments', LazySequenceField(Comment, 'users/{id}/comments')), 360 | ('top_answer_tags', LazySequenceField(TopTag, 'users/{id}/top-answer-tags', response_key = 'top_tags')), 361 | ('top_question_tags', LazySequenceField(TopTag, 'users/{id}/top-question-tags', response_key = 'top_tags')), 362 | ) 363 | 364 | # for compatibility reasons; association_id changed in v2.x 365 | alias = (('id', 'user_id'), ('association_id', 'account_id'), 366 | ('type', 'user_type', UserType.from_string)) 367 | badge_types = ('gold', 'silver', 'bronze') 368 | 369 | def _extend(self, json, site): 370 | user_questions_url = 'users/%d/questions' % self.id 371 | question_count = getattr(json, 'question_count', None) 372 | self.questions = StackExchangeLazySequence(Question, question_count, site, user_questions_url, self._up('questions')) 373 | 374 | user_answers_url = 'users/%d/answers' % self.id 375 | answer_count = getattr(json, 'answer_count', None) 376 | self.answers = StackExchangeLazySequence(Answer, answer_count, site, user_answers_url, self._up('answers')) 377 | 378 | if hasattr(self, 'up_vote_count') and hasattr(self, 'down_vote_count'): 379 | self.vote_counts = (self.up_vote_count, self.down_vote_count) 380 | 381 | if hasattr(json, 'badge_counts'): 382 | self.badge_counts_t = tuple(json.badge_counts.get(c, 0) for c in ('gold', 'silver', 'bronze')) 383 | self.gold_badges, self.silver_badges, self.bronze_badges = self.badge_counts_t 384 | self.badge_counts = { 385 | BadgeType.Gold: self.gold_badges, 386 | BadgeType.Silver: self.silver_badges, 387 | BadgeType.Bronze: self.bronze_badges 388 | } 389 | self.badge_total = sum(self.badge_counts_t) 390 | 391 | if hasattr(self, 'type'): 392 | self.is_moderator = self.type == UserType.Moderator 393 | 394 | self.url = 'http://' + self.site.root_domain + '/users/' + str(self.id) 395 | 396 | def has_privilege(self, privilege): 397 | return self.reputation >= privilege.reputation 398 | 399 | def _get_real_tag(self, tag): 400 | return tag.name if isinstance(tag, Tag) else tag 401 | 402 | def top_answers_in_tag(self, tag, **kw): 403 | return self.site.build('users/%d/tags/%s/top-answers' % (self.id, self._get_real_tag(tag)), Answer, 'answers', kw) 404 | 405 | def top_questions_in_tag(self, tag, **kw): 406 | return self.site.build('users/%d/tags/%s/top-questions' % (self.id, self._get_real_tag(tag)), Question, 'questions', kw) 407 | 408 | def comments_to(self, user, **kw): 409 | uid = user.id if isinstance(user, User) else user 410 | return self.site.build('users/%d/comments/%d' % (self.id, uid), Comment, 'comments' ,kw) 411 | 412 | def __unicode__(self): 413 | return 'User %d [%s]' % (self.id, self.display_name) 414 | def __str__(self): 415 | return str(unicode(self)) 416 | def __repr__(self): 417 | return "" % (self.display_name, self.id, id(self)) 418 | 419 | class BadgeType(Enumeration): 420 | """Describes the rank or type of a badge: one of Bronze, Silver or Gold.""" 421 | Bronze, Silver, Gold = range(3) 422 | 423 | class Badge(JSONModel): 424 | """Describes a badge awardable on a StackExchange site.""" 425 | transfer = ('name', 'description', 'award_count', 'tag_based', 426 | ('user', PartialModelRef(User, lambda s: s.site.user(s.id), extend = True))) 427 | alias = (('id', 'badge_id'),) 428 | 429 | @property 430 | def recipients(self): 431 | for badge in self.site.badge_recipients([self.id]): 432 | yield badge.user 433 | 434 | def __str__(self): 435 | return self.name 436 | def __repr__(self): 437 | return '' % (self.name, id(self)) 438 | 439 | class Privilege(JSONModel): 440 | transfer = ('short_description', 'description', 'reputation') 441 | 442 | 443 | class QuestionsQuery(object): 444 | def __init__(self, site): 445 | self.site = site 446 | 447 | def __call__(self, ids = None, user_id = None, **kw): 448 | self.site.check_filter(kw) 449 | 450 | # Compatibility hack, as user_id= was in versions below v1.1 451 | if ids is None and user_id is not None: 452 | return self.by_user(user_id, **kw) 453 | elif ids is None and user_id is None: 454 | return self.site.build('questions', Question, 'questions', kw) 455 | else: 456 | return self.site._get(Question, ids, 'questions', kw) 457 | 458 | def linked_to(self, qn, **kw): 459 | self.site.check_filter(kw) 460 | url = 'questions/%s/linked' % self.site.vectorise(qn, Question) 461 | return self.site.build(url, Question, 'questions', kw) 462 | 463 | def related_to(self, qn, **kw): 464 | self.site.check_filter(kw) 465 | url = 'questions/%s/related' % self.site.vectorise(qn, Question) 466 | return self.site.build(url, Question, 'questions', kw) 467 | 468 | def by_user(self, usr, **kw): 469 | self.site.check_filter(kw) 470 | kw['user_id'] = usr 471 | return self.site._user_prop('questions', Question, 'questions', kw) 472 | 473 | def unanswered(self, by = None, **kw): 474 | self.site.check_filter(kw) 475 | 476 | if by is None: 477 | return self.site.build('questions/unanswered', Question, 'questions', kw) 478 | else: 479 | kw['user_id'] = by 480 | return self.site._user_prop('questions/unanswered', Question, 'questions', kw) 481 | 482 | def no_answers(self, by = None, **kw): 483 | self.site.check_filter(kw) 484 | 485 | if by is None: 486 | return self.site.build('questions/no-answers', Question, 'questions', kw) 487 | else: 488 | kw['user_id'] = by 489 | return self.site._user_prop('questions/no-answers', Question, 'questions', kw) 490 | 491 | def unaccepted(self, by, **kw): 492 | self.site.check_filter(kw) 493 | kw['user_id'] = by 494 | return self.site._user_prop('questions/unaccepted', Questions, 'questions', kw) 495 | 496 | def favorited_by(self, by, **kw): 497 | self.site.check_filter(kw) 498 | kw['user_id'] = by 499 | return self.site._user_prop('favorites', Question, 'questions', kw) 500 | 501 | -------------------------------------------------------------------------------- /stackexchange/site.py: -------------------------------------------------------------------------------- 1 | import datetime, time 2 | 3 | from six.moves import urllib 4 | from six import string_types 5 | 6 | from stackexchange.web import WebRequestManager 7 | from stackexchange.core import * 8 | from stackexchange.models import * 9 | 10 | ##### Site metadata ### 11 | # (originally in the stackauth module; now we need it for Site#info) 12 | 13 | class SiteState(Enumeration): 14 | """Describes the state of a StackExchange site.""" 15 | Normal, OpenBeta, ClosedBeta, LinkedMeta = range(4) 16 | 17 | class SiteType(Enumeration): 18 | '''Describes the type (meta or non-meta) of a StackExchange site.''' 19 | MainSite, MetaSite = range(2) 20 | 21 | class MarkdownExtensions(Enumeration): 22 | '''Specifies one of the possible extensions to Markdown a site can have enabled.''' 23 | MathJax, Prettify, Balsamiq, JTab = range(4) 24 | 25 | class SiteDefinition(JSONModel): 26 | """Contains information about a StackExchange site, reported by StackAuth.""" 27 | transfer = ('aliases', 'api_site_parameter', 'audience', 'favicon_url', 28 | 'high_resolution_icon_url', 'icon_url', 'logo_url', 'name', 'open_beta_date', 29 | 'related_sites', 'site_state', 'site_type', 'site_url', 'twitter_account', 30 | 'api_site_parameter', 31 | ('closed_beta_date', UNIXTimestamp), 32 | ('open_beta_date', UNIXTimestamp), 33 | ('launch_date', UNIXTimestamp), 34 | ('markdown_extensions', ListOf(MarkdownExtensions.from_string)), 35 | ('site_state', SiteState.from_string), 36 | ('site_type', SiteType.from_string), 37 | ('styling', DictObject)) 38 | 39 | # To maintain API compatibility only; strictly speaking, we should use api_site_parameter 40 | # to create new sites, and that's what we do in get_site() 41 | alias = (('api_endpoint', 'site_url'), ('description', 'audience')) 42 | 43 | 44 | def _extend(self, json, stackauth): 45 | # The usual enumeration heuristics need a bit of help to parse the 46 | # site state as returned by the API 47 | fixed_state = re.sub(r'_([a-z])', lambda match: match.group(1).upper(), json.site_state) 48 | fixed_state = fixed_state[0].upper() + fixed_state[1:] 49 | self.state = SiteState.from_string(fixed_state) 50 | 51 | def get_site(self, *a, **kw): 52 | return Site(self.api_site_parameter, *a, **kw) 53 | 54 | ##### Statistics ### 55 | class Statistics(JSONModel): 56 | """Stores statistics for a StackExchange site.""" 57 | transfer = ('new_active_users', 'total_users', 'badges_per_minute', 58 | 'total_badges', 'total_votes', 'total_comments', 'answers_per_minute', 59 | 'questions_per_minute', 'total_answers', 'total_accepted', 60 | 'total_unanswered', 'total_questions', 'api_revision') 61 | alias = (('site_definition', 'site', ModelRef(SiteDefinition)),) 62 | 63 | 64 | class Site(object): 65 | """Stores information and provides methods to access data on a StackExchange site. This class is the 'root' of the API - all data is accessed 66 | through here.""" 67 | 68 | def __init__(self, domain, app_key = None, cache = 1800, impose_throttling = False, force_http = False): 69 | self.domain = domain 70 | self.app_key = app_key 71 | self.api_version = '2.2' 72 | 73 | self.impose_throttling = impose_throttling 74 | self.throttle_stop = True 75 | self.cache_options = {'cache': False} if cache == 0 else {'cache': True, 'cache_age': cache} 76 | self.force_http = force_http 77 | 78 | self.include_body = False 79 | self.include_comments = False 80 | 81 | # In API v2.x, we generally don't get given api. at the start of these things, nor are they 82 | # strictly domains in many cases. We continue to accept api.* names for compatibility. 83 | domain_components = self.domain.split('.') 84 | if domain_components[0] == 'api': 85 | self.root_domain = '.'.join(domain_components[1:]) 86 | else: 87 | self.root_domain = domain 88 | 89 | URL_Roots = { 90 | User: 'users/%s', 91 | Badge: 'badges/%s', 92 | Answer: 'answers/%s', 93 | Comment: 'comments/%s', 94 | Question: 'questions/%s', 95 | } 96 | 97 | def check_filter(self, kw): 98 | if 'answers' not in kw: 99 | kw['answers'] = 'true' 100 | if self.include_body: 101 | kw['body'] = 'true' 102 | if self.include_comments: 103 | kw['comments'] = 'true' 104 | 105 | # for API v2.x, the comments, body and answers parameters no longer 106 | # exist; instead, we have to use filters. for now, take the easy way 107 | # out and just rewrite them in terms of the new filters. 108 | if 'filter' not in kw: 109 | filter_name = '_' 110 | 111 | if kw.get('body'): 112 | filter_name += 'b' 113 | del kw['body'] 114 | if kw.get('comments'): 115 | filter_name += 'c' 116 | del kw['comments'] 117 | if kw.get('answers'): 118 | filter_name += 'a' 119 | del kw['answers'] 120 | 121 | if filter_name == '_ca': 122 | # every other compatibility filter name works in the above 123 | # order except this one... 124 | kw['filter'] = '_ac' 125 | elif filter_name != '_': 126 | kw['filter'] = filter_name 127 | 128 | def _kw_to_str(self, ob): 129 | try: 130 | if isinstance(ob, datetime.datetime): 131 | return str(time.mktime(ob.timetuple())) 132 | elif isinstance(ob, string_types): 133 | return ob 134 | else: 135 | i = iter(ob) 136 | return ';'.join(i) 137 | except TypeError: 138 | return str(ob).lower() 139 | 140 | def _request(self, to, params): 141 | url = 'api.stackexchange.com/' + self.api_version + '/' + to 142 | params['site'] = params.get('site', self.root_domain) 143 | 144 | new_params = {} 145 | for k, v in params.items(): 146 | if v is None: 147 | pass 148 | elif k in ('fromdate', 'todate'): 149 | # bit of a HACKish workaround for a reported issue; force to an integer 150 | new_params[k] = str(int(v)) 151 | else: 152 | new_params[k] = self._kw_to_str(v) 153 | 154 | if self.app_key != None: 155 | new_params['key'] = self.app_key 156 | 157 | request_properties = dict([(x, getattr(self, x)) for x in ('impose_throttling', 'throttle_stop', 'force_http')]) 158 | request_properties.update(self.cache_options) 159 | request_mgr = WebRequestManager(**request_properties) 160 | 161 | json, info = request_mgr.json_request(url, new_params) 162 | 163 | if 'quota_remaining' in json and 'quota_max' in json: 164 | self.rate_limit = (json['quota_remaining'], json['quota_max']) 165 | self.requests_used = self.rate_limit[1] - self.rate_limit[0] 166 | self.requests_left = self.rate_limit[0] 167 | 168 | return json 169 | 170 | def _user_prop(self, qs, typ, coll, kw, prop = 'user_id'): 171 | if prop not in kw: 172 | raise LookupError('No user ID provided.') 173 | else: 174 | tid = self.vectorise(kw[prop], User) 175 | del kw[prop] 176 | 177 | return self.build('users/%s/%s' % (tid, qs), typ, coll, kw) 178 | 179 | def be_inclusive(self): 180 | """Include the body and comments of a post, where appropriate, by default.""" 181 | self.include_body, self.include_comments = True, True 182 | 183 | def build(self, url, typ, collection, kw = {}): 184 | """Builds a StackExchangeResultset object from the given URL and type.""" 185 | if 'body' not in kw: 186 | kw['body'] = str(self.include_body).lower() 187 | if 'comments' not in kw: 188 | kw['comments'] = str(self.include_comments).lower() 189 | 190 | json = self._request(url, kw) 191 | return JSONMangler.json_to_resultset(self, json, typ, collection, (self, url, typ, collection, kw)) 192 | 193 | def build_from_snippet(self, json, typ): 194 | return StackExchangeResultset([typ(x, self) for x in json]) 195 | 196 | def vectorise(self, lst, or_of_type = None): 197 | # Ensure we're always dealing with an iterable 198 | allowed_types = or_of_type 199 | if allowed_types is not None and not hasattr(allowed_types, '__iter__'): 200 | allowed_types = (allowed_types, ) 201 | 202 | if isinstance(lst, string_types) or type(lst).__name__ == 'bytes': 203 | return lst 204 | elif hasattr(lst, '__iter__'): 205 | return ';'.join([self.vectorise(x, or_of_type) for x in lst]) 206 | elif allowed_types is not None and any([isinstance(lst, typ) for typ in allowed_types]) and hasattr(lst, 'id'): 207 | return str(lst.id) 208 | else: 209 | return str(lst).lower() 210 | 211 | def _get(self, typ, ids, coll, kw): 212 | root = self.URL_Roots[typ] % self.vectorise(ids) 213 | return self.build(root, typ, coll, kw) 214 | 215 | 216 | def user(self, nid, **kw): 217 | """Retrieves an object representing the user with the ID `nid`.""" 218 | u, = self.users((nid,), **kw) 219 | return u 220 | 221 | def users(self, ids = [], **kw): 222 | """Retrieves a list of the users with the IDs specified in the `ids' parameter.""" 223 | if 'filter' not in kw: 224 | # Include answer_count, etc. in the default filter 225 | kw['filter'] = '!-*f(6q3e0kcP' 226 | return self._get(User, ids, 'users', kw) 227 | 228 | def users_by_name(self, name, **kw): 229 | kw['filter'] = name 230 | return self.users(**kw) 231 | 232 | def moderators(self, **kw): 233 | """Retrieves a list of the moderators on the site.""" 234 | return self.build('users/moderators', User, 'users', kw) 235 | 236 | def moderators_elected(self, **kw): 237 | """Retrieves a list of the elected moderators on the site.""" 238 | return self.build('users/moderators/elected', User, 'users', kw) 239 | 240 | def answer(self, nid, **kw): 241 | """Retrieves an object describing the answer with the ID `nid`.""" 242 | a, = self.answers((nid,), **kw) 243 | return a 244 | 245 | def answers(self, ids = None, **kw): 246 | """Retrieves a set of the answers with the IDs specified in the 'ids' parameter, or by the 247 | user_id specified.""" 248 | self.check_filter(kw) 249 | if ids is None and 'user_id' in kw: 250 | return self._user_prop('answers', Answer, 'answers', kw) 251 | elif ids is None: 252 | return self.build('answers', Answer, 'answers', kw) 253 | else: 254 | return self._get(Answer, ids, 'answers', kw) 255 | 256 | def comment(self, nid, **kw): 257 | """Retrieves an object representing a comment with the ID `nid`.""" 258 | c, = self.comments((nid,), **kw) 259 | return c 260 | 261 | def comments(self, ids = None, posts = None, **kw): 262 | """Returns all the comments on the site.""" 263 | if ids is None: 264 | if posts is None: 265 | return self.build('comments', Comment, 'comments', kw) 266 | else: 267 | url = 'posts/%s/comments' % self.vectorise(posts, (Question, Answer)) 268 | return self.build(url, Comment, 'comments', kw) 269 | else: 270 | return self.build('comments/%s' % self.vectorise(ids), Comment, 'comments', kw) 271 | 272 | def question(self, nid, **kw): 273 | """Retrieves an object representing a question with the ID `nid`. Note that an answer ID can not be specified - 274 | unlike on the actual site, you will receive an error rather than a redirect to the actual question.""" 275 | q, = self.questions((nid,), **kw) 276 | return q 277 | 278 | questions = property(lambda s: QuestionsQuery(s)) 279 | 280 | def recent_questions(self, **kw): 281 | """Returns the set of the most recent questions on the site, by last activity.""" 282 | if 'answers' not in kw: 283 | kw['answers'] = 'true' 284 | return self.build('questions', Question, 'questions', kw) 285 | 286 | def badge_recipients(self, bids, **kw): 287 | """Returns a set of badges recently awarded on the site, constrained to those with the given IDs, with the 'user' property set describing the user to whom it was awarded.""" 288 | return self.build('badges/%s/recipients' % self.vectorise(bids), Badge, 'badges', kw) 289 | 290 | def all_badges(self, **kw): 291 | """Returns the set of all the badges which can be awarded on the site, excluding those which are awarded for specific tags.""" 292 | return self.build('badges', Badge, 'badges', kw) 293 | 294 | def badges(self, ids = None, **kw): 295 | """Returns the badge objectss with the given IDs.""" 296 | if ids == None: 297 | return self._user_prop('badges', Badge, 'users', kw) 298 | else: 299 | return self._get(Badge, ids, 'users', kw) 300 | 301 | def badge(self, nid = None, name = None, **kw): 302 | """Returns an object representing the badge with the ID 'nid', or with the name passed in as name=.""" 303 | if nid is not None and name is None: 304 | b, = self.build('badges/%d' % nid, Badge, 'badges', kw) 305 | return b 306 | elif nid is None and name is not None: 307 | # We seem to need to get all badges and find it by name. Sigh. 308 | kw['inname'] = name 309 | all_badges = self.build('badges', Badge, 'badges', kw) 310 | for badge in all_badges: 311 | if badge.name == name: 312 | return badge 313 | return None 314 | else: 315 | raise KeyError('Supply exactly one of the following: a badge ID, a badge name') 316 | 317 | def privileges(self, **kw): 318 | """Returns all the privileges a user can have on the site.""" 319 | return self.build('privileges', Privilege, 'privileges', kw) 320 | 321 | def all_nontag_badges(self, **kw): 322 | """Returns the set of all badges which are not tag-based.""" 323 | return self.build('badges/name', Badge, 'badges', kw) 324 | 325 | def all_tag_badges(self, **kw): 326 | """Returns the set of all the tag-based badges: those which are awarded for performance on a specific tag.""" 327 | return self.build('badges/tags', Badge, 'badges', kw) 328 | 329 | def all_tags(self, **kw): 330 | '''Returns the set of all tags on the site.''' 331 | return self.build('tags', Tag, 'tags', kw) 332 | 333 | def info(self, **kw): 334 | '''Returns statistical information and metadata about the site, such as the total number of questions. 335 | 336 | Call with site=True to receive a SiteDefinition object representing this site in the site_definition field of the result.''' 337 | if kw.get('site'): 338 | # we need to remove site as a query parameter anyway to stop API 339 | # getting confused 340 | del kw['site'] 341 | if 'filter' not in kw: 342 | kw['filter'] = '!9YdnSFfpS' 343 | 344 | return self.build('info', Statistics, 'statistics', kw)[0] 345 | 346 | def stats(self, *a, **kw): 347 | '''An alias for Site.info().''' 348 | # this is just an alias to info(), since the method name has changed since 349 | return self.info(*a, **kw) 350 | 351 | def revision(self, post, guid, **kw): 352 | real_id = post.id if isinstance(post, Question) or isinstance(post, Answer) else post 353 | return self.build('revisions/%d/%s' % (real_id, guid), PostRevision, 'revisions', kw)[0] 354 | 355 | def revisions(self, post, **kw): 356 | return self.build('revisions/' + self.vectorise(post, (Question, Answer)), PostRevision, 'revisions', kw) 357 | 358 | def search(self, **kw): 359 | return self.build('search', Question, 'questions', kw) 360 | 361 | def search_advanced(self, **kw): 362 | kw['body'] = None 363 | kw['comments'] = None 364 | return self.build('search/advanced', Question, 'questions', kw) 365 | 366 | def similar(self, title, tagged = None, nottagged = None, **kw): 367 | if 'answers' not in kw: 368 | kw['answers'] = True 369 | if tagged is not None: 370 | kw['tagged'] = self.vectorise(tagged, Tag) 371 | if nottagged is not None: 372 | kw['nottagged'] = self.vectorise(nottagged, Tag) 373 | 374 | kw['title'] = title 375 | return self.build('similar', Question, 'questions', kw) 376 | 377 | def tags(self, **kw): 378 | return self.build('tags', Tag, 'tags', kw) 379 | 380 | def tag(self, tag, **kw): 381 | return self.build('tags/%s/info' % tag, Tag, 'tags', kw)[0] 382 | 383 | def tag_wiki(self, tag, **kw): 384 | return self.build('tags/%s/wikis' % tag, TagWiki, 'tags', kw) 385 | 386 | def tag_related(self, tag, **kw): 387 | return self.build('tags/%s/related' % tag, Tag, 'tags', kw) 388 | 389 | def tag_synonyms(self, **kw): 390 | return self.build('tags/synonyms', TagSynonym, 'tag_synonyms', kw) 391 | 392 | def error(self, id, **kw): 393 | # for some reason, the SE API couldn't possible just ignore site= 394 | kw['site'] = None 395 | return self._request('errors/%d' % id, kw) 396 | 397 | def __add__(self, other): 398 | if isinstance(other, Site): 399 | return CompositeSite(self, other) 400 | else: 401 | raise NotImplemented 402 | 403 | class CompositeSite(object): 404 | def __init__(self, s1, s2): 405 | self.site_one = s1 406 | self.site_two = s2 407 | 408 | def __getattr__(self, a): 409 | if hasattr(self.site_one, a) and hasattr(self.site_two, a) and callable(getattr(self.site_one, a)): 410 | def handle(*ps, **kws): 411 | res1 = getattr(self.site_one, a)(*ps, **kws) 412 | res2 = getattr(self.site_two, a)(*ps, **kws) 413 | 414 | if hasattr(res1, '__iter__') and hasattr(res2, '__iter__'): 415 | return res1 + res2 416 | else: 417 | return (res1, res2) 418 | 419 | return handle 420 | else: 421 | raise AttributeError(a) 422 | 423 | def __sub__(self, other): 424 | if other is self.site_one: 425 | return self.site_two 426 | elif other is self.site_two: 427 | return self.site_one 428 | else: 429 | raise NotImplemented 430 | -------------------------------------------------------------------------------- /stackexchange/sites.py: -------------------------------------------------------------------------------- 1 | import stackexchange 2 | class __SEAPI(str): 3 | def __call__(self): 4 | return stackexchange.Site(self) 5 | StackOverflow = __SEAPI('stackoverflow.com') 6 | ServerFault = __SEAPI('serverfault.com') 7 | SuperUser = __SEAPI('superuser.com') 8 | MetaStackExchange = __SEAPI('meta.stackexchange.com') 9 | WebApplications = __SEAPI('webapps.stackexchange.com') 10 | WebApplicationsMeta = __SEAPI('meta.webapps.stackexchange.com') 11 | Arqade = __SEAPI('gaming.stackexchange.com') 12 | ArqadeMeta = __SEAPI('meta.gaming.stackexchange.com') 13 | Webmasters = __SEAPI('webmasters.stackexchange.com') 14 | WebmastersMeta = __SEAPI('meta.webmasters.stackexchange.com') 15 | SeasonedAdvice = __SEAPI('cooking.stackexchange.com') 16 | SeasonedAdviceMeta = __SEAPI('meta.cooking.stackexchange.com') 17 | GameDevelopment = __SEAPI('gamedev.stackexchange.com') 18 | GameDevelopmentMeta = __SEAPI('meta.gamedev.stackexchange.com') 19 | Photography = __SEAPI('photo.stackexchange.com') 20 | PhotographyMeta = __SEAPI('meta.photo.stackexchange.com') 21 | CrossValidated = __SEAPI('stats.stackexchange.com') 22 | CrossValidatedMeta = __SEAPI('meta.stats.stackexchange.com') 23 | Mathematics = __SEAPI('math.stackexchange.com') 24 | MathematicsMeta = __SEAPI('meta.math.stackexchange.com') 25 | HomeImprovement = __SEAPI('diy.stackexchange.com') 26 | HomeImprovementMeta = __SEAPI('meta.diy.stackexchange.com') 27 | MetaSuperUser = __SEAPI('meta.superuser.com') 28 | MetaServerFault = __SEAPI('meta.serverfault.com') 29 | GeographicInformationSystems = __SEAPI('gis.stackexchange.com') 30 | GeographicInformationSystemsMeta = __SEAPI('meta.gis.stackexchange.com') 31 | TeXLaTeX = __SEAPI('tex.stackexchange.com') 32 | TeXLaTeXMeta = __SEAPI('meta.tex.stackexchange.com') 33 | AskUbuntu = __SEAPI('askubuntu.com') 34 | AskUbuntuMeta = __SEAPI('meta.askubuntu.com') 35 | PersonalFinanceampMoney = __SEAPI('money.stackexchange.com') 36 | PersonalFinanceampMoneyMeta = __SEAPI('meta.money.stackexchange.com') 37 | EnglishLanguageampUsage = __SEAPI('english.stackexchange.com') 38 | EnglishLanguageampUsageMeta = __SEAPI('meta.english.stackexchange.com') 39 | StackApps = __SEAPI('stackapps.com') 40 | UserExperience = __SEAPI('ux.stackexchange.com') 41 | UserExperienceMeta = __SEAPI('meta.ux.stackexchange.com') 42 | UnixampLinux = __SEAPI('unix.stackexchange.com') 43 | UnixampLinuxMeta = __SEAPI('meta.unix.stackexchange.com') 44 | WordPressDevelopment = __SEAPI('wordpress.stackexchange.com') 45 | WordPressDevelopmentMeta = __SEAPI('meta.wordpress.stackexchange.com') 46 | TheoreticalComputerScience = __SEAPI('cstheory.stackexchange.com') 47 | TheoreticalComputerScienceMeta = __SEAPI('meta.cstheory.stackexchange.com') 48 | AskDifferent = __SEAPI('apple.stackexchange.com') 49 | AskDifferentMeta = __SEAPI('meta.apple.stackexchange.com') 50 | RoleplayingGames = __SEAPI('rpg.stackexchange.com') 51 | RoleplayingGamesMeta = __SEAPI('meta.rpg.stackexchange.com') 52 | Bicycles = __SEAPI('bicycles.stackexchange.com') 53 | BicyclesMeta = __SEAPI('meta.bicycles.stackexchange.com') 54 | Programmers = __SEAPI('programmers.stackexchange.com') 55 | ProgrammersMeta = __SEAPI('meta.programmers.stackexchange.com') 56 | ElectricalEngineering = __SEAPI('electronics.stackexchange.com') 57 | ElectricalEngineeringMeta = __SEAPI('meta.electronics.stackexchange.com') 58 | AndroidEnthusiasts = __SEAPI('android.stackexchange.com') 59 | AndroidEnthusiastsMeta = __SEAPI('meta.android.stackexchange.com') 60 | BoardampCardGames = __SEAPI('boardgames.stackexchange.com') 61 | BoardampCardGamesMeta = __SEAPI('meta.boardgames.stackexchange.com') 62 | Physics = __SEAPI('physics.stackexchange.com') 63 | PhysicsMeta = __SEAPI('meta.physics.stackexchange.com') 64 | Homebrewing = __SEAPI('homebrew.stackexchange.com') 65 | HomebrewingMeta = __SEAPI('meta.homebrew.stackexchange.com') 66 | InformationSecurity = __SEAPI('security.stackexchange.com') 67 | InformationSecurityMeta = __SEAPI('meta.security.stackexchange.com') 68 | Writers = __SEAPI('writers.stackexchange.com') 69 | WritersMeta = __SEAPI('meta.writers.stackexchange.com') 70 | VideoProduction = __SEAPI('video.stackexchange.com') 71 | VideoProductionMeta = __SEAPI('meta.video.stackexchange.com') 72 | GraphicDesign = __SEAPI('graphicdesign.stackexchange.com') 73 | GraphicDesignMeta = __SEAPI('meta.graphicdesign.stackexchange.com') 74 | DatabaseAdministrators = __SEAPI('dba.stackexchange.com') 75 | DatabaseAdministratorsMeta = __SEAPI('meta.dba.stackexchange.com') 76 | ScienceFictionampFantasy = __SEAPI('scifi.stackexchange.com') 77 | ScienceFictionampFantasyMeta = __SEAPI('meta.scifi.stackexchange.com') 78 | CodeReview = __SEAPI('codereview.stackexchange.com') 79 | CodeReviewMeta = __SEAPI('meta.codereview.stackexchange.com') 80 | ProgrammingPuzzlesampCodeGolf = __SEAPI('codegolf.stackexchange.com') 81 | ProgrammingPuzzlesampCodeGolfMeta = __SEAPI('meta.codegolf.stackexchange.com') 82 | QuantitativeFinance = __SEAPI('quant.stackexchange.com') 83 | QuantitativeFinanceMeta = __SEAPI('meta.quant.stackexchange.com') 84 | ProjectManagement = __SEAPI('pm.stackexchange.com') 85 | ProjectManagementMeta = __SEAPI('meta.pm.stackexchange.com') 86 | Skeptics = __SEAPI('skeptics.stackexchange.com') 87 | SkepticsMeta = __SEAPI('meta.skeptics.stackexchange.com') 88 | PhysicalFitness = __SEAPI('fitness.stackexchange.com') 89 | PhysicalFitnessMeta = __SEAPI('meta.fitness.stackexchange.com') 90 | DrupalAnswers = __SEAPI('drupal.stackexchange.com') 91 | DrupalAnswersMeta = __SEAPI('meta.drupal.stackexchange.com') 92 | MotorVehicleMaintenanceampRepair = __SEAPI('mechanics.stackexchange.com') 93 | MotorVehicleMaintenanceampRepairMeta = __SEAPI('meta.mechanics.stackexchange.com') 94 | Parenting = __SEAPI('parenting.stackexchange.com') 95 | ParentingMeta = __SEAPI('meta.parenting.stackexchange.com') 96 | SharePoint = __SEAPI('sharepoint.stackexchange.com') 97 | SharePointMeta = __SEAPI('meta.sharepoint.stackexchange.com') 98 | MusicPracticeampTheory = __SEAPI('music.stackexchange.com') 99 | MusicPracticeampTheoryMeta = __SEAPI('meta.music.stackexchange.com') 100 | SoftwareQualityAssuranceampTesting = __SEAPI('sqa.stackexchange.com') 101 | SoftwareQualityAssuranceampTestingMeta = __SEAPI('meta.sqa.stackexchange.com') 102 | MiYodeya = __SEAPI('judaism.stackexchange.com') 103 | MiYodeyaMeta = __SEAPI('meta.judaism.stackexchange.com') 104 | GermanLanguage = __SEAPI('german.stackexchange.com') 105 | GermanLanguageMeta = __SEAPI('meta.german.stackexchange.com') 106 | JapaneseLanguage = __SEAPI('japanese.stackexchange.com') 107 | JapaneseLanguageMeta = __SEAPI('meta.japanese.stackexchange.com') 108 | Philosophy = __SEAPI('philosophy.stackexchange.com') 109 | PhilosophyMeta = __SEAPI('meta.philosophy.stackexchange.com') 110 | GardeningampLandscaping = __SEAPI('gardening.stackexchange.com') 111 | GardeningampLandscapingMeta = __SEAPI('meta.gardening.stackexchange.com') 112 | Travel = __SEAPI('travel.stackexchange.com') 113 | TravelMeta = __SEAPI('meta.travel.stackexchange.com') 114 | PersonalProductivity = __SEAPI('productivity.stackexchange.com') 115 | PersonalProductivityMeta = __SEAPI('meta.productivity.stackexchange.com') 116 | Cryptography = __SEAPI('crypto.stackexchange.com') 117 | CryptographyMeta = __SEAPI('meta.crypto.stackexchange.com') 118 | SignalProcessing = __SEAPI('dsp.stackexchange.com') 119 | SignalProcessingMeta = __SEAPI('meta.dsp.stackexchange.com') 120 | FrenchLanguage = __SEAPI('french.stackexchange.com') 121 | FrenchLanguageMeta = __SEAPI('meta.french.stackexchange.com') 122 | Christianity = __SEAPI('christianity.stackexchange.com') 123 | ChristianityMeta = __SEAPI('meta.christianity.stackexchange.com') 124 | Bitcoin = __SEAPI('bitcoin.stackexchange.com') 125 | StackOverflow = __SEAPI('stackoverflow.com') 126 | ServerFault = __SEAPI('serverfault.com') 127 | SuperUser = __SEAPI('superuser.com') 128 | MetaStackExchange = __SEAPI('meta.stackexchange.com') 129 | WebApplications = __SEAPI('webapps.stackexchange.com') 130 | WebApplicationsMeta = __SEAPI('meta.webapps.stackexchange.com') 131 | Arqade = __SEAPI('gaming.stackexchange.com') 132 | ArqadeMeta = __SEAPI('meta.gaming.stackexchange.com') 133 | Webmasters = __SEAPI('webmasters.stackexchange.com') 134 | WebmastersMeta = __SEAPI('meta.webmasters.stackexchange.com') 135 | SeasonedAdvice = __SEAPI('cooking.stackexchange.com') 136 | SeasonedAdviceMeta = __SEAPI('meta.cooking.stackexchange.com') 137 | GameDevelopment = __SEAPI('gamedev.stackexchange.com') 138 | GameDevelopmentMeta = __SEAPI('meta.gamedev.stackexchange.com') 139 | Photography = __SEAPI('photo.stackexchange.com') 140 | PhotographyMeta = __SEAPI('meta.photo.stackexchange.com') 141 | CrossValidated = __SEAPI('stats.stackexchange.com') 142 | CrossValidatedMeta = __SEAPI('meta.stats.stackexchange.com') 143 | Mathematics = __SEAPI('math.stackexchange.com') 144 | MathematicsMeta = __SEAPI('meta.math.stackexchange.com') 145 | HomeImprovement = __SEAPI('diy.stackexchange.com') 146 | HomeImprovementMeta = __SEAPI('meta.diy.stackexchange.com') 147 | MetaSuperUser = __SEAPI('meta.superuser.com') 148 | MetaServerFault = __SEAPI('meta.serverfault.com') 149 | GeographicInformationSystems = __SEAPI('gis.stackexchange.com') 150 | GeographicInformationSystemsMeta = __SEAPI('meta.gis.stackexchange.com') 151 | TeXLaTeX = __SEAPI('tex.stackexchange.com') 152 | TeXLaTeXMeta = __SEAPI('meta.tex.stackexchange.com') 153 | AskUbuntu = __SEAPI('askubuntu.com') 154 | AskUbuntuMeta = __SEAPI('meta.askubuntu.com') 155 | PersonalFinanceampMoney = __SEAPI('money.stackexchange.com') 156 | PersonalFinanceampMoneyMeta = __SEAPI('meta.money.stackexchange.com') 157 | EnglishLanguageampUsage = __SEAPI('english.stackexchange.com') 158 | EnglishLanguageampUsageMeta = __SEAPI('meta.english.stackexchange.com') 159 | StackApps = __SEAPI('stackapps.com') 160 | UserExperience = __SEAPI('ux.stackexchange.com') 161 | UserExperienceMeta = __SEAPI('meta.ux.stackexchange.com') 162 | UnixampLinux = __SEAPI('unix.stackexchange.com') 163 | UnixampLinuxMeta = __SEAPI('meta.unix.stackexchange.com') 164 | WordPressDevelopment = __SEAPI('wordpress.stackexchange.com') 165 | WordPressDevelopmentMeta = __SEAPI('meta.wordpress.stackexchange.com') 166 | TheoreticalComputerScience = __SEAPI('cstheory.stackexchange.com') 167 | TheoreticalComputerScienceMeta = __SEAPI('meta.cstheory.stackexchange.com') 168 | AskDifferent = __SEAPI('apple.stackexchange.com') 169 | AskDifferentMeta = __SEAPI('meta.apple.stackexchange.com') 170 | RoleplayingGames = __SEAPI('rpg.stackexchange.com') 171 | RoleplayingGamesMeta = __SEAPI('meta.rpg.stackexchange.com') 172 | Bicycles = __SEAPI('bicycles.stackexchange.com') 173 | BicyclesMeta = __SEAPI('meta.bicycles.stackexchange.com') 174 | Programmers = __SEAPI('programmers.stackexchange.com') 175 | ProgrammersMeta = __SEAPI('meta.programmers.stackexchange.com') 176 | ElectricalEngineering = __SEAPI('electronics.stackexchange.com') 177 | ElectricalEngineeringMeta = __SEAPI('meta.electronics.stackexchange.com') 178 | AndroidEnthusiasts = __SEAPI('android.stackexchange.com') 179 | AndroidEnthusiastsMeta = __SEAPI('meta.android.stackexchange.com') 180 | BoardampCardGames = __SEAPI('boardgames.stackexchange.com') 181 | BoardampCardGamesMeta = __SEAPI('meta.boardgames.stackexchange.com') 182 | Physics = __SEAPI('physics.stackexchange.com') 183 | PhysicsMeta = __SEAPI('meta.physics.stackexchange.com') 184 | Homebrewing = __SEAPI('homebrew.stackexchange.com') 185 | HomebrewingMeta = __SEAPI('meta.homebrew.stackexchange.com') 186 | InformationSecurity = __SEAPI('security.stackexchange.com') 187 | InformationSecurityMeta = __SEAPI('meta.security.stackexchange.com') 188 | Writers = __SEAPI('writers.stackexchange.com') 189 | WritersMeta = __SEAPI('meta.writers.stackexchange.com') 190 | VideoProduction = __SEAPI('video.stackexchange.com') 191 | VideoProductionMeta = __SEAPI('meta.video.stackexchange.com') 192 | GraphicDesign = __SEAPI('graphicdesign.stackexchange.com') 193 | GraphicDesignMeta = __SEAPI('meta.graphicdesign.stackexchange.com') 194 | DatabaseAdministrators = __SEAPI('dba.stackexchange.com') 195 | DatabaseAdministratorsMeta = __SEAPI('meta.dba.stackexchange.com') 196 | ScienceFictionampFantasy = __SEAPI('scifi.stackexchange.com') 197 | ScienceFictionampFantasyMeta = __SEAPI('meta.scifi.stackexchange.com') 198 | CodeReview = __SEAPI('codereview.stackexchange.com') 199 | CodeReviewMeta = __SEAPI('meta.codereview.stackexchange.com') 200 | ProgrammingPuzzlesampCodeGolf = __SEAPI('codegolf.stackexchange.com') 201 | ProgrammingPuzzlesampCodeGolfMeta = __SEAPI('meta.codegolf.stackexchange.com') 202 | QuantitativeFinance = __SEAPI('quant.stackexchange.com') 203 | QuantitativeFinanceMeta = __SEAPI('meta.quant.stackexchange.com') 204 | ProjectManagement = __SEAPI('pm.stackexchange.com') 205 | ProjectManagementMeta = __SEAPI('meta.pm.stackexchange.com') 206 | Skeptics = __SEAPI('skeptics.stackexchange.com') 207 | SkepticsMeta = __SEAPI('meta.skeptics.stackexchange.com') 208 | PhysicalFitness = __SEAPI('fitness.stackexchange.com') 209 | PhysicalFitnessMeta = __SEAPI('meta.fitness.stackexchange.com') 210 | DrupalAnswers = __SEAPI('drupal.stackexchange.com') 211 | DrupalAnswersMeta = __SEAPI('meta.drupal.stackexchange.com') 212 | MotorVehicleMaintenanceampRepair = __SEAPI('mechanics.stackexchange.com') 213 | MotorVehicleMaintenanceampRepairMeta = __SEAPI('meta.mechanics.stackexchange.com') 214 | Parenting = __SEAPI('parenting.stackexchange.com') 215 | ParentingMeta = __SEAPI('meta.parenting.stackexchange.com') 216 | SharePoint = __SEAPI('sharepoint.stackexchange.com') 217 | SharePointMeta = __SEAPI('meta.sharepoint.stackexchange.com') 218 | MusicPracticeampTheory = __SEAPI('music.stackexchange.com') 219 | MusicPracticeampTheoryMeta = __SEAPI('meta.music.stackexchange.com') 220 | SoftwareQualityAssuranceampTesting = __SEAPI('sqa.stackexchange.com') 221 | SoftwareQualityAssuranceampTestingMeta = __SEAPI('meta.sqa.stackexchange.com') 222 | MiYodeya = __SEAPI('judaism.stackexchange.com') 223 | MiYodeyaMeta = __SEAPI('meta.judaism.stackexchange.com') 224 | GermanLanguage = __SEAPI('german.stackexchange.com') 225 | GermanLanguageMeta = __SEAPI('meta.german.stackexchange.com') 226 | JapaneseLanguage = __SEAPI('japanese.stackexchange.com') 227 | JapaneseLanguageMeta = __SEAPI('meta.japanese.stackexchange.com') 228 | Philosophy = __SEAPI('philosophy.stackexchange.com') 229 | PhilosophyMeta = __SEAPI('meta.philosophy.stackexchange.com') 230 | GardeningampLandscaping = __SEAPI('gardening.stackexchange.com') 231 | GardeningampLandscapingMeta = __SEAPI('meta.gardening.stackexchange.com') 232 | Travel = __SEAPI('travel.stackexchange.com') 233 | TravelMeta = __SEAPI('meta.travel.stackexchange.com') 234 | PersonalProductivity = __SEAPI('productivity.stackexchange.com') 235 | PersonalProductivityMeta = __SEAPI('meta.productivity.stackexchange.com') 236 | Cryptography = __SEAPI('crypto.stackexchange.com') 237 | CryptographyMeta = __SEAPI('meta.crypto.stackexchange.com') 238 | SignalProcessing = __SEAPI('dsp.stackexchange.com') 239 | SignalProcessingMeta = __SEAPI('meta.dsp.stackexchange.com') 240 | FrenchLanguage = __SEAPI('french.stackexchange.com') 241 | FrenchLanguageMeta = __SEAPI('meta.french.stackexchange.com') 242 | Christianity = __SEAPI('christianity.stackexchange.com') 243 | ChristianityMeta = __SEAPI('meta.christianity.stackexchange.com') 244 | Bitcoin = __SEAPI('bitcoin.stackexchange.com') 245 | BitcoinMeta = __SEAPI('meta.bitcoin.stackexchange.com') 246 | Linguistics = __SEAPI('linguistics.stackexchange.com') 247 | LinguisticsMeta = __SEAPI('meta.linguistics.stackexchange.com') 248 | BiblicalHermeneutics = __SEAPI('hermeneutics.stackexchange.com') 249 | BiblicalHermeneuticsMeta = __SEAPI('meta.hermeneutics.stackexchange.com') 250 | History = __SEAPI('history.stackexchange.com') 251 | HistoryMeta = __SEAPI('meta.history.stackexchange.com') 252 | LEGO174Answers = __SEAPI('bricks.stackexchange.com') 253 | LEGO174AnswersMeta = __SEAPI('meta.bricks.stackexchange.com') 254 | SpanishLanguage = __SEAPI('spanish.stackexchange.com') 255 | SpanishLanguageMeta = __SEAPI('meta.spanish.stackexchange.com') 256 | ComputationalScience = __SEAPI('scicomp.stackexchange.com') 257 | ComputationalScienceMeta = __SEAPI('meta.scicomp.stackexchange.com') 258 | MoviesampTV = __SEAPI('movies.stackexchange.com') 259 | MoviesampTVMeta = __SEAPI('meta.movies.stackexchange.com') 260 | ChineseLanguage = __SEAPI('chinese.stackexchange.com') 261 | ChineseLanguageMeta = __SEAPI('meta.chinese.stackexchange.com') 262 | Biology = __SEAPI('biology.stackexchange.com') 263 | BiologyMeta = __SEAPI('meta.biology.stackexchange.com') 264 | Poker = __SEAPI('poker.stackexchange.com') 265 | PokerMeta = __SEAPI('meta.poker.stackexchange.com') 266 | Mathematica = __SEAPI('mathematica.stackexchange.com') 267 | MathematicaMeta = __SEAPI('meta.mathematica.stackexchange.com') 268 | CognitiveSciences = __SEAPI('cogsci.stackexchange.com') 269 | CognitiveSciencesMeta = __SEAPI('meta.cogsci.stackexchange.com') 270 | TheGreatOutdoors = __SEAPI('outdoors.stackexchange.com') 271 | TheGreatOutdoorsMeta = __SEAPI('meta.outdoors.stackexchange.com') 272 | MartialArts = __SEAPI('martialarts.stackexchange.com') 273 | MartialArtsMeta = __SEAPI('meta.martialarts.stackexchange.com') 274 | Sports = __SEAPI('sports.stackexchange.com') 275 | SportsMeta = __SEAPI('meta.sports.stackexchange.com') 276 | Academia = __SEAPI('academia.stackexchange.com') 277 | AcademiaMeta = __SEAPI('meta.academia.stackexchange.com') 278 | ComputerScience = __SEAPI('cs.stackexchange.com') 279 | ComputerScienceMeta = __SEAPI('meta.cs.stackexchange.com') 280 | TheWorkplace = __SEAPI('workplace.stackexchange.com') 281 | TheWorkplaceMeta = __SEAPI('meta.workplace.stackexchange.com') 282 | WindowsPhone = __SEAPI('windowsphone.stackexchange.com') 283 | WindowsPhoneMeta = __SEAPI('meta.windowsphone.stackexchange.com') 284 | Chemistry = __SEAPI('chemistry.stackexchange.com') 285 | ChemistryMeta = __SEAPI('meta.chemistry.stackexchange.com') 286 | Chess = __SEAPI('chess.stackexchange.com') 287 | ChessMeta = __SEAPI('meta.chess.stackexchange.com') 288 | RaspberryPi = __SEAPI('raspberrypi.stackexchange.com') 289 | RaspberryPiMeta = __SEAPI('meta.raspberrypi.stackexchange.com') 290 | RussianLanguage = __SEAPI('russian.stackexchange.com') 291 | RussianLanguageMeta = __SEAPI('meta.russian.stackexchange.com') 292 | Islam = __SEAPI('islam.stackexchange.com') 293 | IslamMeta = __SEAPI('meta.islam.stackexchange.com') 294 | Salesforce = __SEAPI('salesforce.stackexchange.com') 295 | SalesforceMeta = __SEAPI('meta.salesforce.stackexchange.com') 296 | AskPatents = __SEAPI('patents.stackexchange.com') 297 | AskPatentsMeta = __SEAPI('meta.patents.stackexchange.com') 298 | GenealogyampFamilyHistory = __SEAPI('genealogy.stackexchange.com') 299 | GenealogyampFamilyHistoryMeta = __SEAPI('meta.genealogy.stackexchange.com') 300 | Robotics = __SEAPI('robotics.stackexchange.com') 301 | RoboticsMeta = __SEAPI('meta.robotics.stackexchange.com') 302 | ExpressionEngine174Answers = __SEAPI('expressionengine.stackexchange.com') 303 | ExpressionEngine174AnswersMeta = __SEAPI('meta.expressionengine.stackexchange.com') 304 | Politics = __SEAPI('politics.stackexchange.com') 305 | PoliticsMeta = __SEAPI('meta.politics.stackexchange.com') 306 | AnimeampManga = __SEAPI('anime.stackexchange.com') 307 | AnimeampMangaMeta = __SEAPI('meta.anime.stackexchange.com') 308 | Magento = __SEAPI('magento.stackexchange.com') 309 | MagentoMeta = __SEAPI('meta.magento.stackexchange.com') 310 | EnglishLanguageLearners = __SEAPI('ell.stackexchange.com') 311 | EnglishLanguageLearnersMeta = __SEAPI('meta.ell.stackexchange.com') 312 | SustainableLiving = __SEAPI('sustainability.stackexchange.com') 313 | SustainableLivingMeta = __SEAPI('meta.sustainability.stackexchange.com') 314 | Tridion = __SEAPI('tridion.stackexchange.com') 315 | TridionMeta = __SEAPI('meta.tridion.stackexchange.com') 316 | ReverseEngineering = __SEAPI('reverseengineering.stackexchange.com') 317 | ReverseEngineeringMeta = __SEAPI('meta.reverseengineering.stackexchange.com') 318 | NetworkEngineering = __SEAPI('networkengineering.stackexchange.com') 319 | NetworkEngineeringMeta = __SEAPI('meta.networkengineering.stackexchange.com') 320 | OpenData = __SEAPI('opendata.stackexchange.com') 321 | OpenDataMeta = __SEAPI('meta.opendata.stackexchange.com') 322 | Freelancing = __SEAPI('freelancing.stackexchange.com') 323 | FreelancingMeta = __SEAPI('meta.freelancing.stackexchange.com') 324 | Blender = __SEAPI('blender.stackexchange.com') 325 | BlenderMeta = __SEAPI('meta.blender.stackexchange.com') 326 | MathOverflow = __SEAPI('mathoverflow.net') 327 | MathOverflowMeta = __SEAPI('meta.mathoverflow.net') 328 | SpaceExploration = __SEAPI('space.stackexchange.com') 329 | SpaceExplorationMeta = __SEAPI('meta.space.stackexchange.com') 330 | SoundDesign = __SEAPI('sound.stackexchange.com') 331 | SoundDesignMeta = __SEAPI('meta.sound.stackexchange.com') 332 | Astronomy = __SEAPI('astronomy.stackexchange.com') 333 | AstronomyMeta = __SEAPI('meta.astronomy.stackexchange.com') 334 | Tor = __SEAPI('tor.stackexchange.com') 335 | TorMeta = __SEAPI('meta.tor.stackexchange.com') 336 | Pets = __SEAPI('pets.stackexchange.com') 337 | PetsMeta = __SEAPI('meta.pets.stackexchange.com') 338 | AmateurRadio = __SEAPI('ham.stackexchange.com') 339 | AmateurRadioMeta = __SEAPI('meta.ham.stackexchange.com') 340 | ItalianLanguage = __SEAPI('italian.stackexchange.com') 341 | ItalianLanguageMeta = __SEAPI('meta.italian.stackexchange.com') 342 | StackOverflowemPortugu234s = __SEAPI('pt.stackoverflow.com') 343 | StackOverflowemPortugu234sMeta = __SEAPI('meta.pt.stackoverflow.com') 344 | Aviation = __SEAPI('aviation.stackexchange.com') 345 | AviationMeta = __SEAPI('meta.aviation.stackexchange.com') 346 | Ebooks = __SEAPI('ebooks.stackexchange.com') 347 | EbooksMeta = __SEAPI('meta.ebooks.stackexchange.com') 348 | Beer = __SEAPI('beer.stackexchange.com') 349 | BeerMeta = __SEAPI('meta.beer.stackexchange.com') 350 | SoftwareRecommendations = __SEAPI('softwarerecs.stackexchange.com') 351 | SoftwareRecommendationsMeta = __SEAPI('meta.softwarerecs.stackexchange.com') 352 | Arduino = __SEAPI('arduino.stackexchange.com') 353 | ArduinoMeta = __SEAPI('meta.arduino.stackexchange.com') 354 | CS50 = __SEAPI('cs50.stackexchange.com') 355 | CS50Meta = __SEAPI('meta.cs50.stackexchange.com') 356 | edxcs1691x = __SEAPI('edx-cs169-1x.stackexchange.com') 357 | edxcs1691xMeta = __SEAPI('meta.edx-cs169-1x.stackexchange.com') 358 | Expatriates = __SEAPI('expatriates.stackexchange.com') 359 | ExpatriatesMeta = __SEAPI('meta.expatriates.stackexchange.com') 360 | MathematicsEducators = __SEAPI('matheducators.stackexchange.com') 361 | MathematicsEducatorsMeta = __SEAPI('meta.matheducators.stackexchange.com') 362 | MetaStackOverflow = __SEAPI('meta.stackoverflow.com') 363 | EarthScience = __SEAPI('earthscience.stackexchange.com') 364 | EarthScienceMeta = __SEAPI('meta.earthscience.stackexchange.com') 365 | -------------------------------------------------------------------------------- /stackexchange/web.py: -------------------------------------------------------------------------------- 1 | # stackweb.py - Core classes for web-request stuff 2 | from __future__ import print_function 3 | 4 | from stackexchange.core import StackExchangeError 5 | from six.moves import urllib 6 | import datetime, operator, io, gzip, time 7 | import datetime 8 | try: 9 | import json 10 | except ImportError: 11 | import simplejson as json 12 | 13 | class TooManyRequestsError(Exception): 14 | def __str__(self): 15 | return "More than 30 requests have been made in the last five seconds." 16 | 17 | class WebRequest(object): 18 | data = '' 19 | info = None 20 | 21 | def __init__(self, data, info): 22 | self.data = data 23 | self.info = info 24 | 25 | def __str__(self): 26 | return str(self.data) 27 | 28 | class WebRequestManager(object): 29 | debug = False 30 | cache = {} 31 | 32 | def __init__(self, impose_throttling = False, throttle_stop = True, cache = True, cache_age = 1800, force_http = False): 33 | # Whether to monitor requests for overuse of the API 34 | self.impose_throttling = impose_throttling 35 | # Whether to throw an error (when True) if the limit is reached, or wait until another request 36 | # can be made (when False). 37 | self.throttle_stop = throttle_stop 38 | # Whether to use request caching. 39 | self.do_cache = cache 40 | # The time, in seconds, for which to cache a response 41 | self.cache_age = cache_age 42 | # The time at which we should resume making requests after receiving a 'backoff' for each method 43 | self.backoff_expires = {} 44 | # Force the use of HTTP instead of HTTPS 45 | self.force_http = force_http 46 | 47 | # When we last made a request 48 | window = datetime.datetime.now() 49 | # Number of requests since last throttle window 50 | num_requests = 0 51 | 52 | def debug_print(self, *p): 53 | if WebRequestManager.debug: 54 | print(' '.join([x if isinstance(x, str) else repr(x) for x in p])) 55 | 56 | def canon_method_name(self, url): 57 | # Take the URL relative to the domain, without initial / or parameters 58 | parsed = urllib.parse.urlparse(url) 59 | return '/'.join(parsed.path.split('/')[1:]) 60 | 61 | def request(self, url, params): 62 | now = datetime.datetime.now() 63 | 64 | # Quote URL fields (mostly for 'c#'), but not : in http:// 65 | components = url.split('/') 66 | url = components[0] + '/' + ('/'.join(urllib.parse.quote(path) for path in components[1:])) 67 | 68 | # Then add in the appropriate protocol 69 | url = '%s://%s' % ('http' if self.force_http else 'https', url) 70 | 71 | done = False 72 | for k, v in params.items(): 73 | if not done: 74 | url += '?' 75 | done = True 76 | else: 77 | url += '&' 78 | 79 | url += '%s=%s' % (k, urllib.parse.quote(str(v).encode('utf-8'))) 80 | 81 | # Now we have the `proper` URL, we can check the cache 82 | if self.do_cache and url in self.cache: 83 | timestamp, data = self.cache[url] 84 | self.debug_print('C>', url, '@', timestamp) 85 | 86 | if (now - timestamp).seconds <= self.cache_age: 87 | self.debug_print('Hit>', url) 88 | return data 89 | 90 | # Before we do the actual request, are we going to be throttled? 91 | def halt(wait_time): 92 | if self.throttle_stop: 93 | raise TooManyRequestsError() 94 | else: 95 | # Wait the required time, plus a bit of extra padding time. 96 | time.sleep(wait_time + 0.1) 97 | 98 | if self.impose_throttling: 99 | # We need to check if we've been told to back off 100 | method = self.canon_method_name(url) 101 | backoff_time = self.backoff_expires.get(method, None) 102 | if backoff_time is not None and backoff_time >= now: 103 | self.debug_print('backoff: %s until %s' % (method, backoff_time)) 104 | halt((now - backoff_time).seconds) 105 | 106 | if (now - WebRequestManager.window).seconds >= 5: 107 | WebRequestManager.window = now 108 | WebRequestManager.num_requests = 0 109 | WebRequestManager.num_requests += 1 110 | if WebRequestManager.num_requests > 30: 111 | halt(5 - (now - WebRequestManager.window).seconds) 112 | 113 | # We definitely do need to go out to the internet, so make the real request 114 | self.debug_print('R>', url) 115 | request = urllib.request.Request(url) 116 | 117 | request.add_header('Accept-encoding', 'gzip') 118 | req_open = urllib.request.build_opener() 119 | 120 | try: 121 | conn = req_open.open(request) 122 | info = conn.info() 123 | req_data = conn.read() 124 | error_code = 200 125 | except urllib.error.HTTPError as e: 126 | # we'll handle the error response later 127 | error_code = e.code 128 | # a hack (headers is an undocumented property), but there's no sensible way to get them 129 | info = getattr(e, 'headers', {}) 130 | req_data = e.read() 131 | 132 | # Handle compressed responses. 133 | # (Stack Exchange's API sends its responses compressed but intermediary 134 | # proxies may send them to us decompressed.) 135 | if info.get('Content-Encoding') == 'gzip': 136 | data_stream = io.BytesIO(req_data) 137 | gzip_stream = gzip.GzipFile(fileobj = data_stream) 138 | 139 | actual_data = gzip_stream.read() 140 | else: 141 | actual_data = req_data 142 | 143 | # Check for errors 144 | if error_code != 200: 145 | try: 146 | error_ob = json.loads(actual_data.decode('utf8')) 147 | except: 148 | raise StackExchangeError() 149 | else: 150 | raise StackExchangeError(error_ob.get('error_id', StackExchangeError.UNKNOWN), error_ob.get('error_name'), error_ob.get('error_message')) 151 | 152 | conn.close() 153 | req_object = WebRequest(actual_data, info) 154 | 155 | # Let's store the response in the cache 156 | if self.do_cache: 157 | self.cache[url] = (now, req_object) 158 | self.debug_print('Store>', url) 159 | 160 | return req_object 161 | 162 | def json_request(self, to, params): 163 | req = self.request(to, params) 164 | parsed_result = json.loads(req.data.decode('utf8')) 165 | 166 | # In API v2.x we now need to respect the 'backoff' warning 167 | if 'backoff' in parsed_result: 168 | method = self.canon_method_name(to) 169 | self.backoff_expires[method] = datetime.datetime.now() + datetime.timedelta(seconds = parsed_result['backoff']) 170 | 171 | return (parsed_result, req.info) 172 | -------------------------------------------------------------------------------- /testsuite.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import datetime, re, stackauth, stackexchange, stackexchange.web, unittest 4 | import stackexchange.sites as stacksites 5 | from stackexchange.core import StackExchangeError 6 | # for Python 3 compatiblity 7 | try: 8 | import htmlentitydefs 9 | except ImportError: 10 | import html.entities as htmlentitydefs 11 | 12 | QUESTION_ID = 4 13 | ANSWER_ID = 98 14 | USER_ID = 23901 15 | API_KEY = 'pXlviKYs*UZIwKLPwJGgpg((' 16 | 17 | _l = logging.getLogger(__name__) 18 | 19 | def _setUp(self): 20 | self.site = stackexchange.Site(stackexchange.StackOverflow, API_KEY, impose_throttling = True) 21 | 22 | stackexchange.web.WebRequestManager.debug = True 23 | 24 | htmlentitydefs.name2codepoint['#39'] = 39 25 | def html_unescape(text): 26 | return re.sub('&(%s);' % '|'.join(htmlentitydefs.name2codepoint), 27 | lambda m: unichr(htmlentitydefs.name2codepoint[m.group(1)]), text) 28 | 29 | class DataTests(unittest.TestCase): 30 | def setUp(self): 31 | _setUp(self) 32 | 33 | def test_fetch_paged(self): 34 | user = stackexchange.Site(stackexchange.Programmers, API_KEY).user(USER_ID) 35 | 36 | answers = user.answers.fetch(pagesize=60) 37 | for answer in answers: 38 | # dummy assert.. we're really testing paging here to make sure it doesn't get 39 | # stuck in an infinite loop. there very well may be a better way of testing this, 40 | # but it's been a long day and this does the trick 41 | # this used to test for title's presence, but title has been removed from the 42 | # default filter 43 | self.assertTrue(answer.id is not None) 44 | 45 | def test_fetch_question(self): 46 | s = self.site.question(QUESTION_ID) 47 | self.assertEqual(html_unescape(s.title), u"While applying opacity to a form should we use a decimal or double value?") 48 | 49 | def test_fetch_answer(self): 50 | s = self.site.answer(ANSWER_ID) 51 | 52 | def test_fetch_answer_owner(self): 53 | s = self.site.answer(ANSWER_ID) 54 | self.assertIsInstance(s.owner_id, int) 55 | self.assertIsNotNone(s.owner) 56 | 57 | def test_fetch_answer_question(self): 58 | s = self.site.answer(ANSWER_ID) 59 | self.assertIsInstance(s.question_id, int) 60 | self.assertIsNotNone(s.question) 61 | 62 | def test_fetch_answer_comment(self): 63 | # First try the comments on an answer with lots of comments 64 | # http://stackoverflow.com/a/22389702 65 | s = self.site.answer(22389702) 66 | s.comments.fetch() 67 | first_comment = s.comments[0] 68 | self.assertNotEqual(first_comment, None) 69 | self.assertTrue(first_comment.body) 70 | 71 | def test_fetch_question_comment(self): 72 | # Now try a question 73 | # http://stackoverflow.com/a/22342854 74 | s = self.site.question(22342854) 75 | s.comments.fetch() 76 | first_comment = s.comments[0] 77 | self.assertNotEqual(first_comment, None) 78 | self.assertTrue(first_comment.body) 79 | 80 | def test_post_revisions(self): 81 | a = self.site.answer(4673436) 82 | a.revisions.fetch() 83 | first_revision = a.revisions[0] 84 | self.assertNotEqual(first_revision, None) 85 | self.assertEqual(first_revision.post_id, a.id) 86 | 87 | def test_has_body(self): 88 | q = self.site.question(QUESTION_ID, body=True) 89 | self.assertTrue(hasattr(q, 'body')) 90 | self.assertNotEqual(q.body, None) 91 | 92 | a = self.site.answer(ANSWER_ID, body=True) 93 | self.assertTrue(hasattr(a, 'body')) 94 | self.assertNotEqual(a.body, None) 95 | 96 | def test_tag_synonyms(self): 97 | syns = self.site.tag_synonyms() 98 | self.assertTrue(len(syns) > 0) 99 | 100 | def test_tag_wiki(self): 101 | tag = self.site.tag('javascript') 102 | self.assertEqual(tag.name, 'javascript') 103 | wiki = tag.wiki.fetch() 104 | self.assertTrue(len(wiki.excerpt) > 0) 105 | 106 | def test_tag_wiki2(self): 107 | wiki = self.site.tag_wiki('javascript') 108 | self.assertEqual(wiki[0].tag_name, 'javascript') 109 | wiki = self.site.tag_wiki('java;c++;python;android', page=1, pagesize=4) 110 | self.assertEqual(wiki[0].tag_name, 'android') 111 | self.assertEqual(wiki[1].tag_name, 'c++') 112 | self.assertEqual(wiki[2].tag_name, 'java') 113 | self.assertEqual(wiki[3].tag_name, 'python') 114 | 115 | def test_tag_related(self): 116 | related = self.site.tag_related('java', page=1, pagesize=40) 117 | names = tuple(tag.name for tag in related[:10]) 118 | self.assertIn('android', names) 119 | self.assertIn('swing', names) 120 | 121 | def test_badge_name(self): 122 | badge = self.site.badge(name = 'Nice Answer') 123 | self.assertNotEqual(badge, None) 124 | self.assertEqual(badge.name, 'Nice Answer') 125 | 126 | def test_badge_id(self): 127 | badge = self.site.badge(23) 128 | self.assertEqual(badge.name, 'Nice Answer') 129 | 130 | def test_rep_change(self): 131 | user = self.site.user(41981) 132 | user.reputation_detail.fetch() 133 | recent_change = user.reputation_detail[0] 134 | self.assertNotEqual(recent_change, None) 135 | self.assertEqual(recent_change.user_id, user.id) 136 | 137 | def test_timeline(self): 138 | user = self.site.user(41981) 139 | user.timeline.fetch() 140 | event = user.timeline[0] 141 | self.assertNotEqual(event, None) 142 | self.assertEqual(event.user_id, user.id) 143 | 144 | def test_top_tag(self): 145 | user = self.site.user(41981) 146 | 147 | user.top_answer_tags.fetch() 148 | answer_tag = user.top_answer_tags[0] 149 | self.assertNotEqual(answer_tag, None) 150 | self.assertTrue(answer_tag.answer_count > 0) 151 | 152 | user.top_question_tags.fetch() 153 | question_tag = user.top_question_tags[0] 154 | self.assertNotEqual(question_tag, None) 155 | self.assertTrue(question_tag.question_count > 0) 156 | 157 | def test_privilege(self): 158 | privileges = self.site.privileges() 159 | self.assertTrue(len(privileges) > 0) 160 | self.assertTrue(privileges[0].reputation > 0) 161 | 162 | def test_stackauth_site_types(self): 163 | s = stackauth.StackAuth() 164 | for site in s.sites(): 165 | self.assertTrue(site.site_type in (stackauth.SiteType.MainSite, stackauth.SiteType.MetaSite)) 166 | 167 | def test_stackauth_site_instantiate(self): 168 | for defn in stackauth.StackAuth().sites(): 169 | site_ob = defn.get_site(API_KEY) 170 | # Do the same as test_fetch_answer() and hope we don't get an exception 171 | defn.get_site(API_KEY).answer(ANSWER_ID) 172 | # Only do it once! 173 | break 174 | 175 | def test_advanced_search(self): 176 | results = self.site.search_advanced(q = 'python') 177 | self.assertTrue(len(results) > 0) 178 | 179 | def test_stats(self): 180 | results = self.site.stats() 181 | self.assertTrue(results.total_users > 0) 182 | 183 | def test_info_site_defn(self): 184 | result = self.site.info(site = True) 185 | self.assertNotEqual(result.site_definition, None) 186 | self.assertTrue(len(result.site_definition.name) > 0) 187 | 188 | def test_badge_recipients(self): 189 | results = self.site.badge_recipients(22) 190 | self.assertTrue(len(results) > 0) 191 | self.assertTrue(hasattr(results[0], 'user')) 192 | self.assertTrue(hasattr(results[0].user, 'id')) 193 | 194 | def test_badge_recipients_field(self): 195 | results = self.site.badge(22).recipients 196 | self.assertNotEqual(next(results), None) 197 | 198 | def test_accepted_answer(self): 199 | # our favourite test question... 200 | question = self.site.question(4) 201 | self.assertEqual(type(question.accepted_answer), stackexchange.Answer) 202 | self.assertEqual(question.accepted_answer.id, question.accepted_answer_id) 203 | 204 | ans = question.accepted_answer 205 | ans.fetch() 206 | self.assertTrue(hasattr(ans, 'score')) 207 | 208 | def test_moderators_elected(self): 209 | moderators = self.site.moderators_elected() 210 | self.assertGreater(len(moderators), 0) 211 | self.assertEqual(type(moderators[0]), stackexchange.User) 212 | 213 | 214 | class PlumbingTests(unittest.TestCase): 215 | def setUp(self): 216 | _setUp(self) 217 | 218 | def test_key_ratelimit(self): 219 | # a key was given, so check the rate limit is 10000 220 | if not hasattr(self.site, 'rate_limit'): 221 | self.site.question(QUESTION_ID) 222 | self.assertTrue(self.site.rate_limit[1] == 10000) 223 | 224 | def test_site_constants(self): 225 | # SOFU should always be present 226 | self.assertTrue(hasattr(stacksites, 'StackOverflow')) 227 | self.assertTrue(hasattr(stacksites, 'ServerFault')) 228 | self.assertTrue(hasattr(stacksites, 'SuperUser')) 229 | 230 | def test_error(self): 231 | try: 232 | self.site.error(401) 233 | except Exception as e: 234 | self.assertEqual(type(e), StackExchangeError) 235 | self.assertEqual(e.code, 401) 236 | else: 237 | self.fail('did not raise exception on error') 238 | 239 | def test_vectorise(self): 240 | # check different types 241 | q = self.site.question(QUESTION_ID) 242 | v = self.site.vectorise(('hello', 10, True, False, q), stackexchange.Question) 243 | self.assertEqual(v, 'hello;10;true;false;%d' % QUESTION_ID) 244 | 245 | def test_total(self): 246 | r = self.site.search(tagged = 'python', filter = 'total') 247 | self.assertTrue(hasattr(r, 'total')) 248 | self.assertTrue(r.total > 0) 249 | 250 | def test_pagesize_independence(self): 251 | # this test is motivated by pull request #37 252 | 253 | # a slightly odd choice of tag indeed, but it has a modest but useful 254 | # number of questions and is unlikely to grow very quickly 255 | qs = self.site.questions(tagged = 'dijkstra', pagesize = 37, filter = '!9YdnSQVoS') 256 | total1 = qs.total 257 | count1 = len(list(qs)) 258 | self.assertEqual(count1, total1) 259 | 260 | qs = self.site.questions(tagged = 'dijkstra', pagesize = 100, filter = '!9YdnSQVoS') 261 | total2 = qs.total 262 | count2 = len(list(qs)) 263 | self.assertEqual(count2, total2) 264 | self.assertEqual(count1, count2) 265 | 266 | def test_resultset_independence(self): 267 | # repro code for bug #4 (thanks, beaumartinez!) 268 | 269 | # Create two different sites. 270 | a = stackexchange.Site('api.askubuntu.com') 271 | b = self.site 272 | 273 | # Create two different searches from the different sites. 274 | a_search = a.search(intitle='vim', pagesize=100) 275 | b_search = b.search(intitle='vim', pagesize=100) 276 | 277 | # (We demonstrate that the second search has a second page.) 278 | self.assertEqual(len(b_search.fetch_next()), 100) 279 | 280 | # Reset the searches. 281 | a_search = a.search(intitle='vim', pagesize=100) 282 | b_search = b.search(intitle='vim', pagesize=100) 283 | 284 | # Exhaust the first search. 285 | while len(a_search) > 0: 286 | a_search = a_search.fetch_next() 287 | 288 | # Try get the next page of the second search. It will be empty. 289 | # Here's the bug. 290 | self.assertEqual(len(b_search.fetch_next()), 100) 291 | 292 | def test_partial(self): 293 | qn = self.site.question(4) 294 | comment = qn.comments.fetch()[0] 295 | owner = comment.owner.fetch() 296 | 297 | if __name__ == '__main__': 298 | unittest.main() 299 | -------------------------------------------------------------------------------- /tools/_genconsts.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import stackauth, string 4 | 5 | sites = stackauth.StackAuth().sites() 6 | source = ['''import stackexchange 7 | class __SEAPI(str): 8 | def __call__(self): 9 | return stackexchange.Site(self)'''] 10 | 11 | for site in sites: 12 | name = ''.join(c for c in site.name if c.isalnum()) 13 | source.append('%s = __SEAPI(\'%s\')' % (name, site.api_endpoint[7:])) 14 | 15 | print('\n'.join(source)) 16 | -------------------------------------------------------------------------------- /tools/api.yml: -------------------------------------------------------------------------------- 1 | answers: 2 | answers: 3 | se_route: /answers 4 | description: Get all answers on the site. 5 | 6 | function: Site.answers 7 | returns: ResultSet 8 | 9 | answers_id: 10 | se_route: /answers/{id} 11 | description: Get the answer identified by the specified ID. 12 | 13 | function: Site.answer 14 | parameters: 15 | id: The ID of the specified answer. 16 | returns: Answer 17 | 18 | answers_ids: 19 | se_route: /answers/{ids} 20 | description: Get the answers identified by the specified IDs. 21 | 22 | function: Site.answers 23 | parameters: 24 | ids: One or more IDs in a Python iterable. 25 | returns: ResultSet 26 | 27 | example: 28 | so.answers([1, 2, 3]) 29 | 30 | answers_comments: 31 | se_route: /answers/{ids}/comments 32 | 33 | see: comments.posts_comments 34 | 35 | badges: 36 | badges: 37 | se_route: /badges 38 | description: Get all badges on the site, in alphabetical order. 39 | 40 | function: Site.all_badges 41 | returns: ResultSet 42 | 43 | badges_id: 44 | se_route: /badges/{id} 45 | description: Get all users who have been awarded the badge specified by ID. 46 | 47 | function: Site.users_with_badge 48 | parameters: 49 | id: The ID of the badge. 50 | 51 | returns: Badge 52 | 53 | badges_ids: 54 | se_route: /badges/{ids} 55 | description: Get all users who have been awarded any of the badges specified in IDs. 56 | 57 | function: Site.badges 58 | parameters: 59 | ids: One or more badge IDs in an iterable. 60 | 61 | returns: ResultSet 62 | 63 | badge_by_id: 64 | description: Get a badge object for the specified ID. 65 | 66 | function: Site.badge 67 | parameters: 68 | nid: The ID of the badge. 69 | 70 | returns: Badge 71 | 72 | badge_by_name: 73 | description: Get a Badge object for the badge with the specified name. 74 | 75 | function: Site.badge 76 | parameters: 77 | name: (Keyword parameter) The name of the badge. 78 | 79 | returns: Badge 80 | 81 | badges_non_tag: 82 | se_route: /badges/name 83 | description: Get all non-tagged-based badges in alphabetical order. 84 | 85 | function: Site.all_nontag_badges 86 | returns: ResultSet 87 | 88 | badges_tags: 89 | se_route: /badges/tags 90 | description: Get all tag-based badges. 91 | 92 | function: Site.all_tag_badges 93 | returns: ResultSet 94 | 95 | comments: 96 | comments: 97 | se_route: /comments 98 | description: Get all comments on the site. 99 | 100 | function: Site.comments 101 | returns: ResultSet 102 | 103 | comments_ids: 104 | se_route: /comments/{ids} 105 | description: Get comments identified by a set of IDs. 106 | 107 | function: Site.comments 108 | parameters: 109 | ids: (Keyword parameter) The IDs of the comments to request. 110 | 111 | returns: ResultSet 112 | 113 | posts_comments: 114 | se_route: /posts/{ids}/comments 115 | description: Get comments on the posts (question or answer) identified by a set of IDs. 116 | 117 | function: Site.comments 118 | parameters: 119 | posts: (Keyword parameter) The IDs of the posts whose comments are to be requested. 120 | returns: ResultSet 121 | 122 | errors: 123 | error: 124 | se_route: /errors/{id} 125 | unimplemented: True 126 | 127 | privileges: 128 | privileges: 129 | se_route: /privileges 130 | description: Get all the privileges available on the site. 131 | 132 | function: Site.privileges 133 | returns: ResultSet 134 | 135 | questions: 136 | questions: 137 | se_route: /questions 138 | description: Get all questions on the site. 139 | 140 | function: Site.questions 141 | returns: ResultSet 142 | 143 | questions_ids: 144 | se_route: /questions/{ids} 145 | description: Get the questions identified by IDs. 146 | 147 | function: Site.questions 148 | parameters: 149 | ids: (Keyword parameter) The IDs of the questions to request. 150 | 151 | returns: ResultSet 152 | 153 | questions_ids_answers: 154 | se_route: /questions/{ids}/answers 155 | description: Get the answers of the questions identified by IDs. 156 | 157 | unimplemented: True 158 | 159 | questions_comments: 160 | se_route: /questions/{ids}/comments 161 | description: Get comments on the questions identified by IDs. 162 | 163 | function: Site.comments 164 | parameters: 165 | posts: (Keyword parameter) The IDs of the questions whose comments to request. 166 | 167 | returns: ResultSet 168 | 169 | questions_linked: 170 | se_route: /questions/{ids}/linked 171 | description: Get the questions that link to the questions identified by a set of IDs. 172 | 173 | function: Site.questions.linked_to 174 | parameters: 175 | qn: The ID, or a list of IDs, of the question whose linked questions should be retrieved. 176 | 177 | returns: ResultSet 178 | 179 | questions_related: 180 | se_route: /questions/{ids}/related 181 | description: Get questions related to the questions specified by IDs. 182 | 183 | function: Site.questions.related_to 184 | parameters: 185 | qn: The ID, or a list of IDs, of the question whose related questions should be retrieved. 186 | 187 | returns: ResultSet 188 | 189 | questions_timeline: 190 | se_route: /questions/{ids}/timeline 191 | description: Get the timelines of the questions identified by IDs. 192 | 193 | unimplemented: True 194 | 195 | questions_unanswered: 196 | se_route: /questions/unanswered 197 | description: Gets all unanswered questions. 198 | 199 | function: Site.questions.unanswered 200 | returns: ResultSet 201 | 202 | questions_no_answers: 203 | se_route: /questions/no-answers 204 | description: Get all the questions on the site with no answers. 205 | 206 | function: Site.questions.no_answers 207 | returns: ResultSet 208 | 209 | revisions: 210 | revisions_post: 211 | se_route: /revisions/{id} 212 | description: Get all revisions for the post with the specified ID. 213 | 214 | function: Site.revisions 215 | parameters: 216 | post: The post or post ID whose revisions are to be requested. 217 | returns: ResultSet 218 | 219 | revision: 220 | se_route: /revisions/{id}/{revision-guid} 221 | description: Get a specific revision identified by a post ID a revision GUID. 222 | 223 | function: Site.revision 224 | parameters: 225 | post: The post or post ID whose revisions are to be requested. 226 | guid: The GUID of the specific revision to request. 227 | 228 | search: 229 | search: 230 | se_route: /search 231 | description: Search the site. 232 | 233 | parameters: 234 | 235 | 236 | 237 | __style__: | 238 | h1, h2, h3, h4 { font-family: Palatino, Palatino Linotype, TeXGyrePagella, serif; } 239 | body { font-family: Gill Sans, GillSans, Gill Sans MT, Arial, Helvetica, sans-serif; } 240 | .description { font-size: 1.1em; } 241 | .prototype, .returns span, .example { font-family: Monaco, Consolas, monospace; background-color: #EEE; padding: 4px; margin: 3px; } 242 | .api { text-indent: 2em; } 243 | .param_name { font-style: italic; padding-right: 10px; } 244 | .returns { font-weight: bold; } 245 | .returns span { font-weight: normal; } 246 | .example, .params { text-indent: 3.5em; } 247 | .see { font-style: italic; } 248 | -------------------------------------------------------------------------------- /tools/makedoc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Creates HTML documentation from api.yml 3 | 4 | import yaml 5 | 6 | class Function(object): 7 | def __init__(self, id, tree_ob): 8 | def use(key, v=None): 9 | if key in tree_ob: 10 | val = tree_ob[key] 11 | 12 | if isinstance(val, str): 13 | val = val.replace('<', '<').replace('>', '>') 14 | 15 | setattr(self, key, val) 16 | return True 17 | else: 18 | if v is not None: 19 | setattr(self, key, v) 20 | return False 21 | 22 | self.id = id 23 | 24 | use('description', '') 25 | use('se_route', self.description) 26 | 27 | if not use('unimplemented', False): 28 | use('function') 29 | use('returns') 30 | use('parameters') 31 | use('see') 32 | use('example') 33 | 34 | @property 35 | def prototype(self): 36 | if self.unimplemented: 37 | raise AttributeError('prototype') 38 | elif hasattr(self, 'see'): 39 | return self.see 40 | else: 41 | params = '' 42 | 43 | if hasattr(self, 'parameters'): 44 | params = ', '.join(self.parameters.keys()) 45 | 46 | return '%s(%s)' % (self.function, params) 47 | 48 | class HTMLDocGenerator(object): 49 | def __init__(self, file_ob): 50 | self.categories = [] 51 | 52 | self.parse(file_ob) 53 | 54 | def parse(self, file_ob): 55 | self.tree = yaml.load(file_ob) 56 | 57 | unimplemented = 0 58 | total = 0 59 | 60 | for name, category in self.tree.items(): 61 | if name.startswith('__'): 62 | continue 63 | 64 | current_category = [] 65 | 66 | for funct_id, function in category.items(): 67 | f = Function('%s.%s' % (name, funct_id), function) 68 | 69 | if f.unimplemented: 70 | unimplemented += 1 71 | 72 | total += 1 73 | current_category.append(f) 74 | 75 | self.categories.append((name, current_category)) 76 | 77 | def to_html(self): 78 | html = [] 79 | 80 | html.append('' % self.tree.get('__style__', '')) 81 | 82 | for category, functions in self.categories: 83 | html.append('

%s

' % category.title()) 84 | 85 | for funct in functions: 86 | html.append('' % funct.id) 87 | html.append('

%s

' % funct.se_route) 88 | html.append('
') 89 | 90 | if hasattr(funct, 'see'): 91 | html.append('
see %s
' % (funct.see, funct.see)) 92 | html.append('
') 93 | continue 94 | 95 | if not funct.unimplemented: 96 | html.append('
%s
' % funct.prototype) 97 | 98 | html.append('
%s
' % funct.description) 99 | 100 | if funct.unimplemented: 101 | html.append('
Unimplemented.
') 102 | html.append('') 103 | continue 104 | 105 | 106 | if hasattr(funct, 'returns'): 107 | html.append('
Returns: %s
' % funct.returns) 108 | 109 | if hasattr(funct, 'parameters'): 110 | html.append('

Parameters

') 111 | html.append('
') 112 | 113 | for key, desc in funct.parameters.items(): 114 | html.append('
%s %s
' % (key, desc)) 115 | 116 | html.append('
') 117 | 118 | if hasattr(funct, 'example'): 119 | html.append('

Example

') 120 | html.append('
%s
' % funct.example) 121 | 122 | html.append('') 123 | 124 | return '\n'.join(html) 125 | 126 | if __name__ == '__main__': 127 | in_handle = open('api.yml') 128 | out_handle = open('api.html', 'w') 129 | 130 | docgen = HTMLDocGenerator(in_handle) 131 | out_handle.write(docgen.to_html()) 132 | 133 | in_handle.close() 134 | out_handle.close() 135 | -------------------------------------------------------------------------------- /tools/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function usage { 4 | echo -n "$1=" 5 | if [ "$(eval echo '$'$1)" == "yes" ]; then 6 | echo -ne "\e[1myes\e[0m|no" 7 | else 8 | echo -ne "yes|\e[1mno\e[0m" 9 | fi 10 | } 11 | 12 | echo "Please don't run this as a user. This generates a new release for PyPI. Press ^C to exit or Enter to continue." 13 | echo "Options set by environment variables: `usage IGNORE_TEST` ; `usage BUILD_WINDOWS` ; `usage DRY_RUN`." 14 | read 15 | 16 | # Re-generate site constants 17 | #constsfile=$(mktemp) 18 | #if PYTHONPATH=. python tools/_genconsts.py > "$constsfile"; then 19 | # cp "$constsfile" stackexchange/sites.py 20 | #fi 21 | #rm "$constsfile" 22 | 23 | if ! ( [ "$IGNORE_TEST" == "yes" ] || tools/test ); then 24 | echo "The test suite failed. Fix it!" 25 | exit 1 26 | fi 27 | 28 | if [ "$BUILD_WINDOWS" == "yes" ]; then 29 | wintarget=bdist_wininst 30 | fi 31 | 32 | uploadtarget=upload 33 | if [ "$DRY_RUN" == "yes" ]; then 34 | unset uploadtarget 35 | fi 36 | 37 | 38 | # Clear old distutils stuff 39 | rm -rf build dist MANIFEST &> /dev/null 40 | 41 | # Build installers, etc. and upload to PyPI 42 | python setup.py register sdist $wintarget $uploadtarget 43 | -------------------------------------------------------------------------------- /tools/se_inter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ipython 2 | # Saves me quite a bit of typing during interactive 3 | # testing sessions. 4 | # Just execute. 5 | import sys 6 | sys.path.append('.') 7 | 8 | from stackexchange import * 9 | from stackauth import * 10 | #so = Site(StackOverflow, '1_9Gj-egW0q_k1JaweDG8Q') 11 | so = Site(StackOverflow, 'pXlviKYs*UZIwKLPwJGgpg((') 12 | -------------------------------------------------------------------------------- /tools/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function test_py2 { 4 | python2 ./testsuite.py $@ 5 | } 6 | 7 | function test_py3 { 8 | python3 ./testsuite.py $@ 9 | } 10 | 11 | test_py3 $@ && test_py2 $@ 12 | --------------------------------------------------------------------------------