Files
tuir/rtv/packages/praw/objects.py
Edridge D'Souza b2d3439faa Added an extra sort option for 'gilded'
Implemented 'gilded' sort both in the banner bar (by pushing the '6' key), and in the prompt (by entering '/subreddit/gilded')
2018-07-16 16:22:19 -04:00

2027 lines
79 KiB
Python

# This file is part of PRAW.
#
# PRAW is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# PRAW is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# PRAW. If not, see <http://www.gnu.org/licenses/>.
"""
Contains code about objects such as Submissions, Redditors or Commments.
There are two main groups of objects in this file. The first are objects that
correspond to a Thing or part of a Thing as specified in reddit's API overview,
https://github.com/reddit/reddit/wiki/API. The second gives functionality that
extends over multiple Things. An object that extends from Saveable indicates
that it can be saved and unsaved in the context of a logged in user.
"""
from __future__ import print_function, unicode_literals
import six
from six.moves.urllib.parse import ( # pylint: disable=F0401
parse_qs, urlparse, urlunparse)
from heapq import heappop, heappush
from json import dumps
from requests.compat import urljoin
from warnings import warn, warn_explicit
from . import (AuthenticatedReddit as AR, ModConfigMixin as MCMix,
ModFlairMixin as MFMix, ModLogMixin as MLMix,
ModOnlyMixin as MOMix, ModSelfMixin as MSMix,
MultiredditMixin as MultiMix, PrivateMessagesMixin as PMMix,
SubmitMixin, SubscribeMixin, UnauthenticatedReddit as UR)
from .decorators import (alias_function, limit_chars, restrict_access,
deprecated)
from .errors import ClientException
from .internal import (_get_redditor_listing, _get_sorter,
_modify_relationship)
REDDITOR_KEYS = ('approved_by', 'author', 'banned_by', 'redditor',
'revision_by')
class RedditContentObject(object):
"""Base class that represents actual reddit objects."""
@classmethod
def from_api_response(cls, reddit_session, json_dict):
"""Return an instance of the appropriate class from the json_dict."""
return cls(reddit_session, json_dict=json_dict)
def __init__(self, reddit_session, json_dict=None, fetch=True,
info_url=None, underscore_names=None, uniq=None):
"""Create a new object from the dict of attributes returned by the API.
The fetch parameter specifies whether to retrieve the object's
information from the API (only matters when it isn't provided using
json_dict).
"""
self._info_url = info_url or reddit_session.config['info']
self.reddit_session = reddit_session
self._underscore_names = underscore_names
self._uniq = uniq
self._has_fetched = self._populate(json_dict, fetch)
def __eq__(self, other):
"""Return whether the other instance equals the current."""
return (isinstance(other, RedditContentObject) and
self.fullname == other.fullname)
def __hash__(self):
"""Return the hash of the current instance."""
return hash(self.fullname)
def __getattr__(self, attr):
"""Return the value of the `attr` attribute."""
# Because this method may perform web requests, there are certain
# attributes we must blacklist to prevent accidental requests:
# __members__, __methods__: Caused by `dir(obj)` in Python 2.
# __setstate__: Caused by Pickle deserialization.
blacklist = ('__members__', '__methods__', '__setstate__')
if attr not in blacklist and not self._has_fetched:
self._has_fetched = self._populate(None, True)
return getattr(self, attr)
msg = '\'{0}\' has no attribute \'{1}\''.format(type(self), attr)
raise AttributeError(msg)
def __getstate__(self):
"""Needed for `pickle`.
Without this, pickle protocol version 0 will make HTTP requests
upon serialization, hence slowing it down significantly.
"""
return self.__dict__
def __ne__(self, other):
"""Return whether the other instance differs from the current."""
return not self == other
def __reduce_ex__(self, _):
"""Needed for `pickle`.
Without this, `pickle` protocol version 2 will make HTTP requests
upon serialization, hence slowing it down significantly.
"""
return self.__reduce__()
def __setattr__(self, name, value):
"""Set the `name` attribute to `value."""
if value and name == 'subreddit' and isinstance(value, six.string_types):
value = Subreddit(self.reddit_session, value, fetch=False)
elif name == 'permalink' and isinstance(self, Comment):
# The Reddit API now returns the permalink field for comments. This
# will unfortunately break PRAW because permalink is a @property on the
# Comment object. I need to investigate if the property can be removed,
# for now this is a quick hack to get things working again.
# https://github.com/michael-lazar/rtv/issues/462
return
elif value and name in REDDITOR_KEYS:
if isinstance(value, bool):
pass
elif isinstance(value, dict):
value = Redditor(self.reddit_session, json_dict=value['data'])
elif not value or value == '[deleted]':
value = None
else:
value = Redditor(self.reddit_session, value, fetch=False)
object.__setattr__(self, name, value)
def __str__(self):
"""Return a string representation of the RedditContentObject."""
retval = self.__unicode__()
if not six.PY3:
retval = retval.encode('utf-8')
return retval
def _get_json_dict(self):
# (disabled for entire function) pylint: disable=W0212
# OAuth handling needs to be special cased here. For instance, the user
# might be calling a method on a Subreddit object that requires first
# loading the information about the subreddit. This method should try
# to obtain the information in a scope-less manner unless either:
# a) The object is a WikiPage and the reddit_session has the `wikiread`
# scope.
# b) The object is not a WikiPage and the reddit_session has the
# `read` scope.
prev_use_oauth = self.reddit_session._use_oauth
wiki_page = isinstance(self, WikiPage)
scope = self.reddit_session.has_scope
self.reddit_session._use_oauth = wiki_page and scope('wikiread') or \
not wiki_page and scope('read')
try:
params = {'uniq': self._uniq} if self._uniq else {}
response = self.reddit_session.request_json(
self._info_url, params=params, as_objects=False)
finally:
self.reddit_session._use_oauth = prev_use_oauth
return response['data']
def _populate(self, json_dict, fetch):
if json_dict is None:
json_dict = self._get_json_dict() if fetch else {}
if self.reddit_session.config.store_json_result is True:
self.json_dict = json_dict
else:
self.json_dict = None
# TODO: Remove this wikipagelisting hack
if isinstance(json_dict, list):
json_dict = {'_tmp': json_dict}
for name, value in six.iteritems(json_dict):
if self._underscore_names and name in self._underscore_names:
name = '_' + name
setattr(self, name, value)
self._post_populate(fetch)
return bool(json_dict) or fetch
def _post_populate(self, fetch):
"""Called after populating the attributes of the instance."""
@property
def fullname(self):
"""Return the object's fullname.
A fullname is an object's kind mapping like `t3` followed by an
underscore and the object's base36 id, e.g., `t1_c5s96e0`.
"""
by_object = self.reddit_session.config.by_object
return '{0}_{1}'.format(by_object[self.__class__], self.id)
@property
@deprecated('``has_fetched`` will not be a public attribute in PRAW4.')
def has_fetched(self):
"""Return whether the object has been fully fetched from reddit."""
return self._has_fetched
class Moderatable(RedditContentObject):
"""Interface for Reddit content objects that have can be moderated."""
@restrict_access(scope='modposts')
def approve(self):
"""Approve object.
This reverts a removal, resets the report counter, marks it with a
green check mark (only visible to other moderators) on the website view
and sets the approved_by attribute to the logged in user.
:returns: The json response from the server.
"""
url = self.reddit_session.config['approve']
data = {'id': self.fullname}
response = self.reddit_session.request_json(url, data=data)
urls = [self.reddit_session.config[x] for x in ['modqueue', 'spam']]
if isinstance(self, Submission):
urls += self.subreddit._listing_urls # pylint: disable=W0212
self.reddit_session.evict(urls)
return response
@restrict_access(scope='modposts')
def distinguish(self, as_made_by='mod', sticky=False):
"""Distinguish object as made by mod, admin or special.
Distinguished objects have a different author color. With Reddit
Enhancement Suite it is the background color that changes.
`sticky` argument only used for top-level Comments.
:returns: The json response from the server.
"""
url = self.reddit_session.config['distinguish']
data = {'id': self.fullname,
'how': 'yes' if as_made_by == 'mod' else as_made_by}
if isinstance(self, Comment) and self.is_root:
data['sticky'] = sticky
return self.reddit_session.request_json(url, data=data)
@restrict_access(scope='modposts')
def ignore_reports(self):
"""Ignore future reports on this object.
This prevents future reports from causing notifications or appearing
in the various moderation listing. The report count will still
increment.
"""
url = self.reddit_session.config['ignore_reports']
data = {'id': self.fullname}
return self.reddit_session.request_json(url, data=data)
@restrict_access(scope='modposts')
def remove(self, spam=False):
"""Remove object. This is the moderator version of delete.
The object is removed from the subreddit listings and placed into the
spam listing. If spam is set to True, then the automatic spam filter
will try to remove objects with similar attributes in the future.
:returns: The json response from the server.
"""
url = self.reddit_session.config['remove']
data = {'id': self.fullname,
'spam': 'True' if spam else 'False'}
response = self.reddit_session.request_json(url, data=data)
urls = [self.reddit_session.config[x] for x in ['modqueue', 'spam']]
if isinstance(self, Submission) and hasattr(self, 'subreddit'):
urls += self.subreddit._listing_urls # pylint: disable=W0212
self.reddit_session.evict(urls)
return response
def undistinguish(self):
"""Remove mod, admin or special distinguishing on object.
:returns: The json response from the server.
"""
return self.distinguish(as_made_by='no')
@restrict_access(scope='modposts')
def unignore_reports(self):
"""Remove ignoring of future reports on this object.
Undoes 'ignore_reports'. Future reports will now cause notifications
and appear in the various moderation listings.
"""
url = self.reddit_session.config['unignore_reports']
data = {'id': self.fullname}
return self.reddit_session.request_json(url, data=data)
class Editable(RedditContentObject):
"""Interface for Reddit content objects that can be edited and deleted."""
@restrict_access(scope='edit')
def delete(self):
"""Delete this object.
:returns: The json response from the server.
"""
url = self.reddit_session.config['del']
data = {'id': self.fullname}
response = self.reddit_session.request_json(url, data=data)
self.reddit_session.evict(self.reddit_session.config['user'])
return response
@restrict_access(scope='edit')
def edit(self, text):
"""Replace the body of the object with `text`.
:returns: The updated object.
"""
url = self.reddit_session.config['edit']
data = {'thing_id': self.fullname,
'text': text}
response = self.reddit_session.request_json(url, data=data)
self.reddit_session.evict(self.reddit_session.config['user'])
return response['data']['things'][0]
class Gildable(RedditContentObject):
"""Interface for RedditContentObjects that can be gilded."""
@restrict_access(scope='creddits', oauth_only=True)
def gild(self, months=None):
"""Gild the Redditor or author of the content.
:param months: Specifies the number of months to gild. This parameter
is Only valid when the instance called upon is of type
Redditor. When not provided, the value defaults to 1.
:returns: True on success, otherwise raises an exception.
"""
if isinstance(self, Redditor):
months = int(months) if months is not None else 1
if months < 1:
raise TypeError('months must be at least 1')
if months > 36:
raise TypeError('months must be no more than 36')
response = self.reddit_session.request(
self.reddit_session.config['gild_user'].format(
username=six.text_type(self)), data={'months': months})
elif months is not None:
raise TypeError('months is not a valid parameter for {0}'
.format(type(self)))
else:
response = self.reddit_session.request(
self.reddit_session.config['gild_thing']
.format(fullname=self.fullname), data=True)
return response.status_code == 200
class Hideable(RedditContentObject):
"""Interface for objects that can be hidden."""
def hide(self, _unhide=False):
"""Hide object in the context of the logged in user.
:param _unhide: If True, unhide the item instead. Use
:meth:`~praw.objects.Hideable.unhide` instead of setting this
manually.
:returns: The json response from the server.
"""
return self.reddit_session.hide(self.fullname, _unhide=_unhide)
def unhide(self):
"""Unhide object in the context of the logged in user.
:returns: The json response from the server.
"""
return self.hide(_unhide=True)
class Inboxable(RedditContentObject):
"""Interface for objects that appear in the inbox (orangereds)."""
def mark_as_read(self):
"""Mark object as read.
:returns: The json response from the server.
"""
return self.reddit_session._mark_as_read([self.fullname])
def mark_as_unread(self):
"""Mark object as unread.
:returns: The json response from the server.
"""
return self.reddit_session._mark_as_read([self.fullname], unread=True)
def reply(self, text):
"""Reply to object with the specified text.
:returns: A Comment object for the newly created comment (reply).
"""
# pylint: disable=W0212
response = self.reddit_session._add_comment(self.fullname, text)
# pylint: enable=W0212
urls = [self.reddit_session.config['inbox']]
if isinstance(self, Comment):
urls.append(self.submission._api_link) # pylint: disable=W0212
elif isinstance(self, Message):
urls.append(self.reddit_session.config['sent'])
self.reddit_session.evict(urls)
return response
class Messageable(RedditContentObject):
"""Interface for RedditContentObjects that can be messaged."""
_methods = (('send_message', PMMix),)
class Refreshable(RedditContentObject):
"""Interface for objects that can be refreshed."""
def refresh(self):
"""Re-query to update object with latest values. Return the object.
Any listing, such as the submissions on a subreddits top page, will
automatically be refreshed serverside. Refreshing a submission will
also refresh all its comments.
In the rare case of a comment being deleted or removed when it had
no replies, a second request will be made, not all information will
be updated and a warning will list the attributes that could not be
retrieved if there were any.
"""
unique = self.reddit_session._unique_count # pylint: disable=W0212
self.reddit_session._unique_count += 1 # pylint: disable=W0212
if isinstance(self, Redditor):
other = Redditor(self.reddit_session, self._case_name, fetch=True,
uniq=unique)
elif isinstance(self, Comment):
sub = Submission.from_url(self.reddit_session, self.permalink,
params={'uniq': unique})
if sub.comments:
other = sub.comments[0]
else:
# comment is "specially deleted", a reddit inconsistency;
# see #519, #524, #535, #537, and #552 it needs to be
# retreived via /api/info, but that's okay since these
# specially deleted comments always have the same json
# structure. The unique count needs to be updated
# in case the comment originally came from /api/info
msg = ("Comment {0} was deleted or removed, and had "
"no replies when such happened, so a second "
"request was made to /api/info.".format(self.name))
unique = self.reddit_session._unique_count
self.reddit_session._unique_count += 1
other = self.reddit_session.get_info(thing_id=self.name,
params={'uniq': unique})
oldkeys = set(self.__dict__.keys())
newkeys = set(other.__dict__.keys())
keydiff = ", ".join(oldkeys - newkeys)
if keydiff:
msg += "\nCould not retrieve:\n{0}".format(keydiff)
self.__dict__.update(other.__dict__) # pylint: disable=W0201
warn(msg, RuntimeWarning)
return self
elif isinstance(self, Multireddit):
other = Multireddit(self.reddit_session, author=self._author,
name=self.name, uniq=unique, fetch=True)
elif isinstance(self, Submission):
params = self._params.copy()
params['uniq'] = unique
other = Submission.from_url(self.reddit_session, self.permalink,
comment_sort=self._comment_sort,
params=params)
elif isinstance(self, Subreddit):
other = Subreddit(self.reddit_session, self._case_name, fetch=True,
uniq=unique)
elif isinstance(self, WikiPage):
other = WikiPage(self.reddit_session,
six.text_type(self.subreddit), self.page,
fetch=True, uniq=unique)
self.__dict__ = other.__dict__ # pylint: disable=W0201
return self
class Reportable(RedditContentObject):
"""Interface for RedditContentObjects that can be reported."""
@restrict_access(scope='report')
def report(self, reason=None):
"""Report this object to the moderators.
:param reason: The user-supplied reason for reporting a comment
or submission. Default: None (blank reason)
:returns: The json response from the server.
"""
url = self.reddit_session.config['report']
data = {'id': self.fullname}
if reason:
data['reason'] = reason
response = self.reddit_session.request_json(url, data=data)
# Reported objects are automatically hidden as well
# pylint: disable=W0212
self.reddit_session.evict(
[self.reddit_session.config['user'],
urljoin(self.reddit_session.user._url, 'hidden')])
# pylint: enable=W0212
return response
class Saveable(RedditContentObject):
"""Interface for RedditContentObjects that can be saved."""
@restrict_access(scope='save')
def save(self, unsave=False):
"""Save the object.
:returns: The json response from the server.
"""
url = self.reddit_session.config['unsave' if unsave else 'save']
data = {'id': self.fullname,
'executed': 'unsaved' if unsave else 'saved'}
response = self.reddit_session.request_json(url, data=data)
self.reddit_session.evict(self.reddit_session.config['saved'])
return response
def unsave(self):
"""Unsave the object.
:returns: The json response from the server.
"""
return self.save(unsave=True)
class Voteable(RedditContentObject):
"""Interface for RedditContentObjects that can be voted on."""
def clear_vote(self):
"""Remove the logged in user's vote on the object.
Running this on an object with no existing vote has no adverse effects.
Note: votes must be cast by humans. That is, API clients proxying a
human's action one-for-one are OK, but bots deciding how to vote on
content or amplifying a human's vote are not. See the reddit rules for
more details on what constitutes vote cheating.
Source for note: http://www.reddit.com/dev/api#POST_api_vote
:returns: The json response from the server.
"""
return self.vote()
def downvote(self):
"""Downvote object. If there already is a vote, replace it.
Note: votes must be cast by humans. That is, API clients proxying a
human's action one-for-one are OK, but bots deciding how to vote on
content or amplifying a human's vote are not. See the reddit rules for
more details on what constitutes vote cheating.
Source for note: http://www.reddit.com/dev/api#POST_api_vote
:returns: The json response from the server.
"""
return self.vote(direction=-1)
def upvote(self):
"""Upvote object. If there already is a vote, replace it.
Note: votes must be cast by humans. That is, API clients proxying a
human's action one-for-one are OK, but bots deciding how to vote on
content or amplifying a human's vote are not. See the reddit rules for
more details on what constitutes vote cheating.
Source for note: http://www.reddit.com/dev/api#POST_api_vote
:returns: The json response from the server.
"""
return self.vote(direction=1)
@restrict_access(scope='vote')
def vote(self, direction=0):
"""Vote for the given item in the direction specified.
Note: votes must be cast by humans. That is, API clients proxying a
human's action one-for-one are OK, but bots deciding how to vote on
content or amplifying a human's vote are not. See the reddit rules for
more details on what constitutes vote cheating.
Source for note: http://www.reddit.com/dev/api#POST_api_vote
:returns: The json response from the server.
"""
url = self.reddit_session.config['vote']
data = {'id': self.fullname,
'dir': six.text_type(direction)}
if self.reddit_session.user:
# pylint: disable=W0212
urls = [urljoin(self.reddit_session.user._url, 'disliked'),
urljoin(self.reddit_session.user._url, 'liked')]
# pylint: enable=W0212
self.reddit_session.evict(urls)
return self.reddit_session.request_json(url, data=data)
class Comment(Editable, Gildable, Inboxable, Moderatable, Refreshable,
Reportable, Saveable, Voteable):
"""A class that represents a reddit comments."""
def __init__(self, reddit_session, json_dict):
"""Construct an instance of the Comment object."""
super(Comment, self).__init__(reddit_session, json_dict,
underscore_names=['replies'])
self._has_fetched_replies = not hasattr(self, 'was_comment')
if self._replies:
self._replies = self._replies['data']['children']
elif self._replies == '': # Comment tree was built and there are none
self._replies = []
else:
self._replies = None
self._submission = None
@limit_chars
def __unicode__(self):
"""Return a string representation of the comment."""
return getattr(self, 'body', '[Unloaded Comment]')
@property
def _fast_permalink(self):
"""Return the short permalink to the comment."""
if hasattr(self, 'link_id'): # from /r or /u comments page
sid = self.link_id.split('_')[1]
else: # from user's /message page
sid = self.context.split('/')[4]
return urljoin(self.reddit_session.config['comments'], '{0}/_/{1}'
.format(sid, self.id))
def _update_submission(self, submission):
"""Submission isn't set on __init__ thus we need to update it."""
submission._comments_by_id[self.name] = self # pylint: disable=W0212
self._submission = submission
if self._replies:
for reply in self._replies:
reply._update_submission(submission) # pylint: disable=W0212
@property
def is_root(self):
"""Return True when the comment is a top level comment."""
sub_prefix = self.reddit_session.config.by_object[Submission]
return self.parent_id.startswith(sub_prefix)
@property
def permalink(self):
"""Return a permalink to the comment."""
return urljoin(self.submission.permalink, self.id)
@property
def replies(self):
"""Return a list of the comment replies to this comment.
If the comment is not from a submission, :meth:`replies` will
always be an empty list unless you call :meth:`refresh()
before calling :meth:`replies` due to a limitation in
reddit's API.
"""
if self._replies is None or not self._has_fetched_replies:
response = self.reddit_session.request_json(self._fast_permalink)
if response[1]['data']['children']:
# pylint: disable=W0212
self._replies = response[1]['data']['children'][0]._replies
else:
# comment is "specially deleted", a reddit inconsistency;
# see #519, #524, #535, #537, and #552 it needs to be
# retreived via /api/info, but that's okay since these
# specially deleted comments always have the same json
# structure.
msg = ("Comment {0} was deleted or removed, and had "
"no replies when such happened, so it still "
"has no replies".format(self.name))
warn(msg, RuntimeWarning)
self._replies = []
# pylint: enable=W0212
self._has_fetched_replies = True
# Set the submission object if it is not set.
if not self._submission:
self._submission = response[0]['data']['children'][0]
return self._replies
@property
def submission(self):
"""Return the Submission object this comment belongs to."""
if not self._submission: # Comment not from submission
self._submission = self.reddit_session.get_submission(
url=self._fast_permalink)
return self._submission
class Message(Inboxable):
"""A class for private messages."""
@staticmethod
@restrict_access(scope='privatemessages')
def from_id(reddit_session, message_id, *args, **kwargs):
"""Request the url for a Message and return a Message object.
:param reddit_session: The session to make the request with.
:param message_id: The ID of the message to request.
The additional parameters are passed directly into
:meth:`.request_json`.
"""
# Reduce fullname to ID if necessary
message_id = message_id.split('_', 1)[-1]
url = reddit_session.config['message'].format(messageid=message_id)
message_info = reddit_session.request_json(url, *args, **kwargs)
message = message_info['data']['children'][0]
# Messages are received as a listing such that
# the first item is always the thread's root.
# The ID requested by the user may be a child.
if message.id == message_id:
return message
for child in message.replies:
if child.id == message_id:
return child
def __init__(self, reddit_session, json_dict):
"""Construct an instance of the Message object."""
super(Message, self).__init__(reddit_session, json_dict)
if self.replies: # pylint: disable=E0203
self.replies = self.replies['data']['children']
else:
self.replies = []
@limit_chars
def __unicode__(self):
"""Return a string representation of the Message."""
return 'From: {0}\nSubject: {1}\n\n{2}'.format(self.author,
self.subject, self.body)
@restrict_access(scope='privatemessages')
def collapse(self):
"""Collapse a private message or modmail."""
url = self.reddit_session.config['collapse_message']
self.reddit_session.request_json(url, data={'id': self.name})
@restrict_access(scope='modcontributors')
def mute_modmail_author(self, _unmute=False):
"""Mute the sender of this modmail message.
:param _unmute: Unmute the user instead. Please use
:meth:`unmute_modmail_author` instead of setting this directly.
"""
path = 'unmute_sender' if _unmute else 'mute_sender'
return self.reddit_session.request_json(
self.reddit_session.config[path], data={'id': self.fullname})
@restrict_access(scope='privatemessages')
def uncollapse(self):
"""Uncollapse a private message or modmail."""
url = self.reddit_session.config['uncollapse_message']
self.reddit_session.request_json(url, data={'id': self.name})
def unmute_modmail_author(self):
"""Unmute the sender of this modmail message."""
return self.mute_modmail_author(_unmute=True)
class MoreComments(RedditContentObject):
"""A class indicating there are more comments."""
def __init__(self, reddit_session, json_dict):
"""Construct an instance of the MoreComment object."""
super(MoreComments, self).__init__(reddit_session, json_dict)
self.submission = None
self._comments = None
def __lt__(self, other):
"""Proide a sort order on the MoreComments object."""
# To work with heapq a "smaller" item is the one with the most comments
# We are intentionally making the biggest element the smallest element
# to turn the min-heap implementation in heapq into a max-heap
# implementation for Submission.replace_more_comments()
return self.count > other.count
def __unicode__(self):
"""Return a string representation of the MoreComments object."""
return '[More Comments: {0}]'.format(self.count)
def _continue_comments(self, update):
assert len(self.children) > 0
tmp = self.reddit_session.get_submission(urljoin(
self.submission.permalink, self.parent_id.split('_', 1)[1]))
assert len(tmp.comments) == 1
self._comments = tmp.comments[0].replies
if update:
for comment in self._comments:
# pylint: disable=W0212
comment._update_submission(self.submission)
# pylint: enable=W0212
return self._comments
def _update_submission(self, submission):
self.submission = submission
def comments(self, update=True):
"""Fetch and return the comments for a single MoreComments object."""
if not self._comments:
if self.count == 0: # Handle 'continue this thread' type
return self._continue_comments(update)
# pylint: disable=W0212
children = [x for x in self.children if 't1_{0}'.format(x)
not in self.submission._comments_by_id]
# pylint: enable=W0212
if not children:
return None
data = {'children': ','.join(children),
'link_id': self.submission.fullname,
'r': str(self.submission.subreddit)}
# pylint: disable=W0212
if self.submission._comment_sort:
data['where'] = self.submission._comment_sort
# pylint: enable=W0212
url = self.reddit_session.config['morechildren']
response = self.reddit_session.request_json(url, data=data)
self._comments = response['data']['things']
if update:
for comment in self._comments:
# pylint: disable=W0212
comment._update_submission(self.submission)
# pylint: enable=W0212
return self._comments
class Redditor(Gildable, Messageable, Refreshable):
"""A class representing the users of reddit."""
_methods = (('get_multireddit', MultiMix), ('get_multireddits', MultiMix))
get_comments = _get_redditor_listing('comments')
get_overview = _get_redditor_listing('')
get_submitted = _get_redditor_listing('submitted')
def __init__(self, reddit_session, user_name=None, json_dict=None,
fetch=False, **kwargs):
"""Construct an instance of the Redditor object."""
if not user_name:
user_name = json_dict['name']
info_url = reddit_session.config['user_about'].format(user=user_name)
# name is set before calling the parent constructor so that the
# json_dict 'name' attribute (if available) has precedence
self._case_name = user_name
super(Redditor, self).__init__(reddit_session, json_dict,
fetch, info_url, **kwargs)
self.name = self._case_name
self._url = reddit_session.config['user'].format(user=self.name)
self._mod_subs = None
def __repr__(self):
"""Return a code representation of the Redditor."""
return 'Redditor(user_name=\'{0}\')'.format(self.name)
def __unicode__(self):
"""Return a string representation of the Redditor."""
return self.name
def _post_populate(self, fetch):
if fetch:
# Maintain a consistent `name` until the user
# explicitly calls `redditor.refresh()`
tmp = self._case_name
self._case_name = self.name
self.name = tmp
@restrict_access(scope='subscribe')
def friend(self, note=None, _unfriend=False):
"""Friend the user.
:param note: A personal note about the user. Requires reddit Gold.
:param _unfriend: Unfriend the user. Please use :meth:`unfriend`
instead of setting this parameter manually.
:returns: The json response from the server.
"""
self.reddit_session.evict(self.reddit_session.config['friends'])
# Requests through password auth use /api/friend
# Requests through oauth use /api/v1/me/friends/{username}
if not self.reddit_session.is_oauth_session():
modifier = _modify_relationship('friend', unlink=_unfriend)
data = {'note': note} if note else {}
return modifier(self.reddit_session.user, self, **data)
url = self.reddit_session.config['friend_v1'].format(user=self.name)
# This endpoint wants the data to be a string instead of an actual
# dictionary, although it is not required to have any content for adds.
# Unfriending does require the 'id' key.
if _unfriend:
data = {'id': self.name}
else:
# We cannot send a null or empty note string.
data = {'note': note} if note else {}
data = dumps(data)
method = 'DELETE' if _unfriend else 'PUT'
return self.reddit_session.request_json(url, data=data, method=method)
def get_disliked(self, *args, **kwargs):
"""Return a listing of the Submissions the user has downvoted.
This method points to :meth:`get_downvoted`, as the "disliked" name
is being phased out.
"""
return self.get_downvoted(*args, **kwargs)
def get_downvoted(self, *args, **kwargs):
"""Return a listing of the Submissions the user has downvoted.
:returns: get_content generator of Submission items.
The additional parameters are passed directly into
:meth:`.get_content`. Note: the `url` parameter cannot be altered.
As a default, this listing is only accessible by the user. Thereby
requiring either user/pswd authentication or OAuth authentication with
the 'history' scope. Users may choose to make their voting record
public by changing a user preference. In this case, no authentication
will be needed to access this listing.
"""
# Sending an OAuth authenticated request for a redditor, who isn't the
# authenticated user. But who has a public voting record will be
# successful.
kwargs['_use_oauth'] = self.reddit_session.is_oauth_session()
return _get_redditor_listing('downvoted')(self, *args, **kwargs)
@restrict_access(scope='mysubreddits')
def get_friend_info(self):
"""Return information about this friend, including personal notes.
The personal note can be added or overwritten with :meth:friend, but
only if the user has reddit Gold.
:returns: The json response from the server.
"""
url = self.reddit_session.config['friend_v1'].format(user=self.name)
data = {'id': self.name}
return self.reddit_session.request_json(url, data=data, method='GET')
def get_liked(self, *args, **kwargs):
"""Return a listing of the Submissions the user has upvoted.
This method points to :meth:`get_upvoted`, as the "liked" name
is being phased out.
"""
return self.get_upvoted(*args, **kwargs)
def get_upvoted(self, *args, **kwargs):
"""Return a listing of the Submissions the user has upvoted.
:returns: get_content generator of Submission items.
The additional parameters are passed directly into
:meth:`.get_content`. Note: the `url` parameter cannot be altered.
As a default, this listing is only accessible by the user. Thereby
requirering either user/pswd authentication or OAuth authentication
with the 'history' scope. Users may choose to make their voting record
public by changing a user preference. In this case, no authentication
will be needed to access this listing.
"""
kwargs['_use_oauth'] = self.reddit_session.is_oauth_session()
return _get_redditor_listing('upvoted')(self, *args, **kwargs)
def mark_as_read(self, messages, unread=False):
"""Mark message(s) as read or unread.
:returns: The json response from the server.
"""
ids = []
if isinstance(messages, Inboxable):
ids.append(messages.fullname)
elif hasattr(messages, '__iter__'):
for msg in messages:
if not isinstance(msg, Inboxable):
msg = 'Invalid message type: {0}'.format(type(msg))
raise ClientException(msg)
ids.append(msg.fullname)
else:
msg = 'Invalid message type: {0}'.format(type(messages))
raise ClientException(msg)
# pylint: disable=W0212
retval = self.reddit_session._mark_as_read(ids, unread=unread)
# pylint: enable=W0212
return retval
def unfriend(self):
"""Unfriend the user.
:returns: The json response from the server.
"""
return self.friend(_unfriend=True)
class LoggedInRedditor(Redditor):
"""A class representing a currently logged in Redditor."""
get_hidden = restrict_access('history')(_get_redditor_listing('hidden'))
get_saved = restrict_access('history')(_get_redditor_listing('saved'))
def get_blocked(self):
"""Return a UserList of Redditors with whom the user has blocked."""
url = self.reddit_session.config['blocked']
return self.reddit_session.request_json(url)
def get_cached_moderated_reddits(self):
"""Return a cached dictionary of the user's moderated reddits.
This list is used internally. Consider using the `get_my_moderation`
function instead.
"""
if self._mod_subs is None:
self._mod_subs = {'mod': self.reddit_session.get_subreddit('mod')}
for sub in self.reddit_session.get_my_moderation(limit=None):
self._mod_subs[six.text_type(sub).lower()] = sub
return self._mod_subs
@deprecated('``get_friends`` has been moved to '
':class:`praw.AuthenticatedReddit` and will be removed from '
':class:`objects.LoggedInRedditor` in PRAW v4.0.0')
def get_friends(self, **params):
"""Return a UserList of Redditors with whom the user is friends.
This method has been moved to :class:`praw.AuthenticatedReddit`.
"""
return self.reddit_session.get_friends(**params)
class ModAction(RedditContentObject):
"""A moderator action."""
def __init__(self, reddit_session, json_dict=None, fetch=False):
"""Construct an instance of the ModAction object."""
super(ModAction, self).__init__(reddit_session, json_dict, fetch)
def __unicode__(self):
"""Return a string reprsentation of the moderator action."""
return 'Action: {0}'.format(self.action)
class Submission(Editable, Gildable, Hideable, Moderatable, Refreshable,
Reportable, Saveable, Voteable):
"""A class for submissions to reddit."""
_methods = (('select_flair', AR),)
@staticmethod
def _extract_more_comments(tree):
"""Return a list of MoreComments objects removed from tree."""
more_comments = []
queue = [(None, x) for x in tree]
while len(queue) > 0:
parent, comm = queue.pop(0)
if isinstance(comm, MoreComments):
heappush(more_comments, comm)
if parent:
parent.replies.remove(comm)
else:
tree.remove(comm)
else:
for item in comm.replies:
queue.append((comm, item))
return more_comments
@staticmethod
def from_id(reddit_session, subreddit_id):
"""Return an edit-only submission object based on the id."""
pseudo_data = {'id': subreddit_id,
'permalink': '/comments/{0}'.format(subreddit_id)}
return Submission(reddit_session, pseudo_data)
@staticmethod
def from_json(json_response):
"""Return a submission object from the json response."""
submission = json_response[0]['data']['children'][0]
submission.comments = json_response[1]['data']['children']
return submission
@staticmethod
@restrict_access(scope='read')
def from_url(reddit_session, url, comment_limit=0, comment_sort=None,
comments_only=False, params=None):
"""Request the url and return a Submission object.
:param reddit_session: The session to make the request with.
:param url: The url to build the Submission object from.
:param comment_limit: The desired number of comments to fetch. If <= 0
fetch the default number for the session's user. If None, fetch the
maximum possible.
:param comment_sort: The sort order for retrieved comments. When None
use the default for the session's user.
:param comments_only: Return only the list of comments.
:param params: dictionary containing extra GET data to put in the url.
"""
if params is None:
params = {}
parsed = urlparse(url)
query_pairs = parse_qs(parsed.query)
get_params = dict((k, ",".join(v)) for k, v in query_pairs.items())
params.update(get_params)
url = urlunparse(parsed[:3] + ("", "", ""))
if comment_limit is None: # Fetch MAX
params['limit'] = 2048 # Just use a big number
elif comment_limit > 0: # Use value
params['limit'] = comment_limit
if comment_sort:
params['sort'] = comment_sort
response = reddit_session.request_json(url, params=params)
if comments_only:
return response[1]['data']['children']
submission = Submission.from_json(response)
submission._comment_sort = comment_sort # pylint: disable=W0212
submission._params = params # pylint: disable=W0212
return submission
def __init__(self, reddit_session, json_dict):
"""Construct an instance of the Subreddit object."""
super(Submission, self).__init__(reddit_session, json_dict)
# pylint: disable=E0203
self._api_link = urljoin(reddit_session.config.api_url, self.permalink)
# pylint: enable=E0203
self.permalink = urljoin(reddit_session.config.permalink_url,
self.permalink)
self._comment_sort = None
self._comments_by_id = {}
self._comments = None
self._orphaned = {}
self._replaced_more = False
self._params = {}
@limit_chars
def __unicode__(self):
"""Return a string representation of the Subreddit.
Note: The representation is truncated to a fix number of characters.
"""
title = self.title.replace('\r\n', ' ')
return six.text_type('{0} :: {1}').format(self.score, title)
def _insert_comment(self, comment):
if comment.name in self._comments_by_id: # Skip existing comments
return
comment._update_submission(self) # pylint: disable=W0212
if comment.name in self._orphaned: # Reunite children with parent
comment.replies.extend(self._orphaned[comment.name])
del self._orphaned[comment.name]
if comment.is_root:
self._comments.append(comment)
elif comment.parent_id in self._comments_by_id:
self._comments_by_id[comment.parent_id].replies.append(comment)
else: # Orphan
if comment.parent_id in self._orphaned:
self._orphaned[comment.parent_id].append(comment)
else:
self._orphaned[comment.parent_id] = [comment]
def _update_comments(self, comments):
self._comments = comments
for comment in self._comments:
comment._update_submission(self) # pylint: disable=W0212
def add_comment(self, text):
"""Comment on the submission using the specified text.
:returns: A Comment object for the newly created comment.
"""
# pylint: disable=W0212
response = self.reddit_session._add_comment(self.fullname, text)
# pylint: enable=W0212
self.reddit_session.evict(self._api_link) # pylint: disable=W0212
return response
@property
def comments(self): # pylint: disable=E0202
"""Return forest of comments, with top-level comments as tree roots.
May contain instances of MoreComment objects. To easily replace these
objects with Comment objects, use the replace_more_comments method then
fetch this attribute. Use comment replies to walk down the tree. To get
an unnested, flat list of comments from this attribute use
helpers.flatten_tree.
"""
if self._comments is None:
self.comments = Submission.from_url( # pylint: disable=W0212
self.reddit_session, self._api_link, comments_only=True)
return self._comments
@comments.setter # NOQA
def comments(self, new_comments): # pylint: disable=E0202
"""Update the list of comments with the provided nested list."""
self._update_comments(new_comments)
self._orphaned = {}
def get_duplicates(self, *args, **kwargs):
"""Return a get_content generator for the submission's duplicates.
:returns: get_content generator iterating over Submission objects.
The additional parameters are passed directly into
:meth:`.get_content`. Note: the `url` and `object_filter` parameters
cannot be altered.
"""
url = self.reddit_session.config['duplicates'].format(
submissionid=self.id)
return self.reddit_session.get_content(url, *args, object_filter=1,
**kwargs)
def get_flair_choices(self, *args, **kwargs):
"""Return available link flair choices and current flair.
Convenience function for
:meth:`~.AuthenticatedReddit.get_flair_choices` populating both the
`subreddit` and `link` parameters.
:returns: The json response from the server.
"""
return self.subreddit.get_flair_choices(self.fullname, *args, **kwargs)
@restrict_access(scope='modposts')
def lock(self):
"""Lock thread.
Requires that the currently authenticated user has the modposts oauth
scope or has user/password authentication as a mod of the subreddit.
:returns: The json response from the server.
"""
url = self.reddit_session.config['lock']
data = {'id': self.fullname}
return self.reddit_session.request_json(url, data=data)
def mark_as_nsfw(self, unmark_nsfw=False):
"""Mark as Not Safe For Work.
Requires that the currently authenticated user is the author of the
submission, has the modposts oauth scope or has user/password
authentication as a mod of the subreddit.
:returns: The json response from the server.
"""
def mark_as_nsfw_helper(self): # pylint: disable=W0613
# It is necessary to have the 'self' argument as it's needed in
# restrict_access to determine what class the decorator is
# operating on.
url = self.reddit_session.config['unmarknsfw' if unmark_nsfw else
'marknsfw']
data = {'id': self.fullname}
return self.reddit_session.request_json(url, data=data)
is_author = (self.reddit_session.is_logged_in() and self.author ==
self.reddit_session.user)
if is_author:
return mark_as_nsfw_helper(self)
else:
return restrict_access('modposts')(mark_as_nsfw_helper)(self)
def replace_more_comments(self, limit=32, threshold=1):
"""Update the comment tree by replacing instances of MoreComments.
:param limit: The maximum number of MoreComments objects to
replace. Each replacement requires 1 API request. Set to None to
have no limit, or to 0 to make no extra requests. Default: 32
:param threshold: The minimum number of children comments a
MoreComments object must have in order to be replaced. Default: 1
:returns: A list of MoreComments objects that were not replaced.
Note that after making this call, the `comments` attribute of the
submission will no longer contain any MoreComments objects. Items that
weren't replaced are still removed from the tree, and will be included
in the returned list.
"""
if self._replaced_more:
return []
remaining = limit
more_comments = self._extract_more_comments(self.comments)
skipped = []
# Fetch largest more_comments until reaching the limit or the threshold
while more_comments:
item = heappop(more_comments)
if remaining == 0: # We're not going to replace any more
heappush(more_comments, item) # It wasn't replaced
break
elif len(item.children) == 0 or 0 < item.count < threshold:
heappush(skipped, item) # It wasn't replaced
continue
# Fetch new comments and decrease remaining if a request was made
new_comments = item.comments(update=False)
if new_comments is not None and remaining is not None:
remaining -= 1
elif new_comments is None:
continue
# Re-add new MoreComment objects to the heap of more_comments
for more in self._extract_more_comments(new_comments):
more._update_submission(self) # pylint: disable=W0212
heappush(more_comments, more)
# Insert the new comments into the tree
for comment in new_comments:
self._insert_comment(comment)
self._replaced_more = True
return more_comments + skipped
def set_flair(self, *args, **kwargs):
"""Set flair for this submission.
Convenience function that utilizes :meth:`.ModFlairMixin.set_flair`
populating both the `subreddit` and `item` parameters.
:returns: The json response from the server.
"""
return self.subreddit.set_flair(self, *args, **kwargs)
@restrict_access(scope='modposts')
def set_contest_mode(self, state=True):
"""Set 'Contest Mode' for the comments of this submission.
Contest mode have the following effects:
* The comment thread will default to being sorted randomly.
* Replies to top-level comments will be hidden behind
"[show replies]" buttons.
* Scores will be hidden from non-moderators.
* Scores accessed through the API (mobile apps, bots) will be
obscured to "1" for non-moderators.
Source for effects: https://www.reddit.com/159bww/
:returns: The json response from the server.
"""
# TODO: Whether a submission is in contest mode is not exposed via the
# API. Adding a test of this method is thus currently impossible.
# Add a test when it becomes possible.
url = self.reddit_session.config['contest_mode']
data = {'id': self.fullname, 'state': state}
return self.reddit_session.request_json(url, data=data)
@restrict_access(scope='modposts')
def set_suggested_sort(self, sort='blank'):
"""Set 'Suggested Sort' for the comments of the submission.
Comments can be sorted in one of (confidence, top, new, hot,
controversial, old, random, qa, blank).
:returns: The json response from the server.
"""
url = self.reddit_session.config['suggested_sort']
data = {'id': self.fullname, 'sort': sort}
return self.reddit_session.request_json(url, data=data)
@property
def short_link(self):
"""Return a short link to the submission.
The short link points to a page on the short_domain that redirects to
the main. For example http://redd.it/eorhm is a short link for
https://www.reddit.com/r/announcements/comments/eorhm/reddit_30_less_typing/.
"""
return urljoin(self.reddit_session.config.short_domain, self.id)
@restrict_access(scope='modposts')
def sticky(self, bottom=True):
"""Sticky a post in its subreddit.
If there is already a stickied post in the designated slot it will be
unstickied.
:param bottom: Set this as the top or bottom sticky. If no top sticky
exists, this submission will become the top sticky regardless.
:returns: The json response from the server
"""
url = self.reddit_session.config['sticky_submission']
data = {'id': self.fullname, 'state': True}
if not bottom:
data['num'] = 1
return self.reddit_session.request_json(url, data=data)
@restrict_access(scope='modposts')
def unlock(self):
"""Lock thread.
Requires that the currently authenticated user has the modposts oauth
scope or has user/password authentication as a mod of the subreddit.
:returns: The json response from the server.
"""
url = self.reddit_session.config['unlock']
data = {'id': self.fullname}
return self.reddit_session.request_json(url, data=data)
def unmark_as_nsfw(self):
"""Mark as Safe For Work.
:returns: The json response from the server.
"""
return self.mark_as_nsfw(unmark_nsfw=True)
@restrict_access(scope='modposts')
def unset_contest_mode(self):
"""Unset 'Contest Mode' for the comments of this submission.
Contest mode have the following effects:
* The comment thread will default to being sorted randomly.
* Replies to top-level comments will be hidden behind
"[show replies]" buttons.
* Scores will be hidden from non-moderators.
* Scores accessed through the API (mobile apps, bots) will be
obscured to "1" for non-moderators.
Source for effects: http://www.reddit.com/159bww/
:returns: The json response from the server.
"""
return self.set_contest_mode(False)
@restrict_access(scope='modposts')
def unsticky(self):
"""Unsticky this post.
:returns: The json response from the server
"""
url = self.reddit_session.config['sticky_submission']
data = {'id': self.fullname, 'state': False}
return self.reddit_session.request_json(url, data=data)
class Subreddit(Messageable, Refreshable):
"""A class for Subreddits."""
_methods = (('accept_moderator_invite', AR),
('add_flair_template', MFMix),
('clear_flair_templates', MFMix),
('configure_flair', MFMix),
('delete_flair', MFMix),
('delete_image', MCMix),
('edit_wiki_page', AR),
('get_banned', MOMix),
('get_comments', UR),
('get_contributors', MOMix),
('get_edited', MOMix),
('get_flair', UR),
('get_flair_choices', AR),
('get_flair_list', MFMix),
('get_moderators', UR),
('get_mod_log', MLMix),
('get_mod_queue', MOMix),
('get_mod_mail', MOMix),
('get_muted', MOMix),
('get_random_submission', UR),
('get_reports', MOMix),
('get_rules', UR),
('get_settings', MCMix),
('get_spam', MOMix),
('get_sticky', UR),
('get_stylesheet', MOMix),
('get_traffic', UR),
('get_unmoderated', MOMix),
('get_wiki_banned', MOMix),
('get_wiki_contributors', MOMix),
('get_wiki_page', UR),
('get_wiki_pages', UR),
('leave_contributor', MSMix),
('leave_moderator', MSMix),
('search', UR),
('select_flair', AR),
('set_flair', MFMix),
('set_flair_csv', MFMix),
('set_settings', MCMix),
('set_stylesheet', MCMix),
('submit', SubmitMixin),
('subscribe', SubscribeMixin),
('unsubscribe', SubscribeMixin),
('update_settings', MCMix),
('upload_image', MCMix))
# Subreddit banned
add_ban = _modify_relationship('banned', is_sub=True)
remove_ban = _modify_relationship('banned', unlink=True, is_sub=True)
# Subreddit contributors
add_contributor = _modify_relationship('contributor', is_sub=True)
remove_contributor = _modify_relationship('contributor', unlink=True,
is_sub=True)
# Subreddit moderators
add_moderator = _modify_relationship('moderator', is_sub=True)
remove_moderator = _modify_relationship('moderator', unlink=True,
is_sub=True)
# Subreddit muted
add_mute = _modify_relationship('muted', is_sub=True)
remove_mute = _modify_relationship('muted', is_sub=True, unlink=True)
# Subreddit wiki banned
add_wiki_ban = _modify_relationship('wikibanned', is_sub=True)
remove_wiki_ban = _modify_relationship('wikibanned', unlink=True,
is_sub=True)
# Subreddit wiki contributors
add_wiki_contributor = _modify_relationship('wikicontributor', is_sub=True)
remove_wiki_contributor = _modify_relationship('wikicontributor',
unlink=True, is_sub=True)
# Generic listing selectors
get_controversial = _get_sorter('controversial')
get_hot = _get_sorter('')
get_new = _get_sorter('new')
get_top = _get_sorter('top')
get_gilded = _get_sorter('gilded')
# Explicit listing selectors
get_controversial_from_all = _get_sorter('controversial', t='all')
get_controversial_from_day = _get_sorter('controversial', t='day')
get_controversial_from_hour = _get_sorter('controversial', t='hour')
get_controversial_from_month = _get_sorter('controversial', t='month')
get_controversial_from_week = _get_sorter('controversial', t='week')
get_controversial_from_year = _get_sorter('controversial', t='year')
get_rising = _get_sorter('rising')
get_top_from_all = _get_sorter('top', t='all')
get_top_from_day = _get_sorter('top', t='day')
get_top_from_hour = _get_sorter('top', t='hour')
get_top_from_month = _get_sorter('top', t='month')
get_top_from_week = _get_sorter('top', t='week')
get_top_from_year = _get_sorter('top', t='year')
def __init__(self, reddit_session, subreddit_name=None, json_dict=None,
fetch=False, **kwargs):
"""Construct an instance of the Subreddit object."""
# Special case for when my_subreddits is called as no name is returned
# so we have to extract the name from the URL. The URLs are returned
# as: /r/reddit_name/
if subreddit_name is None:
subreddit_name = json_dict['url'].split('/')[2]
if not isinstance(subreddit_name, six.string_types) \
or not subreddit_name:
raise TypeError('subreddit_name must be a non-empty string.')
if fetch and ('+' in subreddit_name or '-' in subreddit_name):
fetch = False
warn_explicit('fetch=True has no effect on multireddits',
UserWarning, '', 0)
info_url = reddit_session.config['subreddit_about'].format(
subreddit=subreddit_name)
self._case_name = subreddit_name
super(Subreddit, self).__init__(reddit_session, json_dict, fetch,
info_url, **kwargs)
self.display_name = self._case_name
self._url = reddit_session.config['subreddit'].format(
subreddit=self.display_name)
# '' is the hot listing
listings = ['new/', '', 'top/', 'controversial/', 'rising/']
base = reddit_session.config['subreddit'].format(
subreddit=self.display_name)
self._listing_urls = [base + x + '.json' for x in listings]
def __repr__(self):
"""Return a code representation of the Subreddit."""
return 'Subreddit(subreddit_name=\'{0}\')'.format(self.display_name)
def __unicode__(self):
"""Return a string representation of the Subreddit."""
return self.display_name
def _post_populate(self, fetch):
if fetch:
# Maintain a consistent `display_name` until the user
# explicitly calls `subreddit.refresh()`
tmp = self._case_name
self._case_name = self.display_name
self.display_name = tmp
def clear_all_flair(self):
"""Remove all user flair on this subreddit.
:returns: The json response from the server when there is flair to
clear, otherwise returns None.
"""
csv = [{'user': x['user']} for x in self.get_flair_list(limit=None)]
if csv:
return self.set_flair_csv(csv)
else:
return
class Multireddit(Refreshable):
"""A class for users' Multireddits."""
# 2017-11-13
# Several of the @restrict_access decorators have been removed here,
# because they were duplicated in the corresponding reddit_session
# methods and raised assertion errors. The is the same category of
# bug as this issue:
# https://github.com/praw-dev/praw/issues/477
# Generic listing selectors
get_controversial = _get_sorter('controversial')
get_hot = _get_sorter('')
get_new = _get_sorter('new')
get_top = _get_sorter('top')
# Explicit listing selectors
get_controversial_from_all = _get_sorter('controversial', t='all')
get_controversial_from_day = _get_sorter('controversial', t='day')
get_controversial_from_hour = _get_sorter('controversial', t='hour')
get_controversial_from_month = _get_sorter('controversial', t='month')
get_controversial_from_week = _get_sorter('controversial', t='week')
get_controversial_from_year = _get_sorter('controversial', t='year')
get_rising = _get_sorter('rising')
get_top_from_all = _get_sorter('top', t='all')
get_top_from_day = _get_sorter('top', t='day')
get_top_from_hour = _get_sorter('top', t='hour')
get_top_from_month = _get_sorter('top', t='month')
get_top_from_week = _get_sorter('top', t='week')
get_top_from_year = _get_sorter('top', t='year')
@classmethod
def from_api_response(cls, reddit_session, json_dict):
"""Return an instance of the appropriate class from the json dict."""
# The Multireddit response contains the Subreddits attribute as a list
# of dicts of the form {'name': 'subredditname'}.
# We must convert each of these into a Subreddit object.
json_dict['subreddits'] = [Subreddit(reddit_session, item['name'])
for item in json_dict['subreddits']]
return cls(reddit_session, None, None, json_dict)
def __init__(self, reddit_session, author=None, name=None,
json_dict=None, fetch=False, **kwargs):
"""Construct an instance of the Multireddit object."""
# When get_my_multireddits is called, we extract the author
# and multireddit name from the path. A trailing forward
# slash was recently added to the path string in the API
# response, the needs to be removed to fix the code.
# path = "/user/redditor/m/multi/"
if json_dict and json_dict['path']:
json_dict['path'] = json_dict['path'].rstrip('/')
author = six.text_type(author) if author \
else json_dict['path'].split('/')[-3]
if not name:
name = json_dict['path'].split('/')[-1]
info_url = reddit_session.config['multireddit_about'].format(
user=author, multi=name)
self.name = name
self._author = author
super(Multireddit, self).__init__(reddit_session, json_dict, fetch,
info_url, **kwargs)
self._url = reddit_session.config['multireddit'].format(
user=author, multi=name)
def __repr__(self):
"""Return a code representation of the Multireddit."""
return 'Multireddit(author=\'{0}\', name=\'{1}\')'.format(
self._author, self.name)
def __unicode__(self):
"""Return a string representation of the Multireddit."""
return self.name
def _post_populate(self, fetch):
if fetch:
# Subreddits are returned as dictionaries in the form
# {'name': 'subredditname'}. Convert them to Subreddit objects.
self.subreddits = [Subreddit(self.reddit_session, item['name'])
for item in self.subreddits]
# paths are of the form "/user/{USERNAME}/m/{MULTINAME}"
author = self.path.split('/')[2]
self.author = Redditor(self.reddit_session, author)
@restrict_access(scope='subscribe')
def add_subreddit(self, subreddit, _delete=False, *args, **kwargs):
"""Add a subreddit to the multireddit.
:param subreddit: The subreddit name or Subreddit object to add
The additional parameters are passed directly into
:meth:`~praw.__init__.BaseReddit.request_json`.
"""
subreddit = six.text_type(subreddit)
url = self.reddit_session.config['multireddit_add'].format(
user=self._author, multi=self.name, subreddit=subreddit)
method = 'DELETE' if _delete else 'PUT'
# The modhash isn't necessary for OAuth requests
if not self.reddit_session._use_oauth:
self.reddit_session.http.headers['x-modhash'] = \
self.reddit_session.modhash
data = {'model': dumps({'name': subreddit})}
try:
self.reddit_session.request(url, data=data, method=method,
*args, **kwargs)
finally:
# The modhash isn't necessary for OAuth requests
if not self.reddit_session._use_oauth:
del self.reddit_session.http.headers['x-modhash']
def copy(self, to_name):
"""Copy this multireddit.
Convenience function that utilizes
:meth:`.MultiredditMixin.copy_multireddit` populating both
the `from_redditor` and `from_name` parameters.
"""
return self.reddit_session.copy_multireddit(self._author, self.name,
to_name)
def delete(self):
"""Delete this multireddit.
Convenience function that utilizes
:meth:`.MultiredditMixin.delete_multireddit` populating the `name`
parameter.
"""
return self.reddit_session.delete_multireddit(self.name)
def edit(self, *args, **kwargs):
"""Edit this multireddit.
Convenience function that utilizes
:meth:`.MultiredditMixin.edit_multireddit` populating the `name`
parameter.
"""
return self.reddit_session.edit_multireddit(name=self.name, *args,
**kwargs)
def remove_subreddit(self, subreddit, *args, **kwargs):
"""Remove a subreddit from the user's multireddit."""
return self.add_subreddit(subreddit, True, *args, **kwargs)
def rename(self, new_name, *args, **kwargs):
"""Rename this multireddit.
This function is a handy shortcut to
:meth:`rename_multireddit` of the reddit_session.
"""
new = self.reddit_session.rename_multireddit(self.name, new_name,
*args, **kwargs)
self.__dict__ = new.__dict__ # pylint: disable=W0201
return self
class PRAWListing(RedditContentObject):
"""An abstract class to coerce a listing into RedditContentObjects."""
CHILD_ATTRIBUTE = None
def __init__(self, reddit_session, json_dict=None, fetch=False):
"""Construct an instance of the PRAWListing object."""
super(PRAWListing, self).__init__(reddit_session, json_dict, fetch)
if not self.CHILD_ATTRIBUTE:
raise NotImplementedError('PRAWListing must be extended.')
child_list = getattr(self, self.CHILD_ATTRIBUTE)
for i in range(len(child_list)):
child_list[i] = self._convert(reddit_session, child_list[i])
def __contains__(self, item):
"""Test if item exists in the listing."""
return item in getattr(self, self.CHILD_ATTRIBUTE)
def __delitem__(self, index):
"""Remove the item at position index from the listing."""
del getattr(self, self.CHILD_ATTRIBUTE)[index]
def __getitem__(self, index):
"""Return the item at position index in the listing."""
return getattr(self, self.CHILD_ATTRIBUTE)[index]
def __iter__(self):
"""Return an iterator to the listing."""
return getattr(self, self.CHILD_ATTRIBUTE).__iter__()
def __len__(self):
"""Return the number of items in the listing."""
return len(getattr(self, self.CHILD_ATTRIBUTE))
def __setitem__(self, index, item):
"""Set item at position `index` in the listing."""
getattr(self, self.CHILD_ATTRIBUTE)[index] = item
def __unicode__(self):
"""Return a string representation of the listing."""
return six.text_type(getattr(self, self.CHILD_ATTRIBUTE))
class UserList(PRAWListing):
"""A list of Redditors. Works just like a regular list."""
CHILD_ATTRIBUTE = 'children'
@staticmethod
def _convert(reddit_session, data):
"""Return a Redditor object from the data."""
retval = Redditor(reddit_session, data['name'], fetch=False)
retval.id = data['id'].split('_')[1] # pylint: disable=C0103,W0201
return retval
class WikiPage(Refreshable):
"""An individual WikiPage object."""
@classmethod
def from_api_response(cls, reddit_session, json_dict):
"""Return an instance of the appropriate class from the json_dict."""
# The WikiPage response does not contain the necessary information
# in the JSON response to determine the name of the page nor the
# subreddit it belongs to. Thus we must extract this information
# from the request URL.
# pylint: disable=W0212
parts = reddit_session._request_url.split('/', 6)
# pylint: enable=W0212
subreddit = parts[4]
page = parts[6].split('.', 1)[0]
return cls(reddit_session, subreddit, page, json_dict=json_dict)
def __init__(self, reddit_session, subreddit=None, page=None,
json_dict=None, fetch=False, **kwargs):
"""Construct an instance of the WikiPage object."""
if not subreddit and not page:
subreddit = json_dict['sr']
page = json_dict['page']
info_url = reddit_session.config['wiki_page'].format(
subreddit=six.text_type(subreddit), page=page)
super(WikiPage, self).__init__(reddit_session, json_dict, fetch,
info_url, **kwargs)
self.page = page
self.subreddit = subreddit
def __unicode__(self):
"""Return a string representation of the page."""
return six.text_type('{0}:{1}').format(self.subreddit, self.page)
@restrict_access(scope='modwiki')
def add_editor(self, username, _delete=False, *args, **kwargs):
"""Add an editor to this wiki page.
:param username: The name or Redditor object of the user to add.
:param _delete: If True, remove the user as an editor instead.
Please use :meth:`remove_editor` rather than setting it manually.
Additional parameters are passed into
:meth:`~praw.__init__.BaseReddit.request_json`.
"""
url = self.reddit_session.config['wiki_page_editor']
url = url.format(subreddit=six.text_type(self.subreddit),
method='del' if _delete else 'add')
data = {'page': self.page,
'username': six.text_type(username)}
return self.reddit_session.request_json(url, data=data, *args,
**kwargs)
@restrict_access(scope='modwiki')
def get_settings(self, *args, **kwargs):
"""Return the settings for this wiki page.
Includes permission level, names of editors, and whether
the page is listed on /wiki/pages.
Additional parameters are passed into
:meth:`~praw.__init__.BaseReddit.request_json`
"""
url = self.reddit_session.config['wiki_page_settings']
url = url.format(subreddit=six.text_type(self.subreddit),
page=self.page)
return self.reddit_session.request_json(url, *args, **kwargs)['data']
def edit(self, *args, **kwargs):
"""Edit the wiki page.
Convenience function that utilizes
:meth:`.AuthenticatedReddit.edit_wiki_page` populating both the
``subreddit`` and ``page`` parameters.
"""
return self.subreddit.edit_wiki_page(self.page, *args, **kwargs)
@restrict_access(scope='modwiki')
def edit_settings(self, permlevel, listed, *args, **kwargs):
"""Edit the settings for this individual wiki page.
:param permlevel: Who can edit this page?
(0) use subreddit wiki permissions, (1) only approved wiki
contributors for this page may edit (see
:meth:`~praw.objects.WikiPage.add_editor`), (2) only mods may edit
and view
:param listed: Show this page on the listing?
True - Appear in /wiki/pages
False - Do not appear in /wiki/pages
:returns: The updated settings data.
Additional parameters are passed into :meth:`request_json`.
"""
url = self.reddit_session.config['wiki_page_settings']
url = url.format(subreddit=six.text_type(self.subreddit),
page=self.page)
data = {'permlevel': permlevel,
'listed': 'on' if listed else 'off'}
return self.reddit_session.request_json(url, data=data, *args,
**kwargs)['data']
def remove_editor(self, username, *args, **kwargs):
"""Remove an editor from this wiki page.
:param username: The name or Redditor object of the user to remove.
This method points to :meth:`add_editor` with _delete=True.
Additional parameters are are passed to :meth:`add_editor` and
subsequently into :meth:`~praw.__init__.BaseReddit.request_json`.
"""
return self.add_editor(username=username, _delete=True, *args,
**kwargs)
class WikiPageListing(PRAWListing):
"""A list of WikiPages. Works just like a regular list."""
CHILD_ATTRIBUTE = '_tmp'
@staticmethod
def _convert(reddit_session, data):
"""Return a WikiPage object from the data."""
# TODO: The _request_url hack shouldn't be necessary
# pylint: disable=W0212
subreddit = reddit_session._request_url.rsplit('/', 4)[1]
# pylint: enable=W0212
return WikiPage(reddit_session, subreddit, data, fetch=False)
def _add_aliases():
def predicate(obj):
return inspect.isclass(obj) and hasattr(obj, '_methods')
import inspect
import sys
for _, cls in inspect.getmembers(sys.modules[__name__], predicate):
for name, mixin in cls._methods: # pylint: disable=W0212
setattr(cls, name, alias_function(getattr(mixin, name),
mixin.__name__))
_add_aliases()