1# This file is part of PRAW. 2# 3# PRAW is free software: you can redistribute it and/or modify it under the 4# terms of the GNU General Public License as published by the Free Software 5# Foundation, either version 3 of the License, or (at your option) any later 6# version. 7# 8# PRAW is distributed in the hope that it will be useful, but WITHOUT ANY 9# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 10# A PARTICULAR PURPOSE. See the GNU General Public License for more details. 11# 12# You should have received a copy of the GNU General Public License along with 13# PRAW. If not, see <http://www.gnu.org/licenses/>. 14 15""" 16Python Reddit API Wrapper. 17 18PRAW, an acronym for "Python Reddit API Wrapper", is a python package that 19allows for simple access to reddit's API. PRAW aims to be as easy to use as 20possible and is designed to follow all of reddit's API rules. You have to give 21a useragent, everything else is handled by PRAW so you needn't worry about 22violating them. 23 24More information about PRAW can be found at https://github.com/praw-dev/praw 25""" 26 27from __future__ import print_function, unicode_literals 28 29import json 30import os 31import platform 32import re 33import six 34import sys 35from . import decorators, errors 36from .handlers import DefaultHandler 37from .helpers import chunk_sequence, normalize_url 38from .internal import (_image_type, _prepare_request, 39 _raise_redirect_exceptions, 40 _raise_response_exceptions, 41 _to_reddit_list, _warn_pyopenssl) 42from .settings import CONFIG 43from requests import Session 44from requests.compat import urljoin 45from requests.utils import to_native_string 46from requests import Request 47# pylint: disable=F0401 48from six.moves import html_entities, http_cookiejar 49from six.moves.urllib.parse import parse_qs, urlparse, urlunparse 50# pylint: enable=F0401 51from warnings import warn_explicit 52 53 54__version__ = '3.6.1' 55 56 57class Config(object): # pylint: disable=R0903 58 """A class containing the configuration for a reddit site.""" 59 60 API_PATHS = {'accept_mod_invite': 'api/accept_moderator_invite', 61 'access_token_url': 'api/v1/access_token/', 62 'approve': 'api/approve/', 63 'authorize': 'api/v1/authorize/', 64 'banned': 'r/{subreddit}/about/banned/', 65 'blocked': 'prefs/blocked/', 66 'by_id': 'by_id/', 67 'captcha': 'captcha/', 68 'clearflairtemplates': 'api/clearflairtemplates/', 69 'collapse_message': 'api/collapse_message/', 70 'comment': 'api/comment/', 71 'comment_replies': 'message/comments/', 72 'comments': 'comments/', 73 'compose': 'api/compose/', 74 'contest_mode': 'api/set_contest_mode/', 75 'contributors': 'r/{subreddit}/about/contributors/', 76 'controversial': 'controversial/', 77 'default_subreddits': 'subreddits/default/', 78 'del': 'api/del/', 79 'deleteflair': 'api/deleteflair', 80 'delete_redditor': 'api/delete_user', 81 'delete_sr_header': 'r/{subreddit}/api/delete_sr_header', 82 'delete_sr_image': 'r/{subreddit}/api/delete_sr_img', 83 'distinguish': 'api/distinguish/', 84 'domain': 'domain/{domain}/', 85 'duplicates': 'duplicates/{submissionid}/', 86 'edit': 'api/editusertext/', 87 'edited': 'r/{subreddit}/about/edited/', 88 'flair': 'api/flair/', 89 'flairconfig': 'api/flairconfig/', 90 'flaircsv': 'api/flaircsv/', 91 'flairlist': 'r/{subreddit}/api/flairlist/', 92 'flairselector': 'api/flairselector/', 93 'flairtemplate': 'api/flairtemplate/', 94 'friend': 'api/friend/', 95 'friend_v1': 'api/v1/me/friends/{user}', 96 'friends': 'prefs/friends/', 97 'gild_thing': 'api/v1/gold/gild/{fullname}/', 98 'gild_user': 'api/v1/gold/give/{username}/', 99 'gilded': 'gilded/', 100 'help': 'help/', 101 'hide': 'api/hide/', 102 'ignore_reports': 'api/ignore_reports/', 103 'inbox': 'message/inbox/', 104 'info': 'api/info/', 105 'leavecontributor': 'api/leavecontributor', 106 'leavemoderator': 'api/leavemoderator', 107 'lock': 'api/lock/', 108 'login': 'api/login/', 109 'me': 'api/v1/me', 110 'mentions': 'message/mentions', 111 'message': 'message/messages/{messageid}/', 112 'messages': 'message/messages/', 113 'moderators': 'r/{subreddit}/about/moderators/', 114 'modlog': 'r/{subreddit}/about/log/', 115 'modqueue': 'r/{subreddit}/about/modqueue/', 116 'mod_mail': 'r/{subreddit}/message/moderator/', 117 'morechildren': 'api/morechildren/', 118 'my_con_subreddits': 'subreddits/mine/contributor/', 119 'my_mod_subreddits': 'subreddits/mine/moderator/', 120 'my_multis': 'api/multi/mine/', 121 'my_subreddits': 'subreddits/mine/subscriber/', 122 'new': 'new/', 123 'new_subreddits': 'subreddits/new/', 124 'marknsfw': 'api/marknsfw/', 125 'multireddit': 'user/{user}/m/{multi}/', 126 'multireddit_add': ('api/multi/user/{user}/m/{multi}/r/' 127 '{subreddit}'), 128 'multireddit_about': 'api/multi/user/{user}/m/{multi}/', 129 'multireddit_copy': 'api/multi/copy/', 130 'multireddit_mine': 'me/m/{multi}/', 131 'multireddit_rename': 'api/multi/rename/', 132 'multireddit_user': 'api/multi/user/{user}/', 133 'mute_sender': 'api/mute_message_author/', 134 'muted': 'r/{subreddit}/about/muted/', 135 'popular_subreddits': 'subreddits/popular/', 136 'post_replies': 'message/selfreply/', 137 'read_message': 'api/read_message/', 138 'reddit_url': '/', 139 'register': 'api/register/', 140 'remove': 'api/remove/', 141 'report': 'api/report/', 142 'reports': 'r/{subreddit}/about/reports/', 143 'rising': 'rising/', 144 'rules': 'r/{subreddit}/about/rules/', 145 'save': 'api/save/', 146 'saved': 'saved/', 147 'search': 'r/{subreddit}/search/', 148 'search_reddit_names': 'api/search_reddit_names/', 149 'select_flair': 'api/selectflair/', 150 'sent': 'message/sent/', 151 'sticky': 'r/{subreddit}/about/sticky/', 152 'sticky_submission': 'api/set_subreddit_sticky/', 153 'site_admin': 'api/site_admin/', 154 'spam': 'r/{subreddit}/about/spam/', 155 'stylesheet': 'r/{subreddit}/about/stylesheet/', 156 'submit': 'api/submit/', 157 'sub_comments_gilded': 'r/{subreddit}/comments/gilded/', 158 'sub_recommendations': 'api/recommend/sr/{subreddits}', 159 'subreddit': 'r/{subreddit}/', 160 'subreddit_about': 'r/{subreddit}/about/', 161 'subreddit_comments': 'r/{subreddit}/comments/', 162 'subreddit_css': 'api/subreddit_stylesheet/', 163 'subreddit_random': 'r/{subreddit}/random/', 164 'subreddit_settings': 'r/{subreddit}/about/edit/', 165 'subreddit_traffic': 'r/{subreddit}/about/traffic/', 166 'subscribe': 'api/subscribe/', 167 'suggested_sort': 'api/set_suggested_sort/', 168 'top': 'top/', 169 'uncollapse_message': 'api/uncollapse_message/', 170 'unfriend': 'api/unfriend/', 171 'unhide': 'api/unhide/', 172 'unlock': 'api/unlock/', 173 'unmarknsfw': 'api/unmarknsfw/', 174 'unmoderated': 'r/{subreddit}/about/unmoderated/', 175 'unmute_sender': 'api/unmute_message_author/', 176 'unignore_reports': 'api/unignore_reports/', 177 'unread': 'message/unread/', 178 'unread_message': 'api/unread_message/', 179 'unsave': 'api/unsave/', 180 'upload_image': 'api/upload_sr_img', 181 'user': 'user/{user}/', 182 'user_about': 'user/{user}/about/', 183 'username_available': 'api/username_available/', 184 'vote': 'api/vote/', 185 'wiki_edit': 'api/wiki/edit/', 186 'wiki_page': 'r/{subreddit}/wiki/{page}', # No / 187 'wiki_page_editor': ('r/{subreddit}/api/wiki/alloweditor/' 188 '{method}'), 189 'wiki_page_settings': 'r/{subreddit}/wiki/settings/{page}', 190 'wiki_pages': 'r/{subreddit}/wiki/pages/', 191 'wiki_banned': 'r/{subreddit}/about/wikibanned/', 192 'wiki_contributors': 'r/{subreddit}/about/wikicontributors/' 193 } 194 WWW_PATHS = set(['authorize']) 195 196 @staticmethod 197 def ua_string(praw_info): 198 """Return the user-agent string. 199 200 The user-agent string contains PRAW version and platform version info. 201 202 """ 203 if os.environ.get('SERVER_SOFTWARE') is not None: 204 # Google App Engine information 205 # https://developers.google.com/appengine/docs/python/ 206 info = os.environ.get('SERVER_SOFTWARE') 207 else: 208 # Standard platform information 209 info = platform.platform(True).encode('ascii', 'ignore') 210 211 return '{0} PRAW/{1} Python/{2} {3}'.format( 212 praw_info, __version__, sys.version.split()[0], info) 213 214 def __init__(self, site_name, **kwargs): 215 """Initialize PRAW's configuration.""" 216 def config_boolean(item): 217 return item and item.lower() in ('1', 'yes', 'true', 'on') 218 219 obj = dict(CONFIG.items(site_name)) 220 # Overwrite configuration file settings with those given during 221 # instantiation of the Reddit instance. 222 for key, value in kwargs.items(): 223 obj[key] = value 224 225 self.api_url = 'https://' + obj['api_domain'] 226 self.permalink_url = 'https://' + obj['permalink_domain'] 227 self.oauth_url = ('https://' if config_boolean(obj['oauth_https']) 228 else 'http://') + obj['oauth_domain'] 229 self.api_request_delay = float(obj['api_request_delay']) 230 self.by_kind = {obj['comment_kind']: objects.Comment, 231 obj['message_kind']: objects.Message, 232 obj['redditor_kind']: objects.Redditor, 233 obj['submission_kind']: objects.Submission, 234 obj['subreddit_kind']: objects.Subreddit, 235 'LabeledMulti': objects.Multireddit, 236 'modaction': objects.ModAction, 237 'more': objects.MoreComments, 238 'wikipage': objects.WikiPage, 239 'wikipagelisting': objects.WikiPageListing, 240 'UserList': objects.UserList} 241 self.by_object = dict((value, key) for (key, value) in 242 six.iteritems(self.by_kind)) 243 self.by_object[objects.LoggedInRedditor] = obj['redditor_kind'] 244 self.cache_timeout = float(obj['cache_timeout']) 245 self.check_for_updates = config_boolean(obj['check_for_updates']) 246 self.domain = obj['permalink_domain'] 247 self.output_chars_limit = int(obj['output_chars_limit']) 248 self.log_requests = int(obj['log_requests']) 249 self.http_proxy = (obj.get('http_proxy') or os.getenv('http_proxy') or 250 None) 251 self.https_proxy = (obj.get('https_proxy') or 252 os.getenv('https_proxy') or None) 253 # We use `get(...) or None` because `get` may return an empty string 254 255 self.validate_certs = config_boolean(obj.get('validate_certs')) 256 257 self.client_id = obj.get('oauth_client_id') or None 258 self.client_secret = obj.get('oauth_client_secret') or None 259 self.redirect_uri = obj.get('oauth_redirect_uri') or None 260 self.grant_type = obj.get('oauth_grant_type') or None 261 self.refresh_token = obj.get('oauth_refresh_token') or None 262 self.store_json_result = config_boolean(obj.get('store_json_result')) 263 264 if 'short_domain' in obj and obj['short_domain']: 265 self._short_domain = 'http://' + obj['short_domain'] 266 else: 267 self._short_domain = None 268 self.timeout = float(obj['timeout']) 269 try: 270 self.user = obj['user'] if obj['user'] else None 271 self.pswd = obj['pswd'] 272 except KeyError: 273 self.user = self.pswd = None 274 275 def __getitem__(self, key): 276 """Return the URL for key.""" 277 prefix = self.permalink_url if key in self.WWW_PATHS else self.api_url 278 return urljoin(prefix, self.API_PATHS[key]) 279 280 @property 281 def short_domain(self): 282 """Return the short domain of the reddit server. 283 284 Used to generate the shortlink. For reddit.com the short_domain is 285 redd.it. 286 287 """ 288 if self._short_domain: 289 return self._short_domain 290 else: 291 raise errors.ClientException('No short domain specified.') 292 293 294class BaseReddit(object): 295 """A base class that allows access to reddit's API. 296 297 You should **not** directly instantiate instances of this class. Use 298 :class:`.Reddit` instead. 299 300 """ 301 302 RETRY_CODES = [502, 503, 504] 303 update_checked = False 304 openssl_warned = False 305 306 def __init__(self, user_agent, site_name=None, handler=None, 307 disable_update_check=False, **kwargs): 308 """Initialize our connection with a reddit server. 309 310 The user_agent is how your application identifies itself. Read the 311 official API guidelines for user_agents 312 https://github.com/reddit/reddit/wiki/API. Applications using default 313 user_agents such as "Python/urllib" are drastically limited. 314 315 site_name allows you to specify which reddit you want to connect to. 316 The installation defaults are reddit.com, if you only need to connect 317 to reddit.com then you can safely ignore this. If you want to connect 318 to another reddit, set site_name to the name of that reddit. This must 319 match with an entry in praw.ini. If site_name is None, then the site 320 name will be looked for in the environment variable REDDIT_SITE. If it 321 is not found there, the default site name reddit matching reddit.com 322 will be used. 323 324 disable_update_check allows you to prevent an update check from 325 occurring in spite of the check_for_updates setting in praw.ini. 326 327 All additional parameters specified via kwargs will be used to 328 initialize the Config object. This can be used to specify configuration 329 settings during instantiation of the Reddit instance. See 330 https://praw.readthedocs.io/en/latest/pages/configuration_files.html 331 for more details. 332 333 """ 334 if not user_agent or not isinstance(user_agent, six.string_types): 335 raise TypeError('user_agent must be a non-empty string.') 336 if 'bot' in user_agent.lower(): 337 warn_explicit( 338 'The keyword `bot` in your user_agent may be problematic.', 339 UserWarning, '', 0) 340 341 self.config = Config(site_name or os.getenv('REDDIT_SITE') or 'reddit', 342 **kwargs) 343 self.handler = handler or DefaultHandler() 344 self.http = Session() 345 self.http.headers['User-Agent'] = self.config.ua_string(user_agent) 346 self.http.validate_certs = self.config.validate_certs 347 348 # This `Session` object is only used to store request information that 349 # is used to make prepared requests. It _should_ never be used to make 350 # a direct request, thus we raise an exception when it is used. 351 352 def _req_error(*_, **__): 353 raise errors.ClientException('Do not make direct requests.') 354 self.http.request = _req_error 355 356 if self.config.http_proxy or self.config.https_proxy: 357 self.http.proxies = {} 358 if self.config.http_proxy: 359 self.http.proxies['http'] = self.config.http_proxy 360 if self.config.https_proxy: 361 self.http.proxies['https'] = self.config.https_proxy 362 self.modhash = None 363 364 # Check for updates if permitted and this is the first Reddit instance 365 # if not disable_update_check and not BaseReddit.update_checked \ 366 # and self.config.check_for_updates: 367 # update_check(__name__, __version__) 368 # BaseReddit.update_checked = True 369 370 # Warn against a potentially incompatible version of pyOpenSSL 371 if not BaseReddit.openssl_warned and self.config.validate_certs: 372 _warn_pyopenssl() 373 BaseReddit.openssl_warned = True 374 375 # Initial values 376 self._use_oauth = False 377 378 def _request(self, url, params=None, data=None, files=None, auth=None, 379 timeout=None, raw_response=False, retry_on_error=True, 380 method=None): 381 """Given a page url and a dict of params, open and return the page. 382 383 :param url: the url to grab content from. 384 :param params: a dictionary containing the GET data to put in the url 385 :param data: a dictionary containing the extra data to submit 386 :param files: a dictionary specifying the files to upload 387 :param auth: Add the HTTP authentication headers (see requests) 388 :param timeout: Specifies the maximum time that the actual HTTP request 389 can take. 390 :param raw_response: return the response object rather than the 391 response body 392 :param retry_on_error: if True retry the request, if it fails, for up 393 to 3 attempts 394 :returns: either the response body or the response object 395 396 """ 397 def build_key_items(url, params, data, auth, files, method): 398 request = _prepare_request(self, url, params, data, auth, files, 399 method) 400 401 # Prepare extra arguments 402 key_items = [] 403 oauth = request.headers.get('Authorization', None) 404 for key_value in (params, data, request.cookies, auth, oauth): 405 if isinstance(key_value, dict): 406 key_items.append(tuple(key_value.items())) 407 elif isinstance(key_value, http_cookiejar.CookieJar): 408 key_items.append(tuple(key_value.get_dict().items())) 409 else: 410 key_items.append(key_value) 411 kwargs = {'_rate_domain': self.config.domain, 412 '_rate_delay': int(self.config.api_request_delay), 413 '_cache_ignore': bool(files) or raw_response, 414 '_cache_timeout': int(self.config.cache_timeout)} 415 416 return (request, key_items, kwargs) 417 418 def decode(match): 419 return six.unichr(html_entities.name2codepoint[match.group(1)]) 420 421 def handle_redirect(): 422 response = None 423 url = request.url 424 while url: # Manually handle 302 redirects 425 request.url = url 426 kwargs['_cache_key'] = (normalize_url(request.url), 427 tuple(key_items)) 428 response = self.handler.request( 429 request=request.prepare(), 430 proxies=self.http.proxies, 431 timeout=timeout, 432 verify=self.http.validate_certs, **kwargs) 433 434 if self.config.log_requests >= 2: 435 msg = 'status: {0}\n'.format(response.status_code) 436 sys.stderr.write(msg) 437 url = _raise_redirect_exceptions(response) 438 assert url != request.url 439 return response 440 441 timeout = self.config.timeout if timeout is None else timeout 442 request, key_items, kwargs = build_key_items(url, params, data, 443 auth, files, method) 444 445 tempauth = self._use_oauth 446 remaining_attempts = 3 if retry_on_error else 1 447 attempt_oauth_refresh = bool(self.refresh_token) 448 while True: 449 try: 450 self._use_oauth = self.is_oauth_session() 451 response = handle_redirect() 452 _raise_response_exceptions(response) 453 self.http.cookies.update(response.cookies) 454 if raw_response: 455 return response 456 else: 457 return re.sub('&([^;]+);', decode, response.text) 458 except errors.OAuthInvalidToken as error: 459 if not attempt_oauth_refresh: 460 raise 461 attempt_oauth_refresh = False 462 self._use_oauth = False 463 self.refresh_access_information() 464 self._use_oauth = tempauth 465 request, key_items, kwargs = build_key_items(url, params, 466 data, auth, files, 467 method) 468 except errors.HTTPException as error: 469 remaining_attempts -= 1 470 # pylint: disable=W0212 471 if error._raw.status_code not in self.RETRY_CODES or \ 472 remaining_attempts == 0: 473 raise 474 finally: 475 self._use_oauth = tempauth 476 477 def _json_reddit_objecter(self, json_data): 478 """Return an appropriate RedditObject from json_data when possible.""" 479 try: 480 object_class = self.config.by_kind[json_data['kind']] 481 except KeyError: 482 if 'json' in json_data: 483 if len(json_data) != 1: 484 msg = 'Unknown object type: {0}'.format(json_data) 485 warn_explicit(msg, UserWarning, '', 0) 486 return json_data['json'] 487 else: 488 return object_class.from_api_response(self, json_data['data']) 489 return json_data 490 491 def evict(self, urls): 492 """Evict url(s) from the cache. 493 494 :param urls: An iterable containing normalized urls. 495 :returns: The number of items removed from the cache. 496 497 """ 498 if isinstance(urls, six.string_types): 499 urls = (urls,) 500 return self.handler.evict(urls) 501 502 @decorators.oauth_generator 503 def get_content(self, url, params=None, limit=0, place_holder=None, 504 root_field='data', thing_field='children', 505 after_field='after', object_filter=None, **kwargs): 506 """A generator method to return reddit content from a URL. 507 508 Starts at the initial url, and fetches content using the `after` 509 JSON data until `limit` entries have been fetched, or the 510 `place_holder` has been reached. 511 512 :param url: the url to start fetching content from 513 :param params: dictionary containing extra GET data to put in the url 514 :param limit: the number of content entries to fetch. If limit <= 0, 515 fetch the default for your account (25 for unauthenticated 516 users). If limit is None, then fetch as many entries as possible 517 (reddit returns at most 100 per request, however, PRAW will 518 automatically make additional requests as necessary). 519 :param place_holder: if not None, the method will fetch `limit` 520 content, stopping if it finds content with `id` equal to 521 `place_holder`. The place_holder item is the last item to be 522 yielded from this generator. Note that the use of `place_holder` is 523 not 100% reliable as the place holder item may no longer exist due 524 to being removed or deleted. 525 :param root_field: indicates the field in the json response that holds 526 the data. Most objects use 'data', however some (flairlist) don't 527 have the 'data' object. Use None for the root object. 528 :param thing_field: indicates the field under the root_field which 529 contains the list of things. Most objects use 'children'. 530 :param after_field: indicates the field which holds the after item 531 element 532 :param object_filter: if set to an integer value, fetch content from 533 the corresponding list index in the JSON response. For example 534 the JSON response for submission duplicates is a list of objects, 535 and the object we want to fetch from is at index 1. So we set 536 object_filter=1 to filter out the other useless list elements. 537 :type place_holder: a string corresponding to a reddit base36 id 538 without prefix, e.g. 'asdfasdf' 539 :returns: a list of reddit content, of type Subreddit, Comment, 540 Submission or user flair. 541 542 """ 543 _use_oauth = kwargs.get('_use_oauth', self.is_oauth_session()) 544 545 objects_found = 0 546 params = params or {} 547 fetch_all = fetch_once = False 548 if limit is None: 549 fetch_all = True 550 params['limit'] = 1024 # Just use a big number 551 elif limit > 0: 552 params['limit'] = limit 553 else: 554 fetch_once = True 555 556 if hasattr(self, '_url_update'): 557 url = self._url_update(url) # pylint: disable=E1101 558 559 # While we still need to fetch more content to reach our limit, do so. 560 while fetch_once or fetch_all or objects_found < limit: 561 if _use_oauth: # Set the necessary _use_oauth value 562 assert self._use_oauth is False 563 self._use_oauth = _use_oauth 564 try: 565 page_data = self.request_json(url, params=params) 566 if object_filter: 567 page_data = page_data[object_filter] 568 finally: # Restore _use_oauth value 569 if _use_oauth: 570 self._use_oauth = False 571 fetch_once = False 572 root = page_data.get(root_field, page_data) 573 for thing in root[thing_field]: 574 yield thing 575 objects_found += 1 576 # Terminate when we've reached the limit, or place holder 577 if objects_found == limit or (place_holder and 578 thing.id == place_holder): 579 return 580 # Set/update the 'after' parameter for the next iteration 581 if root.get(after_field): 582 # We use `root.get` to also test if the value evaluates to True 583 params['after'] = root[after_field] 584 else: 585 return 586 587 @decorators.raise_api_exceptions 588 def request(self, url, params=None, data=None, retry_on_error=True, 589 method=None): 590 """Make a HTTP request and return the response. 591 592 :param url: the url to grab content from. 593 :param params: a dictionary containing the GET data to put in the url 594 :param data: a dictionary containing the extra data to submit 595 :param retry_on_error: if True retry the request, if it fails, for up 596 to 3 attempts 597 :param method: The HTTP method to use in the request. 598 :returns: The HTTP response. 599 """ 600 return self._request(url, params, data, raw_response=True, 601 retry_on_error=retry_on_error, method=method) 602 603 @decorators.raise_api_exceptions 604 def request_json(self, url, params=None, data=None, as_objects=True, 605 retry_on_error=True, method=None): 606 """Get the JSON processed from a page. 607 608 :param url: the url to grab content from. 609 :param params: a dictionary containing the GET data to put in the url 610 :param data: a dictionary containing the extra data to submit 611 :param as_objects: if True return reddit objects else raw json dict. 612 :param retry_on_error: if True retry the request, if it fails, for up 613 to 3 attempts 614 :returns: JSON processed page 615 616 """ 617 if not url.endswith('.json'): 618 url += '.json' 619 response = self._request(url, params, data, method=method, 620 retry_on_error=retry_on_error) 621 hook = self._json_reddit_objecter if as_objects else None 622 # Request url just needs to be available for the objecter to use 623 self._request_url = url # pylint: disable=W0201 624 625 if response == '': 626 # Some of the v1 urls don't return anything, even when they're 627 # successful. 628 return response 629 630 data = json.loads(response, object_hook=hook) 631 delattr(self, '_request_url') 632 # Update the modhash 633 if isinstance(data, dict) and 'data' in data \ 634 and 'modhash' in data['data']: 635 self.modhash = data['data']['modhash'] 636 return data 637 638 639class OAuth2Reddit(BaseReddit): 640 """Provides functionality for obtaining reddit OAuth2 access tokens. 641 642 You should **not** directly instantiate instances of this class. Use 643 :class:`.Reddit` instead. 644 645 """ 646 647 def __init__(self, *args, **kwargs): 648 """Initialize an OAuth2Reddit instance.""" 649 super(OAuth2Reddit, self).__init__(*args, **kwargs) 650 self.client_id = self.config.client_id 651 self.client_secret = self.config.client_secret 652 self.redirect_uri = self.config.redirect_uri 653 654 def _handle_oauth_request(self, data): 655 auth = (self.client_id, self.client_secret) 656 url = self.config['access_token_url'] 657 response = self._request(url, auth=auth, data=data, raw_response=True) 658 if not response.ok: 659 msg = 'Unexpected OAuthReturn: {0}'.format(response.status_code) 660 raise errors.OAuthException(msg, url) 661 retval = response.json() 662 if 'error' in retval: 663 error = retval['error'] 664 if error == 'invalid_grant': 665 raise errors.OAuthInvalidGrant(error, url) 666 raise errors.OAuthException(retval['error'], url) 667 return retval 668 669 @decorators.require_oauth 670 def get_access_information(self, code): 671 """Return the access information for an OAuth2 authorization grant. 672 673 :param code: the code received in the request from the OAuth2 server 674 :returns: A dictionary with the key/value pairs for ``access_token``, 675 ``refresh_token`` and ``scope``. The ``refresh_token`` value will 676 be None when the OAuth2 grant is not refreshable. The ``scope`` 677 value will be a set containing the scopes the tokens are valid for. 678 679 """ 680 if self.config.grant_type == 'password': 681 data = {'grant_type': 'password', 682 'username': self.config.user, 683 'password': self.config.pswd} 684 else: 685 data = {'code': code, 'grant_type': 'authorization_code', 686 'redirect_uri': self.redirect_uri} 687 retval = self._handle_oauth_request(data) 688 return {'access_token': retval['access_token'], 689 'refresh_token': retval.get('refresh_token'), 690 'scope': set(retval['scope'].split(' '))} 691 692 @decorators.require_oauth 693 def get_authorize_url(self, state, scope='identity', refreshable=False): 694 """Return the URL to send the user to for OAuth2 authorization. 695 696 :param state: a unique string of your choice that represents this 697 individual client 698 :param scope: the reddit scope to ask permissions for. Multiple scopes 699 can be enabled by passing in a container of strings. 700 :param refreshable: when True, a permanent "refreshable" token is 701 issued 702 703 """ 704 params = {'client_id': self.client_id, 'response_type': 'code', 705 'redirect_uri': self.redirect_uri, 'state': state, 706 'scope': _to_reddit_list(scope)} 707 params['duration'] = 'permanent' if refreshable else 'temporary' 708 request = Request('GET', self.config['authorize'], params=params) 709 return request.prepare().url 710 711 @property 712 def has_oauth_app_info(self): 713 """Return True when OAuth credentials are associated with the instance. 714 715 The necessary credentials are: ``client_id``, ``client_secret`` and 716 ``redirect_uri``. 717 718 """ 719 return all((self.client_id is not None, 720 self.client_secret is not None, 721 self.redirect_uri is not None)) 722 723 @decorators.require_oauth 724 def refresh_access_information(self, refresh_token): 725 """Return updated access information for an OAuth2 authorization grant. 726 727 :param refresh_token: the refresh token used to obtain the updated 728 information 729 :returns: A dictionary with the key/value pairs for access_token, 730 refresh_token and scope. The refresh_token value will be done when 731 the OAuth2 grant is not refreshable. The scope value will be a set 732 containing the scopes the tokens are valid for. 733 734 Password grants aren't refreshable, so use `get_access_information()` 735 again, instead. 736 """ 737 if self.config.grant_type == 'password': 738 data = {'grant_type': 'password', 739 'username': self.config.user, 740 'password': self.config.pswd} 741 else: 742 data = {'grant_type': 'refresh_token', 743 'redirect_uri': self.redirect_uri, 744 'refresh_token': refresh_token} 745 retval = self._handle_oauth_request(data) 746 return {'access_token': retval['access_token'], 747 'refresh_token': refresh_token, 748 'scope': set(retval['scope'].split(' '))} 749 750 def set_oauth_app_info(self, client_id, client_secret, redirect_uri): 751 """Set the app information to use with OAuth2. 752 753 This function need only be called if your praw.ini site configuration 754 does not already contain the necessary information. 755 756 Go to https://www.reddit.com/prefs/apps/ to discover the appropriate 757 values for your application. 758 759 :param client_id: the client_id of your application 760 :param client_secret: the client_secret of your application 761 :param redirect_uri: the redirect_uri of your application 762 763 """ 764 self.client_id = client_id 765 self.client_secret = client_secret 766 self.redirect_uri = redirect_uri 767 768 769class UnauthenticatedReddit(BaseReddit): 770 """This mixin provides bindings for basic functions of reddit's API. 771 772 None of these functions require authenticated access to reddit's API. 773 774 You should **not** directly instantiate instances of this class. Use 775 :class:`.Reddit` instead. 776 777 """ 778 779 def __init__(self, *args, **kwargs): 780 """Initialize an UnauthenticatedReddit instance.""" 781 super(UnauthenticatedReddit, self).__init__(*args, **kwargs) 782 # initialize to 1 instead of 0, because 0 does not reliably make 783 # new requests. 784 self._unique_count = 1 785 786 def create_redditor(self, user_name, password, email=''): 787 """Register a new user. 788 789 :returns: The json response from the server. 790 791 """ 792 data = {'email': email, 793 'passwd': password, 794 'passwd2': password, 795 'user': user_name} 796 return self.request_json(self.config['register'], data=data) 797 798 def default_subreddits(self, *args, **kwargs): 799 """Return a get_content generator for the default subreddits. 800 801 The additional parameters are passed directly into 802 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 803 804 """ 805 url = self.config['default_subreddits'] 806 return self.get_content(url, *args, **kwargs) 807 808 @decorators.restrict_access(scope='read') 809 def get_comments(self, subreddit, gilded_only=False, *args, **kwargs): 810 """Return a get_content generator for comments in the given subreddit. 811 812 :param gilded_only: If True only return gilded comments. 813 814 The additional parameters are passed directly into 815 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 816 817 """ 818 key = 'sub_comments_gilded' if gilded_only else 'subreddit_comments' 819 url = self.config[key].format(subreddit=six.text_type(subreddit)) 820 return self.get_content(url, *args, **kwargs) 821 822 @decorators.restrict_access(scope='read') 823 def get_controversial(self, *args, **kwargs): 824 """Return a get_content generator for controversial submissions. 825 826 Corresponds to submissions provided by 827 ``https://www.reddit.com/controversial/`` for the session. 828 829 The additional parameters are passed directly into 830 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 831 832 """ 833 return self.get_content(self.config['controversial'], *args, **kwargs) 834 835 @decorators.restrict_access(scope='read') 836 def get_domain_listing(self, domain, sort='hot', period=None, *args, 837 **kwargs): 838 """Return a get_content generator for submissions by domain. 839 840 Corresponds to the submissions provided by 841 ``https://www.reddit.com/domain/{domain}``. 842 843 :param domain: The domain to generate a submission listing for. 844 :param sort: When provided must be one of 'hot', 'new', 'rising', 845 'controversial, 'gilded', or 'top'. Defaults to 'hot'. 846 :param period: When sort is either 'controversial', or 'top' the period 847 can be either None (for account default), 'all', 'year', 'month', 848 'week', 'day', or 'hour'. 849 850 The additional parameters are passed directly into 851 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 852 853 """ 854 # Verify arguments 855 if sort not in ('controversial', 'hot', 'new', 'rising', 'top', 856 'gilded'): 857 raise TypeError('Invalid sort parameter.') 858 if period not in (None, 'all', 'day', 'hour', 'month', 'week', 'year'): 859 raise TypeError('Invalid period parameter.') 860 if sort not in ('controversial', 'top') and period: 861 raise TypeError('Period cannot be set for that sort argument.') 862 863 url = self.config['domain'].format(domain=domain) 864 if sort != 'hot': 865 url += sort 866 if period: # Set or overwrite params 't' parameter 867 kwargs.setdefault('params', {})['t'] = period 868 return self.get_content(url, *args, **kwargs) 869 870 @decorators.restrict_access(scope='modflair') 871 def get_flair(self, subreddit, redditor, **params): 872 """Return the flair for a user on the given subreddit. 873 874 :param subreddit: Can be either a Subreddit object or the name of a 875 subreddit. 876 :param redditor: Can be either a Redditor object or the name of a 877 redditor. 878 :returns: None if the user doesn't exist, otherwise a dictionary 879 containing the keys `flair_css_class`, `flair_text`, and `user`. 880 881 """ 882 name = six.text_type(redditor) 883 params.update(name=name) 884 url = self.config['flairlist'].format( 885 subreddit=six.text_type(subreddit)) 886 data = self.request_json(url, params=params) 887 if not data['users'] or \ 888 data['users'][0]['user'].lower() != name.lower(): 889 return None 890 return data['users'][0] 891 892 @decorators.restrict_access(scope='read') 893 def get_front_page(self, *args, **kwargs): 894 """Return a get_content generator for the front page submissions. 895 896 Corresponds to the submissions provided by ``https://www.reddit.com/`` 897 for the session. 898 899 The additional parameters are passed directly into 900 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 901 902 """ 903 return self.get_content(self.config['reddit_url'], *args, **kwargs) 904 905 @decorators.restrict_access(scope='read', generator_called=True) 906 def get_info(self, url=None, thing_id=None, *args, **kwargs): 907 """Look up existing items by thing_id (fullname) or url. 908 909 :param url: A url to lookup. 910 :param thing_id: A single thing_id, or a list of thing_ids. A thing_id 911 can be any one of Comment (``t1_``), Link (``t3_``), or Subreddit 912 (``t5_``) to lookup by fullname. 913 :returns: When a single ``thing_id`` is provided, return the 914 corresponding thing object, or ``None`` if not found. When a list 915 of ``thing_id``s or a ``url`` is provided return a list of thing 916 objects (up to ``limit``). ``None`` is returned if all of the 917 thing_ids or the URL is invalid. 918 919 The additional parameters are passed into :meth:`.get_content` after 920 the `params` parameter is exctracted and used to update the dictionary 921 of url parameters this function sends. Note: the `url` parameter 922 cannot be altered. 923 924 Also, if using thing_id and the `limit` parameter passed to 925 :meth:`.get_content` is used to slice the list of retreived things 926 before returning it to the user, for when `limit > 100` and 927 `(limit % 100) > 0`, to ensure a maximum of `limit` thigns are 928 returned. 929 930 """ 931 if bool(url) == bool(thing_id): 932 raise TypeError('Only one of url or thing_id is required!') 933 934 # In these cases, we will have a list of things to return. 935 # Otherwise, it will just be one item. 936 if isinstance(thing_id, six.string_types) and ',' in thing_id: 937 thing_id = thing_id.split(',') 938 return_list = bool(url) or not isinstance(thing_id, six.string_types) 939 940 if url: 941 param_groups = [{'url': url}] 942 else: 943 if isinstance(thing_id, six.string_types): 944 thing_id = [thing_id] 945 id_chunks = chunk_sequence(thing_id, 100) 946 param_groups = [{'id': ','.join(id_chunk)} for 947 id_chunk in id_chunks] 948 949 items = [] 950 update_with = kwargs.pop('params', {}) 951 for param_group in param_groups: 952 param_group.update(update_with) 953 kwargs['params'] = param_group 954 chunk = self.get_content(self.config['info'], *args, **kwargs) 955 items.extend(list(chunk)) 956 957 # if using ids, manually set the limit 958 if kwargs.get('limit'): 959 items = items[:kwargs['limit']] 960 961 if return_list: 962 return items if items else None 963 elif items: 964 return items[0] 965 else: 966 return None 967 968 @decorators.restrict_access(scope='read') 969 def get_moderators(self, subreddit, **kwargs): 970 """Return the list of moderators for the given subreddit.""" 971 url = self.config['moderators'].format( 972 subreddit=six.text_type(subreddit)) 973 return self.request_json(url, **kwargs) 974 975 @decorators.restrict_access(scope='read') 976 def get_new(self, *args, **kwargs): 977 """Return a get_content generator for new submissions. 978 979 Corresponds to the submissions provided by 980 ``https://www.reddit.com/new/`` for the session. 981 982 The additional parameters are passed directly into 983 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 984 985 """ 986 return self.get_content(self.config['new'], *args, **kwargs) 987 988 def get_new_subreddits(self, *args, **kwargs): 989 """Return a get_content generator for the newest subreddits. 990 991 The additional parameters are passed directly into 992 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 993 994 """ 995 url = self.config['new_subreddits'] 996 return self.get_content(url, *args, **kwargs) 997 998 def get_popular_subreddits(self, *args, **kwargs): 999 """Return a get_content generator for the most active subreddits. 1000 1001 The additional parameters are passed directly into 1002 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 1003 1004 """ 1005 url = self.config['popular_subreddits'] 1006 return self.get_content(url, *args, **kwargs) 1007 1008 def get_random_subreddit(self, nsfw=False): 1009 """Return a random Subreddit object. 1010 1011 :param nsfw: When true, return a random NSFW Subreddit object. Calling 1012 in this manner will set the 'over18' cookie for the duration of the 1013 PRAW session. 1014 1015 """ 1016 path = 'random' 1017 if nsfw: 1018 self.http.cookies.set('over18', '1') 1019 path = 'randnsfw' 1020 url = self.config['subreddit'].format(subreddit=path) 1021 response = self._request(url, params={'unique': self._unique_count}, 1022 raw_response=True) 1023 self._unique_count += 1 1024 return self.get_subreddit(response.url.rsplit('/', 2)[-2]) 1025 1026 def get_random_submission(self, subreddit='all'): 1027 """Return a random Submission object. 1028 1029 :param subreddit: Limit the submission to the specified 1030 subreddit(s). Default: all 1031 1032 """ 1033 url = self.config['subreddit_random'].format( 1034 subreddit=six.text_type(subreddit)) 1035 try: 1036 item = self.request_json(url, 1037 params={'unique': self._unique_count}) 1038 self._unique_count += 1 # Avoid network-level caching 1039 return objects.Submission.from_json(item) 1040 except errors.RedirectException as exc: 1041 self._unique_count += 1 1042 return self.get_submission(exc.response_url) 1043 raise errors.ClientException('Expected exception not raised.') 1044 1045 def get_redditor(self, user_name, *args, **kwargs): 1046 """Return a Redditor instance for the user_name specified. 1047 1048 The additional parameters are passed directly into the 1049 :class:`.Redditor` constructor. 1050 1051 """ 1052 return objects.Redditor(self, user_name, *args, **kwargs) 1053 1054 @decorators.restrict_access(scope='read') 1055 def get_rising(self, *args, **kwargs): 1056 """Return a get_content generator for rising submissions. 1057 1058 Corresponds to the submissions provided by 1059 ``https://www.reddit.com/rising/`` for the session. 1060 1061 The additional parameters are passed directly into 1062 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 1063 1064 """ 1065 return self.get_content(self.config['rising'], *args, **kwargs) 1066 1067 @decorators.restrict_access(scope='read') 1068 def get_rules(self, subreddit, bottom=False): 1069 """Return the json dictionary containing rules for a subreddit. 1070 1071 :param subreddit: The subreddit whose rules we will return. 1072 1073 """ 1074 url = self.config['rules'].format(subreddit=six.text_type(subreddit)) 1075 return self.request_json(url) 1076 1077 @decorators.restrict_access(scope='read') 1078 def get_sticky(self, subreddit, bottom=False): 1079 """Return a Submission object for the sticky of the subreddit. 1080 1081 :param bottom: Get the top or bottom sticky. If the subreddit has only 1082 a single sticky, it is considered the top one. 1083 1084 """ 1085 url = self.config['sticky'].format(subreddit=six.text_type(subreddit)) 1086 param = {'num': 2} if bottom else None 1087 return objects.Submission.from_json(self.request_json(url, 1088 params=param)) 1089 1090 def get_submission(self, url=None, submission_id=None, comment_limit=0, 1091 comment_sort=None, params=None): 1092 """Return a Submission object for the given url or submission_id. 1093 1094 :param comment_limit: The desired number of comments to fetch. If <= 0 1095 fetch the default number for the session's user. If None, fetch the 1096 maximum possible. 1097 :param comment_sort: The sort order for retrieved comments. When None 1098 use the default for the session's user. 1099 :param params: Dictionary containing extra GET data to put in the url. 1100 1101 """ 1102 if bool(url) == bool(submission_id): 1103 raise TypeError('One (and only one) of id or url is required!') 1104 if submission_id: 1105 url = urljoin(self.config['comments'], submission_id) 1106 return objects.Submission.from_url(self, url, 1107 comment_limit=comment_limit, 1108 comment_sort=comment_sort, 1109 params=params) 1110 1111 def get_submissions(self, fullnames, *args, **kwargs): 1112 """Generate Submission objects for each item provided in `fullnames`. 1113 1114 A submission fullname looks like `t3_<base36_id>`. Submissions are 1115 yielded in the same order they appear in `fullnames`. 1116 1117 Up to 100 items are batched at a time -- this happens transparently. 1118 1119 The additional parameters are passed directly into 1120 :meth:`.get_content`. Note: the `url` and `limit` parameters cannot be 1121 altered. 1122 1123 """ 1124 fullnames = fullnames[:] 1125 while fullnames: 1126 cur = fullnames[:100] 1127 fullnames[:100] = [] 1128 url = self.config['by_id'] + ','.join(cur) 1129 for item in self.get_content(url, limit=len(cur), *args, **kwargs): 1130 yield item 1131 1132 def get_subreddit(self, subreddit_name, *args, **kwargs): 1133 """Return a Subreddit object for the subreddit_name specified. 1134 1135 The additional parameters are passed directly into the 1136 :class:`.Subreddit` constructor. 1137 1138 """ 1139 sr_name_lower = subreddit_name.lower() 1140 if sr_name_lower == 'random': 1141 return self.get_random_subreddit() 1142 elif sr_name_lower == 'randnsfw': 1143 return self.get_random_subreddit(nsfw=True) 1144 return objects.Subreddit(self, subreddit_name, *args, **kwargs) 1145 1146 def get_subreddit_recommendations(self, subreddits, omit=None): 1147 """Return a list of recommended subreddits as Subreddit objects. 1148 1149 Subreddits with activity less than a certain threshold, will not have 1150 any recommendations due to lack of data. 1151 1152 :param subreddits: A list of subreddits (either names or Subreddit 1153 objects) to base the recommendations on. 1154 :param omit: A list of subreddits (either names or Subreddit 1155 objects) that will be filtered out of the result. 1156 1157 """ 1158 params = {'omit': _to_reddit_list(omit or [])} 1159 url = self.config['sub_recommendations'].format( 1160 subreddits=_to_reddit_list(subreddits)) 1161 result = self.request_json(url, params=params) 1162 return [objects.Subreddit(self, sub['sr_name']) for sub in result] 1163 1164 @decorators.restrict_access(scope='read') 1165 def get_top(self, *args, **kwargs): 1166 """Return a get_content generator for top submissions. 1167 1168 Corresponds to the submissions provided by 1169 ``https://www.reddit.com/top/`` for the session. 1170 1171 The additional parameters are passed directly into 1172 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 1173 1174 """ 1175 return self.get_content(self.config['top'], *args, **kwargs) 1176 1177 @decorators.restrict_access(scope='read') 1178 def get_gilded(self, *args, **kwargs): 1179 """Return a get_content generator for gilded submissions. 1180 1181 Corresponds to the submissions provided by 1182 ``https://www.reddit.com/gilded/`` for the session. 1183 1184 The additional parameters are passed directly into 1185 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 1186 1187 """ 1188 return self.get_content(self.config['gilded'], *args, **kwargs) 1189 1190 # There exists a `modtraffic` scope, but it is unused. 1191 @decorators.restrict_access(scope='modconfig') 1192 def get_traffic(self, subreddit): 1193 """Return the json dictionary containing traffic stats for a subreddit. 1194 1195 :param subreddit: The subreddit whose /about/traffic page we will 1196 collect. 1197 1198 """ 1199 url = self.config['subreddit_traffic'].format( 1200 subreddit=six.text_type(subreddit)) 1201 return self.request_json(url) 1202 1203 @decorators.restrict_access(scope='wikiread', login=False) 1204 def get_wiki_page(self, subreddit, page): 1205 """Return a WikiPage object for the subreddit and page provided.""" 1206 return objects.WikiPage(self, six.text_type(subreddit), page.lower()) 1207 1208 @decorators.restrict_access(scope='wikiread', login=False) 1209 def get_wiki_pages(self, subreddit): 1210 """Return a list of WikiPage objects for the subreddit.""" 1211 url = self.config['wiki_pages'].format( 1212 subreddit=six.text_type(subreddit)) 1213 return self.request_json(url) 1214 1215 def is_username_available(self, username): 1216 """Return True if username is valid and available, otherwise False.""" 1217 params = {'user': username} 1218 try: 1219 result = self.request_json(self.config['username_available'], 1220 params=params) 1221 except errors.BadUsername: 1222 return False 1223 return result 1224 1225 def search(self, query, subreddit=None, sort=None, syntax=None, 1226 period=None, *args, **kwargs): 1227 """Return a generator for submissions that match the search query. 1228 1229 :param query: The query string to search for. If query is a URL only 1230 submissions which link to that URL will be returned. 1231 :param subreddit: Limit search results to the subreddit if provided. 1232 :param sort: The sort order of the results. 1233 :param syntax: The syntax of the search query. 1234 :param period: The time period of the results. 1235 1236 The additional parameters are passed directly into 1237 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 1238 1239 See https://www.reddit.com/wiki/search for more information on how to 1240 build a search query. 1241 1242 """ 1243 params = {'q': query} 1244 if 'params' in kwargs: 1245 params.update(kwargs['params']) 1246 kwargs.pop('params') 1247 if sort: 1248 params['sort'] = sort 1249 if syntax: 1250 params['syntax'] = syntax 1251 if period: 1252 params['t'] = period 1253 if subreddit: 1254 params['restrict_sr'] = 'on' 1255 subreddit = six.text_type(subreddit) 1256 else: 1257 subreddit = 'all' 1258 url = self.config['search'].format(subreddit=subreddit) 1259 1260 depth = 2 1261 while depth > 0: 1262 depth -= 1 1263 try: 1264 for item in self.get_content(url, params=params, *args, 1265 **kwargs): 1266 yield item 1267 break 1268 except errors.RedirectException as exc: 1269 parsed = urlparse(exc.response_url) 1270 params = dict((k, ",".join(v)) for k, v in 1271 parse_qs(parsed.query).items()) 1272 url = urlunparse(parsed[:3] + ("", "", "")) 1273 # Handle redirects from URL searches 1274 if 'already_submitted' in params: 1275 yield self.get_submission(url) 1276 break 1277 1278 def search_reddit_names(self, query): 1279 """Return subreddits whose display name contains the query.""" 1280 data = {'query': query} 1281 results = self.request_json(self.config['search_reddit_names'], 1282 data=data) 1283 return [self.get_subreddit(name) for name in results['names']] 1284 1285 1286class AuthenticatedReddit(OAuth2Reddit, UnauthenticatedReddit): 1287 """This class adds the methods necessary for authenticating with reddit. 1288 1289 Authentication can either be login based 1290 (through :meth:`~praw.__init__.AuthenticatedReddit.login`), or OAuth2 based 1291 (via :meth:`~praw.__init__.AuthenticatedReddit.set_access_credentials`). 1292 1293 You should **not** directly instantiate instances of this class. Use 1294 :class:`.Reddit` instead. 1295 1296 """ 1297 1298 def __init__(self, *args, **kwargs): 1299 """Initialize an AuthenticatedReddit instance.""" 1300 super(AuthenticatedReddit, self).__init__(*args, **kwargs) 1301 # Add variable to distinguish between authentication type 1302 # * None means unauthenticated 1303 # * True mean login authenticated 1304 # * set(...) means OAuth authenticated with the scopes in the set 1305 self._authentication = None 1306 self.access_token = None 1307 self.refresh_token = self.config.refresh_token or None 1308 self.user = None 1309 1310 def __str__(self): 1311 """Return a string representation of the AuthenticatedReddit.""" 1312 if isinstance(self._authentication, set): 1313 return 'OAuth2 reddit session (scopes: {0})'.format( 1314 ', '.join(self._authentication)) 1315 elif self._authentication: 1316 return 'LoggedIn reddit session (user: {0})'.format(self.user) 1317 else: 1318 return 'Unauthenticated reddit session' 1319 1320 def _url_update(self, url): 1321 # When getting posts from a multireddit owned by the authenticated 1322 # Redditor, we are redirected to me/m/multi/. Handle that now 1323 # instead of catching later. 1324 if re.search('user/.*/m/.*', url): 1325 redditor = url.split('/')[-4] 1326 if self.user and self.user.name.lower() == redditor.lower(): 1327 url = url.replace("user/"+redditor, 'me') 1328 return url 1329 1330 @decorators.restrict_access(scope='modself', mod=False) 1331 def accept_moderator_invite(self, subreddit): 1332 """Accept a moderator invite to the given subreddit. 1333 1334 Callable upon an instance of Subreddit with no arguments. 1335 1336 :returns: The json response from the server. 1337 1338 """ 1339 data = {'r': six.text_type(subreddit)} 1340 # Clear moderated subreddits and cache 1341 self.user._mod_subs = None # pylint: disable=W0212 1342 self.evict(self.config['my_mod_subreddits']) 1343 return self.request_json(self.config['accept_mod_invite'], data=data) 1344 1345 def clear_authentication(self): 1346 """Clear any existing authentication on the reddit object. 1347 1348 This function is implicitly called on `login` and 1349 `set_access_credentials`. 1350 1351 """ 1352 self._authentication = None 1353 self.access_token = None 1354 self.refresh_token = None 1355 self.http.cookies.clear() 1356 self.user = None 1357 1358 def delete(self, password, message=""): 1359 """Delete the currently authenticated redditor. 1360 1361 WARNING! 1362 1363 This action is IRREVERSIBLE. Use only if you're okay with NEVER 1364 accessing this reddit account again. 1365 1366 :param password: password for currently authenticated account 1367 :param message: optional 'reason for deletion' message. 1368 :returns: json response from the server. 1369 1370 """ 1371 data = {'user': self.user.name, 1372 'passwd': password, 1373 'delete_message': message, 1374 'confirm': True} 1375 return self.request_json(self.config['delete_redditor'], data=data) 1376 1377 @decorators.restrict_access(scope='wikiedit') 1378 def edit_wiki_page(self, subreddit, page, content, reason=''): 1379 """Create or edit a wiki page with title `page` for `subreddit`. 1380 1381 :returns: The json response from the server. 1382 1383 """ 1384 data = {'content': content, 1385 'page': page, 1386 'r': six.text_type(subreddit), 1387 'reason': reason} 1388 evict = self.config['wiki_page'].format( 1389 subreddit=six.text_type(subreddit), page=page.lower()) 1390 self.evict(evict) 1391 return self.request_json(self.config['wiki_edit'], data=data) 1392 1393 def get_access_information(self, code, # pylint: disable=W0221 1394 update_session=True): 1395 """Return the access information for an OAuth2 authorization grant. 1396 1397 :param code: the code received in the request from the OAuth2 server 1398 :param update_session: Update the current session with the retrieved 1399 token(s). 1400 :returns: A dictionary with the key/value pairs for access_token, 1401 refresh_token and scope. The refresh_token value will be done when 1402 the OAuth2 grant is not refreshable. 1403 1404 """ 1405 retval = super(AuthenticatedReddit, self).get_access_information(code) 1406 if update_session: 1407 self.set_access_credentials(**retval) 1408 return retval 1409 1410 @decorators.restrict_access(scope='flair') 1411 def get_flair_choices(self, subreddit, link=None): 1412 """Return available flair choices and current flair. 1413 1414 :param link: If link is given, return the flair options for this 1415 submission. Not normally given directly, but instead set by calling 1416 the flair_choices method for Submission objects. 1417 Use the default for the session's user. 1418 1419 :returns: A dictionary with 2 keys. 'current' containing current flair 1420 settings for the authenticated user and 'choices' containing a list 1421 of possible flair choices. 1422 1423 """ 1424 data = {'r': six.text_type(subreddit), 'link': link} 1425 return self.request_json(self.config['flairselector'], data=data) 1426 1427 @decorators.restrict_access(scope='read', login=True) 1428 def get_friends(self, **params): 1429 """Return a UserList of Redditors with whom the user is friends.""" 1430 url = self.config['friends'] 1431 return self.request_json(url, params=params)[0] 1432 1433 @decorators.restrict_access(scope='identity', oauth_only=True) 1434 def get_me(self): 1435 """Return a LoggedInRedditor object. 1436 1437 Note: This function is only intended to be used with an 'identity' 1438 providing OAuth2 grant. 1439 """ 1440 response = self.request_json(self.config['me']) 1441 user = objects.Redditor(self, response['name'], response) 1442 user.__class__ = objects.LoggedInRedditor 1443 return user 1444 1445 def has_scope(self, scope): 1446 """Return True if OAuth2 authorized for the passed in scope(s).""" 1447 if not self.is_oauth_session(): 1448 return False 1449 if '*' in self._authentication: 1450 return True 1451 if isinstance(scope, six.string_types): 1452 scope = [scope] 1453 return all(s in self._authentication for s in scope) 1454 1455 def is_logged_in(self): 1456 """Return True when the session is authenticated via username/password. 1457 1458 Username and passwords are provided via 1459 :meth:`~praw.__init__.AuthenticatedReddit.login`. 1460 1461 """ 1462 return self._authentication is True 1463 1464 def is_oauth_session(self): 1465 """Return True when the current session is an OAuth2 session.""" 1466 return isinstance(self._authentication, set) 1467 1468 @decorators.deprecated('reddit intends to disable password-based ' 1469 'authentication of API clients sometime in the ' 1470 'near future. As a result this method will be ' 1471 'removed in a future major version of PRAW.\n\n' 1472 'For more information please see:\n\n' 1473 '* Original reddit deprecation notice: ' 1474 'https://www.reddit.com/comments/2ujhkr/\n\n' 1475 '* Updated delayed deprecation notice: ' 1476 'https://www.reddit.com/comments/37e2mv/\n\n' 1477 'Pass ``disable_warning=True`` to ``login`` to ' 1478 'disable this warning.') 1479 def login(self, username=None, password=None, **kwargs): 1480 """Login to a reddit site. 1481 1482 **DEPRECATED**. Will be removed in a future version of PRAW. 1483 1484 https://www.reddit.com/comments/2ujhkr/ 1485 https://www.reddit.com/comments/37e2mv/ 1486 1487 Look for username first in parameter, then praw.ini and finally if both 1488 were empty get it from stdin. Look for password in parameter, then 1489 praw.ini (but only if username matches that in praw.ini) and finally 1490 if they both are empty get it with getpass. Add the variables ``user`` 1491 (username) and ``pswd`` (password) to your praw.ini file to allow for 1492 auto-login. 1493 1494 A successful login will overwrite any existing authentication. 1495 1496 """ 1497 if password and not username: 1498 raise Exception('Username must be provided when password is.') 1499 user = username or self.config.user 1500 if not user: 1501 sys.stdout.write('Username: ') 1502 sys.stdout.flush() 1503 user = sys.stdin.readline().strip() 1504 pswd = None 1505 else: 1506 pswd = password or self.config.pswd 1507 if not pswd: 1508 import getpass 1509 pswd = getpass.getpass('Password for {0}: '.format(user) 1510 .encode('ascii', 'ignore')) 1511 1512 data = {'passwd': pswd, 1513 'user': user} 1514 self.clear_authentication() 1515 self.request_json(self.config['login'], data=data) 1516 # Update authentication settings 1517 self._authentication = True 1518 self.user = self.get_redditor(user) 1519 self.user.__class__ = objects.LoggedInRedditor 1520 1521 def refresh_access_information(self, # pylint: disable=W0221 1522 refresh_token=None, 1523 update_session=True): 1524 """Return updated access information for an OAuth2 authorization grant. 1525 1526 :param refresh_token: The refresh token used to obtain the updated 1527 information. When not provided, use the stored refresh_token. 1528 :param update_session: Update the session with the returned data. 1529 :returns: A dictionary with the key/value pairs for ``access_token``, 1530 ``refresh_token`` and ``scope``. The ``refresh_token`` value will 1531 be None when the OAuth2 grant is not refreshable. The ``scope`` 1532 value will be a set containing the scopes the tokens are valid for. 1533 1534 """ 1535 response = super(AuthenticatedReddit, self).refresh_access_information( 1536 refresh_token=refresh_token or self.refresh_token) 1537 if update_session: 1538 self.set_access_credentials(**response) 1539 return response 1540 1541 @decorators.restrict_access(scope='flair') 1542 def select_flair(self, item, flair_template_id='', flair_text=''): 1543 """Select user flair or link flair on subreddits. 1544 1545 This can only be used for assigning your own name flair or link flair 1546 on your own submissions. For assigning other's flairs using moderator 1547 access, see :meth:`~praw.__init__.ModFlairMixin.set_flair`. 1548 1549 :param item: A string, Subreddit object (for user flair), or 1550 Submission object (for link flair). If ``item`` is a string it 1551 will be treated as the name of a Subreddit. 1552 :param flair_template_id: The id for the desired flair template. Use 1553 the :meth:`~praw.objects.Subreddit.get_flair_choices` and 1554 :meth:`~praw.objects.Submission.get_flair_choices` methods to find 1555 the ids for the available user and link flair choices. 1556 :param flair_text: A string containing the custom flair text. 1557 Used on subreddits that allow it. 1558 1559 :returns: The json response from the server. 1560 1561 """ 1562 data = {'flair_template_id': flair_template_id or '', 1563 'text': flair_text or ''} 1564 if isinstance(item, objects.Submission): 1565 # Link flair 1566 data['link'] = item.fullname 1567 evict = item.permalink 1568 else: 1569 # User flair 1570 data['name'] = self.user.name 1571 data['r'] = six.text_type(item) 1572 evict = self.config['flairlist'].format( 1573 subreddit=six.text_type(item)) 1574 response = self.request_json(self.config['select_flair'], data=data) 1575 self.evict(evict) 1576 return response 1577 1578 @decorators.require_oauth 1579 def set_access_credentials(self, scope, access_token, refresh_token=None, 1580 update_user=True): 1581 """Set the credentials used for OAuth2 authentication. 1582 1583 Calling this function will overwrite any currently existing access 1584 credentials. 1585 1586 :param scope: A set of reddit scopes the tokens provide access to 1587 :param access_token: the access token of the authentication 1588 :param refresh_token: the refresh token of the authentication 1589 :param update_user: Whether or not to set the user attribute for 1590 identity scopes 1591 1592 """ 1593 if isinstance(scope, (list, tuple)): 1594 scope = set(scope) 1595 elif isinstance(scope, six.string_types): 1596 scope = set(scope.split()) 1597 if not isinstance(scope, set): 1598 raise TypeError('`scope` parameter must be a set') 1599 self.clear_authentication() 1600 # Update authentication settings 1601 self._authentication = scope 1602 self.access_token = access_token 1603 self.refresh_token = refresh_token 1604 # Update the user object 1605 if update_user and ('identity' in scope or '*' in scope): 1606 self.user = self.get_me() 1607 1608 1609class ModConfigMixin(AuthenticatedReddit): 1610 """Adds methods requiring the 'modconfig' scope (or mod access). 1611 1612 You should **not** directly instantiate instances of this class. Use 1613 :class:`.Reddit` instead. 1614 1615 """ 1616 1617 @decorators.restrict_access(scope='modconfig', mod=False) 1618 @decorators.require_captcha 1619 def create_subreddit(self, name, title, description='', language='en', 1620 subreddit_type='public', content_options='any', 1621 over_18=False, default_set=True, show_media=False, 1622 domain='', wikimode='disabled', captcha=None, 1623 **kwargs): 1624 """Create a new subreddit. 1625 1626 :returns: The json response from the server. 1627 1628 This function may result in a captcha challenge. PRAW will 1629 automatically prompt you for a response. See :ref:`handling-captchas` 1630 if you want to manually handle captchas. 1631 1632 """ 1633 data = {'name': name, 1634 'title': title, 1635 'description': description, 1636 'lang': language, 1637 'type': subreddit_type, 1638 'link_type': content_options, 1639 'over_18': 'on' if over_18 else 'off', 1640 'allow_top': 'on' if default_set else 'off', 1641 'show_media': 'on' if show_media else 'off', 1642 'wikimode': wikimode, 1643 'domain': domain} 1644 if captcha: 1645 data.update(captcha) 1646 return self.request_json(self.config['site_admin'], data=data) 1647 1648 @decorators.restrict_access(scope='modconfig') 1649 def delete_image(self, subreddit, name=None, header=False): 1650 """Delete an image from the subreddit. 1651 1652 :param name: The name of the image if removing a CSS image. 1653 :param header: When true, delete the subreddit header. 1654 :returns: The json response from the server. 1655 1656 """ 1657 subreddit = six.text_type(subreddit) 1658 if name and header: 1659 raise TypeError('Both name and header cannot be set.') 1660 elif name: 1661 data = {'img_name': name} 1662 url = self.config['delete_sr_image'] 1663 self.evict(self.config['stylesheet'].format(subreddit=subreddit)) 1664 else: 1665 data = True 1666 url = self.config['delete_sr_header'] 1667 url = url.format(subreddit=subreddit) 1668 return self.request_json(url, data=data) 1669 1670 @decorators.restrict_access(scope='modconfig') 1671 def get_settings(self, subreddit, **params): 1672 """Return the settings for the given subreddit.""" 1673 url = self.config['subreddit_settings'].format( 1674 subreddit=six.text_type(subreddit)) 1675 return self.request_json(url, params=params)['data'] 1676 1677 @decorators.restrict_access(scope='modconfig') 1678 def set_settings(self, subreddit, title, public_description='', 1679 description='', language='en', subreddit_type='public', 1680 content_options='any', over_18=False, default_set=True, 1681 show_media=False, domain='', domain_css=False, 1682 domain_sidebar=False, header_hover_text='', 1683 wikimode='disabled', wiki_edit_age=30, 1684 wiki_edit_karma=100, 1685 submit_link_label='', submit_text_label='', 1686 exclude_banned_modqueue=False, comment_score_hide_mins=0, 1687 public_traffic=False, collapse_deleted_comments=False, 1688 spam_comments='low', spam_links='high', 1689 spam_selfposts='high', submit_text='', 1690 hide_ads=False, suggested_comment_sort='', 1691 key_color='', 1692 **kwargs): 1693 """Set the settings for the given subreddit. 1694 1695 :param subreddit: Must be a subreddit object. 1696 :returns: The json response from the server. 1697 1698 """ 1699 data = {'sr': subreddit.fullname, 1700 'allow_top': default_set, 1701 'comment_score_hide_mins': comment_score_hide_mins, 1702 'collapse_deleted_comments': collapse_deleted_comments, 1703 'description': description, 1704 'domain': domain or '', 1705 'domain_css': domain_css, 1706 'domain_sidebar': domain_sidebar, 1707 'exclude_banned_modqueue': exclude_banned_modqueue, 1708 'header-title': header_hover_text or '', 1709 'hide_ads': hide_ads, 1710 'key_color': key_color, 1711 'lang': language, 1712 'link_type': content_options, 1713 'over_18': over_18, 1714 'public_description': public_description, 1715 'public_traffic': public_traffic, 1716 'show_media': show_media, 1717 'submit_link_label': submit_link_label or '', 1718 'submit_text': submit_text, 1719 'submit_text_label': submit_text_label or '', 1720 'suggested_comment_sort': suggested_comment_sort or '', 1721 'spam_comments': spam_comments, 1722 'spam_links': spam_links, 1723 'spam_selfposts': spam_selfposts, 1724 'title': title, 1725 'type': subreddit_type, 1726 'wiki_edit_age': six.text_type(wiki_edit_age), 1727 'wiki_edit_karma': six.text_type(wiki_edit_karma), 1728 'wikimode': wikimode} 1729 1730 if kwargs: 1731 msg = 'Extra settings fields: {0}'.format(kwargs.keys()) 1732 warn_explicit(msg, UserWarning, '', 0) 1733 data.update(kwargs) 1734 evict = self.config['subreddit_settings'].format( 1735 subreddit=six.text_type(subreddit)) 1736 self.evict(evict) 1737 return self.request_json(self.config['site_admin'], data=data) 1738 1739 @decorators.restrict_access(scope='modconfig') 1740 def set_stylesheet(self, subreddit, stylesheet): 1741 """Set stylesheet for the given subreddit. 1742 1743 :returns: The json response from the server. 1744 1745 """ 1746 subreddit = six.text_type(subreddit) 1747 data = {'r': subreddit, 1748 'stylesheet_contents': stylesheet, 1749 'op': 'save'} # Options: save / preview 1750 self.evict(self.config['stylesheet'].format(subreddit=subreddit)) 1751 return self.request_json(self.config['subreddit_css'], data=data) 1752 1753 @decorators.restrict_access(scope='modconfig') 1754 def upload_image(self, subreddit, image_path, name=None, 1755 header=False, upload_as=None): 1756 """Upload an image to the subreddit. 1757 1758 :param image_path: A path to the jpg or png image you want to upload. 1759 :param name: The name to provide the image. When None the name will be 1760 filename less any extension. 1761 :param header: When True, upload the image as the subreddit header. 1762 :param upload_as: Must be `'jpg'`, `'png'` or `None`. When None, this 1763 will match the format of the image itself. In all cases where both 1764 this value and the image format is not png, reddit will also 1765 convert the image mode to RGBA. reddit optimizes the image 1766 according to this value. 1767 :returns: A link to the uploaded image. Raises an exception otherwise. 1768 1769 """ 1770 if name and header: 1771 raise TypeError('Both name and header cannot be set.') 1772 if upload_as not in (None, 'png', 'jpg'): 1773 raise TypeError("upload_as must be 'jpg', 'png', or None.") 1774 with open(image_path, 'rb') as image: 1775 image_type = upload_as or _image_type(image) 1776 data = {'r': six.text_type(subreddit), 'img_type': image_type} 1777 if header: 1778 data['header'] = 1 1779 else: 1780 if not name: 1781 name = os.path.splitext(os.path.basename(image.name))[0] 1782 data['name'] = name 1783 1784 response = json.loads(self._request( 1785 self.config['upload_image'], data=data, files={'file': image}, 1786 method=to_native_string('POST'), retry_on_error=False)) 1787 1788 if response['errors']: 1789 raise errors.APIException(response['errors'], None) 1790 return response['img_src'] 1791 1792 def update_settings(self, subreddit, **kwargs): 1793 """Update only the given settings for the given subreddit. 1794 1795 The settings to update must be given by keyword and match one of the 1796 parameter names in `set_settings`. 1797 1798 :returns: The json response from the server. 1799 1800 """ 1801 settings = self.get_settings(subreddit) 1802 settings.update(kwargs) 1803 del settings['subreddit_id'] 1804 return self.set_settings(subreddit, **settings) 1805 1806 1807class ModFlairMixin(AuthenticatedReddit): 1808 """Adds methods requiring the 'modflair' scope (or mod access). 1809 1810 You should **not** directly instantiate instances of this class. Use 1811 :class:`.Reddit` instead. 1812 1813 """ 1814 1815 @decorators.restrict_access(scope='modflair') 1816 def add_flair_template(self, subreddit, text='', css_class='', 1817 text_editable=False, is_link=False): 1818 """Add a flair template to the given subreddit. 1819 1820 :returns: The json response from the server. 1821 1822 """ 1823 data = {'r': six.text_type(subreddit), 1824 'text': text, 1825 'css_class': css_class, 1826 'text_editable': six.text_type(text_editable), 1827 'flair_type': 'LINK_FLAIR' if is_link else 'USER_FLAIR'} 1828 return self.request_json(self.config['flairtemplate'], data=data) 1829 1830 @decorators.restrict_access(scope='modflair') 1831 def clear_flair_templates(self, subreddit, is_link=False): 1832 """Clear flair templates for the given subreddit. 1833 1834 :returns: The json response from the server. 1835 1836 """ 1837 data = {'r': six.text_type(subreddit), 1838 'flair_type': 'LINK_FLAIR' if is_link else 'USER_FLAIR'} 1839 return self.request_json(self.config['clearflairtemplates'], data=data) 1840 1841 @decorators.restrict_access(scope='modflair') 1842 def configure_flair(self, subreddit, flair_enabled=False, 1843 flair_position='right', 1844 flair_self_assign=False, 1845 link_flair_enabled=False, 1846 link_flair_position='left', 1847 link_flair_self_assign=False): 1848 """Configure the flair setting for the given subreddit. 1849 1850 :returns: The json response from the server. 1851 1852 """ 1853 flair_enabled = 'on' if flair_enabled else 'off' 1854 flair_self_assign = 'on' if flair_self_assign else 'off' 1855 if not link_flair_enabled: 1856 link_flair_position = '' 1857 link_flair_self_assign = 'on' if link_flair_self_assign else 'off' 1858 data = {'r': six.text_type(subreddit), 1859 'flair_enabled': flair_enabled, 1860 'flair_position': flair_position, 1861 'flair_self_assign_enabled': flair_self_assign, 1862 'link_flair_position': link_flair_position, 1863 'link_flair_self_assign_enabled': link_flair_self_assign} 1864 return self.request_json(self.config['flairconfig'], data=data) 1865 1866 @decorators.restrict_access(scope='modflair') 1867 def delete_flair(self, subreddit, user): 1868 """Delete the flair for the given user on the given subreddit. 1869 1870 :returns: The json response from the server. 1871 1872 """ 1873 data = {'r': six.text_type(subreddit), 1874 'name': six.text_type(user)} 1875 return self.request_json(self.config['deleteflair'], data=data) 1876 1877 @decorators.restrict_access(scope='modflair') 1878 def get_flair_list(self, subreddit, *args, **kwargs): 1879 """Return a get_content generator of flair mappings. 1880 1881 :param subreddit: Either a Subreddit object or the name of the 1882 subreddit to return the flair list for. 1883 1884 The additional parameters are passed directly into 1885 :meth:`.get_content`. Note: the `url`, `root_field`, `thing_field`, and 1886 `after_field` parameters cannot be altered. 1887 1888 """ 1889 url = self.config['flairlist'].format( 1890 subreddit=six.text_type(subreddit)) 1891 return self.get_content(url, *args, root_field=None, 1892 thing_field='users', after_field='next', 1893 **kwargs) 1894 1895 @decorators.restrict_access(scope='modflair') 1896 def set_flair(self, subreddit, item, flair_text='', flair_css_class=''): 1897 """Set flair for the user in the given subreddit. 1898 1899 `item` can be a string, Redditor object, or Submission object. 1900 If `item` is a string it will be treated as the name of a Redditor. 1901 1902 This method can only be called by a subreddit moderator with flair 1903 permissions. To set flair on yourself or your own links use 1904 :meth:`~praw.__init__.AuthenticatedReddit.select_flair`. 1905 1906 :returns: The json response from the server. 1907 1908 """ 1909 data = {'r': six.text_type(subreddit), 1910 'text': flair_text or '', 1911 'css_class': flair_css_class or ''} 1912 if isinstance(item, objects.Submission): 1913 data['link'] = item.fullname 1914 evict = item.permalink 1915 else: 1916 data['name'] = six.text_type(item) 1917 evict = self.config['flairlist'].format( 1918 subreddit=six.text_type(subreddit)) 1919 response = self.request_json(self.config['flair'], data=data) 1920 self.evict(evict) 1921 return response 1922 1923 @decorators.restrict_access(scope='modflair') 1924 def set_flair_csv(self, subreddit, flair_mapping): 1925 """Set flair for a group of users in the given subreddit. 1926 1927 flair_mapping should be a list of dictionaries with the following keys: 1928 `user`: the user name, 1929 `flair_text`: the flair text for the user (optional), 1930 `flair_css_class`: the flair css class for the user (optional) 1931 1932 :returns: The json response from the server. 1933 1934 """ 1935 if not flair_mapping: 1936 raise errors.ClientException('flair_mapping must be set') 1937 item_order = ['user', 'flair_text', 'flair_css_class'] 1938 lines = [] 1939 for mapping in flair_mapping: 1940 if 'user' not in mapping: 1941 raise errors.ClientException('flair_mapping must ' 1942 'contain `user` key') 1943 lines.append(','.join([mapping.get(x, '') for x in item_order])) 1944 response = [] 1945 while len(lines): 1946 data = {'r': six.text_type(subreddit), 1947 'flair_csv': '\n'.join(lines[:100])} 1948 response.extend(self.request_json(self.config['flaircsv'], 1949 data=data)) 1950 lines = lines[100:] 1951 evict = self.config['flairlist'].format( 1952 subreddit=six.text_type(subreddit)) 1953 self.evict(evict) 1954 return response 1955 1956 1957class ModLogMixin(AuthenticatedReddit): 1958 """Adds methods requiring the 'modlog' scope (or mod access). 1959 1960 You should **not** directly instantiate instances of this class. Use 1961 :class:`.Reddit` instead. 1962 1963 """ 1964 1965 @decorators.restrict_access(scope='modlog') 1966 def get_mod_log(self, subreddit, mod=None, action=None, *args, **kwargs): 1967 """Return a get_content generator for moderation log items. 1968 1969 :param subreddit: Either a Subreddit object or the name of the 1970 subreddit to return the modlog for. 1971 :param mod: If given, only return the actions made by this moderator. 1972 Both a moderator name or Redditor object can be used here. 1973 :param action: If given, only return entries for the specified action. 1974 1975 The additional parameters are passed directly into 1976 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 1977 1978 """ 1979 params = kwargs.setdefault('params', {}) 1980 if mod is not None: 1981 params['mod'] = six.text_type(mod) 1982 if action is not None: 1983 params['type'] = six.text_type(action) 1984 url = self.config['modlog'].format(subreddit=six.text_type(subreddit)) 1985 return self.get_content(url, *args, **kwargs) 1986 1987 1988class ModOnlyMixin(AuthenticatedReddit): 1989 """Adds methods requiring the logged in moderator access. 1990 1991 You should **not** directly instantiate instances of this class. Use 1992 :class:`.Reddit` instead. 1993 1994 """ 1995 1996 def _get_userlist(self, url, user_only, *args, **kwargs): 1997 content = self.get_content(url, *args, **kwargs) 1998 for data in content: 1999 user = objects.Redditor(self, data['name'], fetch=False) 2000 user.id = data['id'].split('_')[1] 2001 if user_only: 2002 yield user 2003 else: 2004 data['name'] = user 2005 yield data 2006 2007 @decorators.restrict_access(scope='read', mod=True) 2008 def get_banned(self, subreddit, user_only=True, *args, **kwargs): 2009 """Return a get_content generator of banned users for the subreddit. 2010 2011 :param subreddit: The subreddit to get the banned user list for. 2012 :param user_only: When False, the generator yields a dictionary of data 2013 associated with the server response for that user. In such cases, 2014 the Redditor will be in key 'name' (default: True). 2015 2016 """ 2017 url = self.config['banned'].format(subreddit=six.text_type(subreddit)) 2018 return self._get_userlist(url, user_only, *args, **kwargs) 2019 2020 def get_contributors(self, subreddit, *args, **kwargs): 2021 """ 2022 Return a get_content generator of contributors for the given subreddit. 2023 2024 If it's a public subreddit, then authentication as a 2025 moderator of the subreddit is required. For protected/private 2026 subreddits only access is required. See issue #246. 2027 2028 """ 2029 # pylint: disable=W0613 2030 def get_contributors_helper(self, subreddit): 2031 # It is necessary to have the 'self' argument as it's needed in 2032 # restrict_access to determine what class the decorator is 2033 # operating on. 2034 url = self.config['contributors'].format( 2035 subreddit=six.text_type(subreddit)) 2036 return self._get_userlist(url, user_only=True, *args, **kwargs) 2037 2038 if self.is_logged_in(): 2039 if not isinstance(subreddit, objects.Subreddit): 2040 subreddit = self.get_subreddit(subreddit) 2041 if subreddit.subreddit_type == "public": 2042 decorator = decorators.restrict_access(scope='read', mod=True) 2043 return decorator(get_contributors_helper)(self, subreddit) 2044 return get_contributors_helper(self, subreddit) 2045 2046 @decorators.restrict_access(scope='read', mod=True) 2047 def get_edited(self, subreddit='mod', *args, **kwargs): 2048 """Return a get_content generator of edited items. 2049 2050 :param subreddit: Either a Subreddit object or the name of the 2051 subreddit to return the edited items for. Defaults to `mod` which 2052 includes items for all the subreddits you moderate. 2053 2054 The additional parameters are passed directly into 2055 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 2056 2057 """ 2058 url = self.config['edited'].format(subreddit=six.text_type(subreddit)) 2059 return self.get_content(url, *args, **kwargs) 2060 2061 @decorators.restrict_access(scope='privatemessages', mod=True) 2062 def get_mod_mail(self, subreddit='mod', *args, **kwargs): 2063 """Return a get_content generator for moderator messages. 2064 2065 :param subreddit: Either a Subreddit object or the name of the 2066 subreddit to return the moderator mail from. Defaults to `mod` 2067 which includes items for all the subreddits you moderate. 2068 2069 The additional parameters are passed directly into 2070 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 2071 2072 """ 2073 url = self.config['mod_mail'].format( 2074 subreddit=six.text_type(subreddit)) 2075 return self.get_content(url, *args, **kwargs) 2076 2077 @decorators.restrict_access(scope='read', mod=True) 2078 def get_mod_queue(self, subreddit='mod', *args, **kwargs): 2079 """Return a get_content generator for the moderator queue. 2080 2081 :param subreddit: Either a Subreddit object or the name of the 2082 subreddit to return the modqueue for. Defaults to `mod` which 2083 includes items for all the subreddits you moderate. 2084 2085 The additional parameters are passed directly into 2086 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 2087 2088 """ 2089 url = self.config['modqueue'].format( 2090 subreddit=six.text_type(subreddit)) 2091 return self.get_content(url, *args, **kwargs) 2092 2093 @decorators.restrict_access(scope='read', mod=True) 2094 def get_muted(self, subreddit, user_only=True, *args, **kwargs): 2095 """Return a get_content generator for modmail-muted users. 2096 2097 :param subreddit: Either a Subreddit object or the name of a subreddit 2098 to get the list of muted users from. 2099 2100 The additional parameters are passed directly into 2101 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 2102 2103 """ 2104 url = self.config['muted'].format(subreddit=six.text_type(subreddit)) 2105 return self._get_userlist(url, user_only, *args, **kwargs) 2106 2107 @decorators.restrict_access(scope='read', mod=True) 2108 def get_reports(self, subreddit='mod', *args, **kwargs): 2109 """Return a get_content generator of reported items. 2110 2111 :param subreddit: Either a Subreddit object or the name of the 2112 subreddit to return the reported items. Defaults to `mod` which 2113 includes items for all the subreddits you moderate. 2114 2115 The additional parameters are passed directly into 2116 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 2117 2118 """ 2119 url = self.config['reports'].format(subreddit=six.text_type(subreddit)) 2120 return self.get_content(url, *args, **kwargs) 2121 2122 @decorators.restrict_access(scope='read', mod=True) 2123 def get_spam(self, subreddit='mod', *args, **kwargs): 2124 """Return a get_content generator of spam-filtered items. 2125 2126 :param subreddit: Either a Subreddit object or the name of the 2127 subreddit to return the spam-filtered items for. Defaults to `mod` 2128 which includes items for all the subreddits you moderate. 2129 2130 The additional parameters are passed directly into 2131 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 2132 2133 """ 2134 url = self.config['spam'].format(subreddit=six.text_type(subreddit)) 2135 return self.get_content(url, *args, **kwargs) 2136 2137 @decorators.restrict_access('modconfig', mod=False, login=False) 2138 def get_stylesheet(self, subreddit, **params): 2139 """Return the stylesheet and images for the given subreddit.""" 2140 url = self.config['stylesheet'].format( 2141 subreddit=six.text_type(subreddit)) 2142 return self.request_json(url, params=params)['data'] 2143 2144 @decorators.restrict_access(scope='read', mod=True) 2145 def get_unmoderated(self, subreddit='mod', *args, **kwargs): 2146 """Return a get_content generator of unmoderated submissions. 2147 2148 :param subreddit: Either a Subreddit object or the name of the 2149 subreddit to return the unmoderated submissions for. Defaults to 2150 `mod` which includes items for all the subreddits you moderate. 2151 2152 The additional parameters are passed directly into 2153 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 2154 2155 """ 2156 url = self.config['unmoderated'].format( 2157 subreddit=six.text_type(subreddit)) 2158 return self.get_content(url, *args, **kwargs) 2159 2160 @decorators.restrict_access(scope='read', mod=True) 2161 def get_wiki_banned(self, subreddit, *args, **kwargs): 2162 """Return a get_content generator of users banned from the wiki.""" 2163 url = self.config['wiki_banned'].format( 2164 subreddit=six.text_type(subreddit)) 2165 return self._get_userlist(url, user_only=True, *args, **kwargs) 2166 2167 @decorators.restrict_access(scope='read', mod=True) 2168 def get_wiki_contributors(self, subreddit, *args, **kwargs): 2169 """Return a get_content generator of wiki contributors. 2170 2171 The returned users are those who have been approved as a wiki 2172 contributor by the moderators of the subreddit, Whether or not they've 2173 actually contributed to the wiki is irrellevant, their approval as wiki 2174 contributors is all that matters. 2175 2176 """ 2177 url = self.config['wiki_contributors'].format( 2178 subreddit=six.text_type(subreddit)) 2179 return self._get_userlist(url, user_only=True, *args, **kwargs) 2180 2181 2182class ModSelfMixin(AuthenticatedReddit): 2183 """Adds methods pertaining to the 'modself' OAuth scope (or login). 2184 2185 You should **not** directly instantiate instances of this class. Use 2186 :class:`.Reddit` instead. 2187 2188 """ 2189 2190 def leave_contributor(self, subreddit): 2191 """Abdicate approved submitter status in a subreddit. Use with care. 2192 2193 :param subreddit: The name of the subreddit to leave `status` from. 2194 2195 :returns: the json response from the server. 2196 """ 2197 return self._leave_status(subreddit, self.config['leavecontributor']) 2198 2199 def leave_moderator(self, subreddit): 2200 """Abdicate moderator status in a subreddit. Use with care. 2201 2202 :param subreddit: The name of the subreddit to leave `status` from. 2203 2204 :returns: the json response from the server. 2205 """ 2206 self.evict(self.config['my_mod_subreddits']) 2207 return self._leave_status(subreddit, self.config['leavemoderator']) 2208 2209 @decorators.restrict_access(scope='modself', mod=False) 2210 def _leave_status(self, subreddit, statusurl): 2211 """Abdicate status in a subreddit. 2212 2213 :param subreddit: The name of the subreddit to leave `status` from. 2214 :param statusurl: The API URL which will be used in the leave request. 2215 Please use :meth:`leave_contributor` or :meth:`leave_moderator` 2216 rather than setting this directly. 2217 2218 :returns: the json response from the server. 2219 """ 2220 if isinstance(subreddit, six.string_types): 2221 subreddit = self.get_subreddit(subreddit) 2222 2223 data = {'id': subreddit.fullname} 2224 return self.request_json(statusurl, data=data) 2225 2226 2227class MultiredditMixin(AuthenticatedReddit): 2228 """Adds methods pertaining to multireddits. 2229 2230 You should **not** directly instantiate instances of this class. Use 2231 :class:`.Reddit` instead. 2232 2233 """ 2234 2235 MULTI_PATH = '/user/{0}/m/{1}' 2236 2237 @decorators.restrict_access(scope='subscribe') 2238 def copy_multireddit(self, from_redditor, from_name, to_name=None, 2239 *args, **kwargs): 2240 """Copy a multireddit. 2241 2242 :param from_redditor: The username or Redditor object for the user 2243 who owns the original multireddit 2244 :param from_name: The name of the multireddit, belonging to 2245 from_redditor 2246 :param to_name: The name to copy the multireddit as. If None, uses 2247 the name of the original 2248 2249 The additional parameters are passed directly into 2250 :meth:`~praw.__init__.BaseReddit.request_json` 2251 2252 """ 2253 if to_name is None: 2254 to_name = from_name 2255 2256 from_multipath = self.MULTI_PATH.format(from_redditor, from_name) 2257 to_multipath = self.MULTI_PATH.format(self.user.name, to_name) 2258 data = {'display_name': to_name, 2259 'from': from_multipath, 2260 'to': to_multipath} 2261 return self.request_json(self.config['multireddit_copy'], data=data, 2262 *args, **kwargs) 2263 2264 @decorators.restrict_access(scope='subscribe') 2265 def create_multireddit(self, name, description_md=None, icon_name=None, 2266 key_color=None, subreddits=None, visibility=None, 2267 weighting_scheme=None, overwrite=False, 2268 *args, **kwargs): # pylint: disable=W0613 2269 """Create a new multireddit. 2270 2271 :param name: The name of the new multireddit. 2272 :param description_md: Optional description for the multireddit, 2273 formatted in markdown. 2274 :param icon_name: Optional, choose an icon name from this list: ``art 2275 and design``, ``ask``, ``books``, ``business``, ``cars``, 2276 ``comics``, ``cute animals``, ``diy``, ``entertainment``, ``food 2277 and drink``, ``funny``, ``games``, ``grooming``, ``health``, ``life 2278 advice``, ``military``, ``models pinup``, ``music``, ``news``, 2279 ``philosophy``, ``pictures and gifs``, ``science``, ``shopping``, 2280 ``sports``, ``style``, ``tech``, ``travel``, ``unusual stories``, 2281 ``video``, or ``None``. 2282 :param key_color: Optional rgb hex color code of the form `#xxxxxx`. 2283 :param subreddits: Optional list of subreddit names or Subreddit 2284 objects to initialize the Multireddit with. You can always 2285 add more later with 2286 :meth:`~praw.objects.Multireddit.add_subreddit`. 2287 :param visibility: Choose a privacy setting from this list: 2288 ``public``, ``private``, ``hidden``. Defaults to private if blank. 2289 :param weighting_scheme: Choose a weighting scheme from this list: 2290 ``classic``, ``fresh``. Defaults to classic if blank. 2291 :param overwrite: Allow for overwriting / updating multireddits. 2292 If False, and the multi name already exists, throw 409 error. 2293 If True, and the multi name already exists, use the given 2294 properties to update that multi. 2295 If True, and the multi name does not exist, create it normally. 2296 2297 :returns: The newly created Multireddit object. 2298 2299 The additional parameters are passed directly into 2300 :meth:`~praw.__init__.BaseReddit.request_json` 2301 2302 """ 2303 url = self.config['multireddit_about'].format(user=self.user.name, 2304 multi=name) 2305 if subreddits: 2306 subreddits = [{'name': six.text_type(sr)} for sr in subreddits] 2307 model = {} 2308 for key in ('description_md', 'icon_name', 'key_color', 'subreddits', 2309 'visibility', 'weighting_scheme'): 2310 value = locals()[key] 2311 if value: 2312 model[key] = value 2313 2314 method = 'PUT' if overwrite else 'POST' 2315 return self.request_json(url, data={'model': json.dumps(model)}, 2316 method=method, *args, **kwargs) 2317 2318 @decorators.restrict_access(scope='subscribe') 2319 def delete_multireddit(self, name, *args, **kwargs): 2320 """Delete a Multireddit. 2321 2322 Any additional parameters are passed directly into 2323 :meth:`~praw.__init__.BaseReddit.request` 2324 2325 """ 2326 url = self.config['multireddit_about'].format(user=self.user.name, 2327 multi=name) 2328 2329 # The modhash isn't necessary for OAuth requests 2330 if not self._use_oauth: 2331 self.http.headers['x-modhash'] = self.modhash 2332 2333 try: 2334 self.request(url, data={}, method='DELETE', *args, **kwargs) 2335 finally: 2336 if not self._use_oauth: 2337 del self.http.headers['x-modhash'] 2338 2339 def edit_multireddit(self, *args, **kwargs): 2340 """Edit a multireddit, or create one if it doesn't already exist. 2341 2342 See :meth:`create_multireddit` for accepted parameters. 2343 2344 """ 2345 return self.create_multireddit(*args, overwrite=True, **kwargs) 2346 2347 def get_multireddit(self, redditor, multi, *args, **kwargs): 2348 """Return a Multireddit object for the author and name specified. 2349 2350 :param redditor: The username or Redditor object of the user 2351 who owns the multireddit. 2352 :param multi: The name of the multireddit to fetch. 2353 2354 The additional parameters are passed directly into the 2355 :class:`.Multireddit` constructor. 2356 2357 """ 2358 return objects.Multireddit(self, six.text_type(redditor), multi, 2359 *args, **kwargs) 2360 2361 def get_multireddits(self, redditor, *args, **kwargs): 2362 """Return a list of multireddits belonging to a redditor. 2363 2364 :param redditor: The username or Redditor object to find multireddits 2365 from. 2366 :returns: The json response from the server 2367 2368 The additional parameters are passed directly into 2369 :meth:`~praw.__init__.BaseReddit.request_json` 2370 2371 If the requested redditor is the current user, all multireddits 2372 are visible. Otherwise, only public multireddits are returned. 2373 2374 """ 2375 redditor = six.text_type(redditor) 2376 url = self.config['multireddit_user'].format(user=redditor) 2377 return self.request_json(url, *args, **kwargs) 2378 2379 @decorators.restrict_access(scope='subscribe') 2380 def rename_multireddit(self, current_name, new_name, *args, **kwargs): 2381 """Rename a Multireddit. 2382 2383 :param current_name: The name of the multireddit to rename 2384 :param new_name: The new name to assign to this multireddit 2385 2386 The additional parameters are passed directly into 2387 :meth:`~praw.__init__.BaseReddit.request_json` 2388 2389 """ 2390 current_path = self.MULTI_PATH.format(self.user.name, current_name) 2391 new_path = self.MULTI_PATH.format(self.user.name, new_name) 2392 data = {'from': current_path, 2393 'to': new_path} 2394 return self.request_json(self.config['multireddit_rename'], data=data, 2395 *args, **kwargs) 2396 2397 2398class MySubredditsMixin(AuthenticatedReddit): 2399 """Adds methods requiring the 'mysubreddits' scope (or login). 2400 2401 You should **not** directly instantiate instances of this class. Use 2402 :class:`.Reddit` instead. 2403 2404 """ 2405 2406 @decorators.restrict_access(scope='mysubreddits') 2407 def get_my_contributions(self, *args, **kwargs): 2408 """Return a get_content generator of subreddits. 2409 2410 The Subreddits generated are those where the session's user is a 2411 contributor. 2412 2413 The additional parameters are passed directly into 2414 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 2415 2416 """ 2417 return self.get_content(self.config['my_con_subreddits'], *args, 2418 **kwargs) 2419 2420 @decorators.restrict_access(scope='mysubreddits') 2421 def get_my_moderation(self, *args, **kwargs): 2422 """Return a get_content generator of subreddits. 2423 2424 The Subreddits generated are those where the session's user is a 2425 moderator. 2426 2427 The additional parameters are passed directly into 2428 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 2429 2430 """ 2431 return self.get_content(self.config['my_mod_subreddits'], *args, 2432 **kwargs) 2433 2434 @decorators.restrict_access(scope='mysubreddits') 2435 def get_my_multireddits(self): 2436 """Return a list of the authenticated Redditor's Multireddits.""" 2437 # The JSON data for multireddits is returned from Reddit as a list 2438 # Therefore, we cannot use :meth:`get_content` to retrieve the objects 2439 return self.request_json(self.config['my_multis']) 2440 2441 @decorators.restrict_access(scope='mysubreddits') 2442 def get_my_subreddits(self, *args, **kwargs): 2443 """Return a get_content generator of subreddits. 2444 2445 The subreddits generated are those that hat the session's user is 2446 subscribed to. 2447 2448 The additional parameters are passed directly into 2449 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 2450 2451 """ 2452 return self.get_content(self.config['my_subreddits'], *args, **kwargs) 2453 2454 2455class PrivateMessagesMixin(AuthenticatedReddit): 2456 """Adds methods requiring the 'privatemessages' scope (or login). 2457 2458 You should **not** directly instantiate instances of this class. Use 2459 :class:`.Reddit` instead. 2460 2461 """ 2462 2463 @decorators.restrict_access(scope='privatemessages') 2464 def _mark_as_read(self, thing_ids, unread=False): 2465 """Mark each of the supplied thing_ids as (un)read. 2466 2467 :returns: The json response from the server. 2468 2469 """ 2470 data = {'id': ','.join(thing_ids)} 2471 key = 'unread_message' if unread else 'read_message' 2472 response = self.request_json(self.config[key], data=data) 2473 self.evict([self.config[x] for x in ['inbox', 'messages', 2474 'mod_mail', 'unread']]) 2475 return response 2476 2477 @decorators.restrict_access(scope='privatemessages') 2478 def get_comment_replies(self, *args, **kwargs): 2479 """Return a get_content generator for inboxed comment replies. 2480 2481 The additional parameters are passed directly into 2482 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 2483 2484 """ 2485 return self.get_content(self.config['comment_replies'], 2486 *args, **kwargs) 2487 2488 @decorators.restrict_access(scope='privatemessages') 2489 def get_inbox(self, *args, **kwargs): 2490 """Return a get_content generator for inbox (messages and comments). 2491 2492 The additional parameters are passed directly into 2493 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 2494 2495 """ 2496 return self.get_content(self.config['inbox'], *args, **kwargs) 2497 2498 def get_message(self, message_id, *args, **kwargs): 2499 """Return a Message object corresponding to the given ID. 2500 2501 :param message_id: The ID or Fullname for a Message 2502 2503 The additional parameters are passed directly into 2504 :meth:`~praw.objects.Message.from_id` of Message, and subsequently into 2505 :meth:`.request_json`. 2506 2507 """ 2508 return objects.Message.from_id(self, message_id, *args, **kwargs) 2509 2510 @decorators.restrict_access(scope='privatemessages') 2511 def get_messages(self, *args, **kwargs): 2512 """Return a get_content generator for inbox (messages only). 2513 2514 The additional parameters are passed directly into 2515 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 2516 2517 """ 2518 return self.get_content(self.config['messages'], *args, **kwargs) 2519 2520 @decorators.restrict_access(scope='privatemessages') 2521 def get_post_replies(self, *args, **kwargs): 2522 """Return a get_content generator for inboxed submission replies. 2523 2524 The additional parameters are passed directly into 2525 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 2526 2527 """ 2528 return self.get_content(self.config['post_replies'], *args, **kwargs) 2529 2530 @decorators.restrict_access(scope='privatemessages') 2531 def get_sent(self, *args, **kwargs): 2532 """Return a get_content generator for sent messages. 2533 2534 The additional parameters are passed directly into 2535 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 2536 2537 """ 2538 return self.get_content(self.config['sent'], *args, **kwargs) 2539 2540 @decorators.restrict_access(scope='privatemessages') 2541 def get_unread(self, unset_has_mail=False, update_user=False, *args, 2542 **kwargs): 2543 """Return a get_content generator for unread messages. 2544 2545 :param unset_has_mail: When True, clear the has_mail flag (orangered) 2546 for the user. 2547 :param update_user: If both `unset_has_mail` and `update user` is True, 2548 set the `has_mail` attribute of the logged-in user to False. 2549 2550 The additional parameters are passed directly into 2551 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 2552 2553 """ 2554 params = kwargs.setdefault('params', {}) 2555 if unset_has_mail: 2556 params['mark'] = 'true' 2557 if update_user: # Update the user object 2558 # Use setattr to avoid pylint error 2559 setattr(self.user, 'has_mail', False) 2560 return self.get_content(self.config['unread'], *args, **kwargs) 2561 2562 @decorators.restrict_access(scope='privatemessages') 2563 def get_mentions(self, *args, **kwargs): 2564 """Return a get_content generator for username mentions. 2565 2566 The additional parameters are passed directly into 2567 :meth:`.get_content`. Note: the `url` parameter cannot be altered. 2568 2569 """ 2570 return self.get_content(self.config['mentions'], *args, **kwargs) 2571 2572 @decorators.restrict_access(scope='privatemessages') 2573 @decorators.require_captcha 2574 def send_message(self, recipient, subject, message, from_sr=None, 2575 captcha=None, **kwargs): 2576 """Send a message to a redditor or a subreddit's moderators (mod mail). 2577 2578 :param recipient: A Redditor or Subreddit instance to send a message 2579 to. A string can also be used in which case the string is treated 2580 as a redditor unless it is prefixed with either '/r/' or '#', in 2581 which case it will be treated as a subreddit. 2582 :param subject: The subject of the message to send. 2583 :param message: The actual message content. 2584 :param from_sr: A Subreddit instance or string to send the message 2585 from. When provided, messages are sent from the subreddit rather 2586 than from the authenticated user. Note that the authenticated user 2587 must be a moderator of the subreddit and have mail permissions. 2588 2589 :returns: The json response from the server. 2590 2591 This function may result in a captcha challenge. PRAW will 2592 automatically prompt you for a response. See :ref:`handling-captchas` 2593 if you want to manually handle captchas. 2594 2595 """ 2596 if isinstance(recipient, objects.Subreddit): 2597 recipient = '/r/{0}'.format(six.text_type(recipient)) 2598 else: 2599 recipient = six.text_type(recipient) 2600 2601 data = {'text': message, 2602 'subject': subject, 2603 'to': recipient} 2604 if from_sr: 2605 data['from_sr'] = six.text_type(from_sr) 2606 if captcha: 2607 data.update(captcha) 2608 response = self.request_json(self.config['compose'], data=data, 2609 retry_on_error=False) 2610 self.evict(self.config['sent']) 2611 return response 2612 2613 2614class ReportMixin(AuthenticatedReddit): 2615 """Adds methods requiring the 'report' scope (or login). 2616 2617 You should **not** directly instantiate instances of this class. Use 2618 :class:`.Reddit` instead. 2619 2620 """ 2621 2622 @decorators.restrict_access(scope='report') 2623 def hide(self, thing_id, _unhide=False): 2624 """Hide one or multiple objects in the context of the logged in user. 2625 2626 :param thing_id: A single fullname or list of fullnames, 2627 representing objects which will be hidden. 2628 :param _unhide: If True, unhide the object(s) instead. Use 2629 :meth:`~praw.__init__.ReportMixin.unhide` rather than setting this 2630 manually. 2631 2632 :returns: The json response from the server. 2633 2634 """ 2635 if isinstance(thing_id, six.string_types): 2636 thing_id = [thing_id] 2637 else: 2638 # Guarantee a subscriptable type. 2639 thing_id = list(thing_id) 2640 2641 if len(thing_id) == 0: 2642 raise ValueError('No fullnames provided') 2643 2644 # Will we return a list of server responses, or just one? 2645 # TODO: In future versions, change the threshold to 1 to get 2646 # list-in-list-out, single-in-single-out behavior. Threshold of 50 2647 # is to avoid a breaking change at this time. 2648 return_list = len(thing_id) > 50 2649 2650 id_chunks = chunk_sequence(thing_id, 50) 2651 responses = [] 2652 for id_chunk in id_chunks: 2653 id_chunk = ','.join(id_chunk) 2654 2655 method = 'unhide' if _unhide else 'hide' 2656 data = {'id': id_chunk, 2657 'executed': method} 2658 2659 response = self.request_json(self.config[method], data=data) 2660 responses.append(response) 2661 2662 if self.user is not None: 2663 self.evict(urljoin(self.user._url, # pylint: disable=W0212 2664 'hidden')) 2665 if return_list: 2666 return responses 2667 else: 2668 return responses[0] 2669 2670 def unhide(self, thing_id): 2671 """Unhide up to 50 objects in the context of the logged in user. 2672 2673 :param thing_id: A single fullname or list of fullnames, 2674 representing objects which will be unhidden. 2675 2676 :returns: The json response from the server. 2677 2678 """ 2679 return self.hide(thing_id, _unhide=True) 2680 2681 2682class SubmitMixin(AuthenticatedReddit): 2683 """Adds methods requiring the 'submit' scope (or login). 2684 2685 You should **not** directly instantiate instances of this class. Use 2686 :class:`.Reddit` instead. 2687 2688 """ 2689 2690 def _add_comment(self, thing_id, text): 2691 """Comment on the given thing with the given text. 2692 2693 :returns: A Comment object for the newly created comment. 2694 2695 """ 2696 def add_comment_helper(self, thing_id, text): 2697 data = {'thing_id': thing_id, 2698 'text': text} 2699 retval = self.request_json(self.config['comment'], data=data, 2700 retry_on_error=False) 2701 return retval 2702 2703 if thing_id.startswith(self.config.by_object[objects.Message]): 2704 decorator = decorators.restrict_access(scope='privatemessages') 2705 else: 2706 decorator = decorators.restrict_access(scope='submit') 2707 retval = decorator(add_comment_helper)(self, thing_id, text) 2708 # REDDIT: reddit's end should only ever return a single comment 2709 return retval['data']['things'][0] 2710 2711 @decorators.restrict_access(scope='submit') 2712 @decorators.require_captcha 2713 def submit(self, subreddit, title, text=None, url=None, captcha=None, 2714 save=None, send_replies=None, resubmit=None, **kwargs): 2715 """Submit a new link to the given subreddit. 2716 2717 Accepts either a Subreddit object or a str containing the subreddit's 2718 display name. 2719 2720 :param resubmit: If True, submit the link even if it has already been 2721 submitted. 2722 :param save: If True the new Submission will be saved after creation. 2723 :param send_replies: If True, inbox replies will be received when 2724 people comment on the submission. If set to None, the default of 2725 True for text posts and False for link posts will be used. 2726 2727 :returns: The newly created Submission object if the reddit instance 2728 can access it. Otherwise, return the url to the submission. 2729 2730 This function may result in a captcha challenge. PRAW will 2731 automatically prompt you for a response. See :ref:`handling-captchas` 2732 if you want to manually handle captchas. 2733 2734 """ 2735 if isinstance(text, six.string_types) == bool(url): 2736 raise TypeError('One (and only one) of text or url is required!') 2737 data = {'sr': six.text_type(subreddit), 2738 'title': title} 2739 if text or text == '': 2740 data['kind'] = 'self' 2741 data['text'] = text 2742 else: 2743 data['kind'] = 'link' 2744 data['url'] = url 2745 if captcha: 2746 data.update(captcha) 2747 if resubmit is not None: 2748 data['resubmit'] = resubmit 2749 if save is not None: 2750 data['save'] = save 2751 if send_replies is not None: 2752 data['sendreplies'] = send_replies 2753 result = self.request_json(self.config['submit'], data=data, 2754 retry_on_error=False) 2755 url = result['data']['url'] 2756 # Clear the OAuth setting when attempting to fetch the submission 2757 if self._use_oauth: 2758 self._use_oauth = False 2759 if url.startswith(self.config.oauth_url): 2760 url = self.config.api_url + url[len(self.config.oauth_url):] 2761 try: 2762 return self.get_submission(url) 2763 except errors.Forbidden: 2764 # While the user may be able to submit to a subreddit, 2765 # that does not guarantee they have read access. 2766 return url 2767 2768 2769class SubscribeMixin(AuthenticatedReddit): 2770 """Adds methods requiring the 'subscribe' scope (or login). 2771 2772 You should **not** directly instantiate instances of this class. Use 2773 :class:`.Reddit` instead. 2774 2775 """ 2776 2777 @decorators.restrict_access(scope='subscribe') 2778 def subscribe(self, subreddit, unsubscribe=False): 2779 """Subscribe to the given subreddit. 2780 2781 :param subreddit: Either the subreddit name or a subreddit object. 2782 :param unsubscribe: When True, unsubscribe. 2783 :returns: The json response from the server. 2784 2785 """ 2786 data = {'action': 'unsub' if unsubscribe else 'sub', 2787 'sr_name': six.text_type(subreddit)} 2788 response = self.request_json(self.config['subscribe'], data=data) 2789 self.evict(self.config['my_subreddits']) 2790 return response 2791 2792 def unsubscribe(self, subreddit): 2793 """Unsubscribe from the given subreddit. 2794 2795 :param subreddit: Either the subreddit name or a subreddit object. 2796 :returns: The json response from the server. 2797 2798 """ 2799 return self.subscribe(subreddit, unsubscribe=True) 2800 2801 2802class Reddit(ModConfigMixin, ModFlairMixin, ModLogMixin, ModOnlyMixin, 2803 ModSelfMixin, MultiredditMixin, MySubredditsMixin, 2804 PrivateMessagesMixin, ReportMixin, SubmitMixin, SubscribeMixin): 2805 """Provides access to reddit's API. 2806 2807 See :class:`.BaseReddit`'s documentation for descriptions of the 2808 initialization parameters. 2809 2810 """ 2811 2812# Prevent recursive import 2813from . import objects # NOQA 2814